using System.Globalization; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Metadata; using ErsatzTV.Infrastructure.Jellyfin.Models; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Refit; namespace ErsatzTV.Infrastructure.Jellyfin; public class JellyfinApiClient : IJellyfinApiClient { private readonly IFallbackMetadataProvider _fallbackMetadataProvider; private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService; private readonly ILogger _logger; private readonly IMemoryCache _memoryCache; public JellyfinApiClient( IMemoryCache memoryCache, IJellyfinPathReplacementService jellyfinPathReplacementService, IFallbackMetadataProvider fallbackMetadataProvider, ILogger logger) { _memoryCache = memoryCache; _jellyfinPathReplacementService = jellyfinPathReplacementService; _fallbackMetadataProvider = fallbackMetadataProvider; _logger = logger; } public async Task> GetServerInformation( string address, string apiKey) { try { IJellyfinApi service = RestService.For(address); var cts = new CancellationTokenSource(); cts.CancelAfter(TimeSpan.FromSeconds(5)); return await service.GetSystemInformation(apiKey, cts.Token) .Map(response => new JellyfinServerInformation(response.ServerName, response.OperatingSystem)); } catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) { _logger.LogError(ex, "Timeout getting jellyfin server name"); return BaseError.New("Jellyfin did not respond in time"); } catch (Exception ex) { _logger.LogError(ex, "Error getting jellyfin server name"); return BaseError.New(ex.Message); } } public async Task>> GetLibraries(string address, string apiKey) { try { IJellyfinApi service = RestService.For(address); List libraries = await service.GetLibraries(apiKey); return libraries .Map(Project) .Somes() .ToList(); } catch (Exception ex) { _logger.LogError(ex, "Error getting jellyfin libraries"); return BaseError.New(ex.Message); } } public IAsyncEnumerable> GetMovieLibraryItems( string address, string apiKey, JellyfinLibrary library) => GetPagedLibraryItems( address, library, library.MediaSourceId, library.ItemId, (service, itemId, skip, pageSize) => service.GetMovieLibraryItems( apiKey, itemId, startIndex: skip, limit: pageSize), (maybeLibrary, item) => maybeLibrary.Map(lib => ProjectToMovie(lib, item)).Flatten()); public IAsyncEnumerable> GetShowLibraryItems( string address, string apiKey, JellyfinLibrary library) => GetPagedLibraryItems( address, library, library.MediaSourceId, library.ItemId, (service, itemId, skip, pageSize) => service.GetShowLibraryItems( apiKey, itemId, startIndex: skip, limit: pageSize), (_, item) => ProjectToShow(item)); public IAsyncEnumerable> GetSeasonLibraryItems( string address, string apiKey, JellyfinLibrary library, string showId) => GetPagedLibraryItems( address, library, library.MediaSourceId, showId, (service, _, skip, pageSize) => service.GetSeasonLibraryItems( apiKey, showId, startIndex: skip, limit: pageSize), (_, item) => ProjectToSeason(item)); public IAsyncEnumerable> GetEpisodeLibraryItems( string address, string apiKey, JellyfinLibrary library, string seasonId) => GetPagedLibraryItems( address, library, library.MediaSourceId, seasonId, (service, _, skip, pageSize) => service.GetEpisodeLibraryItems( apiKey, seasonId, startIndex: skip, limit: pageSize), (maybeLibrary, item) => maybeLibrary.Map(lib => ProjectToEpisode(lib, item)).Flatten()); public IAsyncEnumerable> GetCollectionLibraryItems( string address, string apiKey, int mediaSourceId) { // TODO: should we enumerate collection libraries here? if (_memoryCache.TryGetValue("jellyfin_collections_library_item_id", out string itemId)) { return GetPagedLibraryItems( address, None, mediaSourceId, itemId, (service, _, skip, pageSize) => service.GetCollectionLibraryItems( apiKey, itemId, startIndex: skip, limit: pageSize), (_, item) => ProjectToCollection(item)); } return AsyncEnumerable.Empty>(); } public IAsyncEnumerable> GetCollectionItems( string address, string apiKey, int mediaSourceId, string collectionId) => GetPagedLibraryItems( address, None, mediaSourceId, collectionId, (service, _, skip, pageSize) => service.GetCollectionItems( apiKey, collectionId, startIndex: skip, limit: pageSize), (_, item) => ProjectToCollectionMediaItem(item)); public async Task> GetPlaybackInfo( string address, string apiKey, JellyfinLibrary library, string itemId) { try { IJellyfinApi service = RestService.For(address); JellyfinPlaybackInfoResponse playbackInfo = await service.GetPlaybackInfo(apiKey, itemId); Option maybeVersion = ProjectToMediaVersion(playbackInfo); return maybeVersion.ToEither(() => BaseError.New("Unable to locate Jellyfin statistics")); } catch (Exception ex) { _logger.LogError(ex, "Error getting jellyfin playback info"); return BaseError.New(ex.Message); } } private static async IAsyncEnumerable> GetPagedLibraryItems( string address, Option maybeLibrary, int mediaSourceId, string parentId, Func> getItems, Func, JellyfinLibraryItemResponse, Option> mapper) { IJellyfinApi service = RestService.For(address); const int PAGE_SIZE = 10; int pages = int.MaxValue; for (var i = 0; i < pages; i++) { int skip = i * PAGE_SIZE; JellyfinLibraryItemsResponse result = await getItems(service, parentId, skip, PAGE_SIZE); // update page count pages = Math.Min(pages, (result.TotalRecordCount - 1) / PAGE_SIZE + 1); foreach (TItem item in result.Items.Map(item => mapper(maybeLibrary, item)).Somes()) { yield return new Tuple(item, result.TotalRecordCount); } } } private Option ProjectToCollectionMediaItem(JellyfinLibraryItemResponse item) { try { if (item.LocationType != "FileSystem") { return None; } if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm") { _logger.LogWarning("STRM files are not supported; skipping {Path}", item.Path); return None; } return item.Type switch { "Movie" => new JellyfinMovie { ItemId = item.Id }, "Series" => new JellyfinShow { ItemId = item.Id }, "Season" => new JellyfinSeason { ItemId = item.Id }, "Episode" => new JellyfinEpisode { ItemId = item.Id }, _ => None }; } catch (Exception ex) { _logger.LogWarning(ex, "Error projecting Jellyfin collection media item"); return None; } } private Option Project(JellyfinLibraryResponse response) => response.CollectionType?.ToLowerInvariant() switch { "tvshows" => new JellyfinLibrary { ItemId = response.ItemId, Name = response.Name, MediaKind = LibraryMediaKind.Shows, ShouldSyncItems = false, Paths = new List { new() { Path = $"jellyfin://{response.ItemId}" } }, PathInfos = GetPathInfos(response) }, "movies" => new JellyfinLibrary { ItemId = response.ItemId, Name = response.Name, MediaKind = LibraryMediaKind.Movies, ShouldSyncItems = false, Paths = new List { new() { Path = $"jellyfin://{response.ItemId}" } }, PathInfos = GetPathInfos(response) }, // TODO: ??? for music libraries "boxsets" => CacheCollectionLibraryId(response.ItemId), _ => None }; private static List GetPathInfos(JellyfinLibraryResponse response) { var result = new List(); if (response.LibraryOptions?.PathInfos is not null) { result.AddRange( response.LibraryOptions.PathInfos .Filter(pi => !string.IsNullOrWhiteSpace(pi.NetworkPath)) .Map( pi => new JellyfinPathInfo { Path = pi.Path, NetworkPath = pi.NetworkPath })); } return result; } private Option CacheCollectionLibraryId(string itemId) { _memoryCache.Set("jellyfin_collections_library_item_id", itemId); return None; } private Option ProjectToMovie(JellyfinLibrary library, JellyfinLibraryItemResponse item) { try { if (item.LocationType != "FileSystem") { return None; } if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm") { _logger.LogInformation("STRM files are not supported; skipping {Path}", item.Path); return None; } string path = item.Path ?? string.Empty; foreach (JellyfinPathInfo pathInfo in library.PathInfos.Filter( pi => !string.IsNullOrWhiteSpace(pi.NetworkPath))) { if (path.StartsWith(pathInfo.NetworkPath, StringComparison.Ordinal)) { path = _jellyfinPathReplacementService.ReplaceNetworkPath( (JellyfinMediaSource)library.MediaSource, path, pathInfo.NetworkPath, pathInfo.Path); } } var duration = TimeSpan.FromTicks(item.RunTimeTicks); var version = new MediaVersion { Name = "Main", Duration = duration, DateAdded = item.DateCreated.UtcDateTime, MediaFiles = new List { new() { Path = path } }, Streams = new List(), Chapters = ProjectToModel(Optional(item.Chapters).Flatten(), duration) }; MovieMetadata metadata = ProjectToMovieMetadata(item); var movie = new JellyfinMovie { ItemId = item.Id, Etag = item.Etag, MediaVersions = new List { version }, MovieMetadata = new List { metadata }, TraktListItems = new List() }; return movie; } catch (Exception ex) { _logger.LogWarning(ex, "Error projecting Jellyfin movie"); return None; } } private static List ProjectToModel( IEnumerable jellyfinChapters, TimeSpan duration) { var models = jellyfinChapters.Map(ProjectToModel).OrderBy(c => c.StartTime).ToList(); for (var index = 0; index < models.Count; index++) { MediaChapter model = models[index]; model.ChapterId = index; model.EndTime = index == models.Count - 1 ? duration : models[index + 1].StartTime; } return models; } private static MediaChapter ProjectToModel(JellyfinChapterResponse chapterResponse) => new() { Title = chapterResponse.Name, StartTime = TimeSpan.FromTicks(chapterResponse.StartPositionTicks) }; private static MovieMetadata ProjectToMovieMetadata(JellyfinLibraryItemResponse item) { DateTime dateAdded = item.DateCreated.UtcDateTime; // DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(item.UpdatedAt).DateTime; var metadata = new MovieMetadata { MetadataKind = MetadataKind.External, Title = item.Name, SortTitle = SortTitle.GetSortTitle(item.Name), Plot = item.Overview, Year = item.ProductionYear, Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty), DateAdded = dateAdded, ContentRating = item.OfficialRating, Genres = Optional(item.Genres).Flatten().Map(g => new Genre { Name = g }).ToList(), Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(), Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(), Actors = Optional(item.People).Flatten().Collect(r => ProjectToActor(r, dateAdded)).ToList(), Directors = Optional(item.People).Flatten().Collect(r => ProjectToDirector(r)).ToList(), Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList(), Artwork = new List(), Guids = GuidsFromProviderIds(item.ProviderIds), Subtitles = new List() }; // set order on actors for (var i = 0; i < metadata.Actors.Count; i++) { metadata.Actors[i].Order = i; } if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate)) { metadata.ReleaseDate = releaseDate; } if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary)) { var poster = new Artwork { ArtworkKind = ArtworkKind.Poster, Path = $"jellyfin://Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}", DateAdded = dateAdded }; metadata.Artwork.Add(poster); } if (item.BackdropImageTags.Count != 0) { var fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt, Path = $"jellyfin://Items/{item.Id}/Images/Backdrop?tag={item.BackdropImageTags.Head()}", DateAdded = dateAdded }; metadata.Artwork.Add(fanArt); } return metadata; } private static Option ProjectToActor(JellyfinPersonResponse person, DateTime dateAdded) { if (person.Type?.ToLowerInvariant() != "actor") { return None; } var actor = new Actor { Name = person.Name, Role = person.Role }; if (!string.IsNullOrWhiteSpace(person.Id) && !string.IsNullOrWhiteSpace(person.PrimaryImageTag)) { actor.Artwork = new Artwork { Path = $"jellyfin://Items/{person.Id}/Images/Primary?tag={person.PrimaryImageTag}", ArtworkKind = ArtworkKind.Thumbnail, DateAdded = dateAdded }; } return actor; } private static Option ProjectToDirector(JellyfinPersonResponse person) { if (person.Type?.ToLowerInvariant() != "director") { return None; } return new Director { Name = person.Name }; } private static Option ProjectToWriter(JellyfinPersonResponse person) { if (person.Type?.ToLowerInvariant() != "writer") { return None; } return new Writer { Name = person.Name }; } private Option ProjectToShow(JellyfinLibraryItemResponse item) { try { ShowMetadata metadata = ProjectToShowMetadata(item); var show = new JellyfinShow { ItemId = item.Id, Etag = item.Etag, ShowMetadata = new List { metadata }, TraktListItems = new List() }; return show; } catch (Exception ex) { _logger.LogWarning(ex, "Error projecting Jellyfin show"); return None; } } private static ShowMetadata ProjectToShowMetadata(JellyfinLibraryItemResponse item) { DateTime dateAdded = item.DateCreated.UtcDateTime; // DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(item.UpdatedAt).DateTime; var metadata = new ShowMetadata { MetadataKind = MetadataKind.External, Title = item.Name, SortTitle = SortTitle.GetSortTitle(item.Name), Plot = item.Overview, Year = item.ProductionYear, Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty), DateAdded = dateAdded, ContentRating = item.OfficialRating, Genres = Optional(item.Genres).Flatten().Map(g => new Genre { Name = g }).ToList(), Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(), Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(), Actors = Optional(item.People).Flatten().Collect(r => ProjectToActor(r, dateAdded)).ToList(), Artwork = new List(), Guids = GuidsFromProviderIds(item.ProviderIds) }; // set order on actors for (var i = 0; i < metadata.Actors.Count; i++) { metadata.Actors[i].Order = i; } if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate)) { metadata.ReleaseDate = releaseDate; } if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary)) { var poster = new Artwork { ArtworkKind = ArtworkKind.Poster, Path = $"jellyfin://Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}", DateAdded = dateAdded }; metadata.Artwork.Add(poster); } if (!string.IsNullOrWhiteSpace(item.ImageTags.Thumb)) { var thumb = new Artwork { ArtworkKind = ArtworkKind.Thumbnail, Path = $"jellyfin://Items/{item.Id}/Images/Thumb?tag={item.ImageTags.Thumb}", DateAdded = dateAdded }; metadata.Artwork.Add(thumb); } if (item.BackdropImageTags.Count != 0) { var fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt, Path = $"jellyfin://Items/{item.Id}/Images/Backdrop?tag={item.BackdropImageTags.Head()}", DateAdded = dateAdded }; metadata.Artwork.Add(fanArt); } return metadata; } private Option ProjectToSeason(JellyfinLibraryItemResponse item) { try { DateTime dateAdded = item.DateCreated.UtcDateTime; // DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime; var metadata = new SeasonMetadata { MetadataKind = MetadataKind.External, Title = item.Name, SortTitle = SortTitle.GetSortTitle(item.Name), Year = item.ProductionYear, DateAdded = dateAdded, Artwork = new List(), Guids = GuidsFromProviderIds(item.ProviderIds), Tags = new List() }; if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary)) { var poster = new Artwork { ArtworkKind = ArtworkKind.Poster, Path = $"jellyfin://Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}", DateAdded = dateAdded }; metadata.Artwork.Add(poster); } if (item.BackdropImageTags.Count != 0) { var fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt, Path = $"jellyfin://Items/{item.Id}/Images/Backdrop?tag={item.BackdropImageTags.Head()}", DateAdded = dateAdded }; metadata.Artwork.Add(fanArt); } var season = new JellyfinSeason { ItemId = item.Id, Etag = item.Etag, SeasonMetadata = new List { metadata }, TraktListItems = new List() }; if (item.IndexNumber.HasValue) { season.SeasonNumber = item.IndexNumber.Value; } else { Option maybeSeasonNumber = _fallbackMetadataProvider.GetSeasonNumberForFolder(item.Path ?? string.Empty); foreach (int seasonNumber in maybeSeasonNumber) { season.SeasonNumber = seasonNumber; } } return season; } catch (Exception ex) { _logger.LogWarning(ex, "Error projecting Jellyfin season"); return None; } } private Option ProjectToCollection(JellyfinLibraryItemResponse item) { try { return new JellyfinCollection { ItemId = item.Id, Etag = item.Etag, Name = item.Name }; } catch (Exception ex) { _logger.LogWarning(ex, "Error projecting Jellyfin collection"); return None; } } private Option ProjectToEpisode(JellyfinLibrary library, JellyfinLibraryItemResponse item) { try { if (item.LocationType != "FileSystem") { return None; } if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm") { _logger.LogWarning("STRM files are not supported; skipping {Path}", item.Path); return None; } string path = item.Path ?? string.Empty; foreach (JellyfinPathInfo pathInfo in library.PathInfos.Filter( pi => !string.IsNullOrWhiteSpace(pi.NetworkPath))) { if (path.StartsWith(pathInfo.NetworkPath, StringComparison.Ordinal)) { path = _jellyfinPathReplacementService.ReplaceNetworkPath( (JellyfinMediaSource)library.MediaSource, path, pathInfo.NetworkPath, pathInfo.Path); } } var duration = TimeSpan.FromTicks(item.RunTimeTicks); var version = new MediaVersion { Name = "Main", Duration = duration, DateAdded = item.DateCreated.UtcDateTime, MediaFiles = new List { new() { Path = path } }, Streams = new List(), Chapters = ProjectToModel(Optional(item.Chapters).Flatten(), duration) }; EpisodeMetadata metadata = ProjectToEpisodeMetadata(item); var episode = new JellyfinEpisode { ItemId = item.Id, Etag = item.Etag, MediaVersions = new List { version }, EpisodeMetadata = new List { metadata }, TraktListItems = new List() }; return episode; } catch (Exception ex) { _logger.LogWarning(ex, "Error projecting Jellyfin episode"); return None; } } private static EpisodeMetadata ProjectToEpisodeMetadata(JellyfinLibraryItemResponse item) { DateTime dateAdded = item.DateCreated.UtcDateTime; // DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(item.UpdatedAt).DateTime; var metadata = new EpisodeMetadata { MetadataKind = MetadataKind.External, Title = item.Name, SortTitle = SortTitle.GetSortTitle(item.Name), Plot = item.Overview, Year = item.ProductionYear, DateAdded = dateAdded, Genres = Optional(item.Genres).Flatten().Map(g => new Genre { Name = g }).ToList(), Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(), Studios = new List(), Actors = Optional(item.People).Flatten().Collect(r => ProjectToActor(r, dateAdded)).ToList(), Artwork = new List(), Guids = GuidsFromProviderIds(item.ProviderIds), Directors = Optional(item.People).Flatten().Collect(r => ProjectToDirector(r)).ToList(), Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList(), Subtitles = new List() }; if (item.IndexNumber.HasValue) { metadata.EpisodeNumber = item.IndexNumber.Value; } if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate)) { metadata.ReleaseDate = releaseDate; } if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary)) { var thumbnail = new Artwork { ArtworkKind = ArtworkKind.Thumbnail, Path = $"jellyfin://Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}", DateAdded = dateAdded }; metadata.Artwork.Add(thumbnail); } return metadata; } private static List GuidsFromProviderIds(JellyfinProviderIdsResponse providerIds) { var result = new List(); if (providerIds != null) { if (!string.IsNullOrWhiteSpace(providerIds.Imdb)) { result.Add(new MetadataGuid { Guid = $"imdb://{providerIds.Imdb}" }); } if (!string.IsNullOrWhiteSpace(providerIds.Tmdb)) { result.Add(new MetadataGuid { Guid = $"tmdb://{providerIds.Tmdb}" }); } if (!string.IsNullOrWhiteSpace(providerIds.Tvdb)) { result.Add(new MetadataGuid { Guid = $"tvdb://{providerIds.Tvdb}" }); } } return result; } private Option ProjectToMediaVersion(JellyfinPlaybackInfoResponse response) { if (response.MediaSources is null || response.MediaSources.Count == 0) { _logger.LogWarning("Received empty playback info from Jellyfin"); return None; } JellyfinMediaSourceResponse mediaSource = response.MediaSources.Head(); // jellyfin includes external streams first, obscuring real stream indexes // from the source file int streamIndexOffset = mediaSource.MediaStreams .Filter(s => s.IsExternal) .Map(s => s.Index + 1) .OrderByDescending(i => i) .FirstOrDefault(); IList streams = mediaSource.MediaStreams; Option maybeVideoStream = streams.Find(s => s.Type == JellyfinMediaStreamType.Video); return maybeVideoStream.Map( videoStream => { int width = videoStream.Width ?? 1; int height = videoStream.Height ?? 1; var isAnamorphic = false; if (videoStream.IsAnamorphic.HasValue) { isAnamorphic = videoStream.IsAnamorphic.Value; } else if (!string.IsNullOrWhiteSpace(videoStream.AspectRatio) && videoStream.AspectRatio.Contains(':')) { // if width/height != aspect ratio, is anamorphic double resolutionRatio = width / (double)height; string[] split = videoStream.AspectRatio.Split(":"); var num = double.Parse(split[0], CultureInfo.InvariantCulture); var den = double.Parse(split[1], CultureInfo.InvariantCulture); double aspectRatio = num / den; isAnamorphic = Math.Abs(resolutionRatio - aspectRatio) > 0.01d; } var version = new MediaVersion { Duration = TimeSpan.FromTicks(mediaSource.RunTimeTicks), SampleAspectRatio = isAnamorphic ? "0:0" : "1:1", DisplayAspectRatio = string.IsNullOrWhiteSpace(videoStream.AspectRatio) ? string.Empty : videoStream.AspectRatio, VideoScanKind = videoStream.IsInterlaced switch { true => VideoScanKind.Interlaced, false => VideoScanKind.Progressive }, Streams = new List(), Width = videoStream.Width ?? 1, Height = videoStream.Height ?? 1, RFrameRate = videoStream.RealFrameRate.HasValue ? videoStream.RealFrameRate.Value.ToString("0.00###", CultureInfo.InvariantCulture) : string.Empty, Chapters = new List() }; version.Streams.Add( new MediaStream { MediaVersionId = version.Id, MediaStreamKind = MediaStreamKind.Video, Index = videoStream.Index - streamIndexOffset, Codec = videoStream.Codec, Profile = (videoStream.Profile ?? string.Empty).ToLowerInvariant(), Default = videoStream.IsDefault, Language = videoStream.Language, Forced = videoStream.IsForced, PixelFormat = videoStream.PixelFormat, ColorRange = (videoStream.ColorRange ?? string.Empty).ToLowerInvariant(), ColorSpace = (videoStream.ColorSpace ?? string.Empty).ToLowerInvariant(), ColorTransfer = (videoStream.ColorTransfer ?? string.Empty).ToLowerInvariant(), ColorPrimaries = (videoStream.ColorPrimaries ?? string.Empty).ToLowerInvariant() }); foreach (JellyfinMediaStreamResponse audioStream in streams.Filter( s => s.Type == JellyfinMediaStreamType.Audio)) { var stream = new MediaStream { MediaVersionId = version.Id, MediaStreamKind = MediaStreamKind.Audio, Index = audioStream.Index - streamIndexOffset, Codec = audioStream.Codec, Profile = (audioStream.Profile ?? string.Empty).ToLowerInvariant(), Channels = audioStream.Channels ?? 2, Default = audioStream.IsDefault, Forced = audioStream.IsForced, Language = audioStream.Language, Title = audioStream.Title ?? string.Empty }; version.Streams.Add(stream); } foreach (JellyfinMediaStreamResponse subtitleStream in streams.Filter( s => s.Type == JellyfinMediaStreamType.Subtitle)) { var stream = new MediaStream { MediaVersionId = version.Id, Codec = (subtitleStream.Codec ?? string.Empty).ToLowerInvariant(), Default = subtitleStream.IsDefault, Forced = subtitleStream.IsForced, Language = subtitleStream.Language }; if (subtitleStream.IsExternal) { stream.MediaStreamKind = MediaStreamKind.ExternalSubtitle; // ensure these don't collide with real indexes from the source file stream.Index = subtitleStream.Index + JellyfinStream.ExternalStreamOffset; } else { stream.MediaStreamKind = MediaStreamKind.Subtitle; stream.Index = subtitleStream.Index - streamIndexOffset; } version.Streams.Add(stream); } return version; }); } }