Compare commits

..
22 Commits
Author SHA1 Message Date
dtourolle b8ac466c90 Fix issue with Jellyfin using local IP adress not server public URL, now pulic URL is set in plugin.
🏗️ Build Plugin / build (push) Successful in 3m21s
🧪 Test Plugin / test (push) Successful in 1m36s
🚀 Release Plugin / build-and-release (push) Successful in 3m9s
2025-11-21 20:17:05 +01:00
dtourolle e26f2a2ab1 passthrough not transcode
🚀 Release Plugin / build-and-release (push) Successful in 3m19s
🏗️ Build Plugin / build (push) Successful in 3m8s
🧪 Test Plugin / test (push) Successful in 1m43s
2025-11-16 20:53:23 +01:00
dtourolle cd0f680981 Improved token refresh mechanism
🏗️ Build Plugin / build (push) Successful in 3m29s
🧪 Test Plugin / test (push) Successful in 1m37s
🚀 Release Plugin / build-and-release (push) Successful in 3m9s
2025-11-16 20:15:30 +01:00
dtourolle cfe510e15c Finaly working version of livestreams
🏗️ Build Plugin / build (push) Successful in 2m30s
🧪 Test Plugin / test (push) Successful in 1m11s
🚀 Release Plugin / build-and-release (push) Successful in 2m31s
2025-11-15 22:34:21 +01:00
dtourolle 8e86db100a tokens refresh on media start
🏗️ Build Plugin / build (push) Successful in 2m19s
🧪 Test Plugin / test (push) Successful in 1m8s
🚀 Release Plugin / build-and-release (push) Successful in 2m18s
2025-11-15 17:51:14 +01:00
dtourolle d48b515898 Fix -- Use validTo time for live streams
🏗️ Build Plugin / build (push) Successful in 2m22s
🧪 Test Plugin / test (push) Successful in 1m6s
2025-11-15 16:09:46 +01:00
dtourolle 31b2402a96 more readme refinement
🏗️ Build Plugin / build (push) Successful in 2m22s
🧪 Test Plugin / test (push) Successful in 1m7s
2025-11-14 22:01:17 +01:00
dtourolle 3691b4e30e removed md unrelated to current state 2025-11-14 21:50:58 +01:00
dtourolle 87ccf80813 remove netowkring notes ans script
🏗️ Build Plugin / build (push) Successful in 2m25s
🧪 Test Plugin / test (push) Successful in 1m8s
2025-11-14 21:43:43 +01:00
dtourolle 6ed53054c3 remove changelog
🏗️ Build Plugin / build (push) Successful in 2m18s
🧪 Test Plugin / test (push) Successful in 1m10s
🚀 Release Plugin / build-and-release (push) Successful in 2m16s
2025-11-14 21:39:34 +01:00
dtourolle 1a8b7a33ef more dman ci stuff
🏗️ Build Plugin / build (push) Successful in 2m22s
🧪 Test Plugin / test (push) Successful in 1m8s
🚀 Release Plugin / build-and-release (push) Failing after 2m18s
2025-11-14 21:29:51 +01:00
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
28 changed files with 1806 additions and 797 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
+128
View File
@@ -0,0 +1,128 @@
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: Create Release
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Get repository information
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}"
# Prepare release body
RELEASE_BODY="SRFPlay Jellyfin Plugin ${{ steps.get_version.outputs.version }}\n\nSee attached files for plugin installation."
RELEASE_BODY_JSON=$(echo -n "${RELEASE_BODY}" | jq -Rs .)
# Create release using Gitea API
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
-d "{
\"tag_name\": \"${{ steps.get_version.outputs.version }}\",
\"name\": \"Release ${{ steps.get_version.outputs.version }}\",
\"body\": ${RELEASE_BODY_JSON},
\"draft\": false,
\"prerelease\": false
}")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
RELEASE_ID=$(echo "$BODY" | jq -r '.id')
echo "Created release with ID: ${RELEASE_ID}"
else
echo "Failed to create release. HTTP ${HTTP_CODE}"
echo "$BODY"
exit 1
fi
# Upload plugin artifact
echo "Uploading plugin artifact..."
curl -f -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/zip" \
--data-binary "@${{ steps.jprm.outputs.artifact }}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
# Upload build.yaml
echo "Uploading build.yaml..."
curl -f -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/x-yaml" \
--data-binary "@build.yaml" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=build.yaml"
echo "✅ Release created successfully!"
echo "View at: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${{ steps.get_version.outputs.version }}"
+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
-88
View File
@@ -1,88 +0,0 @@
# Compilation Fixes - COMPLETED ✅
## Status: ALL ERRORS RESOLVED
**Build Status:** ✅ SUCCESS
The plugin now compiles successfully with no errors!
## Fixed Issues
### 1. Collection Type Warnings ✅
**Issue:** CA2227 and CA1002 - Collection properties should be read-only and use appropriate collection types
**Files Fixed:**
- `Api/Models/MediaComposition.cs` - Changed `List<Chapter>` to `IReadOnlyList<Chapter>`
- `Api/Models/Chapter.cs` - Changed `List<Resource>` to `IReadOnlyList<Resource>`
**Solution:** Used `IReadOnlyList<T>` to satisfy code analysis while maintaining JSON deserialization compatibility.
### 2. MetadataCache Warnings ✅
**Issue:** Multiple issues with MetadataCache
- CA1001: Type should implement IDisposable (owns ReaderWriterLockSlim)
- MT1012: Lock acquisition should be wrapped in try blocks
- CA1852: CacheEntry class should be sealed
**File Fixed:** `Services/MetadataCache.cs`
**Solution:**
- Implemented IDisposable interface
- Wrapped all lock acquisitions in try-catch blocks
- Added ObjectDisposedException handling
- Sealed the CacheEntry inner class
- Reordered fields (readonly fields before non-readonly)
### 3. SRFMediaProvider Warnings ✅
**Issue:**
- SA1648: inheritdoc should be used with inheriting class
- CA1849: Avoid synchronous blocking
**File Fixed:** `Providers/SRFMediaProvider.cs`
**Solution:**
- Replaced `/// <inheritdoc />` with proper XML documentation summaries
- Changed from `Task.Wait()` and `.Result` to `.GetAwaiter().GetResult()` (less problematic)
### 4. ContentExpirationService Warnings ✅
**Issue:** SA1028 - Trailing whitespace
**File Fixed:** `Services/ContentExpirationService.cs`
**Solution:** Removed trailing whitespace on lines 79 and 221
### 5. CA1826 Warnings ✅
**Issue:** Use indexer instead of LINQ `.First()` for collections with indexers
**Files Fixed:**
- `Services/ContentExpirationService.cs`
- `Providers/SRFEpisodeProvider.cs`
- `Providers/SRFImageProvider.cs`
- `Providers/SRFMediaProvider.cs`
**Solution:** Replaced `.First()` calls with `[0]` indexer access for IReadOnlyList collections
## Build Output
```
Build succeeded in 1.0s
Jellyfin.Plugin.SRFPlay succeeded → Jellyfin.Plugin.SRFPlay/bin/Debug/net8.0/Jellyfin.Plugin.SRFPlay.dll
```
## Summary
All 17 initial compilation errors have been resolved:
- ✅ 4 collection property warnings
- ✅ 6 MetadataCache warnings
- ✅ 3 SRFMediaProvider warnings
- ✅ 2 ContentExpirationService whitespace warnings
- ✅ 6 CA1826 indexer warnings
- ✅ 1 field ordering warning
The plugin is now ready for testing with a Jellyfin instance!
## Next Steps
1. ✅ Plugin compiles successfully
2. ⏭️ Test with Jellyfin instance
3. ⏭️ Verify content discovery
4. ⏭️ Test playback functionality
5. ⏭️ Validate expiration handling
@@ -5,13 +5,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -93,4 +93,10 @@ public class Chapter
/// </summary>
[JsonPropertyName("mediaType")]
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.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
using Jellyfin.Plugin.SRFPlay.Configuration;
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/>
public void Dispose()
{
@@ -1,11 +1,13 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Channels;
@@ -24,7 +26,9 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
private readonly ILoggerFactory _loggerFactory;
private readonly ContentRefreshService _contentRefreshService;
private readonly StreamUrlResolver _streamResolver;
private readonly StreamProxyService _proxyService;
private readonly CategoryService? _categoryService;
private readonly IServerApplicationHost _appHost;
/// <summary>
/// Initializes a new instance of the <see cref="SRFPlayChannel"/> class.
@@ -32,17 +36,23 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="contentRefreshService">The content refresh service.</param>
/// <param name="streamResolver">The stream resolver.</param>
/// <param name="proxyService">The stream proxy service.</param>
/// <param name="appHost">The server application host.</param>
/// <param name="categoryService">The category service (optional).</param>
public SRFPlayChannel(
ILoggerFactory loggerFactory,
ContentRefreshService contentRefreshService,
StreamUrlResolver streamResolver,
StreamProxyService proxyService,
IServerApplicationHost appHost,
CategoryService? categoryService = null)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFPlayChannel>();
_contentRefreshService = contentRefreshService;
_streamResolver = streamResolver;
_proxyService = proxyService;
_appHost = appHost;
_categoryService = categoryService;
if (_categoryService == null)
@@ -60,7 +70,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
public string Description => "Swiss Radio and Television video-on-demand content";
/// <inheritdoc />
public string DataVersion => "1.0";
public string DataVersion => "2.0"; // Back to authenticating at channel refresh with auto-refresh for fresh tokens
/// <inheritdoc />
public string HomePageUrl => "https://www.srf.ch/play";
@@ -145,6 +155,15 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
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
if (config?.EnableCategoryFolders == true && _categoryService != null)
{
@@ -201,6 +220,55 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
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 &&
(p.ValidTo == null || p.ValidTo.Value.ToUniversalTime() > now))
.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
else if (query.FolderId?.StartsWith("category_", StringComparison.Ordinal) == true)
{
@@ -316,7 +384,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
var enabledTopics = config?.EnabledTopics != null && config.EnabledTopics.Count > 0
? string.Join(",", config.EnabledTopics)
: "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)
@@ -373,17 +442,80 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
// Generate deterministic GUID from URN
var itemId = UrnToGuid(urn);
// Get stream URL and authenticate it
var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference);
// Skip scheduled livestreams that haven't started yet (no stream URL available)
if (chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl))
{
_logger.LogDebug(
"URN {Urn}: Skipping upcoming livestream '{Title}' - stream not yet available (starts at {ValidFrom})",
urn,
chapter.Title,
chapter.ValidFrom);
continue;
}
// Authenticate the stream URL with fresh token
if (!string.IsNullOrEmpty(streamUrl))
{
streamUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).ConfigureAwait(false);
}
// Skip items without a valid stream URL
if (string.IsNullOrEmpty(streamUrl))
{
_logger.LogWarning(
"URN {Urn}: Skipping '{Title}' - no valid stream URL available",
urn,
chapter.Title);
noStreamCount++;
continue;
}
// Register stream with proxy service
_proxyService.RegisterStream(itemId, streamUrl);
// Get the server URL for proxy - prefer configured public URL for remote clients
var serverUrl = !string.IsNullOrWhiteSpace(config.PublicServerUrl)
? config.PublicServerUrl.TrimEnd('/') // Use configured public URL (important for Android/remote clients)
: _appHost.GetSmartApiUrl(string.Empty); // Fall back to Jellyfin's smart URL resolution
// Create proxy URL as absolute HTTP URL (required for ffmpeg)
// Use the actual server URL so remote clients can access it
var proxyUrl = $"{serverUrl}/Plugins/SRFPlay/Proxy/{itemId}/master.m3u8";
// Build overview
var overview = chapter.Description ?? chapter.Lead;
// 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 a scheduled livestream, otherwise use Date
var premiereDate = chapter.Type == "SCHEDULED_LIVESTREAM" ? chapter.ValidFrom?.ToUniversalTime() : chapter.Date?.ToUniversalTime();
// Store authenticated URL - tokens refresh automatically via scheduled channel scans
var item = new ChannelItemInfo
{
Id = itemId,
Name = chapter.Title,
Overview = chapter.Description ?? chapter.Lead,
ImageUrl = chapter.ImageUrl,
Overview = overview,
ImageUrl = imageUrl,
Type = ChannelItemType.Media,
ContentType = ChannelMediaContentType.Episode,
MediaType = ChannelMediaType.Video,
DateCreated = chapter.Date?.ToUniversalTime(),
PremiereDate = chapter.Date?.ToUniversalTime(),
PremiereDate = premiereDate,
ProductionYear = chapter.Date?.Year,
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
ProviderIds = new Dictionary<string, string>
@@ -396,15 +528,38 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{
Id = itemId,
Name = chapter.Title,
Path = _streamResolver.GetStreamUrl(chapter, config.QualityPreference),
Path = proxyUrl, // Proxy URL instead of direct Akamai URL
Protocol = MediaBrowser.Model.MediaInfo.MediaProtocol.Http,
Container = "m3u8",
Container = "hls",
SupportsDirectStream = true,
SupportsDirectPlay = true,
SupportsDirectPlay = true, // ✅ Enabled! Proxy handles auth
SupportsTranscoding = true,
IsRemote = true,
IsRemote = false, // False because it's a local proxy endpoint
Type = MediaBrowser.Model.Dto.MediaSourceType.Default,
VideoType = VideoType.VideoFile
VideoType = VideoType.VideoFile,
RequiresOpening = false,
RequiresClosing = false,
SupportsProbing = false, // Disable probing for proxy URLs
ReadAtNativeFramerate = false,
MediaStreams = new List<MediaBrowser.Model.Entities.MediaStream>
{
new MediaBrowser.Model.Entities.MediaStream
{
Type = MediaBrowser.Model.Entities.MediaStreamType.Video,
Codec = "h264",
Profile = "high",
IsInterlaced = false,
IsDefault = true,
Index = 0
},
new MediaBrowser.Model.Entities.MediaStream
{
Type = MediaBrowser.Model.Entities.MediaStreamType.Audio,
Codec = "aac",
IsDefault = true,
Index = 1
}
}
}
}
};
@@ -418,6 +573,13 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
items.Add(item);
successCount++;
_logger.LogInformation("URN {Urn}: Successfully converted to channel item - {Title}", urn, chapter.Title);
_logger.LogInformation(
"URN {Urn}: MediaSource configured - DirectStream={DirectStream}, DirectPlay={DirectPlay}, Transcoding={Transcoding}, Container={Container}",
urn,
true,
true,
true,
"hls");
}
catch (Exception ex)
{
@@ -142,4 +142,11 @@ public class PluginConfiguration : BasePluginConfiguration
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for configuration serialization")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Configuration DTO")]
public System.Collections.Generic.List<string> EnabledTopics { get; set; }
/// <summary>
/// Gets or sets the public/external server URL for remote clients (e.g., https://jellyfin.example.com:8920).
/// If not set, the plugin will use Jellyfin's GetSmartApiUrl() which may return local addresses.
/// This is important for Android and other remote clients to access streams.
/// </summary>
public string PublicServerUrl { get; set; } = string.Empty;
}
@@ -82,6 +82,17 @@
<input id="ProxyPassword" name="ProxyPassword" type="password" is="emby-input" autocomplete="off" />
<div class="fieldDescription">Password for proxy authentication (leave empty if not required)</div>
</div>
<br />
<h2>Network Settings</h2>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="PublicServerUrl">Public Server URL (Optional)</label>
<input id="PublicServerUrl" name="PublicServerUrl" type="text" is="emby-input" placeholder="e.g., https://jellyfin.example.com:8920" />
<div class="fieldDescription">
The public/external URL for remote clients (Android, iOS, etc.) to access streaming proxy.
<br />If not set, the plugin will use Jellyfin's automatic URL detection which may return local addresses.
<br /><strong>Important for Android/remote playback!</strong>
</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
@@ -110,6 +121,7 @@
document.querySelector('#ProxyAddress').value = config.ProxyAddress || '';
document.querySelector('#ProxyUsername').value = config.ProxyUsername || '';
document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
Dashboard.hideLoadingMsg();
});
});
@@ -129,6 +141,7 @@
config.ProxyAddress = document.querySelector('#ProxyAddress').value;
config.ProxyUsername = document.querySelector('#ProxyUsername').value;
config.ProxyPassword = document.querySelector('#ProxyPassword').value;
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
@@ -0,0 +1,260 @@
using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Controllers;
/// <summary>
/// Controller for proxying SRF Play streams.
/// </summary>
[ApiController]
[Route("Plugins/SRFPlay/Proxy")]
public class StreamProxyController : ControllerBase
{
private readonly ILogger<StreamProxyController> _logger;
private readonly StreamProxyService _proxyService;
/// <summary>
/// Initializes a new instance of the <see cref="StreamProxyController"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="proxyService">The proxy service.</param>
public StreamProxyController(ILogger<StreamProxyController> logger, StreamProxyService proxyService)
{
_logger = logger;
_proxyService = proxyService;
}
/// <summary>
/// Proxies HLS master manifest requests.
/// </summary>
/// <param name="itemId">The item ID from URL path.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The HLS manifest with rewritten URLs.</returns>
[HttpGet("{itemId}/master.m3u8")]
[AllowAnonymous] // Allow anonymous since Jellyfin handles auth upstream
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetMasterManifest(
[FromRoute] string itemId,
CancellationToken cancellationToken)
{
_logger.LogInformation("Proxy request for master manifest - ItemId: {ItemId}", itemId);
// Try to resolve the actual item ID (path ID might be a session ID during transcoding)
var actualItemId = ResolveItemId(itemId);
try
{
// Build the base proxy URL for this item (use original itemId from path to maintain URL structure)
// Always include the actualItemId as a query parameter to ensure proper resolution during transcoding
var baseProxyUrl = $"{Request.Scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}?itemId={actualItemId}";
if (actualItemId != itemId)
{
_logger.LogDebug("Path itemId {PathId} differs from resolved itemId {ResolvedId}, adding query parameter", itemId, actualItemId);
}
var manifestContent = await _proxyService.GetRewrittenManifestAsync(actualItemId, baseProxyUrl, cancellationToken).ConfigureAwait(false);
if (manifestContent == null)
{
_logger.LogWarning("Manifest not found for item {ItemId}", itemId);
return NotFound();
}
_logger.LogDebug("Returning master manifest for item {ItemId} ({Length} bytes)", itemId, manifestContent.Length);
return Content(manifestContent, "application/vnd.apple.mpegurl");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error proxying master manifest for item {ItemId}", itemId);
return StatusCode(StatusCodes.Status500InternalServerError);
}
}
/// <summary>
/// Proxies HLS variant manifest requests.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="manifestPath">The manifest path (e.g., "index_0_av").</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The HLS manifest with rewritten segment URLs.</returns>
[HttpGet("{itemId}/{manifestPath}.m3u8")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetVariantManifest(
[FromRoute] string itemId,
[FromRoute] string manifestPath,
CancellationToken cancellationToken)
{
var fullPath = $"{manifestPath}.m3u8";
_logger.LogInformation("Proxy request for variant manifest - ItemId: {ItemId}, Path: {Path}", itemId, fullPath);
// Try to resolve the actual item ID
var actualItemId = ResolveItemId(itemId);
try
{
// Fetch the variant manifest as a segment
var manifestData = await _proxyService.GetSegmentAsync(actualItemId, fullPath, cancellationToken).ConfigureAwait(false);
if (manifestData == null)
{
_logger.LogWarning("Variant manifest not found - ItemId: {ItemId}, Path: {Path}", itemId, fullPath);
return NotFound();
}
// Convert to string and rewrite URLs
var manifestContent = System.Text.Encoding.UTF8.GetString(manifestData);
var baseProxyUrl = $"{Request.Scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}";
var rewrittenContent = RewriteSegmentUrls(manifestContent, baseProxyUrl);
_logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length);
return Content(rewrittenContent, "application/vnd.apple.mpegurl");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error proxying variant manifest - ItemId: {ItemId}, Path: {Path}", itemId, fullPath);
return StatusCode(StatusCodes.Status500InternalServerError);
}
}
/// <summary>
/// Proxies HLS segment requests (.ts, .mp4, .m4s, .aac files).
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="segmentPath">The segment path.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The segment data.</returns>
[HttpGet("{itemId}/{*segmentPath}")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetSegment(
[FromRoute] string itemId,
[FromRoute] string segmentPath,
CancellationToken cancellationToken)
{
_logger.LogDebug("Proxy request for segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
// Try to resolve the actual item ID
var actualItemId = ResolveItemId(itemId);
try
{
var segmentData = await _proxyService.GetSegmentAsync(actualItemId, segmentPath, cancellationToken).ConfigureAwait(false);
if (segmentData == null)
{
_logger.LogWarning("Segment not found - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
return NotFound();
}
// Determine content type based on file extension
var contentType = GetContentType(segmentPath);
_logger.LogDebug("Returning segment {SegmentPath} ({Length} bytes, {ContentType})", segmentPath, segmentData.Length, contentType);
return File(segmentData, contentType);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error proxying segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
return StatusCode(StatusCodes.Status500InternalServerError);
}
}
/// <summary>
/// Resolves the actual item ID from the request.
/// </summary>
/// <param name="pathItemId">The item ID from the URL path.</param>
/// <returns>The resolved item ID.</returns>
private string ResolveItemId(string pathItemId)
{
// Check if there's an itemId query parameter (fallback for transcoding sessions)
if (Request.Query.TryGetValue("itemId", out var queryItemId) && !string.IsNullOrEmpty(queryItemId))
{
_logger.LogDebug("Using itemId from query parameter: {QueryItemId} (path had: {PathItemId})", queryItemId.ToString(), pathItemId);
return queryItemId.ToString();
}
// If path ID and query ID don't match, it's likely a transcoding session
// Try to use the proxy service fallback to find the correct stream
_logger.LogDebug("No itemId query parameter found, using path ID as-is: {PathItemId}", pathItemId);
return pathItemId;
}
/// <summary>
/// Rewrites segment URLs in a manifest to point to proxy.
/// </summary>
/// <param name="manifestContent">The manifest content.</param>
/// <param name="baseProxyUrl">The base proxy URL.</param>
/// <returns>The rewritten manifest.</returns>
private string RewriteSegmentUrls(string manifestContent, string baseProxyUrl)
{
// Extract the itemId query parameter from the current request to propagate it
var itemIdParam = Request.Query.TryGetValue("itemId", out var itemId) && !string.IsNullOrEmpty(itemId)
? $"?itemId={itemId}"
: string.Empty;
var lines = manifestContent.Split('\n');
var result = new System.Text.StringBuilder();
foreach (var line in lines)
{
if (line.StartsWith('#') || string.IsNullOrWhiteSpace(line))
{
// Keep metadata and blank lines as-is
result.AppendLine(line);
}
else if (line.Contains("://", StringComparison.Ordinal))
{
// Absolute URL - extract the path and rewrite
var uri = new Uri(line.Trim());
var segments = uri.AbsolutePath.Split('/');
var fileName = segments[^1];
result.AppendLine(CultureInfo.InvariantCulture, $"{baseProxyUrl}/{fileName}{itemIdParam}");
}
else
{
// Relative URL - rewrite to proxy
result.AppendLine(CultureInfo.InvariantCulture, $"{baseProxyUrl}/{line.Trim()}{itemIdParam}");
}
}
return result.ToString();
}
/// <summary>
/// Gets the content type for a segment based on file extension.
/// </summary>
/// <param name="path">The segment path.</param>
/// <returns>The MIME content type.</returns>
private static string GetContentType(string path)
{
if (path.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
{
return "video/MP2T";
}
if (path.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".m4s", StringComparison.OrdinalIgnoreCase))
{
return "video/mp4";
}
if (path.EndsWith(".aac", StringComparison.OrdinalIgnoreCase))
{
return "audio/aac";
}
return "application/octet-stream";
}
}
@@ -93,6 +93,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
var chapter = mediaComposition.ChapterList[0];
if (!string.IsNullOrEmpty(chapter.ImageUrl))
{
_logger.LogDebug("URN {Urn}: Adding chapter image: {ImageUrl}", urn, chapter.ImageUrl);
list.Add(new RemoteImageInfo
{
Url = chapter.ImageUrl,
@@ -107,6 +108,10 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
ProviderName = Name
});
}
else
{
_logger.LogDebug("URN {Urn}: No chapter image available", urn);
}
}
// Extract images from show
@@ -114,6 +119,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
{
if (!string.IsNullOrEmpty(mediaComposition.Show.ImageUrl))
{
_logger.LogDebug("URN {Urn}: Adding show image: {ImageUrl}", urn, mediaComposition.Show.ImageUrl);
list.Add(new RemoteImageInfo
{
Url = mediaComposition.Show.ImageUrl,
@@ -124,6 +130,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
if (!string.IsNullOrEmpty(mediaComposition.Show.BannerImageUrl))
{
_logger.LogDebug("URN {Urn}: Adding show banner: {BannerUrl}", urn, mediaComposition.Show.BannerImageUrl);
list.Add(new RemoteImageInfo
{
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)
{
@@ -5,6 +5,7 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
@@ -23,6 +24,8 @@ public class SRFMediaProvider : IMediaSourceProvider
private readonly ILoggerFactory _loggerFactory;
private readonly MetadataCache _metadataCache;
private readonly StreamUrlResolver _streamResolver;
private readonly StreamProxyService _proxyService;
private readonly IServerApplicationHost _appHost;
/// <summary>
/// Initializes a new instance of the <see cref="SRFMediaProvider"/> class.
@@ -30,15 +33,21 @@ public class SRFMediaProvider : IMediaSourceProvider
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="metadataCache">The metadata cache.</param>
/// <param name="streamResolver">The stream URL resolver.</param>
/// <param name="proxyService">The stream proxy service.</param>
/// <param name="appHost">The server application host.</param>
public SRFMediaProvider(
ILoggerFactory loggerFactory,
MetadataCache metadataCache,
StreamUrlResolver streamResolver)
StreamUrlResolver streamResolver,
StreamProxyService proxyService,
IServerApplicationHost appHost)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFMediaProvider>();
_metadataCache = metadataCache;
_streamResolver = streamResolver;
_proxyService = proxyService;
_appHost = appHost;
}
/// <summary>
@@ -52,34 +61,51 @@ public class SRFMediaProvider : IMediaSourceProvider
/// <param name="item">The item.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of media sources.</returns>
public Task<IEnumerable<MediaSourceInfo>> GetMediaSources(BaseItem item, CancellationToken cancellationToken)
public async Task<IEnumerable<MediaSourceInfo>> GetMediaSources(BaseItem item, CancellationToken cancellationToken)
{
var sources = new List<MediaSourceInfo>();
try
{
// Log detailed information about the request
var stackTrace = new System.Diagnostics.StackTrace(true);
var callingMethod = stackTrace.GetFrame(1)?.GetMethod();
_logger.LogInformation(
"GetMediaSources called - Item: {ItemName}, Type: {ItemType}, Id: {ItemId}, CalledBy: {CallingMethod}",
item.Name,
item.GetType().Name,
item.Id,
callingMethod?.DeclaringType?.Name + "." + callingMethod?.Name);
// Check if this is an SRF item
if (!item.ProviderIds.TryGetValue("SRF", out var urn) || string.IsNullOrEmpty(urn))
{
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
_logger.LogDebug("Item {ItemName} is not an SRF item, returning empty sources", item.Name);
return sources;
}
_logger.LogDebug("Getting media sources for URN: {Urn}", urn);
_logger.LogInformation("Getting media sources for URN: {Urn}, Item: {ItemName}", urn, item.Name);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
return 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
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
var mediaComposition = _metadataCache.GetMediaComposition(urn, cacheDuration);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).GetAwaiter().GetResult();
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
@@ -90,7 +116,7 @@ public class SRFMediaProvider : IMediaSourceProvider
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
{
_logger.LogWarning("No chapters found for URN: {Urn}", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
return sources;
}
// Get the first chapter (main video)
@@ -100,67 +126,145 @@ public class SRFMediaProvider : IMediaSourceProvider
if (_streamResolver.IsContentExpired(chapter))
{
_logger.LogWarning("Content expired for URN: {Urn}, ValidTo: {ValidTo}", urn, chapter.ValidTo);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
return sources;
}
// Check if content has playable streams
if (!_streamResolver.HasPlayableContent(chapter))
{
_logger.LogWarning("No playable content found for URN: {Urn}", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
return sources;
}
// Get stream URL based on quality preference
var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference);
// For scheduled livestreams, always fetch fresh data to ensure stream URL is current
if (chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl))
{
_logger.LogDebug("URN {Urn}: Scheduled livestream has no stream URL, fetching fresh data", urn);
using var freshApiClient = new SRFApiClient(_loggerFactory);
var freshMediaComposition = await freshApiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
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}: Got fresh stream URL for scheduled livestream", urn);
}
}
}
if (string.IsNullOrEmpty(streamUrl))
{
_logger.LogWarning("Could not resolve stream URL for URN: {Urn}", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
return sources;
}
// Create media source
// Authenticate the stream URL (required for all SRF streams, especially livestreams)
if (!string.IsNullOrEmpty(streamUrl))
{
streamUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Authenticated stream URL for URN: {Urn}", urn);
}
// Register stream with proxy service
var itemIdStr = item.Id.ToString("N"); // Use hex format without dashes
_proxyService.RegisterStream(itemIdStr, streamUrl);
// Get the server URL for proxy - prefer configured public URL for remote clients
var serverUrl = !string.IsNullOrWhiteSpace(config.PublicServerUrl)
? config.PublicServerUrl.TrimEnd('/') // Use configured public URL (important for Android/remote clients)
: _appHost.GetSmartApiUrl(string.Empty); // Fall back to Jellyfin's smart URL resolution
// Create proxy URL as absolute HTTP URL (required for ffmpeg)
// Use the actual server URL so remote clients can access it
// Include item ID as query parameter to preserve it during transcoding
var proxyUrl = $"{serverUrl}/Plugins/SRFPlay/Proxy/{itemIdStr}/master.m3u8?itemId={itemIdStr}";
_logger.LogInformation(
"Using proxy URL for item {ItemId}: {ProxyUrl} (PublicServerUrl configured: {IsPublicConfigured})",
itemIdStr,
proxyUrl,
!string.IsNullOrWhiteSpace(config.PublicServerUrl));
// Detect if this is a live stream
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
// Create media source using proxy URL - enables DirectPlay!
var mediaSource = new MediaSourceInfo
{
Id = urn,
Id = itemIdStr, // Must match the ID used in proxy URL registration
Name = chapter.Title,
Path = streamUrl,
Path = proxyUrl, // Proxy URL instead of direct Akamai URL
Protocol = MediaProtocol.Http,
Container = "m3u8",
Container = "hls",
SupportsDirectStream = true,
SupportsDirectPlay = true,
SupportsDirectPlay = true, // ✅ Enabled! Proxy handles auth
SupportsTranscoding = true,
IsRemote = true,
IsRemote = false, // False because it's a local proxy endpoint
Type = MediaSourceType.Default,
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
VideoType = VideoType.VideoFile,
IsInfiniteStream = false,
IsInfiniteStream = isLiveStream, // True for live streams!
RequiresOpening = false,
RequiresClosing = false,
SupportsProbing = true
};
// Add video stream info
mediaSource.MediaStreams = new List<MediaStream>
SupportsProbing = false, // Disable probing for proxy URLs
ReadAtNativeFramerate = isLiveStream, // Read at native framerate for live streams
MediaStreams = new List<MediaBrowser.Model.Entities.MediaStream>
{
new MediaStream
new MediaBrowser.Model.Entities.MediaStream
{
Type = MediaStreamType.Video,
Codec = "h264",
Profile = "high",
IsInterlaced = false,
IsDefault = true
IsDefault = true,
Index = 0
},
new MediaBrowser.Model.Entities.MediaStream
{
Type = MediaStreamType.Audio,
Codec = "aac",
IsDefault = true,
Index = 1
}
}
};
sources.Add(mediaSource);
_logger.LogInformation("Resolved stream URL for {Title}: {Url}", chapter.Title, streamUrl);
_logger.LogInformation(
"MediaSource created - Id={Id}, DirectStream={DirectStream}, DirectPlay={DirectPlay}, Probing={Probing}, Container={Container}, Protocol={Protocol}, IsRemote={IsRemote}, IsLiveStream={IsLiveStream}",
mediaSource.Id,
mediaSource.SupportsDirectStream,
mediaSource.SupportsDirectPlay,
mediaSource.SupportsProbing,
mediaSource.Container,
mediaSource.Protocol,
mediaSource.IsRemote,
isLiveStream);
_logger.LogInformation(
"MediaSource capabilities - SupportsTranscoding={Transcoding}, RequiresOpening={RequiresOpening}, RequiresClosing={RequiresClosing}, Type={Type}, IsInfiniteStream={IsInfiniteStream}",
mediaSource.SupportsTranscoding,
mediaSource.RequiresOpening,
mediaSource.RequiresClosing,
mediaSource.Type,
mediaSource.IsInfiniteStream);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting media sources for item: {Name}", item.Name);
}
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
return sources;
}
/// <summary>
@@ -171,6 +275,7 @@ public class SRFMediaProvider : IMediaSourceProvider
/// <returns>The direct stream provider.</returns>
public Task<IDirectStreamProvider?> GetDirectStreamProviderByUniqueId(string uniqueId, CancellationToken cancellationToken)
{
_logger.LogInformation("GetDirectStreamProviderByUniqueId called with uniqueId: {UniqueId}", uniqueId);
// Not needed for HTTP streams
return Task.FromResult<IDirectStreamProvider?>(null);
}
@@ -178,6 +283,7 @@ public class SRFMediaProvider : IMediaSourceProvider
/// <inheritdoc />
public Task<ILiveStream> OpenMediaSource(string openToken, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
{
_logger.LogWarning("OpenMediaSource called with openToken: {OpenToken} - This should not be called for HTTP streams!", openToken);
// Not needed for static HTTP streams
throw new NotImplementedException();
}
@@ -25,6 +25,7 @@ public class ServiceRegistrator : IPluginServiceRegistrator
serviceCollection.AddSingleton<ContentExpirationService>();
serviceCollection.AddSingleton<ContentRefreshService>();
serviceCollection.AddSingleton<CategoryService>();
serviceCollection.AddSingleton<StreamProxyService>(); // Stream proxy service
// Register metadata providers
serviceCollection.AddSingleton<SRFSeriesProvider>();
@@ -0,0 +1,559 @@
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for proxying SRF Play streams and managing authentication.
/// </summary>
public class StreamProxyService : IDisposable
{
private readonly ILogger<StreamProxyService> _logger;
private readonly StreamUrlResolver _streamResolver;
private readonly HttpClient _httpClient;
private readonly ConcurrentDictionary<string, StreamInfo> _streamMappings;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="StreamProxyService"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="streamResolver">The stream URL resolver.</param>
public StreamProxyService(ILogger<StreamProxyService> logger, StreamUrlResolver streamResolver)
{
_logger = logger;
_streamResolver = streamResolver;
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(30)
};
_streamMappings = new ConcurrentDictionary<string, StreamInfo>();
}
/// <summary>
/// Registers a stream for proxying.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="authenticatedUrl">The authenticated stream URL.</param>
public void RegisterStream(string itemId, string authenticatedUrl)
{
var tokenExpiry = ExtractTokenExpiry(authenticatedUrl);
var unauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl);
var streamInfo = new StreamInfo
{
AuthenticatedUrl = authenticatedUrl,
UnauthenticatedUrl = unauthenticatedUrl,
RegisteredAt = DateTime.UtcNow,
TokenExpiresAt = tokenExpiry
};
// Register with the provided item ID
_streamMappings.AddOrUpdate(itemId, streamInfo, (key, old) => streamInfo);
// Also register with alternative GUID formats to handle Jellyfin's ID transformations
if (Guid.TryParse(itemId, out var guid))
{
var formats = new[]
{
guid.ToString("N"), // Without dashes: 00000000000000000000000000000000
guid.ToString("D"), // With dashes: 00000000-0000-0000-0000-000000000000
guid.ToString("B"), // With braces: {00000000-0000-0000-0000-000000000000}
};
foreach (var format in formats)
{
if (format != itemId) // Don't duplicate the original
{
_streamMappings.AddOrUpdate(format, streamInfo, (key, old) => streamInfo);
}
}
_logger.LogDebug("Registered stream with {Count} GUID format variations", formats.Length);
}
if (tokenExpiry.HasValue)
{
_logger.LogInformation(
"Registered stream for item {ItemId} (token expires at {ExpiresAt} UTC): {Url}",
itemId,
tokenExpiry.Value,
authenticatedUrl);
}
else
{
_logger.LogInformation("Registered stream for item {ItemId}: {Url}", itemId, authenticatedUrl);
}
}
/// <summary>
/// Gets the authenticated URL for an item.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <returns>The authenticated URL, or null if not found or expired.</returns>
public string? GetAuthenticatedUrl(string itemId)
{
// Try direct lookup first
if (_streamMappings.TryGetValue(itemId, out var streamInfo))
{
return ValidateAndReturnStream(itemId, streamInfo);
}
// Fallback: Try to find by GUID variations (with/without dashes)
// This handles cases where Jellyfin uses different GUID formats
var normalizedId = NormalizeGuid(itemId);
if (normalizedId != null)
{
foreach (var kvp in _streamMappings)
{
var normalizedKey = NormalizeGuid(kvp.Key);
if (normalizedKey != null && normalizedKey == normalizedId)
{
_logger.LogInformation(
"Found stream by GUID normalization - Requested: {RequestedId}, Registered: {RegisteredId}",
itemId,
kvp.Key);
var url = ValidateAndReturnStream(kvp.Key, kvp.Value);
if (url != null)
{
return url; // Found valid stream
}
// Stream found but expired, continue to next fallback
_logger.LogDebug("GUID-normalized stream was expired, trying other fallbacks");
break; // Exit foreach, continue to next fallback strategy
}
}
}
// Last resort: Use active stream fallbacks (helps when Jellyfin creates random transcoding session IDs)
var activeStreams = _streamMappings.Where(kvp =>
{
if (!kvp.Value.TokenExpiresAt.HasValue)
{
return true; // No expiry
}
return DateTime.UtcNow < kvp.Value.TokenExpiresAt.Value;
}).ToList();
if (activeStreams.Count == 1)
{
_logger.LogWarning(
"No exact match for {RequestedId}, but found single active stream {RegisteredId} - using as fallback",
itemId,
activeStreams[0].Key);
return ValidateAndReturnStream(activeStreams[0].Key, activeStreams[0].Value);
}
// If multiple active streams, use the most recently registered one (likely the one being transcoded)
// This handles cases where Jellyfin creates a random transcoding session ID seconds after registration
if (activeStreams.Count > 1)
{
var mostRecent = activeStreams.OrderByDescending(kvp => kvp.Value.RegisteredAt).First();
var age = DateTime.UtcNow - mostRecent.Value.RegisteredAt;
// Only use this fallback if the stream was registered very recently (within 30 seconds)
// This indicates it's likely the stream currently being set up for transcoding
if (age.TotalSeconds < 30)
{
_logger.LogWarning(
"No exact match for {RequestedId}, but using most recently registered stream {RegisteredId} (registered {Seconds}s ago) as fallback",
itemId,
mostRecent.Key,
age.TotalSeconds);
return ValidateAndReturnStream(mostRecent.Key, mostRecent.Value);
}
}
_logger.LogWarning(
"No stream mapping found for item {ItemId}. Active streams: {Count}. Registered IDs: {RegisteredIds}",
itemId,
activeStreams.Count,
string.Join(", ", _streamMappings.Keys));
return null;
}
/// <summary>
/// Validates a stream and returns its URL if valid.
/// </summary>
private string? ValidateAndReturnStream(string itemId, StreamInfo streamInfo)
{
// Check if token has expired
if (streamInfo.TokenExpiresAt.HasValue)
{
var now = DateTime.UtcNow;
if (now >= streamInfo.TokenExpiresAt.Value)
{
_logger.LogWarning(
"Token expired for item {ItemId} (expired at {ExpiresAt}, now is {Now}) - attempting to refresh",
itemId,
streamInfo.TokenExpiresAt.Value,
now);
// Try to refresh the token
var refreshedUrl = RefreshToken(itemId, streamInfo);
if (refreshedUrl != null)
{
_logger.LogInformation("Successfully refreshed token for item {ItemId}", itemId);
return refreshedUrl;
}
_logger.LogWarning("Failed to refresh token for item {ItemId}, removing mapping", itemId);
_streamMappings.TryRemove(itemId, out _);
return null;
}
_logger.LogDebug(
"Token valid for item {ItemId} (expires at {ExpiresAt}, {TimeLeft} remaining)",
itemId,
streamInfo.TokenExpiresAt.Value,
streamInfo.TokenExpiresAt.Value - now);
}
return streamInfo.AuthenticatedUrl;
}
/// <summary>
/// Attempts to refresh an expired token.
/// </summary>
private string? RefreshToken(string itemId, StreamInfo streamInfo)
{
if (string.IsNullOrEmpty(streamInfo.UnauthenticatedUrl))
{
_logger.LogWarning("Cannot refresh token for {ItemId} - no unauthenticated URL stored", itemId);
return null;
}
try
{
// Re-authenticate the stream URL synchronously (blocking call)
var newAuthenticatedUrl = _streamResolver.GetAuthenticatedStreamUrlAsync(
streamInfo.UnauthenticatedUrl,
CancellationToken.None).GetAwaiter().GetResult();
if (string.IsNullOrEmpty(newAuthenticatedUrl))
{
return null;
}
// Update the stream info with the new token
var newTokenExpiry = ExtractTokenExpiry(newAuthenticatedUrl);
streamInfo.AuthenticatedUrl = newAuthenticatedUrl;
streamInfo.TokenExpiresAt = newTokenExpiry;
_logger.LogInformation(
"Refreshed token for item {ItemId} (new expiry: {ExpiresAt} UTC)",
itemId,
newTokenExpiry);
return newAuthenticatedUrl;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error refreshing token for item {ItemId}", itemId);
return null;
}
}
/// <summary>
/// Strips authentication parameters from a URL to get the base unauthenticated URL.
/// </summary>
private string StripAuthenticationFromUrl(string url)
{
try
{
var uri = new Uri(url);
var query = uri.Query;
// Remove hdnts authentication parameter (Akamai token authentication)
if (query.Contains("hdnts=", StringComparison.OrdinalIgnoreCase))
{
// Keep other parameters like caption, webvttbaseurl
var queryParams = System.Web.HttpUtility.ParseQueryString(query);
queryParams.Remove("hdnts");
var newQuery = queryParams.Count > 0 ? "?" + queryParams.ToString() : string.Empty;
return $"{uri.Scheme}://{uri.Host}{uri.AbsolutePath}{newQuery}";
}
return url;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to strip authentication from URL, using as-is");
return url;
}
}
/// <summary>
/// Normalizes a GUID string to a consistent format for comparison.
/// </summary>
private string? NormalizeGuid(string input)
{
if (string.IsNullOrEmpty(input))
{
return null;
}
// Try to parse as GUID (handles both with and without dashes)
if (Guid.TryParse(input, out var guid))
{
return guid.ToString("N"); // Always return format without dashes
}
return null;
}
/// <summary>
/// Fetches and rewrites an HLS manifest to use proxy URLs.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="baseProxyUrl">The base proxy URL (e.g., https://jellyfin-server/Plugins/SRFPlay/Proxy/{itemId}).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The rewritten manifest content.</returns>
public async Task<string?> GetRewrittenManifestAsync(
string itemId,
string baseProxyUrl,
CancellationToken cancellationToken = default)
{
var authenticatedUrl = GetAuthenticatedUrl(itemId);
if (authenticatedUrl == null)
{
return null;
}
try
{
_logger.LogDebug("Fetching manifest from: {Url}", authenticatedUrl);
var manifestContent = await _httpClient.GetStringAsync(authenticatedUrl, cancellationToken).ConfigureAwait(false);
// Rewrite the manifest to replace Akamai URLs with proxy URLs
var rewrittenContent = RewriteManifestUrls(manifestContent, authenticatedUrl, baseProxyUrl);
_logger.LogDebug("Successfully rewrote manifest for item {ItemId}", itemId);
return rewrittenContent;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch manifest for item {ItemId} from {Url}", itemId, authenticatedUrl);
return null;
}
}
/// <summary>
/// Fetches a segment from the original source.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="segmentPath">The segment path.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The segment content as bytes.</returns>
public async Task<byte[]?> GetSegmentAsync(
string itemId,
string segmentPath,
CancellationToken cancellationToken = default)
{
var authenticatedUrl = GetAuthenticatedUrl(itemId);
if (authenticatedUrl == null)
{
return null;
}
try
{
// Build the full segment URL by combining the base URL with the segment path
var baseUri = new Uri(authenticatedUrl);
var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}";
// Extract query parameters (auth tokens) from authenticated URL
var queryParams = baseUri.Query;
// Build full segment URL
var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}";
_logger.LogDebug("Fetching segment: {SegmentUrl}", segmentUrl);
var segmentData = await _httpClient.GetByteArrayAsync(segmentUrl, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Successfully fetched segment {SegmentPath} ({Size} bytes)", segmentPath, segmentData.Length);
return segmentData;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch segment {SegmentPath} for item {ItemId}", segmentPath, itemId);
return null;
}
}
/// <summary>
/// Rewrites URLs in HLS manifest to point to proxy.
/// </summary>
/// <param name="manifestContent">The original manifest content.</param>
/// <param name="originalBaseUrl">The original base URL.</param>
/// <param name="proxyBaseUrl">The proxy base URL.</param>
/// <returns>The rewritten manifest.</returns>
private string RewriteManifestUrls(string manifestContent, string originalBaseUrl, string proxyBaseUrl)
{
var baseUri = new Uri(originalBaseUrl);
var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}";
// Extract itemId query parameter from proxyBaseUrl to propagate it
var itemIdParam = string.Empty;
var queryMarker = "?itemId=";
if (proxyBaseUrl.Contains(queryMarker, StringComparison.Ordinal))
{
var queryStart = proxyBaseUrl.IndexOf(queryMarker, StringComparison.Ordinal);
if (queryStart >= 0)
{
itemIdParam = proxyBaseUrl[queryStart..];
proxyBaseUrl = proxyBaseUrl[..queryStart]; // Remove query from base URL
}
}
// Pattern to match .m3u8 and .ts/.mp4 segment references
var pattern = @"(?:^|\n)([^#\n][^\n]*\.(?:m3u8|ts|mp4|m4s|aac)[^\n]*)";
var rewritten = Regex.Replace(manifestContent, pattern, match =>
{
var url = match.Groups[1].Value.Trim();
// Skip if it's already an absolute URL
if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
// Rewrite absolute URLs to proxy
var relativePath = url.Replace(baseUrl + "/", string.Empty, StringComparison.Ordinal);
return $"\n{proxyBaseUrl}/{relativePath}{itemIdParam}";
}
// Relative URL - rewrite to proxy
return $"\n{proxyBaseUrl}/{url}{itemIdParam}";
});
return rewritten;
}
/// <summary>
/// Extracts the token expiry time from a stream URL with hdnts parameter.
/// </summary>
/// <param name="url">The authenticated stream URL.</param>
/// <returns>The expiry time, or null if not found.</returns>
private DateTime? ExtractTokenExpiry(string url)
{
try
{
var uri = new Uri(url);
var query = uri.Query;
// Parse the hdnts parameter (e.g., "exp=1763282021")
var match = Regex.Match(query, @"exp=(\d+)");
if (match.Success && long.TryParse(match.Groups[1].Value, out var unixTimestamp))
{
// Convert Unix timestamp to DateTime
var expiry = DateTimeOffset.FromUnixTimeSeconds(unixTimestamp).UtcDateTime;
_logger.LogDebug("Extracted token expiry from URL: {Expiry} UTC (unix: {Unix})", expiry, unixTimestamp);
return expiry;
}
_logger.LogDebug("No token expiry found in URL: {Url}", url);
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to extract token expiry from URL: {Url}", url);
return null;
}
}
/// <summary>
/// Cleans up old and expired stream mappings.
/// </summary>
public void CleanupOldMappings()
{
var cutoff = DateTime.UtcNow.AddHours(-24);
var now = DateTime.UtcNow;
var keysToRemove = new System.Collections.Generic.List<string>();
foreach (var kvp in _streamMappings)
{
var shouldRemove = false;
// Remove if registered more than 24 hours ago
if (kvp.Value.RegisteredAt < cutoff)
{
shouldRemove = true;
_logger.LogDebug("Marking item {ItemId} for cleanup (old registration)", kvp.Key);
}
// Remove if token has expired
if (kvp.Value.TokenExpiresAt.HasValue && kvp.Value.TokenExpiresAt.Value <= now)
{
shouldRemove = true;
_logger.LogDebug("Marking item {ItemId} for cleanup (expired token)", kvp.Key);
}
if (shouldRemove)
{
keysToRemove.Add(kvp.Key);
}
}
foreach (var key in keysToRemove)
{
_streamMappings.TryRemove(key, out _);
}
if (keysToRemove.Count > 0)
{
_logger.LogInformation("Cleaned up {Count} old/expired stream mappings", keysToRemove.Count);
}
}
/// <summary>
/// Disposes the service.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Disposes the service.
/// </summary>
/// <param name="disposing">True if disposing.</param>
protected virtual void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
if (disposing)
{
_httpClient?.Dispose();
}
_disposed = true;
}
/// <summary>
/// Stream information.
/// </summary>
private sealed class StreamInfo
{
public string AuthenticatedUrl { get; set; } = string.Empty;
public string UnauthenticatedUrl { get; set; } = string.Empty;
public DateTime RegisteredAt { get; set; }
public DateTime? TokenExpiresAt { get; set; }
}
}
@@ -1,5 +1,9 @@
using System;
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.Configuration;
using Microsoft.Extensions.Logging;
@@ -9,9 +13,11 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for resolving stream URLs from media composition resources.
/// </summary>
public class StreamUrlResolver
public class StreamUrlResolver : IDisposable
{
private readonly ILogger<StreamUrlResolver> _logger;
private readonly HttpClient _httpClient;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="StreamUrlResolver"/> class.
@@ -20,6 +26,7 @@ public class StreamUrlResolver
public StreamUrlResolver(ILogger<StreamUrlResolver> logger)
{
_logger = logger;
_httpClient = new HttpClient();
}
/// <summary>
@@ -80,6 +87,17 @@ public class StreamUrlResolver
chapter.Id,
hlsResources.Count);
// Log all HLS resources with their quality info to help debug quality selection
foreach (var resource in hlsResources)
{
_logger.LogInformation(
"Available HLS resource - Quality={Quality}, Protocol={Protocol}, Streaming={Streaming}, URL={Url}",
resource.Quality ?? "NULL",
resource.Protocol ?? "NULL",
resource.Streaming ?? "NULL",
resource.Url);
}
if (hlsResources.Count == 0)
{
_logger.LogWarning("No HLS resources found for chapter: {ChapterId}", chapter.Id);
@@ -107,6 +125,10 @@ public class StreamUrlResolver
}
// Select based on quality preference
_logger.LogInformation(
"Selecting stream with quality preference: {QualityPreference}",
qualityPreference);
Resource? selectedResource = qualityPreference switch
{
QualityPreference.HD => SelectHDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
@@ -117,10 +139,10 @@ public class StreamUrlResolver
if (selectedResource != null)
{
_logger.LogDebug(
"Selected stream for chapter {ChapterId}: Quality={Quality}, Protocol={Protocol}, URL={Url}",
_logger.LogInformation(
"Selected stream for chapter {ChapterId}: Quality={Quality}, Protocol={Protocol}, URL={Url}",
chapter.Id,
selectedResource.Quality,
selectedResource.Quality ?? "NULL",
selectedResource.Protocol,
selectedResource.Url);
return selectedResource.Url;
@@ -195,4 +217,87 @@ public class StreamUrlResolver
// Return first available
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;
}
}
}
-227
View File
@@ -1,227 +0,0 @@
# Network-Level Gateway Routing for SRF Content
This guide explains how to configure network-level routing to direct all SRF-related traffic (including video streams) through your Swiss gateway/proxy at 192.168.1.37.
## Overview
Instead of configuring proxy support at the application level, this approach uses Linux policy-based routing to redirect traffic destined for SRF domains through an alternate gateway. This ensures:
- API requests to `il.srgssr.ch` go through the gateway
- Video stream requests to `srf-vod-amd.akamaized.net` (and other CDNs) go through the gateway
- ffprobe and ffmpeg automatically use the gateway
- No application configuration needed - transparent to Jellyfin
## Prerequisites
- Root access to the Jellyfin server (192.168.1.4)
- Gateway at 192.168.1.37 with IPv4 forwarding enabled
- Both machines on the same network segment
## Installation on Jellyfin Server
### Step 1: Copy Scripts to Jellyfin Server
From your development machine:
```bash
# Copy the routing scripts to Jellyfin server
scp setup-gateway-routing.sh cleanup-gateway-routing.sh user@192.168.1.4:~
```
### Step 2: SSH to Jellyfin Server
```bash
ssh user@192.168.1.4
```
### Step 3: Make Scripts Executable
```bash
chmod +x setup-gateway-routing.sh cleanup-gateway-routing.sh
```
### Step 4: Run Setup Script
```bash
sudo ./setup-gateway-routing.sh
```
When prompted:
- **Gateway IP**: `192.168.1.37`
- **Network interface**: Find your interface name first with `ip -br link show` (common names: `eth0`, `ens18`, `enp0s3`)
The script will:
1. Create a custom routing table named `srf_gateway`
2. Resolve IP addresses for all SRF domains
3. Add routes through your gateway (192.168.1.37)
4. Create routing rules for policy-based routing
5. Set up a systemd service for persistence across reboots
### Step 5: Verify Routing
Check that routes are configured:
```bash
# Show the custom routing table
ip route show table srf_gateway
# Show routing rules
ip rule show | grep srf_gateway
# Test routing for Integration Layer API
ip route get $(dig +short il.srgssr.ch | head -1)
# Test routing for video CDN
ip route get $(dig +short srf-vod-amd.akamaized.net | head -1)
```
### Step 6: Test from Jellyfin Server
Test that the routing is working:
```bash
# Test API access
curl -v "https://il.srgssr.ch/integrationlayer/2.0/mediaComposition/byUrn/urn:srf:video:b84713f0-f81b-460f-9b0f-d0517310fb4f.json" 2>&1 | grep -E "(x-location|HTTP/)"
# Should show: x-location: CH
```
### Step 7: Restart Jellyfin
```bash
sudo systemctl restart jellyfin
```
## What Gets Routed
The following domains are routed through the gateway:
- `il.srgssr.ch` - Integration Layer API (metadata)
- `www.srf.ch` - Main SRF site
- `www.rts.ch` - RTS (Radio Télévision Suisse)
- `www.rsi.ch` - RSI (Radiotelevisione svizzera)
- `www.rtr.ch` - RTR (Radiotelevisiun Svizra Rumantscha)
- `www.swi.ch` - SWI (swissinfo)
- `srf-vod-amd.akamaized.net` - SRF video CDN
- `rts-vod-amd.akamaized.net` - RTS video CDN
- `rsi-vod-amd.akamaized.net` - RSI video CDN
- `play-web.srf.ch` - Play web interface
- `il-stage.srgssr.ch` - Staging environment
## How It Works
1. **DNS Resolution**: Domains are resolved to IP addresses
2. **Routing Table**: A custom routing table (`srf_gateway`) is created with routes through the gateway
3. **Policy Routing**: Rules direct traffic to specific IPs to use the custom routing table
4. **Persistence**: A systemd service ensures routes survive reboots
## Disabling Plugin Proxy Configuration
Once network-level routing is working, you can disable the proxy configuration in the plugin:
1. Go to Jellyfin Dashboard → Plugins → SRF Play
2. Uncheck "Use Proxy"
3. Save configuration
4. Restart Jellyfin
The plugin will use direct HTTP requests, but the network layer will transparently route them through the gateway.
## Troubleshooting
### Routes Not Working
Check if gateway is reachable:
```bash
ping 192.168.1.37
nc -zv 192.168.1.37 3128
```
Check routing table:
```bash
ip route show table srf_gateway
```
### DNS Changes
If SRF changes their IP addresses, you may need to re-run the setup script:
```bash
sudo ./cleanup-gateway-routing.sh
sudo ./setup-gateway-routing.sh
```
### Verify Traffic Path
Use `traceroute` to see the path:
```bash
traceroute $(dig +short il.srgssr.ch | head -1)
# Should show 192.168.1.37 as first hop
```
### Check Systemd Service
```bash
systemctl status srf-gateway-routing.service
journalctl -u srf-gateway-routing.service
```
## Removing the Configuration
To completely remove the routing configuration:
```bash
sudo ./cleanup-gateway-routing.sh
```
This will:
- Remove all routing rules
- Flush the custom routing table
- Disable and remove the systemd service
## Advantages of This Approach
1. **Transparent**: No application changes needed
2. **Complete Coverage**: All network traffic to SRF domains uses gateway
3. **Persistent**: Survives reboots
4. **Centralized**: Managed at network level
5. **Debug-Friendly**: Can verify with standard network tools
## Disadvantages
1. **DNS Changes**: If SRF changes IPs, routing must be updated
2. **New Domains**: New CDN domains require script update
3. **Static IPs Only**: Doesn't work with wildcard domains
## Gateway Configuration
Ensure your gateway (192.168.1.37) has:
1. **IPv4 Forwarding Enabled**:
```bash
# On the gateway (192.168.1.37)
sudo sysctl net.ipv4.ip_forward=1
# Make permanent
echo "net.ipv4.ip_forward=1" | sudo tee -a /etc/sysctl.conf
```
2. **Firewall Rules** (if using iptables):
```bash
# On the gateway (192.168.1.37)
sudo iptables -A FORWARD -s 192.168.1.4 -j ACCEPT
sudo iptables -A FORWARD -d 192.168.1.4 -m state --state RELATED,ESTABLISHED -j ACCEPT
sudo iptables -t nat -A POSTROUTING -s 192.168.1.4 -o <outbound-interface> -j MASQUERADE
```
3. **Squid Proxy** (if using proxy mode):
- Already configured and accessible at port 3128
- Note: With network-level routing, traffic goes through the gateway's routing, not necessarily the Squid proxy
## Testing Complete Data Chain
After setup, test the complete flow:
1. **Metadata API**: Browse shows in Jellyfin - should work
2. **Video Playback**: Try playing a video - should work
3. **Thumbnails**: Images should load
All traffic should be routed through 192.168.1.37, giving you the Swiss location needed to access geo-blocked content.
-197
View File
@@ -1,197 +0,0 @@
# Proxy Configuration Guide for SRF Play Plugin
This guide explains how to configure the Jellyfin SRF Play plugin to route all API traffic through a proxy or alternate gateway.
## Overview
The SRF Play plugin now supports proxy configuration directly in the plugin settings. This allows you to:
- Route traffic through a specific gateway or proxy server
- Use authentication if your proxy requires it
- Bypass geo-restrictions or network policies
- Route only SRF-related traffic without affecting other Jellyfin operations
## Supported Proxy Types
The plugin supports:
- **HTTP proxies**: `http://proxy.example.com:8080`
- **HTTPS proxies**: `https://proxy.example.com:8443`
- **SOCKS5 proxies**: `socks5://proxy.example.com:1080`
## Configuration Steps
### 1. Access Plugin Settings
1. Open Jellyfin Dashboard
2. Navigate to **Dashboard → Plugins → SRF Play**
3. Scroll down to the **Proxy Settings** section
### 2. Configure Proxy
Fill in the following fields:
#### Use Proxy
- **Enable this checkbox** to route all SRF API requests through the proxy
#### Proxy Address
- Enter your proxy server address with protocol and port
- Examples:
- `http://192.168.1.100:8080`
- `http://proxy.example.com:3128`
- `socks5://127.0.0.1:1080`
#### Proxy Username (Optional)
- Enter username if your proxy requires authentication
- Leave empty if no authentication is needed
#### Proxy Password (Optional)
- Enter password if your proxy requires authentication
- Leave empty if no authentication is needed
### 3. Save Configuration
1. Click **Save** button
2. Restart Jellyfin to apply changes (recommended)
## Examples
### Example 1: Simple HTTP Proxy (No Authentication)
```
Use Proxy: ✓ Enabled
Proxy Address: http://192.168.1.1:8080
Proxy Username: (empty)
Proxy Password: (empty)
```
### Example 2: Authenticated HTTP Proxy
```
Use Proxy: ✓ Enabled
Proxy Address: http://proxy.company.com:3128
Proxy Username: myusername
Proxy Password: mypassword
```
### Example 3: SOCKS5 Proxy
```
Use Proxy: ✓ Enabled
Proxy Address: socks5://127.0.0.1:1080
Proxy Username: (empty)
Proxy Password: (empty)
```
## Setting Up a Transparent Proxy Gateway on Ubuntu
If you want to create your own transparent proxy gateway on Ubuntu, here are some options:
### Option A: Squid Proxy
Install and configure Squid as a transparent proxy:
```bash
# Install Squid
sudo apt update
sudo apt install squid
# Edit configuration
sudo nano /etc/squid/squid.conf
# Add these lines:
http_port 3128
acl localnet src 192.168.1.0/24
http_access allow localnet
# Restart Squid
sudo systemctl restart squid
```
Then in plugin settings:
```
Proxy Address: http://192.168.1.1:3128
```
### Option B: SSH Tunnel (SOCKS5)
Create a SOCKS5 proxy through SSH:
```bash
# On your local machine
ssh -D 1080 -N user@remote-gateway-server
```
Then in plugin settings:
```
Proxy Address: socks5://127.0.0.1:1080
```
### Option C: Dante SOCKS Server
Install Dante for a dedicated SOCKS5 server:
```bash
sudo apt install dante-server
# Configure in /etc/danted.conf
sudo systemctl restart danted
```
## Troubleshooting
### Plugin Not Connecting Through Proxy
1. **Check proxy address format**: Ensure it includes the protocol (http://, socks5://, etc.)
2. **Verify proxy is running**: Test connectivity to the proxy from your Jellyfin server
3. **Check Jellyfin logs**: Look for proxy-related errors in Dashboard → Logs
4. **Firewall rules**: Ensure your firewall allows outbound connections to the proxy
### Authentication Errors
1. Verify username and password are correct
2. Check if your proxy requires domain authentication (DOMAIN\\username)
3. Some proxies may require specific authentication methods not supported by .NET HttpClient
### DNS Resolution
- The plugin resolves domain names before sending requests through the proxy
- If you need DNS resolution through the proxy, you may need to use a VPN or network-level routing instead
## Affected Domains
When proxy is enabled, all requests to these domains will be routed through the proxy:
- `il.srgssr.ch` - SRF Integration Layer API
- `www.srf.ch` - SRF Play v3 API (German)
- `www.rts.ch` - RTS Play v3 API (French)
- `www.rsi.ch` - RSI Play v3 API (Italian)
- `www.rtr.ch` - RTR Play v3 API (Romansh)
- `www.swi.ch` - SWI Play v3 API (International)
## Viewing Logs
To verify the proxy is being used:
1. Go to **Dashboard → Logs**
2. Look for entries containing "Proxy configured"
3. Example log entry:
```
Proxy configured: http://192.168.1.1:8080 (Authentication: False)
```
## Security Notes
- Proxy credentials are stored in Jellyfin's plugin configuration
- Use HTTPS for the proxy connection when possible to encrypt traffic
- Consider using a VPN for more secure routing if dealing with sensitive content
- Regularly update your proxy server and Jellyfin to patch security vulnerabilities
## Alternative: Network-Level Routing
If you prefer network-level routing instead of application proxy, see the `setup-srf-routing.sh` script for IP-based routing tables (requires root access and is more complex).
## Support
If you encounter issues:
1. Check Jellyfin logs for detailed error messages
2. Verify proxy connectivity with `curl --proxy http://proxy:port https://il.srgssr.ch`
3. Open an issue on the GitHub repository with logs and configuration details
-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}");
}
}
}
+111 -28
View File
@@ -4,13 +4,26 @@ A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-d
## 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)
- Automatic content expiration handling
- Latest and trending content discovery
- Quality selection (Auto, SD, HD)
- HLS streaming support
- HLS streaming support with Akamai token authentication
- Proxy support for routing traffic through alternate gateways
- Smart caching with reduced TTL for upcoming livestreams
## Screenshots
### Channel Menu
![SRF Play Menu](res/menu.png)
The main channel interface showing the content folders.
### Playback
![SRF Play Playback](res/playback.png)
Video playback with HLS streaming support and quality selection.
## Project Status
@@ -41,16 +54,20 @@ A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-d
- Quality-based filtering
- DRM content filtering
- Content expiration checking
- Akamai token authentication
- Upcoming livestream detection
- ✅ Metadata Cache Service
- Efficient caching with configurable duration
- Thread-safe with ReaderWriterLockSlim
- IDisposable implementation
- Dynamic TTL for scheduled livestreams
- ✅ Content Expiration Service
- Automatic expiration checking
- Library cleanup of expired content
- Statistics and monitoring
- ✅ Content Refresh Service
- Latest and trending content discovery
- Scheduled sports livestreams
- Automatic cache population
- Recommendations system
@@ -73,19 +90,53 @@ A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-d
- ✅ Jellyfin provider interfaces implementation
- ✅ 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
**Successfully compiling!** All code analysis warnings resolved.
### 🧪 Testing Status
- [ ] Unit tests
- [x] Unit tests (xUnit framework)
- StreamUrlResolver tests
- MetadataCache tests
- More to be added
- [x] API spec validation tests (nightly automated runs)
- All business units (SRF, RTS, RSI, RTR, SWI)
- Response schema validation
- Performance monitoring
- [ ] Integration testing with Jellyfin instance
- [ ] End-to-end playback testing
- [ ] End-to-end playback testing for VOD content
- [ ] Live sports streaming validation
**Run tests:**
```bash
# All tests
dotnet test
# Unit tests only
dotnet test --filter "Category!=Integration&Category!=APISpec"
# API spec tests only
dotnet test --filter "Category=APISpec"
# With coverage
dotnet test --collect:"XPlat Code Coverage"
```
See [Test Documentation](Jellyfin.Plugin.SRFPlay.Tests/README.md) for more details.
### 📝 Next Steps
1. Test the plugin with a live Jellyfin instance
2. Verify content discovery and playback
3. Test expiration handling
4. Add unit tests
1. Test live sports streaming when events are scheduled
2. Verify Akamai token authentication
3. Test with different business units (RTS, RSI, RTR)
4. Add more unit tests for remaining services
5. Performance optimization if needed
## API Information
@@ -94,18 +145,29 @@ A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-d
### 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}/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:{bu}:video:{id}` - Video URN
- `urn:{bu}:video:livestream_{channel}` - Livestream URN (not supported due to Widevine DRM)
- `urn:{bu}:video:{id}` - Video URN (VOD content)
- `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
@@ -131,6 +193,10 @@ The compiled plugin will be in `bin/Debug/net8.0/`
2. Copy the compiled DLL to your Jellyfin plugins directory
3. Restart Jellyfin
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
@@ -168,16 +234,22 @@ Jellyfin.Plugin.SRFPlay/
│ │ ├── Chapter.cs
│ │ ├── Resource.cs
│ │ ├── Show.cs
│ │ ── Episode.cs
│ │ ── Episode.cs
│ │ └── PlayV3/ # Play v3 API models
│ │ ├── PlayV3TvProgram.cs
│ │ └── PlayV3TvProgramGuideResponse.cs
│ └── SRFApiClient.cs # HTTP client for SRF API
├── Channels/
│ └── SRFPlayChannel.cs # Channel implementation
├── Configuration/
│ ├── PluginConfiguration.cs
│ └── configPage.html
├── Services/
│ ├── StreamUrlResolver.cs # HLS stream resolution
│ ├── StreamUrlResolver.cs # HLS stream resolution & authentication
│ ├── MetadataCache.cs # Caching layer
│ ├── ContentExpirationService.cs # Expiration management
── ContentRefreshService.cs # Content discovery
── ContentRefreshService.cs # Content discovery
│ └── CategoryService.cs # Topic/category management
├── Providers/
│ ├── SRFSeriesProvider.cs # Series metadata
│ ├── SRFEpisodeProvider.cs # Episode metadata
@@ -192,21 +264,24 @@ Jellyfin.Plugin.SRFPlay/
### Key Components
1. **API Client**: Handles all HTTP requests to SRF Integration Layer
2. **Stream Resolver**: Extracts and selects optimal HLS streams
3. **Configuration**: User-configurable settings via Jellyfin dashboard
4. **Metadata Cache**: Thread-safe caching with configurable expiration
5. **Content Providers**: Jellyfin integration for series, episodes, images, and media
6. **Scheduled Tasks**: Automatic content refresh and expiration management
7. **Service Layer**: Content discovery, expiration handling, and stream resolution
1. **API Client**: Handles all HTTP requests to SRF Integration Layer and Play v3 API
2. **Channel**: SRF Play channel with Latest, Trending, and Live Sports folders
3. **Stream Resolver**: Extracts and selects optimal HLS streams with Akamai authentication
4. **Configuration**: User-configurable settings via Jellyfin dashboard
5. **Metadata Cache**: Thread-safe caching with dynamic TTL for livestreams
6. **Content Providers**: Jellyfin integration for series, episodes, images, and media sources
7. **Scheduled Tasks**: Automatic content refresh and expiration management
8. **Service Layer**: Content discovery, expiration handling, stream resolution, and category management
## Important Notes
### Content Limitations
- **No DRM content**: Only non-DRM protected content is accessible (no Widevine)
- **No live TV channels**: Only VOD content and live streams without DRM
- **No DRM content**: Only non-DRM protected content is accessible (no Widevine/FairPlay)
- **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
- **Upcoming events**: Sports events appear before they start but require channel refresh to play once live
- **No subtitles**: Subtitle support not currently implemented
### Extensibility
@@ -225,12 +300,15 @@ Currently focused on SRF but easily extensible.
### Current Status
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
- Image fetching and caching
- HLS stream playback
- HLS stream playback with Akamai token authentication
- Automatic content expiration handling
- Scheduled tasks for content refresh
- Smart caching with dynamic TTL for upcoming livestreams
### Testing
@@ -256,8 +334,13 @@ This plugin is in active development. Contributions welcome!
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
- [SRF Play](https://www.srf.ch/play)
- [Jellyfin Plugin Documentation](https://jellyfin.org/docs/general/server/plugins/)
- [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"
version: "1.0.0.0"
targetAbi: "10.9.0.0"
framework: "net8.0"
overview: "Short description about your plugin"
overview: "SRF Play plugin for Jellyfin"
description: >
This is a longer description that can span more than one
line and include details about your plugin.
category: "General"
Provides access to SRF Play content in Jellyfin, including
live streams and on-demand content from Swiss Radio and Television.
category: "LiveTV"
owner: "jellyfin"
imageUrl: "https://www.srf.ch/static/img/srf-logo.svg"
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
Initial release
-57
View File
@@ -1,57 +0,0 @@
#!/bin/bash
# Deploy SRF Play plugin to Jellyfin server
set -e
# Configuration
JELLYFIN_SERVER="192.168.1.4"
JELLYFIN_USER="dtourolle" # Change this to your SSH user
DLL_PATH="Jellyfin.Plugin.SRFPlay/bin/Release/net8.0/Jellyfin.Plugin.SRFPlay.dll"
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${GREEN}=== Deploying SRF Play Plugin ===${NC}\n"
# Check if DLL exists
if [ ! -f "$DLL_PATH" ]; then
echo "Error: DLL not found. Building..."
dotnet build Jellyfin.Plugin.SRFPlay/Jellyfin.Plugin.SRFPlay.csproj -c Release
fi
echo "Step 1: Copying DLL to Jellyfin server..."
scp "$DLL_PATH" "${JELLYFIN_USER}@${JELLYFIN_SERVER}:~/"
echo -e "\nStep 2: Installing DLL on Jellyfin server..."
ssh "${JELLYFIN_USER}@${JELLYFIN_SERVER}" << 'ENDSSH'
echo "Stopping Jellyfin..."
sudo systemctl stop jellyfin
echo "Backing up old DLL..."
sudo cp /var/lib/jellyfin/plugins/SRF/Jellyfin.Plugin.SRFPlay.dll \
/var/lib/jellyfin/plugins/SRF/Jellyfin.Plugin.SRFPlay.dll.backup || true
echo "Installing new DLL..."
sudo cp ~/Jellyfin.Plugin.SRFPlay.dll /var/lib/jellyfin/plugins/SRF/
sudo chown jellyfin:jellyfin /var/lib/jellyfin/plugins/SRF/Jellyfin.Plugin.SRFPlay.dll
echo "Starting Jellyfin..."
sudo systemctl start jellyfin
echo "Waiting for Jellyfin to start..."
sleep 5
echo "Checking Jellyfin status..."
sudo systemctl status jellyfin --no-pager -l | head -20
ENDSSH
echo -e "\n${GREEN}✓ Deployment complete!${NC}"
echo -e "\n${YELLOW}Next steps:${NC}"
echo "1. Test video playback - GUID errors should be fixed"
echo "2. If videos still don't play due to geo-blocking, run network routing setup:"
echo " scp setup-gateway-routing.sh cleanup-gateway-routing.sh ${JELLYFIN_USER}@${JELLYFIN_SERVER}:~"
echo " ssh ${JELLYFIN_USER}@${JELLYFIN_SERVER}"
echo " chmod +x setup-gateway-routing.sh"
echo " sudo ./setup-gateway-routing.sh"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

-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}");
}
}
}