Compare commits
No commits in common. "ac6a3842dd5826c4bfc38548d93e510b3310a1ed" and "5f4229e0fdb46d5b7c8eee237b51b5f4be7578cb" have entirely different histories.
ac6a3842dd
...
5f4229e0fd
11
.vscode/extensions.json
vendored
11
.vscode/extensions.json
vendored
@ -1,11 +0,0 @@
|
||||
{
|
||||
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
|
||||
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
|
||||
// List of extensions which should be recommended for users of this workspace.
|
||||
"recommendations": [
|
||||
"ms-dotnettools.csharp",
|
||||
"editorconfig.editorconfig"
|
||||
],
|
||||
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@ -7,7 +7,7 @@
|
||||
"name": "Launch",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build-and-copy",
|
||||
"program": "${config:jellyfinDir}/bin/Debug/net8.0/jellyfin.dll",
|
||||
"program": "${config:jellyfinDir}/bin/Debug/net6.0/jellyfin.dll",
|
||||
"args": [
|
||||
//"--nowebclient"
|
||||
"--webdir",
|
||||
|
||||
12
.vscode/settings.json
vendored
12
.vscode/settings.json
vendored
@ -1,19 +1,15 @@
|
||||
{
|
||||
// jellyfinDir : The directory of the cloned jellyfin server project
|
||||
// This needs to be built once before it can be used
|
||||
"jellyfinDir": "${workspaceFolder}/../jellyfin/Jellyfin.Server",
|
||||
"jellyfinDir" : "${workspaceFolder}/../jellyfin/Jellyfin.Server",
|
||||
// jellyfinWebDir : The directory of the cloned jellyfin-web project
|
||||
// This needs to be built once before it can be used
|
||||
"jellyfinWebDir": "${workspaceFolder}/../jellyfin-web",
|
||||
"jellyfinWebDir" : "${workspaceFolder}/../jellyfin-web",
|
||||
// jellyfinDataDir : the root data directory for a running jellyfin instance
|
||||
// This is where jellyfin stores its configs, plugins, metadata etc
|
||||
// This is platform specific by default, but on Windows defaults to
|
||||
// ${env:LOCALAPPDATA}/jellyfin
|
||||
// and on Linux, it defaults to
|
||||
// ${env:XDG_DATA_HOME}/jellyfin
|
||||
// However ${env:XDG_DATA_HOME} does not work in Visual Studio Code's development container!
|
||||
"jellyfinWindowsDataDir": "${env:LOCALAPPDATA}/jellyfin",
|
||||
"jellyfinLinuxDataDir": "$HOME/.local/share/jellyfin",
|
||||
"jellyfinDataDir" : "${env:LOCALAPPDATA}/jellyfin",
|
||||
// The name of the plugin
|
||||
"pluginName": "Jellyfin.Plugin.Template",
|
||||
"pluginName" : "Jellyfin.Plugin.Template",
|
||||
}
|
||||
31
.vscode/tasks.json
vendored
31
.vscode/tasks.json
vendored
@ -7,11 +7,7 @@
|
||||
// jellyfin server's plugin directory
|
||||
"label": "build-and-copy",
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"build",
|
||||
"make-plugin-dir",
|
||||
"copy-dll"
|
||||
]
|
||||
"dependsOn": ["build", "make-plugin-dir", "copy-dll"]
|
||||
},
|
||||
{
|
||||
// Build the plugin
|
||||
@ -20,7 +16,6 @@
|
||||
"type": "shell",
|
||||
"args": [
|
||||
"publish",
|
||||
"--configuration=Debug",
|
||||
"${workspaceFolder}/${config:pluginName}.sln",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
@ -36,20 +31,12 @@
|
||||
"label": "make-plugin-dir",
|
||||
"type": "shell",
|
||||
"command": "mkdir",
|
||||
"windows": {
|
||||
"args": [
|
||||
"-Force",
|
||||
"-Path",
|
||||
"${config:jellyfinWindowsDataDir}/plugins/${config:pluginName}/"
|
||||
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"args": [
|
||||
"-p",
|
||||
"${config:jellyfinLinuxDataDir}/plugins/${config:pluginName}/"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
// Copy the plugin dll to the jellyfin plugin install path
|
||||
// This command copies every .dll from the build directory to the plugin dir
|
||||
@ -58,19 +45,11 @@
|
||||
"label": "copy-dll",
|
||||
"type": "shell",
|
||||
"command": "cp",
|
||||
"windows": {
|
||||
"args": [
|
||||
"./${config:pluginName}/bin/Debug/net8.0/publish/*",
|
||||
"${config:jellyfinWindowsDataDir}/plugins/${config:pluginName}/"
|
||||
"./${config:pluginName}/bin/Debug/net6.0/publish/*",
|
||||
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"args": [
|
||||
"-r",
|
||||
"./${config:pluginName}/bin/Debug/net8.0/publish/*",
|
||||
"${config:jellyfinLinuxDataDir}/plugins/${config:pluginName}/"
|
||||
]
|
||||
}
|
||||
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@ -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
|
||||
254
DEBUG_GUIDE.md
254
DEBUG_GUIDE.md
@ -1,254 +0,0 @@
|
||||
# Debugging Guide for SRF Play Plugin
|
||||
|
||||
This guide helps you debug issues with the SRF Play plugin, including proxy configuration and content fetching problems.
|
||||
|
||||
## Enhanced Logging
|
||||
|
||||
The plugin now includes detailed logging to help diagnose issues. After installing the updated version, check your Jellyfin logs for these key messages.
|
||||
|
||||
## Key Log Messages to Look For
|
||||
|
||||
### 1. Proxy Configuration Status
|
||||
|
||||
When the plugin initializes, you'll see:
|
||||
|
||||
**Without proxy:**
|
||||
```
|
||||
[INF] SRFApiClient initializing without proxy
|
||||
```
|
||||
|
||||
**With proxy:**
|
||||
```
|
||||
[INF] SRFApiClient initializing with proxy enabled: http://your-proxy:port
|
||||
[INF] Proxy configured: http://your-proxy:port (Authentication: True/False)
|
||||
```
|
||||
|
||||
**Proxy configuration errors:**
|
||||
```
|
||||
[ERR] Failed to configure proxy: http://your-proxy:port
|
||||
```
|
||||
|
||||
### 2. Media Composition Fetching
|
||||
|
||||
For each video URN, you'll see detailed logs:
|
||||
|
||||
```
|
||||
[INF] Fetching media composition for URN: urn:srf:video:12345 from https://il.srgssr.ch/integrationlayer/2.0/mediaComposition/byUrn/urn:srf:video:12345.json
|
||||
[INF] Media composition response for URN urn:srf:video:12345: StatusCode=OK
|
||||
[INF] Successfully fetched media composition for URN: urn:srf:video:12345 - Chapters: 1
|
||||
```
|
||||
|
||||
**If there are no chapters:**
|
||||
```
|
||||
[WRN] Media composition for URN urn:srf:video:12345 has no chapters
|
||||
```
|
||||
|
||||
**HTTP errors:**
|
||||
```
|
||||
[ERR] HTTP error fetching media composition for URN: urn:srf:video:12345 - StatusCode: 404
|
||||
```
|
||||
|
||||
### 3. Stream URL Resolution
|
||||
|
||||
For each chapter, you'll see detailed resource processing:
|
||||
|
||||
```
|
||||
[INF] Processing chapter abc123 with 5 resources
|
||||
[INF] Chapter abc123: Total resources=5, Non-DRM resources=2
|
||||
[INF] Chapter abc123: HLS resources found=1
|
||||
```
|
||||
|
||||
**DRM-protected content:**
|
||||
```
|
||||
[WRN] All resources for chapter abc123 require DRM
|
||||
[DBG] DRM resource: Protocol=HLS, Streaming=HLS, DRM=WIDEVINE
|
||||
```
|
||||
|
||||
**No HLS streams:**
|
||||
```
|
||||
[WRN] No HLS resources found for chapter: abc123
|
||||
[DBG] Non-HLS resource: Protocol=HTTP, Streaming=PROGRESSIVE, URL=https://...
|
||||
[INF] Using fallback resource for chapter abc123: https://...
|
||||
```
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue 1: Videos Not Loading (Conversion Failures)
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
Conversion complete: 0 successful, 18 failed, 0 expired, 0 no stream
|
||||
```
|
||||
|
||||
**Debugging steps:**
|
||||
|
||||
1. **Check for DRM content:**
|
||||
- Look for: `All resources for chapter XXX require DRM`
|
||||
- Solution: This content cannot be played (Widevine DRM protection)
|
||||
|
||||
2. **Check for HTTP errors:**
|
||||
- Look for: `HTTP error fetching media composition` with status codes
|
||||
- Common codes:
|
||||
- `403 Forbidden`: Geo-restriction or blocked
|
||||
- `404 Not Found`: Content removed or URN invalid
|
||||
- `429 Too Many Requests`: Rate limiting
|
||||
- Solution: Check if proxy is needed for geo-restrictions
|
||||
|
||||
3. **Check for missing chapters:**
|
||||
- Look for: `Media composition for URN XXX has no chapters`
|
||||
- Solution: This video metadata is incomplete or invalid
|
||||
|
||||
### Issue 2: Proxy Not Being Used
|
||||
|
||||
**Symptoms:**
|
||||
- Plugin says "initializing without proxy" even though configured
|
||||
- Getting geo-restriction errors (403)
|
||||
|
||||
**Debugging steps:**
|
||||
|
||||
1. Check the initialization log:
|
||||
```
|
||||
[INF] SRFApiClient initializing with proxy enabled: http://...
|
||||
```
|
||||
|
||||
2. If it says "without proxy", check:
|
||||
- Is "Use Proxy" checkbox enabled in plugin settings?
|
||||
- Is the Proxy Address field filled in?
|
||||
- Did you restart Jellyfin after saving?
|
||||
|
||||
3. Check for proxy errors:
|
||||
```
|
||||
[ERR] Failed to configure proxy: http://...
|
||||
```
|
||||
- Verify the proxy URL format (must include `http://` or `socks5://`)
|
||||
- Test proxy connectivity: `curl --proxy http://proxy:port https://il.srgssr.ch`
|
||||
|
||||
### Issue 3: Proxy Connection Failures
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
[ERR] HTTP error fetching media composition for URN: ... - StatusCode: null
|
||||
```
|
||||
or timeout errors
|
||||
|
||||
**Debugging steps:**
|
||||
|
||||
1. **Verify proxy is reachable from Jellyfin server:**
|
||||
```bash
|
||||
curl --proxy http://your-proxy:port https://il.srgssr.ch
|
||||
```
|
||||
|
||||
2. **Check proxy authentication:**
|
||||
- If proxy requires auth, ensure username/password are configured
|
||||
- Look for: `Proxy configured: ... (Authentication: True)`
|
||||
|
||||
3. **Check proxy supports HTTPS:**
|
||||
- The SRF API uses HTTPS, ensure your proxy can handle it
|
||||
|
||||
4. **Test with a simple HTTP proxy:**
|
||||
```bash
|
||||
# Test with SSH tunnel as SOCKS5 proxy
|
||||
ssh -D 1080 -N user@gateway-server
|
||||
# Then configure: socks5://127.0.0.1:1080
|
||||
```
|
||||
|
||||
### Issue 4: Content Expiration
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
Conversion complete: 0 successful, 0 failed, 10 expired, 0 no stream
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
- Content has expired and is no longer available
|
||||
- Check the expiration task runs regularly
|
||||
- Some content is time-limited by SRF
|
||||
|
||||
## Enabling Debug Logs
|
||||
|
||||
To see even more detailed logs (like `[DBG]` messages), modify your Jellyfin logging configuration:
|
||||
|
||||
1. Go to **Dashboard → Logs**
|
||||
2. Click **Settings**
|
||||
3. Set log level to **Debug** for troubleshooting
|
||||
4. Restart Jellyfin
|
||||
|
||||
**Warning:** Debug logging can be very verbose and consume disk space quickly. Only enable temporarily.
|
||||
|
||||
## Testing Proxy Configuration
|
||||
|
||||
To verify your proxy is working:
|
||||
|
||||
### 1. Enable Proxy in Plugin Settings
|
||||
- Dashboard → Plugins → SRF Play
|
||||
- Check "Use Proxy"
|
||||
- Enter your proxy address
|
||||
- Save and restart Jellyfin
|
||||
|
||||
### 2. Check Initialization Logs
|
||||
Look for:
|
||||
```
|
||||
[INF] SRFApiClient initializing with proxy enabled: http://your-proxy:port
|
||||
[INF] Proxy configured: http://your-proxy:port (Authentication: False)
|
||||
```
|
||||
|
||||
### 3. Trigger Content Refresh
|
||||
- Dashboard → Scheduled Tasks → "Refresh SRF Play Content"
|
||||
- Run task manually
|
||||
- Watch logs for media composition requests
|
||||
|
||||
### 4. Verify Requests Go Through Proxy
|
||||
You can monitor proxy logs (if available) or use network monitoring tools to confirm traffic routes through the proxy.
|
||||
|
||||
## Getting Help
|
||||
|
||||
When reporting issues, include:
|
||||
|
||||
1. **Plugin version:**
|
||||
- Dashboard → Plugins → SRF Play → Version
|
||||
|
||||
2. **Relevant log entries:**
|
||||
- The initialization message (with/without proxy)
|
||||
- Any error messages
|
||||
- The "Conversion complete" summary
|
||||
- Resource processing details for failed items
|
||||
|
||||
3. **Configuration:**
|
||||
- Business unit selected
|
||||
- Proxy enabled? (yes/no, don't share credentials)
|
||||
- Where Jellyfin is running (local network, VPS, etc.)
|
||||
|
||||
4. **Network environment:**
|
||||
- Are you behind a geo-restriction?
|
||||
- Is the proxy on the same network or remote?
|
||||
- Any firewalls or network policies?
|
||||
|
||||
## Example of Good Debug Output
|
||||
|
||||
When everything works correctly with proxy:
|
||||
|
||||
```
|
||||
[INF] SRFApiClient initializing with proxy enabled: http://192.168.1.1:3128
|
||||
[INF] Proxy configured: http://192.168.1.1:3128 (Authentication: False)
|
||||
[INF] Fetching media composition for URN: urn:srf:video:12345 from https://il.srgssr.ch/...
|
||||
[INF] Media composition response for URN urn:srf:video:12345: StatusCode=OK
|
||||
[INF] Successfully fetched media composition for URN: urn:srf:video:12345 - Chapters: 1
|
||||
[INF] Processing chapter abc123 with 3 resources
|
||||
[INF] Chapter abc123: Total resources=3, Non-DRM resources=2
|
||||
[INF] Chapter abc123: HLS resources found=1
|
||||
[DBG] Selected stream for chapter abc123: Quality=HD, Protocol=HLS, URL=https://...m3u8
|
||||
```
|
||||
|
||||
## Example of Failed Conversion (DRM Issue)
|
||||
|
||||
```
|
||||
[INF] Fetching media composition for URN: urn:srf:video:67890 from https://il.srgssr.ch/...
|
||||
[INF] Media composition response for URN urn:srf:video:67890: StatusCode=OK
|
||||
[INF] Successfully fetched media composition for URN: urn:srf:video:67890 - Chapters: 1
|
||||
[INF] Processing chapter def456 with 2 resources
|
||||
[INF] Chapter def456: Total resources=2, Non-DRM resources=0
|
||||
[WRN] All resources for chapter def456 require DRM
|
||||
[DBG] DRM resource: Protocol=HLS, Streaming=HLS, DRM=WIDEVINE
|
||||
```
|
||||
|
||||
This means the content requires Widevine DRM and cannot be played by Jellyfin.
|
||||
@ -1,19 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../Jellyfin.Plugin.SRFPlay/Jellyfin.Plugin.SRFPlay.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@ -1,284 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||
using Jellyfin.Plugin.SRFPlay.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Tests
|
||||
{
|
||||
class Program
|
||||
{
|
||||
static async Task Main(string[] args)
|
||||
{
|
||||
// Run the new Play v3 API tests
|
||||
await TestPlayV3Api.RunTests();
|
||||
|
||||
Console.WriteLine("\n\n");
|
||||
Console.WriteLine("=================================================================");
|
||||
Console.WriteLine("OLD API TESTS (DEPRECATED - Expected to fail)");
|
||||
Console.WriteLine("=================================================================\n");
|
||||
|
||||
Console.WriteLine("=== SRFPlay Plugin Test Suite (Old API) ===\n");
|
||||
|
||||
// Setup logging
|
||||
using var loggerFactory = LoggerFactory.Create(builder =>
|
||||
{
|
||||
builder.AddConsole();
|
||||
builder.SetMinimumLevel(LogLevel.Warning); // Only show warnings and errors
|
||||
});
|
||||
|
||||
var apiClient = new SRFApiClient(loggerFactory);
|
||||
var streamResolver = new StreamUrlResolver(loggerFactory.CreateLogger<StreamUrlResolver>());
|
||||
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
// Test 1: Check API connectivity
|
||||
Console.WriteLine("[Test 1] API Connectivity Test");
|
||||
Console.WriteLine("-------------------------------");
|
||||
try
|
||||
{
|
||||
var latestVideos = await apiClient.GetLatestVideosAsync("srf", cancellationToken);
|
||||
if (latestVideos?.ChapterList != null && latestVideos.ChapterList.Any())
|
||||
{
|
||||
Console.WriteLine($"✓ Successfully fetched latest videos: {latestVideos.ChapterList.Count} items");
|
||||
Console.WriteLine($" First video: {latestVideos.ChapterList[0]?.Title}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("✗ Failed: No videos returned");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Error: {ex.Message}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
// Test 2: Check trending videos
|
||||
Console.WriteLine("[Test 2] Trending Videos Test");
|
||||
Console.WriteLine("-----------------------------");
|
||||
try
|
||||
{
|
||||
var trendingVideos = await apiClient.GetTrendingVideosAsync("srf", cancellationToken);
|
||||
if (trendingVideos?.ChapterList != null && trendingVideos.ChapterList.Any())
|
||||
{
|
||||
Console.WriteLine($"✓ Successfully fetched trending videos: {trendingVideos.ChapterList.Count} items");
|
||||
Console.WriteLine($" First video: {trendingVideos.ChapterList[0]?.Title}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("✗ Failed: No videos returned");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Error: {ex.Message}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
// Test 3: Get specific video and check stream URL
|
||||
Console.WriteLine("[Test 3] Stream URL Resolution Test");
|
||||
Console.WriteLine("-----------------------------------");
|
||||
try
|
||||
{
|
||||
var latestVideos = await apiClient.GetLatestVideosAsync("srf", cancellationToken);
|
||||
if (latestVideos?.ChapterList != null && latestVideos.ChapterList.Any())
|
||||
{
|
||||
var firstChapter = latestVideos.ChapterList[0];
|
||||
Console.WriteLine($"Testing with: {firstChapter.Title}");
|
||||
Console.WriteLine($"URN: {firstChapter.Urn}");
|
||||
|
||||
// Fetch full media composition for this URN
|
||||
var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(firstChapter.Urn, cancellationToken);
|
||||
|
||||
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Any())
|
||||
{
|
||||
var chapter = mediaComposition.ChapterList[0];
|
||||
|
||||
// Check if content is playable
|
||||
bool hasPlayableContent = streamResolver.HasPlayableContent(chapter);
|
||||
Console.WriteLine($"Has playable content: {hasPlayableContent}");
|
||||
|
||||
// Check if content is expired
|
||||
bool isExpired = streamResolver.IsContentExpired(chapter);
|
||||
Console.WriteLine($"Is expired: {isExpired}");
|
||||
|
||||
// Try to get stream URL
|
||||
if (hasPlayableContent && !isExpired)
|
||||
{
|
||||
var streamUrl = streamResolver.GetStreamUrl(chapter, Jellyfin.Plugin.SRFPlay.Configuration.QualityPreference.Auto);
|
||||
if (!string.IsNullOrEmpty(streamUrl))
|
||||
{
|
||||
Console.WriteLine($"✓ Stream URL resolved: {streamUrl.Substring(0, Math.Min(80, streamUrl.Length))}...");
|
||||
|
||||
// Show available resources
|
||||
if (chapter.ResourceList != null)
|
||||
{
|
||||
Console.WriteLine($" Available resources: {chapter.ResourceList.Count}");
|
||||
foreach (var resource in chapter.ResourceList.Take(5))
|
||||
{
|
||||
var hasDrm = resource.DrmList != null && resource.DrmList.ToString() != "[]";
|
||||
Console.WriteLine($" - {resource.Quality} ({resource.Protocol}) {(hasDrm ? "[DRM]" : "[No DRM]")}");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("✗ Failed to resolve stream URL");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!hasPlayableContent)
|
||||
Console.WriteLine("✗ Content is not playable (likely DRM protected)");
|
||||
if (isExpired)
|
||||
Console.WriteLine($"✗ Content is expired (ValidTo: {chapter.ValidTo})");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("✗ Failed to fetch media composition");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Error: {ex.Message}");
|
||||
Console.WriteLine($"Stack trace: {ex.StackTrace}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
// Test 4: Check multiple videos for playability
|
||||
Console.WriteLine("[Test 4] Multiple Videos Playability Check");
|
||||
Console.WriteLine("------------------------------------------");
|
||||
try
|
||||
{
|
||||
var latestVideos = await apiClient.GetLatestVideosAsync("srf", cancellationToken);
|
||||
if (latestVideos?.ChapterList != null)
|
||||
{
|
||||
int totalVideos = Math.Min(10, latestVideos.ChapterList.Count);
|
||||
int playableCount = 0;
|
||||
int expiredCount = 0;
|
||||
int drmCount = 0;
|
||||
|
||||
Console.WriteLine($"Checking {totalVideos} videos...\n");
|
||||
|
||||
for (int i = 0; i < totalVideos; i++)
|
||||
{
|
||||
var chapter = latestVideos.ChapterList[i];
|
||||
var mediaComp = await apiClient.GetMediaCompositionByUrnAsync(chapter.Urn, cancellationToken);
|
||||
|
||||
if (mediaComp?.ChapterList != null && mediaComp.ChapterList.Any())
|
||||
{
|
||||
var fullChapter = mediaComp.ChapterList[0];
|
||||
bool hasPlayable = streamResolver.HasPlayableContent(fullChapter);
|
||||
bool isExpired = streamResolver.IsContentExpired(fullChapter);
|
||||
|
||||
string status;
|
||||
if (isExpired)
|
||||
{
|
||||
status = "EXPIRED";
|
||||
expiredCount++;
|
||||
}
|
||||
else if (!hasPlayable)
|
||||
{
|
||||
status = "DRM";
|
||||
drmCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
status = "PLAYABLE";
|
||||
playableCount++;
|
||||
}
|
||||
|
||||
Console.WriteLine($"{i + 1}. [{status}] {fullChapter.Title}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Summary:");
|
||||
Console.WriteLine($" Playable: {playableCount}/{totalVideos} ({(playableCount * 100.0 / totalVideos):F1}%)");
|
||||
Console.WriteLine($" DRM Protected: {drmCount}/{totalVideos} ({(drmCount * 100.0 / totalVideos):F1}%)");
|
||||
Console.WriteLine($" Expired: {expiredCount}/{totalVideos} ({(expiredCount * 100.0 / totalVideos):F1}%)");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Error: {ex.Message}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
// Test 5: Test library content discovery (simulates what would be in the library)
|
||||
Console.WriteLine("[Test 5] Library Content Discovery Simulation");
|
||||
Console.WriteLine("----------------------------------------------");
|
||||
try
|
||||
{
|
||||
var latestVideos = await apiClient.GetLatestVideosAsync("srf", cancellationToken);
|
||||
var trendingVideos = await apiClient.GetTrendingVideosAsync("srf", cancellationToken);
|
||||
|
||||
var allUrns = new HashSet<string>();
|
||||
|
||||
if (latestVideos?.ChapterList != null)
|
||||
{
|
||||
foreach (var chapter in latestVideos.ChapterList)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(chapter.Urn))
|
||||
allUrns.Add(chapter.Urn);
|
||||
}
|
||||
}
|
||||
|
||||
if (trendingVideos?.ChapterList != null)
|
||||
{
|
||||
foreach (var chapter in trendingVideos.ChapterList)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(chapter.Urn))
|
||||
allUrns.Add(chapter.Urn);
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"✓ Latest videos available: {latestVideos?.ChapterList?.Count ?? 0}");
|
||||
Console.WriteLine($"✓ Trending videos available: {trendingVideos?.ChapterList?.Count ?? 0}");
|
||||
Console.WriteLine($"✓ Total unique items for library (deduplicated): {allUrns.Count}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Error: {ex.Message}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
// Test 6: Test different business units
|
||||
Console.WriteLine("[Test 6] Business Units Test");
|
||||
Console.WriteLine("----------------------------");
|
||||
var businessUnits = new[] { "srf", "rts", "rsi", "rtr", "swi" };
|
||||
|
||||
foreach (var bu in businessUnits)
|
||||
{
|
||||
try
|
||||
{
|
||||
var videos = await apiClient.GetLatestVideosAsync(bu, cancellationToken);
|
||||
if (videos?.ChapterList != null && videos.ChapterList.Any())
|
||||
{
|
||||
Console.WriteLine($"✓ {bu.ToUpper()}: {videos.ChapterList.Count} videos available");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"✗ {bu.ToUpper()}: No videos found");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ {bu.ToUpper()}: Error - {ex.Message}");
|
||||
}
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine("=== Test Suite Complete ===");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,220 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Tests
|
||||
{
|
||||
public class TestPlayV3Api
|
||||
{
|
||||
public static async Task RunTests()
|
||||
{
|
||||
Console.WriteLine("=== Play v3 API Test Suite ===\n");
|
||||
|
||||
using var loggerFactory = LoggerFactory.Create(builder =>
|
||||
{
|
||||
builder.AddConsole();
|
||||
builder.SetMinimumLevel(LogLevel.Warning);
|
||||
});
|
||||
|
||||
var apiClient = new SRFApiClient(loggerFactory);
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
// Test 1: Get all shows
|
||||
Console.WriteLine("[Test 1] Fetch All Shows");
|
||||
Console.WriteLine("------------------------");
|
||||
try
|
||||
{
|
||||
var shows = await apiClient.GetAllShowsAsync("srf", cancellationToken);
|
||||
if (shows != null && shows.Any())
|
||||
{
|
||||
Console.WriteLine($"✓ Successfully fetched {shows.Count} shows");
|
||||
Console.WriteLine($" First show: {shows[0]?.Title} ({shows[0]?.NumberOfEpisodes} episodes)");
|
||||
Console.WriteLine($" Show ID: {shows[0]?.Id}");
|
||||
Console.WriteLine($" URN: {shows[0]?.Urn}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("✗ Failed: No shows returned");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Error: {ex.Message}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
// Test 2: Get videos for a show
|
||||
Console.WriteLine("[Test 2] Fetch Videos for a Show");
|
||||
Console.WriteLine("--------------------------------");
|
||||
try
|
||||
{
|
||||
var shows = await apiClient.GetAllShowsAsync("srf", cancellationToken);
|
||||
if (shows != null && shows.Any())
|
||||
{
|
||||
// Find a show with episodes
|
||||
var showWithEpisodes = shows.FirstOrDefault(s => s.NumberOfEpisodes > 0);
|
||||
if (showWithEpisodes != null && showWithEpisodes.Id != null)
|
||||
{
|
||||
Console.WriteLine($"Testing with show: {showWithEpisodes.Title}");
|
||||
|
||||
var videos = await apiClient.GetVideosForShowAsync("srf", showWithEpisodes.Id, cancellationToken);
|
||||
if (videos != null && videos.Any())
|
||||
{
|
||||
Console.WriteLine($"✓ Successfully fetched {videos.Count} videos");
|
||||
var firstVideo = videos[0];
|
||||
Console.WriteLine($" First video: {firstVideo?.Title}");
|
||||
Console.WriteLine($" URN: {firstVideo?.Urn}");
|
||||
Console.WriteLine($" Duration: {firstVideo?.Duration / 1000}s");
|
||||
Console.WriteLine($" Date: {firstVideo?.Date}");
|
||||
Console.WriteLine($" Playable abroad: {firstVideo?.PlayableAbroad}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("✗ Failed: No videos returned");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("✗ Failed: No shows with episodes found");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Error: {ex.Message}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
// Test 3: Get video stream URL using Integration Layer
|
||||
Console.WriteLine("[Test 3] Get Stream URL for Video");
|
||||
Console.WriteLine("---------------------------------");
|
||||
try
|
||||
{
|
||||
var shows = await apiClient.GetAllShowsAsync("srf", cancellationToken);
|
||||
if (shows != null && shows.Any())
|
||||
{
|
||||
var showWithEpisodes = shows.FirstOrDefault(s => s.NumberOfEpisodes > 0);
|
||||
if (showWithEpisodes != null && showWithEpisodes.Id != null)
|
||||
{
|
||||
var videos = await apiClient.GetVideosForShowAsync("srf", showWithEpisodes.Id, cancellationToken);
|
||||
if (videos != null && videos.Any())
|
||||
{
|
||||
var video = videos[0];
|
||||
if (video.Urn != null)
|
||||
{
|
||||
Console.WriteLine($"Fetching stream for: {video.Title}");
|
||||
|
||||
// Use Integration Layer 2.0 to get stream URL
|
||||
var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(video.Urn, cancellationToken);
|
||||
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Any())
|
||||
{
|
||||
var chapter = mediaComposition.ChapterList[0];
|
||||
if (chapter.ResourceList != null && chapter.ResourceList.Any())
|
||||
{
|
||||
var hlsResource = chapter.ResourceList.FirstOrDefault(r =>
|
||||
r.Protocol == "HLS" && (r.DrmList == null || r.DrmList.ToString() == "[]"));
|
||||
|
||||
if (hlsResource != null)
|
||||
{
|
||||
Console.WriteLine($"✓ Stream URL found:");
|
||||
Console.WriteLine($" URL: {hlsResource.Url?.Substring(0, Math.Min(80, hlsResource.Url?.Length ?? 0))}...");
|
||||
Console.WriteLine($" Quality: {hlsResource.Quality}");
|
||||
Console.WriteLine($" Protocol: {hlsResource.Protocol}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("✗ No HLS stream without DRM found");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("✗ No resources found");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("✗ Failed to fetch media composition");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Error: {ex.Message}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
// Test 4: Check multiple shows
|
||||
Console.WriteLine("[Test 4] Check Multiple Shows");
|
||||
Console.WriteLine("----------------------------");
|
||||
try
|
||||
{
|
||||
var shows = await apiClient.GetAllShowsAsync("srf", cancellationToken);
|
||||
if (shows != null)
|
||||
{
|
||||
var showsWithEpisodes = shows.Where(s => s.NumberOfEpisodes > 0).Take(10).ToList();
|
||||
Console.WriteLine($"Checking {showsWithEpisodes.Count} shows with episodes...\n");
|
||||
|
||||
int successCount = 0;
|
||||
foreach (var show in showsWithEpisodes)
|
||||
{
|
||||
if (show.Id != null)
|
||||
{
|
||||
var videos = await apiClient.GetVideosForShowAsync("srf", show.Id, cancellationToken);
|
||||
if (videos != null && videos.Any())
|
||||
{
|
||||
Console.WriteLine($"✓ {show.Title}: {videos.Count} videos available");
|
||||
successCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"✗ {show.Title}: No videos found");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Summary: {successCount}/{showsWithEpisodes.Count} shows successfully fetched videos");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Error: {ex.Message}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
// Test 5: Test all business units
|
||||
Console.WriteLine("[Test 5] Test All Business Units");
|
||||
Console.WriteLine("--------------------------------");
|
||||
var businessUnits = new[] { "srf", "rts", "rsi", "rtr", "swi" };
|
||||
|
||||
foreach (var bu in businessUnits)
|
||||
{
|
||||
try
|
||||
{
|
||||
var shows = await apiClient.GetAllShowsAsync(bu, cancellationToken);
|
||||
if (shows != null && shows.Any())
|
||||
{
|
||||
Console.WriteLine($"✓ {bu.ToUpper()}: {shows.Count} shows available");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"✗ {bu.ToUpper()}: No shows found");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ {bu.ToUpper()}: Error - {ex.Message}");
|
||||
}
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine("=== Test Suite Complete ===");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
#
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.SRFPlay", "Jellyfin.Plugin.SRFPlay\Jellyfin.Plugin.SRFPlay.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.SRFPlay.Tests", "Jellyfin.Plugin.SRFPlay.Tests\Jellyfin.Plugin.SRFPlay.Tests.csproj", "{2F8A80BE-D938-42DE-B351-B705D154C1AE}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@ -1,96 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a chapter (video/episode) in the SRF API response.
|
||||
/// </summary>
|
||||
public class Chapter
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the URN (Uniform Resource Name).
|
||||
/// </summary>
|
||||
[JsonPropertyName("urn")]
|
||||
public string Urn { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the title.
|
||||
/// </summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the lead/description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lead")]
|
||||
public string? Lead { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the image URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("imageUrl")]
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the duration in milliseconds.
|
||||
/// </summary>
|
||||
[JsonPropertyName("duration")]
|
||||
public long Duration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the publication date.
|
||||
/// </summary>
|
||||
[JsonPropertyName("date")]
|
||||
public DateTime? Date { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the valid from date.
|
||||
/// </summary>
|
||||
[JsonPropertyName("validFrom")]
|
||||
public DateTime? ValidFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the valid to date (expiration).
|
||||
/// </summary>
|
||||
[JsonPropertyName("validTo")]
|
||||
public DateTime? ValidTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of available resources (streams).
|
||||
/// </summary>
|
||||
[JsonPropertyName("resourceList")]
|
||||
public IReadOnlyList<Resource> ResourceList { get; set; } = new List<Resource>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the episode number.
|
||||
/// </summary>
|
||||
[JsonPropertyName("episodeNumber")]
|
||||
public int? EpisodeNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the season number.
|
||||
/// </summary>
|
||||
[JsonPropertyName("seasonNumber")]
|
||||
public int? SeasonNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the media type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; set; }
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an episode in the SRF API response.
|
||||
/// </summary>
|
||||
public class Episode
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the URN.
|
||||
/// </summary>
|
||||
[JsonPropertyName("urn")]
|
||||
public string? Urn { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the title.
|
||||
/// </summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the lead/description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lead")]
|
||||
public string? Lead { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the image URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("imageUrl")]
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the episode number.
|
||||
/// </summary>
|
||||
[JsonPropertyName("episodeNumber")]
|
||||
public int? EpisodeNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the season number.
|
||||
/// </summary>
|
||||
[JsonPropertyName("seasonNumber")]
|
||||
public int? SeasonNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the publication date.
|
||||
/// </summary>
|
||||
[JsonPropertyName("publishedDate")]
|
||||
public DateTime? PublishedDate { get; set; }
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the root media composition response from SRF API.
|
||||
/// </summary>
|
||||
public class MediaComposition
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the list of chapters (videos/episodes).
|
||||
/// </summary>
|
||||
[JsonPropertyName("chapterList")]
|
||||
public IReadOnlyList<Chapter> ChapterList { get; set; } = new List<Chapter>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the episode information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("episode")]
|
||||
public Episode? Episode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the show information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("show")]
|
||||
public Show? Show { get; set; }
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the data container in a Play v3 response.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of data items.</typeparam>
|
||||
public class PlayV3DataContainer<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the list of data items.
|
||||
/// </summary>
|
||||
[JsonPropertyName("data")]
|
||||
[SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for JSON deserialization")]
|
||||
[SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "DTO for JSON deserialization")]
|
||||
public List<T>? Data { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the next page cursor.
|
||||
/// </summary>
|
||||
[JsonPropertyName("next")]
|
||||
public string? Next { get; set; }
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a direct data response (for shows list).
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of data items.</typeparam>
|
||||
public class PlayV3DirectResponse<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the list of data items.
|
||||
/// </summary>
|
||||
[JsonPropertyName("data")]
|
||||
[SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for JSON deserialization")]
|
||||
[SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "DTO for JSON deserialization")]
|
||||
public List<T>? Data { get; set; }
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a paginated response from the Play v3 API.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of data items.</typeparam>
|
||||
public class PlayV3Response<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the data container.
|
||||
/// </summary>
|
||||
[JsonPropertyName("data")]
|
||||
public PlayV3DataContainer<T>? Data { get; set; }
|
||||
}
|
||||
@ -1,79 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a show from the Play v3 API.
|
||||
/// </summary>
|
||||
public class PlayV3Show
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the show ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the show URN.
|
||||
/// </summary>
|
||||
[JsonPropertyName("urn")]
|
||||
public string? Urn { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the show title.
|
||||
/// </summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the image URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("imageUrl")]
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the transmission type (TV or Radio).
|
||||
/// </summary>
|
||||
[JsonPropertyName("transmission")]
|
||||
public string? Transmission { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the vendor/business unit.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vendor")]
|
||||
public string? Vendor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the lead description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lead")]
|
||||
public string? Lead { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the full description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the poster image URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("posterImageUrl")]
|
||||
public string? PosterImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of episodes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("numberOfEpisodes")]
|
||||
public int NumberOfEpisodes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of topic IDs.
|
||||
/// </summary>
|
||||
[JsonPropertyName("topicList")]
|
||||
[SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for JSON deserialization")]
|
||||
[SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "DTO for JSON deserialization")]
|
||||
public List<string>? TopicList { get; set; }
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a topic/category from the Play v3 API.
|
||||
/// </summary>
|
||||
public class PlayV3Topic
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the topic ID (UUID).
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the topic title.
|
||||
/// </summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the topic description/lead.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lead")]
|
||||
public string? Lead { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the transmission type (TV or Radio).
|
||||
/// </summary>
|
||||
[JsonPropertyName("transmission")]
|
||||
public string? Transmission { get; set; }
|
||||
}
|
||||
@ -1,116 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a video/episode from the Play v3 API.
|
||||
/// </summary>
|
||||
public class PlayV3Video
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the video ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the video URN.
|
||||
/// </summary>
|
||||
[JsonPropertyName("urn")]
|
||||
public string? Urn { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the video title.
|
||||
/// </summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the episode ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("episodeId")]
|
||||
public string? EpisodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the lead description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lead")]
|
||||
public string? Lead { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the full description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the image URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("imageUrl")]
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the duration in milliseconds.
|
||||
/// </summary>
|
||||
[JsonPropertyName("duration")]
|
||||
public long Duration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the publication date.
|
||||
/// </summary>
|
||||
[JsonPropertyName("date")]
|
||||
public DateTime Date { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the valid from date.
|
||||
/// </summary>
|
||||
[JsonPropertyName("validFrom")]
|
||||
public DateTime? ValidFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the valid to date (expiration).
|
||||
/// </summary>
|
||||
[JsonPropertyName("validTo")]
|
||||
public DateTime? ValidTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the media type (VIDEO or AUDIO).
|
||||
/// </summary>
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether subtitles are available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("subtitlesAvailable")]
|
||||
public bool SubtitlesAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the content is playable abroad.
|
||||
/// </summary>
|
||||
[JsonPropertyName("playableAbroad")]
|
||||
public bool PlayableAbroad { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of possible block reasons.
|
||||
/// </summary>
|
||||
[JsonPropertyName("possibleBlockReasons")]
|
||||
[SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for JSON deserialization")]
|
||||
[SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "DTO for JSON deserialization")]
|
||||
public List<string>? PossibleBlockReasons { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the content type (EPISODE, CLIP, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the associated show information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("show")]
|
||||
public PlayV3Show? Show { get; set; }
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a streaming resource (URL) in the SRF API response.
|
||||
/// </summary>
|
||||
public class Resource
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the stream URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the streaming protocol (e.g., HLS, DASH).
|
||||
/// </summary>
|
||||
[JsonPropertyName("protocol")]
|
||||
public string? Protocol { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the quality level (e.g., HD, SD).
|
||||
/// </summary>
|
||||
[JsonPropertyName("quality")]
|
||||
public string? Quality { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the streaming method.
|
||||
/// </summary>
|
||||
[JsonPropertyName("streaming")]
|
||||
public string? Streaming { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("mimeType")]
|
||||
public string? MimeType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the encoding.
|
||||
/// </summary>
|
||||
[JsonPropertyName("encoding")]
|
||||
public string? Encoding { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether DRM is required.
|
||||
/// </summary>
|
||||
[JsonPropertyName("drmList")]
|
||||
public object? DrmList { get; set; }
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a show/program in the SRF API response.
|
||||
/// </summary>
|
||||
public class Show
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the URN.
|
||||
/// </summary>
|
||||
[JsonPropertyName("urn")]
|
||||
public string? Urn { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the title.
|
||||
/// </summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the lead/description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lead")]
|
||||
public string? Lead { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the image URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("imageUrl")]
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the banner image URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("bannerImageUrl")]
|
||||
public string? BannerImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the vendor (business unit).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vendor")]
|
||||
public string? Vendor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the transmission type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("transmission")]
|
||||
public string? Transmission { get; set; }
|
||||
}
|
||||
@ -1,507 +0,0 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for interacting with the SRF Integration Layer API.
|
||||
/// </summary>
|
||||
public class SRFApiClient : IDisposable
|
||||
{
|
||||
private const string BaseUrl = "https://il.srgssr.ch/integrationlayer/2.0";
|
||||
private const string PlayV3BaseUrlTemplate = "https://www.{0}.ch/play/v3/api/{0}/production/";
|
||||
private static readonly System.Text.CompositeFormat PlayV3UrlFormat = System.Text.CompositeFormat.Parse(PlayV3BaseUrlTemplate);
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly HttpClient _playV3HttpClient;
|
||||
private readonly ILogger _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly PluginConfiguration _configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SRFApiClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
public SRFApiClient(ILoggerFactory loggerFactory)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<SRFApiClient>();
|
||||
_configuration = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
|
||||
// Log proxy configuration status
|
||||
if (_configuration.UseProxy && !string.IsNullOrWhiteSpace(_configuration.ProxyAddress))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"SRFApiClient initializing with proxy enabled: {ProxyAddress}",
|
||||
_configuration.ProxyAddress);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("SRFApiClient initializing without proxy");
|
||||
}
|
||||
|
||||
_httpClient = CreateHttpClient(BaseUrl);
|
||||
_playV3HttpClient = CreateHttpClient(null);
|
||||
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HttpClient with optional proxy configuration.
|
||||
/// </summary>
|
||||
/// <param name="baseAddress">The base address for the HTTP client.</param>
|
||||
/// <returns>Configured HttpClient.</returns>
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "CA5399:HttpClient is created without enabling CheckCertificateRevocationList", Justification = "CRL check disabled for proxy compatibility")]
|
||||
private HttpClient CreateHttpClient(string? baseAddress)
|
||||
{
|
||||
HttpClientHandler handler = new HttpClientHandler
|
||||
{
|
||||
CheckCertificateRevocationList = false, // Disable CRL check for proxy compatibility
|
||||
AutomaticDecompression = System.Net.DecompressionMethods.All
|
||||
};
|
||||
|
||||
// Configure proxy if enabled
|
||||
if (_configuration.UseProxy && !string.IsNullOrWhiteSpace(_configuration.ProxyAddress))
|
||||
{
|
||||
try
|
||||
{
|
||||
var proxy = new WebProxy(_configuration.ProxyAddress);
|
||||
|
||||
// Add credentials if provided
|
||||
if (!string.IsNullOrWhiteSpace(_configuration.ProxyUsername))
|
||||
{
|
||||
proxy.Credentials = new NetworkCredential(
|
||||
_configuration.ProxyUsername,
|
||||
_configuration.ProxyPassword);
|
||||
}
|
||||
|
||||
handler.Proxy = proxy;
|
||||
handler.UseProxy = true;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Proxy configured: {ProxyAddress} (Authentication: {HasAuth})",
|
||||
_configuration.ProxyAddress,
|
||||
!string.IsNullOrWhiteSpace(_configuration.ProxyUsername));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to configure proxy: {ProxyAddress}", _configuration.ProxyAddress);
|
||||
handler.UseProxy = false;
|
||||
}
|
||||
}
|
||||
|
||||
var client = new HttpClient(handler)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
DefaultRequestVersion = HttpVersion.Version11, // Force HTTP/1.1
|
||||
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact // Ensure HTTP/1.1 is used, not upgraded
|
||||
};
|
||||
|
||||
// Add browser headers - required by SRF API
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("*/*");
|
||||
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("en-US,en;q=0.9");
|
||||
|
||||
_logger.LogInformation(
|
||||
"HttpClient created with HTTP/1.1 and headers - User-Agent: {UserAgent}, Accept: {Accept}, Accept-Language: {AcceptLanguage}",
|
||||
client.DefaultRequestHeaders.UserAgent.ToString(),
|
||||
client.DefaultRequestHeaders.Accept.ToString(),
|
||||
client.DefaultRequestHeaders.AcceptLanguage.ToString());
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(baseAddress))
|
||||
{
|
||||
client.BaseAddress = new Uri(baseAddress);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets media composition by URN using curl as a fallback.
|
||||
/// </summary>
|
||||
/// <param name="urn">The URN of the content.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The media composition.</returns>
|
||||
public async Task<MediaComposition?> GetMediaCompositionByUrnAsync(string urn, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"/mediaComposition/byUrn/{urn}.json";
|
||||
var fullUrl = $"{BaseUrl}{url}";
|
||||
_logger.LogInformation("Fetching media composition for URN: {Urn} from {Url}", urn, fullUrl);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Log response headers to diagnose geo-blocking
|
||||
var xLocation = response.Headers.Contains("x-location")
|
||||
? string.Join(", ", response.Headers.GetValues("x-location"))
|
||||
: "not present";
|
||||
|
||||
_logger.LogInformation(
|
||||
"Media composition response for URN {Urn}: StatusCode={StatusCode}, x-location={XLocation}",
|
||||
urn,
|
||||
response.StatusCode,
|
||||
xLocation);
|
||||
|
||||
// If HttpClient fails, try curl as fallback
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("HttpClient failed with {StatusCode}, trying curl fallback", response.StatusCode);
|
||||
return await FetchWithCurlAsync(fullUrl, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
|
||||
|
||||
if (result?.ChapterList != null && result.ChapterList.Count > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Successfully fetched media composition for URN: {Urn} - Chapters: {ChapterCount}",
|
||||
urn,
|
||||
result.ChapterList.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Media composition for URN {Urn} has no chapters", urn);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"HTTP error fetching media composition for URN: {Urn} - StatusCode: {StatusCode}, trying curl fallback",
|
||||
urn,
|
||||
ex.StatusCode);
|
||||
|
||||
var fullUrl = $"{BaseUrl}/mediaComposition/byUrn/{urn}.json";
|
||||
return await FetchWithCurlAsync(fullUrl, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching media composition for URN: {Urn}", urn);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches content using curl command as a fallback.
|
||||
/// </summary>
|
||||
/// <param name="url">The full URL to fetch.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The deserialized media composition.</returns>
|
||||
private async Task<MediaComposition?> FetchWithCurlAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Using curl to fetch: {Url}", url);
|
||||
|
||||
var curlArgs = $"-s -H \"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\"";
|
||||
|
||||
// Add proxy if configured
|
||||
if (_configuration.UseProxy && !string.IsNullOrWhiteSpace(_configuration.ProxyAddress))
|
||||
{
|
||||
curlArgs += $" -x {_configuration.ProxyAddress}";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_configuration.ProxyUsername))
|
||||
{
|
||||
curlArgs += $" -U {_configuration.ProxyUsername}:{_configuration.ProxyPassword}";
|
||||
}
|
||||
|
||||
_logger.LogInformation("curl using proxy: {ProxyAddress}", _configuration.ProxyAddress);
|
||||
}
|
||||
|
||||
curlArgs += $" \"{url}\"";
|
||||
|
||||
var processStartInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "curl",
|
||||
Arguments = curlArgs,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = new System.Diagnostics.Process { StartInfo = processStartInfo };
|
||||
process.Start();
|
||||
|
||||
var output = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
var error = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
_logger.LogError("curl failed with exit code {ExitCode}: {Error}", process.ExitCode, error);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
_logger.LogWarning("curl returned empty response");
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation("curl succeeded, response length: {Length}", output.Length);
|
||||
|
||||
var result = JsonSerializer.Deserialize<MediaComposition>(output, _jsonOptions);
|
||||
|
||||
if (result?.ChapterList != null && result.ChapterList.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Successfully fetched media composition via curl - Chapters: {ChapterCount}", result.ChapterList.Count);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error using curl to fetch URL: {Url}", url);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest videos for a business unit.
|
||||
/// </summary>
|
||||
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The media composition containing latest videos.</returns>
|
||||
public async Task<MediaComposition?> GetLatestVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"/video/{businessUnit}/latest.json";
|
||||
_logger.LogInformation("Fetching latest videos for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Latest videos API response: {StatusCode}", response.StatusCode);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Latest videos response length: {Length}", content.Length);
|
||||
|
||||
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
|
||||
|
||||
_logger.LogInformation("Successfully fetched latest videos for business unit: {BusinessUnit}", businessUnit);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching latest videos for business unit: {BusinessUnit}", businessUnit);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the trending videos for a business unit.
|
||||
/// </summary>
|
||||
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The media composition containing trending videos.</returns>
|
||||
public async Task<MediaComposition?> GetTrendingVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"/video/{businessUnit}/trending.json";
|
||||
_logger.LogInformation("Fetching trending videos for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Trending videos API response: {StatusCode}", response.StatusCode);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Trending videos response length: {Length}", content.Length);
|
||||
|
||||
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
|
||||
|
||||
_logger.LogInformation("Successfully fetched trending videos for business unit: {BusinessUnit}", businessUnit);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching trending videos for business unit: {BusinessUnit}", businessUnit);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets raw JSON response from a URL.
|
||||
/// </summary>
|
||||
/// <param name="url">The relative URL.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The JSON string.</returns>
|
||||
public async Task<string?> GetJsonAsync(string url, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Fetching JSON from URL: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return content;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching JSON from URL: {Url}", url);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all shows from the Play v3 API.
|
||||
/// </summary>
|
||||
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of shows.</returns>
|
||||
public async Task<System.Collections.Generic.List<PlayV3Show>?> GetAllShowsAsync(string businessUnit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
|
||||
var url = $"{baseUrl}shows";
|
||||
_logger.LogInformation("Fetching all shows for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
|
||||
|
||||
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = JsonSerializer.Deserialize<PlayV3DirectResponse<PlayV3Show>>(content, _jsonOptions);
|
||||
|
||||
_logger.LogInformation("Successfully fetched {Count} shows for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, businessUnit);
|
||||
return result?.Data;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching shows for business unit: {BusinessUnit}", businessUnit);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all topics from the Play v3 API.
|
||||
/// </summary>
|
||||
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of topics.</returns>
|
||||
public async Task<System.Collections.Generic.List<PlayV3Topic>?> GetAllTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
|
||||
var url = $"{baseUrl}topics";
|
||||
_logger.LogInformation("Fetching all topics for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
|
||||
|
||||
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = JsonSerializer.Deserialize<PlayV3DirectResponse<PlayV3Topic>>(content, _jsonOptions);
|
||||
|
||||
_logger.LogInformation("Successfully fetched {Count} topics for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, businessUnit);
|
||||
return result?.Data;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching topics for business unit: {BusinessUnit}", businessUnit);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets videos for a specific show from the Play v3 API.
|
||||
/// </summary>
|
||||
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
|
||||
/// <param name="showId">The show ID.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of videos.</returns>
|
||||
public async Task<System.Collections.Generic.List<PlayV3Video>?> GetVideosForShowAsync(string businessUnit, string showId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
|
||||
var url = $"{baseUrl}videos-by-show-id?showId={showId}";
|
||||
_logger.LogDebug("Fetching videos for show {ShowId} from business unit: {BusinessUnit}", showId, businessUnit);
|
||||
|
||||
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = JsonSerializer.Deserialize<PlayV3Response<PlayV3Video>>(content, _jsonOptions);
|
||||
|
||||
_logger.LogDebug("Successfully fetched {Count} videos for show {ShowId}", result?.Data?.Data?.Count ?? 0, showId);
|
||||
return result?.Data?.Data;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching videos for show {ShowId} from business unit: {BusinessUnit}", showId, businessUnit);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the unmanaged resources and optionally releases the managed resources.
|
||||
/// </summary>
|
||||
/// <param name="disposing">True to release both managed and unmanaged resources; false to release only unmanaged resources.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_httpClient?.Dispose();
|
||||
_playV3HttpClient?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,463 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Services;
|
||||
using MediaBrowser.Controller.Channels;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Channels;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// SRF Play channel for browsing and playing content.
|
||||
/// </summary>
|
||||
public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
{
|
||||
private readonly ILogger<SRFPlayChannel> _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ContentRefreshService _contentRefreshService;
|
||||
private readonly StreamUrlResolver _streamResolver;
|
||||
private readonly CategoryService? _categoryService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SRFPlayChannel"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
/// <param name="contentRefreshService">The content refresh service.</param>
|
||||
/// <param name="streamResolver">The stream resolver.</param>
|
||||
/// <param name="categoryService">The category service (optional).</param>
|
||||
public SRFPlayChannel(
|
||||
ILoggerFactory loggerFactory,
|
||||
ContentRefreshService contentRefreshService,
|
||||
StreamUrlResolver streamResolver,
|
||||
CategoryService? categoryService = null)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<SRFPlayChannel>();
|
||||
_contentRefreshService = contentRefreshService;
|
||||
_streamResolver = streamResolver;
|
||||
_categoryService = categoryService;
|
||||
|
||||
if (_categoryService == null)
|
||||
{
|
||||
_logger.LogWarning("CategoryService not available - category folders will be disabled");
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== SRFPlayChannel constructor called! Channel is being instantiated ===");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "SRF Play";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Swiss Radio and Television video-on-demand content";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DataVersion => "1.0";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomePageUrl => "https://www.srf.ch/play";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience;
|
||||
|
||||
/// <inheritdoc />
|
||||
public InternalChannelFeatures GetChannelFeatures()
|
||||
{
|
||||
_logger.LogInformation("=== GetChannelFeatures called for SRF Play channel ===");
|
||||
|
||||
return new InternalChannelFeatures
|
||||
{
|
||||
ContentTypes = new List<ChannelMediaContentType>
|
||||
{
|
||||
ChannelMediaContentType.Episode,
|
||||
ChannelMediaContentType.Movie
|
||||
},
|
||||
MediaTypes = new List<ChannelMediaType>
|
||||
{
|
||||
ChannelMediaType.Video
|
||||
},
|
||||
SupportsSortOrderToggle = false,
|
||||
DefaultSortFields = new List<ChannelItemSortField>
|
||||
{
|
||||
ChannelItemSortField.DateCreated,
|
||||
ChannelItemSortField.Name
|
||||
},
|
||||
MaxPageSize = 50
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
|
||||
{
|
||||
// Could provide a channel logo here
|
||||
return Task.FromResult(new DynamicImageResponse
|
||||
{
|
||||
HasImage = false
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ImageType> GetSupportedChannelImages()
|
||||
{
|
||||
return new List<ImageType>
|
||||
{
|
||||
ImageType.Primary,
|
||||
ImageType.Thumb
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("=== GetChannelItems called! FolderId: {FolderId} ===", query.FolderId);
|
||||
|
||||
var items = new List<ChannelItemInfo>();
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
|
||||
try
|
||||
{
|
||||
// Root level - show categories
|
||||
if (string.IsNullOrEmpty(query.FolderId))
|
||||
{
|
||||
items.Add(new ChannelItemInfo
|
||||
{
|
||||
Id = "latest",
|
||||
Name = "Latest Videos",
|
||||
Type = ChannelItemType.Folder,
|
||||
FolderType = ChannelFolderType.Container,
|
||||
ImageUrl = null
|
||||
});
|
||||
|
||||
items.Add(new ChannelItemInfo
|
||||
{
|
||||
Id = "trending",
|
||||
Name = "Trending Videos",
|
||||
Type = ChannelItemType.Folder,
|
||||
FolderType = ChannelFolderType.Container,
|
||||
ImageUrl = null
|
||||
});
|
||||
|
||||
// Add category folders if enabled and CategoryService is available
|
||||
if (config?.EnableCategoryFolders == true && _categoryService != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
|
||||
var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id)))
|
||||
{
|
||||
// Filter by enabled topics if configured
|
||||
if (config.EnabledTopics != null && config.EnabledTopics.Count > 0 && !config.EnabledTopics.Contains(topic.Id!))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
items.Add(new ChannelItemInfo
|
||||
{
|
||||
Id = $"category_{topic.Id}",
|
||||
Name = topic.Title ?? topic.Id!,
|
||||
Type = ChannelItemType.Folder,
|
||||
FolderType = ChannelFolderType.Container,
|
||||
ImageUrl = null,
|
||||
Overview = topic.Lead
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation("Added {Count} category folders", topics.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load category folders - continuing without categories");
|
||||
}
|
||||
}
|
||||
|
||||
return new ChannelItemResult
|
||||
{
|
||||
Items = items,
|
||||
TotalRecordCount = items.Count
|
||||
};
|
||||
}
|
||||
|
||||
// Latest videos
|
||||
if (query.FolderId == "latest")
|
||||
{
|
||||
var urns = await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Trending videos
|
||||
else if (query.FolderId == "trending")
|
||||
{
|
||||
var urns = await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Category folder - show videos for this category
|
||||
else if (query.FolderId?.StartsWith("category_", StringComparison.Ordinal) == true)
|
||||
{
|
||||
if (_categoryService == null)
|
||||
{
|
||||
_logger.LogWarning("CategoryService not available - cannot display category folder");
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var topicId = query.FolderId.Substring("category_".Length);
|
||||
var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf";
|
||||
|
||||
var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false);
|
||||
var urns = new List<string>();
|
||||
|
||||
using var apiClient = new Api.SRFApiClient(_loggerFactory);
|
||||
|
||||
foreach (var show in shows)
|
||||
{
|
||||
if (show.Id == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id, cancellationToken).ConfigureAwait(false);
|
||||
if (videos != null && videos.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): Found {Count} videos", topicId, show.Title, show.Id, videos.Count);
|
||||
|
||||
// Filter to videos that are actually published and not expired
|
||||
var now = DateTime.UtcNow;
|
||||
var availableVideos = videos.Where(v =>
|
||||
(v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now) &&
|
||||
(v.ValidTo == null || v.ValidTo.Value.ToUniversalTime() > now)).ToList();
|
||||
|
||||
_logger.LogDebug("Category {TopicId}, Show {Show}: {AvailableCount} available out of {TotalCount} videos", topicId, show.Title, availableVideos.Count, videos.Count);
|
||||
|
||||
if (availableVideos.Count > 0)
|
||||
{
|
||||
// Get most recent available video from this show
|
||||
var latestVideo = availableVideos.OrderByDescending(v => v.Date).FirstOrDefault();
|
||||
if (latestVideo?.Urn != null)
|
||||
{
|
||||
urns.Add(latestVideo.Urn);
|
||||
_logger.LogInformation(
|
||||
"Category {TopicId}: Added video from show {Show}: {Title} (URN: {Urn}, Date: {Date}, ValidFrom: {ValidFrom}, ValidTo: {ValidTo})",
|
||||
topicId,
|
||||
show.Title,
|
||||
latestVideo.Title,
|
||||
latestVideo.Urn,
|
||||
latestVideo.Date,
|
||||
latestVideo.ValidFrom,
|
||||
latestVideo.ValidTo);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Category {TopicId}, Show {Show}: Latest available video has null URN", topicId, show.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Category {TopicId}, Show {Show}: No available videos (all expired or not yet published)", topicId, show.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): No videos returned from API", topicId, show.Title, show.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error fetching videos for show {ShowId} in category {TopicId}", show.Id, topicId);
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Found {Count} videos for category {TopicId}", items.Count, topicId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load category videos");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Returning {Count} channel items for folder {FolderId}", items.Count, query.FolderId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting channel items for folder {FolderId}", query.FolderId);
|
||||
}
|
||||
|
||||
return new ChannelItemResult
|
||||
{
|
||||
Items = items,
|
||||
TotalRecordCount = items.Count
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? GetCacheKey(string? userId)
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
var enabledTopics = config?.EnabledTopics != null && config.EnabledTopics.Count > 0
|
||||
? string.Join(",", config.EnabledTopics)
|
||||
: "all";
|
||||
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}";
|
||||
}
|
||||
|
||||
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
|
||||
{
|
||||
var items = new List<ChannelItemInfo>();
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
|
||||
if (config == null)
|
||||
{
|
||||
_logger.LogWarning("Plugin configuration is null");
|
||||
return items;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Converting {Count} URNs to channel items", urns.Count);
|
||||
|
||||
using var apiClient = new Api.SRFApiClient(_loggerFactory);
|
||||
int successCount = 0;
|
||||
int failedCount = 0;
|
||||
int expiredCount = 0;
|
||||
int noStreamCount = 0;
|
||||
|
||||
foreach (var urn in urns.Take(50)) // Limit to 50 items per request
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Processing URN: {Urn}", urn);
|
||||
var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("URN {Urn}: No media composition or chapters found", urn);
|
||||
failedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var chapter = mediaComposition.ChapterList[0];
|
||||
|
||||
// Check if content is expired
|
||||
if (_streamResolver.IsContentExpired(chapter))
|
||||
{
|
||||
_logger.LogDebug("URN {Urn}: Content expired (ValidTo: {ValidTo})", urn, chapter.ValidTo);
|
||||
expiredCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if content has playable streams
|
||||
if (!_streamResolver.HasPlayableContent(chapter))
|
||||
{
|
||||
_logger.LogWarning("URN {Urn}: No playable content (likely DRM protected)", urn);
|
||||
noStreamCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate deterministic GUID from URN
|
||||
var itemId = UrnToGuid(urn);
|
||||
|
||||
var item = new ChannelItemInfo
|
||||
{
|
||||
Id = itemId,
|
||||
Name = chapter.Title,
|
||||
Overview = chapter.Description ?? chapter.Lead,
|
||||
ImageUrl = chapter.ImageUrl,
|
||||
Type = ChannelItemType.Media,
|
||||
ContentType = ChannelMediaContentType.Episode,
|
||||
MediaType = ChannelMediaType.Video,
|
||||
DateCreated = chapter.Date?.ToUniversalTime(),
|
||||
PremiereDate = chapter.Date?.ToUniversalTime(),
|
||||
ProductionYear = chapter.Date?.Year,
|
||||
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
|
||||
ProviderIds = new Dictionary<string, string>
|
||||
{
|
||||
{ "SRF", urn }
|
||||
},
|
||||
MediaSources = new List<MediaSourceInfo>
|
||||
{
|
||||
new MediaSourceInfo
|
||||
{
|
||||
Id = itemId,
|
||||
Name = chapter.Title,
|
||||
Path = _streamResolver.GetStreamUrl(chapter, config.QualityPreference),
|
||||
Protocol = MediaBrowser.Model.MediaInfo.MediaProtocol.Http,
|
||||
Container = "m3u8",
|
||||
SupportsDirectStream = true,
|
||||
SupportsDirectPlay = true,
|
||||
SupportsTranscoding = true,
|
||||
IsRemote = true,
|
||||
Type = MediaBrowser.Model.Dto.MediaSourceType.Default,
|
||||
VideoType = VideoType.VideoFile
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add series info if available
|
||||
if (mediaComposition.Show != null)
|
||||
{
|
||||
item.SeriesName = mediaComposition.Show.Title;
|
||||
}
|
||||
|
||||
items.Add(item);
|
||||
successCount++;
|
||||
_logger.LogInformation("URN {Urn}: Successfully converted to channel item - {Title}", urn, chapter.Title);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error converting URN {Urn} to channel item", urn);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Conversion complete: {Success} successful, {Failed} failed, {Expired} expired, {NoStream} no stream",
|
||||
successCount,
|
||||
failedCount,
|
||||
expiredCount,
|
||||
noStreamCount);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a deterministic GUID from a URN.
|
||||
/// This ensures the same URN always produces the same GUID.
|
||||
/// MD5 is used for non-cryptographic purposes only (generating IDs).
|
||||
/// </summary>
|
||||
/// <param name="urn">The URN to convert.</param>
|
||||
/// <returns>A deterministic GUID.</returns>
|
||||
#pragma warning disable CA5351 // MD5 is used for non-cryptographic purposes (ID generation)
|
||||
private static string UrnToGuid(string urn)
|
||||
{
|
||||
// Use MD5 to generate a deterministic hash from the URN
|
||||
var hash = MD5.HashData(Encoding.UTF8.GetBytes(urn));
|
||||
|
||||
// Convert the first 16 bytes to a GUID
|
||||
var guid = new Guid(hash);
|
||||
return guid.ToString();
|
||||
}
|
||||
#pragma warning restore CA5351
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabledFor(string userId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -1,145 +0,0 @@
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Business unit options for SRF content.
|
||||
/// </summary>
|
||||
public enum BusinessUnit
|
||||
{
|
||||
/// <summary>
|
||||
/// SRF (Swiss Radio and Television - German).
|
||||
/// </summary>
|
||||
SRF,
|
||||
|
||||
/// <summary>
|
||||
/// RTS (Radio Télévision Suisse - French).
|
||||
/// </summary>
|
||||
RTS,
|
||||
|
||||
/// <summary>
|
||||
/// RSI (Radiotelevisione svizzera - Italian).
|
||||
/// </summary>
|
||||
RSI,
|
||||
|
||||
/// <summary>
|
||||
/// RTR (Radiotelevisiun Svizra Rumantscha - Romansh).
|
||||
/// </summary>
|
||||
RTR,
|
||||
|
||||
/// <summary>
|
||||
/// SWI (Swiss World International).
|
||||
/// </summary>
|
||||
SWI
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quality preference for video streams.
|
||||
/// </summary>
|
||||
public enum QualityPreference
|
||||
{
|
||||
/// <summary>
|
||||
/// Automatic quality selection.
|
||||
/// </summary>
|
||||
Auto,
|
||||
|
||||
/// <summary>
|
||||
/// Standard definition.
|
||||
/// </summary>
|
||||
SD,
|
||||
|
||||
/// <summary>
|
||||
/// High definition.
|
||||
/// </summary>
|
||||
HD
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plugin configuration.
|
||||
/// </summary>
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
|
||||
/// </summary>
|
||||
public PluginConfiguration()
|
||||
{
|
||||
// Set default options
|
||||
BusinessUnit = BusinessUnit.SRF;
|
||||
QualityPreference = QualityPreference.Auto;
|
||||
ContentRefreshIntervalHours = 6;
|
||||
ExpirationCheckIntervalHours = 24;
|
||||
CacheDurationMinutes = 60;
|
||||
EnableLatestContent = true;
|
||||
EnableTrendingContent = true;
|
||||
EnableCategoryFolders = true;
|
||||
EnabledTopics = new System.Collections.Generic.List<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the business unit to fetch content from.
|
||||
/// </summary>
|
||||
public BusinessUnit BusinessUnit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the preferred video quality.
|
||||
/// </summary>
|
||||
public QualityPreference QualityPreference { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the content refresh interval in hours.
|
||||
/// </summary>
|
||||
public int ContentRefreshIntervalHours { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the expiration check interval in hours.
|
||||
/// </summary>
|
||||
public int ExpirationCheckIntervalHours { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the metadata cache duration in minutes.
|
||||
/// </summary>
|
||||
public int CacheDurationMinutes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to enable latest content discovery.
|
||||
/// </summary>
|
||||
public bool EnableLatestContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to enable trending content discovery.
|
||||
/// </summary>
|
||||
public bool EnableTrendingContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to use a proxy for API requests.
|
||||
/// </summary>
|
||||
public bool UseProxy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the proxy server address (e.g., http://proxy.example.com:8080).
|
||||
/// </summary>
|
||||
public string ProxyAddress { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the proxy username (optional).
|
||||
/// </summary>
|
||||
public string ProxyUsername { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the proxy password (optional).
|
||||
/// </summary>
|
||||
public string ProxyPassword { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to enable category/topic folders in the channel.
|
||||
/// </summary>
|
||||
public bool EnableCategoryFolders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of enabled topic IDs. If empty, all topics are shown.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for configuration serialization")]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Configuration DTO")]
|
||||
public System.Collections.Generic.List<string> EnabledTopics { get; set; }
|
||||
}
|
||||
@ -1,143 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>SRF Play</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="SRFPlayConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<form id="SRFPlayConfigForm">
|
||||
<div class="selectContainer">
|
||||
<label class="selectLabel" for="BusinessUnit">Business Unit</label>
|
||||
<select is="emby-select" id="BusinessUnit" name="BusinessUnit" class="emby-select-withcolor emby-select">
|
||||
<option id="optSRF" value="SRF">SRF (German)</option>
|
||||
<option id="optRTS" value="RTS">RTS (French)</option>
|
||||
<option id="optRSI" value="RSI">RSI (Italian)</option>
|
||||
<option id="optRTR" value="RTR">RTR (Romansh)</option>
|
||||
<option id="optSWI" value="SWI">SWI (International)</option>
|
||||
</select>
|
||||
<div class="fieldDescription">Select the Swiss broadcasting unit to fetch content from</div>
|
||||
</div>
|
||||
<div class="selectContainer">
|
||||
<label class="selectLabel" for="QualityPreference">Quality Preference</label>
|
||||
<select is="emby-select" id="QualityPreference" name="QualityPreference" class="emby-select-withcolor emby-select">
|
||||
<option id="optAuto" value="Auto">Automatic</option>
|
||||
<option id="optSD" value="SD">Standard Definition</option>
|
||||
<option id="optHD" value="HD">High Definition</option>
|
||||
</select>
|
||||
<div class="fieldDescription">Preferred video quality for playback</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="ContentRefreshIntervalHours">Content Refresh Interval (hours)</label>
|
||||
<input id="ContentRefreshIntervalHours" name="ContentRefreshIntervalHours" type="number" is="emby-input" min="1" max="168" />
|
||||
<div class="fieldDescription">How often to check for new content (1-168 hours)</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="ExpirationCheckIntervalHours">Expiration Check Interval (hours)</label>
|
||||
<input id="ExpirationCheckIntervalHours" name="ExpirationCheckIntervalHours" type="number" is="emby-input" min="1" max="168" />
|
||||
<div class="fieldDescription">How often to check for expired content (1-168 hours)</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="CacheDurationMinutes">Cache Duration (minutes)</label>
|
||||
<input id="CacheDurationMinutes" name="CacheDurationMinutes" type="number" is="emby-input" min="5" max="1440" />
|
||||
<div class="fieldDescription">How long to cache metadata (5-1440 minutes)</div>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="EnableLatestContent" name="EnableLatestContent" type="checkbox" is="emby-checkbox" />
|
||||
<span>Enable Latest Content</span>
|
||||
</label>
|
||||
<div class="fieldDescription">Automatically discover and add latest videos</div>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="EnableTrendingContent" name="EnableTrendingContent" type="checkbox" is="emby-checkbox" />
|
||||
<span>Enable Trending Content</span>
|
||||
</label>
|
||||
<div class="fieldDescription">Automatically discover and add trending videos</div>
|
||||
</div>
|
||||
<br />
|
||||
<h2>Proxy Settings</h2>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="UseProxy" name="UseProxy" type="checkbox" is="emby-checkbox" />
|
||||
<span>Use Proxy</span>
|
||||
</label>
|
||||
<div class="fieldDescription">Route all SRF API requests through a proxy server</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="ProxyAddress">Proxy Address</label>
|
||||
<input id="ProxyAddress" name="ProxyAddress" type="text" is="emby-input" />
|
||||
<div class="fieldDescription">Proxy server address (e.g., http://proxy.example.com:8080 or socks5://proxy.example.com:1080)</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="ProxyUsername">Proxy Username (Optional)</label>
|
||||
<input id="ProxyUsername" name="ProxyUsername" type="text" is="emby-input" autocomplete="off" />
|
||||
<div class="fieldDescription">Username for proxy authentication (leave empty if not required)</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="ProxyPassword">Proxy Password (Optional)</label>
|
||||
<input id="ProxyPassword" name="ProxyPassword" type="password" is="emby-input" autocomplete="off" />
|
||||
<div class="fieldDescription">Password for proxy authentication (leave empty if not required)</div>
|
||||
</div>
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
var SRFPlayConfig = {
|
||||
pluginUniqueId: 'a4b12f86-8c3d-4e9a-b7f2-1d5e6c8a9b4f'
|
||||
};
|
||||
|
||||
document.querySelector('#SRFPlayConfigPage')
|
||||
.addEventListener('pageshow', function() {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
|
||||
document.querySelector('#BusinessUnit').value = config.BusinessUnit;
|
||||
document.querySelector('#QualityPreference').value = config.QualityPreference;
|
||||
document.querySelector('#ContentRefreshIntervalHours').value = config.ContentRefreshIntervalHours;
|
||||
document.querySelector('#ExpirationCheckIntervalHours').value = config.ExpirationCheckIntervalHours;
|
||||
document.querySelector('#CacheDurationMinutes').value = config.CacheDurationMinutes;
|
||||
document.querySelector('#EnableLatestContent').checked = config.EnableLatestContent;
|
||||
document.querySelector('#EnableTrendingContent').checked = config.EnableTrendingContent;
|
||||
document.querySelector('#UseProxy').checked = config.UseProxy || false;
|
||||
document.querySelector('#ProxyAddress').value = config.ProxyAddress || '';
|
||||
document.querySelector('#ProxyUsername').value = config.ProxyUsername || '';
|
||||
document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector('#SRFPlayConfigForm')
|
||||
.addEventListener('submit', function(e) {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
|
||||
config.BusinessUnit = document.querySelector('#BusinessUnit').value;
|
||||
config.QualityPreference = document.querySelector('#QualityPreference').value;
|
||||
config.ContentRefreshIntervalHours = parseInt(document.querySelector('#ContentRefreshIntervalHours').value);
|
||||
config.ExpirationCheckIntervalHours = parseInt(document.querySelector('#ExpirationCheckIntervalHours').value);
|
||||
config.CacheDurationMinutes = parseInt(document.querySelector('#CacheDurationMinutes').value);
|
||||
config.EnableLatestContent = document.querySelector('#EnableLatestContent').checked;
|
||||
config.EnableTrendingContent = document.querySelector('#EnableTrendingContent').checked;
|
||||
config.UseProxy = document.querySelector('#UseProxy').checked;
|
||||
config.ProxyAddress = document.querySelector('#ProxyAddress').value;
|
||||
config.ProxyUsername = document.querySelector('#ProxyUsername').value;
|
||||
config.ProxyPassword = document.querySelector('#ProxyPassword').value;
|
||||
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
});
|
||||
});
|
||||
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,212 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api;
|
||||
using Jellyfin.Plugin.SRFPlay.Services;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Providers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Provides metadata for SRF Play episodes.
|
||||
/// </summary>
|
||||
public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
|
||||
{
|
||||
private readonly ILogger<SRFEpisodeProvider> _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly MetadataCache _metadataCache;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SRFEpisodeProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
/// <param name="httpClientFactory">The HTTP client factory.</param>
|
||||
/// <param name="metadataCache">The metadata cache.</param>
|
||||
public SRFEpisodeProvider(
|
||||
ILoggerFactory loggerFactory,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
MetadataCache metadataCache)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<SRFEpisodeProvider>();
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_metadataCache = metadataCache;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "SRF Play";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<RemoteSearchResult>();
|
||||
|
||||
try
|
||||
{
|
||||
// Check if we have a URN to search with
|
||||
if (searchInfo.ProviderIds.TryGetValue("SRF", out var urn) && !string.IsNullOrEmpty(urn))
|
||||
{
|
||||
_logger.LogDebug("Searching for episode with URN: {Urn}", urn);
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null)
|
||||
{
|
||||
return results;
|
||||
}
|
||||
|
||||
// Try cache first
|
||||
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
|
||||
|
||||
// If not in cache, fetch from API
|
||||
if (mediaComposition == null)
|
||||
{
|
||||
using var apiClient = new SRFApiClient(_loggerFactory);
|
||||
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (mediaComposition != null)
|
||||
{
|
||||
_metadataCache.SetMediaComposition(urn, mediaComposition);
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0)
|
||||
{
|
||||
var chapter = mediaComposition.ChapterList[0];
|
||||
results.Add(new RemoteSearchResult
|
||||
{
|
||||
Name = chapter.Title,
|
||||
Overview = chapter.Description ?? chapter.Lead,
|
||||
ImageUrl = chapter.ImageUrl,
|
||||
SearchProviderName = Name,
|
||||
ProviderIds = new Dictionary<string, string>
|
||||
{
|
||||
{ "SRF", chapter.Urn }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error searching for episode: {Name}", searchInfo.Name);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new MetadataResult<Episode>();
|
||||
|
||||
try
|
||||
{
|
||||
// Check if we have a URN
|
||||
if (!info.ProviderIds.TryGetValue("SRF", out var urn) || string.IsNullOrEmpty(urn))
|
||||
{
|
||||
_logger.LogDebug("No SRF URN found for episode: {Name}", info.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Fetching metadata for episode URN: {Urn}", urn);
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Try cache first
|
||||
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
|
||||
|
||||
// If not in cache, fetch from API
|
||||
if (mediaComposition == null)
|
||||
{
|
||||
using var apiClient = new SRFApiClient(_loggerFactory);
|
||||
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (mediaComposition != null)
|
||||
{
|
||||
_metadataCache.SetMediaComposition(urn, mediaComposition);
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No chapter information found for URN: {Urn}", urn);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get the first chapter (main video)
|
||||
var chapter = mediaComposition.ChapterList[0];
|
||||
|
||||
result.Item = new Episode
|
||||
{
|
||||
Name = chapter.Title,
|
||||
Overview = chapter.Description ?? chapter.Lead,
|
||||
ProviderIds = new Dictionary<string, string>
|
||||
{
|
||||
{ "SRF", chapter.Urn }
|
||||
}
|
||||
};
|
||||
|
||||
// Set episode and season numbers if available
|
||||
if (chapter.EpisodeNumber.HasValue)
|
||||
{
|
||||
result.Item.IndexNumber = chapter.EpisodeNumber;
|
||||
}
|
||||
|
||||
if (chapter.SeasonNumber.HasValue)
|
||||
{
|
||||
result.Item.ParentIndexNumber = chapter.SeasonNumber;
|
||||
}
|
||||
|
||||
// Set premiere date if available
|
||||
if (chapter.Date.HasValue)
|
||||
{
|
||||
result.Item.PremiereDate = chapter.Date;
|
||||
}
|
||||
|
||||
// Set runtime (convert from milliseconds to ticks)
|
||||
if (chapter.Duration > 0)
|
||||
{
|
||||
result.Item.RunTimeTicks = TimeSpan.FromMilliseconds(chapter.Duration).Ticks;
|
||||
}
|
||||
|
||||
// Set series information if available
|
||||
if (mediaComposition.Show != null)
|
||||
{
|
||||
result.Item.SeriesName = mediaComposition.Show.Title;
|
||||
|
||||
// Set series provider ID on the episode
|
||||
if (!string.IsNullOrEmpty(mediaComposition.Show.Urn))
|
||||
{
|
||||
result.Item.SetProviderId("SRF_Series", mediaComposition.Show.Urn);
|
||||
}
|
||||
}
|
||||
|
||||
result.HasMetadata = true;
|
||||
_logger.LogDebug("Successfully fetched metadata for episode: {Title}", chapter.Title);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching metadata for episode: {Name}", info.Name);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Image handling is done by SRFImageProvider");
|
||||
}
|
||||
}
|
||||
@ -1,152 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Providers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Provides images for SRF Play content.
|
||||
/// </summary>
|
||||
public class SRFImageProvider : IRemoteImageProvider, IHasOrder
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<SRFImageProvider> _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SRFImageProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="httpClientFactory">The HTTP client factory.</param>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
public SRFImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<SRFImageProvider>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "SRF Play";
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Order => 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Supports(BaseItem item)
|
||||
{
|
||||
// Support movies and episodes for now
|
||||
return item is MediaBrowser.Controller.Entities.Movies.Movie ||
|
||||
item is MediaBrowser.Controller.Entities.TV.Episode ||
|
||||
item is MediaBrowser.Controller.Entities.TV.Series;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
|
||||
{
|
||||
return new List<ImageType>
|
||||
{
|
||||
ImageType.Primary,
|
||||
ImageType.Backdrop,
|
||||
ImageType.Thumb
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
var list = new List<RemoteImageInfo>();
|
||||
|
||||
try
|
||||
{
|
||||
// Check if item has SRF URN in provider IDs
|
||||
if (!item.ProviderIds.TryGetValue("SRF", out var urn) || string.IsNullOrEmpty(urn))
|
||||
{
|
||||
_logger.LogDebug("No SRF URN found for item: {ItemName}", item.Name);
|
||||
return list;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Fetching images for SRF URN: {Urn}", urn);
|
||||
|
||||
// Fetch media composition to get image URLs
|
||||
using var apiClient = new SRFApiClient(_loggerFactory);
|
||||
var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (mediaComposition == null)
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch media composition for URN: {Urn}", urn);
|
||||
return list;
|
||||
}
|
||||
|
||||
// Extract images from chapters
|
||||
if (mediaComposition.ChapterList != null && mediaComposition.ChapterList.Count > 0)
|
||||
{
|
||||
var chapter = mediaComposition.ChapterList[0];
|
||||
if (!string.IsNullOrEmpty(chapter.ImageUrl))
|
||||
{
|
||||
list.Add(new RemoteImageInfo
|
||||
{
|
||||
Url = chapter.ImageUrl,
|
||||
Type = ImageType.Primary,
|
||||
ProviderName = Name
|
||||
});
|
||||
|
||||
list.Add(new RemoteImageInfo
|
||||
{
|
||||
Url = chapter.ImageUrl,
|
||||
Type = ImageType.Thumb,
|
||||
ProviderName = Name
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Extract images from show
|
||||
if (mediaComposition.Show != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(mediaComposition.Show.ImageUrl))
|
||||
{
|
||||
list.Add(new RemoteImageInfo
|
||||
{
|
||||
Url = mediaComposition.Show.ImageUrl,
|
||||
Type = ImageType.Primary,
|
||||
ProviderName = Name
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(mediaComposition.Show.BannerImageUrl))
|
||||
{
|
||||
list.Add(new RemoteImageInfo
|
||||
{
|
||||
Url = mediaComposition.Show.BannerImageUrl,
|
||||
Type = ImageType.Backdrop,
|
||||
ProviderName = Name
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Found {Count} images for URN: {Urn}", list.Count, urn);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching images for item: {ItemName}", item.Name);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
|
||||
return httpClient.GetAsync(new Uri(url), cancellationToken);
|
||||
}
|
||||
}
|
||||
@ -1,184 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api;
|
||||
using Jellyfin.Plugin.SRFPlay.Services;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Provides media sources (playback URLs) for SRF Play content.
|
||||
/// </summary>
|
||||
public class SRFMediaProvider : IMediaSourceProvider
|
||||
{
|
||||
private readonly ILogger<SRFMediaProvider> _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly MetadataCache _metadataCache;
|
||||
private readonly StreamUrlResolver _streamResolver;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SRFMediaProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
/// <param name="metadataCache">The metadata cache.</param>
|
||||
/// <param name="streamResolver">The stream URL resolver.</param>
|
||||
public SRFMediaProvider(
|
||||
ILoggerFactory loggerFactory,
|
||||
MetadataCache metadataCache,
|
||||
StreamUrlResolver streamResolver)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<SRFMediaProvider>();
|
||||
_metadataCache = metadataCache;
|
||||
_streamResolver = streamResolver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the provider name.
|
||||
/// </summary>
|
||||
public string Name => "SRF Play";
|
||||
|
||||
/// <summary>
|
||||
/// Gets media sources for the specified item.
|
||||
/// </summary>
|
||||
/// <param name="item">The item.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of media sources.</returns>
|
||||
public Task<IEnumerable<MediaSourceInfo>> GetMediaSources(BaseItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
var sources = new List<MediaSourceInfo>();
|
||||
|
||||
try
|
||||
{
|
||||
// Check if this is an SRF item
|
||||
if (!item.ProviderIds.TryGetValue("SRF", out var urn) || string.IsNullOrEmpty(urn))
|
||||
{
|
||||
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Getting media sources for URN: {Urn}", urn);
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
|
||||
}
|
||||
|
||||
// Try cache first
|
||||
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
|
||||
|
||||
// If not in cache, fetch from API
|
||||
if (mediaComposition == null)
|
||||
{
|
||||
using var apiClient = new SRFApiClient(_loggerFactory);
|
||||
mediaComposition = apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).GetAwaiter().GetResult();
|
||||
|
||||
if (mediaComposition != null)
|
||||
{
|
||||
_metadataCache.SetMediaComposition(urn, mediaComposition);
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No chapters found for URN: {Urn}", urn);
|
||||
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
|
||||
}
|
||||
|
||||
// Get the first chapter (main video)
|
||||
var chapter = mediaComposition.ChapterList[0];
|
||||
|
||||
// Check if content is expired
|
||||
if (_streamResolver.IsContentExpired(chapter))
|
||||
{
|
||||
_logger.LogWarning("Content expired for URN: {Urn}, ValidTo: {ValidTo}", urn, chapter.ValidTo);
|
||||
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
|
||||
}
|
||||
|
||||
// Check if content has playable streams
|
||||
if (!_streamResolver.HasPlayableContent(chapter))
|
||||
{
|
||||
_logger.LogWarning("No playable content found for URN: {Urn}", urn);
|
||||
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
|
||||
}
|
||||
|
||||
// Get stream URL based on quality preference
|
||||
var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference);
|
||||
|
||||
if (string.IsNullOrEmpty(streamUrl))
|
||||
{
|
||||
_logger.LogWarning("Could not resolve stream URL for URN: {Urn}", urn);
|
||||
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
|
||||
}
|
||||
|
||||
// Create media source
|
||||
var mediaSource = new MediaSourceInfo
|
||||
{
|
||||
Id = urn,
|
||||
Name = chapter.Title,
|
||||
Path = streamUrl,
|
||||
Protocol = MediaProtocol.Http,
|
||||
Container = "m3u8",
|
||||
SupportsDirectStream = true,
|
||||
SupportsDirectPlay = true,
|
||||
SupportsTranscoding = true,
|
||||
IsRemote = true,
|
||||
Type = MediaSourceType.Default,
|
||||
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
|
||||
VideoType = VideoType.VideoFile,
|
||||
IsInfiniteStream = false,
|
||||
RequiresOpening = false,
|
||||
RequiresClosing = false,
|
||||
SupportsProbing = true
|
||||
};
|
||||
|
||||
// Add video stream info
|
||||
mediaSource.MediaStreams = new List<MediaStream>
|
||||
{
|
||||
new MediaStream
|
||||
{
|
||||
Type = MediaStreamType.Video,
|
||||
Codec = "h264",
|
||||
IsInterlaced = false,
|
||||
IsDefault = true
|
||||
}
|
||||
};
|
||||
|
||||
sources.Add(mediaSource);
|
||||
_logger.LogInformation("Resolved stream URL for {Title}: {Url}", chapter.Title, streamUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting media sources for item: {Name}", item.Name);
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets direct stream provider by unique ID.
|
||||
/// </summary>
|
||||
/// <param name="uniqueId">The unique ID.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The direct stream provider.</returns>
|
||||
public Task<IDirectStreamProvider?> GetDirectStreamProviderByUniqueId(string uniqueId, CancellationToken cancellationToken)
|
||||
{
|
||||
// Not needed for HTTP streams
|
||||
return Task.FromResult<IDirectStreamProvider?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ILiveStream> OpenMediaSource(string openToken, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
|
||||
{
|
||||
// Not needed for static HTTP streams
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@ -1,189 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api;
|
||||
using Jellyfin.Plugin.SRFPlay.Services;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Providers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Provides metadata for SRF Play series/shows.
|
||||
/// </summary>
|
||||
public class SRFSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>
|
||||
{
|
||||
private readonly ILogger<SRFSeriesProvider> _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly MetadataCache _metadataCache;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SRFSeriesProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
/// <param name="httpClientFactory">The HTTP client factory.</param>
|
||||
/// <param name="metadataCache">The metadata cache.</param>
|
||||
public SRFSeriesProvider(
|
||||
ILogger<SRFSeriesProvider> logger,
|
||||
ILoggerFactory loggerFactory,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
MetadataCache metadataCache)
|
||||
{
|
||||
_logger = logger;
|
||||
_loggerFactory = loggerFactory;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_metadataCache = metadataCache;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "SRF Play";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<RemoteSearchResult>();
|
||||
|
||||
try
|
||||
{
|
||||
// Check if we have a URN to search with
|
||||
if (searchInfo.ProviderIds.TryGetValue("SRF", out var urn) && !string.IsNullOrEmpty(urn))
|
||||
{
|
||||
_logger.LogDebug("Searching for series with URN: {Urn}", urn);
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null)
|
||||
{
|
||||
return results;
|
||||
}
|
||||
|
||||
// Try cache first
|
||||
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
|
||||
|
||||
// If not in cache, fetch from API
|
||||
if (mediaComposition == null)
|
||||
{
|
||||
using var apiClient = new SRFApiClient(_loggerFactory);
|
||||
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (mediaComposition != null)
|
||||
{
|
||||
_metadataCache.SetMediaComposition(urn, mediaComposition);
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaComposition?.Show != null)
|
||||
{
|
||||
var show = mediaComposition.Show;
|
||||
results.Add(new RemoteSearchResult
|
||||
{
|
||||
Name = show.Title,
|
||||
Overview = show.Description ?? show.Lead,
|
||||
ImageUrl = show.ImageUrl,
|
||||
SearchProviderName = Name,
|
||||
ProviderIds = new Dictionary<string, string>
|
||||
{
|
||||
{ "SRF", show.Urn ?? urn }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(searchInfo.Name))
|
||||
{
|
||||
_logger.LogDebug("Name-based search not yet implemented for: {Name}", searchInfo.Name);
|
||||
// TODO: Implement name-based search when SRF provides search API
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error searching for series: {Name}", searchInfo.Name);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new MetadataResult<Series>();
|
||||
|
||||
try
|
||||
{
|
||||
// Check if we have a URN
|
||||
if (!info.ProviderIds.TryGetValue("SRF", out var urn) || string.IsNullOrEmpty(urn))
|
||||
{
|
||||
_logger.LogDebug("No SRF URN found for series: {Name}", info.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Fetching metadata for series URN: {Urn}", urn);
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Try cache first
|
||||
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
|
||||
|
||||
// If not in cache, fetch from API
|
||||
if (mediaComposition == null)
|
||||
{
|
||||
using var apiClient = new SRFApiClient(_loggerFactory);
|
||||
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (mediaComposition != null)
|
||||
{
|
||||
_metadataCache.SetMediaComposition(urn, mediaComposition);
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaComposition?.Show == null)
|
||||
{
|
||||
_logger.LogWarning("No show information found for URN: {Urn}", urn);
|
||||
return result;
|
||||
}
|
||||
|
||||
var show = mediaComposition.Show;
|
||||
|
||||
result.Item = new Series
|
||||
{
|
||||
Name = show.Title,
|
||||
Overview = show.Description ?? show.Lead,
|
||||
ProviderIds = new Dictionary<string, string>
|
||||
{
|
||||
{ "SRF", show.Urn ?? urn }
|
||||
}
|
||||
};
|
||||
|
||||
// Set additional metadata if available
|
||||
if (!string.IsNullOrEmpty(show.Vendor))
|
||||
{
|
||||
result.Item.Studios = new[] { show.Vendor };
|
||||
}
|
||||
|
||||
result.HasMetadata = true;
|
||||
_logger.LogDebug("Successfully fetched metadata for series: {Title}", show.Title);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching metadata for series: {Name}", info.Name);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Image handling is done by SRFImageProvider");
|
||||
}
|
||||
}
|
||||
@ -1,108 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Services;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks;
|
||||
|
||||
/// <summary>
|
||||
/// Scheduled task for refreshing SRF Play content.
|
||||
/// </summary>
|
||||
public class ContentRefreshTask : IScheduledTask
|
||||
{
|
||||
private readonly ILogger<ContentRefreshTask> _logger;
|
||||
private readonly ContentRefreshService _contentRefreshService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ContentRefreshTask"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
/// <param name="contentRefreshService">The content refresh service.</param>
|
||||
public ContentRefreshTask(
|
||||
ILogger<ContentRefreshTask> logger,
|
||||
ContentRefreshService contentRefreshService)
|
||||
{
|
||||
_logger = logger;
|
||||
_contentRefreshService = contentRefreshService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Refresh SRF Play Content";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Refreshes latest and trending content from SRF Play";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Category => "SRF Play";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => "SRFPlayContentRefresh";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting SRF Play content refresh task");
|
||||
progress?.Report(0);
|
||||
|
||||
try
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null)
|
||||
{
|
||||
_logger.LogWarning("Plugin configuration not available");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.EnableLatestContent && !config.EnableTrendingContent)
|
||||
{
|
||||
_logger.LogInformation("Content refresh is disabled in configuration");
|
||||
progress?.Report(100);
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh latest content
|
||||
if (config.EnableLatestContent)
|
||||
{
|
||||
_logger.LogInformation("Refreshing latest content");
|
||||
progress?.Report(25);
|
||||
await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Refresh trending content
|
||||
if (config.EnableTrendingContent)
|
||||
{
|
||||
_logger.LogInformation("Refreshing trending content");
|
||||
progress?.Report(75);
|
||||
await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
progress?.Report(100);
|
||||
_logger.LogInformation("SRF Play content refresh task completed successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during SRF Play content refresh task");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
var intervalHours = config?.ContentRefreshIntervalHours ?? 6;
|
||||
|
||||
// Run every X hours as configured
|
||||
return new[]
|
||||
{
|
||||
new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfo.TriggerInterval,
|
||||
IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,111 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Services;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks;
|
||||
|
||||
/// <summary>
|
||||
/// Scheduled task for checking and removing expired SRF Play content.
|
||||
/// </summary>
|
||||
public class ExpirationCheckTask : IScheduledTask
|
||||
{
|
||||
private readonly ILogger<ExpirationCheckTask> _logger;
|
||||
private readonly ContentExpirationService _expirationService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ExpirationCheckTask"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
/// <param name="expirationService">The content expiration service.</param>
|
||||
public ExpirationCheckTask(
|
||||
ILogger<ExpirationCheckTask> logger,
|
||||
ContentExpirationService expirationService)
|
||||
{
|
||||
_logger = logger;
|
||||
_expirationService = expirationService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Check SRF Play Content Expiration";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Checks for expired SRF Play content and removes it from the library";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Category => "SRF Play";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => "SRFPlayExpirationCheck";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting SRF Play expiration check task");
|
||||
progress?.Report(0);
|
||||
|
||||
try
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null)
|
||||
{
|
||||
_logger.LogWarning("Plugin configuration not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get expiration statistics first
|
||||
progress?.Report(25);
|
||||
var (total, expired, expiringSoon) = await _expirationService.GetExpirationStatisticsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Expiration statistics - Total: {Total}, Expired: {Expired}, Expiring Soon: {ExpiringSoon}",
|
||||
total,
|
||||
expired,
|
||||
expiringSoon);
|
||||
|
||||
if (expired == 0)
|
||||
{
|
||||
_logger.LogInformation("No expired content found");
|
||||
progress?.Report(100);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove expired content
|
||||
progress?.Report(50);
|
||||
var removedCount = await _expirationService.CheckAndRemoveExpiredContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
progress?.Report(100);
|
||||
_logger.LogInformation("SRF Play expiration check task completed. Removed {Count} expired items", removedCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during SRF Play expiration check task");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
var intervalHours = config?.ExpirationCheckIntervalHours ?? 24;
|
||||
|
||||
// Run daily at 3 AM or every X hours as configured
|
||||
return new[]
|
||||
{
|
||||
new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfo.TriggerDaily,
|
||||
TimeOfDayTicks = TimeSpan.FromHours(3).Ticks
|
||||
},
|
||||
new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfo.TriggerInterval,
|
||||
IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
using Jellyfin.Plugin.SRFPlay.Channels;
|
||||
using Jellyfin.Plugin.SRFPlay.Providers;
|
||||
using Jellyfin.Plugin.SRFPlay.ScheduledTasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Services;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Channels;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay;
|
||||
|
||||
/// <summary>
|
||||
/// Service registrator for dependency injection.
|
||||
/// </summary>
|
||||
public class ServiceRegistrator : IPluginServiceRegistrator
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||
{
|
||||
// Register services as singletons
|
||||
serviceCollection.AddSingleton<MetadataCache>();
|
||||
serviceCollection.AddSingleton<StreamUrlResolver>();
|
||||
serviceCollection.AddSingleton<ContentExpirationService>();
|
||||
serviceCollection.AddSingleton<ContentRefreshService>();
|
||||
serviceCollection.AddSingleton<CategoryService>();
|
||||
|
||||
// Register metadata providers
|
||||
serviceCollection.AddSingleton<SRFSeriesProvider>();
|
||||
serviceCollection.AddSingleton<SRFEpisodeProvider>();
|
||||
serviceCollection.AddSingleton<SRFImageProvider>();
|
||||
|
||||
// Register media source provider
|
||||
serviceCollection.AddSingleton<SRFMediaProvider>();
|
||||
|
||||
// Register scheduled tasks
|
||||
serviceCollection.AddSingleton<IScheduledTask, ContentRefreshTask>();
|
||||
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
|
||||
|
||||
// Register channel - must register as IChannel interface for Jellyfin to discover it
|
||||
serviceCollection.AddSingleton<IChannel, SRFPlayChannel>();
|
||||
}
|
||||
}
|
||||
@ -1,206 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing topic/category data and filtering.
|
||||
/// </summary>
|
||||
public class CategoryService
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly TimeSpan _topicsCacheDuration = TimeSpan.FromHours(24);
|
||||
private Dictionary<string, PlayV3Topic>? _topicsCache;
|
||||
private DateTime _topicsCacheExpiry = DateTime.MinValue;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CategoryService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
public CategoryService(ILoggerFactory loggerFactory)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<CategoryService>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all topics for a business unit.
|
||||
/// </summary>
|
||||
/// <param name="businessUnit">The business unit.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of topics.</returns>
|
||||
public async Task<List<PlayV3Topic>> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Return cached topics if still valid
|
||||
if (_topicsCache != null && DateTime.UtcNow < _topicsCacheExpiry)
|
||||
{
|
||||
_logger.LogDebug("Returning cached topics for business unit: {BusinessUnit}", businessUnit);
|
||||
return _topicsCache.Values.ToList();
|
||||
}
|
||||
|
||||
_logger.LogInformation("Fetching topics for business unit: {BusinessUnit}", businessUnit);
|
||||
using var apiClient = new SRFApiClient(_loggerFactory);
|
||||
var topics = await apiClient.GetAllTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (topics != null && topics.Count > 0)
|
||||
{
|
||||
// Cache topics by ID for quick lookups
|
||||
_topicsCache = topics
|
||||
.Where(t => !string.IsNullOrEmpty(t.Id))
|
||||
.ToDictionary(t => t.Id!, t => t);
|
||||
_topicsCacheExpiry = DateTime.UtcNow.Add(_topicsCacheDuration);
|
||||
|
||||
_logger.LogInformation("Cached {Count} topics for business unit: {BusinessUnit}", _topicsCache.Count, businessUnit);
|
||||
}
|
||||
|
||||
return topics ?? new List<PlayV3Topic>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a topic by ID.
|
||||
/// </summary>
|
||||
/// <param name="topicId">The topic ID.</param>
|
||||
/// <param name="businessUnit">The business unit.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The topic, or null if not found.</returns>
|
||||
public async Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Ensure topics are loaded
|
||||
if (_topicsCache == null || DateTime.UtcNow >= _topicsCacheExpiry)
|
||||
{
|
||||
await GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return _topicsCache?.GetValueOrDefault(topicId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters shows by topic ID.
|
||||
/// </summary>
|
||||
/// <param name="shows">The shows to filter.</param>
|
||||
/// <param name="topicId">The topic ID to filter by.</param>
|
||||
/// <returns>Filtered list of shows.</returns>
|
||||
public IReadOnlyList<PlayV3Show> FilterShowsByTopic(IReadOnlyList<PlayV3Show> shows, string topicId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(topicId))
|
||||
{
|
||||
return shows;
|
||||
}
|
||||
|
||||
return shows
|
||||
.Where(s => s.TopicList != null && s.TopicList.Contains(topicId))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups shows by their topics.
|
||||
/// </summary>
|
||||
/// <param name="shows">The shows to group.</param>
|
||||
/// <returns>Dictionary mapping topic IDs to shows.</returns>
|
||||
public IReadOnlyDictionary<string, List<PlayV3Show>> GroupShowsByTopics(IReadOnlyList<PlayV3Show> shows)
|
||||
{
|
||||
var groupedShows = new Dictionary<string, List<PlayV3Show>>();
|
||||
|
||||
foreach (var show in shows)
|
||||
{
|
||||
if (show.TopicList == null || show.TopicList.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var topicId in show.TopicList)
|
||||
{
|
||||
if (!groupedShows.TryGetValue(topicId, out var showList))
|
||||
{
|
||||
showList = new List<PlayV3Show>();
|
||||
groupedShows[topicId] = showList;
|
||||
}
|
||||
|
||||
showList.Add(show);
|
||||
}
|
||||
}
|
||||
|
||||
return groupedShows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets shows for a specific topic, sorted by number of episodes.
|
||||
/// </summary>
|
||||
/// <param name="topicId">The topic ID.</param>
|
||||
/// <param name="businessUnit">The business unit.</param>
|
||||
/// <param name="maxResults">Maximum number of results to return.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of shows for the topic.</returns>
|
||||
public async Task<List<PlayV3Show>> GetShowsByTopicAsync(
|
||||
string topicId,
|
||||
string businessUnit,
|
||||
int maxResults = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var apiClient = new SRFApiClient(_loggerFactory);
|
||||
var allShows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (allShows == null || allShows.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No shows available for business unit: {BusinessUnit}", businessUnit);
|
||||
return new List<PlayV3Show>();
|
||||
}
|
||||
|
||||
var filteredShows = FilterShowsByTopic(allShows, topicId)
|
||||
.Where(s => s.NumberOfEpisodes > 0)
|
||||
.OrderByDescending(s => s.NumberOfEpisodes)
|
||||
.Take(maxResults)
|
||||
.ToList();
|
||||
|
||||
_logger.LogDebug("Found {Count} shows for topic {TopicId}", filteredShows.Count, topicId);
|
||||
return filteredShows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets video count for each topic.
|
||||
/// </summary>
|
||||
/// <param name="shows">The shows to analyze.</param>
|
||||
/// <returns>Dictionary mapping topic IDs to video counts.</returns>
|
||||
public IReadOnlyDictionary<string, int> GetVideoCountByTopic(IReadOnlyList<PlayV3Show> shows)
|
||||
{
|
||||
var topicCounts = new Dictionary<string, int>();
|
||||
|
||||
foreach (var show in shows)
|
||||
{
|
||||
if (show.TopicList == null || show.TopicList.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var topicId in show.TopicList)
|
||||
{
|
||||
if (!topicCounts.TryGetValue(topicId, out var count))
|
||||
{
|
||||
count = 0;
|
||||
}
|
||||
|
||||
topicCounts[topicId] = count + show.NumberOfEpisodes;
|
||||
}
|
||||
}
|
||||
|
||||
return topicCounts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the topics cache.
|
||||
/// </summary>
|
||||
public void ClearCache()
|
||||
{
|
||||
_topicsCache = null;
|
||||
_topicsCacheExpiry = DateTime.MinValue;
|
||||
_logger.LogInformation("Topics cache cleared");
|
||||
}
|
||||
}
|
||||
@ -1,245 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing content expiration.
|
||||
/// </summary>
|
||||
public class ContentExpirationService
|
||||
{
|
||||
private readonly ILogger<ContentExpirationService> _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly StreamUrlResolver _streamResolver;
|
||||
private readonly MetadataCache _metadataCache;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ContentExpirationService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="streamResolver">The stream URL resolver.</param>
|
||||
/// <param name="metadataCache">The metadata cache.</param>
|
||||
public ContentExpirationService(
|
||||
ILoggerFactory loggerFactory,
|
||||
ILibraryManager libraryManager,
|
||||
StreamUrlResolver streamResolver,
|
||||
MetadataCache metadataCache)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<ContentExpirationService>();
|
||||
_libraryManager = libraryManager;
|
||||
_streamResolver = streamResolver;
|
||||
_metadataCache = metadataCache;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks for expired content and removes it from the library.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The number of items removed.</returns>
|
||||
public async Task<int> CheckAndRemoveExpiredContentAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting content expiration check");
|
||||
var removedCount = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// Get all items with SRF provider ID
|
||||
var query = new InternalItemsQuery
|
||||
{
|
||||
HasAnyProviderId = new Dictionary<string, string> { { "SRF", string.Empty } },
|
||||
IsVirtualItem = false
|
||||
};
|
||||
|
||||
var items = _libraryManager.GetItemList(query);
|
||||
_logger.LogDebug("Found {Count} SRF items to check for expiration", items.Count);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (await IsItemExpiredAsync(item, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
_logger.LogInformation("Removing expired item: {Name} (URN: {Urn})", item.Name, item.ProviderIds.GetValueOrDefault("SRF"));
|
||||
|
||||
// Delete the item from library
|
||||
_libraryManager.DeleteItem(
|
||||
item,
|
||||
new DeleteOptions
|
||||
{
|
||||
DeleteFileLocation = false
|
||||
},
|
||||
false);
|
||||
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking expiration for item: {Name}", item.Name);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Content expiration check completed. Removed {Count} expired items", removedCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during content expiration check");
|
||||
}
|
||||
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an item is expired.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to check.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>True if the item is expired.</returns>
|
||||
private async Task<bool> IsItemExpiredAsync(BaseItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
var urn = item.ProviderIds.GetValueOrDefault("SRF");
|
||||
if (string.IsNullOrEmpty(urn))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try cache first
|
||||
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
|
||||
|
||||
// If not in cache, fetch from API
|
||||
if (mediaComposition == null)
|
||||
{
|
||||
using var apiClient = new SRFApiClient(_loggerFactory);
|
||||
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (mediaComposition != null)
|
||||
{
|
||||
_metadataCache.SetMediaComposition(urn, mediaComposition);
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
|
||||
{
|
||||
// If we can't fetch the content, consider it expired
|
||||
_logger.LogWarning("Could not fetch media composition for URN: {Urn}, treating as expired", urn);
|
||||
return true;
|
||||
}
|
||||
|
||||
var chapter = mediaComposition.ChapterList[0];
|
||||
var isExpired = _streamResolver.IsContentExpired(chapter);
|
||||
|
||||
if (isExpired)
|
||||
{
|
||||
_logger.LogDebug("Item {Name} is expired (ValidTo: {ValidTo})", item.Name, chapter.ValidTo);
|
||||
}
|
||||
|
||||
return isExpired;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets statistics about content expiration.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Tuple with total count, expired count, and items expiring soon.</returns>
|
||||
public async Task<(int Total, int Expired, int ExpiringSoon)> GetExpirationStatisticsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var total = 0;
|
||||
var expired = 0;
|
||||
var expiringSoon = 0;
|
||||
var soonThreshold = DateTime.UtcNow.AddDays(7); // Items expiring within 7 days
|
||||
|
||||
try
|
||||
{
|
||||
var query = new InternalItemsQuery
|
||||
{
|
||||
HasAnyProviderId = new Dictionary<string, string> { { "SRF", string.Empty } },
|
||||
IsVirtualItem = false
|
||||
};
|
||||
|
||||
var items = _libraryManager.GetItemList(query);
|
||||
total = items.Count;
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var urn = item.ProviderIds.GetValueOrDefault("SRF");
|
||||
if (string.IsNullOrEmpty(urn))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
|
||||
|
||||
if (mediaComposition == null)
|
||||
{
|
||||
using var apiClient = new SRFApiClient(_loggerFactory);
|
||||
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (mediaComposition != null)
|
||||
{
|
||||
_metadataCache.SetMediaComposition(urn, mediaComposition);
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0)
|
||||
{
|
||||
var chapter = mediaComposition.ChapterList[0];
|
||||
|
||||
if (_streamResolver.IsContentExpired(chapter))
|
||||
{
|
||||
expired++;
|
||||
}
|
||||
else if (chapter.ValidTo.HasValue && chapter.ValidTo.Value.ToUniversalTime() <= soonThreshold)
|
||||
{
|
||||
expiringSoon++;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking expiration statistics for item: {Name}", item.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting expiration statistics");
|
||||
}
|
||||
|
||||
return (total, expired, expiringSoon);
|
||||
}
|
||||
}
|
||||
@ -1,306 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for refreshing content from SRF API.
|
||||
/// </summary>
|
||||
public class ContentRefreshService
|
||||
{
|
||||
private readonly ILogger<ContentRefreshService> _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly MetadataCache _metadataCache;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ContentRefreshService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
/// <param name="metadataCache">The metadata cache.</param>
|
||||
public ContentRefreshService(
|
||||
ILoggerFactory loggerFactory,
|
||||
MetadataCache metadataCache)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<ContentRefreshService>();
|
||||
_metadataCache = metadataCache;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes latest content from SRF API using Play v3.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of URNs for new content.</returns>
|
||||
public async Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var urns = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null || !config.EnableLatestContent)
|
||||
{
|
||||
_logger.LogDebug("Latest content refresh is disabled");
|
||||
return urns;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Refreshing latest content for business unit: {BusinessUnit}", config.BusinessUnit);
|
||||
|
||||
using var apiClient = new SRFApiClient(_loggerFactory);
|
||||
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
|
||||
|
||||
// Get all shows from Play v3 API
|
||||
var shows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (shows == null || shows.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No shows found for business unit: {BusinessUnit}", config.BusinessUnit);
|
||||
return urns;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {Count} shows, fetching latest episodes from each", shows.Count);
|
||||
|
||||
// Get latest episodes from each show (limit to 20 shows to avoid overwhelming)
|
||||
var showsToFetch = shows.Where(s => s.NumberOfEpisodes > 0)
|
||||
.OrderByDescending(s => s.NumberOfEpisodes)
|
||||
.Take(20)
|
||||
.ToList();
|
||||
|
||||
foreach (var show in showsToFetch)
|
||||
{
|
||||
if (show.Id == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id, cancellationToken).ConfigureAwait(false);
|
||||
if (videos != null && videos.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Show {Show} ({ShowId}): Found {Count} videos", show.Title, show.Id, videos.Count);
|
||||
|
||||
// Filter to videos that are actually published (validFrom in the past)
|
||||
var now = DateTime.UtcNow;
|
||||
var publishedVideos = videos.Where(v =>
|
||||
v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now).ToList();
|
||||
|
||||
_logger.LogDebug("Show {Show}: {PublishedCount} published out of {TotalCount} videos", show.Title, publishedVideos.Count, videos.Count);
|
||||
|
||||
if (publishedVideos.Count > 0)
|
||||
{
|
||||
// Take only the most recent published video from each show
|
||||
var latestVideo = publishedVideos.OrderByDescending(v => v.Date).FirstOrDefault();
|
||||
if (latestVideo?.Urn != null)
|
||||
{
|
||||
urns.Add(latestVideo.Urn);
|
||||
_logger.LogInformation(
|
||||
"Added latest video from show {Show}: {Title} (URN: {Urn}, Date: {Date}, ValidFrom: {ValidFrom}, ValidTo: {ValidTo})",
|
||||
show.Title,
|
||||
latestVideo.Title,
|
||||
latestVideo.Urn,
|
||||
latestVideo.Date,
|
||||
latestVideo.ValidFrom,
|
||||
latestVideo.ValidTo);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Show {Show}: Latest video has null URN", show.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Show {Show} has no published videos yet", show.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Show {Show} ({ShowId}): No videos returned from API", show.Title, show.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error fetching videos for show {ShowId}", show.Id);
|
||||
}
|
||||
|
||||
// Respect cancellation
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Refreshed {Count} latest content items from {ShowCount} shows", urns.Count, showsToFetch.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error refreshing latest content");
|
||||
}
|
||||
|
||||
return urns;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes trending content from SRF API using Play v3.
|
||||
/// Gets videos from shows with the most episodes.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of URNs for trending content.</returns>
|
||||
public async Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var urns = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null || !config.EnableTrendingContent)
|
||||
{
|
||||
_logger.LogDebug("Trending content refresh is disabled");
|
||||
return urns;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Refreshing trending content for business unit: {BusinessUnit}", config.BusinessUnit);
|
||||
|
||||
using var apiClient = new SRFApiClient(_loggerFactory);
|
||||
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
|
||||
|
||||
// Get all shows from Play v3 API
|
||||
var shows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (shows == null || shows.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No shows found for business unit: {BusinessUnit}", config.BusinessUnit);
|
||||
return urns;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {Count} shows, fetching popular content", shows.Count);
|
||||
|
||||
// Get videos from popular shows (those with many episodes)
|
||||
var popularShows = shows.Where(s => s.NumberOfEpisodes > 10)
|
||||
.OrderByDescending(s => s.NumberOfEpisodes)
|
||||
.Take(15)
|
||||
.ToList();
|
||||
|
||||
foreach (var show in popularShows)
|
||||
{
|
||||
if (show.Id == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id, cancellationToken).ConfigureAwait(false);
|
||||
if (videos != null && videos.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Show {Show} ({ShowId}): Found {Count} videos for trending", show.Title, show.Id, videos.Count);
|
||||
|
||||
// Filter to videos that are actually published (validFrom in the past)
|
||||
var now = DateTime.UtcNow;
|
||||
var publishedVideos = videos.Where(v =>
|
||||
v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now).ToList();
|
||||
|
||||
_logger.LogDebug("Show {Show}: {PublishedCount} published out of {TotalCount} videos for trending", show.Title, publishedVideos.Count, videos.Count);
|
||||
|
||||
if (publishedVideos.Count > 0)
|
||||
{
|
||||
// Take 2 recent published videos from each popular show
|
||||
var recentVideos = publishedVideos.OrderByDescending(v => v.Date).Take(2);
|
||||
foreach (var video in recentVideos)
|
||||
{
|
||||
if (video.Urn != null)
|
||||
{
|
||||
urns.Add(video.Urn);
|
||||
_logger.LogInformation(
|
||||
"Added trending video from show {Show}: {Title} (URN: {Urn}, Date: {Date}, ValidFrom: {ValidFrom}, ValidTo: {ValidTo})",
|
||||
show.Title,
|
||||
video.Title,
|
||||
video.Urn,
|
||||
video.Date,
|
||||
video.ValidFrom,
|
||||
video.ValidTo);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Show {Show}: Trending video has null URN - {Title}", show.Title, video.Title);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Show {Show} ({ShowId}): No videos returned from API for trending", show.Title, show.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error fetching videos for show {ShowId}", show.Id);
|
||||
}
|
||||
|
||||
// Respect cancellation
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Refreshed {Count} trending content items from {ShowCount} shows", urns.Count, popularShows.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error refreshing trending content");
|
||||
}
|
||||
|
||||
return urns;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes all content (latest and trending).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Tuple with counts of latest and trending items.</returns>
|
||||
public async Task<(int LatestCount, int TrendingCount)> RefreshAllContentAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting full content refresh");
|
||||
|
||||
var latestUrns = await RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
var trendingUrns = await RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var latestCount = latestUrns.Count;
|
||||
var trendingCount = trendingUrns.Count;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Content refresh completed. Latest: {LatestCount}, Trending: {TrendingCount}",
|
||||
latestCount,
|
||||
trendingCount);
|
||||
|
||||
return (latestCount, trendingCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets content recommendations (combines latest and trending).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of recommended URNs.</returns>
|
||||
public async Task<List<string>> GetRecommendedContentAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var recommendations = new HashSet<string>();
|
||||
|
||||
var latestUrns = await RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
var trendingUrns = await RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var urn in latestUrns.Concat(trendingUrns))
|
||||
{
|
||||
recommendations.Add(urn);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Generated {Count} content recommendations", recommendations.Count);
|
||||
return recommendations.ToList();
|
||||
}
|
||||
}
|
||||
@ -1,236 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for caching metadata from SRF API.
|
||||
/// </summary>
|
||||
public sealed class MetadataCache : IDisposable
|
||||
{
|
||||
private readonly ILogger<MetadataCache> _logger;
|
||||
private readonly ConcurrentDictionary<string, CacheEntry<MediaComposition>> _mediaCompositionCache;
|
||||
private readonly ReaderWriterLockSlim _lock;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MetadataCache"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public MetadataCache(ILogger<MetadataCache> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_mediaCompositionCache = new ConcurrentDictionary<string, CacheEntry<MediaComposition>>();
|
||||
_lock = new ReaderWriterLockSlim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_lock?.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets cached media composition by URN.
|
||||
/// </summary>
|
||||
/// <param name="urn">The URN.</param>
|
||||
/// <param name="cacheDurationMinutes">The cache duration in minutes.</param>
|
||||
/// <returns>The cached media composition, or null if not found or expired.</returns>
|
||||
public MediaComposition? GetMediaComposition(string urn, int cacheDurationMinutes)
|
||||
{
|
||||
if (string.IsNullOrEmpty(urn))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
if (_mediaCompositionCache.TryGetValue(urn, out var entry))
|
||||
{
|
||||
if (entry.IsValid(cacheDurationMinutes))
|
||||
{
|
||||
_logger.LogDebug("Cache hit for URN: {Urn}", urn);
|
||||
return entry.Value;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Cache entry expired for URN: {Urn}", urn);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets media composition in cache.
|
||||
/// </summary>
|
||||
/// <param name="urn">The URN.</param>
|
||||
/// <param name="mediaComposition">The media composition to cache.</param>
|
||||
public void SetMediaComposition(string urn, MediaComposition mediaComposition)
|
||||
{
|
||||
if (string.IsNullOrEmpty(urn) || mediaComposition == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var entry = new CacheEntry<MediaComposition>(mediaComposition);
|
||||
_mediaCompositionCache.AddOrUpdate(urn, entry, (key, oldValue) => entry);
|
||||
_logger.LogDebug("Cached media composition for URN: {Urn}", urn);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Cache is disposed, ignore
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes media composition from cache.
|
||||
/// </summary>
|
||||
/// <param name="urn">The URN.</param>
|
||||
public void RemoveMediaComposition(string urn)
|
||||
{
|
||||
if (string.IsNullOrEmpty(urn))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
if (_mediaCompositionCache.TryRemove(urn, out _))
|
||||
{
|
||||
_logger.LogDebug("Removed cached media composition for URN: {Urn}", urn);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Cache is disposed, ignore
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all cached data.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
try
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
_mediaCompositionCache.Clear();
|
||||
_logger.LogInformation("Cleared metadata cache");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Cache is disposed, ignore
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cache statistics.
|
||||
/// </summary>
|
||||
/// <returns>A tuple with cache count and size estimate.</returns>
|
||||
public (int Count, long SizeEstimate) GetStatistics()
|
||||
{
|
||||
try
|
||||
{
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
var count = _mediaCompositionCache.Count;
|
||||
// Rough estimate: average 50KB per entry
|
||||
var sizeEstimate = count * 50L * 1024;
|
||||
return (count, sizeEstimate);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return (0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a cached entry with timestamp.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of cached value.</typeparam>
|
||||
private sealed class CacheEntry<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CacheEntry{T}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to cache.</param>
|
||||
public CacheEntry(T value)
|
||||
{
|
||||
Value = value;
|
||||
Timestamp = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cached value.
|
||||
/// </summary>
|
||||
public T Value { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when the entry was created.
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the cache entry is still valid.
|
||||
/// </summary>
|
||||
/// <param name="cacheDurationMinutes">The cache duration in minutes.</param>
|
||||
/// <returns>True if the entry is still valid.</returns>
|
||||
public bool IsValid(int cacheDurationMinutes)
|
||||
{
|
||||
var expirationTime = Timestamp.AddMinutes(cacheDurationMinutes);
|
||||
return DateTime.UtcNow < expirationTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,198 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for resolving stream URLs from media composition resources.
|
||||
/// </summary>
|
||||
public class StreamUrlResolver
|
||||
{
|
||||
private readonly ILogger<StreamUrlResolver> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StreamUrlResolver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public StreamUrlResolver(ILogger<StreamUrlResolver> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the best stream URL from a chapter based on quality preference.
|
||||
/// </summary>
|
||||
/// <param name="chapter">The chapter containing resources.</param>
|
||||
/// <param name="qualityPreference">The quality preference.</param>
|
||||
/// <returns>The stream URL, or null if no suitable stream found.</returns>
|
||||
public string? GetStreamUrl(Chapter chapter, QualityPreference qualityPreference)
|
||||
{
|
||||
if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No resources found for chapter: {ChapterId}", chapter?.Id);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Processing chapter {ChapterId} with {ResourceCount} resources",
|
||||
chapter.Id,
|
||||
chapter.ResourceList.Count);
|
||||
|
||||
// Filter out DRM-protected content
|
||||
var nonDrmResources = chapter.ResourceList
|
||||
.Where(r => r.DrmList == null || r.DrmList.ToString() == "[]")
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Chapter {ChapterId}: Total resources={Total}, Non-DRM resources={NonDrm}",
|
||||
chapter.Id,
|
||||
chapter.ResourceList.Count,
|
||||
nonDrmResources.Count);
|
||||
|
||||
if (nonDrmResources.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("All resources for chapter {ChapterId} require DRM", chapter.Id);
|
||||
// Log what DRM types are present
|
||||
foreach (var resource in chapter.ResourceList)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"DRM resource: Protocol={Protocol}, Streaming={Streaming}, DRM={Drm}",
|
||||
resource.Protocol,
|
||||
resource.Streaming,
|
||||
resource.DrmList);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer HLS protocol
|
||||
var hlsResources = nonDrmResources
|
||||
.Where(r => string.Equals(r.Protocol, "HLS", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(r.Streaming, "HLS", StringComparison.OrdinalIgnoreCase) ||
|
||||
r.Url.Contains(".m3u8", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Chapter {ChapterId}: HLS resources found={HlsCount}",
|
||||
chapter.Id,
|
||||
hlsResources.Count);
|
||||
|
||||
if (hlsResources.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No HLS resources found for chapter: {ChapterId}", chapter.Id);
|
||||
// Log available protocols
|
||||
foreach (var resource in nonDrmResources)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Non-HLS resource: Protocol={Protocol}, Streaming={Streaming}, URL={Url}",
|
||||
resource.Protocol,
|
||||
resource.Streaming,
|
||||
resource.Url);
|
||||
}
|
||||
|
||||
// Fallback to any available non-DRM resource
|
||||
var fallbackResource = nonDrmResources.FirstOrDefault();
|
||||
if (fallbackResource != null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Using fallback resource for chapter {ChapterId}: {Url}",
|
||||
chapter.Id,
|
||||
fallbackResource.Url);
|
||||
}
|
||||
|
||||
return fallbackResource?.Url;
|
||||
}
|
||||
|
||||
// Select based on quality preference
|
||||
Resource? selectedResource = qualityPreference switch
|
||||
{
|
||||
QualityPreference.HD => SelectHDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
|
||||
QualityPreference.SD => SelectSDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
|
||||
QualityPreference.Auto => SelectBestAvailableResource(hlsResources),
|
||||
_ => SelectBestAvailableResource(hlsResources)
|
||||
};
|
||||
|
||||
if (selectedResource != null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Selected stream for chapter {ChapterId}: Quality={Quality}, Protocol={Protocol}, URL={Url}",
|
||||
chapter.Id,
|
||||
selectedResource.Quality,
|
||||
selectedResource.Protocol,
|
||||
selectedResource.Url);
|
||||
return selectedResource.Url;
|
||||
}
|
||||
|
||||
_logger.LogWarning("Could not select appropriate stream for chapter: {ChapterId}", chapter.Id);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a chapter has non-DRM playable content.
|
||||
/// </summary>
|
||||
/// <param name="chapter">The chapter to check.</param>
|
||||
/// <returns>True if playable content is available.</returns>
|
||||
public bool HasPlayableContent(Chapter chapter)
|
||||
{
|
||||
if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return chapter.ResourceList.Any(r => r.DrmList == null || r.DrmList.ToString() == "[]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if content is expired based on ValidTo date.
|
||||
/// </summary>
|
||||
/// <param name="chapter">The chapter to check.</param>
|
||||
/// <returns>True if the content is expired.</returns>
|
||||
public bool IsContentExpired(Chapter chapter)
|
||||
{
|
||||
if (chapter?.ValidTo == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return DateTime.UtcNow > chapter.ValidTo.Value.ToUniversalTime();
|
||||
}
|
||||
|
||||
private Resource? SelectHDResource(System.Collections.Generic.List<Resource> resources)
|
||||
{
|
||||
return resources.FirstOrDefault(r =>
|
||||
string.Equals(r.Quality, "HD", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(r.Quality, "1080", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(r.Quality, "720", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private Resource? SelectSDResource(System.Collections.Generic.List<Resource> resources)
|
||||
{
|
||||
return resources.FirstOrDefault(r =>
|
||||
string.Equals(r.Quality, "SD", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(r.Quality, "480", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(r.Quality, "360", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private Resource? SelectBestAvailableResource(System.Collections.Generic.List<Resource> resources)
|
||||
{
|
||||
// Try HD first
|
||||
var hdResource = SelectHDResource(resources);
|
||||
if (hdResource != null)
|
||||
{
|
||||
return hdResource;
|
||||
}
|
||||
|
||||
// Fall back to SD
|
||||
var sdResource = SelectSDResource(resources);
|
||||
if (sdResource != null)
|
||||
{
|
||||
return sdResource;
|
||||
}
|
||||
|
||||
// Return first available
|
||||
return resources.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
15
Jellyfin.Plugin.Template.sln
Normal file
15
Jellyfin.Plugin.Template.sln
Normal file
@ -0,0 +1,15 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Template", "Jellyfin.Plugin.Template\Jellyfin.Plugin.Template.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@ -0,0 +1,57 @@
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.Plugin.Template.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// The configuration options.
|
||||
/// </summary>
|
||||
public enum SomeOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Option one.
|
||||
/// </summary>
|
||||
OneOption,
|
||||
|
||||
/// <summary>
|
||||
/// Second option.
|
||||
/// </summary>
|
||||
AnotherOption
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plugin configuration.
|
||||
/// </summary>
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
|
||||
/// </summary>
|
||||
public PluginConfiguration()
|
||||
{
|
||||
// set default options here
|
||||
Options = SomeOptions.AnotherOption;
|
||||
TrueFalseSetting = true;
|
||||
AnInteger = 2;
|
||||
AString = "string";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether some true or false setting is enabled..
|
||||
/// </summary>
|
||||
public bool TrueFalseSetting { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an integer setting.
|
||||
/// </summary>
|
||||
public int AnInteger { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a string setting.
|
||||
/// </summary>
|
||||
public string AString { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an enum option.
|
||||
/// </summary>
|
||||
public SomeOptions Options { get; set; }
|
||||
}
|
||||
79
Jellyfin.Plugin.Template/Configuration/configPage.html
Normal file
79
Jellyfin.Plugin.Template/Configuration/configPage.html
Normal file
@ -0,0 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Template</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="TemplateConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<form id="TemplateConfigForm">
|
||||
<div class="selectContainer">
|
||||
<label class="selectLabel" for="Options">Several Options</label>
|
||||
<select is="emby-select" id="Options" name="Options" class="emby-select-withcolor emby-select">
|
||||
<option id="optOneOption" value="OneOption">One Option</option>
|
||||
<option id="optAnotherOption" value="AnotherOption">Another Option</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="AnInteger">An Integer</label>
|
||||
<input id="AnInteger" name="AnInteger" type="number" is="emby-input" min="0" />
|
||||
<div class="fieldDescription">A Description</div>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="TrueFalseSetting" name="TrueFalseCheckBox" type="checkbox" is="emby-checkbox" />
|
||||
<span>A Checkbox</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="AString">A String</label>
|
||||
<input id="AString" name="AString" type="text" is="emby-input" />
|
||||
<div class="fieldDescription">Another Description</div>
|
||||
</div>
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
var TemplateConfig = {
|
||||
pluginUniqueId: 'eb5d7894-8eef-4b36-aa6f-5d124e828ce1'
|
||||
};
|
||||
|
||||
document.querySelector('#TemplateConfigPage')
|
||||
.addEventListener('pageshow', function() {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(TemplateConfig.pluginUniqueId).then(function (config) {
|
||||
document.querySelector('#Options').value = config.Options;
|
||||
document.querySelector('#AnInteger').value = config.AnInteger;
|
||||
document.querySelector('#TrueFalseSetting').checked = config.TrueFalseSetting;
|
||||
document.querySelector('#AString').value = config.AString;
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector('#TemplateConfigForm')
|
||||
.addEventListener('submit', function(e) {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(TemplateConfig.pluginUniqueId).then(function (config) {
|
||||
config.Options = document.querySelector('#Options').value;
|
||||
config.AnInteger = document.querySelector('#AnInteger').value;
|
||||
config.TrueFalseSetting = document.querySelector('#TrueFalseSetting').checked;
|
||||
config.AString = document.querySelector('#AString').value;
|
||||
ApiClient.updatePluginConfiguration(TemplateConfig.pluginUniqueId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
});
|
||||
});
|
||||
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,8 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.SRFPlay</RootNamespace>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.Template</RootNamespace>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Nullable>enable</Nullable>
|
||||
@ -11,14 +11,13 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.9.11" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.9.11" />
|
||||
<PackageReference Include="Socks5" Version="1.1.0" />
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.8.13" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.8.13" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
|
||||
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="All" />
|
||||
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.507" PrivateAssets="All" />
|
||||
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||
using Jellyfin.Plugin.Template.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay;
|
||||
namespace Jellyfin.Plugin.Template;
|
||||
|
||||
/// <summary>
|
||||
/// The main plugin.
|
||||
@ -26,10 +26,10 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "SRF Play";
|
||||
public override string Name => "Template";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Guid Id => Guid.Parse("a4b12f86-8c3d-4e9a-b7f2-1d5e6c8a9b4f");
|
||||
public override Guid Id => Guid.Parse("eb5d7894-8eef-4b36-aa6f-5d124e828ce1");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current plugin instance.
|
||||
@ -39,13 +39,13 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
return
|
||||
[
|
||||
return new[]
|
||||
{
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name,
|
||||
Name = this.Name,
|
||||
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
|
||||
}
|
||||
];
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
@ -1,197 +0,0 @@
|
||||
# Proxy Configuration Guide for SRF Play Plugin
|
||||
|
||||
This guide explains how to configure the Jellyfin SRF Play plugin to route all API traffic through a proxy or alternate gateway.
|
||||
|
||||
## Overview
|
||||
|
||||
The SRF Play plugin now supports proxy configuration directly in the plugin settings. This allows you to:
|
||||
- Route traffic through a specific gateway or proxy server
|
||||
- Use authentication if your proxy requires it
|
||||
- Bypass geo-restrictions or network policies
|
||||
- Route only SRF-related traffic without affecting other Jellyfin operations
|
||||
|
||||
## Supported Proxy Types
|
||||
|
||||
The plugin supports:
|
||||
- **HTTP proxies**: `http://proxy.example.com:8080`
|
||||
- **HTTPS proxies**: `https://proxy.example.com:8443`
|
||||
- **SOCKS5 proxies**: `socks5://proxy.example.com:1080`
|
||||
|
||||
## Configuration Steps
|
||||
|
||||
### 1. Access Plugin Settings
|
||||
|
||||
1. Open Jellyfin Dashboard
|
||||
2. Navigate to **Dashboard → Plugins → SRF Play**
|
||||
3. Scroll down to the **Proxy Settings** section
|
||||
|
||||
### 2. Configure Proxy
|
||||
|
||||
Fill in the following fields:
|
||||
|
||||
#### Use Proxy
|
||||
- **Enable this checkbox** to route all SRF API requests through the proxy
|
||||
|
||||
#### Proxy Address
|
||||
- Enter your proxy server address with protocol and port
|
||||
- Examples:
|
||||
- `http://192.168.1.100:8080`
|
||||
- `http://proxy.example.com:3128`
|
||||
- `socks5://127.0.0.1:1080`
|
||||
|
||||
#### Proxy Username (Optional)
|
||||
- Enter username if your proxy requires authentication
|
||||
- Leave empty if no authentication is needed
|
||||
|
||||
#### Proxy Password (Optional)
|
||||
- Enter password if your proxy requires authentication
|
||||
- Leave empty if no authentication is needed
|
||||
|
||||
### 3. Save Configuration
|
||||
|
||||
1. Click **Save** button
|
||||
2. Restart Jellyfin to apply changes (recommended)
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Simple HTTP Proxy (No Authentication)
|
||||
|
||||
```
|
||||
Use Proxy: ✓ Enabled
|
||||
Proxy Address: http://192.168.1.1:8080
|
||||
Proxy Username: (empty)
|
||||
Proxy Password: (empty)
|
||||
```
|
||||
|
||||
### Example 2: Authenticated HTTP Proxy
|
||||
|
||||
```
|
||||
Use Proxy: ✓ Enabled
|
||||
Proxy Address: http://proxy.company.com:3128
|
||||
Proxy Username: myusername
|
||||
Proxy Password: mypassword
|
||||
```
|
||||
|
||||
### Example 3: SOCKS5 Proxy
|
||||
|
||||
```
|
||||
Use Proxy: ✓ Enabled
|
||||
Proxy Address: socks5://127.0.0.1:1080
|
||||
Proxy Username: (empty)
|
||||
Proxy Password: (empty)
|
||||
```
|
||||
|
||||
## Setting Up a Transparent Proxy Gateway on Ubuntu
|
||||
|
||||
If you want to create your own transparent proxy gateway on Ubuntu, here are some options:
|
||||
|
||||
### Option A: Squid Proxy
|
||||
|
||||
Install and configure Squid as a transparent proxy:
|
||||
|
||||
```bash
|
||||
# Install Squid
|
||||
sudo apt update
|
||||
sudo apt install squid
|
||||
|
||||
# Edit configuration
|
||||
sudo nano /etc/squid/squid.conf
|
||||
|
||||
# Add these lines:
|
||||
http_port 3128
|
||||
acl localnet src 192.168.1.0/24
|
||||
http_access allow localnet
|
||||
|
||||
# Restart Squid
|
||||
sudo systemctl restart squid
|
||||
```
|
||||
|
||||
Then in plugin settings:
|
||||
```
|
||||
Proxy Address: http://192.168.1.1:3128
|
||||
```
|
||||
|
||||
### Option B: SSH Tunnel (SOCKS5)
|
||||
|
||||
Create a SOCKS5 proxy through SSH:
|
||||
|
||||
```bash
|
||||
# On your local machine
|
||||
ssh -D 1080 -N user@remote-gateway-server
|
||||
```
|
||||
|
||||
Then in plugin settings:
|
||||
```
|
||||
Proxy Address: socks5://127.0.0.1:1080
|
||||
```
|
||||
|
||||
### Option C: Dante SOCKS Server
|
||||
|
||||
Install Dante for a dedicated SOCKS5 server:
|
||||
|
||||
```bash
|
||||
sudo apt install dante-server
|
||||
|
||||
# Configure in /etc/danted.conf
|
||||
sudo systemctl restart danted
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin Not Connecting Through Proxy
|
||||
|
||||
1. **Check proxy address format**: Ensure it includes the protocol (http://, socks5://, etc.)
|
||||
2. **Verify proxy is running**: Test connectivity to the proxy from your Jellyfin server
|
||||
3. **Check Jellyfin logs**: Look for proxy-related errors in Dashboard → Logs
|
||||
4. **Firewall rules**: Ensure your firewall allows outbound connections to the proxy
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
1. Verify username and password are correct
|
||||
2. Check if your proxy requires domain authentication (DOMAIN\\username)
|
||||
3. Some proxies may require specific authentication methods not supported by .NET HttpClient
|
||||
|
||||
### DNS Resolution
|
||||
|
||||
- The plugin resolves domain names before sending requests through the proxy
|
||||
- If you need DNS resolution through the proxy, you may need to use a VPN or network-level routing instead
|
||||
|
||||
## Affected Domains
|
||||
|
||||
When proxy is enabled, all requests to these domains will be routed through the proxy:
|
||||
|
||||
- `il.srgssr.ch` - SRF Integration Layer API
|
||||
- `www.srf.ch` - SRF Play v3 API (German)
|
||||
- `www.rts.ch` - RTS Play v3 API (French)
|
||||
- `www.rsi.ch` - RSI Play v3 API (Italian)
|
||||
- `www.rtr.ch` - RTR Play v3 API (Romansh)
|
||||
- `www.swi.ch` - SWI Play v3 API (International)
|
||||
|
||||
## Viewing Logs
|
||||
|
||||
To verify the proxy is being used:
|
||||
|
||||
1. Go to **Dashboard → Logs**
|
||||
2. Look for entries containing "Proxy configured"
|
||||
3. Example log entry:
|
||||
```
|
||||
Proxy configured: http://192.168.1.1:8080 (Authentication: False)
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Proxy credentials are stored in Jellyfin's plugin configuration
|
||||
- Use HTTPS for the proxy connection when possible to encrypt traffic
|
||||
- Consider using a VPN for more secure routing if dealing with sensitive content
|
||||
- Regularly update your proxy server and Jellyfin to patch security vulnerabilities
|
||||
|
||||
## Alternative: Network-Level Routing
|
||||
|
||||
If you prefer network-level routing instead of application proxy, see the `setup-srf-routing.sh` script for IP-based routing tables (requires root access and is more complex).
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues:
|
||||
1. Check Jellyfin logs for detailed error messages
|
||||
2. Verify proxy connectivity with `curl --proxy http://proxy:port https://il.srgssr.ch`
|
||||
3. Open an issue on the GitHub repository with logs and configuration details
|
||||
68
Program.cs
68
Program.cs
@ -1,68 +0,0 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
class Program
|
||||
{
|
||||
static async Task Main(string[] args)
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
Proxy = new WebProxy("http://192.168.1.37:3128"),
|
||||
UseProxy = true,
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
CheckCertificateRevocationList = false
|
||||
};
|
||||
|
||||
using var client = new HttpClient(handler)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
DefaultRequestVersion = new Version(1, 1)
|
||||
};
|
||||
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("*/*");
|
||||
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("en-US,en;q=0.9");
|
||||
|
||||
var url = "https://il.srgssr.ch/integrationlayer/2.0/mediaComposition/byUrn/urn:srf:video:b84713f0-f81b-460f-9b0f-d0517310fb4f.json";
|
||||
|
||||
Console.WriteLine($"Testing: {url}");
|
||||
Console.WriteLine("Using proxy: http://192.168.1.37:3128");
|
||||
Console.WriteLine();
|
||||
|
||||
try
|
||||
{
|
||||
Console.WriteLine("Making request...");
|
||||
var response = await client.GetAsync(url);
|
||||
Console.WriteLine($"Status Code: {response.StatusCode}");
|
||||
Console.WriteLine($"Headers:");
|
||||
foreach (var header in response.Headers)
|
||||
{
|
||||
Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}");
|
||||
}
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Console.WriteLine($"\nContent Length: {content.Length}");
|
||||
Console.WriteLine($"First 500 chars: {content.Substring(0, Math.Min(500, content.Length))}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Console.WriteLine($"\nError content: {content}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.GetType().Name}");
|
||||
Console.WriteLine($"Message: {ex.Message}");
|
||||
if (ex.InnerException != null)
|
||||
{
|
||||
Console.WriteLine($"Inner: {ex.InnerException.Message}");
|
||||
}
|
||||
Console.WriteLine($"\nStack trace:\n{ex.StackTrace}");
|
||||
}
|
||||
}
|
||||
}
|
||||
552
README.md
552
README.md
@ -1,263 +1,373 @@
|
||||
# Jellyfin SRF Play Plugin
|
||||
# So you want to make a Jellyfin plugin
|
||||
|
||||
A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-demand content.
|
||||
Awesome! This guide is for you. Jellyfin plugins are written using the dotnet standard framework. What that means is you can write them in any language that implements the CLI or the DLI and can compile to net6.0. The examples on this page are in C# because that is what most of Jellyfin is written in, but F#, Visual Basic, and IronPython should all be compatible once compiled.
|
||||
|
||||
## Features
|
||||
## 0. Things you need to get started
|
||||
|
||||
- Access to SRF Play VOD content (video-on-demand only, no DRM-protected content)
|
||||
- Support for all Swiss broadcasting units (SRF, RTS, RSI, RTR, SWI)
|
||||
- Automatic content expiration handling
|
||||
- Latest and trending content discovery
|
||||
- Quality selection (Auto, SD, HD)
|
||||
- HLS streaming support
|
||||
- Proxy support for routing traffic through alternate gateways
|
||||
- [Dotnet SDK 6.0](https://dotnet.microsoft.com/download)
|
||||
|
||||
## Project Status
|
||||
- An editor of your choice. Some free choices are:
|
||||
|
||||
### ✅ Completed Components
|
||||
[Visual Studio Code](https://code.visualstudio.com)
|
||||
|
||||
#### Phase 1: Project Setup
|
||||
- ✅ Renamed from Template to SRFPlay
|
||||
- ✅ Updated all namespaces and identifiers
|
||||
- ✅ Configured plugin metadata (ID, name)
|
||||
[Visual Studio Community Edition](https://visualstudio.microsoft.com/downloads)
|
||||
|
||||
#### Phase 2: Core API Infrastructure
|
||||
- ✅ API Models (MediaComposition, Chapter, Resource, Show, Episode)
|
||||
- ✅ SRF API Client with HTTP client wrapper
|
||||
- ✅ JSON deserialization support
|
||||
- ✅ Error handling and logging
|
||||
[Mono Develop](https://www.monodevelop.com)
|
||||
|
||||
#### Phase 3: Configuration
|
||||
- ✅ Business unit selection (SRF/RTS/RSI/RTR/SWI)
|
||||
- ✅ Quality preferences (Auto/SD/HD)
|
||||
- ✅ Content refresh intervals
|
||||
- ✅ Expiration check settings
|
||||
- ✅ Cache duration configuration
|
||||
- ✅ HTML configuration page
|
||||
## 0.5. Quickstarts
|
||||
|
||||
#### Phase 4: Services
|
||||
- ✅ Stream URL Resolver
|
||||
- HLS stream selection
|
||||
- Quality-based filtering
|
||||
- DRM content filtering
|
||||
- Content expiration checking
|
||||
- ✅ Metadata Cache Service
|
||||
- Efficient caching with configurable duration
|
||||
- Thread-safe with ReaderWriterLockSlim
|
||||
- IDisposable implementation
|
||||
- ✅ Content Expiration Service
|
||||
- Automatic expiration checking
|
||||
- Library cleanup of expired content
|
||||
- Statistics and monitoring
|
||||
- ✅ Content Refresh Service
|
||||
- Latest and trending content discovery
|
||||
- Automatic cache population
|
||||
- Recommendations system
|
||||
We have a number of quickstart options available to speed you along the way.
|
||||
|
||||
#### Phase 5: Content Providers
|
||||
- ✅ Series Provider (for show metadata)
|
||||
- ✅ Episode Provider (for episode metadata)
|
||||
- ✅ Image Provider (for thumbnails and artwork)
|
||||
- ✅ Media Provider (for playback URLs and HLS streams)
|
||||
- [Download the Example Plugin Project](https://github.com/jellyfin/jellyfin-plugin-template/tree/master/Jellyfin.Plugin.Template) from this repository, open it in your IDE and go to [step 3](https://github.com/jellyfin/jellyfin-plugin-template#3-customize-plugin-information)
|
||||
|
||||
#### Phase 6: Scheduled Tasks
|
||||
- ✅ Content Refresh Task
|
||||
- Periodic discovery of new content
|
||||
- Configurable refresh intervals
|
||||
- ✅ Expiration Check Task
|
||||
- Automatic cleanup of expired content
|
||||
- Configurable check intervals
|
||||
- Install our dotnet template by [downloading the dotnet-template/content folder from this repo](https://github.com/jellyfin/jellyfin-plugin-template/tree/master/dotnet-template/content) or off of Nuget (Coming soon)
|
||||
|
||||
#### Phase 7: Dependency Injection & Integration
|
||||
- ✅ Service registration (ServiceRegistrator)
|
||||
- ✅ Jellyfin provider interfaces implementation
|
||||
- ✅ Plugin initialization and configuration
|
||||
```
|
||||
dotnet new -i /path/to/templatefolder
|
||||
```
|
||||
|
||||
### ✅ Build Status
|
||||
**Successfully compiling!** All code analysis warnings resolved.
|
||||
- Run this command then skip to step 4
|
||||
|
||||
### 🧪 Testing Status
|
||||
- [ ] Unit tests
|
||||
- [ ] Integration testing with Jellyfin instance
|
||||
- [ ] End-to-end playback testing
|
||||
```
|
||||
dotnet new Jellyfin-plugin -name MyPlugin
|
||||
```
|
||||
|
||||
### 📝 Next Steps
|
||||
1. Test the plugin with a live Jellyfin instance
|
||||
2. Verify content discovery and playback
|
||||
3. Test expiration handling
|
||||
4. Add unit tests
|
||||
5. Performance optimization if needed
|
||||
If you'd rather start from scratch keep going on to step one. This assumes no specific editor or IDE and requires only the command line with dotnet in the path.
|
||||
|
||||
## API Information
|
||||
## 1. Initialize Your Project
|
||||
|
||||
**Base URL:** `https://il.srgssr.ch/integrationlayer/2.0/`
|
||||
|
||||
### Key Endpoints
|
||||
|
||||
- `GET /mediaComposition/byUrn/{urn}.json` - Get video metadata and playable URLs
|
||||
- `GET /video/{businessUnit}/latest` - Get latest videos
|
||||
- `GET /video/{businessUnit}/trending` - Get trending videos
|
||||
- `GET /showList/{businessUnit}` - Get all shows (to be implemented)
|
||||
- `GET /episodeList/{businessUnit}/{showId}` - Get episodes for a show (to be implemented)
|
||||
|
||||
### URN Format
|
||||
|
||||
- `urn:{bu}:video:{id}` - Video URN
|
||||
- `urn:{bu}:video:livestream_{channel}` - Livestream URN (not supported due to Widevine DRM)
|
||||
|
||||
Example: `urn:srf:video:livestream_SRF1`
|
||||
|
||||
## Building the Plugin
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- .NET 8.0 SDK
|
||||
- Jellyfin 10.9.11 or later
|
||||
|
||||
### Build Steps
|
||||
|
||||
```bash
|
||||
cd Jellyfin.Plugin.SRFPlay
|
||||
dotnet build
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
The compiled plugin will be in `bin/Debug/net8.0/`
|
||||
|
||||
## Installation
|
||||
|
||||
1. Build the plugin (see above)
|
||||
2. Copy the compiled DLL to your Jellyfin plugins directory
|
||||
3. Restart Jellyfin
|
||||
4. Configure the plugin in Jellyfin Dashboard → Plugins → SRF Play
|
||||
|
||||
## Configuration
|
||||
|
||||
- **Business Unit**: Select the Swiss broadcasting unit (default: SRF)
|
||||
- **Quality Preference**: Choose video quality (Auto/SD/HD)
|
||||
- **Content Refresh Interval**: How often to check for new content (1-168 hours)
|
||||
- **Expiration Check Interval**: How often to check for expired content (1-168 hours)
|
||||
- **Cache Duration**: How long to cache metadata (5-1440 minutes)
|
||||
- **Enable Latest Content**: Automatically discover latest videos
|
||||
- **Enable Trending Content**: Automatically discover trending videos
|
||||
- **Proxy Settings**: Configure proxy server for routing SRF API traffic (optional)
|
||||
- **Use Proxy**: Enable/disable proxy usage
|
||||
- **Proxy Address**: Proxy server URL (e.g., http://proxy.example.com:8080)
|
||||
- **Proxy Username**: Optional authentication username
|
||||
- **Proxy Password**: Optional authentication password
|
||||
|
||||
For detailed proxy setup instructions, see [PROXY_SETUP_GUIDE.md](PROXY_SETUP_GUIDE.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues with the plugin:
|
||||
- Check [DEBUG_GUIDE.md](DEBUG_GUIDE.md) for detailed logging information
|
||||
- Enable debug logging in Jellyfin to see detailed request/response information
|
||||
- Common issues include DRM-protected content and geo-restrictions
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Directory Structure
|
||||
Make a new dotnet standard project with the following command, it will make a directory for itself.
|
||||
|
||||
```
|
||||
Jellyfin.Plugin.SRFPlay/
|
||||
├── Api/
|
||||
│ ├── Models/ # API response models
|
||||
│ │ ├── MediaComposition.cs
|
||||
│ │ ├── Chapter.cs
|
||||
│ │ ├── Resource.cs
|
||||
│ │ ├── Show.cs
|
||||
│ │ └── Episode.cs
|
||||
│ └── SRFApiClient.cs # HTTP client for SRF API
|
||||
├── Configuration/
|
||||
│ ├── PluginConfiguration.cs
|
||||
│ └── configPage.html
|
||||
├── Services/
|
||||
│ ├── StreamUrlResolver.cs # HLS stream resolution
|
||||
│ ├── MetadataCache.cs # Caching layer
|
||||
│ ├── ContentExpirationService.cs # Expiration management
|
||||
│ └── ContentRefreshService.cs # Content discovery
|
||||
├── Providers/
|
||||
│ ├── SRFSeriesProvider.cs # Series metadata
|
||||
│ ├── SRFEpisodeProvider.cs # Episode metadata
|
||||
│ ├── SRFImageProvider.cs # Image fetching
|
||||
│ └── SRFMediaProvider.cs # Playback URLs
|
||||
├── ScheduledTasks/
|
||||
│ ├── ContentRefreshTask.cs # Periodic content refresh
|
||||
│ └── ExpirationCheckTask.cs # Periodic expiration check
|
||||
├── ServiceRegistrator.cs # DI registration
|
||||
└── Plugin.cs # Main plugin entry point
|
||||
dotnet new classlib -f net6.0 -n MyJellyfinPlugin
|
||||
```
|
||||
|
||||
### Key Components
|
||||
Now add the Jellyfin shared libraries.
|
||||
|
||||
1. **API Client**: Handles all HTTP requests to SRF Integration Layer
|
||||
2. **Stream Resolver**: Extracts and selects optimal HLS streams
|
||||
3. **Configuration**: User-configurable settings via Jellyfin dashboard
|
||||
4. **Metadata Cache**: Thread-safe caching with configurable expiration
|
||||
5. **Content Providers**: Jellyfin integration for series, episodes, images, and media
|
||||
6. **Scheduled Tasks**: Automatic content refresh and expiration management
|
||||
7. **Service Layer**: Content discovery, expiration handling, and stream resolution
|
||||
```
|
||||
dotnet add package Jellyfin.Model
|
||||
dotnet add package Jellyfin.Controller
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
You have an autogenerated Class1.cs file. You won't be needing this, so go ahead and delete it.
|
||||
|
||||
### Content Limitations
|
||||
## 2. Set Up the Basics
|
||||
|
||||
- **No DRM content**: Only non-DRM protected content is accessible (no Widevine)
|
||||
- **No live TV channels**: Only VOD content and live streams without DRM
|
||||
- **Content expiration**: SRF content has validity periods, plugin tracks and removes expired content
|
||||
- **No subtitles**: Subtitle support not currently implemented
|
||||
There are a few mandatory classes you'll need for a plugin so we need to make them.
|
||||
|
||||
### Extensibility
|
||||
### PluginConfiguration
|
||||
|
||||
The plugin is designed to support all Swiss broadcasting units:
|
||||
- **SRF** (Schweizer Radio und Fernsehen - German)
|
||||
- **RTS** (Radio Télévision Suisse - French)
|
||||
- **RSI** (Radiotelevisione svizzera - Italian)
|
||||
- **RTR** (Radiotelevisiun Svizra Rumantscha - Romansh)
|
||||
- **SWI** (Swiss World International)
|
||||
You can call it whatever you'd like really. This class is used to hold settings your plugin might need. We can leave it empty for now. This class should inherit from `MediaBrowser.Model.Plugins.BasePluginConfiguration`
|
||||
|
||||
Currently focused on SRF but easily extensible.
|
||||
### Plugin
|
||||
|
||||
## Development
|
||||
This is the main class for your plugin. It will define your name, version and Id. It should inherit from `MediaBrowser.Common.Plugins.BasePlugin<PluginConfiguration>`
|
||||
|
||||
### Current Status
|
||||
Note: If you called your PluginConfiguration class something different, you need to put that between the <>
|
||||
|
||||
All core functionality is implemented and compiling successfully! The plugin includes:
|
||||
- Complete API integration with SRF Play
|
||||
- Metadata providers for series and episodes
|
||||
- Image fetching and caching
|
||||
- HLS stream playback
|
||||
- Automatic content expiration handling
|
||||
- Scheduled tasks for content refresh
|
||||
### Implement Required Properties
|
||||
|
||||
### Testing
|
||||
The Plugin class needs a few properties implemented before it can work correctly.
|
||||
|
||||
To test the plugin:
|
||||
It needs an override on ID, an override on Name, and a constructor that follows a specific model. To get started you can use the following section.
|
||||
|
||||
1. Build the plugin: `dotnet build`
|
||||
2. Copy `bin/Debug/net8.0/Jellyfin.Plugin.SRFPlay.dll` to your Jellyfin plugins directory
|
||||
3. Restart Jellyfin
|
||||
4. Configure the plugin in Dashboard → Plugins → SRF Play
|
||||
5. Add a library with the SRF Play content provider
|
||||
```c#
|
||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer){}
|
||||
public override string Name => throw new System.NotImplementedException();
|
||||
public override Guid Id => Guid.Parse("");
|
||||
```
|
||||
|
||||
### Known Limitations
|
||||
## 3. Customize Plugin Information
|
||||
|
||||
- This is a first version that has not been tested with a live Jellyfin instance yet
|
||||
- Some edge cases may need handling
|
||||
- Performance optimization may be needed for large content catalogs
|
||||
You need to populate some of your plugin's information. Go ahead a put in a string of the Name you've overridden name, and generate a GUID
|
||||
|
||||
### Contributing
|
||||
- **Windows Users**: you can use the Powershell command `New-Guid`, `[guid]::NewGuid()` or the Visual Studio GUID generator
|
||||
|
||||
This plugin is in active development. Contributions welcome!
|
||||
- **Linux and OS X Users**: you can use the Powershell Core command `New-Guid` or this command from your shell of choice:
|
||||
|
||||
## License
|
||||
```bash
|
||||
od -x /dev/urandom | head -1 | awk '{OFS="-"; srand($6); sub(/./,"4",$5); sub(/./,substr("89ab",rand()*4,1),$6); print $2$3,$4,$5,$6,$7$8$9}'
|
||||
```
|
||||
|
||||
See LICENSE file for details.
|
||||
or
|
||||
|
||||
## References
|
||||
```bash
|
||||
uuidgen
|
||||
```
|
||||
|
||||
- [SRF Play](https://www.srf.ch/play)
|
||||
- [Jellyfin Plugin Documentation](https://jellyfin.org/docs/general/server/plugins/)
|
||||
- [SRG SSR Integration Layer API](https://il.srgssr.ch/)
|
||||
- Place that guid inside the `Guid.Parse("")` quotes to define your plugin's ID.
|
||||
|
||||
## 4. Adding Functionality
|
||||
|
||||
Congratulations, you now have everything you need for a perfectly functional functionless Jellyfin plugin! You can try it out right now if you'd like by compiling it, then placing the dll you generate in the plugins folder under your Jellyfin config directory. If you want to try and hook it up to a debugger make sure you copy the generated PDB file alongside it.
|
||||
|
||||
Most people aren't satisfied with just having an entry in a menu for their plugin, most people want to have some functionality, so lets look at how to add it.
|
||||
|
||||
### 4a. Implement Interfaces
|
||||
|
||||
If the functionality you are trying to add is functionality related to something that Jellyfin has an interface for you're in luck. Jellyfin uses some automatic discovery and injection to allow any interfaces you implement in your plugin to be available in Jellyfin.
|
||||
|
||||
Here's some interfaces you could implement for common use cases:
|
||||
|
||||
- **IAuthenticationProvider** - Allows you to add an authentication provider that can authenticate a user based on a name and a password, but that doesn't expect to deal with local users.
|
||||
- **IBaseItemComparer** - Allows you to add sorting rules for dealing with media that will show up in sort menus
|
||||
- **IIntroProvider** - Allows you to play a piece of media before another piece of media (i.e. a trailer before a movie, or a network bumper before an episode of a show)
|
||||
- **IItemResolver** - Allows you to define custom media types
|
||||
- **ILibraryPostScanTask** - Allows you to define a task that fires after scanning a library
|
||||
- **IMetadataSaver** - Allows you to define a metadata standard that Jellyfin can use to write metadata
|
||||
- **IResolverIgnoreRule** - Allows you to define subpaths that are ignored by media resolvers for use with another function (i.e. you wanted to have a theme song for each tv series stored in a subfolder that could be accessed by your plugin for playback in a menu).
|
||||
- **IScheduledTask** - Allows you to create a scheduled task that will appear in the scheduled task lists on the dashboard.
|
||||
|
||||
There are loads of other interfaces that can be used, but you'll need to poke around the API to get some info. If you're an expert on a particular interface, you should help [contribute some documentation](https://docs.jellyfin.org/general/contributing/index.html)!
|
||||
|
||||
### 4b. Use plugin aimed interfaces to add custom functionality
|
||||
|
||||
If your plugin doesn't fit perfectly neatly into a predefined interface, never fear, there are a set of interfaces and classes that allow your plugin to extend Jellyfin any which way you please. Here's a quick overview on how to use them
|
||||
|
||||
- **IPluginConfigurationPage** - Allows you to have a plugin config page on the dashboard. If you used one of the quickstart example projects, a premade page with some useful components to work with has been created for you! If not you can check out this guide here for how to whip one up.
|
||||
|
||||
- **IServerEntryPoint** - Allows you to run code at server startup that will stay in memory. You can make as many of these as you need and it is wildly useful for loading configs or persisting state. **Be aware that your main plugin class (IBasePlugin) cannot also be a IServerEntryPoint.**
|
||||
|
||||
- **ControllerBase** - Allows you to define custom REST-API endpoints. This is the default ASP.NET Web-API controller. You can use it exactly as you would in a normal Web-API project. Learn more about it [here](https://docs.microsoft.com/aspnet/core/web-api/?view=aspnetcore-5.0).
|
||||
|
||||
Likewise you might need to get data and services from the Jellyfin core, Jellyfin provides a number of interfaces you can add as parameters to your plugin constructor which are then made available in your project (you can see the 2 mandatory ones that are needed by the plugin system in the constructor as is).
|
||||
|
||||
- **IBlurayExaminer** - Allows you to examine blu-ray folders
|
||||
- **IDtoService** - Allows you to create data transport objects, presumably to send to other plugins or to the core
|
||||
- **ILibraryManager** - Allows you to directly access the media libraries without hopping through the API
|
||||
- **ILocalizationManager** - Allows you tap into the main localization engine which governs translations, rating systems, units etc...
|
||||
- **INetworkManager** - Allows you to get information about the server's networking status
|
||||
- **IServerApplicationPaths** - Allows you to get the running server's paths
|
||||
- **IServerConfigurationManager** - Allows you to write or read server configuration data into the application paths
|
||||
- **ITaskManager** - Allows you to execute and manipulate scheduled tasks
|
||||
- **IUserManager** - Allows you to retrieve user info and user library related info
|
||||
- **IXmlSerializer** - Allows you to use the main xml serializer
|
||||
- **IZipClient** - Allows you to use the core zip client for compressing and decompressing data
|
||||
|
||||
## 5. Create a Repository
|
||||
|
||||
- [See blog post](https://jellyfin.org/posts/plugin-updates/)
|
||||
|
||||
## 6. Set Up Debugging
|
||||
|
||||
Debugging can be set up by creating tasks which will be executed when running the plugin project. The specifics on setting up these tasks are not included as they may differ from IDE to IDE. The following list describes the general process:
|
||||
|
||||
- Compile the plugin in debug mode.
|
||||
- Create the plugin directory if it doesn't exist.
|
||||
- Copy the plugin into your server's plugin directory. The server will then execute it.
|
||||
- Make sure to set the working directory of the program being debugged to the working directory of the Jellyfin Server.
|
||||
- Start the server.
|
||||
|
||||
Some IDEs like Visual Studio Code may need the following compile flags to compile the plugin:
|
||||
|
||||
```shell
|
||||
dotnet build Your-Plugin.sln /property:GenerateFullPaths=true /consoleloggerparameters:NoSummary
|
||||
```
|
||||
|
||||
These flags generate the full paths for file names and **do not** generate a summary during the build process as this may lead to duplicate errors in the problem panel of your IDE.
|
||||
|
||||
### 6.a Set Up Debugging on Visual Studio
|
||||
|
||||
Visual Studio allows developers to connect to other processes and debug them, setting breakpoints and inspecting the variables of the program. We can set this up following this steps:
|
||||
On this section we will explain how to set up our solution to enable debugging before the server starts.
|
||||
|
||||
1. Right-click on the solution, And click on Add -> Existing Project...
|
||||
2. Locate Jellyfin executable in your installation folder and click on 'Open'. It is called `Jellyfin.exe`. Now The solution will have a new "Project" called Jellyfin. This is the executable, not the source code of Jellyfin.
|
||||
3. Right-click on this new project and click on 'Set up as Startup Project'
|
||||
4. Right-click on this new project and click on 'Properties'
|
||||
5. Make sure that the 'Attach' parameter is set to 'No'
|
||||
|
||||
From now on, everytime you click on start from Visual Studio, it will start Jellyfin attached to the debugger!
|
||||
|
||||
The only thing left to do is to compile the project as it is specified a few lines above and you are done.
|
||||
|
||||
### 6.b Automate the Setup on Visual Studio Code
|
||||
|
||||
Visual Studio Code allows developers to automate the process of starting all necessary dependencies to start debugging the plugin. This guide assumes the reader is familiar with the [documentation on debugging in Visual Studio Code](https://code.visualstudio.com/docs/editor/debugging) and has read the documentation in this file. It is assumed that the Jellyfin Server has already been compiled once. However, should one desire to automatically compile the server before the start of the debugging session, this can be easily implemented, but is not further discussed here.
|
||||
|
||||
A full example, which aims to be portable may be found in this repo's `.vscode` folder.
|
||||
|
||||
This example expects you to clone `jellyfin`, `jellyfin-web` and `jellyfin-plugin-template` under the same parent directory, though you can customize this in `settings.json`
|
||||
|
||||
1. Create a `settings.json` file inside your `.vscode` folder, to specify common options specific to your local setup.
|
||||
```jsonc
|
||||
{
|
||||
// jellyfinDir : The directory of the cloned jellyfin server project
|
||||
// This needs to be built once before it can be used
|
||||
"jellyfinDir" : "${workspaceFolder}/../jellyfin/Jellyfin.Server",
|
||||
// jellyfinWebDir : The directory of the cloned jellyfin-web project
|
||||
// This needs to be built once before it can be used
|
||||
"jellyfinWebDir" : "${workspaceFolder}/../jellyfin-web",
|
||||
// jellyfinDataDir : the root data directory for a running jellyfin instance
|
||||
// This is where jellyfin stores its configs, plugins, metadata etc
|
||||
// This is platform specific by default, but on Windows defaults to
|
||||
// ${env:LOCALAPPDATA}/jellyfin
|
||||
"jellyfinDataDir" : "${env:LOCALAPPDATA}/jellyfin",
|
||||
// The name of the plugin
|
||||
"pluginName" : "Jellyfin.Plugin.Template",
|
||||
}
|
||||
```
|
||||
|
||||
1. To automate the launch process, create a new `launch.json` file for C# projects inside the `.vscode` folder. The example below shows only the relevant parts of the file. Adjustments to your specific setup and operating system may be required.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
// Paths and plugin names are configured in settings.json
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "coreclr",
|
||||
"name": "Launch",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build-and-copy",
|
||||
"program": "${config:jellyfinDir}/bin/Debug/net6.0/jellyfin.dll",
|
||||
"args": [
|
||||
//"--nowebclient"
|
||||
"--webdir",
|
||||
"${config:jellyfinWebDir}/dist/"
|
||||
],
|
||||
"cwd": "${config:jellyfinDir}",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
The `request` type is specified as `launch`, as this `launch.json` file will start the Jellyfin Server process. The `preLaunchTask` defines a task that will run before the Jellyfin Server starts. More on this later. It is important to set the `program` path to the Jellyin Server program and set the current working directory (`cwd`) to the working directory of the Jellyfin Server.
|
||||
The `args` option allows to specify arguments to be passed to the server, e.g. whether Jellyfin should start with the web-client or without it.
|
||||
|
||||
2. Create a `tasks.json` file inside your `.vscode` folder and specify a `build-and-copy` task that will run in `sequence` order. This tasks depends on multiple other tasks and all of those other tasks can be defined as simple `shell` tasks that run commands like the `cp` command to copy a file. The sequence to run those tasks in is given below. Please note that it might be necessary to adjust the examples for your specific setup and operating system.
|
||||
|
||||
The full file is shown here - Specific sections will be discussed in depth
|
||||
```jsonc
|
||||
{
|
||||
// Paths and plugin name are configured in settings.json
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
// A chain task - build the plugin, then copy it to your
|
||||
// jellyfin server's plugin directory
|
||||
"label": "build-and-copy",
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": ["build", "make-plugin-dir", "copy-dll"]
|
||||
},
|
||||
{
|
||||
// Build the plugin
|
||||
"label": "build",
|
||||
"command": "dotnet",
|
||||
"type": "shell",
|
||||
"args": [
|
||||
"publish",
|
||||
"${workspaceFolder}/${config:pluginName}.sln",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"reveal": "silent"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
// Ensure the plugin directory exists before trying to use it
|
||||
"label": "make-plugin-dir",
|
||||
"type": "shell",
|
||||
"command": "mkdir",
|
||||
"args": [
|
||||
"-Force",
|
||||
"-Path",
|
||||
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
|
||||
]
|
||||
},
|
||||
{
|
||||
// Copy the plugin dll to the jellyfin plugin install path
|
||||
// This command copies every .dll from the build directory to the plugin dir
|
||||
// Usually, you probablly only need ${config:pluginName}.dll
|
||||
// But some plugins may bundle extra requirements
|
||||
"label": "copy-dll",
|
||||
"type": "shell",
|
||||
"command": "cp",
|
||||
"args": [
|
||||
"./${config:pluginName}/bin/Debug/net6.0/publish/*",
|
||||
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
|
||||
]
|
||||
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
1. The "build-and-copy" task which triggers all of the other tasks
|
||||
```jsonc
|
||||
{
|
||||
// A chain task - build the plugin, then copy it to your
|
||||
// jellyfin server's plugin directory
|
||||
"label": "build-and-copy",
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": ["build", "make-plugin-dir", "copy-dll"]
|
||||
},
|
||||
```
|
||||
2. A build task. This task builds the plugin without generating summary, but with full paths for file names enabled.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
// Build the plugin
|
||||
"label": "build",
|
||||
"command": "dotnet",
|
||||
"type": "shell",
|
||||
"args": [
|
||||
"publish",
|
||||
"${workspaceFolder}/${config:pluginName}.sln",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"reveal": "silent"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
```
|
||||
|
||||
3. A tasks which creates the necessary plugin directory and a sub-folder for the specific plugin. The plugin directory is located below the [data directory](https://jellyfin.org/docs/general/administration/configuration.html) of the Jellyfin Server. As an example, the following path can be used for the bookshelf plugin: `$HOME/.local/share/jellyfin/plugins/Bookshelf/`
|
||||
```jsonc
|
||||
{
|
||||
// Ensure the plugin directory exists before trying to use it
|
||||
"label": "make-plugin-dir",
|
||||
"type": "shell",
|
||||
"command": "mkdir",
|
||||
"args": [
|
||||
"-Force",
|
||||
"-Path",
|
||||
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
|
||||
]
|
||||
},
|
||||
```
|
||||
|
||||
4. A tasks which copies the plugin dll which has been built in step 2.1. The file is copied into it's specific plugin directory within the server's plugin directory.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
// Copy the plugin dll to the jellyfin plugin install path
|
||||
// This command copies every .dll from the build directory to the plugin dir
|
||||
// Usually, you probablly only need ${config:pluginName}.dll
|
||||
// But some plugins may bundle extra requirements
|
||||
"label": "copy-dll",
|
||||
"type": "shell",
|
||||
"command": "cp",
|
||||
"args": [
|
||||
"./${config:pluginName}/bin/Debug/net6.0/publish/*",
|
||||
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
|
||||
]
|
||||
},
|
||||
```
|
||||
|
||||
## Licensing
|
||||
|
||||
Licensing is a complex topic. This repository features a GPLv3 license template that can be used to provide a good default license for your plugin. You may alter this if you like, but if you do a permissive license must be chosen.
|
||||
|
||||
Due to how plugins in Jellyfin work, when your plugin is compiled into a binary, it will link against the various Jellyfin binary NuGet packages. These packages are licensed under the GPLv3. Thus, due to the nature and restrictions of the GPL, the binary plugin you get will also be licensed under the GPLv3.
|
||||
|
||||
If you accept the default GPLv3 license from this template, all will be good. However if you choose a different license, please keep this fact in mind, as it might not always be obvious that an, e.g. MIT-licensed plugin would become GPLv3 when compiled.
|
||||
|
||||
Please note that this also means making "proprietary", source-unavailable, or otherwise "hidden" plugins for public consumption is not permitted. To build a Jellyfin plugin for distribution to others, it must be under the GPLv3 or a permissive open-source license that can be linked against the GPLv3.
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@ -1,92 +0,0 @@
|
||||
# SRF Play Plugin - Usage Guide
|
||||
|
||||
## Finding the Channel in Jellyfin
|
||||
|
||||
After installing and enabling the plugin, you can access SRF Play content through the **Channels** feature in Jellyfin.
|
||||
|
||||
### Step-by-Step Instructions:
|
||||
|
||||
1. **Open Jellyfin** in your web browser
|
||||
|
||||
2. **Navigate to Channels**:
|
||||
- On the Jellyfin home screen, look in the left sidebar menu
|
||||
- Click on **"Live TV & Channels"** or **"Channels"**
|
||||
- You should see "SRF Play" listed among your available channels
|
||||
|
||||
3. **Browse Content**:
|
||||
- Click on the "SRF Play" channel
|
||||
- You'll see two main folders:
|
||||
- **Latest Videos** - Recently published SRF content
|
||||
- **Trending Videos** - Popular content from SRF
|
||||
|
||||
4. **Watch Videos**:
|
||||
- Click into either folder to browse videos
|
||||
- Click on any video to play it
|
||||
- Videos will stream directly via HLS
|
||||
|
||||
### Alternative Navigation:
|
||||
|
||||
If you don't see a "Channels" section in the sidebar:
|
||||
|
||||
1. Go to **Dashboard** (gear icon or Admin menu)
|
||||
2. Click on **"Live TV"** in the left menu
|
||||
3. Look for **"Channels"** section
|
||||
4. Enable the "SRF Play" channel if it's not already enabled
|
||||
|
||||
### Configuration:
|
||||
|
||||
To configure the plugin (change business unit, quality, etc.):
|
||||
|
||||
1. Go to **Dashboard** → **Plugins**
|
||||
2. Find **"SRF Play"** in the list
|
||||
3. Click on it to access settings
|
||||
4. Configure:
|
||||
- **Business Unit**: SRF, RTS, RSI, RTR, or SWI
|
||||
- **Quality Preference**: Auto, SD, or HD
|
||||
- **Enable Latest Content**: Show latest videos
|
||||
- **Enable Trending Content**: Show trending videos
|
||||
|
||||
### Troubleshooting:
|
||||
|
||||
**Channel not appearing?**
|
||||
- Restart Jellyfin after installing the plugin
|
||||
- Check Dashboard → Plugins to ensure SRF Play is enabled
|
||||
- Check the plugin configuration (Dashboard → Plugins → SRF Play)
|
||||
|
||||
**No videos showing?**
|
||||
- Ensure "Enable Latest Content" and/or "Enable Trending Content" are checked in plugin settings
|
||||
- Check Jellyfin logs for any error messages (Dashboard → Logs)
|
||||
- Verify your Jellyfin server has internet access to reach `il.srgssr.ch`
|
||||
|
||||
**Videos won't play?**
|
||||
- Check your Jellyfin server can access HLS streams
|
||||
- Verify firewall settings allow outbound HTTPS connections
|
||||
- Check the quality setting - try "Auto" if HD/SD isn't working
|
||||
|
||||
### What Content is Available?
|
||||
|
||||
- **Latest Videos**: The most recently published content from the selected business unit (SRF, RTS, etc.)
|
||||
- **Trending Videos**: Popular/trending content from the business unit
|
||||
- All videos are VOD (Video on Demand) - no live TV channels due to DRM restrictions
|
||||
|
||||
### Content Expiration:
|
||||
|
||||
SRF content has expiration dates. The plugin:
|
||||
- Automatically filters out expired content
|
||||
- Runs scheduled tasks to remove expired items from your library
|
||||
- Checks expiration every 24 hours (configurable)
|
||||
|
||||
### Notes:
|
||||
|
||||
- Only non-DRM content is accessible (no Widevine protected streams)
|
||||
- Live TV channels are not available due to DRM
|
||||
- Content availability depends on the selected business unit and SRF's content policies
|
||||
- The plugin shows up to 50 videos per category
|
||||
|
||||
## Need Help?
|
||||
|
||||
If you encounter issues:
|
||||
1. Check Jellyfin logs (Dashboard → Logs)
|
||||
2. Verify plugin configuration
|
||||
3. Ensure your network allows access to `il.srgssr.ch`
|
||||
4. Report issues on the plugin's GitHub repository
|
||||
@ -2,8 +2,8 @@
|
||||
name: "Template"
|
||||
guid: "eb5d7894-8eef-4b36-aa6f-5d124e828ce1"
|
||||
version: "1.0.0.0"
|
||||
targetAbi: "10.9.0.0"
|
||||
framework: "net8.0"
|
||||
targetAbi: "10.8.0.0"
|
||||
framework: "net6.0"
|
||||
overview: "Short description about your plugin"
|
||||
description: >
|
||||
This is a longer description that can span more than one
|
||||
|
||||
@ -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"
|
||||
@ -1,70 +0,0 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
class Program
|
||||
{
|
||||
static async Task Main(string[] args)
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
Proxy = new WebProxy("http://192.168.1.37:3128"),
|
||||
UseProxy = true,
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
CheckCertificateRevocationList = false
|
||||
};
|
||||
|
||||
using var client = new HttpClient(handler)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
DefaultRequestVersion = new Version(1, 1)
|
||||
};
|
||||
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("*/*");
|
||||
client.DefaultRequestHeaders.AcceptEncoding.ParseAdd("gzip, deflate, br");
|
||||
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("en-US,en;q=0.9");
|
||||
client.DefaultRequestHeaders.Connection.ParseAdd("keep-alive");
|
||||
|
||||
var url = "https://il.srgssr.ch/integrationlayer/2.0/mediaComposition/byUrn/urn:srf:video:b84713f0-f81b-460f-9b0f-d0517310fb4f.json";
|
||||
|
||||
Console.WriteLine($"Testing: {url}");
|
||||
Console.WriteLine("Using proxy: http://192.168.1.37:3128");
|
||||
Console.WriteLine();
|
||||
|
||||
try
|
||||
{
|
||||
Console.WriteLine("Making request...");
|
||||
var response = await client.GetAsync(url);
|
||||
Console.WriteLine($"Status Code: {response.StatusCode}");
|
||||
Console.WriteLine($"Headers:");
|
||||
foreach (var header in response.Headers)
|
||||
{
|
||||
Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}");
|
||||
}
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Console.WriteLine($"\nContent Length: {content.Length}");
|
||||
Console.WriteLine($"First 500 chars: {content.Substring(0, Math.Min(500, content.Length))}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Console.WriteLine($"\nError content: {content}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.GetType().Name}");
|
||||
Console.WriteLine($"Message: {ex.Message}");
|
||||
if (ex.InnerException != null)
|
||||
{
|
||||
Console.WriteLine($"Inner: {ex.InnerException.Message}");
|
||||
}
|
||||
Console.WriteLine($"\nStack trace:\n{ex.StackTrace}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user