Compare commits

..
1 Commits
Author SHA1 Message Date
dtourolle 3604e8f7a0 Add unit.tests
🧪 Test Plugin / test (pull_request) Failing after 9s
🏗️ Build Plugin / build (pull_request) Failing after 9s
2025-11-14 22:13:24 +01:00
46 changed files with 1735 additions and 2321 deletions
+69
View File
@@ -0,0 +1,69 @@
name: '🌙 Nightly API Spec Tests'
on:
schedule:
# Run every night at 2 AM UTC
- cron: '0 2 * * *'
workflow_dispatch: # Allow manual trigger
jobs:
api-spec-validation:
name: Validate SRF API Spec
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build solution
run: dotnet build --no-restore --configuration Release
- name: Run API Spec Tests
id: api_tests
run: |
dotnet test \
--no-build \
--configuration Release \
--filter "Category=APISpec" \
--logger "console;verbosity=detailed" || echo "TESTS_FAILED=true" >> $GITHUB_ENV
- name: Check Test Results
if: always()
run: |
if [ "$TESTS_FAILED" = "true" ]; then
echo "❌ API Spec Tests Failed"
echo ""
echo "This may indicate:"
echo " - SRF Play API has changed"
echo " - API endpoints are experiencing issues"
echo " - Response schemas have been modified"
echo ""
echo "Actions needed:"
echo " 1. Review the test output above"
echo " 2. Check if SRF Play API documentation has been updated"
echo " 3. Update API models if the schema has changed"
echo " 4. Update tests if they need to be adjusted"
echo ""
exit 1
else
echo "✅ All API spec tests passed!"
echo "The SRF Play API is working as expected."
fi
- name: Upload Test Logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: api-test-failure-logs
path: |
**/*.trx
**/TestResults/
retention-days: 30
-18
View File
@@ -1,18 +0,0 @@
name: '🏗️ Build Plugin'
on:
push:
branches:
- master
paths-ignore:
- '**/*.md'
pull_request:
branches:
- master
paths-ignore:
- '**/*.md'
workflow_dispatch:
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/build.yaml@master
-20
View File
@@ -1,20 +0,0 @@
name: '📝 Create/Update Release Draft & Release Bump PR'
on:
push:
branches:
- master
paths-ignore:
- build.yaml
workflow_dispatch:
repository_dispatch:
types:
- update-prep-command
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/changelog.yaml@master
with:
repository-name: jellyfin/jellyfin-plugin-template
secrets:
token: ${{ secrets.GITHUB_TOKEN }}
-13
View File
@@ -1,13 +0,0 @@
# Allows for the definition of PR and Issue /commands
name: '📟 Slash Command Dispatcher'
on:
issue_comment:
types:
- created
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/command-dispatch.yaml@master
secrets:
token: .
-16
View File
@@ -1,16 +0,0 @@
name: '🔀 PR Rebase Command'
on:
repository_dispatch:
types:
- rebase-command
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/command-rebase.yaml@master
with:
rebase-head: ${{ github.event.client_payload.pull_request.head.label }}
repository-full-name: ${{ github.event.client_payload.github.payload.repository.full_name }}
comment-id: ${{ github.event.client_payload.github.payload.comment.id }}
secrets:
token: ${{ secrets.GITHUB_TOKEN }}
-18
View File
@@ -1,18 +0,0 @@
name: '🚀 Publish Plugin'
on:
release:
types:
- released
workflow_dispatch:
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/publish.yaml@master
with:
version: ${{ github.event.release.tag_name }}
is-unstable: ${{ github.event.release.prerelease }}
secrets:
deploy-host: ${{ secrets.DEPLOY_HOST }}
deploy-user: ${{ secrets.DEPLOY_USER }}
deploy-key: ${{ secrets.DEPLOY_KEY }}
-20
View File
@@ -1,20 +0,0 @@
name: '🔬 Run CodeQL'
on:
push:
branches: [ master ]
paths-ignore:
- '**/*.md'
pull_request:
branches: [ master ]
paths-ignore:
- '**/*.md'
schedule:
- cron: '24 2 * * 4'
workflow_dispatch:
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/scan-codeql.yaml@master
with:
repository-name: jellyfin/jellyfin-plugin-template
-12
View File
@@ -1,12 +0,0 @@
name: '🏷️ Sync labels'
on:
schedule:
- cron: '0 0 1 * *'
workflow_dispatch:
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/sync-labels.yaml@master
secrets:
token: ${{ secrets.GITHUB_TOKEN }}
-18
View File
@@ -1,18 +0,0 @@
name: '🧪 Test Plugin'
on:
push:
branches:
- master
paths-ignore:
- '**/*.md'
pull_request:
branches:
- master
paths-ignore:
- '**/*.md'
workflow_dispatch:
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/test.yaml@master
@@ -0,0 +1,287 @@
using FluentAssertions;
using Jellyfin.Plugin.SRFPlay.Api;
using Microsoft.Extensions.Logging;
using Xunit;
namespace Jellyfin.Plugin.SRFPlay.Tests.IntegrationTests;
/// <summary>
/// Integration tests to validate SRF API spec compliance.
/// These tests make real API calls to ensure the API is still working as expected.
/// </summary>
[Trait("Category", "Integration")]
[Trait("Category", "APISpec")]
public class SRFApiSpecTests : IDisposable
{
private readonly ILoggerFactory _loggerFactory;
private readonly SRFApiClient _apiClient;
private readonly CancellationToken _cancellationToken = CancellationToken.None;
public SRFApiSpecTests()
{
_loggerFactory = LoggerFactory.Create(builder =>
{
builder.AddConsole();
builder.SetMinimumLevel(LogLevel.Information);
});
_apiClient = new SRFApiClient(_loggerFactory);
}
[Fact]
public async Task GetAllShows_SRF_ReturnsShows()
{
// Act
var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken);
// Assert
shows.Should().NotBeNull();
shows.Should().NotBeEmpty();
var firstShow = shows.FirstOrDefault();
firstShow.Should().NotBeNull();
firstShow!.Id.Should().NotBeNullOrEmpty();
firstShow.Title.Should().NotBeNullOrEmpty();
}
[Theory]
[InlineData("srf")]
[InlineData("rts")]
[InlineData("rsi")]
public async Task GetAllShows_MultipleBusinessUnits_ReturnsValidData(string businessUnit)
{
// Act
var shows = await _apiClient.GetAllShowsAsync(businessUnit, _cancellationToken);
// Assert
shows.Should().NotBeNull();
// If shows exist, validate their structure
if (shows != null && shows.Count > 0)
{
var firstShow = shows.FirstOrDefault();
firstShow.Should().NotBeNull();
firstShow!.Id.Should().NotBeNullOrEmpty();
// Validate URN format if present
if (!string.IsNullOrEmpty(firstShow.Urn))
{
firstShow.Urn.Should().Contain($":{businessUnit}:");
}
}
}
[Fact]
public async Task GetVideosForShow_ValidShowId_ReturnsVideos()
{
// Arrange - First get a show
var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken);
shows.Should().NotBeNull().And.NotBeEmpty();
var showWithEpisodes = shows!.FirstOrDefault(s => s != null && s.NumberOfEpisodes > 0);
showWithEpisodes.Should().NotBeNull("at least one show should have episodes");
// Act
var videos = await _apiClient.GetVideosForShowAsync("srf", showWithEpisodes!.Id!, _cancellationToken);
// Assert
videos.Should().NotBeNull();
videos.Should().NotBeEmpty();
var firstVideo = videos!.FirstOrDefault();
firstVideo.Should().NotBeNull();
firstVideo!.Id.Should().NotBeNullOrEmpty();
firstVideo.Title.Should().NotBeNullOrEmpty();
firstVideo.Urn.Should().NotBeNullOrEmpty();
firstVideo.Urn.Should().StartWith("urn:srf:video:");
}
[Fact]
public async Task GetMediaCompositionByUrn_ValidVideoUrn_ReturnsMediaComposition()
{
// Arrange - Get a video URN
var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken);
shows.Should().NotBeNull().And.NotBeEmpty();
var showWithEpisodes = shows!.FirstOrDefault(s => s != null && s.NumberOfEpisodes > 0);
var videos = await _apiClient.GetVideosForShowAsync("srf", showWithEpisodes!.Id!, _cancellationToken);
videos.Should().NotBeNull().And.NotBeEmpty();
var videoUrn = videos!.First()!.Urn!;
// Act
var mediaComposition = await _apiClient.GetMediaCompositionByUrnAsync(videoUrn, _cancellationToken);
// Assert
mediaComposition.Should().NotBeNull();
mediaComposition!.ChapterList.Should().NotBeNull();
mediaComposition.ChapterList.Should().NotBeEmpty();
var chapter = mediaComposition.ChapterList.First();
chapter.Should().NotBeNull();
chapter.Urn.Should().Be(videoUrn);
chapter.ResourceList.Should().NotBeNull();
}
[Fact]
public async Task GetMediaCompositionByUrn_ValidVideoUrn_HasHLSResources()
{
// Arrange - Get a video URN
var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken);
var showWithEpisodes = shows!.FirstOrDefault(s => s != null && s.NumberOfEpisodes > 0);
var videos = await _apiClient.GetVideosForShowAsync("srf", showWithEpisodes!.Id!, _cancellationToken);
var videoUrn = videos!.First()!.Urn!;
// Act
var mediaComposition = await _apiClient.GetMediaCompositionByUrnAsync(videoUrn, _cancellationToken);
// Assert
var chapter = mediaComposition!.ChapterList.First();
var hlsResources = chapter.ResourceList!.Where(r =>
r.Protocol == "HLS" || r.Streaming == "HLS" || r.Url.Contains(".m3u8")).ToList();
hlsResources.Should().NotBeEmpty("video should have HLS streaming resources");
var hlsResource = hlsResources.First();
hlsResource.Url.Should().NotBeNullOrEmpty();
hlsResource.Url.Should().Contain(".m3u8");
}
[Fact]
public async Task VideoResource_Url_IsAccessible()
{
// Arrange - Get a video URN and stream URL
var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken);
var showWithEpisodes = shows!.FirstOrDefault(s => s != null && s.NumberOfEpisodes > 0);
var videos = await _apiClient.GetVideosForShowAsync("srf", showWithEpisodes!.Id!, _cancellationToken);
var videoUrn = videos!.First()!.Urn!;
var mediaComposition = await _apiClient.GetMediaCompositionByUrnAsync(videoUrn, _cancellationToken);
var chapter = mediaComposition!.ChapterList.First();
var hlsResource = chapter.ResourceList!.FirstOrDefault(r =>
(r.DrmList == null || r.DrmList.ToString() == "[]") &&
(r.Protocol == "HLS" || r.Url.Contains(".m3u8")));
hlsResource.Should().NotBeNull("at least one non-DRM HLS resource should exist");
// Act - Try to access the URL
using var httpClient = new HttpClient();
var response = await httpClient.GetAsync(hlsResource!.Url, _cancellationToken);
// Assert
response.Should().NotBeNull();
response.IsSuccessStatusCode.Should().BeTrue("stream URL should be accessible");
var content = await response.Content.ReadAsStringAsync(_cancellationToken);
content.Should().Contain("#EXTM3U", "should be a valid M3U8 playlist");
}
[Fact]
public async Task GetAllShows_ResponseStructure_MatchesExpectedSchema()
{
// Act
var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken);
// Assert - Validate response structure
shows.Should().NotBeNull();
shows.Should().NotBeEmpty();
var show = shows!.First()!;
// Required fields
show.Id.Should().NotBeNullOrEmpty();
show.Title.Should().NotBeNullOrEmpty();
// Optional but commonly present fields
show.NumberOfEpisodes.Should().BeGreaterThanOrEqualTo(0);
// If URN is present, validate format
if (!string.IsNullOrEmpty(show.Urn))
{
show.Urn.Should().MatchRegex(@"^urn:srf:(show|video):.+$");
}
}
[Fact]
public async Task GetVideosForShow_ResponseStructure_MatchesExpectedSchema()
{
// Arrange
var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken);
var showWithEpisodes = shows!.FirstOrDefault(s => s != null && s.NumberOfEpisodes > 0);
// Act
var videos = await _apiClient.GetVideosForShowAsync("srf", showWithEpisodes!.Id!, _cancellationToken);
// Assert - Validate response structure
var video = videos!.First()!;
// Required fields
video.Id.Should().NotBeNullOrEmpty();
video.Title.Should().NotBeNullOrEmpty();
video.Urn.Should().NotBeNullOrEmpty();
video.Urn.Should().MatchRegex(@"^urn:srf:(video|scheduled_livestream:video):.+$");
// Duration should be positive
video.Duration.Should().BeGreaterThan(0);
// Date should be valid
video.Date.Should().BeAfter(DateTime.MinValue);
}
[Fact]
public async Task GetScheduledLivestreams_ReturnsValidData()
{
// Act
var livestreams = await _apiClient.GetScheduledLivestreamsAsync("srf", "SPORT", _cancellationToken);
// Assert
livestreams.Should().NotBeNull();
// If there are livestreams, validate their structure
if (livestreams != null && livestreams.Count > 0)
{
var livestream = livestreams.First();
livestream.Should().NotBeNull();
livestream.Urn.Should().NotBeNullOrEmpty();
livestream.Urn.Should().Contain("scheduled_livestream");
}
}
[Fact]
public async Task ApiEndpoints_AreResponsive()
{
// This test ensures all major endpoints are responsive
var tasks = new List<Task>
{
_apiClient.GetAllShowsAsync("srf", _cancellationToken),
_apiClient.GetScheduledLivestreamsAsync("srf", "SPORT", _cancellationToken)
};
// Act - All API calls should complete without exceptions
var act = async () => await Task.WhenAll(tasks);
// Assert
await act.Should().NotThrowAsync("all API endpoints should be responsive");
}
[Fact]
public async Task ApiPerformance_ReasonableResponseTime()
{
// Arrange
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
// Act
var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken);
// Assert
stopwatch.Stop();
stopwatch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(30),
"API should respond within 30 seconds");
shows.Should().NotBeEmpty();
}
public void Dispose()
{
_apiClient?.Dispose();
_loggerFactory?.Dispose();
}
}
@@ -7,13 +7,23 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="6.12.1" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<!-- Support both .NET 8 and 9 for flexibility in dev environments -->
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
</Project>
+154
View File
@@ -0,0 +1,154 @@
# SRF Play Plugin Tests
This directory contains the test suite for the Jellyfin SRF Play plugin.
## Test Structure
### Unit Tests (`UnitTests/`)
Fast, isolated tests that verify individual components without external dependencies.
- `StreamUrlResolverTests.cs` - Tests for stream URL resolution logic
- `MetadataCacheTests.cs` - Tests for metadata caching functionality
### Integration Tests (`IntegrationTests/`)
Tests that make real API calls to validate the SRF Play API spec compliance.
- `SRFApiSpecTests.cs` - Validates API endpoints, response schemas, and data integrity
### Legacy Tests
- `Program.cs` - Legacy console test application (kept for manual testing)
- `TestPlayV3Api.cs` - Legacy Play v3 API tests (kept for manual testing)
## Running Tests
### Run All Tests
```bash
dotnet test
```
### Run Only Unit Tests
```bash
dotnet test --filter "Category!=Integration&Category!=APISpec"
```
### Run Only Integration Tests
```bash
dotnet test --filter "Category=Integration"
```
### Run Only API Spec Tests
```bash
dotnet test --filter "Category=APISpec"
```
### Run Tests with Coverage
```bash
dotnet test --collect:"XPlat Code Coverage"
```
### Run Tests with Detailed Output
```bash
dotnet test --logger "console;verbosity=detailed"
```
## Test Categories
Tests are organized using xUnit traits:
- **Unit Tests**: No category (default)
- **Integration Tests**: `[Trait("Category", "Integration")]`
- **API Spec Tests**: `[Trait("Category", "APISpec")]`
## Continuous Integration
### Unit Tests
- Run on every push and pull request
- Workflow: `.github/workflows/unit-tests.yaml`
- Must pass before merging PRs
### API Spec Tests (Nightly)
- Run every night at 2 AM UTC
- Workflow: `.github/workflows/nightly-api-tests.yaml`
- Validates that the SRF Play API is still working as expected
- Creates an issue automatically if tests fail
## Adding New Tests
### Unit Test Example
```csharp
using Xunit;
using FluentAssertions;
public class MyServiceTests
{
[Fact]
public void MyMethod_WithValidInput_ReturnsExpectedResult()
{
// Arrange
var service = new MyService();
// Act
var result = service.MyMethod("test");
// Assert
result.Should().Be("expected");
}
}
```
### Integration Test Example
```csharp
using Xunit;
using FluentAssertions;
[Trait("Category", "Integration")]
public class MyApiTests
{
[Fact]
public async Task ApiCall_ReturnsValidData()
{
// Arrange
var client = new ApiClient();
// Act
var result = await client.GetDataAsync();
// Assert
result.Should().NotBeNull();
}
}
```
## Test Dependencies
- **xUnit** - Test framework
- **FluentAssertions** - Fluent assertion library for readable tests
- **Moq** - Mocking framework for creating test doubles
- **Microsoft.NET.Test.Sdk** - .NET test SDK
## Best Practices
1. **Unit Tests Should Be Fast**: Each test should run in milliseconds
2. **Integration Tests Can Be Slower**: API calls may take seconds
3. **Use Descriptive Names**: Test names should describe what they test
4. **Follow AAA Pattern**: Arrange, Act, Assert
5. **One Assertion Per Test**: Focus each test on a single behavior
6. **Clean Up Resources**: Implement `IDisposable` when needed
7. **Avoid Test Interdependence**: Each test should be independent
## Troubleshooting
### Tests Fail Locally
1. Ensure you have internet connectivity (integration tests need it)
2. Check if the SRF Play API is accessible from your location
3. Verify .NET 8.0 SDK is installed
### API Spec Tests Fail
1. Check if the SRF Play API has changed
2. Review the API documentation
3. Update models and tests if necessary
### Coverage is Low
1. Add tests for uncovered code paths
2. Use `dotnet test --collect:"XPlat Code Coverage"` to generate reports
3. Review `TestResults/` directory for coverage details
@@ -0,0 +1,163 @@
using FluentAssertions;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Services;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Jellyfin.Plugin.SRFPlay.Tests.UnitTests;
/// <summary>
/// Unit tests for MetadataCache.
/// </summary>
public class MetadataCacheTests : IDisposable
{
private readonly Mock<ILogger<MetadataCache>> _loggerMock;
private readonly MetadataCache _cache;
public MetadataCacheTests()
{
_loggerMock = new Mock<ILogger<MetadataCache>>();
_cache = new MetadataCache(_loggerMock.Object);
}
[Fact]
public void SetMediaComposition_And_GetMediaComposition_ReturnsCorrectValue()
{
// Arrange
const string urn = "urn:srf:video:test-id";
var mediaComposition = new MediaComposition
{
Episode = new Episode { Id = "test-episode", Title = "Test" }
};
const int cacheDurationMinutes = 10;
// Act
_cache.SetMediaComposition(urn, mediaComposition);
var result = _cache.GetMediaComposition(urn, cacheDurationMinutes);
// Assert
result.Should().NotBeNull();
result.Should().Be(mediaComposition);
}
[Fact]
public void GetMediaComposition_NonExistentKey_ReturnsNull()
{
// Act
var result = _cache.GetMediaComposition("non-existent-urn", 10);
// Assert
result.Should().BeNull();
}
[Fact]
public void GetMediaComposition_ExpiredEntry_ReturnsNull()
{
// Arrange
const string urn = "urn:srf:video:test-id";
var mediaComposition = new MediaComposition
{
Episode = new Episode { Id = "test-episode" }
};
// Set with cache
_cache.SetMediaComposition(urn, mediaComposition);
// Wait a tiny bit to ensure expiration
System.Threading.Thread.Sleep(10);
// Act - Try to get with 0 minute cache duration (immediate expiration)
var result = _cache.GetMediaComposition(urn, 0);
// Assert - Should be null because it's expired
result.Should().BeNull();
}
[Fact]
public void RemoveMediaComposition_ExistingUrn_RemovesValue()
{
// Arrange
const string urn = "urn:srf:video:test-id";
var mediaComposition = new MediaComposition
{
Episode = new Episode { Id = "test-episode" }
};
_cache.SetMediaComposition(urn, mediaComposition);
// Act
_cache.RemoveMediaComposition(urn);
var result = _cache.GetMediaComposition(urn, 10);
// Assert
result.Should().BeNull();
}
[Fact]
public void Clear_RemovesAllValues()
{
// Arrange
var mc1 = new MediaComposition { Episode = new Episode { Id = "episode1" } };
var mc2 = new MediaComposition { Episode = new Episode { Id = "episode2" } };
_cache.SetMediaComposition("urn1", mc1);
_cache.SetMediaComposition("urn2", mc2);
// Act
_cache.Clear();
var result1 = _cache.GetMediaComposition("urn1", 10);
var result2 = _cache.GetMediaComposition("urn2", 10);
// Assert
result1.Should().BeNull();
result2.Should().BeNull();
}
[Fact]
public void GetStatistics_ReturnsCorrectCount()
{
// Arrange
var mc1 = new MediaComposition { Episode = new Episode { Id = "episode1" } };
var mc2 = new MediaComposition { Episode = new Episode { Id = "episode2" } };
_cache.SetMediaComposition("urn1", mc1);
_cache.SetMediaComposition("urn2", mc2);
// Act
var (count, sizeEstimate) = _cache.GetStatistics();
// Assert
count.Should().Be(2);
sizeEstimate.Should().BeGreaterThan(0);
}
[Fact]
public void ConcurrentAccess_DoesNotThrow()
{
// Arrange
var tasks = new List<Task>();
// Act - Perform concurrent operations
for (int i = 0; i < 100; i++)
{
var index = i;
tasks.Add(Task.Run(() =>
{
var mc = new MediaComposition { Episode = new Episode { Id = $"episode-{index}" } };
_cache.SetMediaComposition($"urn-{index}", mc);
_cache.GetMediaComposition($"urn-{index}", 10);
if (index % 2 == 0)
{
_cache.RemoveMediaComposition($"urn-{index}");
}
}));
}
// Assert - Should not throw
var action = async () => await Task.WhenAll(tasks);
action.Should().NotThrowAsync();
}
public void Dispose()
{
_cache?.Dispose();
}
}
@@ -0,0 +1,179 @@
using FluentAssertions;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Jellyfin.Plugin.SRFPlay.Tests.UnitTests;
/// <summary>
/// Unit tests for StreamUrlResolver.
/// </summary>
public class StreamUrlResolverTests : IDisposable
{
private readonly Mock<ILogger<StreamUrlResolver>> _loggerMock;
private readonly StreamUrlResolver _resolver;
public StreamUrlResolverTests()
{
_loggerMock = new Mock<ILogger<StreamUrlResolver>>();
_resolver = new StreamUrlResolver(_loggerMock.Object);
}
[Fact]
public void GetStreamUrl_WithNullChapter_ReturnsNull()
{
// Act
var result = _resolver.GetStreamUrl(null!, QualityPreference.Auto);
// Assert
result.Should().BeNull();
}
[Fact]
public void GetStreamUrl_WithNoResources_ReturnsNull()
{
// Arrange
var chapter = new Chapter
{
Id = "test-id",
ResourceList = new List<Resource>()
};
// Act
var result = _resolver.GetStreamUrl(chapter, QualityPreference.Auto);
// Assert
result.Should().BeNull();
}
[Fact]
public void GetStreamUrl_WithDrmProtectedOnly_ReturnsNull()
{
// Arrange
var chapter = new Chapter
{
Id = "test-id",
ResourceList = new List<Resource>
{
new Resource
{
Url = "https://example.com/stream.m3u8",
Protocol = "HLS",
Quality = "HD",
DrmList = new System.Text.Json.JsonElement() // Non-empty DRM
}
}
};
// Act
var result = _resolver.GetStreamUrl(chapter, QualityPreference.Auto);
// Assert
result.Should().BeNull();
}
[Fact]
public void HasPlayableContent_WithNonDrmHlsStream_ReturnsTrue()
{
// Arrange
var chapter = new Chapter
{
ResourceList = new List<Resource>
{
new Resource
{
Url = "https://example.com/stream.m3u8",
Protocol = "HLS",
Quality = "HD",
DrmList = null
}
}
};
// Act
var result = _resolver.HasPlayableContent(chapter);
// Assert
result.Should().BeTrue();
}
[Fact]
public void HasPlayableContent_WithDrmOnly_ReturnsFalse()
{
// Arrange
var chapter = new Chapter
{
ResourceList = new List<Resource>
{
new Resource
{
Url = "https://example.com/stream.m3u8",
Protocol = "HLS",
DrmList = new System.Text.Json.JsonElement() // Non-empty DRM
}
}
};
// Act
var result = _resolver.HasPlayableContent(chapter);
// Assert
result.Should().BeFalse();
}
[Fact]
public void IsContentExpired_WithFutureValidTo_ReturnsFalse()
{
// Arrange
var chapter = new Chapter
{
ValidTo = DateTime.UtcNow.AddDays(7)
};
// Act
var result = _resolver.IsContentExpired(chapter);
// Assert
result.Should().BeFalse();
}
[Fact]
public void IsContentExpired_WithPastValidTo_ReturnsTrue()
{
// Arrange
var chapter = new Chapter
{
ValidTo = DateTime.UtcNow.AddDays(-1)
};
// Act
var result = _resolver.IsContentExpired(chapter);
// Assert
result.Should().BeTrue();
}
[Fact]
public void IsContentExpired_WithNullValidTo_ReturnsFalse()
{
// Arrange
var chapter = new Chapter
{
ValidTo = null
};
// Act
var result = _resolver.IsContentExpired(chapter);
// Assert
result.Should().BeFalse();
}
public void Dispose()
{
_resolver?.Dispose();
}
}
@@ -1,13 +0,0 @@
namespace Jellyfin.Plugin.SRFPlay.Api;
/// <summary>
/// Factory interface for creating SRF API clients.
/// </summary>
public interface ISRFApiClientFactory
{
/// <summary>
/// Creates a new instance of the SRF API client.
/// </summary>
/// <returns>A new SRFApiClient instance.</returns>
SRFApiClient CreateClient();
}
+11 -6
View File
@@ -140,11 +140,6 @@ public class SRFApiClient : IDisposable
var fullUrl = $"{BaseUrl}{url}";
_logger.LogInformation("Fetching media composition for URN: {Urn} from {Url}", urn, fullUrl);
// HttpClient consistently fails with 404, use curl directly
// This is likely due to routing/network configuration on the Jellyfin server
return await FetchWithCurlAsync(fullUrl, cancellationToken).ConfigureAwait(false);
/* HttpClient fallback disabled - always returns 404
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
// Log response headers to diagnose geo-blocking
@@ -183,7 +178,17 @@ public class SRFApiClient : IDisposable
}
return result;
*/
}
catch (HttpRequestException ex)
{
_logger.LogError(
ex,
"HTTP error fetching media composition for URN: {Urn} - StatusCode: {StatusCode}, trying curl fallback",
urn,
ex.StatusCode);
var fullUrl = $"{BaseUrl}/mediaComposition/byUrn/{urn}.json";
return await FetchWithCurlAsync(fullUrl, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -1,26 +0,0 @@
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Api;
/// <summary>
/// Factory for creating SRF API clients.
/// </summary>
public class SRFApiClientFactory : ISRFApiClientFactory
{
private readonly ILoggerFactory _loggerFactory;
/// <summary>
/// Initializes a new instance of the <see cref="SRFApiClientFactory"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
public SRFApiClientFactory(ILoggerFactory loggerFactory)
{
_loggerFactory = loggerFactory;
}
/// <inheritdoc />
public SRFApiClient CreateClient()
{
return new SRFApiClient(_loggerFactory);
}
}
@@ -6,8 +6,7 @@ using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller;
using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Channels;
@@ -24,11 +23,9 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{
private readonly ILogger<SRFPlayChannel> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly IContentRefreshService _contentRefreshService;
private readonly IStreamUrlResolver _streamResolver;
private readonly IStreamProxyService _proxyService;
private readonly ICategoryService? _categoryService;
private readonly IServerApplicationHost _appHost;
private readonly ContentRefreshService _contentRefreshService;
private readonly StreamUrlResolver _streamResolver;
private readonly CategoryService? _categoryService;
/// <summary>
/// Initializes a new instance of the <see cref="SRFPlayChannel"/> class.
@@ -36,23 +33,17 @@ 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,
IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver,
IStreamProxyService proxyService,
IServerApplicationHost appHost,
ICategoryService? categoryService = null)
ContentRefreshService contentRefreshService,
StreamUrlResolver streamResolver,
CategoryService? categoryService = null)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFPlayChannel>();
_contentRefreshService = contentRefreshService;
_streamResolver = streamResolver;
_proxyService = proxyService;
_appHost = appHost;
_categoryService = categoryService;
if (_categoryService == null)
@@ -70,7 +61,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
public string Description => "Swiss Radio and Television video-on-demand content";
/// <inheritdoc />
public string DataVersion => "2.0"; // Back to authenticating at channel refresh with auto-refresh for fresh tokens
public string DataVersion => "1.0";
/// <inheritdoc />
public string HomePageUrl => "https://www.srf.ch/play";
@@ -240,8 +231,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
.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))
p.ValidFrom.Value.ToUniversalTime() <= weekFromNow)
.OrderBy(p => p.ValidFrom)
.ToList();
@@ -445,69 +435,49 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
// 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))
// For scheduled livestreams that haven't started, streamUrl might be null
var isUpcomingLivestream = chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl);
if (!string.IsNullOrEmpty(streamUrl))
{
_logger.LogDebug(
"URN {Urn}: Skipping upcoming livestream '{Title}' - stream not yet available (starts at {ValidFrom})",
// Authenticate the stream URL (required for all SRF streams, especially livestreams)
streamUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).ConfigureAwait(false);
}
else if (isUpcomingLivestream)
{
// Use a placeholder for upcoming events
streamUrl = "http://placeholder.local/upcoming.m3u8";
_logger.LogInformation(
"URN {Urn}: Upcoming livestream '{Title}' - stream will be available at {ValidFrom}",
urn,
chapter.Title,
chapter.ValidFrom);
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
// Build overview with event time for upcoming livestreams
var overview = chapter.Description ?? chapter.Lead;
if (isUpcomingLivestream && chapter.ValidFrom != null)
{
var eventStart = chapter.ValidFrom.Value.ToString("dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture);
overview = $"[Upcoming Event - Starts at {eventStart}]\n\n{overview}";
}
// Get image URL - prefer chapter image, fall back to show image if available
var originalImageUrl = chapter.ImageUrl;
if (string.IsNullOrEmpty(originalImageUrl) && mediaComposition.Show != null)
var imageUrl = chapter.ImageUrl;
if (string.IsNullOrEmpty(imageUrl) && mediaComposition.Show != null)
{
originalImageUrl = mediaComposition.Show.ImageUrl;
imageUrl = mediaComposition.Show.ImageUrl;
_logger.LogDebug("URN {Urn}: Using show image as fallback", urn);
}
if (string.IsNullOrEmpty(originalImageUrl))
if (string.IsNullOrEmpty(imageUrl))
{
_logger.LogWarning("URN {Urn}: No image URL available for '{Title}'", urn, chapter.Title);
}
// Proxy image URL to fix Content-Type headers from SRF CDN
var imageUrl = CreateProxiedImageUrl(originalImageUrl, serverUrl);
// Use ValidFrom for premiere date if this is an upcoming livestream, otherwise use Date
var premiereDate = isUpcomingLivestream ? chapter.ValidFrom?.ToUniversalTime() : chapter.Date?.ToUniversalTime();
// 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,
@@ -531,38 +501,15 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{
Id = itemId,
Name = chapter.Title,
Path = proxyUrl, // Proxy URL instead of direct Akamai URL
Path = streamUrl,
Protocol = MediaBrowser.Model.MediaInfo.MediaProtocol.Http,
Container = "hls",
Container = "m3u8",
SupportsDirectStream = true,
SupportsDirectPlay = true, // ✅ Enabled! Proxy handles auth
SupportsDirectPlay = true,
SupportsTranscoding = true,
IsRemote = false, // False because it's a local proxy endpoint
IsRemote = true,
Type = MediaBrowser.Model.Dto.MediaSourceType.Default,
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
}
}
VideoType = VideoType.VideoFile
}
}
};
@@ -576,13 +523,6 @@ 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)
{
@@ -620,24 +560,6 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
}
#pragma warning restore CA5351
/// <summary>
/// Creates a proxy URL for an image to fix Content-Type headers from SRF CDN.
/// </summary>
/// <param name="originalUrl">The original SRF image URL.</param>
/// <param name="serverUrl">The server base URL.</param>
/// <returns>The proxied image URL, or null if original is null.</returns>
private static string? CreateProxiedImageUrl(string? originalUrl, string serverUrl)
{
if (string.IsNullOrEmpty(originalUrl))
{
return null;
}
// Encode the original URL as base64 for safe transport
var encodedUrl = Convert.ToBase64String(Encoding.UTF8.GetBytes(originalUrl));
return $"{serverUrl}/Plugins/SRFPlay/Proxy/Image?url={encodedUrl}";
}
/// <inheritdoc />
public bool IsEnabledFor(string userId)
{
@@ -142,11 +142,4 @@ 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,17 +82,6 @@
<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>
@@ -121,7 +110,6 @@
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();
});
});
@@ -141,7 +129,6 @@
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);
});
@@ -1,441 +0,0 @@
using System;
using System.Globalization;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Common.Net;
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 IStreamProxyService _proxyService;
private readonly IHttpClientFactory _httpClientFactory;
/// <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>
/// <param name="httpClientFactory">The HTTP client factory.</param>
public StreamProxyController(
ILogger<StreamProxyController> logger,
IStreamProxyService proxyService,
IHttpClientFactory httpClientFactory)
{
_logger = logger;
_proxyService = proxyService;
_httpClientFactory = httpClientFactory;
}
/// <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 - Path ItemId: {PathItemId}, Query params: {QueryString}", itemId, Request.QueryString);
// Try to resolve the actual item ID (path ID might be a session ID during transcoding)
var actualItemId = ResolveItemId(itemId);
try
{
// Get the correct scheme (https if configured, otherwise use request scheme)
var scheme = GetProxyScheme();
// Build the base proxy URL for this item
// Preserve query parameters (token or itemId) from the original request
string baseProxyUrl;
if (Request.Query.TryGetValue("token", out var token) && !string.IsNullOrEmpty(token))
{
baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}?token={token}";
_logger.LogDebug("Using token-based proxy URL with token: {Token}", token.ToString());
}
else if (actualItemId != itemId)
{
// Legacy: If path ID differs from resolved ID, add itemId query parameter
baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}?itemId={actualItemId}";
_logger.LogInformation("Path itemId {PathId} differs from resolved itemId {ResolvedId}, adding query parameter", itemId, actualItemId);
}
else
{
// Simple case: no query parameters needed
baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}";
_logger.LogDebug("Path itemId matches resolved itemId: {ItemId}", itemId);
}
var manifestContent = await _proxyService.GetRewrittenManifestAsync(actualItemId, baseProxyUrl, cancellationToken).ConfigureAwait(false);
if (manifestContent == null)
{
_logger.LogWarning("Manifest not found for path itemId {PathItemId}, resolved itemId {ResolvedItemId} - stream may not be registered", itemId, actualItemId);
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 scheme = GetProxyScheme();
var baseProxyUrl = $"{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>
/// Gets the correct scheme for proxy URLs (https if public URL is configured with https).
/// </summary>
/// <returns>The scheme to use (http or https).</returns>
private string GetProxyScheme()
{
// Check if PublicServerUrl is configured and uses HTTPS
var config = Plugin.Instance?.Configuration;
if (config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl))
{
if (config.PublicServerUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return "https";
}
}
// Fall back to request scheme, but prefer https if forwarded headers indicate it
if (Request.Headers.TryGetValue("X-Forwarded-Proto", out var forwardedProto))
{
return forwardedProto.ToString().ToLowerInvariant();
}
return Request.Scheme;
}
/// <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 for token parameter first (preferred method)
if (Request.Query.TryGetValue("token", out var token) && !string.IsNullOrEmpty(token))
{
// Try to resolve the original item ID from the token via the proxy service
// We'll need to add a method to StreamProxyService to look up by token
_logger.LogInformation("Found token parameter: {Token}, will use path ID {PathItemId} for lookup", token.ToString(), pathItemId);
return pathItemId; // Use path ID for now; token prevents Jellyfin from rewriting the URL
}
// Check if there's an itemId query parameter (legacy fallback)
if (Request.Query.TryGetValue("itemId", out var queryItemId) && !string.IsNullOrEmpty(queryItemId))
{
_logger.LogInformation("Using itemId from query parameter: {QueryItemId} (path had: {PathItemId})", queryItemId.ToString(), pathItemId);
return queryItemId.ToString();
}
// No query parameters - use path ID as-is (TRANSCODING SESSION ID CASE)
_logger.LogWarning("⚠️ No query parameters found, using path ID as-is: {PathItemId} (likely transcoding session ID)", 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 query parameters from the current request to propagate them
string queryParams;
if (Request.Query.TryGetValue("token", out var token) && !string.IsNullOrEmpty(token))
{
queryParams = $"?token={token}";
}
else if (Request.Query.TryGetValue("itemId", out var itemId) && !string.IsNullOrEmpty(itemId))
{
queryParams = $"?itemId={itemId}";
}
else
{
queryParams = 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}{queryParams}");
}
else
{
// Relative URL - rewrite to proxy
result.AppendLine(CultureInfo.InvariantCulture, $"{baseProxyUrl}/{line.Trim()}{queryParams}");
}
}
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";
}
/// <summary>
/// Proxies image requests from SRF CDN, fixing Content-Type headers.
/// </summary>
/// <param name="url">The original image URL (base64 encoded).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The image data with correct Content-Type.</returns>
[HttpGet("Image")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetImage(
[FromQuery] string url,
CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(url))
{
return BadRequest("URL parameter is required");
}
string decodedUrl;
try
{
// Decode base64 URL
var bytes = Convert.FromBase64String(url);
decodedUrl = System.Text.Encoding.UTF8.GetString(bytes);
}
catch (FormatException)
{
_logger.LogWarning("Invalid base64 URL parameter: {Url}", url);
return BadRequest("Invalid URL encoding");
}
_logger.LogDebug("Proxying image from: {Url}", decodedUrl);
try
{
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
// Create request with proper headers
var request = new HttpRequestMessage(HttpMethod.Get, new Uri(decodedUrl));
request.Headers.UserAgent.ParseAdd(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
request.Headers.Accept.ParseAdd("image/jpeg, image/png, image/webp, image/*;q=0.8");
var response = await httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Image fetch failed with status {StatusCode} for {Url}",
response.StatusCode,
decodedUrl);
return NotFound();
}
var imageData = await response.Content.ReadAsByteArrayAsync(cancellationToken)
.ConfigureAwait(false);
// Determine correct content type
var contentType = GetImageContentType(decodedUrl);
_logger.LogDebug(
"Returning proxied image ({Length} bytes, {ContentType})",
imageData.Length,
contentType);
return File(imageData, contentType);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Error fetching image from {Url}", decodedUrl);
return StatusCode(StatusCodes.Status502BadGateway);
}
}
/// <summary>
/// Gets the content type for an image based on URL or file extension.
/// </summary>
/// <param name="url">The image URL.</param>
/// <returns>The MIME content type.</returns>
private static string GetImageContentType(string url)
{
if (string.IsNullOrEmpty(url))
{
return "image/jpeg";
}
var uri = new Uri(url);
var path = uri.AbsolutePath.ToLowerInvariant();
if (path.EndsWith(".png", StringComparison.Ordinal))
{
return "image/png";
}
if (path.EndsWith(".gif", StringComparison.Ordinal))
{
return "image/gif";
}
if (path.EndsWith(".webp", StringComparison.Ordinal))
{
return "image/webp";
}
// Default to JPEG for SRF images (most common)
return "image/jpeg";
}
}
@@ -4,7 +4,8 @@ using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
@@ -19,23 +20,25 @@ namespace Jellyfin.Plugin.SRFPlay.Providers;
public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
{
private readonly ILogger<SRFEpisodeProvider> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMediaCompositionFetcher _compositionFetcher;
private readonly MetadataCache _metadataCache;
/// <summary>
/// Initializes a new instance of the <see cref="SRFEpisodeProvider"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="httpClientFactory">The HTTP client factory.</param>
/// <param name="compositionFetcher">The media composition fetcher.</param>
/// <param name="metadataCache">The metadata cache.</param>
public SRFEpisodeProvider(
ILoggerFactory loggerFactory,
IHttpClientFactory httpClientFactory,
IMediaCompositionFetcher compositionFetcher)
MetadataCache metadataCache)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFEpisodeProvider>();
_httpClientFactory = httpClientFactory;
_compositionFetcher = compositionFetcher;
_metadataCache = metadataCache;
}
/// <inheritdoc />
@@ -53,7 +56,26 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
{
_logger.LogDebug("Searching for episode with URN: {Urn}", urn);
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return results;
}
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0)
{
@@ -96,7 +118,26 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
_logger.LogDebug("Fetching metadata for episode URN: {Urn}", urn);
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return result;
}
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
{
@@ -1,10 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Api;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
@@ -21,22 +21,18 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<SRFImageProvider> _logger;
private readonly IMediaCompositionFetcher _compositionFetcher;
private readonly ILoggerFactory _loggerFactory;
/// <summary>
/// Initializes a new instance of the <see cref="SRFImageProvider"/> class.
/// </summary>
/// <param name="httpClientFactory">The HTTP client factory.</param>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="compositionFetcher">The media composition fetcher.</param>
public SRFImageProvider(
IHttpClientFactory httpClientFactory,
ILoggerFactory loggerFactory,
IMediaCompositionFetcher compositionFetcher)
public SRFImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory)
{
_httpClientFactory = httpClientFactory;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFImageProvider>();
_compositionFetcher = compositionFetcher;
}
/// <inheritdoc />
@@ -82,7 +78,8 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
_logger.LogDebug("Fetching images for SRF URN: {Urn}", urn);
// Fetch media composition to get image URLs
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
using var apiClient = new SRFApiClient(_loggerFactory);
var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition == null)
{
@@ -154,89 +151,9 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
}
/// <inheritdoc />
public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
_logger.LogDebug("Fetching image from URL: {Url}", url);
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
// Create request with proper headers - SRF CDN requires User-Agent
var request = new HttpRequestMessage(HttpMethod.Get, new Uri(url));
request.Headers.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");
request.Headers.Accept.ParseAdd("image/jpeg, image/png, image/webp, image/*;q=0.8");
var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var originalContentType = response.Content.Headers.ContentType?.MediaType;
_logger.LogDebug(
"Image response status: {StatusCode}, Content-Type: {ContentType}, Content-Length: {Length}",
response.StatusCode,
originalContentType ?? "null",
response.Content.Headers.ContentLength);
// Fix Content-Type if it's not a proper image type - SRF CDN often returns wrong content type
// Jellyfin needs correct Content-Type to process images
if (response.IsSuccessStatusCode)
{
var needsContentTypeFix = string.IsNullOrEmpty(originalContentType) ||
originalContentType == "binary/octet-stream" ||
originalContentType == "application/octet-stream" ||
!originalContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase);
if (needsContentTypeFix)
{
// Determine correct content type from URL extension or default to JPEG
var contentType = GetContentTypeFromUrl(url);
if (!string.IsNullOrEmpty(contentType))
{
_logger.LogInformation(
"Fixing Content-Type from '{OriginalType}' to '{NewType}' for {Url}",
originalContentType ?? "null",
contentType,
url);
response.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
}
}
}
return response;
}
/// <summary>
/// Determines the correct content type based on URL file extension.
/// </summary>
private static string? GetContentTypeFromUrl(string url)
{
if (string.IsNullOrEmpty(url))
{
return null;
}
// Get the file extension from the URL (ignore query string)
var uri = new Uri(url);
var path = uri.AbsolutePath.ToLowerInvariant();
if (path.EndsWith(".jpg", StringComparison.Ordinal) || path.EndsWith(".jpeg", StringComparison.Ordinal))
{
return "image/jpeg";
}
if (path.EndsWith(".png", StringComparison.Ordinal))
{
return "image/png";
}
if (path.EndsWith(".gif", StringComparison.Ordinal))
{
return "image/gif";
}
if (path.EndsWith(".webp", StringComparison.Ordinal))
{
return "image/webp";
}
// Default to JPEG for SRF images (most common)
return "image/jpeg";
return httpClient.GetAsync(new Uri(url), cancellationToken);
}
}
@@ -1,139 +0,0 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Providers;
/// <summary>
/// Live stream wrapper for SRF Play streams to handle transcoding sessions.
/// </summary>
internal sealed class SRFLiveStream : ILiveStream
{
private readonly ILogger _logger;
private readonly IStreamProxyService _proxyService;
private readonly string _originalItemId;
private MediaSourceInfo? _mediaSource;
/// <summary>
/// Initializes a new instance of the <see cref="SRFLiveStream"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="proxyService">The stream proxy service.</param>
/// <param name="originalItemId">The original item ID.</param>
/// <param name="openToken">The open token.</param>
public SRFLiveStream(
ILogger logger,
IStreamProxyService proxyService,
string originalItemId,
string openToken)
{
_logger = logger;
_proxyService = proxyService;
_originalItemId = originalItemId;
OriginalStreamId = openToken;
UniqueId = openToken;
}
/// <inheritdoc />
public int ConsumerCount { get; set; }
/// <inheritdoc />
public string OriginalStreamId { get; set; }
/// <inheritdoc />
public string UniqueId { get; }
/// <inheritdoc />
public string TunerHostId => string.Empty;
/// <inheritdoc />
public bool EnableStreamSharing => false;
/// <inheritdoc />
public MediaSourceInfo MediaSource
{
get => _mediaSource ?? throw new InvalidOperationException("MediaSource not set");
set
{
_mediaSource = value;
_logger.LogInformation(
"SRFLiveStream MediaSource set - Id: {MediaSourceId}, Path: {Path}, OriginalItemId: {OriginalItemId}",
value.Id,
value.Path,
_originalItemId);
// When Jellyfin assigns a live stream ID (for transcoding), register the stream with that ID too
if (value.Id != _originalItemId)
{
_logger.LogInformation(
"Transcoding session detected - LiveStream ID {LiveStreamId} differs from original item ID {OriginalItemId}. Registering stream with both IDs.",
value.Id,
_originalItemId);
// Get the authenticated URL and metadata from the original registration
var authenticatedUrl = _proxyService.GetAuthenticatedUrl(_originalItemId);
var metadata = _proxyService.GetStreamMetadata(_originalItemId);
if (authenticatedUrl != null)
{
// Register the same stream URL with the transcoding session ID, preserving metadata
var urn = metadata?.Urn;
var isLiveStream = metadata?.IsLiveStream ?? false;
_proxyService.RegisterStream(value.Id, authenticatedUrl, urn, isLiveStream);
_logger.LogInformation(
"Registered stream for transcoding session ID: {LiveStreamId} (URN: {Urn}, IsLiveStream: {IsLiveStream})",
value.Id,
urn ?? "null",
isLiveStream);
}
else
{
_logger.LogWarning("Could not find authenticated URL for original item {OriginalItemId}", _originalItemId);
}
}
}
}
/// <inheritdoc />
public Task Close()
{
_logger.LogInformation("Closing SRF live stream for item {OriginalItemId}", _originalItemId);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task Open(CancellationToken cancellationToken)
{
_logger.LogInformation("Opening SRF live stream for item {OriginalItemId}", _originalItemId);
return Task.CompletedTask;
}
/// <inheritdoc />
public Stream GetStream()
{
throw new NotSupportedException("Direct stream access not supported for SRF streams");
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases the unmanaged resources used by the SRFLiveStream 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>
private void Dispose(bool disposing)
{
if (disposing)
{
_logger.LogDebug("Disposing SRF live stream for item {OriginalItemId}", _originalItemId);
}
}
}
@@ -1,10 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
@@ -20,31 +20,25 @@ namespace Jellyfin.Plugin.SRFPlay.Providers;
public class SRFMediaProvider : IMediaSourceProvider
{
private readonly ILogger<SRFMediaProvider> _logger;
private readonly IMediaCompositionFetcher _compositionFetcher;
private readonly IStreamUrlResolver _streamResolver;
private readonly IStreamProxyService _proxyService;
private readonly IServerApplicationHost _appHost;
private readonly ILoggerFactory _loggerFactory;
private readonly MetadataCache _metadataCache;
private readonly StreamUrlResolver _streamResolver;
/// <summary>
/// Initializes a new instance of the <see cref="SRFMediaProvider"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="compositionFetcher">The media composition fetcher.</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,
IMediaCompositionFetcher compositionFetcher,
IStreamUrlResolver streamResolver,
IStreamProxyService proxyService,
IServerApplicationHost appHost)
MetadataCache metadataCache,
StreamUrlResolver streamResolver)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFMediaProvider>();
_compositionFetcher = compositionFetcher;
_metadataCache = metadataCache;
_streamResolver = streamResolver;
_proxyService = proxyService;
_appHost = appHost;
}
/// <summary>
@@ -58,40 +52,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 async Task<IEnumerable<MediaSourceInfo>> GetMediaSources(BaseItem item, CancellationToken cancellationToken)
public 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))
{
_logger.LogDebug("Item {ItemName} is not an SRF item, returning empty sources", item.Name);
return sources;
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
_logger.LogInformation("Getting media sources for URN: {Urn}, Item: {ItemName}", urn, item.Name);
_logger.LogDebug("Getting media sources for URN: {Urn}", urn);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
// For scheduled livestreams, use shorter cache TTL (5 minutes) so they refresh when they go live
var cacheDuration = urn.Contains("scheduled_livestream", StringComparison.OrdinalIgnoreCase) ? 5 : (int?)null;
// For regular content, use configured cache duration
var cacheDuration = urn.Contains("scheduled_livestream", StringComparison.OrdinalIgnoreCase)
? 5
: config.CacheDurationMinutes;
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, cacheDuration).ConfigureAwait(false);
// Try cache first
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();
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
{
_logger.LogWarning("No chapters found for URN: {Urn}", urn);
return sources;
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
// Get the first chapter (main video)
@@ -101,130 +106,127 @@ public class SRFMediaProvider : IMediaSourceProvider
if (_streamResolver.IsContentExpired(chapter))
{
_logger.LogWarning("Content expired for URN: {Urn}, ValidTo: {ValidTo}", urn, chapter.ValidTo);
return sources;
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
// Check if content has playable streams
if (!_streamResolver.HasPlayableContent(chapter))
{
_logger.LogWarning("No playable content found for URN: {Urn}", urn);
return sources;
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
// Get stream URL based on quality preference
var config = Plugin.Instance?.Configuration;
var qualityPref = config?.QualityPreference ?? QualityPreference.HD;
var streamUrl = _streamResolver.GetStreamUrl(chapter, qualityPref);
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))
// Check if this is an upcoming livestream that hasn't started yet
var isUpcomingLivestream = chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl);
if (string.IsNullOrEmpty(streamUrl) && !isUpcomingLivestream)
{
_logger.LogDebug("URN {Urn}: Scheduled livestream has no stream URL, fetching fresh data", urn);
_logger.LogWarning("Could not resolve stream URL for URN: {Urn}", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
// Force fresh fetch with short cache duration
var freshMediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, 1).ConfigureAwait(false);
// For upcoming livestreams, check if the event has started
if (isUpcomingLivestream)
{
if (chapter.ValidFrom != null)
{
var eventStart = chapter.ValidFrom.Value.ToUniversalTime();
var now = DateTime.UtcNow;
if (eventStart > now)
{
_logger.LogInformation(
"URN {Urn}: Livestream '{Title}' hasn't started yet (starts at {ValidFrom}). User should refresh when live.",
urn,
chapter.Title,
chapter.ValidFrom);
// Return empty sources - event not yet playable
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
}
// Event should be live now - re-fetch media composition without cache
using var freshApiClient = new SRFApiClient(_loggerFactory);
var freshMediaComposition = freshApiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).GetAwaiter().GetResult();
if (freshMediaComposition?.ChapterList != null && freshMediaComposition.ChapterList.Count > 0)
{
var freshChapter = freshMediaComposition.ChapterList[0];
streamUrl = _streamResolver.GetStreamUrl(freshChapter, qualityPref);
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);
_logger.LogInformation("URN {Urn}: Livestream is now live, got fresh stream URL", urn);
}
else
{
_logger.LogWarning("URN {Urn}: Livestream should be live but still no stream URL available", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
}
}
if (string.IsNullOrEmpty(streamUrl))
{
_logger.LogWarning("Could not resolve stream URL for URN: {Urn}", urn);
return sources;
else
{
_logger.LogWarning("URN {Urn}: Failed to fetch fresh media composition for livestream", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
}
// Authenticate the stream URL (required for all SRF streams, especially livestreams)
if (!string.IsNullOrEmpty(streamUrl))
{
streamUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).ConfigureAwait(false);
streamUrl = _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).GetAwaiter().GetResult();
_logger.LogDebug("Authenticated stream URL for URN: {Urn}", urn);
}
// Detect if this is a live stream
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
_logger.LogInformation(
"Livestream detection - ChapterType: {ChapterType}, URN: {Urn}, IsLiveStream: {IsLiveStream}",
chapter.Type,
urn,
isLiveStream);
// Register stream with proxy service
var itemIdStr = item.Id.ToString("N"); // Use hex format without dashes
_proxyService.RegisterStream(itemIdStr, streamUrl, urn, isLiveStream);
// Get the server URL for proxy - prefer configured public URL for remote clients
var serverUrl = config != null && !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 - item ID is all we need since proxy handles auth
var proxyUrl = $"{serverUrl}/Plugins/SRFPlay/Proxy/{itemIdStr}/master.m3u8";
_logger.LogInformation(
"Using proxy URL for item {ItemId}: {ProxyUrl} (PublicServerUrl configured: {IsPublicConfigured})",
itemIdStr,
proxyUrl,
config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl));
// Create media source using proxy URL - enables DirectPlay!
// Create media source
var mediaSource = new MediaSourceInfo
{
Id = itemIdStr, // Must match the ID used in proxy URL registration
Id = urn,
Name = chapter.Title,
Path = proxyUrl, // Proxy URL instead of direct Akamai URL
Path = streamUrl,
Protocol = MediaProtocol.Http,
Container = "hls",
Container = "m3u8",
SupportsDirectStream = true,
SupportsDirectPlay = true, // ✅ Enabled! Proxy handles auth
SupportsTranscoding = false, // Prefer DirectPlay - no transcoding needed for HLS
IsRemote = false, // False because it's a local proxy endpoint
SupportsDirectPlay = true,
SupportsTranscoding = true,
IsRemote = true,
Type = MediaSourceType.Default,
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
VideoType = VideoType.VideoFile,
IsInfiniteStream = isLiveStream, // True for live streams!
RequiresOpening = false, // Proxy handles auth - no need for OpenMediaSource
IsInfiniteStream = false,
RequiresOpening = false,
RequiresClosing = false,
SupportsProbing = true, // Enable probing so Jellyfin can verify stream compatibility
ReadAtNativeFramerate = isLiveStream, // Read at native framerate for live streams
MediaStreams = CreateMediaStreams(qualityPref)
SupportsProbing = true
};
// Add video stream info
mediaSource.MediaStreams = new List<MediaStream>
{
new MediaStream
{
Type = MediaStreamType.Video,
Codec = "h264",
IsInterlaced = false,
IsDefault = true
}
};
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 sources;
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
/// <summary>
@@ -235,7 +237,6 @@ 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);
}
@@ -243,55 +244,7 @@ public class SRFMediaProvider : IMediaSourceProvider
/// <inheritdoc />
public Task<ILiveStream> OpenMediaSource(string openToken, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
{
// Not used - RequiresOpening is false, proxy handles authentication directly
_logger.LogWarning("OpenMediaSource unexpectedly called with openToken: {OpenToken}", openToken);
throw new NotSupportedException("OpenMediaSource not supported - streams use direct proxy access");
}
/// <summary>
/// Creates MediaStream metadata based on quality preference.
/// These are approximate values - HLS adaptive streaming will use actual stream qualities.
/// </summary>
private static List<MediaBrowser.Model.Entities.MediaStream> CreateMediaStreams(QualityPreference quality)
{
// Set resolution/bitrate based on quality preference
// These are typical values for SRF streams - actual HLS will adapt
var (width, height, videoBitrate) = quality switch
{
QualityPreference.SD => (1280, 720, 2500000),
QualityPreference.HD => (1920, 1080, 5000000),
_ => (1280, 720, 3000000)
};
return new List<MediaBrowser.Model.Entities.MediaStream>
{
new MediaBrowser.Model.Entities.MediaStream
{
Type = MediaStreamType.Video,
Codec = "h264",
Profile = "high",
Level = 40,
Width = width,
Height = height,
BitRate = videoBitrate,
BitDepth = 8,
IsInterlaced = false,
IsDefault = true,
Index = 0,
IsAVC = true,
PixelFormat = "yuv420p"
},
new MediaBrowser.Model.Entities.MediaStream
{
Type = MediaStreamType.Audio,
Codec = "aac",
Profile = "LC",
Channels = 2,
SampleRate = 48000,
BitRate = 128000,
IsDefault = true,
Index = 1
}
};
// Not needed for static HTTP streams
throw new NotImplementedException();
}
}
@@ -4,7 +4,8 @@ using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
@@ -19,23 +20,27 @@ namespace Jellyfin.Plugin.SRFPlay.Providers;
public class SRFSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>
{
private readonly ILogger<SRFSeriesProvider> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMediaCompositionFetcher _compositionFetcher;
private readonly MetadataCache _metadataCache;
/// <summary>
/// Initializes a new instance of the <see cref="SRFSeriesProvider"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="httpClientFactory">The HTTP client factory.</param>
/// <param name="compositionFetcher">The media composition fetcher.</param>
/// <param name="metadataCache">The metadata cache.</param>
public SRFSeriesProvider(
ILogger<SRFSeriesProvider> logger,
ILoggerFactory loggerFactory,
IHttpClientFactory httpClientFactory,
IMediaCompositionFetcher compositionFetcher)
MetadataCache metadataCache)
{
_logger = loggerFactory.CreateLogger<SRFSeriesProvider>();
_logger = logger;
_loggerFactory = loggerFactory;
_httpClientFactory = httpClientFactory;
_compositionFetcher = compositionFetcher;
_metadataCache = metadataCache;
}
/// <inheritdoc />
@@ -53,7 +58,26 @@ public class SRFSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>
{
_logger.LogDebug("Searching for series with URN: {Urn}", urn);
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return results;
}
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
if (mediaComposition?.Show != null)
{
@@ -101,7 +125,26 @@ public class SRFSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>
_logger.LogDebug("Fetching metadata for series URN: {Urn}", urn);
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return result;
}
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
if (mediaComposition?.Show == null)
{
@@ -2,7 +2,7 @@ using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
@@ -14,7 +14,7 @@ namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks;
public class ContentRefreshTask : IScheduledTask
{
private readonly ILogger<ContentRefreshTask> _logger;
private readonly IContentRefreshService _contentRefreshService;
private readonly ContentRefreshService _contentRefreshService;
/// <summary>
/// Initializes a new instance of the <see cref="ContentRefreshTask"/> class.
@@ -23,7 +23,7 @@ public class ContentRefreshTask : IScheduledTask
/// <param name="contentRefreshService">The content refresh service.</param>
public ContentRefreshTask(
ILogger<ContentRefreshTask> logger,
IContentRefreshService contentRefreshService)
ContentRefreshService contentRefreshService)
{
_logger = logger;
_contentRefreshService = contentRefreshService;
@@ -2,7 +2,7 @@ using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
@@ -14,7 +14,7 @@ namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks;
public class ExpirationCheckTask : IScheduledTask
{
private readonly ILogger<ExpirationCheckTask> _logger;
private readonly IContentExpirationService _expirationService;
private readonly ContentExpirationService _expirationService;
/// <summary>
/// Initializes a new instance of the <see cref="ExpirationCheckTask"/> class.
@@ -23,7 +23,7 @@ public class ExpirationCheckTask : IScheduledTask
/// <param name="expirationService">The content expiration service.</param>
public ExpirationCheckTask(
ILogger<ExpirationCheckTask> logger,
IContentExpirationService expirationService)
ContentExpirationService expirationService)
{
_logger = logger;
_expirationService = expirationService;
+7 -13
View File
@@ -1,14 +1,13 @@
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Channels;
using Jellyfin.Plugin.SRFPlay.Providers;
using Jellyfin.Plugin.SRFPlay.ScheduledTasks;
using Jellyfin.Plugin.SRFPlay.Services;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay;
@@ -20,17 +19,12 @@ public class ServiceRegistrator : IPluginServiceRegistrator
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
// Register API client factory
serviceCollection.AddSingleton<ISRFApiClientFactory, SRFApiClientFactory>();
// Register core services with interfaces
serviceCollection.AddSingleton<IMetadataCache, MetadataCache>();
serviceCollection.AddSingleton<IStreamUrlResolver, StreamUrlResolver>();
serviceCollection.AddSingleton<IMediaCompositionFetcher, MediaCompositionFetcher>();
serviceCollection.AddSingleton<IStreamProxyService, StreamProxyService>();
serviceCollection.AddSingleton<IContentExpirationService, ContentExpirationService>();
serviceCollection.AddSingleton<IContentRefreshService, ContentRefreshService>();
serviceCollection.AddSingleton<ICategoryService, CategoryService>();
// Register services as singletons
serviceCollection.AddSingleton<MetadataCache>();
serviceCollection.AddSingleton<StreamUrlResolver>();
serviceCollection.AddSingleton<ContentExpirationService>();
serviceCollection.AddSingleton<ContentRefreshService>();
serviceCollection.AddSingleton<CategoryService>();
// Register metadata providers
serviceCollection.AddSingleton<SRFSeriesProvider>();
@@ -6,7 +6,6 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -14,7 +13,7 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for managing topic/category data and filtering.
/// </summary>
public class CategoryService : ICategoryService
public class CategoryService
{
private readonly ILogger _logger;
private readonly ILoggerFactory _loggerFactory;
@@ -3,9 +3,10 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Api;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -13,12 +14,13 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for managing content expiration.
/// </summary>
public class ContentExpirationService : IContentExpirationService
public class ContentExpirationService
{
private readonly ILogger<ContentExpirationService> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager;
private readonly IStreamUrlResolver _streamResolver;
private readonly IMediaCompositionFetcher _compositionFetcher;
private readonly StreamUrlResolver _streamResolver;
private readonly MetadataCache _metadataCache;
/// <summary>
/// Initializes a new instance of the <see cref="ContentExpirationService"/> class.
@@ -26,17 +28,18 @@ public class ContentExpirationService : IContentExpirationService
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="streamResolver">The stream URL resolver.</param>
/// <param name="compositionFetcher">The media composition fetcher.</param>
/// <param name="metadataCache">The metadata cache.</param>
public ContentExpirationService(
ILoggerFactory loggerFactory,
ILibraryManager libraryManager,
IStreamUrlResolver streamResolver,
IMediaCompositionFetcher compositionFetcher)
StreamUrlResolver streamResolver,
MetadataCache metadataCache)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<ContentExpirationService>();
_libraryManager = libraryManager;
_streamResolver = streamResolver;
_compositionFetcher = compositionFetcher;
_metadataCache = metadataCache;
}
/// <summary>
@@ -116,7 +119,26 @@ public class ContentExpirationService : IContentExpirationService
return false;
}
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return false;
}
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
{
@@ -174,7 +196,24 @@ public class ContentExpirationService : IContentExpirationService
continue;
}
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
continue;
}
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0)
{
@@ -4,7 +4,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -12,22 +12,24 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for refreshing content from SRF API.
/// </summary>
public class ContentRefreshService : IContentRefreshService
public class ContentRefreshService
{
private readonly ILogger<ContentRefreshService> _logger;
private readonly ISRFApiClientFactory _apiClientFactory;
private readonly ILoggerFactory _loggerFactory;
private readonly MetadataCache _metadataCache;
/// <summary>
/// Initializes a new instance of the <see cref="ContentRefreshService"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="apiClientFactory">The API client factory.</param>
/// <param name="metadataCache">The metadata cache.</param>
public ContentRefreshService(
ILoggerFactory loggerFactory,
ISRFApiClientFactory apiClientFactory)
MetadataCache metadataCache)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<ContentRefreshService>();
_apiClientFactory = apiClientFactory;
_metadataCache = metadataCache;
}
/// <summary>
@@ -50,7 +52,7 @@ public class ContentRefreshService : IContentRefreshService
_logger.LogInformation("Refreshing latest content for business unit: {BusinessUnit}", config.BusinessUnit);
using var apiClient = _apiClientFactory.CreateClient();
using var apiClient = new SRFApiClient(_loggerFactory);
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
// Get all shows from Play v3 API
@@ -165,7 +167,7 @@ public class ContentRefreshService : IContentRefreshService
_logger.LogInformation("Refreshing trending content for business unit: {BusinessUnit}", config.BusinessUnit);
using var apiClient = _apiClientFactory.CreateClient();
using var apiClient = new SRFApiClient(_loggerFactory);
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
// Get all shows from Play v3 API
@@ -1,70 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for managing topic/category data and filtering.
/// </summary>
public interface ICategoryService
{
/// <summary>
/// Gets all topics for a business unit.
/// </summary>
/// <param name="businessUnit">The business unit.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of topics.</returns>
Task<List<PlayV3Topic>> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a topic by ID.
/// </summary>
/// <param name="topicId">The topic ID.</param>
/// <param name="businessUnit">The business unit.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The topic, or null if not found.</returns>
Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default);
/// <summary>
/// Filters shows by topic ID.
/// </summary>
/// <param name="shows">The shows to filter.</param>
/// <param name="topicId">The topic ID to filter by.</param>
/// <returns>Filtered list of shows.</returns>
IReadOnlyList<PlayV3Show> FilterShowsByTopic(IReadOnlyList<PlayV3Show> shows, string topicId);
/// <summary>
/// Groups shows by their topics.
/// </summary>
/// <param name="shows">The shows to group.</param>
/// <returns>Dictionary mapping topic IDs to shows.</returns>
IReadOnlyDictionary<string, List<PlayV3Show>> GroupShowsByTopics(IReadOnlyList<PlayV3Show> shows);
/// <summary>
/// Gets shows for a specific topic, sorted by number of episodes.
/// </summary>
/// <param name="topicId">The topic ID.</param>
/// <param name="businessUnit">The business unit.</param>
/// <param name="maxResults">Maximum number of results to return.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of shows for the topic.</returns>
Task<List<PlayV3Show>> GetShowsByTopicAsync(
string topicId,
string businessUnit,
int maxResults = 50,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets video count for each topic.
/// </summary>
/// <param name="shows">The shows to analyze.</param>
/// <returns>Dictionary mapping topic IDs to video counts.</returns>
IReadOnlyDictionary<string, int> GetVideoCountByTopic(IReadOnlyList<PlayV3Show> shows);
/// <summary>
/// Clears the topics cache.
/// </summary>
void ClearCache();
}
@@ -1,24 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for managing content expiration.
/// </summary>
public interface IContentExpirationService
{
/// <summary>
/// Checks for expired content and removes it from the library.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of items removed.</returns>
Task<int> CheckAndRemoveExpiredContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Gets statistics about content expiration.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with total count, expired count, and items expiring soon.</returns>
Task<(int Total, int Expired, int ExpiringSoon)> GetExpirationStatisticsAsync(CancellationToken cancellationToken);
}
@@ -1,39 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for refreshing content from SRF API.
/// </summary>
public interface IContentRefreshService
{
/// <summary>
/// Refreshes latest content from SRF API using Play v3.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for new content.</returns>
Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Refreshes trending content from SRF API using Play v3.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for trending content.</returns>
Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Refreshes all content (latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with counts of latest and trending items.</returns>
Task<(int LatestCount, int TrendingCount)> RefreshAllContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Gets content recommendations (combines latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of recommended URNs.</returns>
Task<List<string>> GetRecommendedContentAsync(CancellationToken cancellationToken);
}
@@ -1,23 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for fetching media composition with caching support.
/// </summary>
public interface IMediaCompositionFetcher
{
/// <summary>
/// Gets media composition by URN, using cache if available.
/// </summary>
/// <param name="urn">The URN to fetch.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="cacheDurationOverride">Optional override for cache duration (e.g., 5 min for livestreams).</param>
/// <returns>The media composition, or null if not found.</returns>
Task<MediaComposition?> GetMediaCompositionAsync(
string urn,
CancellationToken cancellationToken,
int? cacheDurationOverride = null);
}
@@ -1,41 +0,0 @@
using Jellyfin.Plugin.SRFPlay.Api.Models;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for caching metadata from SRF API.
/// </summary>
public interface IMetadataCache
{
/// <summary>
/// Gets cached media composition by URN.
/// </summary>
/// <param name="urn">The URN.</param>
/// <param name="cacheDurationMinutes">The cache duration in minutes.</param>
/// <returns>The cached media composition, or null if not found or expired.</returns>
MediaComposition? GetMediaComposition(string urn, int cacheDurationMinutes);
/// <summary>
/// Sets media composition in cache.
/// </summary>
/// <param name="urn">The URN.</param>
/// <param name="mediaComposition">The media composition to cache.</param>
void SetMediaComposition(string urn, MediaComposition mediaComposition);
/// <summary>
/// Removes media composition from cache.
/// </summary>
/// <param name="urn">The URN.</param>
void RemoveMediaComposition(string urn);
/// <summary>
/// Clears all cached data.
/// </summary>
void Clear();
/// <summary>
/// Gets the cache statistics.
/// </summary>
/// <returns>A tuple with cache count and size estimate.</returns>
(int Count, long SizeEstimate) GetStatistics();
}
@@ -1,56 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for proxying SRF Play streams and managing authentication.
/// </summary>
public interface IStreamProxyService
{
/// <summary>
/// Registers a stream for proxying.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="authenticatedUrl">The authenticated stream URL.</param>
/// <param name="urn">The SRF URN for this content (used for re-fetching fresh URLs).</param>
/// <param name="isLiveStream">Whether this is a livestream (livestreams always fetch fresh URLs).</param>
void RegisterStream(string itemId, string authenticatedUrl, string? urn = null, bool isLiveStream = false);
/// <summary>
/// Gets stream metadata for an item (URN and isLiveStream flag).
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <returns>A tuple of (URN, IsLiveStream), or null if not found.</returns>
(string? Urn, bool IsLiveStream)? GetStreamMetadata(string itemId);
/// <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>
string? GetAuthenticatedUrl(string itemId);
/// <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.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The rewritten manifest content.</returns>
Task<string?> GetRewrittenManifestAsync(string itemId, string baseProxyUrl, CancellationToken cancellationToken = default);
/// <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>
Task<byte[]?> GetSegmentAsync(string itemId, string segmentPath, CancellationToken cancellationToken = default);
/// <summary>
/// Cleans up old and expired stream mappings.
/// </summary>
void CleanupOldMappings();
}
@@ -1,42 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for resolving stream URLs from media composition resources.
/// </summary>
public interface IStreamUrlResolver
{
/// <summary>
/// Gets the best stream URL from a chapter based on quality preference.
/// </summary>
/// <param name="chapter">The chapter containing resources.</param>
/// <param name="qualityPreference">The quality preference.</param>
/// <returns>The stream URL, or null if no suitable stream found.</returns>
string? GetStreamUrl(Chapter chapter, QualityPreference qualityPreference);
/// <summary>
/// Checks if a chapter has non-DRM playable content.
/// </summary>
/// <param name="chapter">The chapter to check.</param>
/// <returns>True if playable content is available.</returns>
bool HasPlayableContent(Chapter chapter);
/// <summary>
/// Checks if content is expired based on ValidTo date.
/// </summary>
/// <param name="chapter">The chapter to check.</param>
/// <returns>True if the content is expired.</returns>
bool IsContentExpired(Chapter chapter);
/// <summary>
/// Authenticates a stream URL by fetching an Akamai token.
/// </summary>
/// <param name="streamUrl">The unauthenticated stream URL.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The authenticated stream URL with token.</returns>
Task<string> GetAuthenticatedStreamUrlAsync(string streamUrl, CancellationToken cancellationToken = default);
}
@@ -1,82 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for fetching media composition with caching support.
/// This consolidates the cache-check → API-fetch → cache-store pattern.
/// </summary>
public class MediaCompositionFetcher : IMediaCompositionFetcher
{
private readonly ILogger<MediaCompositionFetcher> _logger;
private readonly IMetadataCache _metadataCache;
private readonly ISRFApiClientFactory _apiClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="MediaCompositionFetcher"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="metadataCache">The metadata cache.</param>
/// <param name="apiClientFactory">The API client factory.</param>
public MediaCompositionFetcher(
ILogger<MediaCompositionFetcher> logger,
IMetadataCache metadataCache,
ISRFApiClientFactory apiClientFactory)
{
_logger = logger;
_metadataCache = metadataCache;
_apiClientFactory = apiClientFactory;
}
/// <inheritdoc />
public async Task<MediaComposition?> GetMediaCompositionAsync(
string urn,
CancellationToken cancellationToken,
int? cacheDurationOverride = null)
{
if (string.IsNullOrEmpty(urn))
{
_logger.LogDebug("GetMediaCompositionAsync called with null/empty URN");
return null;
}
var config = Plugin.Instance?.Configuration;
if (config == null)
{
_logger.LogWarning("Plugin configuration is null, cannot fetch media composition");
return null;
}
var cacheDuration = cacheDurationOverride ?? config.CacheDurationMinutes;
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, cacheDuration);
if (mediaComposition != null)
{
_logger.LogDebug("Cache hit for URN: {Urn}", urn);
return mediaComposition;
}
// Fetch from API
_logger.LogDebug("Cache miss for URN: {Urn}, fetching from API", urn);
using var apiClient = _apiClientFactory.CreateClient();
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
_logger.LogDebug("Cached media composition for URN: {Urn}", urn);
}
else
{
_logger.LogWarning("Failed to fetch media composition for URN: {Urn}", urn);
}
return mediaComposition;
}
}
@@ -2,7 +2,6 @@ using System;
using System.Collections.Concurrent;
using System.Threading;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -10,7 +9,7 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for caching metadata from SRF API.
/// </summary>
public sealed class MetadataCache : IMetadataCache, IDisposable
public sealed class MetadataCache : IDisposable
{
private readonly ILogger<MetadataCache> _logger;
private readonly ConcurrentDictionary<string, CacheEntry<MediaComposition>> _mediaCompositionCache;
@@ -1,701 +0,0 @@
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 Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for proxying SRF Play streams and managing authentication.
/// </summary>
public class StreamProxyService : IStreamProxyService, IDisposable
{
private readonly ILogger<StreamProxyService> _logger;
private readonly IStreamUrlResolver _streamResolver;
private readonly IMediaCompositionFetcher _compositionFetcher;
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>
/// <param name="compositionFetcher">The media composition fetcher.</param>
public StreamProxyService(
ILogger<StreamProxyService> logger,
IStreamUrlResolver streamResolver,
IMediaCompositionFetcher compositionFetcher)
{
_logger = logger;
_streamResolver = streamResolver;
_compositionFetcher = compositionFetcher;
_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>
/// <param name="urn">The SRF URN for this content (used for re-fetching fresh URLs).</param>
/// <param name="isLiveStream">Whether this is a livestream (livestreams always fetch fresh URLs).</param>
public void RegisterStream(string itemId, string authenticatedUrl, string? urn = null, bool isLiveStream = false)
{
var tokenExpiry = ExtractTokenExpiry(authenticatedUrl);
var unauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl);
var streamInfo = new StreamInfo
{
AuthenticatedUrl = authenticatedUrl,
UnauthenticatedUrl = unauthenticatedUrl,
RegisteredAt = DateTime.UtcNow,
TokenExpiresAt = tokenExpiry,
Urn = urn,
IsLiveStream = isLiveStream
};
// 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 stream metadata for an item (URN and isLiveStream flag).
/// Used when propagating stream registration to transcoding sessions.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <returns>A tuple of (URN, IsLiveStream), or null if not found.</returns>
public (string? Urn, bool IsLiveStream)? GetStreamMetadata(string itemId)
{
if (_streamMappings.TryGetValue(itemId, out var streamInfo))
{
return (streamInfo.Urn, streamInfo.IsLiveStream);
}
// Try GUID normalization
var normalizedId = NormalizeGuid(itemId);
if (normalizedId != null)
{
foreach (var kvp in _streamMappings)
{
var normalizedKey = NormalizeGuid(kvp.Key);
if (normalizedKey != null && normalizedKey == normalizedId)
{
return (kvp.Value.Urn, kvp.Value.IsLiveStream);
}
}
}
return null;
}
/// <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)
{
_logger.LogInformation("GetAuthenticatedUrl called for itemId: {ItemId}", itemId);
// Try direct lookup first
if (_streamMappings.TryGetValue(itemId, out var streamInfo))
{
_logger.LogInformation("✅ Found stream by direct lookup for itemId: {ItemId}", itemId);
return ValidateAndReturnStream(itemId, streamInfo);
}
_logger.LogWarning("❌ No direct match for itemId: {ItemId}, trying fallbacks... (Registered streams: {Count})", itemId, _streamMappings.Count);
// 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)
{
// For livestreams, always fetch fresh URL from API to avoid stale CDN paths
if (streamInfo.IsLiveStream && !string.IsNullOrEmpty(streamInfo.Urn))
{
_logger.LogInformation(
"Livestream detected for item {ItemId} (URN: {Urn}) - fetching fresh stream URL from API",
itemId,
streamInfo.Urn);
var freshUrl = FetchFreshStreamUrl(itemId, streamInfo);
if (freshUrl != null)
{
return freshUrl;
}
_logger.LogWarning("Failed to fetch fresh URL for livestream {ItemId}, falling back to cached URL", itemId);
// Fall through to use cached URL as fallback
}
// Check if token has expired or is about to expire
if (streamInfo.TokenExpiresAt.HasValue)
{
var now = DateTime.UtcNow;
var timeUntilExpiry = streamInfo.TokenExpiresAt.Value - now;
// Proactive refresh: refresh if token has expired OR will expire within 5 seconds
// This prevents race conditions during rapid segment fetching in transcoding
var shouldRefresh = now >= streamInfo.TokenExpiresAt.Value || timeUntilExpiry.TotalSeconds <= 5;
if (shouldRefresh)
{
var reason = now >= streamInfo.TokenExpiresAt.Value
? "expired"
: $"expiring in {timeUntilExpiry.TotalSeconds:F1}s";
_logger.LogWarning(
"Token {Reason} for item {ItemId} (expires at {ExpiresAt}, now is {Now}) - attempting to refresh",
reason,
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:F1}s remaining)",
itemId,
streamInfo.TokenExpiresAt.Value,
timeUntilExpiry.TotalSeconds);
}
return streamInfo.AuthenticatedUrl;
}
/// <summary>
/// Fetches a fresh stream URL from the SRF API for livestreams.
/// </summary>
private string? FetchFreshStreamUrl(string itemId, StreamInfo streamInfo)
{
if (string.IsNullOrEmpty(streamInfo.Urn))
{
return null;
}
try
{
// Use short cache duration (5 min) for livestreams
var mediaComposition = _compositionFetcher.GetMediaCompositionAsync(streamInfo.Urn, CancellationToken.None, 5)
.GetAwaiter().GetResult();
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
{
_logger.LogWarning("No chapters found when refreshing livestream URL for URN: {Urn}", streamInfo.Urn);
return null;
}
var chapter = mediaComposition.ChapterList[0];
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference);
if (string.IsNullOrEmpty(streamUrl))
{
_logger.LogWarning("No stream URL found when refreshing livestream for URN: {Urn}", streamInfo.Urn);
return null;
}
// Authenticate the fresh URL
var authenticatedUrl = _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, CancellationToken.None)
.GetAwaiter().GetResult();
// Update the stored stream info with the fresh data
var newTokenExpiry = ExtractTokenExpiry(authenticatedUrl);
streamInfo.AuthenticatedUrl = authenticatedUrl;
streamInfo.UnauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl);
streamInfo.TokenExpiresAt = newTokenExpiry;
_logger.LogInformation(
"Fetched fresh livestream URL for item {ItemId} (URN: {Urn}, new expiry: {Expiry})",
itemId,
streamInfo.Urn,
newTokenExpiry);
return authenticatedUrl;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching fresh stream URL for livestream {ItemId} (URN: {Urn})", itemId, streamInfo.Urn);
return null;
}
}
/// <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 query parameters from proxyBaseUrl to propagate them
var queryParams = string.Empty;
var queryStart = proxyBaseUrl.IndexOf('?', StringComparison.Ordinal);
if (queryStart >= 0)
{
queryParams = proxyBaseUrl[queryStart..];
proxyBaseUrl = proxyBaseUrl[..queryStart]; // Remove query from base URL
_logger.LogDebug("Extracted query parameters from proxy URL: {QueryParams}", queryParams);
}
// 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}{queryParams}";
}
// Relative URL - rewrite to proxy
return $"\n{proxyBaseUrl}/{url}{queryParams}";
});
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; }
/// <summary>
/// Gets or sets the SRF URN for this stream (used for re-fetching fresh URLs).
/// </summary>
public string? Urn { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this is a livestream.
/// Livestreams always fetch fresh URLs from the API to avoid stale CDN paths.
/// </summary>
public bool IsLiveStream { get; set; }
}
}
@@ -6,7 +6,6 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -14,7 +13,7 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for resolving stream URLs from media composition resources.
/// </summary>
public class StreamUrlResolver : IStreamUrlResolver, IDisposable
public class StreamUrlResolver : IDisposable
{
private readonly ILogger<StreamUrlResolver> _logger;
private readonly HttpClient _httpClient;
@@ -88,17 +87,6 @@ public class StreamUrlResolver : IStreamUrlResolver, IDisposable
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);
@@ -126,10 +114,6 @@ public class StreamUrlResolver : IStreamUrlResolver, IDisposable
}
// Select based on quality preference
_logger.LogInformation(
"Selecting stream with quality preference: {QualityPreference}",
qualityPreference);
Resource? selectedResource = qualityPreference switch
{
QualityPreference.HD => SelectHDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
@@ -140,10 +124,10 @@ public class StreamUrlResolver : IStreamUrlResolver, IDisposable
if (selectedResource != null)
{
_logger.LogInformation(
"Selected stream for chapter {ChapterId}: Quality={Quality}, Protocol={Protocol}, URL={Url}",
_logger.LogDebug(
"Selected stream for chapter {ChapterId}: Quality={Quality}, Protocol={Protocol}, URL={Url}",
chapter.Id,
selectedResource.Quality ?? "NULL",
selectedResource.Quality,
selectedResource.Protocol,
selectedResource.Url);
return selectedResource.Url;
@@ -160,6 +144,23 @@ public class StreamUrlResolver : IStreamUrlResolver, IDisposable
/// <returns>True if playable content is available.</returns>
public bool HasPlayableContent(Chapter chapter)
{
// For scheduled livestreams that haven't started yet, resources won't exist
// but we still want to show them. The stream will become available when the event starts.
if (chapter?.Type == "SCHEDULED_LIVESTREAM")
{
var now = DateTime.UtcNow;
var hasStarted = chapter.ValidFrom == null || chapter.ValidFrom.Value.ToUniversalTime() <= now;
if (!hasStarted)
{
_logger.LogInformation(
"Scheduled livestream '{Title}' hasn't started yet (starts at {ValidFrom}), will be playable when live",
chapter.Title,
chapter.ValidFrom);
return true; // Show it, stream will be available when event starts
}
}
if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0)
{
return false;
+112 -16
View File
@@ -1,10 +1,6 @@
# Jellyfin SRF Play Plugin
A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-demand content and live sports streaming.
## Status
**Beta/Alpha** - This plugin has been tested on two Jellyfin instances and is working. Some clients may experience issues with hardware decoding, which appears to be client-specific behavior.
A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-demand content.
## Features
@@ -29,13 +25,94 @@ The main channel interface showing the content folders.
Video playback with HLS streaming support and quality selection.
## Testing
## Project Status
The plugin includes comprehensive testing:
### ✅ Completed Components
- **Unit tests** (xUnit framework) for core services
- **API spec validation tests** for all business units (SRF, RTS, RSI, RTR, SWI)
- **Integration testing** - Tested on multiple Jellyfin instances with VOD and live sports streaming
#### Phase 1: Project Setup
- ✅ Renamed from Template to SRFPlay
- ✅ Updated all namespaces and identifiers
- ✅ Configured plugin metadata (ID, name)
#### Phase 2: Core API Infrastructure
- ✅ API Models (MediaComposition, Chapter, Resource, Show, Episode)
- ✅ SRF API Client with HTTP client wrapper
- ✅ JSON deserialization support
- ✅ Error handling and logging
#### Phase 3: Configuration
- ✅ Business unit selection (SRF/RTS/RSI/RTR/SWI)
- ✅ Quality preferences (Auto/SD/HD)
- ✅ Content refresh intervals
- ✅ Expiration check settings
- ✅ Cache duration configuration
- ✅ HTML configuration page
#### Phase 4: Services
- ✅ Stream URL Resolver
- HLS stream selection
- 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
#### Phase 5: Content Providers
- ✅ Series Provider (for show metadata)
- ✅ Episode Provider (for episode metadata)
- ✅ Image Provider (for thumbnails and artwork)
- ✅ Media Provider (for playback URLs and HLS streams)
#### Phase 6: Scheduled Tasks
- ✅ Content Refresh Task
- Periodic discovery of new content
- Configurable refresh intervals
- ✅ Expiration Check Task
- Automatic cleanup of expired content
- Configurable check intervals
#### Phase 7: Dependency Injection & Integration
- ✅ Service registration (ServiceRegistrator)
- ✅ 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
- StreamUrlResolver tests
- MetadataCache tests
- More to be added
- [ ] 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 for VOD content
- [ ] Live sports streaming validation
**Run tests:**
```bash
@@ -54,6 +131,13 @@ dotnet test --collect:"XPlat Code Coverage"
See [Test Documentation](Jellyfin.Plugin.SRFPlay.Tests/README.md) for more details.
### 📝 Next Steps
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
**Base URL:** `https://il.srgssr.ch/integrationlayer/2.0/`
@@ -212,7 +296,9 @@ Currently focused on SRF but easily extensible.
## Development
The plugin includes:
### Current Status
All core functionality is implemented and compiling successfully! The plugin includes:
- 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
@@ -223,15 +309,25 @@ The plugin includes:
- Scheduled tasks for content refresh
- Smart caching with dynamic TTL for upcoming livestreams
### Known Issues
### Testing
- Some clients may experience issues with hardware decoding (appears to be client-specific)
- Some edge cases may need additional handling
- Performance optimization may be needed for very large content catalogs
To test the plugin:
1. Build the plugin: `dotnet build`
2. Copy `bin/Debug/net8.0/Jellyfin.Plugin.SRFPlay.dll` to your Jellyfin plugins directory
3. Restart Jellyfin
4. Configure the plugin in Dashboard → Plugins → SRF Play
5. Add a library with the SRF Play content provider
### Known Limitations
- This is a first version that has not been tested with a live Jellyfin instance yet
- Some edge cases may need handling
- Performance optimization may be needed for large content catalogs
### Contributing
Contributions welcome!
This plugin is in active development. Contributions welcome!
## License
+319
View File
@@ -0,0 +1,319 @@
# Testing Guide for SRF Play Plugin
This guide explains how to run tests and set up the nightly API validation for the Jellyfin SRF Play plugin.
## Overview
The plugin now has a comprehensive test suite:
1. **Unit Tests** - Fast, isolated tests for individual components
2. **Integration Tests (API Spec Tests)** - Real API calls to validate the SRF Play API is working correctly
3. **Nightly CI Tests** - Automated nightly runs to detect API changes
## Prerequisites
### Quick Setup
Run the setup script to check your environment:
```bash
./setup-tests.sh
```
### Required Software
**.NET 8.0 SDK** (Required - matches Jellyfin requirements)
```bash
# Arch Linux/CachyOS
sudo pacman -S dotnet-sdk-8.0 aspnet-runtime-8.0
# Or download from:
# https://dotnet.microsoft.com/download/dotnet/8.0
```
**Verify Installation:**
```bash
dotnet --list-runtimes
# Should show:
# Microsoft.NETCore.App 8.x.x
```
**Other Requirements:**
- Internet connection (for integration/API tests)
## Running Tests Locally
### All Tests
```bash
dotnet test
```
### Unit Tests Only
```bash
dotnet test --filter "Category!=Integration&Category!=APISpec"
```
### API Spec Tests Only
```bash
dotnet test --filter "Category=APISpec"
```
### With Code Coverage
```bash
dotnet test --collect:"XPlat Code Coverage"
```
### With Detailed Output
```bash
dotnet test --logger "console;verbosity=detailed"
```
## Test Structure
### Unit Tests ([Jellyfin.Plugin.SRFPlay.Tests/UnitTests/](Jellyfin.Plugin.SRFPlay.Tests/UnitTests/))
- **StreamUrlResolverTests.cs** - Tests stream URL resolution, DRM filtering, expiration checking
- **MetadataCacheTests.cs** - Tests metadata caching, expiration, thread safety
**Characteristics:**
- Fast execution (milliseconds)
- No external dependencies
- Run on every commit/PR
### Integration Tests ([Jellyfin.Plugin.SRFPlay.Tests/IntegrationTests/](Jellyfin.Plugin.SRFPlay.Tests/IntegrationTests/))
- **SRFApiSpecTests.cs** - Validates SRF Play API compliance
- Tests all business units (SRF, RTS, RSI, RTR, SWI)
- Validates response schemas
- Tests API endpoints accessibility
- Validates HLS stream availability
- Performance monitoring
**Characteristics:**
- Slower execution (seconds to minutes)
- Makes real API calls
- Run nightly via CI
## Continuous Integration
### Unit Tests Workflow
**File:** [.github/workflows/unit-tests.yaml](.github/workflows/unit-tests.yaml)
**Triggers:**
- Push to master branch
- Pull requests to master
- Manual trigger
**Features:**
- Runs all unit tests
- Generates code coverage reports
- Posts coverage summary on PRs
- Fails if tests fail
### Nightly API Spec Tests Workflow
**File:** [.github/workflows/nightly-api-tests.yaml](.github/workflows/nightly-api-tests.yaml)
**Schedule:** Every night at 2 AM UTC
**Features:**
- Validates SRF Play API is still working
- Tests all business units
- Validates response schemas
- **Automatically creates a GitHub issue if tests fail**
- Provides detailed test reports
**What happens when tests fail:**
- A GitHub issue is automatically created with:
- Link to the failed workflow run
- Description of what likely changed
- Suggested actions to take
- Labels: `bug`, `api`, `nightly-test-failure`
## Adding New Tests
### Unit Test Example
```csharp
using Xunit;
using FluentAssertions;
namespace Jellyfin.Plugin.SRFPlay.Tests.UnitTests;
public class MyServiceTests
{
[Fact]
public void MyMethod_WithValidInput_ReturnsExpectedResult()
{
// Arrange
var service = new MyService();
// Act
var result = service.MyMethod("test");
// Assert
result.Should().Be("expected");
}
}
```
### Integration Test Example
```csharp
using Xunit;
using FluentAssertions;
namespace Jellyfin.Plugin.SRFPlay.Tests.IntegrationTests;
[Trait("Category", "Integration")]
[Trait("Category", "APISpec")]
public class MyApiTests
{
[Fact]
public async Task ApiCall_ReturnsValidData()
{
// Arrange
var client = new SRFApiClient(loggerFactory);
// Act
var result = await client.GetDataAsync("srf", cancellationToken);
// Assert
result.Should().NotBeNull();
result.Should().NotBeEmpty();
}
}
```
## Test Naming Conventions
Follow the pattern: `MethodName_Scenario_ExpectedBehavior`
**Good Examples:**
- `GetStreamUrl_WithDrmProtectedOnly_ReturnsNull`
- `IsContentExpired_WithPastValidTo_ReturnsTrue`
- `GetAllShows_SRF_ReturnsShows`
## What to Do When Tests Fail
### Unit Tests Fail
1. Check the error message in the test output
2. Review recent code changes
3. Fix the bug or update the test if behavior changed intentionally
4. Run tests locally before pushing
### API Spec Tests Fail (Nightly)
1. **Check the GitHub issue** created automatically
2. **Review the workflow logs** for detailed error messages
3. **Common causes:**
- SRF Play API schema changed
- New authentication requirements
- Endpoints moved or deprecated
- Rate limiting or temporary outages
4. **Actions to take:**
- Update API models in [Jellyfin.Plugin.SRFPlay/Api/Models/](Jellyfin.Plugin.SRFPlay/Api/Models/)
- Update API client in [SRFApiClient.cs](Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs)
- Update tests to match new behavior
- Document any breaking changes
### Temporary API Outages
If the API is temporarily down:
1. Monitor the issue - it will auto-close on next successful run
2. No action needed unless failures persist for multiple days
## Code Coverage
Code coverage reports are generated automatically for unit tests in CI.
**View coverage locally:**
```bash
dotnet test --collect:"XPlat Code Coverage"
# Coverage reports will be in TestResults/*/coverage.cobertura.xml
```
**Target:** Aim for >70% coverage for core services
## Performance Benchmarks
API spec tests include performance validation:
- API calls should complete within 30 seconds
- Failures indicate potential performance degradation
## Best Practices
1. **Write tests first** (TDD) when fixing bugs
2. **Keep unit tests fast** - under 100ms per test
3. **Use descriptive test names** that explain what's being tested
4. **One assertion per test** for clear failure messages
5. **Clean up resources** with IDisposable
6. **Mock external dependencies** in unit tests
7. **Use real APIs** only in integration tests
## Troubleshooting
### Tests won't run locally
```bash
# Ensure .NET 8.0 SDK is installed
dotnet --list-sdks
# If not installed, download from:
# https://dotnet.microsoft.com/download/dotnet/8.0
```
### Integration tests fail with network errors
- Check internet connectivity
- Check if SRF Play API is accessible from your location
- Some regions may have geo-restrictions
### Build succeeds but tests won't execute
```bash
# Clean and rebuild
dotnet clean
dotnet build
dotnet test
```
## Legacy Tests
The project still contains legacy console-based tests:
- [Program.cs](Jellyfin.Plugin.SRFPlay.Tests/Program.cs)
- [TestPlayV3Api.cs](Jellyfin.Plugin.SRFPlay.Tests/TestPlayV3Api.cs)
These are kept for manual testing but are not run by CI. To run them:
```bash
cd Jellyfin.Plugin.SRFPlay.Tests
dotnet run
```
## Future Improvements
- [ ] Add more unit tests for remaining services
- [ ] Add tests for scheduled task functionality
- [ ] Add tests for proxy configuration
- [ ] Increase code coverage to >80%
- [ ] Add mutation testing
- [ ] Add performance benchmarks
## Questions?
- Review [Test Documentation](Jellyfin.Plugin.SRFPlay.Tests/README.md)
- Check [GitHub Actions](../../actions) for CI results
- Look at existing tests for examples
## Summary
With this testing infrastructure:
-**Developers** get immediate feedback on code changes
-**Maintainers** are automatically notified of API changes
-**Users** benefit from more reliable plugin
-**Contributors** have clear examples to follow
Executable
+74
View File
@@ -0,0 +1,74 @@
#!/bin/bash
# Setup script for SRF Play Plugin Tests
# This script helps set up the testing environment
echo "=== SRF Play Plugin - Test Environment Setup ==="
echo
# Check .NET version
echo "Checking .NET installation..."
dotnet --version
echo
# Check SDKs
echo "Installed .NET SDKs:"
dotnet --list-sdks
echo
# Check runtimes
echo "Installed .NET Runtimes:"
dotnet --list-runtimes
echo
# Check if .NET 8 runtime is installed
if dotnet --list-runtimes | grep -q "Microsoft.NETCore.App 8."; then
echo "✓ .NET 8 runtime is installed"
NET8_INSTALLED=true
else
echo "✗ .NET 8 runtime is NOT installed"
NET8_INSTALLED=false
fi
# Check if ASP.NET Core 9 runtime is installed
if dotnet --list-runtimes | grep -q "Microsoft.AspNetCore.App 9."; then
echo "✓ ASP.NET Core 9 runtime is installed"
ASPNET9_INSTALLED=true
else
echo "✗ ASP.NET Core 9 runtime is NOT installed"
ASPNET9_INSTALLED=false
fi
echo
echo "=== Test Execution Options ==="
echo
if [ "$NET8_INSTALLED" = true ]; then
echo "✅ Ready to run tests with .NET 8"
echo " dotnet test"
echo
else
echo "⚠️ .NET 8 runtime required (to match Jellyfin requirements)"
echo
echo "Install .NET 8:"
echo " For Arch Linux/CachyOS:"
echo " sudo pacman -S dotnet-sdk-8.0 aspnet-runtime-8.0"
echo
echo " Or download from:"
echo " https://dotnet.microsoft.com/download/dotnet/8.0"
echo
fi
echo "Option 3: Run tests in GitHub Actions (always works)"
echo " - Tests run automatically on push/PR"
echo " - Nightly API tests run at 2 AM UTC"
echo
echo "=== Quick Test Commands ==="
echo "Build: dotnet build"
echo "All tests: dotnet test"
echo "Unit tests: dotnet test --filter \"Category!=Integration&Category!=APISpec\""
echo "API tests: dotnet test --filter \"Category=APISpec\""
echo
echo "For more information, see TESTING_GUIDE.md"