Skip to content

Commit

Permalink
Merge pull request #22 from Kevinjil/feature/issue-17
Browse files Browse the repository at this point in the history
Implement Catch-up support
  • Loading branch information
Kevinjil authored Jun 27, 2022
2 parents cd1972a + e273379 commit 68b1799
Show file tree
Hide file tree
Showing 8 changed files with 313 additions and 40 deletions.
237 changes: 237 additions & 0 deletions Jellyfin.Xtream/CatchupChannel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
// 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 System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Xtream.Client;
using Jellyfin.Xtream.Client.Models;
using Jellyfin.Xtream.Service;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Channels;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;

namespace Jellyfin.Xtream
{
/// <summary>
/// The Xtream Codes API channel.
/// </summary>
public class CatchupChannel : IChannel
{
private readonly ILogger<CatchupChannel> logger;

/// <summary>
/// Initializes a new instance of the <see cref="CatchupChannel"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
public CatchupChannel(ILogger<CatchupChannel> logger)
{
this.logger = logger;
}

/// <inheritdoc />
public string? Name => "Xtream Catch-up";

/// <inheritdoc />
public string? Description => "Rewatch IPTV streamed from the Xtream-compatible server.";

/// <inheritdoc />
public string DataVersion => Plugin.Instance.Creds.ToString() + Random.Shared.NextInt64();

/// <inheritdoc />
public string HomePageUrl => string.Empty;

/// <inheritdoc />
public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience;

/// <inheritdoc />
public InternalChannelFeatures GetChannelFeatures()
{
return new InternalChannelFeatures
{
ContentTypes = new List<ChannelMediaContentType>
{
ChannelMediaContentType.TvExtra,
},

MediaTypes = new List<ChannelMediaType>
{
ChannelMediaType.Video
},
};
}

/// <inheritdoc />
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
{
switch (type)
{
default:
throw new ArgumentException("Unsupported image type: " + type);
}
}

/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedChannelImages()
{
return new List<ImageType>
{
// ImageType.Primary
};
}

/// <inheritdoc />
public async Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(query.FolderId))
{
return await GetChannels(cancellationToken).ConfigureAwait(false);
}

int separator = query.FolderId.IndexOf('-', StringComparison.InvariantCulture);
string categoryId = query.FolderId.Substring(0, separator);
int channelId = int.Parse(query.FolderId.Substring(separator + 1), CultureInfo.InvariantCulture);
return await GetStreams(categoryId, channelId, cancellationToken).ConfigureAwait(false);
}

private async Task<ChannelItemResult> GetChannels(CancellationToken cancellationToken)
{
Plugin plugin = Plugin.Instance;
List<ChannelItemInfo> items = new List<ChannelItemInfo>();
await foreach (StreamInfo channel in plugin.StreamService.GetLiveStreams(cancellationToken))
{
if (!channel.TvArchive)
{
// Channel has no catch-up support.
continue;
}

ParsedName parsedName = plugin.StreamService.ParseName(channel.Name);
items.Add(new ChannelItemInfo()
{
Id = $"{channel.CategoryId}-{channel.StreamId}",
ImageUrl = channel.StreamIcon,
Name = parsedName.Title,
Tags = new List<string>(parsedName.Tags),
Type = ChannelItemType.Folder,
});
}

ChannelItemResult result = new ChannelItemResult()
{
Items = items,
TotalRecordCount = items.Count
};
return result;
}

private async Task<ChannelItemResult> GetStreams(string categoryId, int channelId, CancellationToken cancellationToken)
{
Plugin plugin = Plugin.Instance;
using (XtreamClient client = new XtreamClient())
{
StreamInfo? channel = (
await client.GetLiveStreamsByCategoryAsync(plugin.Creds, categoryId, cancellationToken).ConfigureAwait(false)
).FirstOrDefault(s => s.StreamId == channelId);
if (channel == null)
{
throw new ArgumentException($"Channel with id {channelId} not found in category {categoryId}");
}

string channelString = channelId.ToString(CultureInfo.InvariantCulture);
EpgListings epgs = await client.GetEpgInfoAsync(plugin.Creds, channelId, cancellationToken).ConfigureAwait(false);
List<ChannelItemInfo> items = new List<ChannelItemInfo>();

// Create fallback single-stream catch-up if no EPG is available.
if (epgs.Listings.Count == 0)
{
DateTime now = DateTime.UtcNow;
DateTime start = now.AddDays(-channel.TvArchiveDuration);
int duration = channel.TvArchiveDuration * 24 * 60;
return new ChannelItemResult()
{
Items = new List<ChannelItemInfo>()
{
new ChannelItemInfo()
{
ContentType = ChannelMediaContentType.TvExtra,
FolderType = ChannelFolderType.Container,
Id = $"fallback-{channelId}",
IsLiveStream = false,
MediaSources = new List<MediaSourceInfo>()
{
plugin.StreamService.GetMediaSourceInfo(StreamType.CatchUp, channelString, start: start, durationMinutes: duration)
},
MediaType = ChannelMediaType.Video,
Name = $"No EPG available",
Type = ChannelItemType.Media,
}
},
TotalRecordCount = items.Count
};
}

// Include all EPGs that start during the maximum cache interval of Jellyfin for channels.
DateTime startBefore = DateTime.UtcNow.AddHours(3);
DateTime startAfter = DateTime.UtcNow.AddDays(-channel.TvArchiveDuration);
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);
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>()
{
plugin.StreamService.GetMediaSourceInfo(StreamType.CatchUp, channelString, start: epg.StartLocalTime, durationMinutes: durationMinutes)
};

items.Add(new ChannelItemInfo()
{
ContentType = ChannelMediaContentType.TvExtra,
DateCreated = epg.Start,
FolderType = ChannelFolderType.Container,
Id = id,
IsLiveStream = false,
MediaSources = sources,
MediaType = ChannelMediaType.Video,
Name = $"{dateTitle} - {parsedName.Title}",
PremiereDate = epg.Start,
Tags = new List<string>(parsedName.Tags),
Type = ChannelItemType.Media,
});
}

ChannelItemResult result = new ChannelItemResult()
{
Items = items,
TotalRecordCount = items.Count
};
return result;
}
}

/// <inheritdoc />
public bool IsEnabledFor(string userId)
{
return true;
}
}
}
3 changes: 3 additions & 0 deletions Jellyfin.Xtream/Client/Models/EpgInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ public class EpgInfo
[JsonProperty("start_timestamp")]
public DateTime Start { get; set; }

[JsonProperty("start")]
public DateTime StartLocalTime { get; set; }

[JsonConverter(typeof(UnixDateTimeConverter))]
[JsonProperty("stop_timestamp")]
public DateTime End { get; set; }
Expand Down
2 changes: 1 addition & 1 deletion Jellyfin.Xtream/Client/Models/StreamInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public class StreamInfo
public string CustomSid { get; set; } = string.Empty;

[JsonProperty("tv_archive")]
public int TvArchive { get; set; }
public bool TvArchive { get; set; }

[JsonProperty("direct_source")]
public string DirectSource { get; set; } = string.Empty;
Expand Down
10 changes: 10 additions & 0 deletions Jellyfin.Xtream/Configuration/Web/XtreamLive.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ export default function (view) {
tr.dataset['streamId'] = data.StreamId;

let td = document.createElement('td');
if (data.TvArchive) {
let span = document.createElement('span');
span.ariaHidden = true;
span.title = `Catch-up supported for ${data.TvArchiveDuration} days.`;
span.className = 'material-icons fiber_manual_record';
td.appendChild(span);
}
tr.appendChild(td);

td = document.createElement('td');
td.innerHTML = data.Name;
tr.appendChild(td);

Expand Down
2 changes: 1 addition & 1 deletion Jellyfin.Xtream/LiveChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ private async Task<ChannelItemResult> GetVideos(string categoryId, CancellationT
ParsedName parsedName = plugin.StreamService.ParseName(channel.Name);
List<MediaSourceInfo> sources = new List<MediaSourceInfo>()
{
plugin.StreamService.GetMediaSourceInfo(StreamType.Live, id, string.Empty)
plugin.StreamService.GetMediaSourceInfo(StreamType.Live, id)
};

items.Add(new ChannelItemInfo()
Expand Down
39 changes: 2 additions & 37 deletions Jellyfin.Xtream/LiveTvService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,10 @@
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Xtream.Client;
using Jellyfin.Xtream.Client.Models;
using Jellyfin.Xtream.Configuration;
using Jellyfin.Xtream.Service;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Library;
Expand Down Expand Up @@ -65,45 +63,12 @@ public LiveTvService(IServerApplicationHost appHost, IHttpClientFactory httpClie
/// <inheritdoc />
public string HomePageUrl => string.Empty;

/// <summary>
/// Gets an async iterator for the configured channels.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>IAsyncEnumerable{StreamInfo}.</returns>
private async IAsyncEnumerable<StreamInfo> GetLiveStreams([EnumeratorCancellation] CancellationToken cancellationToken)
{
Plugin plugin = Plugin.Instance;
PluginConfiguration config = plugin.Configuration;
using (XtreamClient client = new XtreamClient())
{
foreach (var entry in config.LiveTv)
{
string categoryId = entry.Key.ToString(CultureInfo.InvariantCulture);
HashSet<int> streams = entry.Value;
if (cancellationToken.IsCancellationRequested)
{
break;
}

IEnumerable<StreamInfo> channels = await client.GetLiveStreamsByCategoryAsync(plugin.Creds, categoryId, cancellationToken).ConfigureAwait(false);
foreach (StreamInfo channel in channels)
{
// If the set is empty, include all channels for the category.
if (streams.Count == 0 || streams.Contains(channel.StreamId))
{
yield return channel;
}
}
}
}
}

/// <inheritdoc />
public async Task<IEnumerable<ChannelInfo>> GetChannelsAsync(CancellationToken cancellationToken)
{
Plugin plugin = Plugin.Instance;
List<ChannelInfo> items = new List<ChannelInfo>();
await foreach (StreamInfo channel in GetLiveStreams(cancellationToken))
await foreach (StreamInfo channel in plugin.StreamService.GetLiveStreams(cancellationToken))
{
ParsedName parsed = plugin.StreamService.ParseName(channel.Name);
items.Add(new ChannelInfo()
Expand Down Expand Up @@ -260,7 +225,7 @@ public Task<ILiveStream> GetChannelStreamWithDirectStreamProvider(string channel
}

Plugin plugin = Plugin.Instance;
MediaSourceInfo mediaSourceInfo = plugin.StreamService.GetMediaSourceInfo(StreamType.Live, channelId, null, true);
MediaSourceInfo mediaSourceInfo = plugin.StreamService.GetMediaSourceInfo(StreamType.Live, channelId, restream: true);
stream = new Restream(appHost, httpClientFactory, logger, mediaSourceInfo);
return Task.FromResult(stream);
}
Expand Down
Loading

0 comments on commit 68b1799

Please sign in to comment.