Movie folder view, sort and search works
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
@@ -1,13 +1,456 @@
|
||||
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 StreamCinemaLib.API;
|
||||
|
||||
namespace Jellyfin.Plugin.StreamCinema;
|
||||
|
||||
/// <summary>
|
||||
/// Stream Cinema folder that also implicitly represents a filter (minimally filter by <see cref="ItemType"/>).
|
||||
/// </summary>
|
||||
public abstract class StreamCinemaFilterFolder : Folder
|
||||
{
|
||||
public sealed override bool CanDelete()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
private const string PreferredCulture = "cs";
|
||||
private const string FallbackCulture = "en";
|
||||
|
||||
internal abstract string ImageName { get; }
|
||||
/// <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="StreamCinemaImageProvider"/> 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 : StreamCinemaFilterFolder, 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 : StreamCinemaRootFolder, new()
|
||||
{
|
||||
return CreateFilterFolderInternal<T>(null, localizedName);
|
||||
}
|
||||
|
||||
private static T CreateFilterFolderInternal<T>(StreamCinemaFilterFolder? parent, string localizedName) where T : StreamCinemaFilterFolder, new()
|
||||
{
|
||||
Guid folderId = StreamCinemaHost.LibraryManager.GetNewItemId("folder", typeof(T));
|
||||
string folderPath = GetInternalMetadataPath(StreamCinemaHost.InternalMetadataPath!, folderId);
|
||||
Directory.CreateDirectory(folderPath);
|
||||
|
||||
T? folder = StreamCinemaHost.LibraryManager.GetItemById(folderId) as T;
|
||||
bool isNew;
|
||||
bool forceUpdate = false;
|
||||
if (isNew = folder == null)
|
||||
{
|
||||
folder = new T
|
||||
{
|
||||
Id = folderId,
|
||||
Name = localizedName,
|
||||
DateCreated = StreamCinemaHost.FileSystem.GetCreationTimeUtc(folderPath),
|
||||
DateModified = StreamCinemaHost.FileSystem.GetLastWriteTimeUtc(folderPath)
|
||||
};
|
||||
}
|
||||
|
||||
folder.Id = folderId;
|
||||
folder.Path = folderPath;
|
||||
if (parent == null)
|
||||
{
|
||||
folder.ParentId = Guid.Empty;
|
||||
folder.IsRoot = true;
|
||||
StreamCinemaHost.LibraryManager.RootFolder.AddVirtualChild(folder);
|
||||
}
|
||||
else
|
||||
{
|
||||
folder.ParentId = parent.Id;
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
folder.OnMetadataChanged();
|
||||
StreamCinemaHost.LibraryManager.CreateItem(folder, parent);
|
||||
}
|
||||
|
||||
folder.RefreshMetadata(
|
||||
new MetadataRefreshOptions(new DirectoryService(StreamCinemaHost.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, out isNew);
|
||||
else if (singleLevel)
|
||||
item = GetMediaItemById<MediaBrowser.Controller.Entities.TV.Season>(csId, out isNew);
|
||||
else
|
||||
item = GetMediaItemById<MediaBrowser.Controller.Entities.TV.Series>(csId, out isNew);
|
||||
|
||||
}
|
||||
else if (isAudio)
|
||||
{
|
||||
// TODO determine if this is an AudioBook od Audio
|
||||
item = GetMediaItemById<MediaBrowser.Controller.Entities.Audio.Audio>(csId, out isNew);
|
||||
|
||||
}
|
||||
else if (media.info_labels?.mediatype == "tvshow")
|
||||
{
|
||||
item = GetMediaItemById<MediaBrowser.Controller.Entities.TV.Episode>(csId, out isNew);
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
item = GetMediaItemById<MediaBrowser.Controller.Entities.Movies.Movie>(csId, 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 = media.services != null ? ConvertProviderIds(media.services) : null;
|
||||
//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 ?? loc?.art?.fanart;
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
StreamCinemaHost.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 Dictionary<string, string> ConvertProviderIds(ServicesIds services)
|
||||
{
|
||||
Dictionary<string, string> result = new Dictionary<string, string>();
|
||||
if (services.imdb != null)
|
||||
result.Add("Imdb", services.imdb);
|
||||
if (services.tvdb != null)
|
||||
result.Add("Tvdb", services.tvdb);
|
||||
if (services.tmdb != null)
|
||||
result.Add("Tmdb", services.tmdb);
|
||||
return result;
|
||||
|
||||
//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, "streamcinema", id.ToString("N", CultureInfo.InvariantCulture), "metadata");
|
||||
}
|
||||
|
||||
private T GetMediaItemById<T>(string idString, out bool isNew)
|
||||
where T : BaseItem, new()
|
||||
{
|
||||
Guid id = StreamCinemaHost.LibraryManager.GetNewItemId(idString, typeof(StreamCinemaPlugin));
|
||||
T? item = StreamCinemaHost.LibraryManager.GetItemById(id) as T;
|
||||
|
||||
if (item == null)
|
||||
{
|
||||
item = new T();
|
||||
isNew = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
isNew = false;
|
||||
}
|
||||
|
||||
item.Id = id;
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -12,82 +12,48 @@ namespace Jellyfin.Plugin.StreamCinema;
|
||||
/// </summary>
|
||||
public sealed class StreamCinemaHost : IHostedService
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IServerConfigurationManager _config;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILogger<StreamCinemaHost> _logger;
|
||||
#pragma warning disable CS8618
|
||||
// This instance is specially registered and gets created before all classes
|
||||
// except StreamCinemaSeccviceRegistrator and StreamCinemaPlugin.
|
||||
private static ILibraryManager _libraryManager;
|
||||
private static IServerConfigurationManager _config;
|
||||
private static IFileSystem _fileSystem;
|
||||
#pragma warning restore CS8618
|
||||
private readonly ILogger<StreamCinemaHost> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a the Stream Cinema plugin.
|
||||
/// </summary>
|
||||
public StreamCinemaHost(ILibraryManager libraryManager, IServerConfigurationManager config, IFileSystem fileSystem, ILogger<StreamCinemaHost> logger)
|
||||
{
|
||||
this._libraryManager = libraryManager;
|
||||
this._config = config;
|
||||
this._fileSystem = fileSystem;
|
||||
this._logger = logger;
|
||||
}
|
||||
/// <summary>
|
||||
/// Initializes a the Stream Cinema plugin.
|
||||
/// </summary>
|
||||
public StreamCinemaHost(ILibraryManager libraryManager, IServerConfigurationManager config, IFileSystem fileSystem, ILogger<StreamCinemaHost> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_config = config;
|
||||
_fileSystem = fileSystem;
|
||||
this._logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Make sure the Stream Cinema root folders are created
|
||||
CreateRoot<StreamCinemaMoviesFolder>("movies", "Movies", _libraryManager, _config, _fileSystem);
|
||||
public static ILibraryManager LibraryManager => _libraryManager;
|
||||
|
||||
/*
|
||||
public static string InternalMetadataPath => _config.ApplicationPaths.InternalMetadataPath;
|
||||
|
||||
public static IFileSystem FileSystem => _fileSystem;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Make sure the Stream Cinema root folders are created
|
||||
StreamCinemaFilterFolder.CreateRootFilterFolder<StreamCinemaMoviesFolder>("Movies");
|
||||
|
||||
/*
|
||||
pluginItems.Add(CreateMenuItem("movies", Resources.Movies, GetResourceUrl("movies.png")));
|
||||
pluginItems.Add(CreateMenuItem("shows", Resources.Shows, GetResourceUrl("tvshows.png")));
|
||||
pluginItems.Add(CreateMenuItem("tv", Resources.TvProgram, GetResourceUrl("tv-program.png")));
|
||||
pluginItems.Add(CreateMenuItem("anime", Resources.Anime, GetResourceUrl("anime.png")));
|
||||
pluginItems.Add(CreateMenuItem("concerts", Resources.Concerts, GetResourceUrl("music.png")));*/
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private static void CreateRoot<T>(string idPrefix, string localizedName, ILibraryManager libraryManager, IServerConfigurationManager config, IFileSystem fileSystem) where T : StreamCinemaRootFolder, new()
|
||||
{
|
||||
string internalMetadataPath = config.ApplicationPaths.InternalMetadataPath;
|
||||
|
||||
Guid rootFolderId = libraryManager.GetNewItemId(StreamCinemaRootFolder.GetIdToHash("", idPrefix), typeof(StreamCinemaRootFolder));
|
||||
string rootFolderPath = StreamCinemaRootFolder.GetInternalMetadataPath(internalMetadataPath, rootFolderId);
|
||||
Directory.CreateDirectory(rootFolderPath);
|
||||
StreamCinemaRootFolder? rootFolder = libraryManager.GetItemById(rootFolderId) as StreamCinemaRootFolder;
|
||||
bool isNew;
|
||||
bool forceUpdate = false;
|
||||
if (isNew = rootFolder == null)
|
||||
{
|
||||
rootFolder = new T
|
||||
{
|
||||
Name = localizedName,
|
||||
Id = rootFolderId,
|
||||
DateCreated = fileSystem.GetCreationTimeUtc(rootFolderPath),
|
||||
DateModified = fileSystem.GetLastWriteTimeUtc(rootFolderPath)
|
||||
};
|
||||
}
|
||||
|
||||
rootFolder.Initialize(libraryManager, idPrefix, internalMetadataPath);
|
||||
rootFolder.Id = rootFolderId; // seems to get lost on existing item
|
||||
rootFolder.Path = rootFolderPath;
|
||||
rootFolder.ParentId = Guid.Empty;
|
||||
rootFolder.IsRoot = true;
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
rootFolder.OnMetadataChanged();
|
||||
libraryManager.CreateItem(rootFolder, null);
|
||||
}
|
||||
|
||||
// We do not bother waiting for the task to finish
|
||||
rootFolder.RefreshMetadata(
|
||||
new MetadataRefreshOptions(new DirectoryService(fileSystem))
|
||||
{
|
||||
ForceSave = !isNew && forceUpdate
|
||||
},
|
||||
default).ConfigureAwait(false);
|
||||
|
||||
libraryManager.RootFolder.AddVirtualChild(rootFolder);
|
||||
}
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -1,45 +1,39 @@
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using StreamCinemaLib.API;
|
||||
|
||||
namespace Jellyfin.Plugin.StreamCinema;
|
||||
|
||||
sealed class StreamCinemaMoviesFolder : StreamCinemaRootFolder
|
||||
{
|
||||
private BaseItem? _trending;
|
||||
private BaseItem? _popular;
|
||||
private BaseItem? _mostWatched;
|
||||
private BaseItem? _newReleases;
|
||||
private readonly BaseItem _trending;
|
||||
private readonly BaseItem _popular;
|
||||
private readonly BaseItem _mostWatched;
|
||||
private readonly BaseItem _newReleases;
|
||||
|
||||
internal override void Initialize(ILibraryManager libraryManager, string idPrefix, string internalMetadataPath)
|
||||
{
|
||||
base.Initialize(libraryManager, idPrefix, internalMetadataPath);
|
||||
public StreamCinemaMoviesFolder()
|
||||
{
|
||||
this._trending = CreateChildFolder<StreamCinemaTrendingFolder>("Trending");
|
||||
this._popular = CreateChildFolder<StreamCinemaPopularFolder>("Popular");
|
||||
//this._mostWatched = CreateFilterFolder("mostWatched", this, "Most Watched", "watched.png");
|
||||
//this._newReleases = CreateFilterFolder("newReleases", this, "New Releases", "new.png");
|
||||
}
|
||||
|
||||
this._trending = CreateFilterFolder<StreamCinemaTrendingFolder>("trending", this, "Trending");
|
||||
this._popular = CreateFilterFolder<StreamCinemaPopularFolder>("popular", this, "Popular");
|
||||
//this._mostWatched = CreateFilterFolder("mostWatched", this, "Most Watched", "watched.png");
|
||||
//this._newReleases = CreateFilterFolder("newReleases", this, "New Releases", "new.png");
|
||||
}
|
||||
public override CollectionType? CollectionType => Data.Enums.CollectionType.movies;
|
||||
|
||||
public override CollectionType? CollectionType => Data.Enums.CollectionType.movies;
|
||||
public override BaseItemKind ClientType => BaseItemKind.Movie;
|
||||
|
||||
public override BaseItemKind ClientType => BaseItemKind.Movie;
|
||||
public override ItemType ItemType => ItemType.Movie;
|
||||
|
||||
internal override string ImageName => "movies.png";
|
||||
internal override string ImageName => "movies.png";
|
||||
|
||||
protected override IEnumerable<BaseItem> GetFilterItems(string path)
|
||||
{
|
||||
if (_trending == null)
|
||||
// Not yet initialized
|
||||
yield break;
|
||||
|
||||
if (path.Length == 0)
|
||||
{
|
||||
// Root items
|
||||
yield return _trending;
|
||||
yield return _popular!;
|
||||
//yield return _mostWatched!;
|
||||
//yield return _newReleases!;
|
||||
}
|
||||
}
|
||||
protected override IEnumerable<BaseItem> GetFilterItems()
|
||||
{
|
||||
// Root items
|
||||
yield return _trending;
|
||||
yield return _popular!;
|
||||
//yield return _mostWatched!;
|
||||
//yield return _newReleases!;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ public class StreamCinemaPlugin : BasePlugin<StreamCinemaPluginConfiguration>, I
|
||||
public StreamCinemaPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -30,12 +29,6 @@ public class StreamCinemaPlugin : BasePlugin<StreamCinemaPluginConfiguration>, I
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Videodoplněk obsahující rozsáhlou databázi filmů a seriálů.";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the instance.
|
||||
/// </summary>
|
||||
/// <value>The instance.</value>
|
||||
public static StreamCinemaPlugin Instance { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using StreamCinemaLib.API;
|
||||
|
||||
namespace Jellyfin.Plugin.StreamCinema;
|
||||
@@ -6,5 +8,14 @@ public sealed class StreamCinemaPopularFolder : StreamCinemaSortFolder
|
||||
{
|
||||
internal override string ImageName => "popular.png";
|
||||
|
||||
public override BaseItemKind ClientType => BaseItemKind.Movie;
|
||||
|
||||
public override ItemType ItemType => ItemType.Movie;
|
||||
|
||||
internal override FilterSortBy SortBy { get { return FilterSortBy.Popularity; } }
|
||||
|
||||
protected override IEnumerable<BaseItem> GetFilterItems()
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
@@ -13,644 +13,209 @@ using StreamCinemaLib.API;
|
||||
|
||||
namespace Jellyfin.Plugin.StreamCinema;
|
||||
|
||||
/// <summary>
|
||||
/// Stream Cinema root folder that is displayed identically to user-defined libraries.
|
||||
/// </summary>
|
||||
public abstract class StreamCinemaRootFolder : StreamCinemaFilterFolder, ICollectionFolder
|
||||
{
|
||||
private const int PageLimit = 30;
|
||||
private const string PreferredCulture = "cs";
|
||||
private const string FallbackCulture = "en";
|
||||
|
||||
private ILibraryManager? _libraryManager;
|
||||
private string? _idPrefix;
|
||||
private string? _internalMetadataPath;
|
||||
|
||||
internal virtual void Initialize(ILibraryManager libraryManager, string idPrefix, string internalMetadataPath)
|
||||
{
|
||||
this._libraryManager = libraryManager;
|
||||
this._idPrefix = idPrefix;
|
||||
this._internalMetadataPath = internalMetadataPath;
|
||||
}
|
||||
|
||||
public abstract CollectionType? CollectionType { get; }
|
||||
|
||||
public abstract BaseItemKind ClientType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the static filter folders either directly from the root or nested filter folder.
|
||||
/// </summary>
|
||||
/// <param name="path">Path to folder whose filter items to get (empty string for root).</param>
|
||||
protected abstract IEnumerable<BaseItem> GetFilterItems(string path);
|
||||
|
||||
public sealed override string GetClientTypeName()
|
||||
{
|
||||
return ClientType.ToString();
|
||||
}
|
||||
|
||||
protected sealed override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query)
|
||||
{
|
||||
if (_libraryManager == null)
|
||||
// We were not properly initialized yet so refuse to work
|
||||
return new QueryResult<BaseItem>() { Items = new List<BaseItem>() };
|
||||
|
||||
BaseItem? parentItem = query.ParentId.IsEmpty() ? this : _libraryManager.GetItemById(query.ParentId);
|
||||
if (parentItem == null)
|
||||
throw new InvalidOperationException("StreamCinema parent folder is missing.");
|
||||
|
||||
string folderId = parentItem?.ExternalId ?? "";
|
||||
int offset = query.StartIndex ?? 0;
|
||||
int limit = query.Limit ?? PageLimit;
|
||||
|
||||
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(folderId))
|
||||
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
|
||||
FilterResponse? filterRes = Metadata.SearchAsync(query.SearchTerm ?? "", 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, parentItem, out BaseItem? a))
|
||||
items.Add(a);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
protected BaseItem CreateFilterFolder<T>(string id, StreamCinemaFilterFolder parent, string localizedName) where T : StreamCinemaFilterFolder, new()
|
||||
{
|
||||
if (_libraryManager == null)
|
||||
throw new InvalidOperationException();
|
||||
|
||||
Guid folderId = _libraryManager.GetNewItemId(GetIdToHash(id, _idPrefix!), typeof(StreamCinemaRootFolder));
|
||||
string folderPath = GetInternalMetadataPath(_internalMetadataPath!, folderId);
|
||||
T? folder = _libraryManager.GetItemById(folderId) as T;
|
||||
bool isNew;
|
||||
bool forceUpdate = false;
|
||||
if (isNew = folder == null)
|
||||
{
|
||||
folder = new T
|
||||
{
|
||||
Id = folderId,
|
||||
Name = localizedName,
|
||||
ExternalId = id,
|
||||
//DateCreated = fileSystem.GetCreationTimeUtc(rootFolderPath),
|
||||
//DateModified = fileSystem.GetLastWriteTimeUtc(rootFolderPath)
|
||||
};
|
||||
}
|
||||
|
||||
folder.Path = folderPath;
|
||||
folder.ParentId = parent.Id;
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
folder.OnMetadataChanged();
|
||||
_libraryManager.CreateItem(folder, parent);
|
||||
}
|
||||
|
||||
return folder;
|
||||
}
|
||||
|
||||
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 = GetItemById<MediaBrowser.Controller.Entities.Audio.MusicAlbum>(csId, out isNew);
|
||||
else if (singleLevel)
|
||||
item = GetItemById<MediaBrowser.Controller.Entities.TV.Season>(csId, out isNew);
|
||||
else
|
||||
item = GetItemById<MediaBrowser.Controller.Entities.TV.Series>(csId, out isNew);
|
||||
|
||||
}
|
||||
else if (isAudio)
|
||||
{
|
||||
// TODO determine if this is an AudioBook od Audio
|
||||
item = GetItemById<MediaBrowser.Controller.Entities.Audio.Audio>(csId, out isNew);
|
||||
|
||||
}
|
||||
else if (media.info_labels?.mediatype == "tvshow")
|
||||
{
|
||||
item = GetItemById<MediaBrowser.Controller.Entities.TV.Episode>(csId, out isNew);
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
item = GetItemById<MediaBrowser.Controller.Entities.Movies.Movie>(csId, 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 = media.services != null ? ConvertProviderIds(media.services) : null;
|
||||
//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 ?? loc?.art?.fanart;
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
_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 Dictionary<string, string> ConvertProviderIds(ServicesIds services)
|
||||
{
|
||||
Dictionary<string, string> result = new Dictionary<string, string>();
|
||||
if (services.imdb != null)
|
||||
result.Add("Imdb", services.imdb);
|
||||
if (services.tvdb != null)
|
||||
result.Add("Tvdb", services.tvdb);
|
||||
if (services.tmdb != null)
|
||||
result.Add("Tmdb", services.tmdb);
|
||||
return result;
|
||||
|
||||
//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, "streamcinema", id.ToString("N", CultureInfo.InvariantCulture), "metadata");
|
||||
}
|
||||
|
||||
private T GetItemById<T>(string idString, out bool isNew)
|
||||
where T : BaseItem, new()
|
||||
{
|
||||
if (_libraryManager == null)
|
||||
throw new InvalidOperationException();
|
||||
|
||||
var id = _libraryManager.GetNewItemId(GetIdToHash(idString, _idPrefix!), typeof(T));
|
||||
|
||||
T? item = null;
|
||||
|
||||
//try
|
||||
//{
|
||||
item = _libraryManager.GetItemById(id) as T;
|
||||
//}
|
||||
//catch (Exception ex)
|
||||
//{
|
||||
// _logger.LogError(ex, "Error retrieving channel item from database");
|
||||
//}
|
||||
|
||||
if (item == null)
|
||||
{
|
||||
item = new T();
|
||||
isNew = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
isNew = false;
|
||||
}
|
||||
|
||||
item.Id = id;
|
||||
return item;
|
||||
}
|
||||
|
||||
internal static string GetIdToHash(string externalId, string idPrefix)
|
||||
{
|
||||
// Increment this as needed to force new downloads
|
||||
return "StreamCinema-" + idPrefix + "-" + externalId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
public async Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query.FolderId))
|
||||
{
|
||||
// Main Menu
|
||||
List<ChannelItemInfo> pluginItems = new List<ChannelItemInfo>();
|
||||
return new ChannelItemResult() { Items = pluginItems };
|
||||
}
|
||||
|
||||
/*
|
||||
if (query.FolderId.StartsWith("series_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var hash = query.FolderId.Split('_')[1];
|
||||
return await GetChannelItems(query, i => i.IsSeries && string.Equals(i.Name.GetMD5().ToString("N"), hash, StringComparison.Ordinal), cancellationToken);
|
||||
}
|
||||
|
||||
if (string.Equals(query.FolderId, "kids", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await GetChannelItems(query, i => i.IsKids, cancellationToken);
|
||||
}
|
||||
|
||||
if (string.Equals(query.FolderId, "movies", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await GetChannelItems(query, i => i.IsMovie, cancellationToken);
|
||||
}
|
||||
|
||||
if (string.Equals(query.FolderId, "news", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await GetChannelItems(query, i => i.IsNews, cancellationToken);
|
||||
}
|
||||
|
||||
if (string.Equals(query.FolderId, "sports", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await GetChannelItems(query, i => i.IsSports, cancellationToken);
|
||||
}
|
||||
|
||||
if (string.Equals(query.FolderId, "others", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await GetChannelItems(query, i => !i.IsSports && !i.IsNews && !i.IsMovie && !i.IsKids && !i.IsSeries, cancellationToken);
|
||||
}*/
|
||||
/*
|
||||
var result = new ChannelItemResult() { Items = new List<ChannelItemInfo>() };
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
|
||||
{
|
||||
if (type == ImageType.Primary)
|
||||
{
|
||||
return Task.FromResult(new DynamicImageResponse { Path = "https://streamcinema.cz/assets/logo-iqdz28nk.png", Protocol = MediaProtocol.Http, HasImage = true });
|
||||
}
|
||||
|
||||
return Task.FromResult(new DynamicImageResponse { HasImage = false });
|
||||
}
|
||||
|
||||
public IEnumerable<ImageType> GetSupportedChannelImages()
|
||||
{
|
||||
return new List<ImageType> { ImageType.Primary };
|
||||
}
|
||||
|
||||
|
||||
#region IHasCacheKey Members
|
||||
|
||||
public string GetCacheKey(string userId)
|
||||
{
|
||||
DateTimeOffset dto = LiveTvService.Instance.RecordingModificationTime;
|
||||
return $"{dto.ToUnixTimeSeconds()}-{_cacheKeyBase}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ISupportsLatestMedia Members
|
||||
|
||||
public async Task<IEnumerable<ChannelItemInfo>> GetLatestMedia(ChannelLatestMediaSearch request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await GetChannelItems(new InternalChannelItemQuery(), _ => true, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return result.Items.OrderByDescending(i => i.DateCreated ?? DateTime.MinValue);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IHasFolderAttributes Members
|
||||
|
||||
#pragma warning disable CA1819
|
||||
public string[] Attributes => ["Recordings"];
|
||||
#pragma warning restore CA1819
|
||||
|
||||
#endregion
|
||||
|
||||
private static ChannelItemInfo CreateMenuItem(string id, string localizedName, string? imageUrl = null)
|
||||
{
|
||||
return new ChannelItemInfo
|
||||
{
|
||||
Id = id,
|
||||
Name = localizedName,
|
||||
FolderType = ChannelFolderType.Container,
|
||||
Type = ChannelItemType.Folder,
|
||||
ImageUrl = imageUrl
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetResourceUrl(string fileName)
|
||||
{
|
||||
return "__plugin/" + fileName;
|
||||
}
|
||||
|
||||
private void CleanCache(bool cleanAll = false)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_recordingCacheDirectory) && Directory.Exists(_recordingCacheDirectory))
|
||||
{
|
||||
string[] cachedJson = Directory.GetFiles(_recordingCacheDirectory, "*.json");
|
||||
_logger.LogInformation("Cleaning JSON cache {CacheDirectory} {FileCount}", _recordingCacheDirectory, cachedJson.Length);
|
||||
foreach (string fileName in cachedJson)
|
||||
{
|
||||
if (cleanAll || _fileSystem.GetLastWriteTimeUtc(fileName).Add(TimeSpan.FromHours(3)) <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
_fileSystem.DeleteFile(fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private LiveTvService GetService()
|
||||
{
|
||||
LiveTvService service = LiveTvService.Instance;
|
||||
if (service is not null && (!service.IsActive || _cachedRecordingModificationTime != Plugin.Instance.Configuration.RecordingModificationTime || service.FlagRecordingChange))
|
||||
{
|
||||
try
|
||||
{
|
||||
CancellationToken cancellationToken = CancellationToken.None;
|
||||
service.EnsureConnectionAsync(cancellationToken).Wait();
|
||||
if (service.IsActive)
|
||||
{
|
||||
_useCachedRecordings = false;
|
||||
if (_cachedRecordingModificationTime != Plugin.Instance.Configuration.RecordingModificationTime)
|
||||
{
|
||||
_cachedRecordingModificationTime = Plugin.Instance.Configuration.RecordingModificationTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
public async Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, Func<MyRecordingInfo, bool> filter, CancellationToken cancellationToken)
|
||||
{
|
||||
await GetRecordingsAsync("GetChannelItems", cancellationToken);
|
||||
List<ChannelItemInfo> pluginItems = new List<ChannelItemInfo>();
|
||||
pluginItems.AddRange(_allRecordings.Where(filter).Select(ConvertToChannelItem));
|
||||
var result = new ChannelItemResult() { Items = pluginItems };
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private ChannelItemInfo ConvertToChannelItem(MyRecordingInfo item)
|
||||
{
|
||||
var path = string.IsNullOrEmpty(item.Path) ? item.Url : item.Path;
|
||||
|
||||
var channelItem = new ChannelItemInfo
|
||||
{
|
||||
Name = string.IsNullOrEmpty(item.EpisodeTitle) ? item.Name : item.EpisodeTitle,
|
||||
SeriesName = !string.IsNullOrEmpty(item.EpisodeTitle) || item.IsSeries ? item.Name : null,
|
||||
StartDate = item.StartDate,
|
||||
EndDate = item.EndDate,
|
||||
OfficialRating = item.OfficialRating,
|
||||
CommunityRating = item.CommunityRating,
|
||||
ContentType = item.IsMovie ? ChannelMediaContentType.Movie : ChannelMediaContentType.Episode,
|
||||
Genres = item.Genres,
|
||||
ImageUrl = item.ImageUrl,
|
||||
Id = item.Id,
|
||||
ParentIndexNumber = item.SeasonNumber,
|
||||
IndexNumber = item.EpisodeNumber,
|
||||
MediaType = item.ChannelType == ChannelType.TV ? ChannelMediaType.Video : ChannelMediaType.Audio,
|
||||
MediaSources = new List<MediaSourceInfo>
|
||||
{
|
||||
new MediaSourceInfo
|
||||
{
|
||||
Path = path,
|
||||
Container = item.Status == RecordingStatus.InProgress ? "ts" : null,
|
||||
Protocol = path.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? MediaProtocol.Http : MediaProtocol.File,
|
||||
BufferMs = 1000,
|
||||
AnalyzeDurationMs = 0,
|
||||
IsInfiniteStream = item.Status == RecordingStatus.InProgress,
|
||||
TranscodingContainer = "ts",
|
||||
RunTimeTicks = item.Status == RecordingStatus.InProgress ? null : (item.EndDate - item.StartDate).Ticks,
|
||||
}
|
||||
},
|
||||
PremiereDate = item.OriginalAirDate,
|
||||
ProductionYear = item.ProductionYear,
|
||||
Type = ChannelItemType.Media,
|
||||
DateModified = item.Status == RecordingStatus.InProgress ? DateTime.Now : Plugin.Instance.Configuration.RecordingModificationTime,
|
||||
Overview = item.Overview,
|
||||
IsLiveStream = item.Status != RecordingStatus.InProgress ? false : Plugin.Instance.Configuration.EnableInProgress,
|
||||
Etag = item.Status.ToString()
|
||||
};
|
||||
|
||||
return channelItem;
|
||||
}
|
||||
|
||||
private async Task<bool> GetRecordingsAsync(string name, CancellationToken cancellationToken)
|
||||
{
|
||||
var service = GetService();
|
||||
if (service is null || !service.IsActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_useCachedRecordings == false || service.FlagRecordingChange)
|
||||
{
|
||||
if (_pollInterval == -1)
|
||||
{
|
||||
var interval = TimeSpan.FromSeconds(Plugin.Instance.Configuration.PollInterval);
|
||||
_updateTimer = new Timer(OnUpdateTimerCallbackAsync, null, TimeSpan.FromMinutes(2), interval);
|
||||
if (_updateTimer != null)
|
||||
{
|
||||
_pollInterval = Plugin.Instance.Configuration.PollInterval;
|
||||
}
|
||||
}
|
||||
|
||||
if (await _semaphore.WaitAsync(30000, cancellationToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("{0} Reload cache", name);
|
||||
_allRecordings = await service.GetAllRecordingsAsync(cancellationToken).ConfigureAwait(false);
|
||||
int maxId = _allRecordings.Max(r => int.Parse(r.Id, CultureInfo.InvariantCulture));
|
||||
int inProcessCount = _allRecordings.Count(r => r.Status == RecordingStatus.InProgress);
|
||||
string keyBase = $"{maxId}-{inProcessCount}-{_allRecordings.Count()}";
|
||||
if (keyBase != _cacheKeyBase && !service.FlagRecordingChange)
|
||||
{
|
||||
_logger.LogDebug("External recording list change {0}", keyBase);
|
||||
CleanCache(true);
|
||||
}
|
||||
|
||||
_cacheKeyBase = keyBase;
|
||||
_lastUpdate = DateTimeOffset.UtcNow;
|
||||
service.FlagRecordingChange = false;
|
||||
_useCachedRecordings = true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
return _useCachedRecordings;
|
||||
}
|
||||
|
||||
private async void OnUpdateTimerCallbackAsync(object state)
|
||||
{
|
||||
LiveTvService service = LiveTvService.Instance;
|
||||
if (service is not null && service.IsActive)
|
||||
{
|
||||
var backendUpdate = await service.GetLastUpdate(_cancellationToken.Token).ConfigureAwait(false);
|
||||
if (backendUpdate > _lastUpdate)
|
||||
{
|
||||
_logger.LogDebug("Recordings reset {0}", backendUpdate);
|
||||
_useCachedRecordings = false;
|
||||
await GetRecordingsAsync("OnUpdateTimerCallbackAsync", _cancellationToken.Token);
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
/// <summary>
|
||||
/// Gets the item type for <see cref="ICollectionFolder"/>. Shall be kept in
|
||||
/// sync with value for <see cref="StreamCinemaFilterFolder.ClientType"/>
|
||||
/// </summary>
|
||||
public abstract CollectionType? CollectionType { get; }
|
||||
|
||||
|
||||
|
||||
|
||||
/*
|
||||
|
||||
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
|
||||
{
|
||||
if (type == ImageType.Primary)
|
||||
{
|
||||
return Task.FromResult(new DynamicImageResponse { Path = "https://streamcinema.cz/assets/logo-iqdz28nk.png", Protocol = MediaProtocol.Http, HasImage = true });
|
||||
}
|
||||
|
||||
return Task.FromResult(new DynamicImageResponse { HasImage = false });
|
||||
}
|
||||
|
||||
public IEnumerable<ImageType> GetSupportedChannelImages()
|
||||
{
|
||||
return new List<ImageType> { ImageType.Primary };
|
||||
}
|
||||
|
||||
|
||||
#region IHasCacheKey Members
|
||||
|
||||
public string GetCacheKey(string userId)
|
||||
{
|
||||
DateTimeOffset dto = LiveTvService.Instance.RecordingModificationTime;
|
||||
return $"{dto.ToUnixTimeSeconds()}-{_cacheKeyBase}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ISupportsLatestMedia Members
|
||||
|
||||
public async Task<IEnumerable<ChannelItemInfo>> GetLatestMedia(ChannelLatestMediaSearch request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await GetChannelItems(new InternalChannelItemQuery(), _ => true, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return result.Items.OrderByDescending(i => i.DateCreated ?? DateTime.MinValue);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IHasFolderAttributes Members
|
||||
|
||||
#pragma warning disable CA1819
|
||||
public string[] Attributes => ["Recordings"];
|
||||
#pragma warning restore CA1819
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
private static string GetResourceUrl(string fileName)
|
||||
{
|
||||
return "__plugin/" + fileName;
|
||||
}
|
||||
|
||||
private void CleanCache(bool cleanAll = false)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_recordingCacheDirectory) && Directory.Exists(_recordingCacheDirectory))
|
||||
{
|
||||
string[] cachedJson = Directory.GetFiles(_recordingCacheDirectory, "*.json");
|
||||
_logger.LogInformation("Cleaning JSON cache {CacheDirectory} {FileCount}", _recordingCacheDirectory, cachedJson.Length);
|
||||
foreach (string fileName in cachedJson)
|
||||
{
|
||||
if (cleanAll || _fileSystem.GetLastWriteTimeUtc(fileName).Add(TimeSpan.FromHours(3)) <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
_fileSystem.DeleteFile(fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, Func<MyRecordingInfo, bool> filter, CancellationToken cancellationToken)
|
||||
{
|
||||
await GetRecordingsAsync("GetChannelItems", cancellationToken);
|
||||
List<ChannelItemInfo> pluginItems = new List<ChannelItemInfo>();
|
||||
pluginItems.AddRange(_allRecordings.Where(filter).Select(ConvertToChannelItem));
|
||||
var result = new ChannelItemResult() { Items = pluginItems };
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private ChannelItemInfo ConvertToChannelItem(MyRecordingInfo item)
|
||||
{
|
||||
var path = string.IsNullOrEmpty(item.Path) ? item.Url : item.Path;
|
||||
|
||||
var channelItem = new ChannelItemInfo
|
||||
{
|
||||
Name = string.IsNullOrEmpty(item.EpisodeTitle) ? item.Name : item.EpisodeTitle,
|
||||
SeriesName = !string.IsNullOrEmpty(item.EpisodeTitle) || item.IsSeries ? item.Name : null,
|
||||
StartDate = item.StartDate,
|
||||
EndDate = item.EndDate,
|
||||
OfficialRating = item.OfficialRating,
|
||||
CommunityRating = item.CommunityRating,
|
||||
ContentType = item.IsMovie ? ChannelMediaContentType.Movie : ChannelMediaContentType.Episode,
|
||||
Genres = item.Genres,
|
||||
ImageUrl = item.ImageUrl,
|
||||
Id = item.Id,
|
||||
ParentIndexNumber = item.SeasonNumber,
|
||||
IndexNumber = item.EpisodeNumber,
|
||||
MediaType = item.ChannelType == ChannelType.TV ? ChannelMediaType.Video : ChannelMediaType.Audio,
|
||||
MediaSources = new List<MediaSourceInfo>
|
||||
{
|
||||
new MediaSourceInfo
|
||||
{
|
||||
Path = path,
|
||||
Container = item.Status == RecordingStatus.InProgress ? "ts" : null,
|
||||
Protocol = path.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? MediaProtocol.Http : MediaProtocol.File,
|
||||
BufferMs = 1000,
|
||||
AnalyzeDurationMs = 0,
|
||||
IsInfiniteStream = item.Status == RecordingStatus.InProgress,
|
||||
TranscodingContainer = "ts",
|
||||
RunTimeTicks = item.Status == RecordingStatus.InProgress ? null : (item.EndDate - item.StartDate).Ticks,
|
||||
}
|
||||
},
|
||||
PremiereDate = item.OriginalAirDate,
|
||||
ProductionYear = item.ProductionYear,
|
||||
Type = ChannelItemType.Media,
|
||||
DateModified = item.Status == RecordingStatus.InProgress ? DateTime.Now : Plugin.Instance.Configuration.RecordingModificationTime,
|
||||
Overview = item.Overview,
|
||||
IsLiveStream = item.Status != RecordingStatus.InProgress ? false : Plugin.Instance.Configuration.EnableInProgress,
|
||||
Etag = item.Status.ToString()
|
||||
};
|
||||
|
||||
return channelItem;
|
||||
}
|
||||
|
||||
private async Task<bool> GetRecordingsAsync(string name, CancellationToken cancellationToken)
|
||||
{
|
||||
var service = GetService();
|
||||
if (service is null || !service.IsActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_useCachedRecordings == false || service.FlagRecordingChange)
|
||||
{
|
||||
if (_pollInterval == -1)
|
||||
{
|
||||
var interval = TimeSpan.FromSeconds(Plugin.Instance.Configuration.PollInterval);
|
||||
_updateTimer = new Timer(OnUpdateTimerCallbackAsync, null, TimeSpan.FromMinutes(2), interval);
|
||||
if (_updateTimer != null)
|
||||
{
|
||||
_pollInterval = Plugin.Instance.Configuration.PollInterval;
|
||||
}
|
||||
}
|
||||
|
||||
if (await _semaphore.WaitAsync(30000, cancellationToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("{0} Reload cache", name);
|
||||
_allRecordings = await service.GetAllRecordingsAsync(cancellationToken).ConfigureAwait(false);
|
||||
int maxId = _allRecordings.Max(r => int.Parse(r.Id, CultureInfo.InvariantCulture));
|
||||
int inProcessCount = _allRecordings.Count(r => r.Status == RecordingStatus.InProgress);
|
||||
string keyBase = $"{maxId}-{inProcessCount}-{_allRecordings.Count()}";
|
||||
if (keyBase != _cacheKeyBase && !service.FlagRecordingChange)
|
||||
{
|
||||
_logger.LogDebug("External recording list change {0}", keyBase);
|
||||
CleanCache(true);
|
||||
}
|
||||
|
||||
_cacheKeyBase = keyBase;
|
||||
_lastUpdate = DateTimeOffset.UtcNow;
|
||||
service.FlagRecordingChange = false;
|
||||
_useCachedRecordings = true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
return _useCachedRecordings;
|
||||
}
|
||||
|
||||
private async void OnUpdateTimerCallbackAsync(object state)
|
||||
{
|
||||
LiveTvService service = LiveTvService.Instance;
|
||||
if (service is not null && service.IsActive)
|
||||
{
|
||||
var backendUpdate = await service.GetLastUpdate(_cancellationToken.Token).ConfigureAwait(false);
|
||||
if (backendUpdate > _lastUpdate)
|
||||
{
|
||||
_logger.LogDebug("Recordings reset {0}", backendUpdate);
|
||||
_useCachedRecordings = false;
|
||||
await GetRecordingsAsync("OnUpdateTimerCallbackAsync", _cancellationToken.Token);
|
||||
}
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using StreamCinemaLib.API;
|
||||
|
||||
namespace Jellyfin.Plugin.StreamCinema;
|
||||
@@ -6,5 +8,14 @@ public sealed class StreamCinemaTrendingFolder : StreamCinemaSortFolder
|
||||
{
|
||||
internal override string ImageName => "trending.png";
|
||||
|
||||
public override BaseItemKind ClientType => BaseItemKind.Movie;
|
||||
|
||||
public override ItemType ItemType => ItemType.Movie;
|
||||
|
||||
internal override FilterSortBy SortBy { get { return FilterSortBy.Trending; } }
|
||||
|
||||
protected override IEnumerable<BaseItem> GetFilterItems()
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ public class Metadata
|
||||
{
|
||||
private const string UserAgent = "User-Agent: XBMC/19 (Linux 6.1; http://www.xbmc.org)";
|
||||
private const string AccessToken = "9ajdu4xyn1ig8nxsodr3";
|
||||
private const int MaxPageLimit = 1000;
|
||||
private const int MaxPageLimit = 100; // thousand is too much for some search types
|
||||
|
||||
private static readonly Uri ApiRoot = new Uri("https://plugin.sc2.zone/api/");
|
||||
private static readonly Uri ApiFilter = new Uri(ApiRoot, "media/filter/v2/");
|
||||
@@ -20,7 +20,7 @@ public class Metadata
|
||||
/// <summary>
|
||||
/// Searches for media using an expression.
|
||||
/// </summary>
|
||||
/// <param name="expression">Expression like part of title.</param>
|
||||
/// <param name="expression">Expression like part of title. Can be empty string to list all media.</param>
|
||||
/// <param name="order">Result ordering direction.</param>
|
||||
/// <param name="sort">Result ordering column.</param>
|
||||
/// <param name="type">Allowed result type.</param>
|
||||
@@ -37,8 +37,28 @@ public class Metadata
|
||||
if (limit == 0 || limit > MaxPageLimit)
|
||||
limit = MaxPageLimit;
|
||||
|
||||
UriBuilder uri = new UriBuilder(new Uri(ApiFilter, "search"));
|
||||
/*
|
||||
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);
|
||||
if (sort == FilterSortBy.Title) {
|
||||
filterName = "startsWithSimple";
|
||||
sortS = "";
|
||||
} else if (noSearchTerms) {
|
||||
filterName = "all";
|
||||
} else {
|
||||
filterName = "search";
|
||||
}
|
||||
|
||||
UriBuilder uri = new UriBuilder(new Uri(ApiFilter, filterName));
|
||||
string orderS = order == ItemOrder.Ascending ? "asc" : "desc";
|
||||
uri.Query = $"?access_token={AccessToken}&value={Uri.EscapeDataString(expression)}&type={ToString(type)}&from={offset.ToString()}&size={limit.ToString()}"
|
||||
+ (sortS.Length != 0 ? $"&order={ToString(order)}&sort={sortS}" : "");
|
||||
|
||||
FilterResponse? result = await Program._http.GetFromJsonAsync<FilterResponse>(uri.Uri, CreateFilterJsonOptions(), cancel);
|
||||
if (result != null)
|
||||
|
||||
Reference in New Issue
Block a user