diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 6b14277..b14b9cb 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -70,6 +70,13 @@ jobs: echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT echo "Found artifact: ${ARTIFACT}" + - name: Calculate checksum + id: checksum + run: | + CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}') + echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT + echo "Checksum: ${CHECKSUM}" + - name: Create Release env: GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -126,3 +133,38 @@ jobs: echo "✅ Release created successfully!" echo "View at: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${{ steps.get_version.outputs.version }}" + + - name: Update manifest.json + env: + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + REPO_OWNER="${{ github.repository_owner }}" + REPO_NAME="${{ github.event.repository.name }}" + GITEA_URL="${{ github.server_url }}" + VERSION="${{ steps.get_version.outputs.version_number }}" + CHECKSUM="${{ steps.checksum.outputs.checksum }}" + ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}" + TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/${{ steps.get_version.outputs.version }}/${ARTIFACT_NAME}" + + git config user.name "Gitea Actions" + git config user.email "actions@gitea.tourolle.paris" + git fetch origin master + git checkout master + + NEW_VERSION=$(cat < manifest.tmp && mv manifest.tmp manifest.json + git add manifest.json + git commit -m "Update manifest.json for version ${VERSION}" + git push origin master diff --git a/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs b/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs index 5879b95..b8625c8 100644 --- a/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs +++ b/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs @@ -107,10 +107,19 @@ public class SRFPlayChannel : IChannel, IHasCacheKey /// public Task GetChannelImage(ImageType type, CancellationToken cancellationToken) { - // Could provide a channel logo here + var assembly = GetType().Assembly; + var resourceStream = assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Images.logo.png"); + + if (resourceStream == null) + { + return Task.FromResult(new DynamicImageResponse { HasImage = false }); + } + return Task.FromResult(new DynamicImageResponse { - HasImage = false + HasImage = true, + Stream = resourceStream, + Format = MediaBrowser.Model.Drawing.ImageFormat.Png }); } @@ -491,21 +500,36 @@ public class SRFPlayChannel : IChannel, IHasCacheKey // Build overview var overview = chapter.Description ?? chapter.Lead; - // Get image URL - prefer chapter image, fall back to show image if available - var originalImageUrl = chapter.ImageUrl; - if (string.IsNullOrEmpty(originalImageUrl) && mediaComposition.Show != null) - { - originalImageUrl = mediaComposition.Show.ImageUrl; - _logger.LogDebug("URN {Urn}: Using show image as fallback", urn); - } + // Determine image URL based on configuration + string? imageUrl; + var generateTitleCards = config.GenerateTitleCards; - if (string.IsNullOrEmpty(originalImageUrl)) + if (generateTitleCards) { - _logger.LogWarning("URN {Urn}: No image URL available for '{Title}'", urn, chapter.Title); + // Generate title card with content name + _logger.LogDebug("URN {Urn}: Generating title card for '{Title}'", urn, chapter.Title); + imageUrl = CreatePlaceholderImageUrl(chapter.Title ?? "SRF Play", _mediaSourceFactory.GetServerBaseUrl()); } + else + { + // Use SRF-provided images - prefer chapter image, fall back to show image + var originalImageUrl = chapter.ImageUrl; + if (string.IsNullOrEmpty(originalImageUrl) && mediaComposition.Show != null) + { + originalImageUrl = mediaComposition.Show.ImageUrl; + _logger.LogDebug("URN {Urn}: Using show image as fallback", urn); + } - // Proxy image URL to fix Content-Type headers from SRF CDN - var imageUrl = CreateProxiedImageUrl(originalImageUrl, _mediaSourceFactory.GetServerBaseUrl()); + if (string.IsNullOrEmpty(originalImageUrl)) + { + _logger.LogDebug("URN {Urn}: No image URL available for '{Title}', using placeholder", urn, chapter.Title); + imageUrl = CreatePlaceholderImageUrl(chapter.Title ?? "SRF Play", _mediaSourceFactory.GetServerBaseUrl()); + } + else + { + imageUrl = CreateProxiedImageUrl(originalImageUrl, _mediaSourceFactory.GetServerBaseUrl()); + } + } // Use ValidFrom for premiere date if this is a scheduled livestream, otherwise use Date var premiereDate = chapter.Type == "SCHEDULED_LIVESTREAM" ? chapter.ValidFrom?.ToUniversalTime() : chapter.Date?.ToUniversalTime(); @@ -581,6 +605,18 @@ public class SRFPlayChannel : IChannel, IHasCacheKey return $"{serverUrl}/Plugins/SRFPlay/Proxy/Image?url={encodedUrl}"; } + /// + /// Creates a placeholder image URL with the given text. + /// + /// The text to display on the placeholder. + /// The server base URL. + /// The placeholder image URL. + private static string CreatePlaceholderImageUrl(string text, string serverUrl) + { + var encodedText = Convert.ToBase64String(Encoding.UTF8.GetBytes(text)); + return $"{serverUrl}/Plugins/SRFPlay/Proxy/Placeholder?text={encodedText}"; + } + /// public bool IsEnabledFor(string userId) { diff --git a/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs index 417ff0c..abd4c93 100644 --- a/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs @@ -74,6 +74,7 @@ public class PluginConfiguration : BasePluginConfiguration EnableTrendingContent = true; EnableCategoryFolders = true; EnabledTopics = new System.Collections.Generic.List(); + GenerateTitleCards = true; } /// @@ -149,4 +150,10 @@ public class PluginConfiguration : BasePluginConfiguration /// This is important for Android and other remote clients to access streams. /// public string PublicServerUrl { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether to generate title card images with the content title. + /// When enabled, generates custom thumbnails instead of using SRF-provided images. + /// + public bool GenerateTitleCards { get; set; } } diff --git a/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html b/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html index 1d34e2d..31379ad 100644 --- a/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html +++ b/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html @@ -58,6 +58,13 @@
Automatically discover and add trending videos
+
+ +
Generate custom thumbnails with the content title instead of using SRF-provided images
+

Proxy Settings

@@ -117,6 +124,7 @@ document.querySelector('#CacheDurationMinutes').value = config.CacheDurationMinutes; document.querySelector('#EnableLatestContent').checked = config.EnableLatestContent; document.querySelector('#EnableTrendingContent').checked = config.EnableTrendingContent; + document.querySelector('#GenerateTitleCards').checked = config.GenerateTitleCards !== false; document.querySelector('#UseProxy').checked = config.UseProxy || false; document.querySelector('#ProxyAddress').value = config.ProxyAddress || ''; document.querySelector('#ProxyUsername').value = config.ProxyUsername || ''; @@ -137,6 +145,7 @@ config.CacheDurationMinutes = parseInt(document.querySelector('#CacheDurationMinutes').value); config.EnableLatestContent = document.querySelector('#EnableLatestContent').checked; config.EnableTrendingContent = document.querySelector('#EnableTrendingContent').checked; + config.GenerateTitleCards = document.querySelector('#GenerateTitleCards').checked; config.UseProxy = document.querySelector('#UseProxy').checked; config.ProxyAddress = document.querySelector('#ProxyAddress').value; config.ProxyUsername = document.querySelector('#ProxyUsername').value; diff --git a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs index 88d8186..03e1aea 100644 --- a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs +++ b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs @@ -461,4 +461,38 @@ public class StreamProxyController : ControllerBase return StatusCode(StatusCodes.Status502BadGateway); } } + + /// + /// Generates a placeholder image with the given text centered. + /// + /// The text to display (base64 encoded). + /// A PNG image with the text centered. + [HttpGet("Placeholder")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public IActionResult GetPlaceholder([FromQuery] string text) + { + if (string.IsNullOrEmpty(text)) + { + return BadRequest("Text parameter is required"); + } + + string decodedText; + try + { + var bytes = Convert.FromBase64String(text); + decodedText = System.Text.Encoding.UTF8.GetString(bytes); + } + catch (FormatException) + { + _logger.LogWarning("Invalid base64 text parameter: {Text}", text); + return BadRequest("Invalid text encoding"); + } + + _logger.LogDebug("Generating placeholder image for: {Text}", decodedText); + + var imageStream = PlaceholderImageGenerator.GeneratePlaceholder(decodedText); + return File(imageStream, "image/png"); + } } diff --git a/Jellyfin.Plugin.SRFPlay/Jellyfin.Plugin.SRFPlay.csproj b/Jellyfin.Plugin.SRFPlay/Jellyfin.Plugin.SRFPlay.csproj index 5251330..f9005cf 100644 --- a/Jellyfin.Plugin.SRFPlay/Jellyfin.Plugin.SRFPlay.csproj +++ b/Jellyfin.Plugin.SRFPlay/Jellyfin.Plugin.SRFPlay.csproj @@ -14,6 +14,7 @@ + @@ -27,4 +28,9 @@ + + + + + diff --git a/Jellyfin.Plugin.SRFPlay/Utilities/PlaceholderImageGenerator.cs b/Jellyfin.Plugin.SRFPlay/Utilities/PlaceholderImageGenerator.cs new file mode 100644 index 0000000..3d05157 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Utilities/PlaceholderImageGenerator.cs @@ -0,0 +1,73 @@ +using System.IO; +using SkiaSharp; + +namespace Jellyfin.Plugin.SRFPlay.Utilities; + +/// +/// Generates placeholder images for content without thumbnails. +/// +public static class PlaceholderImageGenerator +{ + private const int Width = 640; + private const int Height = 360; // 16:9 aspect ratio + + /// + /// Generates a placeholder image with the given text centered. + /// + /// The text to display (typically channel/show name). + /// A memory stream containing the PNG image. + public static MemoryStream GeneratePlaceholder(string text) + { + using var surface = SKSurface.Create(new SKImageInfo(Width, Height)); + var canvas = surface.Canvas; + + // Dark gradient background + using var backgroundPaint = new SKPaint(); + using var shader = SKShader.CreateLinearGradient( + new SKPoint(0, 0), + new SKPoint(Width, Height), + new[] { new SKColor(45, 45, 48), new SKColor(28, 28, 30) }, + null, + SKShaderTileMode.Clamp); + backgroundPaint.Shader = shader; + canvas.DrawRect(0, 0, Width, Height, backgroundPaint); + + // Text + using var textPaint = new SKPaint + { + Color = SKColors.White, + IsAntialias = true, + TextAlign = SKTextAlign.Center, + Typeface = SKTypeface.FromFamilyName("Arial", SKFontStyle.Bold) + }; + + // Calculate font size to fit text (max 48, min 24) + float fontSize = 48; + textPaint.TextSize = fontSize; + var textWidth = textPaint.MeasureText(text); + var maxWidth = Width * 0.85f; + + if (textWidth > maxWidth) + { + fontSize = fontSize * maxWidth / textWidth; + fontSize = fontSize < 24 ? 24 : fontSize; + textPaint.TextSize = fontSize; + } + + // Center text vertically + SKRect textBounds = default; + textPaint.MeasureText(text, ref textBounds); + var yPos = (Height / 2) - textBounds.MidY; + + canvas.DrawText(text, Width / 2, yPos, textPaint); + + // Encode to PNG + using var image = surface.Snapshot(); + using var data = image.Encode(SKEncodedImageFormat.Png, 100); + + var stream = new MemoryStream(); + data.SaveTo(stream); + stream.Position = 0; + return stream; + } +} diff --git a/README.md b/README.md index aab9f47..0474ee9 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,17 @@ dotnet build The compiled plugin will be in `bin/Debug/net8.0/` -## Installation +## Quick Install + +Add this repository URL in Jellyfin (Dashboard → Plugins → Repositories): + +``` +https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/manifest.json +``` + +Then install "SRF Play" from the plugin catalog. + +## Manual Installation 1. Build the plugin (see above) 2. Copy the compiled DLL to your Jellyfin plugins directory diff --git a/assests/main logo.png b/assests/main logo.png new file mode 100644 index 0000000..06c4b2c Binary files /dev/null and b/assests/main logo.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..a52f856 --- /dev/null +++ b/manifest.json @@ -0,0 +1,12 @@ +[ + { + "guid": "a4b12f86-8c3d-4e9a-b7f2-1d5e6c8a9b4f", + "name": "SRF Play", + "description": "SRF Play plugin for Jellyfin enables streaming of content from SRF (Swiss Radio and Television). Access live TV channels and on-demand content from Switzerland's German-language public broadcaster directly within Jellyfin.", + "overview": "Stream SRF content in Jellyfin", + "owner": "dtourolle", + "category": "Live TV", + "imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png", + "versions": [] + } +]