All checks were successful
continuous-integration/drone/push Build is passing
487 lines
15 KiB
C#
487 lines
15 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
using System.Globalization;
|
|
using Jellyfin.Data.Enums;
|
|
using MediaBrowser.Controller.Entities;
|
|
using MediaBrowser.Controller.Entities.Audio;
|
|
using MediaBrowser.Controller.Providers;
|
|
using MediaBrowser.Model.Entities;
|
|
using MediaBrowser.Model.Querying;
|
|
using CinemaLib.API;
|
|
|
|
namespace Jellyfin.Plugin.Cinema;
|
|
|
|
/// <summary>
|
|
/// Stream Cinema folder that also implicitly represents a filter (minimally filter by <see cref="ItemType"/>).
|
|
/// </summary>
|
|
public abstract class CinemaFilterFolder : Folder
|
|
{
|
|
private const string PreferredCulture = "cs";
|
|
private const string FallbackCulture = "en";
|
|
private const char VersionSeparator = '-';
|
|
|
|
/// <summary>
|
|
/// Filtering folders can never be deleted.
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
public sealed override bool CanDelete()
|
|
{
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Type of the collection as reported to the web client so it knows what feaures (mostly filtering ones) to render.
|
|
/// </summary>
|
|
public abstract BaseItemKind ClientType { get; }
|
|
|
|
/// <summary>
|
|
/// Forwards <see cref="ClientType"/>.
|
|
/// </summary>
|
|
public sealed override string GetClientTypeName() => ClientType.ToString();
|
|
|
|
/// <summary>
|
|
/// Stream Cinema item type in this folder. Shall follow <see cref="ClientType"/> as faithfully
|
|
/// as possible. Used for filtering.
|
|
/// </summary>
|
|
public abstract ItemType ItemType { get; }
|
|
|
|
/// <summary>
|
|
/// Information for <see cref="CinemaImageProvider"/> to render icon for us.
|
|
/// </summary>
|
|
internal abstract string ImageName { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the static filter folders.
|
|
/// </summary>
|
|
protected abstract IEnumerable<BaseItem> GetFilterItems();
|
|
|
|
/// <summary>
|
|
/// Gets static as well as dynamic items from Stream Cinema database.
|
|
/// </summary>
|
|
protected sealed override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query)
|
|
{
|
|
int offset = query.StartIndex ?? 0;
|
|
int limit = query.Limit ?? 0;
|
|
|
|
List<BaseItem> items = new List<BaseItem>();
|
|
QueryResult<BaseItem> result = new QueryResult<BaseItem>() { Items = items, StartIndex = offset };
|
|
|
|
// Static items
|
|
if (string.IsNullOrEmpty(query.SearchTerm))
|
|
{
|
|
foreach (BaseItem i in GetFilterItems())
|
|
items.Add(i);
|
|
int staticCount = items.Count;
|
|
if (offset <= items.Count)
|
|
{
|
|
items.RemoveRange(0, offset);
|
|
limit -= items.Count;
|
|
offset = 0;
|
|
}
|
|
else
|
|
{
|
|
items.Clear();
|
|
offset -= staticCount;
|
|
}
|
|
}
|
|
|
|
// Filtered content items
|
|
FilterSortBy sortBy;
|
|
ItemOrder sortDir;
|
|
if (query.OrderBy.Count == 0)
|
|
{
|
|
sortBy = FilterSortBy.Title;
|
|
sortDir = ItemOrder.Ascending;
|
|
}
|
|
else
|
|
{
|
|
(ItemSortBy sortByJ, SortOrder sortDirJ) = query.OrderBy.First();
|
|
sortBy = ConvertSort(sortByJ);
|
|
sortDir = sortDirJ == SortOrder.Ascending ? ItemOrder.Ascending : ItemOrder.Descending;
|
|
}
|
|
|
|
FilterResponse? filterRes = Metadata.SearchAsync(query.SearchTerm ?? "", order: sortDir, sort: sortBy, type: ItemType, 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 (TryCreateMediaItem(i._id, i._source, this, out BaseItem? a))
|
|
items.Add(a);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets an already persisted instance or creates a new instance of the given child filter folder.
|
|
/// </summary>
|
|
/// <typeparam name="T">Type of the folder.</typeparam>
|
|
/// <param name="localizedName">Culture localized name of the folder.</param>
|
|
protected T CreateChildFolder<T>(string localizedName) where T : CinemaFilterFolder, new()
|
|
{
|
|
return CreateFilterFolderInternal<T>(this, localizedName);
|
|
}
|
|
/// <summary>
|
|
/// Gets an already persisted instance or creates a new instance of the given filter folder.
|
|
/// </summary>
|
|
/// <typeparam name="T">Type of the folder.</typeparam>
|
|
/// <param name="parent">Parent folder.</param>
|
|
/// <param name="localizedName">Culture localized name of the folder.</param>
|
|
internal static T CreateRootFilterFolder<T>(string localizedName) where T : CinemaRootFolder, new()
|
|
{
|
|
return CreateFilterFolderInternal<T>(null, localizedName);
|
|
}
|
|
|
|
private static T CreateFilterFolderInternal<T>(CinemaFilterFolder? parent, string localizedName) where T : CinemaFilterFolder, new()
|
|
{
|
|
Guid folderId = CinemaHost.LibraryManager.GetNewItemId("folder", typeof(T));
|
|
string folderPath = GetInternalMetadataPath(CinemaHost.InternalMetadataPath!, folderId);
|
|
Directory.CreateDirectory(folderPath);
|
|
|
|
T? folder = CinemaHost.LibraryManager.GetItemById(folderId) as T;
|
|
bool isNew;
|
|
bool forceUpdate = false;
|
|
if (isNew = folder == null)
|
|
{
|
|
folder = new T
|
|
{
|
|
Id = folderId,
|
|
Name = localizedName,
|
|
DateCreated = CinemaHost.FileSystem.GetCreationTimeUtc(folderPath),
|
|
DateModified = CinemaHost.FileSystem.GetLastWriteTimeUtc(folderPath)
|
|
};
|
|
}
|
|
|
|
folder.Id = folderId;
|
|
folder.Path = folderPath;
|
|
if (parent == null)
|
|
{
|
|
folder.ParentId = Guid.Empty;
|
|
folder.IsRoot = true;
|
|
CinemaHost.LibraryManager.RootFolder.AddVirtualChild(folder);
|
|
}
|
|
else
|
|
{
|
|
folder.ParentId = parent.Id;
|
|
}
|
|
|
|
if (isNew)
|
|
{
|
|
folder.OnMetadataChanged();
|
|
CinemaHost.LibraryManager.CreateItem(folder, parent);
|
|
}
|
|
|
|
folder.RefreshMetadata(
|
|
new MetadataRefreshOptions(new DirectoryService(CinemaHost.FileSystem)) { ForceSave = !isNew && forceUpdate },
|
|
default
|
|
).GetAwaiter().GetResult();
|
|
|
|
return folder;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tries to create a Jellyfin media item from the provided Cinema Stream media item.
|
|
/// </summary>
|
|
/// <param name="csId">Cinema Stream media identifier.</param>
|
|
/// <param name="media">Cinema Stream metadata.</param>
|
|
/// <param name="parentFolder">Jellyfin parent folder.</param>
|
|
/// <param name="item">On success the created item.</param>
|
|
/// <returns>True on success, false otherwise.</returns>
|
|
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<MediaBrowser.Controller.Entities.Audio.MusicAlbum>(csId, null, out isNew);
|
|
else if (singleLevel)
|
|
item = GetMediaItemById<MediaBrowser.Controller.Entities.TV.Season>(csId, null, out isNew);
|
|
else
|
|
item = GetMediaItemById<MediaBrowser.Controller.Entities.TV.Series>(csId, null, out isNew);
|
|
|
|
}
|
|
else if (isAudio)
|
|
{
|
|
// TODO determine if this is an AudioBook od Audio
|
|
item = GetMediaItemById<MediaBrowser.Controller.Entities.Audio.Audio>(csId, null, out isNew);
|
|
|
|
}
|
|
else if (media.info_labels?.mediatype == "tvshow")
|
|
{
|
|
item = GetMediaItemById<MediaBrowser.Controller.Entities.TV.Episode>(csId, null, out isNew);
|
|
|
|
}
|
|
else
|
|
{
|
|
item = GetMediaItemById<CinemaMovie>(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;
|
|
item.ProviderIds = new Dictionary<string, string>();
|
|
if (media.services != null)
|
|
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());
|
|
}
|
|
}
|
|
|
|
// Identify the item as it originates from Cinema
|
|
// Note: This is getting lost in persisted items
|
|
if (item.ProviderIds == null)
|
|
item.ProviderIds = new Dictionary<string, string>();
|
|
item.ProviderIds[CinemaPlugin.CinemaProviderName] = "";
|
|
|
|
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 = 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<MediaSourceInfo>()).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<string, string> 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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates Jellyfin item identifier from CinemaStream identifier, optionally also with version identifier.
|
|
/// </summary>
|
|
internal static Guid GetMediaItemId(string csPrimaryId, string? csVersionId) {
|
|
string idS = csVersionId == null ? csPrimaryId : (csPrimaryId + VersionSeparator + csVersionId);
|
|
return CinemaHost.LibraryManager.GetNewItemId(idS, typeof(CinemaPlugin));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new media item (non-folder) with a proper unique identifier.
|
|
/// </summary>
|
|
internal static T GetMediaItemById<T>(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;
|
|
}
|
|
} |