TV channel customization #44

Merged
Kevinjil merged 3 commits from feature/issue-13 into master 2022-10-09 11:14:05 +00:00
14 changed files with 293 additions and 14 deletions

View File

@@ -0,0 +1,43 @@
// 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/>.
namespace Jellyfin.Xtream.Api.Models
{
/// <summary>
/// Override configuration for a Live TV channel.
/// </summary>
public class ChannelResponse
{
/// <summary>
/// Gets or sets the Xtream API id of the TV channel.
/// </summary>
public int Id { get; set; }
/// <summary>
/// Gets or sets the TV channel number.
/// </summary>
public int Number { get; set; }
/// <summary>
/// Gets or sets the TV channel name.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the url of the channel logo.
/// </summary>
public string LogoUrl { get; set; } = string.Empty;
}
}

View File

@@ -71,6 +71,15 @@ namespace Jellyfin.Xtream.Api
CatchupDuration = 0,
};
private static ChannelResponse CreateChannelResponse(StreamInfo stream) =>
new ChannelResponse()
{
Id = stream.StreamId,
LogoUrl = stream.StreamIcon,
Name = stream.Name,
Number = stream.Num,
};
/// <summary>
/// Get all Live TV categories.
/// </summary>
@@ -184,5 +193,23 @@ namespace Jellyfin.Xtream.Api
return Ok(series.Select((Series s) => CreateItemResponse(s)));
}
}
/// <summary>
/// Get all configured TV channels.
/// </summary>
/// <param name="cancellationToken">The cancellation token for cancelling requests.</param>
/// <returns>An enumerable containing the streams.</returns>
[Authorize(Policy = "RequiresElevation")]
[HttpGet("LiveTv")]
public async Task<ActionResult<IEnumerable<StreamInfo>>> GetLiveTvChannels(CancellationToken cancellationToken)
{
List<ChannelResponse> channels = new List<ChannelResponse>();
await foreach (StreamInfo stream in Plugin.Instance.StreamService.GetLiveStreams(cancellationToken))
{
channels.Add(CreateChannelResponse(stream));
}
return Ok(channels);
}
}
}

View File

@@ -116,7 +116,7 @@ namespace Jellyfin.Xtream
{
Plugin plugin = Plugin.Instance;
List<ChannelItemInfo> items = new List<ChannelItemInfo>();
await foreach (StreamInfo channel in plugin.StreamService.GetLiveStreams(cancellationToken))
await foreach (StreamInfo channel in plugin.StreamService.GetLiveStreamsWithOverrides(cancellationToken))
{
if (!channel.TvArchive)
{

View File

@@ -0,0 +1,45 @@
// 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/>.
namespace Jellyfin.Xtream.Configuration
{
/// <summary>
/// Override configuration for a Live TV channel.
/// </summary>
public class ChannelOverrides
{
/// <summary>
/// Initializes a new instance of the <see cref="ChannelOverrides"/> class.
/// </summary>
public ChannelOverrides()
{
}
/// <summary>
/// Gets or sets the TV channel number.
/// </summary>
public int? Number { get; set; }
/// <summary>
/// Gets or sets the TV channel name.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets or sets the url of the channel logo.
/// </summary>
public string? LogoUrl { get; set; }
}
}

View File

@@ -39,6 +39,7 @@ namespace Jellyfin.Xtream.Configuration
LiveTv = new SerializableDictionary<int, HashSet<int>>();
Vod = new SerializableDictionary<int, HashSet<int>>();
Series = new SerializableDictionary<int, HashSet<int>>();
LiveTvOverrides = new SerializableDictionary<int, ChannelOverrides>();
}
/// <summary>
@@ -85,6 +86,11 @@ namespace Jellyfin.Xtream.Configuration
/// Gets or sets the streams displayed in Series.
/// </summary>
public SerializableDictionary<int, HashSet<int>> Series { get; set; }
/// <summary>
/// Gets or sets the channel override configuration for Live TV.
/// </summary>
public SerializableDictionary<int, ChannelOverrides> LiveTvOverrides { get; set; }
}
}
#pragma warning restore CA2227

View File

@@ -26,3 +26,11 @@
font-size: 1em;
vertical-align: middle;
}
.overrides-table {
width: 100%;
}
.overrides-table thead th:first-child {
width: 0;
}

View File

@@ -180,15 +180,23 @@ const fetchJson = (url) => ApiClient.fetch({
url: ApiClient.getUrl(url),
});
const filter = (obj, predicate) => Object.keys(obj)
.filter(key => predicate(obj[key]))
.reduce((res, key) => (res[key] = obj[key], res), {});
const tabs = [
{
href: url('XtreamCredentials.html'),
name: 'Xtream Credentials'
name: 'Credentials'
},
{
href: url('XtreamLive.html'),
name: 'Live TV'
},
{
href: url('XtreamLiveOverrides.html'),
name: 'TV overrides'
},
{
href: url('XtreamVod.html'),
name: 'Video On-Demand',
@@ -210,6 +218,7 @@ const pluginConfig = {
export default {
fetchJson,
filter,
pluginConfig,
populateCategoriesTable,
setTabs,

View File

@@ -0,0 +1,31 @@
<div id="XtreamLivePage" data-role="page" class="page type-interior pluginConfigurationPage withTabs"
data-require="emby-input,emby-button" data-controller="__plugin/XtreamLiveOverrides.js">
<div data-role="content">
<div class="content-primary">
<form id="XtreamLiveOverridesForm">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">TV channel overrides</h2>
</div>
<div class="inputContainer">
<table class="overrides-table">
<thead>
<tr>
<th>Number</th>
<th>Name</th>
<th>Logo</th>
</tr>
</thead>
<tbody id="LiveChannels">
</tbody>
</table>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,86 @@
export default function (view) {
const createChannelRow = (channel, overrides) => {
const tr = document.createElement('tr');
tr.dataset['channelId'] = channel.Id;
let td = document.createElement('td');
const number = document.createElement('input');
number.type = 'number';
number.setAttribute('is', 'emby-input');
number.placeholder = channel.Number;
number.value = overrides.Number ?? '';
number.onchange = () => number.value ?
overrides.Number = parseInt(number.value) :
delete overrides.Number;
td.appendChild(number);
tr.appendChild(td);
td = document.createElement('td');
const name = document.createElement('input');
name.type = 'text';
name.setAttribute('is', 'emby-input');
name.placeholder = channel.Name;
name.value = overrides.Name ?? '';
name.onchange = () => name.value ?
overrides.Name = name.value :
delete overrides.Name;
td.appendChild(name);
tr.appendChild(td);
td = document.createElement('td');
const image = document.createElement('input');
image.type = 'text';
image.setAttribute('is', 'emby-input');
image.placeholder = channel.LogoUrl;
image.value = overrides.LogoUrl ?? '';
image.onchange = () => image.value ?
overrides.LogoUrl = image.value :
delete overrides.LogoUrl;
td.appendChild(image);
tr.appendChild(td);
return tr;
};
view.addEventListener("viewshow", () => import(
ApiClient.getUrl("web/ConfigurationPage", {
name: "Xtream.js",
})
).then((Xtream) => Xtream.default
).then((Xtream) => {
const pluginId = Xtream.pluginConfig.UniqueId;
Xtream.setTabs(2);
const getConfig = ApiClient.getPluginConfiguration(pluginId);
const table = view.querySelector('#LiveChannels');
Dashboard.showLoadingMsg();
Promise.all([
getConfig.then((config) => config.LiveTvOverrides),
Xtream.fetchJson('Xtream/LiveTv'),
]).then(([data, channels]) => {
for (const channel of channels) {
data[channel.Id] ??= {};
const row = createChannelRow(channel, data[channel.Id]);
table.appendChild(row);
}
Dashboard.hideLoadingMsg();
view.querySelector('#XtreamLiveOverridesForm').addEventListener('submit', (e) => {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(pluginId).then((config) => {
config.LiveTvOverrides = Xtream.filter(
data,
overrides => Object.keys(overrides).length > 0
);
ApiClient.updatePluginConfiguration(pluginId, config).then((result) => {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
e.preventDefault();
return false;
});
});
}));
}

View File

@@ -6,7 +6,7 @@ export default function (view) {
).then((Xtream) => Xtream.default
).then((Xtream) => {
const pluginId = Xtream.pluginConfig.UniqueId;
Xtream.setTabs(3);
Xtream.setTabs(4);
const getConfig = ApiClient.getPluginConfiguration(pluginId);
const visible = view.querySelector("#Visible");

View File

@@ -6,7 +6,7 @@ export default function (view) {
).then((Xtream) => Xtream.default
).then((Xtream) => {
const pluginId = Xtream.pluginConfig.UniqueId;
Xtream.setTabs(2);
Xtream.setTabs(3);
const getConfig = ApiClient.getPluginConfiguration(pluginId);
const visible = view.querySelector("#Visible");

View File

@@ -68,12 +68,13 @@ namespace Jellyfin.Xtream
{
Plugin plugin = Plugin.Instance;
List<ChannelInfo> items = new List<ChannelInfo>();
await foreach (StreamInfo channel in plugin.StreamService.GetLiveStreams(cancellationToken))
await foreach (StreamInfo channel in plugin.StreamService.GetLiveStreamsWithOverrides(cancellationToken))
{
ParsedName parsed = plugin.StreamService.ParseName(channel.Name);
items.Add(new ChannelInfo()
{
Id = channel.StreamId.ToString(System.Globalization.CultureInfo.InvariantCulture),
Id = channel.StreamId.ToString(CultureInfo.InvariantCulture),
Number = channel.Num.ToString(CultureInfo.InvariantCulture),
ImageUrl = channel.StreamIcon,
Name = parsed.Title,
Tags = parsed.Tags,

View File

@@ -94,14 +94,14 @@ namespace Jellyfin.Xtream
public TaskService TaskService { get; init; }
private static PluginPageInfo CreateStatic(string name) => new PluginPageInfo
{
Name = name,
EmbeddedResourcePath = string.Format(
CultureInfo.InvariantCulture,
"{0}.Configuration.Web.{1}",
typeof(Plugin).Namespace,
name),
};
{
Name = name,
EmbeddedResourcePath = string.Format(
CultureInfo.InvariantCulture,
"{0}.Configuration.Web.{1}",
typeof(Plugin).Namespace,
name),
};
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
@@ -114,6 +114,8 @@ namespace Jellyfin.Xtream
CreateStatic("Xtream.js"),
CreateStatic("XtreamLive.html"),
CreateStatic("XtreamLive.js"),
CreateStatic("XtreamLiveOverrides.html"),
CreateStatic("XtreamLiveOverrides.js"),
CreateStatic("XtreamSeries.html"),
CreateStatic("XtreamSeries.js"),
CreateStatic("XtreamVod.html"),

View File

@@ -168,6 +168,27 @@ namespace Jellyfin.Xtream.Service
}
}
/// <summary>
/// Gets an async iterator for the configured channels after applying the configured overrides.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>IAsyncEnumerable{StreamInfo}.</returns>
public async IAsyncEnumerable<StreamInfo> GetLiveStreamsWithOverrides([EnumeratorCancellation] CancellationToken cancellationToken)
{
PluginConfiguration config = Plugin.Instance.Configuration;
await foreach (StreamInfo stream in GetLiveStreams(cancellationToken))
{
if (config.LiveTvOverrides.TryGetValue(stream.StreamId, out ChannelOverrides? overrides))
{
stream.Num = overrides.Number ?? stream.Num;
stream.Name = overrides.Name ?? stream.Name;
stream.StreamIcon = overrides.LogoUrl ?? stream.StreamIcon;
}
yield return stream;
}
}
/// <summary>
/// Gets an channel item info for the category.
/// </summary>