Compare commits

..
6 Commits
Author SHA1 Message Date
dtourolle 3604e8f7a0 Add unit.tests
🏗️ Build Plugin / build (pull_request) Failing after 9s
🧪 Test Plugin / test (pull_request) Failing after 9s
2025-11-14 22:13:24 +01:00
dtourolle 31b2402a96 more readme refinement
🏗️ Build Plugin / build (push) Successful in 2m22s
🧪 Test Plugin / test (push) Successful in 1m7s
2025-11-14 22:01:17 +01:00
dtourolle 3691b4e30e removed md unrelated to current state 2025-11-14 21:50:58 +01:00
dtourolle 87ccf80813 remove netowkring notes ans script
🏗️ Build Plugin / build (push) Successful in 2m25s
🧪 Test Plugin / test (push) Successful in 1m8s
2025-11-14 21:43:43 +01:00
dtourolle 6ed53054c3 remove changelog
🏗️ Build Plugin / build (push) Successful in 2m18s
🧪 Test Plugin / test (push) Successful in 1m10s
🚀 Release Plugin / build-and-release (push) Successful in 2m16s
2025-11-14 21:39:34 +01:00
dtourolle 1a8b7a33ef more dman ci stuff
🏗️ Build Plugin / build (push) Successful in 2m22s
🧪 Test Plugin / test (push) Successful in 1m8s
🚀 Release Plugin / build-and-release (push) Failing after 2m18s
2025-11-14 21:29:51 +01:00
24 changed files with 1344 additions and 733 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
+51 -25
View File
@@ -70,33 +70,59 @@ jobs:
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}" echo "Found artifact: ${ARTIFACT}"
- name: Generate changelog - name: Create Release
id: changelog env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
# Get commits since last tag # Get repository information
PREV_TAG=$(git tag --sort=-creatordate | grep -v "${{ steps.get_version.outputs.version }}" | head -n 1) REPO_OWNER="${{ github.repository_owner }}"
if [ -z "$PREV_TAG" ]; then REPO_NAME="${{ github.event.repository.name }}"
CHANGELOG=$(git log --pretty=format:"- %s (%h)" --no-merges) GITEA_URL="${{ github.server_url }}"
# Prepare release body
RELEASE_BODY="SRFPlay Jellyfin Plugin ${{ steps.get_version.outputs.version }}\n\nSee attached files for plugin installation."
RELEASE_BODY_JSON=$(echo -n "${RELEASE_BODY}" | jq -Rs .)
# Create release using Gitea API
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
-d "{
\"tag_name\": \"${{ steps.get_version.outputs.version }}\",
\"name\": \"Release ${{ steps.get_version.outputs.version }}\",
\"body\": ${RELEASE_BODY_JSON},
\"draft\": false,
\"prerelease\": false
}")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
RELEASE_ID=$(echo "$BODY" | jq -r '.id')
echo "Created release with ID: ${RELEASE_ID}"
else else
CHANGELOG=$(git log ${PREV_TAG}..${{ steps.get_version.outputs.version }} --pretty=format:"- %s (%h)" --no-merges) echo "Failed to create release. HTTP ${HTTP_CODE}"
echo "$BODY"
exit 1
fi fi
# Save to file for release notes # Upload plugin artifact
echo "## What's Changed" > RELEASE_NOTES.md echo "Uploading plugin artifact..."
echo "" >> RELEASE_NOTES.md curl -f -X POST \
echo "${CHANGELOG}" >> RELEASE_NOTES.md -H "Authorization: token ${GITEA_TOKEN}" \
echo "" >> RELEASE_NOTES.md -H "Content-Type: application/zip" \
echo "**Full Changelog**: ${PREV_TAG}...${{ steps.get_version.outputs.version }}" >> RELEASE_NOTES.md --data-binary "@${{ steps.jprm.outputs.artifact }}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
- name: Create Release # Upload build.yaml
uses: actions/gitea-release-action@v1 echo "Uploading build.yaml..."
with: curl -f -X POST \
token: ${{ secrets.GITHUB_TOKEN }} -H "Authorization: token ${GITEA_TOKEN}" \
tag_name: ${{ steps.get_version.outputs.version }} -H "Content-Type: application/x-yaml" \
name: Release ${{ steps.get_version.outputs.version }} --data-binary "@build.yaml" \
body_path: RELEASE_NOTES.md "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=build.yaml"
draft: false
prerelease: false echo "✅ Release created successfully!"
files: | echo "View at: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${{ steps.get_version.outputs.version }}"
${{ steps.jprm.outputs.artifact }}
build.yaml
-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
-88
View File
@@ -1,88 +0,0 @@
# Compilation Fixes - COMPLETED ✅
## Status: ALL ERRORS RESOLVED
**Build Status:** ✅ SUCCESS
The plugin now compiles successfully with no errors!
## Fixed Issues
### 1. Collection Type Warnings ✅
**Issue:** CA2227 and CA1002 - Collection properties should be read-only and use appropriate collection types
**Files Fixed:**
- `Api/Models/MediaComposition.cs` - Changed `List<Chapter>` to `IReadOnlyList<Chapter>`
- `Api/Models/Chapter.cs` - Changed `List<Resource>` to `IReadOnlyList<Resource>`
**Solution:** Used `IReadOnlyList<T>` to satisfy code analysis while maintaining JSON deserialization compatibility.
### 2. MetadataCache Warnings ✅
**Issue:** Multiple issues with MetadataCache
- CA1001: Type should implement IDisposable (owns ReaderWriterLockSlim)
- MT1012: Lock acquisition should be wrapped in try blocks
- CA1852: CacheEntry class should be sealed
**File Fixed:** `Services/MetadataCache.cs`
**Solution:**
- Implemented IDisposable interface
- Wrapped all lock acquisitions in try-catch blocks
- Added ObjectDisposedException handling
- Sealed the CacheEntry inner class
- Reordered fields (readonly fields before non-readonly)
### 3. SRFMediaProvider Warnings ✅
**Issue:**
- SA1648: inheritdoc should be used with inheriting class
- CA1849: Avoid synchronous blocking
**File Fixed:** `Providers/SRFMediaProvider.cs`
**Solution:**
- Replaced `/// <inheritdoc />` with proper XML documentation summaries
- Changed from `Task.Wait()` and `.Result` to `.GetAwaiter().GetResult()` (less problematic)
### 4. ContentExpirationService Warnings ✅
**Issue:** SA1028 - Trailing whitespace
**File Fixed:** `Services/ContentExpirationService.cs`
**Solution:** Removed trailing whitespace on lines 79 and 221
### 5. CA1826 Warnings ✅
**Issue:** Use indexer instead of LINQ `.First()` for collections with indexers
**Files Fixed:**
- `Services/ContentExpirationService.cs`
- `Providers/SRFEpisodeProvider.cs`
- `Providers/SRFImageProvider.cs`
- `Providers/SRFMediaProvider.cs`
**Solution:** Replaced `.First()` calls with `[0]` indexer access for IReadOnlyList collections
## Build Output
```
Build succeeded in 1.0s
Jellyfin.Plugin.SRFPlay succeeded → Jellyfin.Plugin.SRFPlay/bin/Debug/net8.0/Jellyfin.Plugin.SRFPlay.dll
```
## Summary
All 17 initial compilation errors have been resolved:
- ✅ 4 collection property warnings
- ✅ 6 MetadataCache warnings
- ✅ 3 SRFMediaProvider warnings
- ✅ 2 ContentExpirationService whitespace warnings
- ✅ 6 CA1826 indexer warnings
- ✅ 1 field ordering warning
The plugin is now ready for testing with a Jellyfin instance!
## Next Steps
1. ✅ Plugin compiles successfully
2. ⏭️ Test with Jellyfin instance
3. ⏭️ Verify content discovery
4. ⏭️ Test playback functionality
5. ⏭️ Validate expiration handling
@@ -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> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" 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> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <!-- Support both .NET 8 and 9 for flexibility in dev environments -->
<TargetFramework>net8.0</TargetFramework> <TargetFrameworks>net8.0;net9.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup> </PropertyGroup>
</Project> </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();
}
}
-227
View File
@@ -1,227 +0,0 @@
# Network-Level Gateway Routing for SRF Content
This guide explains how to configure network-level routing to direct all SRF-related traffic (including video streams) through your Swiss gateway/proxy at 192.168.1.37.
## Overview
Instead of configuring proxy support at the application level, this approach uses Linux policy-based routing to redirect traffic destined for SRF domains through an alternate gateway. This ensures:
- API requests to `il.srgssr.ch` go through the gateway
- Video stream requests to `srf-vod-amd.akamaized.net` (and other CDNs) go through the gateway
- ffprobe and ffmpeg automatically use the gateway
- No application configuration needed - transparent to Jellyfin
## Prerequisites
- Root access to the Jellyfin server (192.168.1.4)
- Gateway at 192.168.1.37 with IPv4 forwarding enabled
- Both machines on the same network segment
## Installation on Jellyfin Server
### Step 1: Copy Scripts to Jellyfin Server
From your development machine:
```bash
# Copy the routing scripts to Jellyfin server
scp setup-gateway-routing.sh cleanup-gateway-routing.sh user@192.168.1.4:~
```
### Step 2: SSH to Jellyfin Server
```bash
ssh user@192.168.1.4
```
### Step 3: Make Scripts Executable
```bash
chmod +x setup-gateway-routing.sh cleanup-gateway-routing.sh
```
### Step 4: Run Setup Script
```bash
sudo ./setup-gateway-routing.sh
```
When prompted:
- **Gateway IP**: `192.168.1.37`
- **Network interface**: Find your interface name first with `ip -br link show` (common names: `eth0`, `ens18`, `enp0s3`)
The script will:
1. Create a custom routing table named `srf_gateway`
2. Resolve IP addresses for all SRF domains
3. Add routes through your gateway (192.168.1.37)
4. Create routing rules for policy-based routing
5. Set up a systemd service for persistence across reboots
### Step 5: Verify Routing
Check that routes are configured:
```bash
# Show the custom routing table
ip route show table srf_gateway
# Show routing rules
ip rule show | grep srf_gateway
# Test routing for Integration Layer API
ip route get $(dig +short il.srgssr.ch | head -1)
# Test routing for video CDN
ip route get $(dig +short srf-vod-amd.akamaized.net | head -1)
```
### Step 6: Test from Jellyfin Server
Test that the routing is working:
```bash
# Test API access
curl -v "https://il.srgssr.ch/integrationlayer/2.0/mediaComposition/byUrn/urn:srf:video:b84713f0-f81b-460f-9b0f-d0517310fb4f.json" 2>&1 | grep -E "(x-location|HTTP/)"
# Should show: x-location: CH
```
### Step 7: Restart Jellyfin
```bash
sudo systemctl restart jellyfin
```
## What Gets Routed
The following domains are routed through the gateway:
- `il.srgssr.ch` - Integration Layer API (metadata)
- `www.srf.ch` - Main SRF site
- `www.rts.ch` - RTS (Radio Télévision Suisse)
- `www.rsi.ch` - RSI (Radiotelevisione svizzera)
- `www.rtr.ch` - RTR (Radiotelevisiun Svizra Rumantscha)
- `www.swi.ch` - SWI (swissinfo)
- `srf-vod-amd.akamaized.net` - SRF video CDN
- `rts-vod-amd.akamaized.net` - RTS video CDN
- `rsi-vod-amd.akamaized.net` - RSI video CDN
- `play-web.srf.ch` - Play web interface
- `il-stage.srgssr.ch` - Staging environment
## How It Works
1. **DNS Resolution**: Domains are resolved to IP addresses
2. **Routing Table**: A custom routing table (`srf_gateway`) is created with routes through the gateway
3. **Policy Routing**: Rules direct traffic to specific IPs to use the custom routing table
4. **Persistence**: A systemd service ensures routes survive reboots
## Disabling Plugin Proxy Configuration
Once network-level routing is working, you can disable the proxy configuration in the plugin:
1. Go to Jellyfin Dashboard → Plugins → SRF Play
2. Uncheck "Use Proxy"
3. Save configuration
4. Restart Jellyfin
The plugin will use direct HTTP requests, but the network layer will transparently route them through the gateway.
## Troubleshooting
### Routes Not Working
Check if gateway is reachable:
```bash
ping 192.168.1.37
nc -zv 192.168.1.37 3128
```
Check routing table:
```bash
ip route show table srf_gateway
```
### DNS Changes
If SRF changes their IP addresses, you may need to re-run the setup script:
```bash
sudo ./cleanup-gateway-routing.sh
sudo ./setup-gateway-routing.sh
```
### Verify Traffic Path
Use `traceroute` to see the path:
```bash
traceroute $(dig +short il.srgssr.ch | head -1)
# Should show 192.168.1.37 as first hop
```
### Check Systemd Service
```bash
systemctl status srf-gateway-routing.service
journalctl -u srf-gateway-routing.service
```
## Removing the Configuration
To completely remove the routing configuration:
```bash
sudo ./cleanup-gateway-routing.sh
```
This will:
- Remove all routing rules
- Flush the custom routing table
- Disable and remove the systemd service
## Advantages of This Approach
1. **Transparent**: No application changes needed
2. **Complete Coverage**: All network traffic to SRF domains uses gateway
3. **Persistent**: Survives reboots
4. **Centralized**: Managed at network level
5. **Debug-Friendly**: Can verify with standard network tools
## Disadvantages
1. **DNS Changes**: If SRF changes IPs, routing must be updated
2. **New Domains**: New CDN domains require script update
3. **Static IPs Only**: Doesn't work with wildcard domains
## Gateway Configuration
Ensure your gateway (192.168.1.37) has:
1. **IPv4 Forwarding Enabled**:
```bash
# On the gateway (192.168.1.37)
sudo sysctl net.ipv4.ip_forward=1
# Make permanent
echo "net.ipv4.ip_forward=1" | sudo tee -a /etc/sysctl.conf
```
2. **Firewall Rules** (if using iptables):
```bash
# On the gateway (192.168.1.37)
sudo iptables -A FORWARD -s 192.168.1.4 -j ACCEPT
sudo iptables -A FORWARD -d 192.168.1.4 -m state --state RELATED,ESTABLISHED -j ACCEPT
sudo iptables -t nat -A POSTROUTING -s 192.168.1.4 -o <outbound-interface> -j MASQUERADE
```
3. **Squid Proxy** (if using proxy mode):
- Already configured and accessible at port 3128
- Note: With network-level routing, traffic goes through the gateway's routing, not necessarily the Squid proxy
## Testing Complete Data Chain
After setup, test the complete flow:
1. **Metadata API**: Browse shows in Jellyfin - should work
2. **Video Playback**: Try playing a video - should work
3. **Thumbnails**: Images should load
All traffic should be routed through 192.168.1.37, giving you the Swiss location needed to access geo-blocked content.
-197
View File
@@ -1,197 +0,0 @@
# Proxy Configuration Guide for SRF Play Plugin
This guide explains how to configure the Jellyfin SRF Play plugin to route all API traffic through a proxy or alternate gateway.
## Overview
The SRF Play plugin now supports proxy configuration directly in the plugin settings. This allows you to:
- Route traffic through a specific gateway or proxy server
- Use authentication if your proxy requires it
- Bypass geo-restrictions or network policies
- Route only SRF-related traffic without affecting other Jellyfin operations
## Supported Proxy Types
The plugin supports:
- **HTTP proxies**: `http://proxy.example.com:8080`
- **HTTPS proxies**: `https://proxy.example.com:8443`
- **SOCKS5 proxies**: `socks5://proxy.example.com:1080`
## Configuration Steps
### 1. Access Plugin Settings
1. Open Jellyfin Dashboard
2. Navigate to **Dashboard → Plugins → SRF Play**
3. Scroll down to the **Proxy Settings** section
### 2. Configure Proxy
Fill in the following fields:
#### Use Proxy
- **Enable this checkbox** to route all SRF API requests through the proxy
#### Proxy Address
- Enter your proxy server address with protocol and port
- Examples:
- `http://192.168.1.100:8080`
- `http://proxy.example.com:3128`
- `socks5://127.0.0.1:1080`
#### Proxy Username (Optional)
- Enter username if your proxy requires authentication
- Leave empty if no authentication is needed
#### Proxy Password (Optional)
- Enter password if your proxy requires authentication
- Leave empty if no authentication is needed
### 3. Save Configuration
1. Click **Save** button
2. Restart Jellyfin to apply changes (recommended)
## Examples
### Example 1: Simple HTTP Proxy (No Authentication)
```
Use Proxy: ✓ Enabled
Proxy Address: http://192.168.1.1:8080
Proxy Username: (empty)
Proxy Password: (empty)
```
### Example 2: Authenticated HTTP Proxy
```
Use Proxy: ✓ Enabled
Proxy Address: http://proxy.company.com:3128
Proxy Username: myusername
Proxy Password: mypassword
```
### Example 3: SOCKS5 Proxy
```
Use Proxy: ✓ Enabled
Proxy Address: socks5://127.0.0.1:1080
Proxy Username: (empty)
Proxy Password: (empty)
```
## Setting Up a Transparent Proxy Gateway on Ubuntu
If you want to create your own transparent proxy gateway on Ubuntu, here are some options:
### Option A: Squid Proxy
Install and configure Squid as a transparent proxy:
```bash
# Install Squid
sudo apt update
sudo apt install squid
# Edit configuration
sudo nano /etc/squid/squid.conf
# Add these lines:
http_port 3128
acl localnet src 192.168.1.0/24
http_access allow localnet
# Restart Squid
sudo systemctl restart squid
```
Then in plugin settings:
```
Proxy Address: http://192.168.1.1:3128
```
### Option B: SSH Tunnel (SOCKS5)
Create a SOCKS5 proxy through SSH:
```bash
# On your local machine
ssh -D 1080 -N user@remote-gateway-server
```
Then in plugin settings:
```
Proxy Address: socks5://127.0.0.1:1080
```
### Option C: Dante SOCKS Server
Install Dante for a dedicated SOCKS5 server:
```bash
sudo apt install dante-server
# Configure in /etc/danted.conf
sudo systemctl restart danted
```
## Troubleshooting
### Plugin Not Connecting Through Proxy
1. **Check proxy address format**: Ensure it includes the protocol (http://, socks5://, etc.)
2. **Verify proxy is running**: Test connectivity to the proxy from your Jellyfin server
3. **Check Jellyfin logs**: Look for proxy-related errors in Dashboard → Logs
4. **Firewall rules**: Ensure your firewall allows outbound connections to the proxy
### Authentication Errors
1. Verify username and password are correct
2. Check if your proxy requires domain authentication (DOMAIN\\username)
3. Some proxies may require specific authentication methods not supported by .NET HttpClient
### DNS Resolution
- The plugin resolves domain names before sending requests through the proxy
- If you need DNS resolution through the proxy, you may need to use a VPN or network-level routing instead
## Affected Domains
When proxy is enabled, all requests to these domains will be routed through the proxy:
- `il.srgssr.ch` - SRF Integration Layer API
- `www.srf.ch` - SRF Play v3 API (German)
- `www.rts.ch` - RTS Play v3 API (French)
- `www.rsi.ch` - RSI Play v3 API (Italian)
- `www.rtr.ch` - RTR Play v3 API (Romansh)
- `www.swi.ch` - SWI Play v3 API (International)
## Viewing Logs
To verify the proxy is being used:
1. Go to **Dashboard → Logs**
2. Look for entries containing "Proxy configured"
3. Example log entry:
```
Proxy configured: http://192.168.1.1:8080 (Authentication: False)
```
## Security Notes
- Proxy credentials are stored in Jellyfin's plugin configuration
- Use HTTPS for the proxy connection when possible to encrypt traffic
- Consider using a VPN for more secure routing if dealing with sensitive content
- Regularly update your proxy server and Jellyfin to patch security vulnerabilities
## Alternative: Network-Level Routing
If you prefer network-level routing instead of application proxy, see the `setup-srf-routing.sh` script for IP-based routing tables (requires root access and is more complex).
## Support
If you encounter issues:
1. Check Jellyfin logs for detailed error messages
2. Verify proxy connectivity with `curl --proxy http://proxy:port https://il.srgssr.ch`
3. Open an issue on the GitHub repository with logs and configuration details
+36 -2
View File
@@ -14,6 +14,17 @@ A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-d
- Proxy support for routing traffic through alternate gateways - Proxy support for routing traffic through alternate gateways
- Smart caching with reduced TTL for upcoming livestreams - Smart caching with reduced TTL for upcoming livestreams
## Screenshots
### Channel Menu
![SRF Play Menu](res/menu.png)
The main channel interface showing the content folders.
### Playback
![SRF Play Playback](res/playback.png)
Video playback with HLS streaming support and quality selection.
## Project Status ## Project Status
### ✅ Completed Components ### ✅ Completed Components
@@ -92,16 +103,39 @@ A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-d
**Successfully compiling!** All code analysis warnings resolved. **Successfully compiling!** All code analysis warnings resolved.
### 🧪 Testing Status ### 🧪 Testing Status
- [ ] Unit tests - 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 - [ ] Integration testing with Jellyfin instance
- [ ] End-to-end playback testing for VOD content - [ ] End-to-end playback testing for VOD content
- [ ] Live sports streaming validation - [ ] Live sports streaming validation
**Run tests:**
```bash
# All tests
dotnet test
# Unit tests only
dotnet test --filter "Category!=Integration&Category!=APISpec"
# API spec tests only
dotnet test --filter "Category=APISpec"
# With coverage
dotnet test --collect:"XPlat Code Coverage"
```
See [Test Documentation](Jellyfin.Plugin.SRFPlay.Tests/README.md) for more details.
### 📝 Next Steps ### 📝 Next Steps
1. Test live sports streaming when events are scheduled 1. Test live sports streaming when events are scheduled
2. Verify Akamai token authentication 2. Verify Akamai token authentication
3. Test with different business units (RTS, RSI, RTR) 3. Test with different business units (RTS, RSI, RTR)
4. Add unit tests 4. Add more unit tests for remaining services
5. Performance optimization if needed 5. Performance optimization if needed
## API Information ## API Information
+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
-57
View File
@@ -1,57 +0,0 @@
#!/bin/bash
# Deploy SRF Play plugin to Jellyfin server
set -e
# Configuration
JELLYFIN_SERVER="192.168.1.4"
JELLYFIN_USER="dtourolle" # Change this to your SSH user
DLL_PATH="Jellyfin.Plugin.SRFPlay/bin/Release/net8.0/Jellyfin.Plugin.SRFPlay.dll"
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${GREEN}=== Deploying SRF Play Plugin ===${NC}\n"
# Check if DLL exists
if [ ! -f "$DLL_PATH" ]; then
echo "Error: DLL not found. Building..."
dotnet build Jellyfin.Plugin.SRFPlay/Jellyfin.Plugin.SRFPlay.csproj -c Release
fi
echo "Step 1: Copying DLL to Jellyfin server..."
scp "$DLL_PATH" "${JELLYFIN_USER}@${JELLYFIN_SERVER}:~/"
echo -e "\nStep 2: Installing DLL on Jellyfin server..."
ssh "${JELLYFIN_USER}@${JELLYFIN_SERVER}" << 'ENDSSH'
echo "Stopping Jellyfin..."
sudo systemctl stop jellyfin
echo "Backing up old DLL..."
sudo cp /var/lib/jellyfin/plugins/SRF/Jellyfin.Plugin.SRFPlay.dll \
/var/lib/jellyfin/plugins/SRF/Jellyfin.Plugin.SRFPlay.dll.backup || true
echo "Installing new DLL..."
sudo cp ~/Jellyfin.Plugin.SRFPlay.dll /var/lib/jellyfin/plugins/SRF/
sudo chown jellyfin:jellyfin /var/lib/jellyfin/plugins/SRF/Jellyfin.Plugin.SRFPlay.dll
echo "Starting Jellyfin..."
sudo systemctl start jellyfin
echo "Waiting for Jellyfin to start..."
sleep 5
echo "Checking Jellyfin status..."
sudo systemctl status jellyfin --no-pager -l | head -20
ENDSSH
echo -e "\n${GREEN}✓ Deployment complete!${NC}"
echo -e "\n${YELLOW}Next steps:${NC}"
echo "1. Test video playback - GUID errors should be fixed"
echo "2. If videos still don't play due to geo-blocking, run network routing setup:"
echo " scp setup-gateway-routing.sh cleanup-gateway-routing.sh ${JELLYFIN_USER}@${JELLYFIN_SERVER}:~"
echo " ssh ${JELLYFIN_USER}@${JELLYFIN_SERVER}"
echo " chmod +x setup-gateway-routing.sh"
echo " sudo ./setup-gateway-routing.sh"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

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"