From 591a92f6aabc8dec93127117ba8ec4bbe602a2c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20Van=C3=AD=C4=8Dek?= Date: Mon, 2 Dec 2024 00:13:40 +0100 Subject: [PATCH] TV Series playback works --- CinemaJellyfin/CinemaAnimeFolder.cs | 33 ++ CinemaJellyfin/CinemaAudio.cs | 0 CinemaJellyfin/CinemaConcertFolder.cs | 33 ++ CinemaJellyfin/CinemaEpisode.cs | 32 ++ CinemaJellyfin/CinemaFilterFolder.cs | 330 +----------- CinemaJellyfin/CinemaHost.cs | 7 + CinemaJellyfin/CinemaInnerLibraryManager.cs | 12 + .../CinemaInnerMediaSourceManager.cs | 2 +- CinemaJellyfin/CinemaLibraryManager.cs | 505 ++++++++++++++++++ CinemaJellyfin/CinemaMediaSourceManager.cs | 21 +- CinemaJellyfin/CinemaMoviesFolder.cs | 11 +- CinemaJellyfin/CinemaMusicAlbum.cs | 68 +++ CinemaJellyfin/CinemaPopularFolder.cs | 6 + CinemaJellyfin/CinemaQueryExtensions.cs | 334 ++++++++++++ CinemaJellyfin/CinemaSeason.cs | 69 +++ CinemaJellyfin/CinemaServiceRegistrator.cs | 27 +- CinemaJellyfin/CinemaSubfolderHelper.cs | 100 ++++ CinemaJellyfin/CinemaTrendingFolder.cs | 6 + CinemaJellyfin/CinemaTvSeries.cs | 78 +++ CinemaJellyfin/CinemaTvShowsFolder.cs | 33 ++ CinemaJellyfin/ICinemaChildrenCount.cs | 6 + .../ICinemaInnerMediaSourceManager.cs | 5 - CinemaLib/API/Metadata.cs | 10 +- 23 files changed, 1381 insertions(+), 347 deletions(-) create mode 100644 CinemaJellyfin/CinemaAnimeFolder.cs create mode 100644 CinemaJellyfin/CinemaAudio.cs create mode 100644 CinemaJellyfin/CinemaConcertFolder.cs create mode 100644 CinemaJellyfin/CinemaInnerLibraryManager.cs create mode 100644 CinemaJellyfin/CinemaLibraryManager.cs create mode 100644 CinemaJellyfin/CinemaMusicAlbum.cs create mode 100644 CinemaJellyfin/CinemaQueryExtensions.cs create mode 100644 CinemaJellyfin/CinemaSeason.cs create mode 100644 CinemaJellyfin/CinemaSubfolderHelper.cs create mode 100644 CinemaJellyfin/CinemaTvSeries.cs create mode 100644 CinemaJellyfin/CinemaTvShowsFolder.cs create mode 100644 CinemaJellyfin/ICinemaChildrenCount.cs delete mode 100644 CinemaJellyfin/ICinemaInnerMediaSourceManager.cs diff --git a/CinemaJellyfin/CinemaAnimeFolder.cs b/CinemaJellyfin/CinemaAnimeFolder.cs new file mode 100644 index 0000000..0e0af13 --- /dev/null +++ b/CinemaJellyfin/CinemaAnimeFolder.cs @@ -0,0 +1,33 @@ +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using CinemaLib.API; +using System.Diagnostics.CodeAnalysis; + +namespace Jellyfin.Plugin.Cinema; + +sealed class CinemaAnimeFolder : CinemaRootFolder +{ + public CinemaAnimeFolder() + { + } + + public override CollectionType? CollectionType => Data.Enums.CollectionType.movies; + + public override BaseItemKind ClientType => BaseItemKind.Movie; + + public override ItemType ItemType => ItemType.Anime; + + internal override string ImageName => "anime.png"; + + protected override IEnumerable GetFilterItems() + { + // Root items + // none + yield break; + } + + public override bool TryCreateMediaItem(MediaSource? media, string csId, BaseItem parentFolder, [NotNullWhen(true)] out BaseItem? item) + { + return media.TryCreateMediaItem(csId, parentFolder, false, out item); + } +} \ No newline at end of file diff --git a/CinemaJellyfin/CinemaAudio.cs b/CinemaJellyfin/CinemaAudio.cs new file mode 100644 index 0000000..e69de29 diff --git a/CinemaJellyfin/CinemaConcertFolder.cs b/CinemaJellyfin/CinemaConcertFolder.cs new file mode 100644 index 0000000..1a84d43 --- /dev/null +++ b/CinemaJellyfin/CinemaConcertFolder.cs @@ -0,0 +1,33 @@ +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using CinemaLib.API; +using System.Diagnostics.CodeAnalysis; + +namespace Jellyfin.Plugin.Cinema; + +sealed class CinemaConcertFolder : CinemaRootFolder +{ + public CinemaConcertFolder() + { + } + + public override CollectionType? CollectionType => Data.Enums.CollectionType.music; + + public override BaseItemKind ClientType => BaseItemKind.MusicAlbum; + + public override ItemType ItemType => ItemType.Concert; + + internal override string ImageName => "music.png"; + + protected override IEnumerable GetFilterItems() + { + // Root items + // none + yield break; + } + + public override bool TryCreateMediaItem(MediaSource? media, string csId, BaseItem parentFolder, [NotNullWhen(true)] out BaseItem? item) + { + return media.TryCreateMediaItem(csId, parentFolder, true, out item); + } +} \ No newline at end of file diff --git a/CinemaJellyfin/CinemaEpisode.cs b/CinemaJellyfin/CinemaEpisode.cs index e69de29..60c16af 100644 --- a/CinemaJellyfin/CinemaEpisode.cs +++ b/CinemaJellyfin/CinemaEpisode.cs @@ -0,0 +1,32 @@ +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Dto; + +namespace Jellyfin.Plugin.Cinema; + +/// +/// Episode media item from Cinema. +/// +public class CinemaEpisode : Episode +{ + public sealed override string GetClientTypeName() => BaseItemKind.Episode.ToString(); + + protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources() + { + var result = this.VideoGetAllItemsForMediaSources(); + if (result == null) + return base.GetAllItemsForMediaSources(); + else + return result; + } + + public override List GetMediaSources(bool enablePathSubstitution) + { + var result = this.VideoGetMediaSources(enablePathSubstitution); + if (result == null) + return base.GetMediaSources(enablePathSubstitution); + else + return result; + } +} \ No newline at end of file diff --git a/CinemaJellyfin/CinemaFilterFolder.cs b/CinemaJellyfin/CinemaFilterFolder.cs index e0d9b3d..ec6a933 100644 --- a/CinemaJellyfin/CinemaFilterFolder.cs +++ b/CinemaJellyfin/CinemaFilterFolder.cs @@ -15,10 +15,6 @@ namespace Jellyfin.Plugin.Cinema; /// public abstract class CinemaFilterFolder : Folder { - private const string PreferredCulture = "cs"; - private const string FallbackCulture = "en"; - private const char VersionSeparator = '-'; - /// /// Filtering folders can never be deleted. /// @@ -54,6 +50,17 @@ public abstract class CinemaFilterFolder : Folder /// protected abstract IEnumerable GetFilterItems(); + /// + /// Tries to create a Jellyfin media item from the provided Cinema media item. + /// + /// Cinema media identifier. + /// Cinema metadata. + /// Jellyfin parent folder. + /// On success the created item. + /// True on success, false otherwise. + /// Implementation shall just choose proper container type and call + public abstract bool TryCreateMediaItem(MediaSource? media, string csId, BaseItem parentFolder, [NotNullWhen(true)] out BaseItem? item); + /// /// Gets static as well as dynamic items from Stream Cinema database. /// @@ -95,7 +102,7 @@ public abstract class CinemaFilterFolder : Folder else { (ItemSortBy sortByJ, SortOrder sortDirJ) = query.OrderBy.First(); - sortBy = ConvertSort(sortByJ); + sortBy = sortByJ.ToCinema(); sortDir = sortDirJ == SortOrder.Ascending ? ItemOrder.Ascending : ItemOrder.Descending; } @@ -106,7 +113,7 @@ public abstract class CinemaFilterFolder : Folder result.TotalRecordCount = (int)filterRes.hits.total.value; foreach (var i in filterRes.hits.hits) { - if (TryCreateMediaItem(i._id, i._source, this, out BaseItem? a)) + if (TryCreateMediaItem(i._source, i._id, this, out BaseItem? a)) items.Add(a); } } @@ -114,46 +121,6 @@ public abstract class CinemaFilterFolder : Folder return result; } - private static FilterSortBy ConvertSort(ItemSortBy sortByJ) - { - switch (sortByJ) - { - default: - case ItemSortBy.Default: return FilterSortBy.Title; - case ItemSortBy.AiredEpisodeOrder: return FilterSortBy.Premiered; - //case ItemSortBy.Album: return - //case ItemSortBy.AlbumArtist: return - //case ItemSortBy.Artist: return - case ItemSortBy.DateCreated: return FilterSortBy.DateAdded; - case ItemSortBy.OfficialRating: return FilterSortBy.Score; - case ItemSortBy.DatePlayed: return FilterSortBy.LastSeen; - case ItemSortBy.PremiereDate: return FilterSortBy.Premiered; - case ItemSortBy.StartDate: return FilterSortBy.Premiered; - case ItemSortBy.SortName: return FilterSortBy.Title; - case ItemSortBy.Name: return FilterSortBy.Title; - case ItemSortBy.Random: return FilterSortBy.Trending; - //case ItemSortBy.Runtime: return - case ItemSortBy.CommunityRating: return FilterSortBy.Popularity; - case ItemSortBy.ProductionYear: return FilterSortBy.Year; - case ItemSortBy.PlayCount: return FilterSortBy.PlayCount; - case ItemSortBy.CriticRating: return FilterSortBy.Score; - //case ItemSortBy.IsFolder: - //case ItemSortBy.IsUnplayed: - //case ItemSortBy.IsPlayed: - case ItemSortBy.SeriesSortName: return FilterSortBy.Title; - //case ItemSortBy.VideoBitRate: - //case ItemSortBy.AirTime: - //case ItemSortBy.Studio: - //case ItemSortBy.IsFavoriteOrLiked: - case ItemSortBy.DateLastContentAdded: return FilterSortBy.LastChildrenDateAdded; - case ItemSortBy.SeriesDatePlayed: return FilterSortBy.LastChildPremiered; - //case ItemSortBy.ParentIndexNumber: - //case ItemSortBy.IndexNumber: - case ItemSortBy.SimilarityScore: return FilterSortBy.Score; - case ItemSortBy.SearchScore: return FilterSortBy.Score; - } - } - /// /// Gets an already persisted instance or creates a new instance of the given child filter folder. /// @@ -221,281 +188,10 @@ public abstract class CinemaFilterFolder : Folder return folder; } - /// - /// Tries to create a Jellyfin media item from the provided Cinema Stream media item. - /// - /// Cinema Stream media identifier. - /// Cinema Stream metadata. - /// Jellyfin parent folder. - /// On success the created item. - /// True on success, false otherwise. - private bool TryCreateMediaItem(string csId, MediaSource? media, BaseItem parentFolder, [NotNullWhen(true)] out BaseItem? item) - { - if (media == null) - { - item = null; - return false; - } - var parentFolderId = parentFolder.Id; - - bool isNew; - bool forceUpdate = false; - - bool isAudio = media.is_concert ?? false; - if (media.children_count != 0) - { - // Series, season or a music album - FilterResponse? res = Metadata.ChildrenAsync(csId, sort: FilterSortBy.Episode).GetAwaiter().GetResult(); - if (res == null || res.hits == null || res.hits.hits == null || res.hits.hits.Count == 0) - { - item = null; - return false; - } - - bool singleLevel = res.hits.hits[0]._source?.children_count == 0; - if (isAudio) - item = GetMediaItemById(csId, null, out isNew); - else if (singleLevel) - item = GetMediaItemById(csId, null, out isNew); - else - item = GetMediaItemById(csId, null, out isNew); - - } - else if (isAudio) - { - // TODO determine if this is an AudioBook od Audio - item = GetMediaItemById(csId, null, out isNew); - - } - else if (media.info_labels?.mediatype == "tvshow") - { - item = GetMediaItemById(csId, null, out isNew); - - } - else - { - item = GetMediaItemById(csId, null, out isNew); - } - - if (isNew && media.info_labels != null) - { - item.RunTimeTicks = (long?)(media.info_labels.duration * TimeSpan.TicksPerSecond); - } - - if (isNew) - { - InfoLabelI18n? loc = GetLocalized(media); - item.Name = loc?.title ?? media.info_labels?.originaltitle; - item.Genres = media.info_labels?.genre?.ToArray(); - item.Studios = media.info_labels?.studio?.ToArray(); - item.CommunityRating = (float?)media.ratings?.overall?.rating; - item.Overview = loc?.plot; - // TODO - // item.IndexNumber = info.IndexNumber; - //item.ParentIndexNumber = info.ParentIndexNumber; - item.PremiereDate = media.info_labels?.premiered; - item.ProductionYear = media.info_labels?.year; - if (media.services != null) { - item.ProviderIds = new Dictionary(); - ConvertProviderIds(media.services, item.ProviderIds); - } - //item.OfficialRating = info.OfficialRating; - item.DateCreated = media.info_labels?.dateadded ?? DateTime.UtcNow; - //item.Tags = info.Tags.ToArray(); - item.OriginalTitle = media.info_labels?.originaltitle; - - string? artUriS = loc?.art?.poster; - Uri? artUri; - if (artUriS != null) - { - artUri = new Uri(artUriS); - if (Metadata.TryGetThumbnail(artUri, 128, 128, out Uri? artThumbUri)) - { - item.SetImagePath(ImageType.Thumb, artThumbUri.ToString()); - } - item.SetImagePath(ImageType.Primary, artUri.ToString()); - } - - artUriS = loc?.art?.fanart; - if (artUriS != null) - { - artUri = new Uri(artUriS); - item.SetImagePath(ImageType.Backdrop, artUri.ToString()); - } - } - - // Indicate just HTTP CinemaMediaSourceManager and CinemaMediaSourceController will handle the rest - item.Path = "https://a/b"; - - if (item is IHasArtist hasArtists) - { - hasArtists.Artists = media.info_labels?.writer; - } - - if (item is IHasAlbumArtist hasAlbumArtists) - { - hasAlbumArtists.AlbumArtists = media.info_labels?.director; - } - - item.ParentId = parentFolderId; - - /* - if (item is IHasSeries hasSeries) - { - if (!string.Equals(hasSeries.SeriesName, info.SeriesName, StringComparison.OrdinalIgnoreCase)) - { - forceUpdate = true; - _logger.LogDebug("Forcing update due to SeriesName {0}", item.Name); - } - - hasSeries.SeriesName = info.SeriesName; - }*/ - - item.ExternalId = CinemaIdToExternalId(csId); - - /* - if (item is Audio channelAudioItem) - { - channelAudioItem.ExtraType = info.ExtraType; - - var mediaSource = info.MediaSources.FirstOrDefault(); - item.Path = mediaSource?.Path; - } - - if (item is Video channelVideoItem) - { - channelVideoItem.ExtraType = info.ExtraType; - - var mediaSource = info.MediaSources.FirstOrDefault(); - item.Path = mediaSource?.Path; - }*/ - - item.OnMetadataChanged(); - - if (isNew) - { - // HACK: We use RegisterItem that is volatile intead of CreateItem - //_libraryManager.CreateItem(item, parentFolder); - CinemaHost.LibraryManager.RegisterItem(item); - - if (media.cast != null && media.cast.Count > 0) - { - //await _libraryManager.UpdatePeopleAsync(item, info.People, cancellationToken).ConfigureAwait(false); - } - } - else if (forceUpdate) - { - // HACK our items are volatile - //item.UpdateToRepositoryAsync(ItemUpdateType.None, default).GetAwaiter().GetResult(); - } - - /* - if ((isNew || forceUpdate) && info.Type == ChannelItemType.Media) - { - if (enableMediaProbe && !info.IsLiveStream && item.HasPathProtocol) - { - await SaveMediaSources(item, new List()).ConfigureAwait(false); - } - else - { - await SaveMediaSources(item, info.MediaSources).ConfigureAwait(false); - } - }*/ - - /* - if (isNew || forceUpdate || item.DateLastRefreshed == default) - { - _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.Normal); - }*/ - - return true; - } - - private InfoLabelI18n? GetLocalized(MediaSource media) - { - if (media.i18n_info_labels == null) - return null; - InfoLabelI18n? first = null; - InfoLabelI18n? preferred = null; - InfoLabelI18n? fallback = null; - foreach (InfoLabelI18n i in media.i18n_info_labels) - if (i.lang == PreferredCulture) - preferred = i; - else if (i.lang == FallbackCulture) - fallback = i; - else if (first == null) - first = i; - - return preferred ?? fallback ?? first; - } - - private static void ConvertProviderIds(ServicesIds services, Dictionary dest) - { - if (services.imdb != null) - dest.Add("Imdb", services.imdb); - if (services.tvdb != null) - dest.Add("Tvdb", services.tvdb); - if (services.tmdb != null) - dest.Add("Tmdb", services.tmdb); - - //public string? csfd { get; set; } - //public string? trakt { get; set; } - //public string? trakt_with_type { get; set; } - //public string? slug { get; set; } - } internal static string GetInternalMetadataPath(string basePath, Guid id) { return System.IO.Path.Combine(basePath, "cinema", id.ToString("N", CultureInfo.InvariantCulture), "metadata"); } - - /// - /// Calculates Jellyfin item identifier from CinemaStream identifier, optionally also with version identifier. - /// - internal static Guid GetMediaItemId(string csPrimaryId, string? csVersionId) { - string idS = csVersionId == null ? csPrimaryId : (csPrimaryId + VersionSeparator + csVersionId); - return CinemaHost.LibraryManager.GetNewItemId(idS, typeof(CinemaPlugin)); - } - - /// - /// Creates a new media item (non-folder) with a proper unique identifier. - /// - internal static T GetMediaItemById(string csPrimaryId, string? csVersionId, out bool isNew) - where T : BaseItem, new() - { - Guid id = GetMediaItemId(csPrimaryId, csVersionId); - T? item = CinemaHost.LibraryManager.GetItemById(id) as T; - - if (item == null) - { - item = new T(); - isNew = true; - } - else - { - isNew = false; - } - - item.Id = id; - return item; - } - - internal static string CinemaIdToExternalId(string csId) { - return CinemaPlugin.CinemaExtIdPrefix + csId; - } - - internal static bool IsCinemaExternalId(string? externalId) { - return externalId != null && externalId.StartsWith(CinemaPlugin.CinemaExtIdPrefix); - } - - internal static bool TryGetCinemaIdFromExternalId(string? externalId, [NotNullWhen(true)] out string? csId) { - if (IsCinemaExternalId(externalId)) { - csId = externalId!.Substring(CinemaPlugin.CinemaExtIdPrefix.Length); - return true; - } else { - csId = null; - return false; - } - } } \ No newline at end of file diff --git a/CinemaJellyfin/CinemaHost.cs b/CinemaJellyfin/CinemaHost.cs index c0e1ba2..4998681 100644 --- a/CinemaJellyfin/CinemaHost.cs +++ b/CinemaJellyfin/CinemaHost.cs @@ -38,11 +38,18 @@ public sealed class CinemaHost : IHostedService public static IFileSystem FileSystem => _fileSystem; + public static string PreferredCulture => _config.Configuration.PreferredMetadataLanguage; + + public static string FallbackCulture => "en"; + /// public Task StartAsync(CancellationToken cancellationToken) { // Make sure the Stream Cinema root folders are created CinemaFilterFolder.CreateRootFilterFolder("Movies"); + CinemaFilterFolder.CreateRootFilterFolder("TV Shows"); + CinemaFilterFolder.CreateRootFilterFolder("Anime"); + CinemaFilterFolder.CreateRootFilterFolder("Music"); /* pluginItems.Add(CreateMenuItem("movies", Resources.Movies, GetResourceUrl("movies.png"))); diff --git a/CinemaJellyfin/CinemaInnerLibraryManager.cs b/CinemaJellyfin/CinemaInnerLibraryManager.cs new file mode 100644 index 0000000..562894a --- /dev/null +++ b/CinemaJellyfin/CinemaInnerLibraryManager.cs @@ -0,0 +1,12 @@ +namespace Jellyfin.Plugin.Cinema; + +class CinemaInnerLibraryManager +{ + private readonly Type _innerType; + + public CinemaInnerLibraryManager(Type innerType) { + this._innerType = innerType; + } + + public Type InnerType => _innerType; +} \ No newline at end of file diff --git a/CinemaJellyfin/CinemaInnerMediaSourceManager.cs b/CinemaJellyfin/CinemaInnerMediaSourceManager.cs index 4bdc1a8..59306aa 100644 --- a/CinemaJellyfin/CinemaInnerMediaSourceManager.cs +++ b/CinemaJellyfin/CinemaInnerMediaSourceManager.cs @@ -1,6 +1,6 @@ namespace Jellyfin.Plugin.Cinema; -class CinemaInnerMediaSourceManager : ICinemaInnerMediaSourceManager +public class CinemaInnerMediaSourceManager { private readonly Type _innerType; diff --git a/CinemaJellyfin/CinemaLibraryManager.cs b/CinemaJellyfin/CinemaLibraryManager.cs new file mode 100644 index 0000000..da52dc0 --- /dev/null +++ b/CinemaJellyfin/CinemaLibraryManager.cs @@ -0,0 +1,505 @@ +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Controller.Sorting; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Querying; + +namespace Jellyfin.Plugin.Cinema; + +sealed class CinemaLibraryManager : ILibraryManager +{ + private readonly ILibraryManager _inner; + + public CinemaLibraryManager(CinemaInnerLibraryManager innerLibraryManager, IServiceProvider svc) + { + if (innerLibraryManager == null || svc == null) + throw new ArgumentNullException(); + + ILibraryManager? inner = svc.GetService(innerLibraryManager.InnerType) as ILibraryManager; + if (inner == null) + throw new InvalidOperationException("Original LibraryManager service not found."); + this._inner = inner; + + } + + #region ILibraryManager Members + + public AggregateFolder RootFolder => _inner.RootFolder; + + public bool IsScanRunning => _inner.IsScanRunning; + + public event EventHandler? ItemAdded { + add => _inner.ItemAdded += value; + remove => _inner.ItemAdded -= value; + } + public event EventHandler? ItemUpdated { + add => _inner.ItemUpdated += value; + remove => _inner.ItemUpdated -= value; + } + public event EventHandler? ItemRemoved { + add => _inner.ItemRemoved += value; + remove => _inner.ItemRemoved -= value; + } + + public void AddMediaPath(string virtualFolderName, MediaPathInfo mediaPath) + { + _inner.AddMediaPath(virtualFolderName, mediaPath); + } + + public void AddParts(IEnumerable rules, IEnumerable resolvers, IEnumerable introProviders, IEnumerable itemComparers, IEnumerable postscanTasks) + { + _inner.AddParts(rules, resolvers, introProviders, itemComparers, postscanTasks); + } + + public Task AddVirtualFolder(string name, CollectionTypeOptions? collectionType, LibraryOptions options, bool refreshLibrary) + { + return _inner.AddVirtualFolder(name, collectionType, options, refreshLibrary); + } + + public Task ConvertImageToLocal(BaseItem item, ItemImageInfo image, int imageIndex, bool removeOnFailure = true) + { + return _inner.ConvertImageToLocal(item, image, imageIndex, removeOnFailure); + } + + public void CreateItem(BaseItem item, BaseItem? parent) + { + _inner.CreateItem(item, parent); + } + + public void CreateItems(IReadOnlyList items, BaseItem? parent, CancellationToken cancellationToken) + { + _inner.CreateItems(items, parent, cancellationToken); + } + + public void DeleteItem(BaseItem item, DeleteOptions options) + { + _inner.DeleteItem(item, options); + } + + public void DeleteItem(BaseItem item, DeleteOptions options, bool notifyParentItem) + { + _inner.DeleteItem(item, options, notifyParentItem); + } + + public void DeleteItem(BaseItem item, DeleteOptions options, BaseItem parent, bool notifyParentItem) + { + _inner.DeleteItem(item, options, parent, notifyParentItem); + } + + public bool FillMissingEpisodeNumbersFromPath(Episode episode, bool forceRefresh) + { + return _inner.FillMissingEpisodeNumbersFromPath(episode, forceRefresh); + } + + public BaseItem? FindByPath(string path, bool? isFolder) + { + return _inner.FindByPath(path, isFolder); + } + + public IEnumerable FindExtras(BaseItem owner, IReadOnlyList fileSystemChildren, IDirectoryService directoryService) + { + return _inner.FindExtras(owner, fileSystemChildren, directoryService); + } + + public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery query) + { + return _inner.GetAlbumArtists(query); + } + + public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery query) + { + return _inner.GetAllArtists(query); + } + + public MusicArtist GetArtist(string name) + { + return _inner.GetArtist(name); + } + + public MusicArtist GetArtist(string name, DtoOptions options) + { + return _inner.GetArtist(name, options); + } + + public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery query) + { + return _inner.GetArtists(query); + } + + public List GetCollectionFolders(BaseItem item) + { + return _inner.GetCollectionFolders(item); + } + + public List GetCollectionFolders(BaseItem item, IEnumerable allUserRootChildren) + { + return _inner.GetCollectionFolders(item, allUserRootChildren); + } + + public CollectionType? GetConfiguredContentType(BaseItem item) + { + return _inner.GetConfiguredContentType(item); + } + + public CollectionType? GetConfiguredContentType(string path) + { + return _inner.GetConfiguredContentType(path); + } + + public CollectionType? GetContentType(BaseItem item) + { + return _inner.GetContentType(item); + } + + public int GetCount(InternalItemsQuery query) + { + return _inner.GetCount(query); + } + + public Genre GetGenre(string name) + { + return _inner.GetGenre(name); + } + + public Guid GetGenreId(string name) + { + return _inner.GetGenreId(name); + } + + public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery query) + { + return _inner.GetGenres(query); + } + + public CollectionType? GetInheritedContentType(BaseItem item) + { + return _inner.GetInheritedContentType(item); + } + + public Task> GetIntros(BaseItem item, User user) + { + return _inner.GetIntros(item, user); + } + + public BaseItem? GetItemById(Guid id) + { + return _inner.GetItemById(id); + } + + public T? GetItemById(Guid id) where T : BaseItem + { + return _inner.GetItemById(id); + } + + public T? GetItemById(Guid id, Guid userId) where T : BaseItem + { + return _inner.GetItemById(id, userId); + } + + public T? GetItemById(Guid id, User? user) where T : BaseItem + { + return _inner.GetItemById(id, user); + } + + public List GetItemIds(InternalItemsQuery query) + { + return _inner.GetItemIds(query); + } + + public List GetItemList(InternalItemsQuery query) + { + string? serieOrSeasonIdS; + Guid serieOrSeasonId; + BaseItem? serieOrSeason; + if (query.IncludeItemTypes == null + || query.IncludeItemTypes.Length != 1 + || query.IncludeItemTypes[0] != BaseItemKind.Episode + || (serieOrSeasonIdS = query.AncestorWithPresentationUniqueKey ?? query.SeriesPresentationUniqueKey) == null + || !Guid.TryParse(serieOrSeasonIdS, out serieOrSeasonId) + || (serieOrSeason = _inner.GetItemById(serieOrSeasonId)) == null + || !CinemaQueryExtensions.IsCinemaExternalId(serieOrSeason.ExternalId)) + return _inner.GetItemList(query); + + // HACK: Necessary until GetEpisodes is virtual + switch (serieOrSeason) { + case CinemaSeason season: return new List(season.GetItemList(query)); + case CinemaTvSeries series: + List result = new List(); + foreach (BaseItem i in series.GetItemList(new InternalItemsQuery())) { + switch (i) { + case CinemaEpisode episode: result.Add(episode); break; + case CinemaSeason season: result.AddRange(season.GetItemList(query)); break; + } + } + return result; + default: return _inner.GetItemList(query); + } + } + + public List GetItemList(InternalItemsQuery query, bool allowExternalContent) + { + return _inner.GetItemList(query, allowExternalContent); + } + + public List GetItemList(InternalItemsQuery query, List parents) + { + return _inner.GetItemList(query, parents); + } + + public QueryResult GetItemsResult(InternalItemsQuery query) + { + return _inner.GetItemsResult(query); + } + + public LibraryOptions GetLibraryOptions(BaseItem item) + { + return _inner.GetLibraryOptions(item); + } + + public MusicGenre GetMusicGenre(string name) + { + return _inner.GetMusicGenre(name); + } + + public Guid GetMusicGenreId(string name) + { + return _inner.GetMusicGenreId(name); + } + + public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery query) + { + return _inner.GetMusicGenres(query); + } + + public UserView GetNamedView(User user, string name, Guid parentId, CollectionType? viewType, string sortName) + { + return _inner.GetNamedView(user, name, parentId, viewType, sortName); + } + + public UserView GetNamedView(User user, string name, CollectionType? viewType, string sortName) + { + return _inner.GetNamedView(user, name, viewType, sortName); + } + + public UserView GetNamedView(string name, CollectionType viewType, string sortName) + { + return _inner.GetNamedView(name, viewType, sortName); + } + + public UserView GetNamedView(string name, Guid parentId, CollectionType? viewType, string sortName, string uniqueId) + { + return _inner.GetNamedView(name, parentId, viewType, sortName, uniqueId); + } + + public Guid GetNewItemId(string key, Type type) + { + return _inner.GetNewItemId(key, type); + } + + public BaseItem GetParentItem(Guid? parentId, Guid? userId) + { + return _inner.GetParentItem(parentId, userId); + } + + public string GetPathAfterNetworkSubstitution(string path, BaseItem? ownerItem = null) + { + return _inner.GetPathAfterNetworkSubstitution(path, ownerItem); + } + + public List GetPeople(BaseItem item) + { + return _inner.GetPeople(item); + } + + public List GetPeople(InternalPeopleQuery query) + { + return _inner.GetPeople(query); + } + + public List GetPeopleItems(InternalPeopleQuery query) + { + return _inner.GetPeopleItems(query); + } + + public List GetPeopleNames(InternalPeopleQuery query) + { + return _inner.GetPeopleNames(query); + } + + public Person? GetPerson(string name) + { + return _inner.GetPerson(name); + } + + public int? GetSeasonNumberFromPath(string path) + { + return _inner.GetSeasonNumberFromPath(path); + } + + public UserView GetShadowView(BaseItem parent, CollectionType? viewType, string sortName) + { + return _inner.GetShadowView(parent, viewType, sortName); + } + + public Studio GetStudio(string name) + { + return _inner.GetStudio(name); + } + + public Guid GetStudioId(string name) + { + return _inner.GetStudioId(name); + } + + public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetStudios(InternalItemsQuery query) + { + return GetStudios(query); + } + + public Folder GetUserRootFolder() + { + return _inner.GetUserRootFolder(); + } + + public List GetVirtualFolders() + { + return _inner.GetVirtualFolders(); + } + + public List GetVirtualFolders(bool includeRefreshState) + { + return _inner.GetVirtualFolders(includeRefreshState); + } + + public Year GetYear(int value) + { + return _inner.GetYear(value); + } + + public bool IgnoreFile(FileSystemMetadata file, BaseItem parent) + { + return _inner.IgnoreFile(file, parent); + } + + public List NormalizeRootPathList(IEnumerable paths) + { + return _inner.NormalizeRootPathList(paths); + } + + public ItemLookupInfo ParseName(string name) + { + return _inner.ParseName(name); + } + + public QueryResult QueryItems(InternalItemsQuery query) + { + return _inner.QueryItems(query); + } + + public void QueueLibraryScan() + { + _inner.QueueLibraryScan(); + } + + public void RegisterItem(BaseItem item) + { + _inner.RegisterItem(item); + } + + public void RemoveMediaPath(string virtualFolderName, string mediaPath) + { + _inner.RemoveMediaPath(virtualFolderName, mediaPath); + } + + public Task RemoveVirtualFolder(string name, bool refreshLibrary) + { + return _inner.RemoveVirtualFolder(name, refreshLibrary); + } + + public BaseItem? ResolvePath(FileSystemMetadata fileInfo, Folder? parent = null, IDirectoryService? directoryService = null) + { + return _inner.ResolvePath(fileInfo, parent, directoryService); + } + + public IEnumerable ResolvePaths(IEnumerable files, IDirectoryService directoryService, Folder parent, LibraryOptions libraryOptions, CollectionType? collectionType = null) + { + return _inner.ResolvePaths(files, directoryService, parent, libraryOptions, collectionType); + } + + public BaseItem RetrieveItem(Guid id) + { + return _inner.RetrieveItem(id); + } + + public Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason) + { + return _inner.RunMetadataSavers(item, updateReason); + } + + public IEnumerable Sort(IEnumerable items, User? user, IEnumerable sortBy, SortOrder sortOrder) + { + return _inner.Sort(items, user, sortBy, sortOrder); + } + + public IEnumerable Sort(IEnumerable items, User? user, IEnumerable<(ItemSortBy OrderBy, SortOrder SortOrder)> orderBy) + { + return _inner.Sort(items, user, orderBy); + } + + public Task UpdateImagesAsync(BaseItem item, bool forceUpdate = false) + { + return _inner.UpdateImagesAsync(item, forceUpdate); + } + + public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken) + { + return _inner.UpdateItemAsync(item, parent, updateReason, cancellationToken); + } + + public Task UpdateItemsAsync(IReadOnlyList items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken) + { + return _inner.UpdateItemsAsync(items, parent, updateReason, cancellationToken); + } + + public void UpdateMediaPath(string virtualFolderName, MediaPathInfo mediaPath) + { + _inner.UpdateMediaPath(virtualFolderName, mediaPath); + } + + public void UpdatePeople(BaseItem item, List people) + { + _inner.UpdatePeople(item, people); + } + + public Task UpdatePeopleAsync(BaseItem item, List people, CancellationToken cancellationToken) + { + return _inner.UpdatePeopleAsync(item, people, cancellationToken); + } + + public Task ValidateMediaLibrary(IProgress progress, CancellationToken cancellationToken) + { + return _inner.ValidateMediaLibrary(progress, cancellationToken); + } + + public Task ValidatePeopleAsync(IProgress progress, CancellationToken cancellationToken) + { + return _inner.ValidatePeopleAsync(progress, cancellationToken); + } + + public Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false) + { + return _inner.ValidateTopLibraryFolders(cancellationToken, removeRoot); + } + + #endregion +} \ No newline at end of file diff --git a/CinemaJellyfin/CinemaMediaSourceManager.cs b/CinemaJellyfin/CinemaMediaSourceManager.cs index 211e156..fdcaad6 100644 --- a/CinemaJellyfin/CinemaMediaSourceManager.cs +++ b/CinemaJellyfin/CinemaMediaSourceManager.cs @@ -7,7 +7,6 @@ using System.Globalization; using System.Text; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Persistence; @@ -22,7 +21,7 @@ using MediaBrowser.Controller; namespace Jellyfin.Plugin.Cinema; -public class CinemaMediaSourceManager : IMediaSourceManager +public sealed class CinemaMediaSourceManager : IMediaSourceManager { private const bool IsWebshareFreeAccount = true; private const double BitrateMargin = 0.1; // 10 % @@ -30,6 +29,7 @@ public class CinemaMediaSourceManager : IMediaSourceManager private static readonly TimeSpan VersionValidityTimeout = TimeSpan.FromMinutes(180); private static readonly TimeSpan LinkValidityTimeout = VersionValidityTimeout; + internal static readonly TimeSpan SubfolderValidityTimeout = VersionValidityTimeout; private static CinemaMediaSourceManager? _instance; @@ -40,7 +40,7 @@ public class CinemaMediaSourceManager : IMediaSourceManager private readonly ConcurrentDictionary _videoVersions; private readonly ConcurrentDictionary _links; - public CinemaMediaSourceManager(ICinemaInnerMediaSourceManager innerMediaSourceManager, ILibraryManager libraryManager, IServerApplicationHost host, IServiceProvider svc, IHttpContextAccessor http) + public CinemaMediaSourceManager(CinemaInnerMediaSourceManager innerMediaSourceManager, ILibraryManager libraryManager, IServerApplicationHost host, IServiceProvider svc, IHttpContextAccessor http) { if (innerMediaSourceManager == null || svc == null) throw new ArgumentNullException(); @@ -148,7 +148,7 @@ public class CinemaMediaSourceManager : IMediaSourceManager HttpContext? ctx = _http.HttpContext; if (ctx == null || item == null - || !CinemaFilterFolder.IsCinemaExternalId(item.ExternalId) + || !CinemaQueryExtensions.IsCinemaExternalId(item.ExternalId) || string.IsNullOrEmpty(item.ExternalId)) return _inner.GetStaticMediaSources(item, enablePathSubstitution, user); @@ -167,7 +167,7 @@ public class CinemaMediaSourceManager : IMediaSourceManager HttpContext? ctx = _http.HttpContext; if (ctx == null || item == null - || !CinemaFilterFolder.IsCinemaExternalId(item.ExternalId) + || !CinemaQueryExtensions.IsCinemaExternalId(item.ExternalId) || string.IsNullOrEmpty(item.ExternalId)) return await _inner.GetPlaybackMediaSources(item, user, allowMediaProbe, enablePathSubstitution, cancellationToken); @@ -183,6 +183,7 @@ public class CinemaMediaSourceManager : IMediaSourceManager switch (video) { case CinemaMovie movie: items = GetVideoVersionsEnumerate(csId!, movie, videoPrimary!, ver.Versions); break; + case CinemaEpisode episode: items = GetVideoVersionsEnumerate(csId!, episode, videoPrimary!, ver.Versions); break; default: throw new NotSupportedException(string.Format("BaseItem type '{0}' not supported in CinemaMediaSources.", video.GetType().Name)); } @@ -249,7 +250,7 @@ public class CinemaMediaSourceManager : IMediaSourceManager } else { - a = CinemaFilterFolder.GetMediaItemById(csId, i.Meta._id, out bool isNew); + a = CinemaQueryExtensions.GetMediaItemById(csId, i.Meta._id, out bool isNew); if (isNew) { // Copy properties from the parent version @@ -275,7 +276,7 @@ public class CinemaMediaSourceManager : IMediaSourceManager if (item == null) throw new ArgumentNullException(); - if (!CinemaFilterFolder.IsCinemaExternalId(item.ExternalId) + if (!CinemaQueryExtensions.IsCinemaExternalId(item.ExternalId) || string.IsNullOrEmpty(item.ExternalId)) { // Not a Stream Cinema video @@ -292,7 +293,7 @@ public class CinemaMediaSourceManager : IMediaSourceManager primaryId = Guid.Parse(item.PrimaryVersionId, CultureInfo.InvariantCulture); primary = _libraryManager.GetItemById(primaryId); if (primary == null - || !CinemaFilterFolder.IsCinemaExternalId(primary.ExternalId) + || !CinemaQueryExtensions.IsCinemaExternalId(primary.ExternalId) || string.IsNullOrEmpty(primary.ExternalId)) { // Not a Stream Cinema video csId = null; @@ -308,7 +309,7 @@ public class CinemaMediaSourceManager : IMediaSourceManager externalId = item.ExternalId; } - if (!CinemaFilterFolder.TryGetCinemaIdFromExternalId(externalId, out csId)) + if (!CinemaQueryExtensions.TryGetCinemaIdFromExternalId(externalId, out csId)) throw new InvalidOperationException("Cannot parse Cinema identifier."); return GetVersionSet(primaryId, csId, cancel); @@ -352,7 +353,7 @@ public class CinemaMediaSourceManager : IMediaSourceManager id = primary!.Id; } else - id = CinemaFilterFolder.GetMediaItemId(csId!, i.Meta._id); + id = CinemaQueryExtensions.GetMediaItemId(csId!, i.Meta._id); if (id == item.Id) return i; diff --git a/CinemaJellyfin/CinemaMoviesFolder.cs b/CinemaJellyfin/CinemaMoviesFolder.cs index 42e92b6..9868a4d 100644 --- a/CinemaJellyfin/CinemaMoviesFolder.cs +++ b/CinemaJellyfin/CinemaMoviesFolder.cs @@ -1,7 +1,7 @@ using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; using CinemaLib.API; +using System.Diagnostics.CodeAnalysis; namespace Jellyfin.Plugin.Cinema; @@ -9,8 +9,8 @@ sealed class CinemaMoviesFolder : CinemaRootFolder { private readonly BaseItem _trending; private readonly BaseItem _popular; - private readonly BaseItem _mostWatched; - private readonly BaseItem _newReleases; + //private readonly BaseItem _mostWatched; + //private readonly BaseItem _newReleases; public CinemaMoviesFolder() { @@ -36,4 +36,9 @@ sealed class CinemaMoviesFolder : CinemaRootFolder //yield return _mostWatched!; //yield return _newReleases!; } + + public override bool TryCreateMediaItem(MediaSource? media, string csId, BaseItem parentFolder, [NotNullWhen(true)] out BaseItem? item) + { + return media.TryCreateMediaItem(csId, parentFolder, false, out item); + } } \ No newline at end of file diff --git a/CinemaJellyfin/CinemaMusicAlbum.cs b/CinemaJellyfin/CinemaMusicAlbum.cs new file mode 100644 index 0000000..00ed947 --- /dev/null +++ b/CinemaJellyfin/CinemaMusicAlbum.cs @@ -0,0 +1,68 @@ +using CinemaLib.API; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Model.Querying; + +namespace Jellyfin.Plugin.Cinema; + +/// +/// Music album folder item from Cinema. +/// +public sealed class CinemaMusicAlbum : MusicAlbum +{ + public sealed override string GetClientTypeName() => BaseItemKind.Series.ToString(); + + public override int GetChildCount(User user) + { + return base.GetChildCount(user); + } + + public override int GetRecursiveChildCount(User user) + { + return base.GetRecursiveChildCount(user); + } + + public override List GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) + { + return (List)GetItemsInternal(query).Items; + } + + protected override QueryResult GetItemsInternal(InternalItemsQuery query) + { + int offset = query.StartIndex ?? 0; + int limit = query.Limit ?? 0; + + List items = new List(); + QueryResult result = new QueryResult() { Items = items, StartIndex = offset }; + + FilterSortBy sortBy; + ItemOrder sortDir; + if (query.OrderBy.Count == 0) + { + sortBy = FilterSortBy.Episode; + sortDir = ItemOrder.Ascending; + } + else + { + (ItemSortBy sortByJ, SortOrder sortDirJ) = query.OrderBy.First(); + sortBy = sortByJ.ToCinema(); + sortDir = sortDirJ == SortOrder.Ascending ? ItemOrder.Ascending : ItemOrder.Descending; + } + + FilterResponse? filterRes = Metadata.ChildrenAsync(query.SearchTerm ?? "", order: sortDir, sort: sortBy, offset: offset, limit: limit).GetAwaiter().GetResult(); + if (filterRes != null && filterRes.hits != null && filterRes.hits.hits != null) + { + if (filterRes.hits.total != null) + result.TotalRecordCount = (int)filterRes.hits.total.value; + foreach (var i in filterRes.hits.hits) + { + if (i._source.TryCreateMediaItem(i._id, this, false, out BaseItem? a)) + items.Add(a); + } + } + + return result; + } +} \ No newline at end of file diff --git a/CinemaJellyfin/CinemaPopularFolder.cs b/CinemaJellyfin/CinemaPopularFolder.cs index 902772d..e9d9e5d 100644 --- a/CinemaJellyfin/CinemaPopularFolder.cs +++ b/CinemaJellyfin/CinemaPopularFolder.cs @@ -1,6 +1,7 @@ using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using CinemaLib.API; +using System.Diagnostics.CodeAnalysis; namespace Jellyfin.Plugin.Cinema; @@ -18,4 +19,9 @@ public sealed class CinemaPopularFolder : CinemaSortFolder { yield break; } + + public override bool TryCreateMediaItem(MediaSource? media, string csId, BaseItem parentFolder, [NotNullWhen(true)] out BaseItem? item) + { + return media.TryCreateMediaItem(csId, parentFolder, false, out item); + } } \ No newline at end of file diff --git a/CinemaJellyfin/CinemaQueryExtensions.cs b/CinemaJellyfin/CinemaQueryExtensions.cs new file mode 100644 index 0000000..1469b24 --- /dev/null +++ b/CinemaJellyfin/CinemaQueryExtensions.cs @@ -0,0 +1,334 @@ + + +using System.Diagnostics.CodeAnalysis; +using CinemaLib.API; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Model.Entities; + +namespace Jellyfin.Plugin.Cinema; + +static class CinemaQueryExtensions +{ + private const char VersionSeparator = '-'; + + public static FilterSortBy ToCinema(this ItemSortBy sortByJ) + { + switch (sortByJ) + { + default: + case ItemSortBy.Default: return FilterSortBy.Title; + case ItemSortBy.AiredEpisodeOrder: return FilterSortBy.Premiered; + //case ItemSortBy.Album: return + //case ItemSortBy.AlbumArtist: return + //case ItemSortBy.Artist: return + case ItemSortBy.DateCreated: return FilterSortBy.DateAdded; + case ItemSortBy.OfficialRating: return FilterSortBy.Score; + case ItemSortBy.DatePlayed: return FilterSortBy.LastSeen; + case ItemSortBy.PremiereDate: return FilterSortBy.Premiered; + case ItemSortBy.StartDate: return FilterSortBy.Premiered; + case ItemSortBy.SortName: return FilterSortBy.Title; + case ItemSortBy.Name: return FilterSortBy.Title; + case ItemSortBy.Random: return FilterSortBy.Trending; + //case ItemSortBy.Runtime: return + case ItemSortBy.CommunityRating: return FilterSortBy.Popularity; + case ItemSortBy.ProductionYear: return FilterSortBy.Year; + case ItemSortBy.PlayCount: return FilterSortBy.PlayCount; + case ItemSortBy.CriticRating: return FilterSortBy.Score; + //case ItemSortBy.IsFolder: + //case ItemSortBy.IsUnplayed: + //case ItemSortBy.IsPlayed: + case ItemSortBy.SeriesSortName: return FilterSortBy.Title; + //case ItemSortBy.VideoBitRate: + //case ItemSortBy.AirTime: + //case ItemSortBy.Studio: + //case ItemSortBy.IsFavoriteOrLiked: + case ItemSortBy.DateLastContentAdded: return FilterSortBy.LastChildrenDateAdded; + case ItemSortBy.SeriesDatePlayed: return FilterSortBy.LastChildPremiered; + //case ItemSortBy.ParentIndexNumber: + //case ItemSortBy.IndexNumber: + case ItemSortBy.SimilarityScore: return FilterSortBy.Score; + case ItemSortBy.SearchScore: return FilterSortBy.Score; + } + } + + /// + /// Tries to create a Jellyfin media item from the provided Cinema media item. + /// + /// Cinema media identifier. + /// Cinema metadata. + /// Jellyfin parent folder. + /// On success the created item. + /// True on success, false otherwise. + public static bool TryCreateMediaItem(this MediaSource? media, string csId, BaseItem parentFolder, bool allowContainer, [NotNullWhen(true)] out BaseItem? item) + where TContainerType : Folder, new() + { + if (media == null) + { + item = null; + return false; + } + + var parentFolderId = parentFolder.Id; + + bool isNew; + bool forceUpdate = false; + + bool isAudio = media.is_concert ?? false; + if (media.children_count != 0) + { + // Container + if (!allowContainer) { + item = null; + return false; + } + + item = GetMediaItemById(csId, null, out isNew); + + } + else if (isAudio) + { + // TODO determine if this is an AudioBook od Audio + item = GetMediaItemById(csId, null, out isNew); + + } + else if (media.info_labels?.mediatype == "episode") + { + item = GetMediaItemById(csId, null, out isNew); + + } + else + { + item = GetMediaItemById(csId, null, out isNew); + } + + if (isNew && media.info_labels != null) + { + item.RunTimeTicks = (long?)(media.info_labels.duration * TimeSpan.TicksPerSecond); + } + + if (isNew) + { + InfoLabelI18n? loc = GetLocalized(media); + item.Name = loc?.title ?? media.info_labels?.originaltitle; + item.Genres = media.info_labels?.genre?.ToArray(); + item.Studios = media.info_labels?.studio?.ToArray(); + item.CommunityRating = (float?)media.ratings?.overall?.rating; + item.Overview = loc?.plot; + // TODO + // item.IndexNumber = info.IndexNumber; + //item.ParentIndexNumber = info.ParentIndexNumber; + item.PremiereDate = media.info_labels?.premiered; + item.ProductionYear = media.info_labels?.year; + if (media.services != null) + { + item.ProviderIds = new Dictionary(); + ConvertProviderIds(media.services, item.ProviderIds); + } + //item.OfficialRating = info.OfficialRating; + item.DateCreated = media.info_labels?.dateadded ?? DateTime.UtcNow; + //item.Tags = info.Tags.ToArray(); + item.OriginalTitle = media.info_labels?.originaltitle; + + string? artUriS = loc?.art?.poster; + Uri? artUri; + if (artUriS != null) + { + artUri = new Uri(artUriS); + if (Metadata.TryGetThumbnail(artUri, 128, 128, out Uri? artThumbUri)) + { + item.SetImagePath(ImageType.Thumb, artThumbUri.ToString()); + } + item.SetImagePath(ImageType.Primary, artUri.ToString()); + } + + artUriS = loc?.art?.fanart; + if (artUriS != null) + { + artUri = new Uri(artUriS); + item.SetImagePath(ImageType.Backdrop, artUri.ToString()); + } + } + + // Indicate just HTTP CinemaMediaSourceManager and CinemaMediaSourceController will handle the rest + item.Path = "https://a/b"; + + if (item is IHasArtist hasArtists) + { + hasArtists.Artists = media.info_labels?.writer; + } + + if (item is IHasAlbumArtist hasAlbumArtists) + { + hasAlbumArtists.AlbumArtists = media.info_labels?.director; + } + + item.ParentId = parentFolderId; + + /* + if (item is IHasSeries hasSeries) + { + if (!string.Equals(hasSeries.SeriesName, info.SeriesName, StringComparison.OrdinalIgnoreCase)) + { + forceUpdate = true; + _logger.LogDebug("Forcing update due to SeriesName {0}", item.Name); + } + + hasSeries.SeriesName = info.SeriesName; + }*/ + + item.ExternalId = CinemaIdToExternalId(csId); + + if (item is ICinemaChildrenCount childrenCount) { + childrenCount.ChildrenCount = media.children_count; + childrenCount.TotalChildrenCount = media.total_children_count; + } + + /* + if (item is Audio channelAudioItem) + { + channelAudioItem.ExtraType = info.ExtraType; + + var mediaSource = info.MediaSources.FirstOrDefault(); + item.Path = mediaSource?.Path; + } + + if (item is Video channelVideoItem) + { + channelVideoItem.ExtraType = info.ExtraType; + + var mediaSource = info.MediaSources.FirstOrDefault(); + item.Path = mediaSource?.Path; + }*/ + + item.OnMetadataChanged(); + + if (isNew) + { + // HACK: We use RegisterItem that is volatile intead of CreateItem + //_libraryManager.CreateItem(item, parentFolder); + CinemaHost.LibraryManager.RegisterItem(item); + + if (media.cast != null && media.cast.Count > 0) + { + //await _libraryManager.UpdatePeopleAsync(item, info.People, cancellationToken).ConfigureAwait(false); + } + } + else if (forceUpdate) + { + // HACK our items are volatile + //item.UpdateToRepositoryAsync(ItemUpdateType.None, default).GetAwaiter().GetResult(); + } + + /* + if ((isNew || forceUpdate) && info.Type == ChannelItemType.Media) + { + if (enableMediaProbe && !info.IsLiveStream && item.HasPathProtocol) + { + await SaveMediaSources(item, new List()).ConfigureAwait(false); + } + else + { + await SaveMediaSources(item, info.MediaSources).ConfigureAwait(false); + } + }*/ + + /* + if (isNew || forceUpdate || item.DateLastRefreshed == default) + { + _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.Normal); + }*/ + + return true; + } + + private static void ConvertProviderIds(ServicesIds services, Dictionary dest) + { + if (services.imdb != null) + dest.Add("Imdb", services.imdb); + if (services.tvdb != null) + dest.Add("Tvdb", services.tvdb); + if (services.tmdb != null) + dest.Add("Tmdb", services.tmdb); + + //public string? csfd { get; set; } + //public string? trakt { get; set; } + //public string? trakt_with_type { get; set; } + //public string? slug { get; set; } + } + + private static InfoLabelI18n? GetLocalized(MediaSource media) + { + if (media.i18n_info_labels == null) + return null; + InfoLabelI18n? first = null; + InfoLabelI18n? preferred = null; + InfoLabelI18n? fallback = null; + foreach (InfoLabelI18n i in media.i18n_info_labels) + if (i.lang == CinemaHost.PreferredCulture) + preferred = i; + else if (i.lang == CinemaHost.FallbackCulture) + fallback = i; + else if (first == null) + first = i; + + return preferred ?? fallback ?? first; + } + + /// + /// Calculates Jellyfin item identifier from Cinema identifier, optionally also with version identifier. + /// + internal static Guid GetMediaItemId(string csPrimaryId, string? csVersionId) + { + string idS = csVersionId == null ? csPrimaryId : (csPrimaryId + VersionSeparator + csVersionId); + return CinemaHost.LibraryManager.GetNewItemId(idS, typeof(CinemaPlugin)); + } + + /// + /// Creates a new media item (non-folder) with a proper unique identifier. + /// + internal static T GetMediaItemById(string csPrimaryId, string? csVersionId, out bool isNew) + where T : BaseItem, new() + { + Guid id = GetMediaItemId(csPrimaryId, csVersionId); + T? item = CinemaHost.LibraryManager.GetItemById(id) as T; + + if (item == null) + { + item = new T(); + isNew = true; + } + else + { + isNew = false; + } + + item.Id = id; + return item; + } + + internal static string CinemaIdToExternalId(string csId) + { + return CinemaPlugin.CinemaExtIdPrefix + csId; + } + + internal static bool IsCinemaExternalId(string? externalId) + { + return externalId != null && externalId.StartsWith(CinemaPlugin.CinemaExtIdPrefix); + } + + internal static bool TryGetCinemaIdFromExternalId(string? externalId, [NotNullWhen(true)] out string? csId) + { + if (IsCinemaExternalId(externalId)) + { + csId = externalId!.Substring(CinemaPlugin.CinemaExtIdPrefix.Length); + return true; + } + else + { + csId = null; + return false; + } + } +} \ No newline at end of file diff --git a/CinemaJellyfin/CinemaSeason.cs b/CinemaJellyfin/CinemaSeason.cs new file mode 100644 index 0000000..41f333f --- /dev/null +++ b/CinemaJellyfin/CinemaSeason.cs @@ -0,0 +1,69 @@ +using System.Globalization; +using CinemaLib.API; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Querying; + +namespace Jellyfin.Plugin.Cinema; + +/// +/// Season folder item from Cinema. +/// +public sealed class CinemaSeason : Season, ICinemaChildrenCount +{ + private CinemaSubfolderHelper _helper; + + public CinemaSeason() { + this._helper = new CinemaSubfolderHelper(this, FilterSortBy.Episode); + } + + #region ICinemaChildrenCountMembers + + public int ChildrenCount + { + get => _helper.ChildrenCount; + set => _helper.ChildrenCount = value; + } + + public int TotalChildrenCount { + get => _helper.TotalChildrenCount; + set => _helper.TotalChildrenCount = value; + } + + #endregion + + public sealed override string GetClientTypeName() => BaseItemKind.Season.ToString(); + + public override int GetChildCount(User user) + { + return _helper.ChildrenCount; + } + + public override int GetRecursiveChildCount(User user) + { + return _helper.TotalChildrenCount; + } + + /// + /// Always false as it causes iteration of all our items that degrades performance + /// in the CinemaTvShowsFolder. + /// + public override bool SupportsPlayedStatus => false; + + /// + /// Let the hack in find us. + /// + public override string CreatePresentationUniqueKey() => Id.ToString("N", CultureInfo.InvariantCulture); + + public override List GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) + { + return (List)_helper.GetItemsInternal(query, false).Items; + } + + protected override QueryResult GetItemsInternal(InternalItemsQuery query) + { + return _helper.GetItemsInternal(query, false); + } +} \ No newline at end of file diff --git a/CinemaJellyfin/CinemaServiceRegistrator.cs b/CinemaJellyfin/CinemaServiceRegistrator.cs index 6761501..1f35107 100644 --- a/CinemaJellyfin/CinemaServiceRegistrator.cs +++ b/CinemaJellyfin/CinemaServiceRegistrator.cs @@ -21,16 +21,16 @@ public class CinemaServiceRegistrator : IPluginServiceRegistrator // HACK: Replace IMediaSourceManager as CinemaMediaSourceProvider is not called soon enough while // entering a media item page. - Type tIf = typeof(IMediaSourceManager); + Type tMsm = typeof(IMediaSourceManager); int count = serviceCollection.Count; for (int i = 0; i < count; i++) { ServiceDescriptor a = serviceCollection[i]; - if (a.ServiceType == tIf && !a.IsKeyedService) + if (a.ServiceType == tMsm && !a.IsKeyedService) { Type oldImplType = a.ImplementationType!; serviceCollection.RemoveAt(i); - serviceCollection.AddSingleton(new CinemaInnerMediaSourceManager(oldImplType)); + serviceCollection.AddSingleton(new CinemaInnerMediaSourceManager(oldImplType)); serviceCollection.AddSingleton(oldImplType, oldImplType); // Replace IMediaSourceManager but also need direct access to the CinemaMediaSourceManager if it itself // also gets replaced in a longer chain @@ -39,5 +39,26 @@ public class CinemaServiceRegistrator : IPluginServiceRegistrator break; } } + + // HACK: Replace ILibraryManager as Series.GetEpisodes is not virtual + Type tLm = typeof(ILibraryManager); + int count2 = serviceCollection.Count; + for (int i = 0; i < count2; i++) + { + ServiceDescriptor a = serviceCollection[i]; + if (a.ServiceType == tLm && !a.IsKeyedService) + { + Type oldImplType = a.ImplementationType!; + serviceCollection.RemoveAt(i); + serviceCollection.AddSingleton(new CinemaInnerLibraryManager(oldImplType)); + serviceCollection.AddSingleton(oldImplType, oldImplType); + // Replace IMediaSourceManager but also need direct access to the CinemaLibraryManager if it itself + // also gets replaced in a longer chain + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(x => x.GetRequiredService()); + break; + } + } + } } \ No newline at end of file diff --git a/CinemaJellyfin/CinemaSubfolderHelper.cs b/CinemaJellyfin/CinemaSubfolderHelper.cs new file mode 100644 index 0000000..91f0c87 --- /dev/null +++ b/CinemaJellyfin/CinemaSubfolderHelper.cs @@ -0,0 +1,100 @@ +using CinemaLib.API; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Querying; + +namespace Jellyfin.Plugin.Cinema; + +/// +/// Helper data for Cinema items that inherit from a . +/// +struct CinemaSubfolderHelper +{ + private readonly Folder @this; + private readonly FilterSortBy _sortBy; + + private int? _childrenCount; + private int? _totalChildrenCount; + private FilterResponse? _cached; + private DateTime _cacheValidTo; + + internal CinemaSubfolderHelper(Folder @this, FilterSortBy sortBy) + { + this.@this = @this; + this._sortBy = sortBy; + } + + public int ChildrenCount + { + get + { + if (_childrenCount == 0) + EnsureCached(); + return _childrenCount ?? 0; + } + set + { + _childrenCount = value; + } + } + + public int TotalChildrenCount + { + get + { + if (_totalChildrenCount == 0) + EnsureCached(); + return _totalChildrenCount ?? 0; + } + set + { + _totalChildrenCount = value; + } + } + + internal QueryResult GetItemsInternal(InternalItemsQuery query, bool allowSubfolders) + where TContainerType : Folder, new() + { + FilterResponse filterRes = EnsureCached(); + List items = new List(); + QueryResult result = new QueryResult() { Items = items }; + if (filterRes != null && filterRes.hits != null && filterRes.hits.hits != null) + { + if (filterRes.hits.total != null) + result.TotalRecordCount = (int)filterRes.hits.total.value; + + foreach (var i in filterRes.hits.hits) + { + if (i._source.TryCreateMediaItem(i._id, @this, allowSubfolders, out BaseItem? a)) + items.Add(a); + } + } + return result; + } + + private FilterResponse EnsureCached() + { + DateTime now = DateTime.UtcNow; + if (_cached != null && _cacheValidTo > now) + return _cached; + + string? csId; + if (!CinemaQueryExtensions.TryGetCinemaIdFromExternalId(@this.ExternalId, out csId)) + throw new InvalidOperationException("Expected Cinema ID to be present."); + + FilterResponse? filterRes = Metadata.ChildrenAsync(csId, order: ItemOrder.Ascending, sort: _sortBy).GetAwaiter().GetResult(); + if (filterRes != null && filterRes.hits != null && filterRes.hits.hits != null) + { + _childrenCount = filterRes.hits.hits.Count; + _totalChildrenCount = filterRes.hits.total != null ? (int)filterRes.hits.total.value : 0; + } + else + { + _childrenCount = 0; + _totalChildrenCount = 0; + } + + _cached = filterRes ?? new FilterResponse(); + _cacheValidTo = now + CinemaMediaSourceManager.SubfolderValidityTimeout; + return _cached; + } +} \ No newline at end of file diff --git a/CinemaJellyfin/CinemaTrendingFolder.cs b/CinemaJellyfin/CinemaTrendingFolder.cs index fefbc30..d73f6fe 100644 --- a/CinemaJellyfin/CinemaTrendingFolder.cs +++ b/CinemaJellyfin/CinemaTrendingFolder.cs @@ -1,6 +1,7 @@ using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using CinemaLib.API; +using System.Diagnostics.CodeAnalysis; namespace Jellyfin.Plugin.Cinema; @@ -18,4 +19,9 @@ public sealed class CinemaTrendingFolder : CinemaSortFolder { yield break; } + + public override bool TryCreateMediaItem(MediaSource? media, string csId, BaseItem parentFolder, [NotNullWhen(true)] out BaseItem? item) + { + return media.TryCreateMediaItem(csId, parentFolder, false, out item); + } } \ No newline at end of file diff --git a/CinemaJellyfin/CinemaTvSeries.cs b/CinemaJellyfin/CinemaTvSeries.cs new file mode 100644 index 0000000..dc5e0ff --- /dev/null +++ b/CinemaJellyfin/CinemaTvSeries.cs @@ -0,0 +1,78 @@ +using System.Globalization; +using CinemaLib.API; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Querying; + +namespace Jellyfin.Plugin.Cinema; + +/// +/// TV Series folder item from Cinema. +/// +public sealed class CinemaTvSeries : Series, ICinemaChildrenCount +{ + private CinemaSubfolderHelper _helper; + + public CinemaTvSeries() { + this._helper = new CinemaSubfolderHelper(this, FilterSortBy.Episode); + } + + #region ICinemaChildrenCountMembers + + public int ChildrenCount + { + get => _helper.ChildrenCount; + set => _helper.ChildrenCount = value; + } + + public int TotalChildrenCount { + get => _helper.TotalChildrenCount; + set => _helper.TotalChildrenCount = value; + } + + #endregion + + public sealed override string GetClientTypeName() => BaseItemKind.Series.ToString(); + + public override int GetChildCount(User user) + { + return _helper.ChildrenCount; + } + + public override int GetRecursiveChildCount(User user) + { + return _helper.TotalChildrenCount; + } + + /// + /// Always false as it causes iteration of all our items that degrades performance + /// in the CinemaTvShowsFolder. + /// + public override bool SupportsPlayedStatus => false; + + /// + /// Let the hack in find us. + /// + public override string CreatePresentationUniqueKey() => Id.ToString("N", CultureInfo.InvariantCulture); + + public override List GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) + { + return (List)GetItemsInternal(query).Items; + } + + protected override QueryResult GetItemsInternal(InternalItemsQuery query) + { + QueryResult result = _helper.GetItemsInternal(query, true); + + // Set series identifier on all seasons + foreach (var i in result.Items) + if (i is CinemaSeason season) { + season.SeriesId = Id; + season.SeriesName = Name; + } + + return result; + } +} \ No newline at end of file diff --git a/CinemaJellyfin/CinemaTvShowsFolder.cs b/CinemaJellyfin/CinemaTvShowsFolder.cs new file mode 100644 index 0000000..d470a0c --- /dev/null +++ b/CinemaJellyfin/CinemaTvShowsFolder.cs @@ -0,0 +1,33 @@ +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using CinemaLib.API; +using System.Diagnostics.CodeAnalysis; + +namespace Jellyfin.Plugin.Cinema; + +sealed class CinemaTvShowsFolder : CinemaRootFolder +{ + public CinemaTvShowsFolder() + { + } + + public override CollectionType? CollectionType => Data.Enums.CollectionType.tvshows; + + public override BaseItemKind ClientType => BaseItemKind.Series; + + public override ItemType ItemType => ItemType.TVShow; + + internal override string ImageName => "tvshow.png"; + + protected override IEnumerable GetFilterItems() + { + // Root items + // none + yield break; + } + + public override bool TryCreateMediaItem(MediaSource? media, string csId, BaseItem parentFolder, [NotNullWhen(true)] out BaseItem? item) + { + return media.TryCreateMediaItem(csId, parentFolder, true, out item); + } +} \ No newline at end of file diff --git a/CinemaJellyfin/ICinemaChildrenCount.cs b/CinemaJellyfin/ICinemaChildrenCount.cs new file mode 100644 index 0000000..293936d --- /dev/null +++ b/CinemaJellyfin/ICinemaChildrenCount.cs @@ -0,0 +1,6 @@ +namespace Jellyfin.Plugin.Cinema; + +public interface ICinemaChildrenCount { + int ChildrenCount { get; set; } + int TotalChildrenCount { get; set; } +} \ No newline at end of file diff --git a/CinemaJellyfin/ICinemaInnerMediaSourceManager.cs b/CinemaJellyfin/ICinemaInnerMediaSourceManager.cs deleted file mode 100644 index 69cff52..0000000 --- a/CinemaJellyfin/ICinemaInnerMediaSourceManager.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Jellyfin.Plugin.Cinema; - -public interface ICinemaInnerMediaSourceManager { - Type InnerType { get; } -} \ No newline at end of file diff --git a/CinemaLib/API/Metadata.cs b/CinemaLib/API/Metadata.cs index 4272b33..c433d46 100644 --- a/CinemaLib/API/Metadata.cs +++ b/CinemaLib/API/Metadata.cs @@ -37,12 +37,6 @@ public class Metadata if (limit == 0 || limit > MaxPageLimit) limit = MaxPageLimit; - /* - bool noSearchTerms = expression.Trim().Length == 0; - UriBuilder uri = new UriBuilder(new Uri(ApiFilter, noSearchTerms ? "all" : "search")); - uri.Query = $"?access_token={AccessToken}&value={Uri.EscapeDataString(expression)}&order={ToString(order)}&sort={ToString(sort)}&type={ToString(type)}&from={offset.ToString()}&size={limit.ToString()}"; - */ - bool noSearchTerms = expression.Trim().Length == 0; string filterName; string sortS = ToString(sort); @@ -73,13 +67,13 @@ public class Metadata /// Result ordering column. /// Asynchronous cancellation. /// Response. - public async static Task ChildrenAsync(string parentId, FilterSortBy sort = FilterSortBy.Episode, CancellationToken cancel = default) + public async static Task ChildrenAsync(string parentId, ItemOrder order = ItemOrder.Descending, FilterSortBy sort = FilterSortBy.Episode, int offset = 0, int limit = 0, CancellationToken cancel = default) { if (parentId == null) throw new ArgumentNullException(); UriBuilder uri = new UriBuilder(new Uri(ApiFilter, "parent")); - uri.Query = $"?access_token={AccessToken}&value={parentId}&sort={ToString(sort)}&size={MaxPageLimit.ToString()}"; + uri.Query = $"?access_token={AccessToken}&value={parentId}&order={ToString(order)}&sort={ToString(sort)}&from={offset.ToString()}&size={limit.ToString()}"; FilterResponse? result = await Program._http.GetFromJsonAsync(uri.Uri, CreateFilterJsonOptions(), cancel); if (result != null)