Support Jellyfin 10.9 #96

Merged
Kevinjil merged 4 commits from feature/jellyfin-10.9 into master 2024-05-15 17:52:08 +00:00
15 changed files with 172 additions and 113 deletions

View File

@@ -17,5 +17,5 @@ jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/build.yaml@master
with:
dotnet-version: "6.0.*"
dotnet-target: "net6.0"
dotnet-version: "8.0.*"
dotnet-target: "net8.0"

View File

@@ -10,8 +10,8 @@ jobs:
build:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/build.yaml@master
with:
dotnet-version: "6.0.*"
dotnet-target: "net6.0"
dotnet-version: "8.0.*"
dotnet-target: "net8.0"
upload:
runs-on: ubuntu-latest
needs:

View File

@@ -9,5 +9,5 @@ jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/scan-codeql.yaml@master
with:
dotnet-version: "6.0.*"
dotnet-version: "8.0.*"
repository-name: Kevinjil/Jellyfin.Xtream

View File

@@ -106,9 +106,8 @@ namespace Jellyfin.Xtream
return await GetChannels(cancellationToken).ConfigureAwait(false);
}
int separator = query.FolderId.IndexOf('-', StringComparison.InvariantCulture);
int categoryId = int.Parse(query.FolderId.Substring(0, separator), CultureInfo.InvariantCulture);
int channelId = int.Parse(query.FolderId.Substring(separator + 1), CultureInfo.InvariantCulture);
Guid guid = Guid.Parse(query.FolderId);
StreamService.FromGuid(guid, out int prefix, out int categoryId, out int channelId, out int _);
return await GetStreams(categoryId, channelId, cancellationToken).ConfigureAwait(false);
}
@@ -124,10 +123,10 @@ namespace Jellyfin.Xtream
continue;
}
ParsedName parsedName = plugin.StreamService.ParseName(channel.Name);
ParsedName parsedName = StreamService.ParseName(channel.Name);
items.Add(new ChannelItemInfo()
{
Id = $"{channel.CategoryId}-{channel.StreamId}",
Id = StreamService.ToGuid(StreamService.CatchupPrefix, channel.CategoryId, channel.StreamId, 0).ToString(),
ImageUrl = channel.StreamIcon,
Name = parsedName.Title,
Tags = new List<string>(parsedName.Tags),
@@ -173,7 +172,7 @@ namespace Jellyfin.Xtream
{
ContentType = ChannelMediaContentType.TvExtra,
FolderType = ChannelFolderType.Container,
Id = $"fallback-{channelId}",
Id = StreamService.ToGuid(StreamService.FallbackPrefix, channelId, 0, 0).ToString(),
IsLiveStream = false,
MediaSources = new List<MediaSourceInfo>()
{
@@ -194,7 +193,7 @@ namespace Jellyfin.Xtream
foreach (EpgInfo epg in epgs.Listings.Where(epg => epg.Start < startBefore && epg.Start >= startAfter))
{
string id = epg.Id.ToString(System.Globalization.CultureInfo.InvariantCulture);
ParsedName parsedName = plugin.StreamService.ParseName(epg.Title);
ParsedName parsedName = StreamService.ParseName(epg.Title);
int durationMinutes = (int)Math.Ceiling((epg.End - epg.Start).TotalMinutes);
string dateTitle = epg.Start.ToLocalTime().ToString("ddd HH:mm", CultureInfo.InvariantCulture);
List<MediaSourceInfo> sources = new List<MediaSourceInfo>()

View File

@@ -49,25 +49,6 @@ namespace Jellyfin.Xtream.Configuration
{
}
/// <summary>
/// Initializes a new instance of the
/// <see cref="SerializableDictionary&lt;TKey, TValue&gt;"/> class.
/// </summary>
/// <param name="info">A
/// <see cref="System.Runtime.Serialization.SerializationInfo"/> object
/// containing the information required to serialize the
/// <see cref="System.Collections.Generic.Dictionary{TKey, TValue}"/>.
/// </param>
/// <param name="context">A
/// <see cref="System.Runtime.Serialization.StreamingContext"/> structure
/// containing the source and destination of the serialized stream
/// associated with the
/// <see cref="System.Collections.Generic.Dictionary{TKey, TValue}"/>.
/// </param>
private SerializableDictionary(SerializationInfo info, StreamingContext context) : base(info, context)
{
}
private string ItemTagName => DefaultItemTag;
private string KeyTagName => DefaultKeyTag;

View File

@@ -1,10 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Jellyfin.Xtream</RootNamespace>
<AssemblyVersion>0.6.1.0</AssemblyVersion>
<FileVersion>0.6.1.0</FileVersion>
<AssemblyVersion>0.6.2.0</AssemblyVersion>
<FileVersion>0.6.2.0</FileVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
@@ -13,8 +13,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.8.4" />
<PackageReference Include="Jellyfin.Model" Version="10.8.4" />
<PackageReference Include="Jellyfin.Controller" Version="10.9.1" />
<PackageReference Include="Jellyfin.Model" Version="10.9.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
@@ -24,7 +24,7 @@
<ItemGroup>
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.507" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
</ItemGroup>

View File

@@ -70,7 +70,7 @@ namespace Jellyfin.Xtream
List<ChannelInfo> items = new List<ChannelInfo>();
await foreach (StreamInfo channel in plugin.StreamService.GetLiveStreamsWithOverrides(cancellationToken))
{
ParsedName parsed = plugin.StreamService.ParseName(channel.Name);
ParsedName parsed = StreamService.ParseName(channel.Name);
items.Add(new ChannelInfo()
{
Id = channel.StreamId.ToString(CultureInfo.InvariantCulture),
@@ -170,7 +170,7 @@ namespace Jellyfin.Xtream
{
string key = $"xtream-epg-{channelId}";
ICollection<ProgramInfo>? items = null;
if (memoryCache.TryGetValue(key, out ICollection<ProgramInfo> o))
if (memoryCache.TryGetValue(key, out ICollection<ProgramInfo>? o))
{
items = o;
}
@@ -204,12 +204,6 @@ namespace Jellyfin.Xtream
select epg;
}
/// <inheritdoc />
public Task RecordLiveStream(string id, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public Task ResetTuner(string id, CancellationToken cancellationToken)
{

View File

@@ -132,15 +132,15 @@ namespace Jellyfin.Xtream
// - This will update the TV channels.
// - This will remove channels on credentials change.
TaskService.CancelIfRunningAndQueue(
"Emby.Server.Implementations",
"Emby.Server.Implementations.LiveTv.RefreshGuideScheduledTask");
"Jellyfin.LiveTv",
"Jellyfin.LiveTv.Guide.RefreshGuideScheduledTask");
// Force a refresh of Channels on configuration update.
// - This will update the channel entries.
// - This will remove channel entries on credentials change.
TaskService.CancelIfRunningAndQueue(
"Emby.Server.Implementations",
"Emby.Server.Implementations.Channels.RefreshChannelsScheduledTask");
"Jellyfin.LiveTv",
"Jellyfin.LiveTv.Channels.RefreshChannelsScheduledTask");
}
}
}

View File

@@ -0,0 +1,35 @@
// Copyright (C) 2022 Kevin Jilissen
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using MediaBrowser.Controller;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Xtream;
/// <inheritdoc />
public class PluginServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
serviceCollection.AddSingleton<ILiveTvService, LiveTvService>();
serviceCollection.AddSingleton<IChannel, CatchupChannel>();
serviceCollection.AddSingleton<IChannel, SeriesChannel>();
serviceCollection.AddSingleton<IChannel, VodChannel>();
}
}

View File

@@ -101,31 +101,25 @@ namespace Jellyfin.Xtream
/// <inheritdoc />
public async Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken)
{
Plugin plugin = Plugin.Instance;
if (string.IsNullOrEmpty(query.FolderId))
{
return await GetCategories(cancellationToken).ConfigureAwait(false);
}
if (plugin.StreamService.IsId(query.FolderId, StreamService.CategoryPrefix))
Guid guid = Guid.Parse(query.FolderId);
StreamService.FromGuid(guid, out int prefix, out int categoryId, out int seriesId, out int seasonId);
if (prefix == StreamService.SeriesCategoryPrefix)
{
int categoryId = plugin.StreamService.ParseId(query.FolderId, StreamService.CategoryPrefix);
return await GetSeries(categoryId, cancellationToken).ConfigureAwait(false);
}
if (plugin.StreamService.IsId(query.FolderId, StreamService.SeriesPrefix))
if (prefix == StreamService.SeriesPrefix)
{
int seriesId = plugin.StreamService.ParseId(query.FolderId, StreamService.SeriesPrefix);
return await GetSeasons(seriesId, cancellationToken).ConfigureAwait(false);
}
if (plugin.StreamService.IsId(query.FolderId, StreamService.SeasonPrefix))
if (prefix == StreamService.SeasonPrefix)
{
string folder = query.FolderId.Substring(StreamService.SeasonPrefix.Length);
string[] parts = folder.Split('-');
int seriesId = int.Parse(parts[0], System.Globalization.CultureInfo.InvariantCulture);
int seasonId = int.Parse(parts[1], System.Globalization.CultureInfo.InvariantCulture);
return await GetEpisodes(seriesId, seasonId, cancellationToken).ConfigureAwait(false);
}
@@ -137,14 +131,14 @@ namespace Jellyfin.Xtream
private ChannelItemInfo CreateChannelItemInfo(Series series)
{
ParsedName parsedName = Plugin.Instance.StreamService.ParseName(series.Name);
ParsedName parsedName = StreamService.ParseName(series.Name);
return new ChannelItemInfo()
{
CommunityRating = (float)series.Rating5Based,
DateModified = series.LastModified,
// FolderType = ChannelFolderType.Series,
Genres = GetGenres(series.Genre),
Id = $"{StreamService.SeriesPrefix}{series.SeriesId}",
Id = StreamService.ToGuid(StreamService.SeriesPrefix, series.CategoryId, series.SeriesId, 0).ToString(),
ImageUrl = series.Cover,
Name = parsedName.Title,
People = GetPeople(series.Cast),
@@ -178,7 +172,7 @@ namespace Jellyfin.Xtream
Season? season = series.Seasons.FirstOrDefault(s => s.SeasonId == seasonId);
if (season != null)
{
ParsedName parsedName = Plugin.Instance.StreamService.ParseName(season.Name);
ParsedName parsedName = StreamService.ParseName(season.Name);
name = parsedName.Title;
tags.AddRange(parsedName.Tags);
created = season.AirDate;
@@ -194,7 +188,7 @@ namespace Jellyfin.Xtream
DateCreated = created,
// FolderType = ChannelFolderType.Season,
Genres = GetGenres(serie.Genre),
Id = $"{StreamService.SeasonPrefix}{seriesId}-{seasonId}",
Id = StreamService.ToGuid(StreamService.SeasonPrefix, serie.CategoryId, seriesId, seasonId).ToString(),
ImageUrl = cover,
Name = name,
Overview = overview,
@@ -207,7 +201,7 @@ namespace Jellyfin.Xtream
private ChannelItemInfo CreateChannelItemInfo(SeriesStreamInfo series, Season? season, Episode episode)
{
Jellyfin.Xtream.Client.Models.SeriesInfo serie = series.Info;
ParsedName parsedName = Plugin.Instance.StreamService.ParseName(episode.Title);
ParsedName parsedName = StreamService.ParseName(episode.Title);
List<MediaSourceInfo> sources = new List<MediaSourceInfo>()
{
Plugin.Instance.StreamService.GetMediaSourceInfo(StreamType.Series, episode.EpisodeId, episode.ContainerExtension)
@@ -229,7 +223,7 @@ namespace Jellyfin.Xtream
ContentType = ChannelMediaContentType.Episode,
DateCreated = DateTimeOffset.FromUnixTimeSeconds(episode.Added).DateTime,
Genres = GetGenres(serie.Genre),
Id = $"{StreamService.EpisodePrefix}{episode.EpisodeId}",
Id = StreamService.ToGuid(StreamService.EpisodePrefix, 0, 0, episode.EpisodeId).ToString(),
ImageUrl = cover,
IsLiveStream = false,
MediaSources = sources,
@@ -246,7 +240,7 @@ namespace Jellyfin.Xtream
{
List<ChannelItemInfo> items = new List<ChannelItemInfo>(
(await Plugin.Instance.StreamService.GetSeriesCategories(cancellationToken).ConfigureAwait(false))
.Select((Category category) => Plugin.Instance.StreamService.CreateChannelItemInfo(category)));
.Select((Category category) => StreamService.CreateChannelItemInfo(StreamService.SeriesCategoryPrefix, category)));
return new ChannelItemResult()
{
Items = items,

View File

@@ -158,7 +158,7 @@ namespace Jellyfin.Xtream.Service
throw new ArgumentNullException("copyTask");
}
tokenSource.Cancel();
await tokenSource.CancelAsync().ConfigureAwait(false);
await copyTask.ConfigureAwait(false);
}

View File

@@ -37,29 +37,49 @@ namespace Jellyfin.Xtream.Service
public class StreamService
{
/// <summary>
/// The id prefix for category channel items.
/// The id prefix for VOD category channel items.
/// </summary>
public const string CategoryPrefix = "category-";
public const int VodCategoryPrefix = 0x5d774c35;
/// <summary>
/// The id prefix for stream channel items.
/// </summary>
public const string StreamPrefix = "stream-";
public const int StreamPrefix = 0x5d774c36;
/// <summary>
/// The id prefix for series channel items.
/// The id prefix for series category channel items.
/// </summary>
public const string SeriesPrefix = "series-";
public const int SeriesCategoryPrefix = 0x5d774c37;
/// <summary>
/// The id prefix for series category channel items.
/// </summary>
public const int SeriesPrefix = 0x5d774c38;
/// <summary>
/// The id prefix for season channel items.
/// </summary>
public const string SeasonPrefix = "seasons-";
public const int SeasonPrefix = 0x5d774c39;
/// <summary>
/// The id prefix for season channel items.
/// </summary>
public const string EpisodePrefix = "episode-";
public const int EpisodePrefix = 0x5d774c3a;
/// <summary>
/// The id prefix for catchup channel items.
/// </summary>
public const int CatchupPrefix = 0x5d774c3b;
/// <summary>
/// The id prefix for fallback EPG items.
/// </summary>
public const int FallbackPrefix = 0x5d774c3c;
/// <summary>
/// The id prefix for media source items.
/// </summary>
public const int MediaSourcePrefix = 0x5d774c3d;
private static readonly Regex TagRegex = new Regex(@"\[([^\]]+)\]|\|([^\|]+)\|");
@@ -89,7 +109,7 @@ namespace Jellyfin.Xtream.Service
/// </summary>
/// <param name="name">The name which should be parsed.</param>
/// <returns>A <see cref="ParsedName"/> struct containing the cleaned title and parsed tags.</returns>
public ParsedName ParseName(string name)
public static ParsedName ParseName(string name)
{
List<string> tags = new List<string>();
string title = TagRegex.Replace(
@@ -115,28 +135,6 @@ namespace Jellyfin.Xtream.Service
};
}
/// <summary>
/// Checks if the id string is an id with the given prefix.
/// </summary>
/// <param name="id">The id string.</param>
/// <param name="prefix">The prefix string.</param>
/// <returns>Whether or not the id string has the given prefix.</returns>
public bool IsId(string id, string prefix)
{
return id.StartsWith(prefix, StringComparison.InvariantCulture);
}
/// <summary>
/// Parses the given id by removing the prefix.
/// </summary>
/// <param name="id">The id string.</param>
/// <param name="prefix">The prefix string.</param>
/// <returns>The parsed it as integer.</returns>
public int ParseId(string id, string prefix)
{
return int.Parse(id.Substring(prefix.Length), CultureInfo.InvariantCulture);
}
private bool IsConfigured(SerializableDictionary<int, HashSet<int>> config, int category, int id)
{
HashSet<int>? values;
@@ -192,14 +190,15 @@ namespace Jellyfin.Xtream.Service
/// <summary>
/// Gets an channel item info for the category.
/// </summary>
/// <param name="prefix">The channel category prefix.</param>
/// <param name="category">The Xtream category.</param>
/// <returns>A channel item representing the category.</returns>
public ChannelItemInfo CreateChannelItemInfo(Category category)
public static ChannelItemInfo CreateChannelItemInfo(int prefix, Category category)
{
ParsedName parsedName = ParseName(category.CategoryName);
return new ChannelItemInfo()
{
Id = $"{CategoryPrefix}{category.CategoryId}",
Id = ToGuid(prefix, category.CategoryId, 0, 0).ToString(),
Name = category.CategoryName,
Tags = new List<string>(parsedName.Tags),
Type = ChannelItemType.Folder,
@@ -313,6 +312,63 @@ namespace Jellyfin.Xtream.Service
}
}
private static void StoreBytes(byte[] dst, int offset, int i)
{
byte[] intBytes = BitConverter.GetBytes(i);
if (BitConverter.IsLittleEndian)
{
Array.Reverse(intBytes);
}
Buffer.BlockCopy(intBytes, 0, dst, offset, 4);
}
/// <summary>
/// Gets a GUID representing the four 32-bit integers.
/// </summary>
/// <param name="i0">Bytes 0-3.</param>
/// <param name="i1">Bytes 4-7.</param>
/// <param name="i2">Bytes 8-11.</param>
/// <param name="i3">Bytes 12-15.</param>
/// <returns>Guid.</returns>
public static Guid ToGuid(int i0, int i1, int i2, int i3)
{
byte[] guid = new byte[16];
StoreBytes(guid, 0, i0);
StoreBytes(guid, 4, i1);
StoreBytes(guid, 8, i2);
StoreBytes(guid, 12, i3);
return new Guid(guid);
}
/// <summary>
/// Gets the four 32-bit integers represented in the GUID.
/// </summary>
/// <param name="guid">The input GUID.</param>
/// <param name="i0">Bytes 0-3.</param>
/// <param name="i1">Bytes 4-7.</param>
/// <param name="i2">Bytes 8-11.</param>
/// <param name="i3">Bytes 12-15.</param>
public static void FromGuid(Guid guid, out int i0, out int i1, out int i2, out int i3)
{
byte[] tmp = guid.ToByteArray();
if (BitConverter.IsLittleEndian)
{
Array.Reverse(tmp);
i0 = BitConverter.ToInt32(tmp, 12);
i1 = BitConverter.ToInt32(tmp, 8);
i2 = BitConverter.ToInt32(tmp, 4);
i3 = BitConverter.ToInt32(tmp, 0);
}
else
{
i0 = BitConverter.ToInt32(tmp, 0);
i1 = BitConverter.ToInt32(tmp, 4);
i2 = BitConverter.ToInt32(tmp, 8);
i3 = BitConverter.ToInt32(tmp, 12);
}
}
/// <summary>
/// Gets the media source information for the given Xtream stream.
/// </summary>
@@ -359,7 +415,7 @@ namespace Jellyfin.Xtream.Service
return new MediaSourceInfo()
{
EncoderProtocol = MediaProtocol.Http,
Id = id.ToString(CultureInfo.InvariantCulture),
Id = ToGuid(MediaSourcePrefix, (int)type, id, 0).ToString(),
IsInfiniteStream = isLive,
IsRemote = true,
Name = "default",

View File

@@ -18,26 +18,26 @@ namespace Jellyfin.Xtream.Service
/// <summary>
/// An enum describing the Xtream stream types.
/// </summary>
public enum StreamType
public enum StreamType : int
{
/// <summary>
/// Live IPTV.
/// </summary>
Live,
Live = 0,
/// <summary>
/// Catch up IPTV.
/// </summary>
CatchUp,
CatchUp = 1,
/// <summary>
/// On-demand series grouped in seasons and episodes.
/// </summary>
Series,
Series = 2,
/// <summary>
/// Video on-demand.
/// </summary>
Vod,
Vod = 3,
}
}

View File

@@ -100,15 +100,15 @@ namespace Jellyfin.Xtream
/// <inheritdoc />
public async Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken)
{
Plugin plugin = Plugin.Instance;
if (string.IsNullOrEmpty(query.FolderId))
{
return await GetCategories(cancellationToken).ConfigureAwait(false);
}
if (plugin.StreamService.IsId(query.FolderId, StreamService.CategoryPrefix))
Guid guid = Guid.Parse(query.FolderId);
StreamService.FromGuid(guid, out int prefix, out int categoryId, out int _, out int _);
if (prefix == StreamService.VodCategoryPrefix)
{
int categoryId = plugin.StreamService.ParseId(query.FolderId, StreamService.CategoryPrefix);
return await GetStreams(categoryId, cancellationToken).ConfigureAwait(false);
}
@@ -121,7 +121,7 @@ namespace Jellyfin.Xtream
private ChannelItemInfo CreateChannelItemInfo(StreamInfo stream)
{
long added = long.Parse(stream.Added, CultureInfo.InvariantCulture);
ParsedName parsedName = Plugin.Instance.StreamService.ParseName(stream.Name);
ParsedName parsedName = StreamService.ParseName(stream.Name);
List<MediaSourceInfo> sources = new List<MediaSourceInfo>()
{
Plugin.Instance.StreamService.GetMediaSourceInfo(StreamType.Vod, stream.StreamId, stream.ContainerExtension)
@@ -147,7 +147,7 @@ namespace Jellyfin.Xtream
{
List<ChannelItemInfo> items = new List<ChannelItemInfo>(
(await Plugin.Instance.StreamService.GetVodCategories(cancellationToken).ConfigureAwait(false))
.Select((Category category) => Plugin.Instance.StreamService.CreateChannelItemInfo(category)));
.Select((Category category) => StreamService.CreateChannelItemInfo(StreamService.VodCategoryPrefix, category)));
return new ChannelItemResult()
{
Items = items,

View File

@@ -1,9 +1,9 @@
---
name: "Jellyfin Xtream"
guid: "5d774c35-8567-46d3-a950-9bb8227a0c5d"
version: "0.6.1.0"
version: "0.6.2.0"
targetAbi: "10.8.4.0"
framework: "net6.0"
framework: "net8.0"
overview: "Stream content from an Xtream-compatible server."
description: >
Stream Live IPTV, Video On-Demand, and Series from an Xtream-compatible server using this plugin.