diff --git a/.gitea/workflows/nightly-api-tests.yaml b/.gitea/workflows/nightly-api-tests.yaml
new file mode 100644
index 0000000..68279b1
--- /dev/null
+++ b/.gitea/workflows/nightly-api-tests.yaml
@@ -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
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
deleted file mode 100644
index f290747..0000000
--- a/.github/workflows/build.yaml
+++ /dev/null
@@ -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
diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml
deleted file mode 100644
index 5b3c3be..0000000
--- a/.github/workflows/changelog.yaml
+++ /dev/null
@@ -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 }}
diff --git a/.github/workflows/command-dispatch.yaml b/.github/workflows/command-dispatch.yaml
deleted file mode 100644
index 1b5e4ee..0000000
--- a/.github/workflows/command-dispatch.yaml
+++ /dev/null
@@ -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: .
diff --git a/.github/workflows/command-rebase.yaml b/.github/workflows/command-rebase.yaml
deleted file mode 100644
index 7847e20..0000000
--- a/.github/workflows/command-rebase.yaml
+++ /dev/null
@@ -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 }}
diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml
deleted file mode 100644
index 80483cf..0000000
--- a/.github/workflows/publish.yaml
+++ /dev/null
@@ -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 }}
diff --git a/.github/workflows/scan-codeql.yaml b/.github/workflows/scan-codeql.yaml
deleted file mode 100644
index ca8b0b0..0000000
--- a/.github/workflows/scan-codeql.yaml
+++ /dev/null
@@ -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
diff --git a/.github/workflows/sync-labels.yaml b/.github/workflows/sync-labels.yaml
deleted file mode 100644
index 5e06ae4..0000000
--- a/.github/workflows/sync-labels.yaml
+++ /dev/null
@@ -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 }}
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
deleted file mode 100644
index d90b14d..0000000
--- a/.github/workflows/test.yaml
+++ /dev/null
@@ -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
diff --git a/Jellyfin.Plugin.SRFPlay.Tests/IntegrationTests/SRFApiSpecTests.cs b/Jellyfin.Plugin.SRFPlay.Tests/IntegrationTests/SRFApiSpecTests.cs
new file mode 100644
index 0000000..5aaa569
--- /dev/null
+++ b/Jellyfin.Plugin.SRFPlay.Tests/IntegrationTests/SRFApiSpecTests.cs
@@ -0,0 +1,287 @@
+using FluentAssertions;
+using Jellyfin.Plugin.SRFPlay.Api;
+using Microsoft.Extensions.Logging;
+using Xunit;
+
+namespace Jellyfin.Plugin.SRFPlay.Tests.IntegrationTests;
+
+///
+/// Integration tests to validate SRF API spec compliance.
+/// These tests make real API calls to ensure the API is still working as expected.
+///
+[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
+ {
+ _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();
+ }
+}
diff --git a/Jellyfin.Plugin.SRFPlay.Tests/Jellyfin.Plugin.SRFPlay.Tests.csproj b/Jellyfin.Plugin.SRFPlay.Tests/Jellyfin.Plugin.SRFPlay.Tests.csproj
index 22b4348..030642f 100644
--- a/Jellyfin.Plugin.SRFPlay.Tests/Jellyfin.Plugin.SRFPlay.Tests.csproj
+++ b/Jellyfin.Plugin.SRFPlay.Tests/Jellyfin.Plugin.SRFPlay.Tests.csproj
@@ -7,13 +7,23 @@
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
- Exe
- net8.0
+
+ net8.0;net9.0
enable
enable
+ false
+ true
diff --git a/Jellyfin.Plugin.SRFPlay.Tests/README.md b/Jellyfin.Plugin.SRFPlay.Tests/README.md
new file mode 100644
index 0000000..6d96d5a
--- /dev/null
+++ b/Jellyfin.Plugin.SRFPlay.Tests/README.md
@@ -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
diff --git a/Jellyfin.Plugin.SRFPlay.Tests/UnitTests/MetadataCacheTests.cs b/Jellyfin.Plugin.SRFPlay.Tests/UnitTests/MetadataCacheTests.cs
new file mode 100644
index 0000000..9cb6cca
--- /dev/null
+++ b/Jellyfin.Plugin.SRFPlay.Tests/UnitTests/MetadataCacheTests.cs
@@ -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;
+
+///
+/// Unit tests for MetadataCache.
+///
+public class MetadataCacheTests : IDisposable
+{
+ private readonly Mock> _loggerMock;
+ private readonly MetadataCache _cache;
+
+ public MetadataCacheTests()
+ {
+ _loggerMock = new Mock>();
+ _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();
+
+ // 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();
+ }
+}
diff --git a/Jellyfin.Plugin.SRFPlay.Tests/UnitTests/StreamUrlResolverTests.cs b/Jellyfin.Plugin.SRFPlay.Tests/UnitTests/StreamUrlResolverTests.cs
new file mode 100644
index 0000000..569f66b
--- /dev/null
+++ b/Jellyfin.Plugin.SRFPlay.Tests/UnitTests/StreamUrlResolverTests.cs
@@ -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;
+
+///
+/// Unit tests for StreamUrlResolver.
+///
+public class StreamUrlResolverTests : IDisposable
+{
+ private readonly Mock> _loggerMock;
+ private readonly StreamUrlResolver _resolver;
+
+ public StreamUrlResolverTests()
+ {
+ _loggerMock = new Mock>();
+ _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()
+ };
+
+ // 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
+ {
+ 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
+ {
+ 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
+ {
+ 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();
+ }
+}
diff --git a/README.md b/README.md
index 29fc7eb..dcbcfb9 100644
--- a/README.md
+++ b/README.md
@@ -103,11 +103,10 @@ Video playback with HLS streaming support and quality selection.
**Successfully compiling!** All code analysis warnings resolved.
### ๐งช Testing Status
-- [x] Unit tests (xUnit framework)
- StreamUrlResolver tests
- MetadataCache tests
- More to be added
-- [x] API spec validation tests (nightly automated runs)
+- [ ] API spec validation tests (nightly automated runs)
- All business units (SRF, RTS, RSI, RTR, SWI)
- Response schema validation
- Performance monitoring
diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md
new file mode 100644
index 0000000..ce9ab1d
--- /dev/null
+++ b/TESTING_GUIDE.md
@@ -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
diff --git a/setup-tests.sh b/setup-tests.sh
new file mode 100755
index 0000000..658aea7
--- /dev/null
+++ b/setup-tests.sh
@@ -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"