Compare commits
No commits in common. "master" and "v1.0.26" have entirely different histories.
@ -1,170 +0,0 @@
|
||||
name: 'Latest Release'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'manifest.json'
|
||||
|
||||
jobs:
|
||||
latest-release:
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: build-${{ github.run_id }}
|
||||
|
||||
- name: Restore dependencies
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
||||
|
||||
- name: Build solution
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
|
||||
|
||||
- name: Run tests
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
|
||||
|
||||
- name: Build Jellyfin Plugin
|
||||
id: jprm
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
jprm --verbosity=debug plugin build .
|
||||
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
|
||||
ARTIFACT_NAME=$(basename "${ARTIFACT}")
|
||||
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
||||
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
|
||||
echo "Found artifact: ${ARTIFACT}"
|
||||
|
||||
- name: Calculate checksum
|
||||
id: checksum
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: |
|
||||
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
|
||||
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
||||
echo "Checksum: ${CHECKSUM}"
|
||||
|
||||
- name: Delete existing latest release
|
||||
working-directory: build-${{ github.run_id }}
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
REPO_OWNER="${{ github.repository_owner }}"
|
||||
REPO_NAME="${{ github.event.repository.name }}"
|
||||
GITEA_URL="${{ github.server_url }}"
|
||||
TAG="latest"
|
||||
|
||||
EXISTING=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}")
|
||||
|
||||
if [ "$EXISTING" = "200" ]; then
|
||||
RELEASE_ID=$(curl -s \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" | jq -r '.id')
|
||||
|
||||
echo "Deleting existing latest release (ID: ${RELEASE_ID})..."
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}"
|
||||
fi
|
||||
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/tags/${TAG}" || true
|
||||
|
||||
- name: Create latest release
|
||||
working-directory: build-${{ github.run_id }}
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
REPO_OWNER="${{ github.repository_owner }}"
|
||||
REPO_NAME="${{ github.event.repository.name }}"
|
||||
GITEA_URL="${{ github.server_url }}"
|
||||
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
|
||||
-d "$(jq -n --arg tag "latest" --arg name "Latest Build" --arg body "SRFPlay Jellyfin Plugin latest build from master." '{tag_name: $tag, name: $name, body: $body, target_commitish: "master", draft: false, prerelease: true}')")
|
||||
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
|
||||
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
|
||||
RELEASE_ID=$(echo "$BODY" | jq -r '.id')
|
||||
echo "Created release with ID: ${RELEASE_ID}"
|
||||
else
|
||||
echo "Failed to create release. HTTP ${HTTP_CODE}"
|
||||
echo "$BODY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Upload plugin artifact
|
||||
echo "Uploading plugin artifact..."
|
||||
curl -f -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/zip" \
|
||||
--data-binary "@${{ steps.jprm.outputs.artifact }}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
|
||||
|
||||
# Upload build.yaml
|
||||
echo "Uploading build.yaml..."
|
||||
curl -f -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/x-yaml" \
|
||||
--data-binary "@build.yaml" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=build.yaml"
|
||||
|
||||
echo "Latest release updated successfully!"
|
||||
|
||||
- name: Update manifest.json
|
||||
working-directory: build-${{ github.run_id }}
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
REPO_OWNER="${{ github.repository_owner }}"
|
||||
REPO_NAME="${{ github.event.repository.name }}"
|
||||
GITEA_URL="${{ github.server_url }}"
|
||||
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/latest/${ARTIFACT_NAME}"
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
|
||||
git config user.name "Gitea Actions"
|
||||
git config user.email "actions@gitea.tourolle.paris"
|
||||
git fetch origin master
|
||||
git checkout master
|
||||
|
||||
# Remove existing "latest" entry if present, then prepend new one
|
||||
jq --arg url "$DOWNLOAD_URL" 'if .[0].versions[0].changelog == "Latest Build" then .[0].versions = .[0].versions[1:] else . end' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
|
||||
|
||||
NEW_VERSION=$(cat <<EOF
|
||||
{
|
||||
"version": "0.0.0.0",
|
||||
"changelog": "Latest Build",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "${DOWNLOAD_URL}",
|
||||
"checksum": "${CHECKSUM}",
|
||||
"timestamp": "${TIMESTAMP}"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
|
||||
git add manifest.json
|
||||
git commit -m "Update manifest.json for latest build (${SHORT_SHA})" || echo "No changes to commit"
|
||||
git push origin master
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: rm -rf build-${{ github.run_id }}
|
||||
@ -6,7 +6,6 @@ using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Constants;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
@ -16,7 +15,6 @@ using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Channels;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
||||
@ -32,7 +30,6 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
private readonly IMediaSourceFactory _mediaSourceFactory;
|
||||
private readonly ICategoryService? _categoryService;
|
||||
private readonly ISRFApiClientFactory _apiClientFactory;
|
||||
private readonly IRecordingService _recordingService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SRFPlayChannel"/> class.
|
||||
@ -43,15 +40,13 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
/// <param name="mediaSourceFactory">The media source factory.</param>
|
||||
/// <param name="categoryService">The category service (optional).</param>
|
||||
/// <param name="apiClientFactory">The API client factory.</param>
|
||||
/// <param name="recordingService">The recording service.</param>
|
||||
public SRFPlayChannel(
|
||||
ILoggerFactory loggerFactory,
|
||||
IContentRefreshService contentRefreshService,
|
||||
IStreamUrlResolver streamResolver,
|
||||
IMediaSourceFactory mediaSourceFactory,
|
||||
ICategoryService? categoryService,
|
||||
ISRFApiClientFactory apiClientFactory,
|
||||
IRecordingService recordingService)
|
||||
ISRFApiClientFactory apiClientFactory)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<SRFPlayChannel>();
|
||||
_contentRefreshService = contentRefreshService;
|
||||
@ -59,7 +54,6 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
_mediaSourceFactory = mediaSourceFactory;
|
||||
_categoryService = categoryService;
|
||||
_apiClientFactory = apiClientFactory;
|
||||
_recordingService = recordingService;
|
||||
|
||||
if (_categoryService == null)
|
||||
{
|
||||
@ -176,7 +170,6 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
"latest" => await GetLatestVideosAsync(cancellationToken).ConfigureAwait(false),
|
||||
"trending" => await GetTrendingVideosAsync(cancellationToken).ConfigureAwait(false),
|
||||
"live_sports" => await GetLiveSportsAsync(cancellationToken).ConfigureAwait(false),
|
||||
"recordings" => GetRecordingItems(),
|
||||
_ when folderId.StartsWith("category_", StringComparison.Ordinal) => await GetCategoryVideosAsync(folderId, cancellationToken).ConfigureAwait(false),
|
||||
_ => new List<ChannelItemInfo>()
|
||||
};
|
||||
@ -188,8 +181,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
{
|
||||
CreateFolder("latest", "Latest Videos"),
|
||||
CreateFolder("trending", "Trending Videos"),
|
||||
CreateFolder("live_sports", "Live Sports & Events"),
|
||||
CreateFolder("recordings", "Recordings")
|
||||
CreateFolder("live_sports", "Live Sports & Events")
|
||||
};
|
||||
|
||||
// Add category folders if enabled
|
||||
@ -304,58 +296,6 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
return items;
|
||||
}
|
||||
|
||||
private List<ChannelItemInfo> GetRecordingItems()
|
||||
{
|
||||
var items = new List<ChannelItemInfo>();
|
||||
var recordings = _recordingService.GetRecordings(RecordingState.Completed);
|
||||
|
||||
foreach (var recording in recordings)
|
||||
{
|
||||
if (string.IsNullOrEmpty(recording.OutputPath) || !System.IO.File.Exists(recording.OutputPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fileInfo = new System.IO.FileInfo(recording.OutputPath);
|
||||
var itemId = $"recording_{recording.Id}";
|
||||
|
||||
var mediaSource = new MediaSourceInfo
|
||||
{
|
||||
Id = itemId,
|
||||
Name = recording.Title,
|
||||
Path = recording.OutputPath,
|
||||
Protocol = MediaProtocol.File,
|
||||
Container = "mkv",
|
||||
SupportsDirectPlay = true,
|
||||
SupportsDirectStream = true,
|
||||
SupportsTranscoding = true,
|
||||
IsRemote = false,
|
||||
Size = fileInfo.Length,
|
||||
Type = MediaSourceType.Default
|
||||
};
|
||||
|
||||
var item = new ChannelItemInfo
|
||||
{
|
||||
Id = itemId,
|
||||
Name = recording.Title,
|
||||
Overview = recording.Description,
|
||||
Type = ChannelItemType.Media,
|
||||
ContentType = ChannelMediaContentType.Movie,
|
||||
MediaType = ChannelMediaType.Video,
|
||||
DateCreated = recording.RecordingStartedAt,
|
||||
ImageUrl = !string.IsNullOrEmpty(recording.ImageUrl)
|
||||
? CreateProxiedImageUrl(recording.ImageUrl, _mediaSourceFactory.GetServerBaseUrl())
|
||||
: CreatePlaceholderImageUrl(recording.Title, _mediaSourceFactory.GetServerBaseUrl()),
|
||||
MediaSources = new List<MediaSourceInfo> { mediaSource }
|
||||
};
|
||||
|
||||
items.Add(item);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Returning {Count} completed recordings as channel items", items.Count);
|
||||
return items;
|
||||
}
|
||||
|
||||
private async Task<List<ChannelItemInfo>> GetCategoryVideosAsync(string folderId, CancellationToken cancellationToken)
|
||||
{
|
||||
var items = new List<ChannelItemInfo>();
|
||||
@ -471,8 +411,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 15) * 15, 0);
|
||||
var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
|
||||
|
||||
var recordingCount = _recordingService.GetRecordings(RecordingState.Completed).Count;
|
||||
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingCount}";
|
||||
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}";
|
||||
}
|
||||
|
||||
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
|
||||
|
||||
@ -1,344 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SRF Sport Recordings</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 20px; }
|
||||
h1 { color: #fff; margin-bottom: 8px; font-size: 1.8em; }
|
||||
h2 { color: #ccc; margin: 24px 0 12px; font-size: 1.3em; border-bottom: 1px solid #333; padding-bottom: 6px; }
|
||||
.subtitle { color: #999; margin-bottom: 20px; font-size: 0.9em; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 10px 0 20px; }
|
||||
th { text-align: left; padding: 10px 8px; border-bottom: 2px solid #444; color: #aaa; font-size: 0.85em; text-transform: uppercase; }
|
||||
td { padding: 10px 8px; border-bottom: 1px solid #2a2a3e; }
|
||||
tr:hover { background: #2a2a3e; }
|
||||
.btn { padding: 6px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.85em; color: #fff; transition: opacity 0.2s; }
|
||||
.btn:hover { opacity: 0.85; }
|
||||
.btn-record { background: #4CAF50; }
|
||||
.btn-stop { background: #FF9800; }
|
||||
.btn-cancel { background: #9E9E9E; }
|
||||
.btn-delete { background: #f44336; }
|
||||
.btn-refresh { background: #2196F3; margin-bottom: 16px; }
|
||||
.status { padding: 3px 8px; border-radius: 3px; font-size: 0.8em; font-weight: 600; }
|
||||
.status-scheduled { background: #1565C0; }
|
||||
.status-waiting { background: #E65100; }
|
||||
.status-recording { background: #2E7D32; }
|
||||
.status-failed { background: #C62828; }
|
||||
.msg { padding: 12px; color: #999; font-style: italic; }
|
||||
.error { color: #f44; }
|
||||
.login-form { max-width: 400px; margin: 60px auto; }
|
||||
.login-form input { width: 100%; padding: 10px; margin: 8px 0; background: #2a2a3e; border: 1px solid #444; color: #fff; border-radius: 4px; font-size: 1em; }
|
||||
.login-form .btn { width: 100%; padding: 12px; font-size: 1em; margin-top: 12px; }
|
||||
@media (max-width: 600px) { body { padding: 10px; } td, th { padding: 6px 4px; font-size: 0.85em; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="loginSection" style="display:none;">
|
||||
<div class="login-form">
|
||||
<h1>SRF Sport Recordings</h1>
|
||||
<p class="subtitle">Sign in to your Jellyfin server</p>
|
||||
<input type="text" id="loginServer" placeholder="Server URL (e.g. http://192.168.1.50:8096)" />
|
||||
<input type="text" id="loginUser" placeholder="Username" />
|
||||
<input type="password" id="loginPass" placeholder="Password" />
|
||||
<button class="btn btn-record" onclick="SRFRec.login()">Sign In</button>
|
||||
<p id="loginError" class="error" style="margin-top:10px;"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mainSection" style="display:none;">
|
||||
<h1>SRF Sport Recordings</h1>
|
||||
<p class="subtitle">Browse upcoming sport livestreams and schedule recordings. <span id="userInfo"></span></p>
|
||||
|
||||
<h2>Upcoming Sport Livestreams</h2>
|
||||
<button class="btn btn-refresh" onclick="SRFRec.loadSchedule()">Refresh Schedule</button>
|
||||
<div id="scheduleContainer"><p class="msg">Loading schedule...</p></div>
|
||||
|
||||
<h2>Scheduled & Active Recordings</h2>
|
||||
<div id="activeRecordingsContainer"><p class="msg">Loading...</p></div>
|
||||
|
||||
<h2>Completed Recordings</h2>
|
||||
<button class="btn btn-refresh" onclick="SRFRec.loadRecordings()">Refresh</button>
|
||||
<div id="completedRecordingsContainer"><p class="msg">Loading...</p></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var SRFRec = {
|
||||
serverUrl: '',
|
||||
token: '',
|
||||
|
||||
init: function() {
|
||||
// Check if we're inside Jellyfin's web client (ApiClient available)
|
||||
if (typeof ApiClient !== 'undefined' && ApiClient.accessToken()) {
|
||||
this.serverUrl = ApiClient.serverAddress();
|
||||
this.token = ApiClient.accessToken();
|
||||
this.showMain();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check localStorage for saved session
|
||||
var saved = localStorage.getItem('srfRecSession');
|
||||
if (saved) {
|
||||
try {
|
||||
var session = JSON.parse(saved);
|
||||
this.serverUrl = session.serverUrl;
|
||||
this.token = session.token;
|
||||
// Verify token still works
|
||||
this.verifySession(session);
|
||||
return;
|
||||
} catch(e) { /* fall through to login */ }
|
||||
}
|
||||
|
||||
// Show login form
|
||||
var serverInput = document.getElementById('loginServer');
|
||||
serverInput.value = window.location.origin;
|
||||
document.getElementById('loginSection').style.display = 'block';
|
||||
},
|
||||
|
||||
verifySession: function(session) {
|
||||
var self = this;
|
||||
fetch(this.serverUrl + '/System/Info', {
|
||||
headers: { 'X-Emby-Token': this.token }
|
||||
}).then(function(r) {
|
||||
if (r.ok) {
|
||||
document.getElementById('userInfo').textContent = '(Server: ' + self.serverUrl + ')';
|
||||
self.showMain();
|
||||
} else {
|
||||
localStorage.removeItem('srfRecSession');
|
||||
document.getElementById('loginSection').style.display = 'block';
|
||||
}
|
||||
}).catch(function() {
|
||||
localStorage.removeItem('srfRecSession');
|
||||
document.getElementById('loginSection').style.display = 'block';
|
||||
});
|
||||
},
|
||||
|
||||
login: function() {
|
||||
var self = this;
|
||||
var server = document.getElementById('loginServer').value.replace(/\/+$/, '');
|
||||
var user = document.getElementById('loginUser').value;
|
||||
var pass = document.getElementById('loginPass').value;
|
||||
var errorEl = document.getElementById('loginError');
|
||||
errorEl.textContent = '';
|
||||
|
||||
fetch(server + '/Users/AuthenticateByName', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Emby-Authorization': 'MediaBrowser Client="SRF Recordings", Device="Web", DeviceId="srfrec-' + Date.now() + '", Version="1.0.0"'
|
||||
},
|
||||
body: JSON.stringify({ Username: user, Pw: pass })
|
||||
})
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error('Authentication failed');
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
self.serverUrl = server;
|
||||
self.token = data.AccessToken;
|
||||
localStorage.setItem('srfRecSession', JSON.stringify({
|
||||
serverUrl: server,
|
||||
token: data.AccessToken,
|
||||
userName: data.User.Name
|
||||
}));
|
||||
document.getElementById('userInfo').textContent = '(Signed in as ' + data.User.Name + ')';
|
||||
self.showMain();
|
||||
})
|
||||
.catch(function(err) {
|
||||
errorEl.textContent = err.message;
|
||||
});
|
||||
},
|
||||
|
||||
showMain: function() {
|
||||
document.getElementById('loginSection').style.display = 'none';
|
||||
document.getElementById('mainSection').style.display = 'block';
|
||||
this.loadSchedule();
|
||||
this.loadRecordings();
|
||||
},
|
||||
|
||||
getHeaders: function() {
|
||||
return { 'X-Emby-Token': this.token };
|
||||
},
|
||||
|
||||
formatDate: function(dateStr) {
|
||||
if (!dateStr) return 'N/A';
|
||||
var d = new Date(dateStr);
|
||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||
},
|
||||
|
||||
formatSize: function(bytes) {
|
||||
if (!bytes) return '';
|
||||
if (bytes > 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||
if (bytes > 1048576) return (bytes / 1048576).toFixed(0) + ' MB';
|
||||
return (bytes / 1024).toFixed(0) + ' KB';
|
||||
},
|
||||
|
||||
loadSchedule: function() {
|
||||
var container = document.getElementById('scheduleContainer');
|
||||
container.innerHTML = '<p class="msg">Loading schedule...</p>';
|
||||
|
||||
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule', { headers: this.getHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(programs) {
|
||||
if (!programs || programs.length === 0) {
|
||||
container.innerHTML = '<p class="msg">No upcoming sport livestreams found.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '<table>';
|
||||
html += '<thead><tr><th>Title</th><th>Start</th><th>End</th><th>Action</th></tr></thead><tbody>';
|
||||
|
||||
programs.forEach(function(p) {
|
||||
html += '<tr>';
|
||||
html += '<td>' + (p.title || 'Unknown') + '</td>';
|
||||
html += '<td>' + SRFRec.formatDate(p.validFrom || p.date) + '</td>';
|
||||
html += '<td>' + SRFRec.formatDate(p.validTo) + '</td>';
|
||||
html += '<td><button class="btn btn-record" onclick="SRFRec.scheduleRecording(\'' + encodeURIComponent(p.urn) + '\')">Record</button></td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
})
|
||||
.catch(function(err) {
|
||||
container.innerHTML = '<p class="error">Error loading schedule: ' + err.message + '</p>';
|
||||
});
|
||||
},
|
||||
|
||||
scheduleRecording: function(encodedUrn) {
|
||||
var self = this;
|
||||
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule/' + encodedUrn, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
.then(function(r) {
|
||||
if (r.ok) {
|
||||
alert('Recording scheduled!');
|
||||
self.loadRecordings();
|
||||
} else {
|
||||
alert('Failed to schedule recording');
|
||||
}
|
||||
})
|
||||
.catch(function(err) { alert('Error: ' + err.message); });
|
||||
},
|
||||
|
||||
loadRecordings: function() {
|
||||
this.loadActiveRecordings();
|
||||
this.loadCompletedRecordings();
|
||||
},
|
||||
|
||||
loadActiveRecordings: function() {
|
||||
var container = document.getElementById('activeRecordingsContainer');
|
||||
container.innerHTML = '<p class="msg">Loading...</p>';
|
||||
|
||||
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/All', { headers: this.getHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(recordings) {
|
||||
var activeStates = ['Scheduled', 'WaitingForStream', 'Recording', 0, 1, 2];
|
||||
var active = recordings.filter(function(r) {
|
||||
return activeStates.indexOf(r.state) !== -1;
|
||||
});
|
||||
|
||||
if (active.length === 0) {
|
||||
container.innerHTML = '<p class="msg">No scheduled or active recordings.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var stateMap = {
|
||||
'Scheduled': {label: 'Scheduled', cls: 'status-scheduled'},
|
||||
'WaitingForStream': {label: 'Waiting', cls: 'status-waiting'},
|
||||
'Recording': {label: 'Recording', cls: 'status-recording'},
|
||||
0: {label: 'Scheduled', cls: 'status-scheduled'},
|
||||
1: {label: 'Waiting', cls: 'status-waiting'},
|
||||
2: {label: 'Recording', cls: 'status-recording'}
|
||||
};
|
||||
|
||||
var html = '<table>';
|
||||
html += '<thead><tr><th>Title</th><th>Status</th><th>Start</th><th>Action</th></tr></thead><tbody>';
|
||||
|
||||
active.forEach(function(r) {
|
||||
var st = stateMap[r.state] || {label: r.state, cls: ''};
|
||||
html += '<tr>';
|
||||
html += '<td>' + (r.title || 'Unknown') + '</td>';
|
||||
html += '<td><span class="status ' + st.cls + '">' + st.label + '</span></td>';
|
||||
html += '<td>' + SRFRec.formatDate(r.validFrom) + '</td>';
|
||||
html += '<td>';
|
||||
if (r.state === 2 || r.state === 'Recording') {
|
||||
html += '<button class="btn btn-stop" onclick="SRFRec.stopRecording(\'' + r.id + '\')">Stop</button>';
|
||||
} else {
|
||||
html += '<button class="btn btn-cancel" onclick="SRFRec.cancelRecording(\'' + r.id + '\')">Cancel</button>';
|
||||
}
|
||||
html += '</td></tr>';
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
})
|
||||
.catch(function(err) {
|
||||
container.innerHTML = '<p class="error">Error: ' + err.message + '</p>';
|
||||
});
|
||||
},
|
||||
|
||||
loadCompletedRecordings: function() {
|
||||
var container = document.getElementById('completedRecordingsContainer');
|
||||
container.innerHTML = '<p class="msg">Loading...</p>';
|
||||
|
||||
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Completed', { headers: this.getHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(recordings) {
|
||||
if (!recordings || recordings.length === 0) {
|
||||
container.innerHTML = '<p class="msg">No completed recordings.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '<table>';
|
||||
html += '<thead><tr><th>Title</th><th>Recorded</th><th>Size</th><th>Action</th></tr></thead><tbody>';
|
||||
|
||||
recordings.forEach(function(r) {
|
||||
html += '<tr>';
|
||||
html += '<td>' + (r.title || 'Unknown') + '</td>';
|
||||
html += '<td>' + SRFRec.formatDate(r.recordingStartedAt) + '</td>';
|
||||
html += '<td>' + SRFRec.formatSize(r.fileSizeBytes) + '</td>';
|
||||
html += '<td><button class="btn btn-delete" onclick="SRFRec.deleteRecording(\'' + r.id + '\')">Delete</button></td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
})
|
||||
.catch(function(err) {
|
||||
container.innerHTML = '<p class="error">Error: ' + err.message + '</p>';
|
||||
});
|
||||
},
|
||||
|
||||
stopRecording: function(id) {
|
||||
var self = this;
|
||||
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Active/' + id + '/Stop', {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders()
|
||||
}).then(function() { self.loadRecordings(); });
|
||||
},
|
||||
|
||||
cancelRecording: function(id) {
|
||||
var self = this;
|
||||
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule/' + id, {
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders()
|
||||
}).then(function() { self.loadRecordings(); });
|
||||
},
|
||||
|
||||
deleteRecording: function(id) {
|
||||
if (!confirm('Delete this recording and its file?')) return;
|
||||
var self = this;
|
||||
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Completed/' + id + '?deleteFile=true', {
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders()
|
||||
}).then(function() { self.loadRecordings(); });
|
||||
}
|
||||
};
|
||||
|
||||
SRFRec.init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,5 +1,3 @@
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
@ -35,26 +33,6 @@ public class RecordingController : ControllerBase
|
||||
_recordingService = recordingService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the recording manager page accessible to any authenticated user.
|
||||
/// </summary>
|
||||
/// <returns>The recording manager HTML page.</returns>
|
||||
[HttpGet("Page")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public IActionResult GetRecordingPage()
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var resourceStream = assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Configuration.recordingPage.html");
|
||||
|
||||
if (resourceStream == null)
|
||||
{
|
||||
return NotFound("Recording page not found");
|
||||
}
|
||||
|
||||
return File(resourceStream, "text/html");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets upcoming sport livestreams available for recording.
|
||||
/// </summary>
|
||||
|
||||
@ -26,8 +26,6 @@
|
||||
<ItemGroup>
|
||||
<None Remove="Configuration\configPage.html" />
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
<None Remove="Configuration\recordingPage.html" />
|
||||
<EmbeddedResource Include="Configuration\recordingPage.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -45,15 +45,6 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
Name = Name,
|
||||
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
|
||||
},
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = "SRF Play Recordings",
|
||||
DisplayName = "SRF Sport Recordings",
|
||||
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.recordingPage.html", GetType().Namespace),
|
||||
EnableInMainMenu = true,
|
||||
MenuSection = "Live TV",
|
||||
MenuIcon = "fiber_smart_record"
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@ -160,7 +160,7 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
|
||||
// Filter to only future/current livestreams that aren't blocked
|
||||
return livestreams
|
||||
.Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo.Value.ToUniversalTime() > DateTime.UtcNow))
|
||||
.Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo > DateTime.UtcNow))
|
||||
.OrderBy(ls => ls.ValidFrom)
|
||||
.ToList();
|
||||
}
|
||||
@ -308,7 +308,7 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
public async Task ProcessRecordingsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Prevent overlapping scheduler runs from spawning duplicate ffmpeg processes
|
||||
if (!await _processLock.WaitAsync(0, CancellationToken.None).ConfigureAwait(false))
|
||||
if (!await _processLock.WaitAsync(0).ConfigureAwait(false))
|
||||
{
|
||||
_logger.LogDebug("ProcessRecordingsAsync already running, skipping");
|
||||
return;
|
||||
@ -333,23 +333,13 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
|
||||
foreach (var entry in _recordings.ToList())
|
||||
{
|
||||
// Normalize ValidFrom/ValidTo to UTC for correct comparison
|
||||
var validFromUtc = entry.ValidFrom.HasValue ? entry.ValidFrom.Value.ToUniversalTime() : (DateTime?)null;
|
||||
var validToUtc = entry.ValidTo.HasValue ? entry.ValidTo.Value.ToUniversalTime() : (DateTime?)null;
|
||||
|
||||
switch (entry.State)
|
||||
{
|
||||
case RecordingState.Scheduled:
|
||||
case RecordingState.WaitingForStream:
|
||||
// Check if it's time to start recording
|
||||
if (validFromUtc.HasValue && validFromUtc.Value <= now.AddMinutes(2))
|
||||
if (entry.ValidFrom.HasValue && entry.ValidFrom.Value <= now.AddMinutes(2))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Time to start recording '{Title}': ValidFrom={ValidFrom} (UTC: {ValidFromUtc}), Now={Now}",
|
||||
entry.Title,
|
||||
entry.ValidFrom,
|
||||
validFromUtc,
|
||||
now);
|
||||
changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@ -357,7 +347,7 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
|
||||
case RecordingState.Recording:
|
||||
// Check if recording should stop (ValidTo reached or process died)
|
||||
if (validToUtc.HasValue && validToUtc.Value <= now)
|
||||
if (entry.ValidTo.HasValue && entry.ValidTo.Value <= now)
|
||||
{
|
||||
_logger.LogInformation("Recording '{Title}' reached ValidTo, stopping", entry.Title);
|
||||
StopFfmpeg(entry.Id);
|
||||
|
||||
@ -8,30 +8,6 @@
|
||||
"category": "Live TV",
|
||||
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.0.0.0",
|
||||
"changelog": "Latest Build",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
|
||||
"checksum": "a58587b0c596992eb059885b20246d21",
|
||||
"timestamp": "2026-03-07T17:41:15Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.27",
|
||||
"changelog": "Release 1.0.27",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.27/srfplay_1.0.27.0.zip",
|
||||
"checksum": "1e15e35452f7b82bf74d8c3560c15949",
|
||||
"timestamp": "2026-03-07T16:40:17Z"
|
||||
},
|
||||
{
|
||||
"version": "0.0.0.0",
|
||||
"changelog": "Latest Build",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
|
||||
"checksum": "c7e868d23293adcc21d72e735094d9d6",
|
||||
"timestamp": "2026-03-07T16:28:38Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.25",
|
||||
"changelog": "Release 1.0.25",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user