Add recording of livestreams
This commit is contained in:
parent
5f6e928409
commit
87bdec280b
94
Jellyfin.Plugin.SRFPlay/Api/Models/RecordingEntry.cs
Normal file
94
Jellyfin.Plugin.SRFPlay/Api/Models/RecordingEntry.cs
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A scheduled or active recording.
|
||||||
|
/// </summary>
|
||||||
|
public class RecordingEntry
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the unique recording ID.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the SRF URN.
|
||||||
|
/// </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 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 when the livestream starts.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("validFrom")]
|
||||||
|
public DateTime? ValidFrom { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets when the livestream ends.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("validTo")]
|
||||||
|
public DateTime? ValidTo { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the recording state.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("state")]
|
||||||
|
public RecordingState State { get; set; } = RecordingState.Scheduled;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the output file path.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("outputPath")]
|
||||||
|
public string? OutputPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets when the recording actually started.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("recordingStartedAt")]
|
||||||
|
public DateTime? RecordingStartedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets when the recording ended.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("recordingEndedAt")]
|
||||||
|
public DateTime? RecordingEndedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the file size in bytes.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("fileSizeBytes")]
|
||||||
|
public long? FileSizeBytes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the error message if recording failed.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("errorMessage")]
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets when this entry was created.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("createdAt")]
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
25
Jellyfin.Plugin.SRFPlay/Api/Models/RecordingState.cs
Normal file
25
Jellyfin.Plugin.SRFPlay/Api/Models/RecordingState.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// State of a recording.
|
||||||
|
/// </summary>
|
||||||
|
public enum RecordingState
|
||||||
|
{
|
||||||
|
/// <summary>Scheduled for future recording.</summary>
|
||||||
|
Scheduled,
|
||||||
|
|
||||||
|
/// <summary>Waiting for stream to become available.</summary>
|
||||||
|
WaitingForStream,
|
||||||
|
|
||||||
|
/// <summary>Currently recording.</summary>
|
||||||
|
Recording,
|
||||||
|
|
||||||
|
/// <summary>Recording completed successfully.</summary>
|
||||||
|
Completed,
|
||||||
|
|
||||||
|
/// <summary>Recording failed.</summary>
|
||||||
|
Failed,
|
||||||
|
|
||||||
|
/// <summary>Recording was cancelled.</summary>
|
||||||
|
Cancelled
|
||||||
|
}
|
||||||
@ -156,4 +156,9 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// When enabled, generates custom thumbnails instead of using SRF-provided images.
|
/// When enabled, generates custom thumbnails instead of using SRF-provided images.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool GenerateTitleCards { get; set; }
|
public bool GenerateTitleCards { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the output directory for sport livestream recordings.
|
||||||
|
/// </summary>
|
||||||
|
public string RecordingOutputPath { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,6 +90,13 @@
|
|||||||
<div class="fieldDescription">Password for proxy authentication (leave empty if not required)</div>
|
<div class="fieldDescription">Password for proxy authentication (leave empty if not required)</div>
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
|
<h2>Recording Settings</h2>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="RecordingOutputPath">Recording Output Directory</label>
|
||||||
|
<input id="RecordingOutputPath" name="RecordingOutputPath" type="text" is="emby-input" placeholder="e.g., /media/recordings/srf" />
|
||||||
|
<div class="fieldDescription">Directory where sport livestream recordings will be saved (requires ffmpeg)</div>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
<h2>Network Settings</h2>
|
<h2>Network Settings</h2>
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
<label class="inputLabel inputLabelUnfocused" for="PublicServerUrl">Public Server URL (Optional)</label>
|
<label class="inputLabel inputLabelUnfocused" for="PublicServerUrl">Public Server URL (Optional)</label>
|
||||||
@ -106,6 +113,31 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
<h2>Sport Livestream Recordings</h2>
|
||||||
|
|
||||||
|
<h3>Upcoming Sport Livestreams</h3>
|
||||||
|
<div class="fieldDescription">Select livestreams to record. The recording starts automatically when the stream goes live.</div>
|
||||||
|
<div id="scheduleContainer" style="margin: 10px 0;">
|
||||||
|
<p><em>Loading schedule...</em></p>
|
||||||
|
</div>
|
||||||
|
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayRecordings.loadSchedule()" style="margin-bottom: 20px;">
|
||||||
|
<span>Refresh Schedule</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h3>Scheduled & Active Recordings</h3>
|
||||||
|
<div id="activeRecordingsContainer" style="margin: 10px 0;">
|
||||||
|
<p><em>Loading...</em></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Completed Recordings</h3>
|
||||||
|
<div id="completedRecordingsContainer" style="margin: 10px 0;">
|
||||||
|
<p><em>Loading...</em></p>
|
||||||
|
</div>
|
||||||
|
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayRecordings.loadRecordings()" style="margin-bottom: 20px;">
|
||||||
|
<span>Refresh Recordings</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
@ -130,7 +162,12 @@
|
|||||||
document.querySelector('#ProxyUsername').value = config.ProxyUsername || '';
|
document.querySelector('#ProxyUsername').value = config.ProxyUsername || '';
|
||||||
document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
|
document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
|
||||||
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
|
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
|
||||||
|
document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || '';
|
||||||
Dashboard.hideLoadingMsg();
|
Dashboard.hideLoadingMsg();
|
||||||
|
|
||||||
|
// Load recordings UI
|
||||||
|
SRFPlayRecordings.loadSchedule();
|
||||||
|
SRFPlayRecordings.loadRecordings();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -151,6 +188,7 @@
|
|||||||
config.ProxyUsername = document.querySelector('#ProxyUsername').value;
|
config.ProxyUsername = document.querySelector('#ProxyUsername').value;
|
||||||
config.ProxyPassword = document.querySelector('#ProxyPassword').value;
|
config.ProxyPassword = document.querySelector('#ProxyPassword').value;
|
||||||
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
|
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
|
||||||
|
config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value;
|
||||||
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
|
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
|
||||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||||
});
|
});
|
||||||
@ -159,6 +197,204 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var SRFPlayRecordings = {
|
||||||
|
apiBase: ApiClient.serverAddress() + '/Plugins/SRFPlay/Recording',
|
||||||
|
|
||||||
|
getHeaders: function() {
|
||||||
|
return {
|
||||||
|
'X-Emby-Token': ApiClient.accessToken()
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
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><em>Loading schedule...</em></p>';
|
||||||
|
|
||||||
|
fetch(this.apiBase + '/Schedule', { headers: this.getHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(programs) {
|
||||||
|
if (!programs || programs.length === 0) {
|
||||||
|
container.innerHTML = '<p>No upcoming sport livestreams found.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = '<table style="width:100%; border-collapse: collapse;">';
|
||||||
|
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
|
||||||
|
html += '<th style="padding: 8px;">Title</th>';
|
||||||
|
html += '<th style="padding: 8px;">Start</th>';
|
||||||
|
html += '<th style="padding: 8px;">End</th>';
|
||||||
|
html += '<th style="padding: 8px;">Action</th>';
|
||||||
|
html += '</tr></thead><tbody>';
|
||||||
|
|
||||||
|
programs.forEach(function(p) {
|
||||||
|
html += '<tr style="border-bottom: 1px solid #333;">';
|
||||||
|
html += '<td style="padding: 8px;">' + (p.title || 'Unknown') + '</td>';
|
||||||
|
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(p.validFrom || p.date) + '</td>';
|
||||||
|
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(p.validTo) + '</td>';
|
||||||
|
html += '<td style="padding: 8px;">';
|
||||||
|
html += '<button is="emby-button" type="button" class="raised emby-button" ';
|
||||||
|
html += 'onclick="SRFPlayRecordings.scheduleRecording(\'' + encodeURIComponent(p.urn) + '\')">';
|
||||||
|
html += '<span>Record</span></button>';
|
||||||
|
html += '</td></tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
container.innerHTML = '<p style="color: #f44;">Error loading schedule: ' + err.message + '</p>';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
scheduleRecording: function(encodedUrn) {
|
||||||
|
fetch(this.apiBase + '/Schedule/' + encodedUrn, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.getHeaders()
|
||||||
|
})
|
||||||
|
.then(function(r) {
|
||||||
|
if (r.ok) {
|
||||||
|
Dashboard.alert('Recording scheduled!');
|
||||||
|
SRFPlayRecordings.loadRecordings();
|
||||||
|
} else {
|
||||||
|
Dashboard.alert('Failed to schedule recording');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) { Dashboard.alert('Error: ' + err.message); });
|
||||||
|
},
|
||||||
|
|
||||||
|
loadRecordings: function() {
|
||||||
|
this.loadActiveRecordings();
|
||||||
|
this.loadCompletedRecordings();
|
||||||
|
},
|
||||||
|
|
||||||
|
loadActiveRecordings: function() {
|
||||||
|
var container = document.getElementById('activeRecordingsContainer');
|
||||||
|
container.innerHTML = '<p><em>Loading...</em></p>';
|
||||||
|
|
||||||
|
fetch(this.apiBase + '/All', { headers: this.getHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(recordings) {
|
||||||
|
var active = recordings.filter(function(r) {
|
||||||
|
return r.state === 0 || r.state === 1 || r.state === 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (active.length === 0) {
|
||||||
|
container.innerHTML = '<p>No scheduled or active recordings.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stateLabels = {0: 'Scheduled', 1: 'Waiting', 2: 'Recording', 3: 'Completed', 4: 'Failed', 5: 'Cancelled'};
|
||||||
|
var stateColors = {0: '#2196F3', 1: '#FF9800', 2: '#4CAF50', 4: '#f44336', 5: '#9E9E9E'};
|
||||||
|
|
||||||
|
var html = '<table style="width:100%; border-collapse: collapse;">';
|
||||||
|
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
|
||||||
|
html += '<th style="padding: 8px;">Title</th>';
|
||||||
|
html += '<th style="padding: 8px;">Status</th>';
|
||||||
|
html += '<th style="padding: 8px;">Start</th>';
|
||||||
|
html += '<th style="padding: 8px;">Action</th>';
|
||||||
|
html += '</tr></thead><tbody>';
|
||||||
|
|
||||||
|
active.forEach(function(r) {
|
||||||
|
html += '<tr style="border-bottom: 1px solid #333;">';
|
||||||
|
html += '<td style="padding: 8px;">' + (r.title || 'Unknown') + '</td>';
|
||||||
|
html += '<td style="padding: 8px;"><span style="color:' + (stateColors[r.state] || '#fff') + ';">' + (stateLabels[r.state] || r.state) + '</span></td>';
|
||||||
|
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(r.validFrom) + '</td>';
|
||||||
|
html += '<td style="padding: 8px;">';
|
||||||
|
if (r.state === 2) {
|
||||||
|
html += '<button is="emby-button" type="button" class="raised emby-button" ';
|
||||||
|
html += 'onclick="SRFPlayRecordings.stopRecording(\'' + r.id + '\')"><span>Stop</span></button>';
|
||||||
|
} else {
|
||||||
|
html += '<button is="emby-button" type="button" class="raised emby-button" ';
|
||||||
|
html += 'onclick="SRFPlayRecordings.cancelRecording(\'' + r.id + '\')"><span>Cancel</span></button>';
|
||||||
|
}
|
||||||
|
html += '</td></tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
container.innerHTML = '<p style="color: #f44;">Error: ' + err.message + '</p>';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
loadCompletedRecordings: function() {
|
||||||
|
var container = document.getElementById('completedRecordingsContainer');
|
||||||
|
container.innerHTML = '<p><em>Loading...</em></p>';
|
||||||
|
|
||||||
|
fetch(this.apiBase + '/Completed', { headers: this.getHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(recordings) {
|
||||||
|
if (!recordings || recordings.length === 0) {
|
||||||
|
container.innerHTML = '<p>No completed recordings.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = '<table style="width:100%; border-collapse: collapse;">';
|
||||||
|
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
|
||||||
|
html += '<th style="padding: 8px;">Title</th>';
|
||||||
|
html += '<th style="padding: 8px;">Recorded</th>';
|
||||||
|
html += '<th style="padding: 8px;">Size</th>';
|
||||||
|
html += '<th style="padding: 8px;">File</th>';
|
||||||
|
html += '<th style="padding: 8px;">Action</th>';
|
||||||
|
html += '</tr></thead><tbody>';
|
||||||
|
|
||||||
|
recordings.forEach(function(r) {
|
||||||
|
html += '<tr style="border-bottom: 1px solid #333;">';
|
||||||
|
html += '<td style="padding: 8px;">' + (r.title || 'Unknown') + '</td>';
|
||||||
|
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(r.recordingStartedAt) + '</td>';
|
||||||
|
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatSize(r.fileSizeBytes) + '</td>';
|
||||||
|
html += '<td style="padding: 8px; font-size: 0.85em; word-break: break-all;">' + (r.outputPath || '') + '</td>';
|
||||||
|
html += '<td style="padding: 8px;">';
|
||||||
|
html += '<button is="emby-button" type="button" class="raised emby-button" style="background:#f44336;" ';
|
||||||
|
html += 'onclick="SRFPlayRecordings.deleteRecording(\'' + r.id + '\')"><span>Delete</span></button>';
|
||||||
|
html += '</td></tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
container.innerHTML = '<p style="color: #f44;">Error: ' + err.message + '</p>';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
stopRecording: function(id) {
|
||||||
|
fetch(this.apiBase + '/Active/' + id + '/Stop', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}).then(function() { SRFPlayRecordings.loadRecordings(); });
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelRecording: function(id) {
|
||||||
|
fetch(this.apiBase + '/Schedule/' + id, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}).then(function() { SRFPlayRecordings.loadRecordings(); });
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteRecording: function(id) {
|
||||||
|
if (!confirm('Delete this recording and its file?')) return;
|
||||||
|
fetch(this.apiBase + '/Completed/' + id + '?deleteFile=true', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}).then(function() { SRFPlayRecordings.loadRecordings(); });
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
152
Jellyfin.Plugin.SRFPlay/Controllers/RecordingController.cs
Normal file
152
Jellyfin.Plugin.SRFPlay/Controllers/RecordingController.cs
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controller for managing sport livestream recordings.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("Plugins/SRFPlay/Recording")]
|
||||||
|
[Authorize]
|
||||||
|
public class RecordingController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<RecordingController> _logger;
|
||||||
|
private readonly IRecordingService _recordingService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RecordingController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
/// <param name="recordingService">The recording service.</param>
|
||||||
|
public RecordingController(
|
||||||
|
ILogger<RecordingController> logger,
|
||||||
|
IRecordingService recordingService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_recordingService = recordingService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets upcoming sport livestreams available for recording.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>List of upcoming livestreams.</returns>
|
||||||
|
[HttpGet("Schedule")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> GetSchedule(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var schedule = await _recordingService.GetUpcomingScheduleAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return Ok(schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schedules a livestream for recording by URN.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="urn">The SRF URN.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>The created recording entry.</returns>
|
||||||
|
[HttpPost("Schedule/{urn}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> ScheduleRecording(
|
||||||
|
[FromRoute] string urn,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(urn))
|
||||||
|
{
|
||||||
|
return BadRequest("URN is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// URN comes URL-encoded with colons, decode it
|
||||||
|
urn = System.Net.WebUtility.UrlDecode(urn);
|
||||||
|
|
||||||
|
_logger.LogInformation("Scheduling recording for URN: {Urn}", urn);
|
||||||
|
var entry = await _recordingService.ScheduleRecordingAsync(urn, cancellationToken).ConfigureAwait(false);
|
||||||
|
return Ok(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancels a scheduled recording.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The recording ID.</param>
|
||||||
|
/// <returns>OK or NotFound.</returns>
|
||||||
|
[HttpDelete("Schedule/{id}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public IActionResult CancelRecording([FromRoute] string id)
|
||||||
|
{
|
||||||
|
return _recordingService.CancelRecording(id) ? Ok() : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets currently active recordings.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>List of active recordings.</returns>
|
||||||
|
[HttpGet("Active")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public IActionResult GetActiveRecordings()
|
||||||
|
{
|
||||||
|
var active = _recordingService.GetRecordings(RecordingState.Recording);
|
||||||
|
return Ok(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops an active recording.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The recording ID.</param>
|
||||||
|
/// <returns>OK or NotFound.</returns>
|
||||||
|
[HttpPost("Active/{id}/Stop")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public IActionResult StopRecording([FromRoute] string id)
|
||||||
|
{
|
||||||
|
return _recordingService.StopRecording(id) ? Ok() : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets completed recordings.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>List of completed recordings.</returns>
|
||||||
|
[HttpGet("Completed")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public IActionResult GetCompletedRecordings()
|
||||||
|
{
|
||||||
|
var completed = _recordingService.GetRecordings(RecordingState.Completed);
|
||||||
|
return Ok(completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all recordings (all states).
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>List of all recordings.</returns>
|
||||||
|
[HttpGet("All")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public IActionResult GetAllRecordings()
|
||||||
|
{
|
||||||
|
var all = _recordingService.GetRecordings();
|
||||||
|
return Ok(all);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a completed recording and its file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The recording ID.</param>
|
||||||
|
/// <param name="deleteFile">Whether to delete the file too.</param>
|
||||||
|
/// <returns>OK or NotFound.</returns>
|
||||||
|
[HttpDelete("Completed/{id}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public IActionResult DeleteRecording(
|
||||||
|
[FromRoute] string id,
|
||||||
|
[FromQuery] bool deleteFile = true)
|
||||||
|
{
|
||||||
|
return _recordingService.DeleteRecording(id, deleteFile) ? Ok() : NotFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using MediaBrowser.Model.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scheduled task that checks and manages sport livestream recordings.
|
||||||
|
/// Runs every 2 minutes to start scheduled recordings when streams go live
|
||||||
|
/// and stop recordings when they end.
|
||||||
|
/// </summary>
|
||||||
|
public class RecordingSchedulerTask : IScheduledTask
|
||||||
|
{
|
||||||
|
private readonly ILogger<RecordingSchedulerTask> _logger;
|
||||||
|
private readonly IRecordingService _recordingService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RecordingSchedulerTask"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
/// <param name="recordingService">The recording service.</param>
|
||||||
|
public RecordingSchedulerTask(
|
||||||
|
ILogger<RecordingSchedulerTask> logger,
|
||||||
|
IRecordingService recordingService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_recordingService = recordingService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name => "Process SRF Play Recordings";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Description => "Checks scheduled recordings and starts/stops them as needed";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Category => "SRF Play";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Key => "SRFPlayRecordingScheduler";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Processing SRF Play recordings");
|
||||||
|
progress?.Report(0);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _recordingService.ProcessRecordingsAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
progress?.Report(100);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error processing recordings");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||||
|
{
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
new TaskTriggerInfo
|
||||||
|
{
|
||||||
|
Type = TaskTriggerInfo.TriggerInterval,
|
||||||
|
IntervalTicks = TimeSpan.FromMinutes(2).Ticks
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -41,9 +41,13 @@ public class ServiceRegistrator : IPluginServiceRegistrator
|
|||||||
// Register media source provider
|
// Register media source provider
|
||||||
serviceCollection.AddSingleton<SRFMediaProvider>();
|
serviceCollection.AddSingleton<SRFMediaProvider>();
|
||||||
|
|
||||||
|
// Register recording service
|
||||||
|
serviceCollection.AddSingleton<IRecordingService, RecordingService>();
|
||||||
|
|
||||||
// Register scheduled tasks
|
// Register scheduled tasks
|
||||||
serviceCollection.AddSingleton<IScheduledTask, ContentRefreshTask>();
|
serviceCollection.AddSingleton<IScheduledTask, ContentRefreshTask>();
|
||||||
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
|
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
|
||||||
|
serviceCollection.AddSingleton<IScheduledTask, RecordingSchedulerTask>();
|
||||||
|
|
||||||
// Register channel - must register as IChannel interface for Jellyfin to discover it
|
// Register channel - must register as IChannel interface for Jellyfin to discover it
|
||||||
serviceCollection.AddSingleton<IChannel, SRFPlayChannel>();
|
serviceCollection.AddSingleton<IChannel, SRFPlayChannel>();
|
||||||
|
|||||||
@ -0,0 +1,65 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for managing sport livestream recordings.
|
||||||
|
/// </summary>
|
||||||
|
public interface IRecordingService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets upcoming sport livestreams that can be recorded.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>List of upcoming sport livestreams.</returns>
|
||||||
|
Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schedules a livestream for recording.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="urn">The SRF URN to record.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>The created recording entry.</returns>
|
||||||
|
Task<RecordingEntry> ScheduleRecordingAsync(string urn, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancels a scheduled recording.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="recordingId">The recording ID.</param>
|
||||||
|
/// <returns>True if cancelled.</returns>
|
||||||
|
bool CancelRecording(string recordingId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops an active recording.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="recordingId">The recording ID.</param>
|
||||||
|
/// <returns>True if stopped.</returns>
|
||||||
|
bool StopRecording(string recordingId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all recordings by state.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stateFilter">Optional state filter.</param>
|
||||||
|
/// <returns>List of matching recording entries.</returns>
|
||||||
|
IReadOnlyList<RecordingEntry> GetRecordings(RecordingState? stateFilter = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a completed recording (entry and optionally the file).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="recordingId">The recording ID.</param>
|
||||||
|
/// <param name="deleteFile">Whether to delete the file too.</param>
|
||||||
|
/// <returns>True if deleted.</returns>
|
||||||
|
bool DeleteRecording(string recordingId, bool deleteFile = true);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks scheduled recordings and starts/stops them as needed.
|
||||||
|
/// Called periodically by the scheduler task.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A task representing the async operation.</returns>
|
||||||
|
Task ProcessRecordingsAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
517
Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs
Normal file
517
Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs
Normal file
@ -0,0 +1,517 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Api;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for managing sport livestream recordings using ffmpeg.
|
||||||
|
/// </summary>
|
||||||
|
public class RecordingService : IRecordingService, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ILogger<RecordingService> _logger;
|
||||||
|
private readonly ISRFApiClientFactory _apiClientFactory;
|
||||||
|
private readonly IStreamProxyService _proxyService;
|
||||||
|
private readonly IStreamUrlResolver _streamUrlResolver;
|
||||||
|
private readonly IMediaCompositionFetcher _mediaCompositionFetcher;
|
||||||
|
private readonly IServerApplicationHost _appHost;
|
||||||
|
private readonly ConcurrentDictionary<string, Process> _activeProcesses = new();
|
||||||
|
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
|
||||||
|
private readonly SemaphoreSlim _persistLock = new(1, 1);
|
||||||
|
private List<RecordingEntry> _recordings = new();
|
||||||
|
private bool _loaded;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RecordingService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
/// <param name="apiClientFactory">The API client factory.</param>
|
||||||
|
/// <param name="proxyService">The stream proxy service.</param>
|
||||||
|
/// <param name="streamUrlResolver">The stream URL resolver.</param>
|
||||||
|
/// <param name="mediaCompositionFetcher">The media composition fetcher.</param>
|
||||||
|
/// <param name="appHost">The application host.</param>
|
||||||
|
public RecordingService(
|
||||||
|
ILogger<RecordingService> logger,
|
||||||
|
ISRFApiClientFactory apiClientFactory,
|
||||||
|
IStreamProxyService proxyService,
|
||||||
|
IStreamUrlResolver streamUrlResolver,
|
||||||
|
IMediaCompositionFetcher mediaCompositionFetcher,
|
||||||
|
IServerApplicationHost appHost)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_apiClientFactory = apiClientFactory;
|
||||||
|
_proxyService = proxyService;
|
||||||
|
_streamUrlResolver = streamUrlResolver;
|
||||||
|
_mediaCompositionFetcher = mediaCompositionFetcher;
|
||||||
|
_appHost = appHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetDataFilePath()
|
||||||
|
{
|
||||||
|
var dataPath = Plugin.Instance?.DataFolderPath ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "jellyfin", "plugins", "SRFPlay");
|
||||||
|
Directory.CreateDirectory(dataPath);
|
||||||
|
return Path.Combine(dataPath, "recordings.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetRecordingOutputPath()
|
||||||
|
{
|
||||||
|
var config = Plugin.Instance?.Configuration;
|
||||||
|
var path = config?.RecordingOutputPath;
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "SRFRecordings");
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetServerBaseUrl()
|
||||||
|
{
|
||||||
|
var config = Plugin.Instance?.Configuration;
|
||||||
|
if (config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl))
|
||||||
|
{
|
||||||
|
return config.PublicServerUrl.TrimEnd('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// For local ffmpeg access, use localhost directly
|
||||||
|
return "http://localhost:8096";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadRecordingsAsync()
|
||||||
|
{
|
||||||
|
if (_loaded)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var filePath = GetDataFilePath();
|
||||||
|
if (File.Exists(filePath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false);
|
||||||
|
_recordings = JsonSerializer.Deserialize<List<RecordingEntry>>(json) ?? new List<RecordingEntry>();
|
||||||
|
_logger.LogInformation("Loaded {Count} recording entries from {Path}", _recordings.Count, filePath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to load recordings from {Path}", filePath);
|
||||||
|
_recordings = new List<RecordingEntry>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveRecordingsAsync()
|
||||||
|
{
|
||||||
|
await _persistLock.WaitAsync().ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var filePath = GetDataFilePath();
|
||||||
|
var json = JsonSerializer.Serialize(_recordings, _jsonOptions);
|
||||||
|
await File.WriteAllTextAsync(filePath, json).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to save recordings");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_persistLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var config = Plugin.Instance?.Configuration;
|
||||||
|
var businessUnit = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
|
||||||
|
|
||||||
|
using var apiClient = _apiClientFactory.CreateClient();
|
||||||
|
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (livestreams == null)
|
||||||
|
{
|
||||||
|
return Array.Empty<PlayV3TvProgram>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only future/current livestreams that aren't blocked
|
||||||
|
return livestreams
|
||||||
|
.Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo > DateTime.UtcNow))
|
||||||
|
.OrderBy(ls => ls.ValidFrom)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<RecordingEntry> ScheduleRecordingAsync(string urn, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await LoadRecordingsAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Check if already scheduled
|
||||||
|
var existing = _recordings.FirstOrDefault(r => r.Urn == urn && r.State is RecordingState.Scheduled or RecordingState.WaitingForStream or RecordingState.Recording);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Recording already exists for URN {Urn} in state {State}", urn, existing.State);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch metadata for the URN
|
||||||
|
var config = Plugin.Instance?.Configuration;
|
||||||
|
var businessUnit = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
|
||||||
|
|
||||||
|
using var apiClient = _apiClientFactory.CreateClient();
|
||||||
|
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
||||||
|
var program = livestreams?.FirstOrDefault(ls => ls.Urn == urn);
|
||||||
|
|
||||||
|
var entry = new RecordingEntry
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString("N"),
|
||||||
|
Urn = urn,
|
||||||
|
Title = program?.Title ?? urn,
|
||||||
|
Description = program?.Lead ?? program?.Description,
|
||||||
|
ImageUrl = program?.ImageUrl,
|
||||||
|
ValidFrom = program?.ValidFrom,
|
||||||
|
ValidTo = program?.ValidTo,
|
||||||
|
State = RecordingState.Scheduled,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
_recordings.Add(entry);
|
||||||
|
await SaveRecordingsAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogInformation("Scheduled recording for '{Title}' (URN: {Urn}, starts: {ValidFrom})", entry.Title, urn, entry.ValidFrom);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool CancelRecording(string recordingId)
|
||||||
|
{
|
||||||
|
var entry = _recordings.FirstOrDefault(r => r.Id == recordingId);
|
||||||
|
if (entry == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.State == RecordingState.Recording)
|
||||||
|
{
|
||||||
|
StopFfmpeg(recordingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.State = RecordingState.Cancelled;
|
||||||
|
entry.RecordingEndedAt = DateTime.UtcNow;
|
||||||
|
_ = SaveRecordingsAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Cancelled recording '{Title}' ({Id})", entry.Title, recordingId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool StopRecording(string recordingId)
|
||||||
|
{
|
||||||
|
var entry = _recordings.FirstOrDefault(r => r.Id == recordingId && r.State == RecordingState.Recording);
|
||||||
|
if (entry == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
StopFfmpeg(recordingId);
|
||||||
|
|
||||||
|
entry.State = RecordingState.Completed;
|
||||||
|
entry.RecordingEndedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (entry.OutputPath != null && File.Exists(entry.OutputPath))
|
||||||
|
{
|
||||||
|
entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = SaveRecordingsAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Stopped recording '{Title}' ({Id})", entry.Title, recordingId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<RecordingEntry> GetRecordings(RecordingState? stateFilter)
|
||||||
|
{
|
||||||
|
// Ensure loaded synchronously for simple reads
|
||||||
|
if (!_loaded)
|
||||||
|
{
|
||||||
|
LoadRecordingsAsync().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateFilter.HasValue)
|
||||||
|
{
|
||||||
|
return _recordings.Where(r => r.State == stateFilter.Value).OrderByDescending(r => r.CreatedAt).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _recordings.OrderByDescending(r => r.CreatedAt).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool DeleteRecording(string recordingId, bool deleteFile)
|
||||||
|
{
|
||||||
|
var entry = _recordings.FirstOrDefault(r => r.Id == recordingId);
|
||||||
|
if (entry == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.State == RecordingState.Recording)
|
||||||
|
{
|
||||||
|
StopFfmpeg(recordingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteFile && !string.IsNullOrEmpty(entry.OutputPath) && File.Exists(entry.OutputPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(entry.OutputPath);
|
||||||
|
_logger.LogInformation("Deleted recording file: {Path}", entry.OutputPath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to delete recording file: {Path}", entry.OutputPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_recordings.Remove(entry);
|
||||||
|
_ = SaveRecordingsAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Deleted recording entry '{Title}' ({Id})", entry.Title, recordingId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task ProcessRecordingsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await LoadRecordingsAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var changed = false;
|
||||||
|
|
||||||
|
foreach (var entry in _recordings.ToList())
|
||||||
|
{
|
||||||
|
switch (entry.State)
|
||||||
|
{
|
||||||
|
case RecordingState.Scheduled:
|
||||||
|
case RecordingState.WaitingForStream:
|
||||||
|
// Check if it's time to start recording
|
||||||
|
if (entry.ValidFrom.HasValue && entry.ValidFrom.Value <= now.AddMinutes(2))
|
||||||
|
{
|
||||||
|
changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RecordingState.Recording:
|
||||||
|
// Check if recording should stop (ValidTo reached or process died)
|
||||||
|
if (entry.ValidTo.HasValue && entry.ValidTo.Value <= now)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Recording '{Title}' reached ValidTo, stopping", entry.Title);
|
||||||
|
StopFfmpeg(entry.Id);
|
||||||
|
entry.State = RecordingState.Completed;
|
||||||
|
entry.RecordingEndedAt = now;
|
||||||
|
if (entry.OutputPath != null && File.Exists(entry.OutputPath))
|
||||||
|
{
|
||||||
|
entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
else if (!_activeProcesses.ContainsKey(entry.Id))
|
||||||
|
{
|
||||||
|
// ffmpeg process died unexpectedly — try to restart
|
||||||
|
_logger.LogWarning("ffmpeg process for '{Title}' is no longer running, attempting restart", entry.Title);
|
||||||
|
changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
await SaveRecordingsAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> TryStartRecordingAsync(RecordingEntry entry, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Fetch the media composition to get the stream URL
|
||||||
|
var mediaComposition = await _mediaCompositionFetcher.GetMediaCompositionAsync(entry.Urn, cacheDurationOverride: 2, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
var chapter = mediaComposition?.ChapterList is { Count: > 0 } list ? list[0] : null;
|
||||||
|
|
||||||
|
if (chapter == null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No chapter found for '{Title}', stream may not be live yet", entry.Title);
|
||||||
|
entry.State = RecordingState.WaitingForStream;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = Plugin.Instance?.Configuration;
|
||||||
|
var quality = config?.QualityPreference ?? Configuration.QualityPreference.Auto;
|
||||||
|
var streamUrl = _streamUrlResolver.GetStreamUrl(chapter, quality);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(streamUrl))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No stream URL available for '{Title}', waiting", entry.Title);
|
||||||
|
entry.State = RecordingState.WaitingForStream;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the stream with the proxy so we can use the proxy URL
|
||||||
|
var itemId = $"rec_{entry.Id}";
|
||||||
|
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || entry.Urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
|
||||||
|
_proxyService.RegisterStreamDeferred(itemId, streamUrl, entry.Urn, isLiveStream);
|
||||||
|
|
||||||
|
// Build proxy URL for ffmpeg (use localhost for local access)
|
||||||
|
var proxyUrl = $"{GetServerBaseUrl()}/Plugins/SRFPlay/Proxy/{itemId}/master.m3u8";
|
||||||
|
|
||||||
|
// Build output file path
|
||||||
|
var safeTitle = SanitizeFileName(entry.Title);
|
||||||
|
var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HHmm", CultureInfo.InvariantCulture);
|
||||||
|
var outputPath = Path.Combine(GetRecordingOutputPath(), $"{safeTitle}_{timestamp}.mkv");
|
||||||
|
entry.OutputPath = outputPath;
|
||||||
|
|
||||||
|
// Start ffmpeg
|
||||||
|
StartFfmpeg(entry.Id, proxyUrl, outputPath);
|
||||||
|
|
||||||
|
entry.State = RecordingState.Recording;
|
||||||
|
entry.RecordingStartedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
_logger.LogInformation("Started recording '{Title}' to {OutputPath}", entry.Title, outputPath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to start recording '{Title}'", entry.Title);
|
||||||
|
entry.State = RecordingState.Failed;
|
||||||
|
entry.ErrorMessage = ex.Message;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartFfmpeg(string recordingId, string inputUrl, string outputPath)
|
||||||
|
{
|
||||||
|
var process = new Process
|
||||||
|
{
|
||||||
|
StartInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "ffmpeg",
|
||||||
|
Arguments = $"-y -i \"{inputUrl}\" -c copy -movflags +faststart \"{outputPath}\"",
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardInput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
CreateNoWindow = true
|
||||||
|
},
|
||||||
|
EnableRaisingEvents = true
|
||||||
|
};
|
||||||
|
|
||||||
|
process.ErrorDataReceived += (_, args) =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(args.Data))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("ffmpeg [{RecordingId}]: {Data}", recordingId, args.Data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.Exited += (_, _) =>
|
||||||
|
{
|
||||||
|
_logger.LogInformation("ffmpeg process exited for recording {RecordingId} with code {ExitCode}", recordingId, process.ExitCode);
|
||||||
|
_activeProcesses.TryRemove(recordingId, out _);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.Start();
|
||||||
|
process.BeginErrorReadLine();
|
||||||
|
|
||||||
|
_activeProcesses[recordingId] = process;
|
||||||
|
_logger.LogInformation("Started ffmpeg (PID {Pid}) for recording {RecordingId}: {Args}", process.Id, recordingId, process.StartInfo.Arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopFfmpeg(string recordingId)
|
||||||
|
{
|
||||||
|
if (_activeProcesses.TryRemove(recordingId, out var process))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!process.HasExited)
|
||||||
|
{
|
||||||
|
// Send 'q' to ffmpeg stdin for graceful shutdown
|
||||||
|
process.StandardInput.Write("q");
|
||||||
|
process.StandardInput.Flush();
|
||||||
|
|
||||||
|
if (!process.WaitForExit(10000))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("ffmpeg did not exit gracefully for {RecordingId}, killing", recordingId);
|
||||||
|
process.Kill(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Error stopping ffmpeg for recording {RecordingId}", recordingId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeFileName(string name)
|
||||||
|
{
|
||||||
|
var invalid = Path.GetInvalidFileNameChars();
|
||||||
|
var sanitized = string.Join("_", name.Split(invalid, StringSplitOptions.RemoveEmptyEntries));
|
||||||
|
// Also replace spaces and other problematic chars
|
||||||
|
sanitized = Regex.Replace(sanitized, @"[\s]+", "_");
|
||||||
|
return sanitized.Length > 100 ? sanitized[..100] : sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Dispose(true);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases resources.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="disposing">True to release managed resources.</param>
|
||||||
|
protected virtual void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
foreach (var kvp in _activeProcesses)
|
||||||
|
{
|
||||||
|
StopFfmpeg(kvp.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
_persistLock.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user