Compare commits

...
11 Commits
Author SHA1 Message Date
dtourolle e5f82c53da Update readme (acknowledge goggin/kodi plugin, clearer arch desc, problem with main channels)
🚀 Release Plugin / build-and-release (push) Failing after 2s
2025-11-14 21:25:22 +01:00
dtourolle fddad35cf4 more CI stuff
🏗️ Build Plugin / build (push) Successful in 2m23s
🧪 Test Plugin / test (push) Successful in 1m8s
2025-11-14 21:22:13 +01:00
dtourolle ce6c435a92 Added sports livestreams 2025-11-14 21:20:41 +01:00
dtourolle d830f8ae5f more CI fun
🏗️ Build Plugin / build (push) Failing after 2m19s
🧪 Test Plugin / test (push) Successful in 1m6s
2025-11-14 21:18:14 +01:00
dtourolle def475d0f3 remove debugging scripts
🏗️ Build Plugin / build (push) Failing after 2m16s
🧪 Test Plugin / test (push) Successful in 1m7s
2025-11-14 21:06:53 +01:00
dtourolle 70bcdf7103 more ci fixes
🏗️ Build Plugin / build (push) Failing after 1m15s
🧪 Test Plugin / test (push) Successful in 1m9s
2025-11-14 20:58:50 +01:00
dtourolle c3e3008d57 use pything venv
🏗️ Build Plugin / build (push) Failing after 1m24s
🧪 Test Plugin / test (push) Successful in 1m11s
2025-11-14 20:52:55 +01:00
dtourolle a3fc82f13c set dontet version
🏗️ Build Plugin / build (push) Failing after 1m41s
🧪 Test Plugin / test (push) Successful in 1m8s
2025-11-14 20:45:36 +01:00
dtourolle 24ed1de959 CI fix
🏗️ Build Plugin / build (push) Failing after 9s
🧪 Test Plugin / test (push) Failing after 11s
2025-11-14 20:43:09 +01:00
dtourolle eb6bfad0d2 CI fix
🏗️ Build Plugin / build (push) Failing after 7s
🧪 Test Plugin / test (push) Failing after 8s
2025-11-14 20:34:59 +01:00
dtourolle a8d3968925 gitea ci workflows
🏗️ Build Plugin / build (push) Failing after 1m12s
🧪 Test Plugin / test (push) Failing after 12s
2025-11-14 20:26:52 +01:00
17 changed files with 772 additions and 190 deletions
+61
View File
@@ -0,0 +1,61 @@
name: '🏗️ Build Plugin'
on:
push:
branches:
- master
paths-ignore:
- '**/*.md'
pull_request:
branches:
- master
paths-ignore:
- '**/*.md'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Verify .NET installation
run: dotnet --version
- name: Restore dependencies
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
- name: Build solution
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained
- name: Run tests
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
- name: Install JPRM
run: |
python3 -m venv /tmp/jprm-venv
/tmp/jprm-venv/bin/pip install jprm
- name: Build Jellyfin Plugin
id: jprm
run: |
# Create artifacts directory for JPRM output
mkdir -p artifacts
# Build plugin using JPRM
/tmp/jprm-venv/bin/jprm --verbosity=debug plugin build .
# Find the generated zip file
ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- name: Upload build artifact
uses: actions/upload-artifact@v3
with:
name: jellyfin-srfplay-plugin
path: ${{ steps.jprm.outputs.artifact }}
retention-days: 30
if-no-files-found: error
+102
View File
@@ -0,0 +1,102 @@
name: '🚀 Release Plugin'
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., v1.0.0)'
required: true
type: string
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Verify .NET installation
run: dotnet --version
- name: Get version
id: get_version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ github.event.inputs.version }}"
else
VERSION="${GITHUB_REF#refs/tags/}"
fi
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
echo "Building version: ${VERSION}"
- name: Update build.yaml with version
run: |
VERSION="${{ steps.get_version.outputs.version_number }}"
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
cat build.yaml
- name: Restore dependencies
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
- name: Build solution
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained
- name: Run tests
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
- name: Install JPRM
run: |
python3 -m venv /tmp/jprm-venv
/tmp/jprm-venv/bin/pip install jprm
- name: Build Jellyfin Plugin
id: jprm
run: |
# Create artifacts directory for JPRM output
mkdir -p artifacts
# Build plugin using JPRM
/tmp/jprm-venv/bin/jprm --verbosity=debug plugin build ./
# Find the generated zip file
ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
ARTIFACT_NAME=$(basename "${ARTIFACT}")
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- name: Generate changelog
id: changelog
run: |
# Get commits since last tag
PREV_TAG=$(git tag --sort=-creatordate | grep -v "${{ steps.get_version.outputs.version }}" | head -n 1)
if [ -z "$PREV_TAG" ]; then
CHANGELOG=$(git log --pretty=format:"- %s (%h)" --no-merges)
else
CHANGELOG=$(git log ${PREV_TAG}..${{ steps.get_version.outputs.version }} --pretty=format:"- %s (%h)" --no-merges)
fi
# Save to file for release notes
echo "## What's Changed" > RELEASE_NOTES.md
echo "" >> RELEASE_NOTES.md
echo "${CHANGELOG}" >> RELEASE_NOTES.md
echo "" >> RELEASE_NOTES.md
echo "**Full Changelog**: ${PREV_TAG}...${{ steps.get_version.outputs.version }}" >> RELEASE_NOTES.md
- name: Create Release
uses: actions/gitea-release-action@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ steps.get_version.outputs.version }}
name: Release ${{ steps.get_version.outputs.version }}
body_path: RELEASE_NOTES.md
draft: false
prerelease: false
files: |
${{ steps.jprm.outputs.artifact }}
build.yaml
+44
View File
@@ -0,0 +1,44 @@
name: '🧪 Test Plugin'
on:
push:
branches:
- master
- develop
paths-ignore:
- '**/*.md'
pull_request:
branches:
- master
- develop
paths-ignore:
- '**/*.md'
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Verify .NET installation
run: dotnet --version
- name: Restore dependencies
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
- name: Build solution
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Debug --no-restore --no-self-contained
- name: Run tests
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Debug --verbosity normal --logger "trx;LogFileName=test-results.trx"
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: '**/test-results.trx'
retention-days: 7
@@ -5,13 +5,13 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
@@ -93,4 +93,10 @@ public class Chapter
/// </summary> /// </summary>
[JsonPropertyName("mediaType")] [JsonPropertyName("mediaType")]
public string? MediaType { get; set; } public string? MediaType { get; set; }
/// <summary>
/// Gets or sets the type (e.g., SCHEDULED_LIVESTREAM).
/// </summary>
[JsonPropertyName("type")]
public string? Type { get; set; }
} }
@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
/// <summary>
/// TV Program entry in the guide.
/// </summary>
public class PlayV3TvProgram
{
/// <summary>
/// Gets or sets the URN identifier.
/// </summary>
[JsonPropertyName("urn")]
public string? Urn { get; set; }
/// <summary>
/// Gets or sets the title.
/// </summary>
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>
/// Gets or sets the lead/description.
/// </summary>
[JsonPropertyName("lead")]
public string? Lead { get; set; }
/// <summary>
/// Gets or sets the description.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
/// <summary>
/// Gets or sets the image URL.
/// </summary>
[JsonPropertyName("imageUrl")]
public string? ImageUrl { get; set; }
/// <summary>
/// Gets or sets the content type (e.g., SCHEDULED_LIVESTREAM).
/// </summary>
[JsonPropertyName("type")]
public string? Type { get; set; }
/// <summary>
/// Gets or sets the media type.
/// </summary>
[JsonPropertyName("mediaType")]
public string? MediaType { get; set; }
/// <summary>
/// Gets or sets the vendor (e.g., SRF, RTS).
/// </summary>
[JsonPropertyName("vendor")]
public string? Vendor { get; set; }
/// <summary>
/// Gets or sets the start time.
/// </summary>
[JsonPropertyName("date")]
public DateTime? Date { get; set; }
/// <summary>
/// Gets or sets the valid from time (for scheduled livestreams).
/// </summary>
[JsonPropertyName("validFrom")]
public DateTime? ValidFrom { get; set; }
/// <summary>
/// Gets or sets the valid to time (for scheduled livestreams).
/// </summary>
[JsonPropertyName("validTo")]
public DateTime? ValidTo { get; set; }
/// <summary>
/// Gets or sets the duration in milliseconds.
/// </summary>
[JsonPropertyName("duration")]
public long? Duration { get; set; }
/// <summary>
/// Gets or sets the channel ID.
/// </summary>
[JsonPropertyName("channelId")]
public string? ChannelId { get; set; }
/// <summary>
/// Gets or sets the channel title.
/// </summary>
[JsonPropertyName("channelTitle")]
public string? ChannelTitle { get; set; }
/// <summary>
/// Gets or sets whether this is blocked (DRM).
/// </summary>
[JsonPropertyName("blocked")]
public bool? Blocked { get; set; }
/// <summary>
/// Gets or sets whether this is geoblocked.
/// </summary>
[JsonPropertyName("geoblocked")]
public bool? Geoblocked { get; set; }
}
@@ -0,0 +1,16 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
/// <summary>
/// TV Program Guide response from Play v3 API.
/// </summary>
public class PlayV3TvProgramGuideResponse
{
/// <summary>
/// Gets the list of TV program entries.
/// </summary>
[JsonPropertyName("data")]
public IReadOnlyList<PlayV3TvProgram>? Data { get; init; }
}
@@ -6,6 +6,7 @@ using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models; using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
using Jellyfin.Plugin.SRFPlay.Configuration; using Jellyfin.Plugin.SRFPlay.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -485,6 +486,58 @@ public class SRFApiClient : IDisposable
} }
} }
/// <summary>
/// Gets scheduled livestreams for sports or news events from the Play v3 API.
/// </summary>
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="eventType">The event type (SPORT or NEWS).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of scheduled livestream entries.</returns>
public async Task<System.Collections.Generic.IReadOnlyList<PlayV3TvProgram>?> GetScheduledLivestreamsAsync(
string businessUnit,
string eventType = "SPORT",
CancellationToken cancellationToken = default)
{
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var url = $"{baseUrl}livestreams?eventType={eventType.ToUpperInvariant()}";
_logger.LogInformation("Fetching scheduled livestreams for eventType={EventType} from business unit: {BusinessUnit}", eventType, businessUnit);
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
// The response structure is: { "data": { "scheduledLivestreams": [...] } }
var jsonDoc = JsonSerializer.Deserialize<JsonElement>(content, _jsonOptions);
if (jsonDoc.TryGetProperty("data", out var dataElement) &&
dataElement.TryGetProperty("scheduledLivestreams", out var livestreamsElement))
{
var livestreams = JsonSerializer.Deserialize<System.Collections.Generic.List<PlayV3TvProgram>>(
livestreamsElement.GetRawText(),
_jsonOptions);
_logger.LogInformation("Successfully fetched {Count} scheduled livestreams for eventType={EventType}", livestreams?.Count ?? 0, eventType);
return livestreams;
}
_logger.LogWarning("No scheduledLivestreams found in response");
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching scheduled livestreams for eventType={EventType} from business unit: {BusinessUnit}", eventType, businessUnit);
return null;
}
}
/// <inheritdoc/> /// <inheritdoc/>
public void Dispose() public void Dispose()
{ {
@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
@@ -145,6 +146,15 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
ImageUrl = null ImageUrl = null
}); });
items.Add(new ChannelItemInfo
{
Id = "live_sports",
Name = "Live Sports & Events",
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
ImageUrl = null
});
// Add category folders if enabled and CategoryService is available // Add category folders if enabled and CategoryService is available
if (config?.EnableCategoryFolders == true && _categoryService != null) if (config?.EnableCategoryFolders == true && _categoryService != null)
{ {
@@ -201,6 +211,54 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false); items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
} }
// Live Sports & Events
else if (query.FolderId == "live_sports")
{
try
{
var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf";
using var apiClient = new Api.SRFApiClient(_loggerFactory);
var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
if (scheduledLivestreams != null)
{
// Filter for upcoming/current events (within next 7 days) that have URNs
var now = DateTime.UtcNow;
var weekFromNow = now.AddDays(7);
var upcomingEvents = scheduledLivestreams
.Where(p => p.Urn != null &&
!string.IsNullOrEmpty(p.Title) &&
p.ValidFrom != null &&
p.ValidFrom.Value.ToUniversalTime() <= weekFromNow)
.OrderBy(p => p.ValidFrom)
.ToList();
_logger.LogInformation("Found {Count} scheduled live sports events", upcomingEvents.Count);
var urns = upcomingEvents.Select(e => e.Urn!).ToList();
items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
// Enhance items with scheduled time information
foreach (var item in items)
{
var matchingEvent = upcomingEvents.FirstOrDefault(e => item.ProviderIds.ContainsKey("SRF") && item.ProviderIds["SRF"] == e.Urn);
if (matchingEvent?.ValidFrom != null)
{
var eventTime = matchingEvent.ValidFrom.Value;
item.Name = $"[{eventTime:dd.MM HH:mm}] {matchingEvent.Title}";
item.PremiereDate = eventTime;
}
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load live sports events");
}
}
// Category folder - show videos for this category // Category folder - show videos for this category
else if (query.FolderId?.StartsWith("category_", StringComparison.Ordinal) == true) else if (query.FolderId?.StartsWith("category_", StringComparison.Ordinal) == true)
{ {
@@ -316,7 +374,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
var enabledTopics = config?.EnabledTopics != null && config.EnabledTopics.Count > 0 var enabledTopics = config?.EnabledTopics != null && config.EnabledTopics.Count > 0
? string.Join(",", config.EnabledTopics) ? string.Join(",", config.EnabledTopics)
: "all"; : "all";
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}"; var date = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{date}";
} }
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken) private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
@@ -373,17 +432,63 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
// Generate deterministic GUID from URN // Generate deterministic GUID from URN
var itemId = UrnToGuid(urn); var itemId = UrnToGuid(urn);
// Get stream URL and authenticate it
var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference);
// For scheduled livestreams that haven't started, streamUrl might be null
var isUpcomingLivestream = chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl);
if (!string.IsNullOrEmpty(streamUrl))
{
// Authenticate the stream URL (required for all SRF streams, especially livestreams)
streamUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).ConfigureAwait(false);
}
else if (isUpcomingLivestream)
{
// Use a placeholder for upcoming events
streamUrl = "http://placeholder.local/upcoming.m3u8";
_logger.LogInformation(
"URN {Urn}: Upcoming livestream '{Title}' - stream will be available at {ValidFrom}",
urn,
chapter.Title,
chapter.ValidFrom);
}
// Build overview with event time for upcoming livestreams
var overview = chapter.Description ?? chapter.Lead;
if (isUpcomingLivestream && chapter.ValidFrom != null)
{
var eventStart = chapter.ValidFrom.Value.ToString("dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture);
overview = $"[Upcoming Event - Starts at {eventStart}]\n\n{overview}";
}
// Get image URL - prefer chapter image, fall back to show image if available
var imageUrl = chapter.ImageUrl;
if (string.IsNullOrEmpty(imageUrl) && mediaComposition.Show != null)
{
imageUrl = mediaComposition.Show.ImageUrl;
_logger.LogDebug("URN {Urn}: Using show image as fallback", urn);
}
if (string.IsNullOrEmpty(imageUrl))
{
_logger.LogWarning("URN {Urn}: No image URL available for '{Title}'", urn, chapter.Title);
}
// Use ValidFrom for premiere date if this is an upcoming livestream, otherwise use Date
var premiereDate = isUpcomingLivestream ? chapter.ValidFrom?.ToUniversalTime() : chapter.Date?.ToUniversalTime();
var item = new ChannelItemInfo var item = new ChannelItemInfo
{ {
Id = itemId, Id = itemId,
Name = chapter.Title, Name = chapter.Title,
Overview = chapter.Description ?? chapter.Lead, Overview = overview,
ImageUrl = chapter.ImageUrl, ImageUrl = imageUrl,
Type = ChannelItemType.Media, Type = ChannelItemType.Media,
ContentType = ChannelMediaContentType.Episode, ContentType = ChannelMediaContentType.Episode,
MediaType = ChannelMediaType.Video, MediaType = ChannelMediaType.Video,
DateCreated = chapter.Date?.ToUniversalTime(), DateCreated = chapter.Date?.ToUniversalTime(),
PremiereDate = chapter.Date?.ToUniversalTime(), PremiereDate = premiereDate,
ProductionYear = chapter.Date?.Year, ProductionYear = chapter.Date?.Year,
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null, RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
ProviderIds = new Dictionary<string, string> ProviderIds = new Dictionary<string, string>
@@ -396,7 +501,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{ {
Id = itemId, Id = itemId,
Name = chapter.Title, Name = chapter.Title,
Path = _streamResolver.GetStreamUrl(chapter, config.QualityPreference), Path = streamUrl,
Protocol = MediaBrowser.Model.MediaInfo.MediaProtocol.Http, Protocol = MediaBrowser.Model.MediaInfo.MediaProtocol.Http,
Container = "m3u8", Container = "m3u8",
SupportsDirectStream = true, SupportsDirectStream = true,
@@ -93,6 +93,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
var chapter = mediaComposition.ChapterList[0]; var chapter = mediaComposition.ChapterList[0];
if (!string.IsNullOrEmpty(chapter.ImageUrl)) if (!string.IsNullOrEmpty(chapter.ImageUrl))
{ {
_logger.LogDebug("URN {Urn}: Adding chapter image: {ImageUrl}", urn, chapter.ImageUrl);
list.Add(new RemoteImageInfo list.Add(new RemoteImageInfo
{ {
Url = chapter.ImageUrl, Url = chapter.ImageUrl,
@@ -107,6 +108,10 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
ProviderName = Name ProviderName = Name
}); });
} }
else
{
_logger.LogDebug("URN {Urn}: No chapter image available", urn);
}
} }
// Extract images from show // Extract images from show
@@ -114,6 +119,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
{ {
if (!string.IsNullOrEmpty(mediaComposition.Show.ImageUrl)) if (!string.IsNullOrEmpty(mediaComposition.Show.ImageUrl))
{ {
_logger.LogDebug("URN {Urn}: Adding show image: {ImageUrl}", urn, mediaComposition.Show.ImageUrl);
list.Add(new RemoteImageInfo list.Add(new RemoteImageInfo
{ {
Url = mediaComposition.Show.ImageUrl, Url = mediaComposition.Show.ImageUrl,
@@ -124,6 +130,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
if (!string.IsNullOrEmpty(mediaComposition.Show.BannerImageUrl)) if (!string.IsNullOrEmpty(mediaComposition.Show.BannerImageUrl))
{ {
_logger.LogDebug("URN {Urn}: Adding show banner: {BannerUrl}", urn, mediaComposition.Show.BannerImageUrl);
list.Add(new RemoteImageInfo list.Add(new RemoteImageInfo
{ {
Url = mediaComposition.Show.BannerImageUrl, Url = mediaComposition.Show.BannerImageUrl,
@@ -133,7 +140,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
} }
} }
_logger.LogDebug("Found {Count} images for URN: {Urn}", list.Count, urn); _logger.LogInformation("URN {Urn}: Found {Count} images for '{ItemName}'", urn, list.Count, item.Name);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -72,8 +72,14 @@ public class SRFMediaProvider : IMediaSourceProvider
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources); return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
} }
// For scheduled livestreams, use shorter cache TTL (5 minutes) so they refresh when they go live
// For regular content, use configured cache duration
var cacheDuration = urn.Contains("scheduled_livestream", StringComparison.OrdinalIgnoreCase)
? 5
: config.CacheDurationMinutes;
// Try cache first // Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes); var mediaComposition = _metadataCache.GetMediaComposition(urn, cacheDuration);
// If not in cache, fetch from API // If not in cache, fetch from API
if (mediaComposition == null) if (mediaComposition == null)
@@ -113,12 +119,72 @@ public class SRFMediaProvider : IMediaSourceProvider
// Get stream URL based on quality preference // Get stream URL based on quality preference
var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference); var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference);
if (string.IsNullOrEmpty(streamUrl)) // Check if this is an upcoming livestream that hasn't started yet
var isUpcomingLivestream = chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl);
if (string.IsNullOrEmpty(streamUrl) && !isUpcomingLivestream)
{ {
_logger.LogWarning("Could not resolve stream URL for URN: {Urn}", urn); _logger.LogWarning("Could not resolve stream URL for URN: {Urn}", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources); return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
} }
// For upcoming livestreams, check if the event has started
if (isUpcomingLivestream)
{
if (chapter.ValidFrom != null)
{
var eventStart = chapter.ValidFrom.Value.ToUniversalTime();
var now = DateTime.UtcNow;
if (eventStart > now)
{
_logger.LogInformation(
"URN {Urn}: Livestream '{Title}' hasn't started yet (starts at {ValidFrom}). User should refresh when live.",
urn,
chapter.Title,
chapter.ValidFrom);
// Return empty sources - event not yet playable
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
}
// Event should be live now - re-fetch media composition without cache
using var freshApiClient = new SRFApiClient(_loggerFactory);
var freshMediaComposition = freshApiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).GetAwaiter().GetResult();
if (freshMediaComposition?.ChapterList != null && freshMediaComposition.ChapterList.Count > 0)
{
var freshChapter = freshMediaComposition.ChapterList[0];
streamUrl = _streamResolver.GetStreamUrl(freshChapter, config.QualityPreference);
if (!string.IsNullOrEmpty(streamUrl))
{
// Update cache with fresh data
_metadataCache.SetMediaComposition(urn, freshMediaComposition);
chapter = freshChapter;
_logger.LogInformation("URN {Urn}: Livestream is now live, got fresh stream URL", urn);
}
else
{
_logger.LogWarning("URN {Urn}: Livestream should be live but still no stream URL available", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
}
else
{
_logger.LogWarning("URN {Urn}: Failed to fetch fresh media composition for livestream", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
}
// Authenticate the stream URL (required for all SRF streams, especially livestreams)
if (!string.IsNullOrEmpty(streamUrl))
{
streamUrl = _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).GetAwaiter().GetResult();
_logger.LogDebug("Authenticated stream URL for URN: {Urn}", urn);
}
// Create media source // Create media source
var mediaSource = new MediaSourceInfo var mediaSource = new MediaSourceInfo
{ {
@@ -1,5 +1,9 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models; using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration; using Jellyfin.Plugin.SRFPlay.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -9,9 +13,11 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary> /// <summary>
/// Service for resolving stream URLs from media composition resources. /// Service for resolving stream URLs from media composition resources.
/// </summary> /// </summary>
public class StreamUrlResolver public class StreamUrlResolver : IDisposable
{ {
private readonly ILogger<StreamUrlResolver> _logger; private readonly ILogger<StreamUrlResolver> _logger;
private readonly HttpClient _httpClient;
private bool _disposed;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="StreamUrlResolver"/> class. /// Initializes a new instance of the <see cref="StreamUrlResolver"/> class.
@@ -20,6 +26,7 @@ public class StreamUrlResolver
public StreamUrlResolver(ILogger<StreamUrlResolver> logger) public StreamUrlResolver(ILogger<StreamUrlResolver> logger)
{ {
_logger = logger; _logger = logger;
_httpClient = new HttpClient();
} }
/// <summary> /// <summary>
@@ -137,6 +144,23 @@ public class StreamUrlResolver
/// <returns>True if playable content is available.</returns> /// <returns>True if playable content is available.</returns>
public bool HasPlayableContent(Chapter chapter) public bool HasPlayableContent(Chapter chapter)
{ {
// For scheduled livestreams that haven't started yet, resources won't exist
// but we still want to show them. The stream will become available when the event starts.
if (chapter?.Type == "SCHEDULED_LIVESTREAM")
{
var now = DateTime.UtcNow;
var hasStarted = chapter.ValidFrom == null || chapter.ValidFrom.Value.ToUniversalTime() <= now;
if (!hasStarted)
{
_logger.LogInformation(
"Scheduled livestream '{Title}' hasn't started yet (starts at {ValidFrom}), will be playable when live",
chapter.Title,
chapter.ValidFrom);
return true; // Show it, stream will be available when event starts
}
}
if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0) if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0)
{ {
return false; return false;
@@ -195,4 +219,87 @@ public class StreamUrlResolver
// Return first available // Return first available
return resources.FirstOrDefault(); return resources.FirstOrDefault();
} }
/// <summary>
/// Authenticates a stream URL by fetching an Akamai token.
/// Based on the Kodi addon implementation.
/// </summary>
/// <param name="streamUrl">The unauthenticated stream URL.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The authenticated stream URL with token.</returns>
public async Task<string> GetAuthenticatedStreamUrlAsync(string streamUrl, CancellationToken cancellationToken = default)
{
try
{
// Parse the stream URL to extract path components
var uri = new Uri(streamUrl);
var pathSegments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (pathSegments.Length < 2)
{
_logger.LogWarning("Stream URL has insufficient path segments for authentication: {Url}", streamUrl);
return streamUrl;
}
// Build ACL path: /{segment1}/{segment2}/*
var aclPath = $"/{pathSegments[0]}/{pathSegments[1]}/*";
var tokenUrl = $"https://tp.srgssr.ch/akahd/token?acl={Uri.EscapeDataString(aclPath)}";
_logger.LogDebug("Fetching auth token from: {TokenUrl}", tokenUrl);
var response = await _httpClient.GetAsync(tokenUrl, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var jsonContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var tokenResponse = JsonSerializer.Deserialize<JsonElement>(jsonContent);
// Extract authparams from response
if (tokenResponse.TryGetProperty("token", out var token) &&
token.TryGetProperty("authparams", out var authParams))
{
var authParamsValue = authParams.GetString();
if (!string.IsNullOrEmpty(authParamsValue))
{
// Append auth params to original URL
var separator = streamUrl.Contains('?', StringComparison.Ordinal) ? "&" : "?";
var authenticatedUrl = $"{streamUrl}{separator}{authParamsValue}";
_logger.LogInformation("Successfully authenticated stream URL");
return authenticatedUrl;
}
}
_logger.LogWarning("No auth params found in token response, returning original URL");
return streamUrl;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to authenticate stream URL: {Url}", streamUrl);
return streamUrl; // Return original URL as fallback
}
}
/// <inheritdoc/>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases the unmanaged resources and optionally releases the managed resources.
/// </summary>
/// <param name="disposing">True to release both managed and unmanaged resources; false to release only unmanaged resources.</param>
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
_httpClient?.Dispose();
}
_disposed = true;
}
}
} }
-68
View File
@@ -1,68 +0,0 @@
using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
class Program
{
static async Task Main(string[] args)
{
var handler = new HttpClientHandler
{
Proxy = new WebProxy("http://192.168.1.37:3128"),
UseProxy = true,
AutomaticDecompression = DecompressionMethods.All,
CheckCertificateRevocationList = false
};
using var client = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(30),
DefaultRequestVersion = new Version(1, 1)
};
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
client.DefaultRequestHeaders.Accept.ParseAdd("*/*");
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("en-US,en;q=0.9");
var url = "https://il.srgssr.ch/integrationlayer/2.0/mediaComposition/byUrn/urn:srf:video:b84713f0-f81b-460f-9b0f-d0517310fb4f.json";
Console.WriteLine($"Testing: {url}");
Console.WriteLine("Using proxy: http://192.168.1.37:3128");
Console.WriteLine();
try
{
Console.WriteLine("Making request...");
var response = await client.GetAsync(url);
Console.WriteLine($"Status Code: {response.StatusCode}");
Console.WriteLine($"Headers:");
foreach (var header in response.Headers)
{
Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}");
}
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
Console.WriteLine($"\nContent Length: {content.Length}");
Console.WriteLine($"First 500 chars: {content.Substring(0, Math.Min(500, content.Length))}");
}
else
{
var content = await response.Content.ReadAsStringAsync();
Console.WriteLine($"\nError content: {content}");
}
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.GetType().Name}");
Console.WriteLine($"Message: {ex.Message}");
if (ex.InnerException != null)
{
Console.WriteLine($"Inner: {ex.InnerException.Message}");
}
Console.WriteLine($"\nStack trace:\n{ex.StackTrace}");
}
}
}
+74 -26
View File
@@ -4,13 +4,15 @@ A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-d
## Features ## Features
- Access to SRF Play VOD content (video-on-demand only, no DRM-protected content) - Access to SRF Play VOD content (video-on-demand, no DRM-protected content)
- **Live Sports Streaming** - Watch scheduled sports events (skiing, Formula 1, football, tennis, etc.)
- Support for all Swiss broadcasting units (SRF, RTS, RSI, RTR, SWI) - Support for all Swiss broadcasting units (SRF, RTS, RSI, RTR, SWI)
- Automatic content expiration handling - Automatic content expiration handling
- Latest and trending content discovery - Latest and trending content discovery
- Quality selection (Auto, SD, HD) - Quality selection (Auto, SD, HD)
- HLS streaming support - HLS streaming support with Akamai token authentication
- Proxy support for routing traffic through alternate gateways - Proxy support for routing traffic through alternate gateways
- Smart caching with reduced TTL for upcoming livestreams
## Project Status ## Project Status
@@ -41,16 +43,20 @@ A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-d
- Quality-based filtering - Quality-based filtering
- DRM content filtering - DRM content filtering
- Content expiration checking - Content expiration checking
- Akamai token authentication
- Upcoming livestream detection
- ✅ Metadata Cache Service - ✅ Metadata Cache Service
- Efficient caching with configurable duration - Efficient caching with configurable duration
- Thread-safe with ReaderWriterLockSlim - Thread-safe with ReaderWriterLockSlim
- IDisposable implementation - IDisposable implementation
- Dynamic TTL for scheduled livestreams
- ✅ Content Expiration Service - ✅ Content Expiration Service
- Automatic expiration checking - Automatic expiration checking
- Library cleanup of expired content - Library cleanup of expired content
- Statistics and monitoring - Statistics and monitoring
- ✅ Content Refresh Service - ✅ Content Refresh Service
- Latest and trending content discovery - Latest and trending content discovery
- Scheduled sports livestreams
- Automatic cache population - Automatic cache population
- Recommendations system - Recommendations system
@@ -73,18 +79,28 @@ A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-d
- ✅ Jellyfin provider interfaces implementation - ✅ Jellyfin provider interfaces implementation
- ✅ Plugin initialization and configuration - ✅ Plugin initialization and configuration
#### Phase 8: Live Sports Streaming
- ✅ Play v3 API integration for scheduled livestreams
- ✅ Sports events folder in channel
- ✅ Upcoming event detection and display
- ✅ Akamai token authentication for streams
- ✅ Dynamic cache refresh for live events
- ✅ Event scheduling with ValidFrom/ValidTo handling
- ✅ Automatic stream URL resolution when events go live
### ✅ Build Status ### ✅ Build Status
**Successfully compiling!** All code analysis warnings resolved. **Successfully compiling!** All code analysis warnings resolved.
### 🧪 Testing Status ### 🧪 Testing Status
- [ ] Unit tests - [ ] Unit tests
- [ ] Integration testing with Jellyfin instance - [ ] Integration testing with Jellyfin instance
- [ ] End-to-end playback testing - [ ] End-to-end playback testing for VOD content
- [ ] Live sports streaming validation
### 📝 Next Steps ### 📝 Next Steps
1. Test the plugin with a live Jellyfin instance 1. Test live sports streaming when events are scheduled
2. Verify content discovery and playback 2. Verify Akamai token authentication
3. Test expiration handling 3. Test with different business units (RTS, RSI, RTR)
4. Add unit tests 4. Add unit tests
5. Performance optimization if needed 5. Performance optimization if needed
@@ -94,18 +110,29 @@ A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-d
### Key Endpoints ### Key Endpoints
- `GET /mediaComposition/byUrn/{urn}.json` - Get video metadata and playable URLs **Integration Layer API v2.0:**
- `GET /mediaComposition/byUrn/{urn}` - Get video metadata and playable URLs
- `GET /video/{businessUnit}/latest` - Get latest videos - `GET /video/{businessUnit}/latest` - Get latest videos
- `GET /video/{businessUnit}/trending` - Get trending videos - `GET /video/{businessUnit}/trending` - Get trending videos
- `GET /showList/{businessUnit}` - Get all shows (to be implemented)
- `GET /episodeList/{businessUnit}/{showId}` - Get episodes for a show (to be implemented) **Play v3 API:**
- `GET /play/v3/api/{bu}/production/livestreams?eventType=SPORT` - Get scheduled sports livestreams
- `GET /play/v3/api/{bu}/production/shows/{id}` - Get show details
- `GET /play/v3/api/{bu}/production/videos-by-show-id?showId={id}` - Get episodes for a show
**Akamai Token Service:**
- `GET /akahd/token?acl=/{path}/*` - Authenticate stream URLs for playback
### URN Format ### URN Format
- `urn:{bu}:video:{id}` - Video URN - `urn:{bu}:video:{id}` - Video URN (VOD content)
- `urn:{bu}:video:livestream_{channel}` - Livestream URN (not supported due to Widevine DRM) - `urn:{bu}:scheduled_livestream:video:{id}` - Scheduled sports livestream URN (supported)
- `urn:{bu}:video:livestream_{channel}` - Live TV channel URN (not supported due to Widevine DRM)
Example: `urn:srf:video:livestream_SRF1` Examples:
- `urn:srf:video:c4927fcf-4ab4-4bcf-be4e-00e4ee9e6d6b` (VOD)
- `urn:srf:scheduled_livestream:video:7b31c9a6-a96e-4c0e-bfc2-f0d6237f2233` (Sports event)
- `urn:srf:video:c4927fcf-e1a0-0001-7edd-1ef01d441651` (SRF 1 Live - DRM protected)
## Building the Plugin ## Building the Plugin
@@ -131,6 +158,10 @@ The compiled plugin will be in `bin/Debug/net8.0/`
2. Copy the compiled DLL to your Jellyfin plugins directory 2. Copy the compiled DLL to your Jellyfin plugins directory
3. Restart Jellyfin 3. Restart Jellyfin
4. Configure the plugin in Jellyfin Dashboard → Plugins → SRF Play 4. Configure the plugin in Jellyfin Dashboard → Plugins → SRF Play
5. The SRF Play channel will appear in Jellyfin with three main folders:
- **Latest Videos** - Recently published content
- **Trending Videos** - Popular content
- **Live Sports & Events** - Scheduled sports livestreams (skiing, F1, football, etc.)
## Configuration ## Configuration
@@ -168,16 +199,22 @@ Jellyfin.Plugin.SRFPlay/
│ │ ├── Chapter.cs │ │ ├── Chapter.cs
│ │ ├── Resource.cs │ │ ├── Resource.cs
│ │ ├── Show.cs │ │ ├── Show.cs
│ │ ── Episode.cs │ │ ── Episode.cs
│ │ └── PlayV3/ # Play v3 API models
│ │ ├── PlayV3TvProgram.cs
│ │ └── PlayV3TvProgramGuideResponse.cs
│ └── SRFApiClient.cs # HTTP client for SRF API │ └── SRFApiClient.cs # HTTP client for SRF API
├── Channels/
│ └── SRFPlayChannel.cs # Channel implementation
├── Configuration/ ├── Configuration/
│ ├── PluginConfiguration.cs │ ├── PluginConfiguration.cs
│ └── configPage.html │ └── configPage.html
├── Services/ ├── Services/
│ ├── StreamUrlResolver.cs # HLS stream resolution │ ├── StreamUrlResolver.cs # HLS stream resolution & authentication
│ ├── MetadataCache.cs # Caching layer │ ├── MetadataCache.cs # Caching layer
│ ├── ContentExpirationService.cs # Expiration management │ ├── ContentExpirationService.cs # Expiration management
── ContentRefreshService.cs # Content discovery ── ContentRefreshService.cs # Content discovery
│ └── CategoryService.cs # Topic/category management
├── Providers/ ├── Providers/
│ ├── SRFSeriesProvider.cs # Series metadata │ ├── SRFSeriesProvider.cs # Series metadata
│ ├── SRFEpisodeProvider.cs # Episode metadata │ ├── SRFEpisodeProvider.cs # Episode metadata
@@ -192,21 +229,24 @@ Jellyfin.Plugin.SRFPlay/
### Key Components ### Key Components
1. **API Client**: Handles all HTTP requests to SRF Integration Layer 1. **API Client**: Handles all HTTP requests to SRF Integration Layer and Play v3 API
2. **Stream Resolver**: Extracts and selects optimal HLS streams 2. **Channel**: SRF Play channel with Latest, Trending, and Live Sports folders
3. **Configuration**: User-configurable settings via Jellyfin dashboard 3. **Stream Resolver**: Extracts and selects optimal HLS streams with Akamai authentication
4. **Metadata Cache**: Thread-safe caching with configurable expiration 4. **Configuration**: User-configurable settings via Jellyfin dashboard
5. **Content Providers**: Jellyfin integration for series, episodes, images, and media 5. **Metadata Cache**: Thread-safe caching with dynamic TTL for livestreams
6. **Scheduled Tasks**: Automatic content refresh and expiration management 6. **Content Providers**: Jellyfin integration for series, episodes, images, and media sources
7. **Service Layer**: Content discovery, expiration handling, and stream resolution 7. **Scheduled Tasks**: Automatic content refresh and expiration management
8. **Service Layer**: Content discovery, expiration handling, stream resolution, and category management
## Important Notes ## Important Notes
### Content Limitations ### Content Limitations
- **No DRM content**: Only non-DRM protected content is accessible (no Widevine) - **No DRM content**: Only non-DRM protected content is accessible (no Widevine/FairPlay)
- **No live TV channels**: Only VOD content and live streams without DRM - **No live TV channels**: Main channels (SRF 1, SRF zwei, SRF info) use DRM and are not supported
- **Sports livestreams supported**: Scheduled sports events (skiing, F1, football, etc.) work without DRM
- **Content expiration**: SRF content has validity periods, plugin tracks and removes expired content - **Content expiration**: SRF content has validity periods, plugin tracks and removes expired content
- **Upcoming events**: Sports events appear before they start but require channel refresh to play once live
- **No subtitles**: Subtitle support not currently implemented - **No subtitles**: Subtitle support not currently implemented
### Extensibility ### Extensibility
@@ -225,12 +265,15 @@ Currently focused on SRF but easily extensible.
### Current Status ### Current Status
All core functionality is implemented and compiling successfully! The plugin includes: All core functionality is implemented and compiling successfully! The plugin includes:
- Complete API integration with SRF Play - Complete API integration with SRF Play (Integration Layer v2.0 and Play v3)
- **Live sports streaming** with scheduled event detection
- Channel with Latest, Trending, and Live Sports folders
- Metadata providers for series and episodes - Metadata providers for series and episodes
- Image fetching and caching - Image fetching and caching
- HLS stream playback - HLS stream playback with Akamai token authentication
- Automatic content expiration handling - Automatic content expiration handling
- Scheduled tasks for content refresh - Scheduled tasks for content refresh
- Smart caching with dynamic TTL for upcoming livestreams
### Testing ### Testing
@@ -256,8 +299,13 @@ This plugin is in active development. Contributions welcome!
See LICENSE file for details. See LICENSE file for details.
## Acknowledgments
This plugin was developed with inspiration from the excellent [Kodi SRG SSR addon](https://github.com/goggle/script.module.srgssr) by [@goggle](https://github.com/goggle). The Kodi addon served as a fantastic reference for understanding the SRG SSR API structure, authentication mechanisms, and handling of scheduled livestreams.
## References ## References
- [SRF Play](https://www.srf.ch/play) - [SRF Play](https://www.srf.ch/play)
- [Jellyfin Plugin Documentation](https://jellyfin.org/docs/general/server/plugins/) - [Jellyfin Plugin Documentation](https://jellyfin.org/docs/general/server/plugins/)
- [SRG SSR Integration Layer API](https://il.srgssr.ch/) - [SRG SSR Integration Layer API](https://il.srgssr.ch/)
- [Kodi SRG SSR Addon](https://github.com/goggle/script.module.srgssr) - Reference implementation
-7
View File
@@ -1,7 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>
+12 -7
View File
@@ -1,16 +1,21 @@
--- ---
name: "Template" name: "SRFPlay"
guid: "eb5d7894-8eef-4b36-aa6f-5d124e828ce1" guid: "eb5d7894-8eef-4b36-aa6f-5d124e828ce1"
version: "1.0.0.0" version: "1.0.0.0"
targetAbi: "10.9.0.0" targetAbi: "10.9.0.0"
framework: "net8.0" framework: "net8.0"
overview: "Short description about your plugin" overview: "SRF Play plugin for Jellyfin"
description: > description: >
This is a longer description that can span more than one Provides access to SRF Play content in Jellyfin, including
line and include details about your plugin. live streams and on-demand content from Swiss Radio and Television.
category: "General" category: "LiveTV"
owner: "jellyfin" owner: "jellyfin"
imageUrl: "https://www.srf.ch/static/img/srf-logo.svg"
artifacts: artifacts:
- "Jellyfin.Plugin.Template.dll" - "Jellyfin.Plugin.SRFPlay.dll"
build_type: "dotnet"
dotnet_configuration: "Release"
dotnet_framework: "net8.0"
project: "Jellyfin.Plugin.SRFPlay/Jellyfin.Plugin.SRFPlay.csproj"
changelog: > changelog: >
changelog Initial release
-70
View File
@@ -1,70 +0,0 @@
using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
class Program
{
static async Task Main(string[] args)
{
var handler = new HttpClientHandler
{
Proxy = new WebProxy("http://192.168.1.37:3128"),
UseProxy = true,
AutomaticDecompression = DecompressionMethods.All,
CheckCertificateRevocationList = false
};
using var client = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(30),
DefaultRequestVersion = new Version(1, 1)
};
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
client.DefaultRequestHeaders.Accept.ParseAdd("*/*");
client.DefaultRequestHeaders.AcceptEncoding.ParseAdd("gzip, deflate, br");
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("en-US,en;q=0.9");
client.DefaultRequestHeaders.Connection.ParseAdd("keep-alive");
var url = "https://il.srgssr.ch/integrationlayer/2.0/mediaComposition/byUrn/urn:srf:video:b84713f0-f81b-460f-9b0f-d0517310fb4f.json";
Console.WriteLine($"Testing: {url}");
Console.WriteLine("Using proxy: http://192.168.1.37:3128");
Console.WriteLine();
try
{
Console.WriteLine("Making request...");
var response = await client.GetAsync(url);
Console.WriteLine($"Status Code: {response.StatusCode}");
Console.WriteLine($"Headers:");
foreach (var header in response.Headers)
{
Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}");
}
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
Console.WriteLine($"\nContent Length: {content.Length}");
Console.WriteLine($"First 500 chars: {content.Substring(0, Math.Min(500, content.Length))}");
}
else
{
var content = await response.Content.ReadAsStringAsync();
Console.WriteLine($"\nError content: {content}");
}
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.GetType().Name}");
Console.WriteLine($"Message: {ex.Message}");
if (ex.InnerException != null)
{
Console.WriteLine($"Inner: {ex.InnerException.Message}");
}
Console.WriteLine($"\nStack trace:\n{ex.StackTrace}");
}
}
}