Files
2026-01-21 13:14:34 +01:00

485 lines
18 KiB
C#

using Jellyfin2Samsung.Helpers;
using Jellyfin2Samsung.Helpers.Core;
using Jellyfin2Samsung.Interfaces;
using Jellyfin2Samsung.Models;
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Xml.Linq;
namespace Jellyfin2Samsung.Services
{
/// <summary>
/// Service for checking and applying application updates via GitHub.
/// Uses the Atom feed endpoint to avoid API rate limiting.
/// </summary>
public class UpdaterService : IUpdaterService
{
private readonly HttpClient _httpClient;
private const string RepoOwner = "Jellyfin2Samsung";
private const string RepoName = "Samsung-Jellyfin-Installer";
private const string AtomFeedUrl = $"https://github.com/{RepoOwner}/{RepoName}/releases.atom";
private const string ReleasesApiUrl = $"https://api.github.com/repos/{RepoOwner}/{RepoName}/releases/latest";
public string ReleasesPageUrl => $"https://github.com/{RepoOwner}/{RepoName}/releases";
public string CurrentVersion => AppSettings.Default.AppVersion;
public UpdaterService(HttpClient httpClient)
{
_httpClient = httpClient;
}
/// <inheritdoc />
public async Task<UpdateCheckResult> CheckForUpdateAsync(CancellationToken cancellationToken = default)
{
try
{
// First try Atom feed (no rate limit)
var atomResult = await CheckViaAtomFeedAsync(cancellationToken);
if (atomResult.IsSuccess && atomResult.IsUpdateAvailable)
{
// Get download URL from API (only if update available)
await EnrichWithDownloadUrlAsync(atomResult, cancellationToken);
}
return atomResult;
}
catch (Exception ex)
{
Trace.WriteLine($"Update check failed: {ex}");
return UpdateCheckResult.Failed($"Failed to check for updates: {ex.Message}", CurrentVersion);
}
}
private async Task<UpdateCheckResult> CheckViaAtomFeedAsync(CancellationToken cancellationToken)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, AtomFeedUrl);
request.Headers.Accept.ParseAdd("application/atom+xml");
using var response = await _httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
var atomXml = await response.Content.ReadAsStringAsync(cancellationToken);
var latestEntry = ParseAtomFeed(atomXml);
if (latestEntry == null)
{
return UpdateCheckResult.NoUpdateAvailable(CurrentVersion);
}
var latestVersion = latestEntry.TagName;
var isUpdateAvailable = IsVersionGreater(latestVersion, CurrentVersion);
return new UpdateCheckResult
{
IsUpdateAvailable = isUpdateAvailable,
CurrentVersion = CurrentVersion,
LatestVersion = latestVersion,
ReleaseTitle = latestEntry.Title,
ReleaseNotes = HtmlUtils.StripHtml(HtmlUtils.RemoveMarkdownTable(latestEntry.Content)),
ReleasesPageUrl = latestEntry.Link,
PublishedAt = latestEntry.Updated
};
}
catch (Exception ex)
{
Trace.WriteLine($"Atom feed check failed: {ex}");
return UpdateCheckResult.Failed($"Failed to parse release feed: {ex.Message}", CurrentVersion);
}
}
private GitHubAtomEntry? ParseAtomFeed(string atomXml)
{
try
{
var doc = XDocument.Parse(atomXml);
XNamespace atom = "http://www.w3.org/2005/Atom";
// Get all entries and filter out beta/pre-releases
var entry = doc.Descendants(atom + "entry").FirstOrDefault(e =>
{
var title = e.Element(atom + "title")?.Value ?? string.Empty;
// Filter out entries with beta
return !title.Contains("beta", StringComparison.OrdinalIgnoreCase);
});
if (entry == null)
return null;
return new GitHubAtomEntry
{
Id = entry.Element(atom + "id")?.Value ?? string.Empty,
Title = entry.Element(atom + "title")?.Value ?? string.Empty,
Updated = DateTime.TryParse(entry.Element(atom + "updated")?.Value, out var updated) ? updated : null,
Link = entry.Element(atom + "link")?.Attribute("href")?.Value ?? string.Empty,
Content = entry.Element(atom + "content")?.Value ?? string.Empty,
AuthorName = entry.Element(atom + "author")?.Element(atom + "name")?.Value ?? string.Empty
};
}
catch (Exception ex)
{
Trace.WriteLine($"Failed to parse Atom feed: {ex}");
return null;
}
}
private async Task EnrichWithDownloadUrlAsync(UpdateCheckResult result, CancellationToken cancellationToken)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, ReleasesApiUrl);
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
// API rate limited or error - user can still use manual download
Trace.WriteLine($"GitHub API returned {response.StatusCode}, download URL unavailable");
return;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("assets", out var assets))
return;
var platformSuffix = GetPlatformSuffix();
foreach (var asset in assets.EnumerateArray())
{
if (!asset.TryGetProperty("name", out var nameElement))
continue;
var name = nameElement.GetString() ?? string.Empty;
// Match platform-specific archive
if (name.Contains(platformSuffix, StringComparison.OrdinalIgnoreCase) &&
(name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) ||
name.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)))
{
if (asset.TryGetProperty("browser_download_url", out var urlElement))
{
result.DownloadUrl = urlElement.GetString();
return;
}
}
}
// Fallback: try to find any zip file
foreach (var asset in assets.EnumerateArray())
{
if (!asset.TryGetProperty("name", out var nameElement))
continue;
var name = nameElement.GetString() ?? string.Empty;
if (name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
if (asset.TryGetProperty("browser_download_url", out var urlElement))
{
result.DownloadUrl = urlElement.GetString();
return;
}
}
}
}
catch (Exception ex)
{
Trace.WriteLine($"Failed to get download URL: {ex}");
// Not critical - user can still download manually
}
}
private static string GetPlatformSuffix()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return "win";
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
return "linux";
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
return "osx";
return "win"; // Default fallback
}
/// <inheritdoc />
public async Task<string> DownloadUpdateAsync(
string downloadUrl,
IProgress<int>? progress = null,
CancellationToken cancellationToken = default)
{
var tempDir = Path.Combine(Path.GetTempPath(), "Jellyfin2Samsung_Update");
Directory.CreateDirectory(tempDir);
var fileName = Path.GetFileName(new Uri(downloadUrl).LocalPath);
var downloadPath = Path.Combine(tempDir, fileName);
// Clean up old downloads
if (File.Exists(downloadPath))
File.Delete(downloadPath);
using var response = await _httpClient.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var totalBytes = response.Content.Headers.ContentLength ?? -1L;
var downloadedBytes = 0L;
await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var fileStream = new FileStream(downloadPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true);
var buffer = new byte[8192];
int bytesRead;
while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken)) > 0)
{
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
downloadedBytes += bytesRead;
if (totalBytes > 0)
{
var percentage = (int)((downloadedBytes * 100) / totalBytes);
progress?.Report(percentage);
}
}
progress?.Report(100);
return downloadPath;
}
/// <inheritdoc />
public async Task<bool> ApplyUpdateAsync(string downloadedFilePath, CancellationToken cancellationToken = default)
{
try
{
var appDir = AppContext.BaseDirectory;
var updateDir = Path.Combine(Path.GetTempPath(), "Jellyfin2Samsung_Update", "extracted");
var backupDir = Path.Combine(Path.GetTempPath(), "Jellyfin2Samsung_Update", "backup");
// Clean extraction directory
if (Directory.Exists(updateDir))
Directory.Delete(updateDir, true);
Directory.CreateDirectory(updateDir);
// Extract update
if (downloadedFilePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
ZipFile.ExtractToDirectory(downloadedFilePath, updateDir);
}
else
{
throw new NotSupportedException("Only ZIP archives are supported for automatic updates.");
}
// Find the actual application directory (might be in a subfolder)
var extractedAppDir = FindApplicationDirectory(updateDir);
if (extractedAppDir == null)
{
throw new InvalidOperationException("Could not find application files in the update package.");
}
// Create update script
var scriptPath = CreateUpdateScript(extractedAppDir, appDir, backupDir);
// Launch the update script and exit
LaunchUpdateScript(scriptPath);
return true;
}
catch (Exception ex)
{
Trace.WriteLine($"Failed to apply update: {ex}");
throw;
}
}
private string? FindApplicationDirectory(string extractedDir)
{
// Check if the main executable is directly in the extracted directory
var exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "Jellyfin2Samsung.exe"
: "Jellyfin2Samsung";
if (File.Exists(Path.Combine(extractedDir, exeName)))
return extractedDir;
// Check subdirectories
foreach (var subDir in Directory.GetDirectories(extractedDir))
{
if (File.Exists(Path.Combine(subDir, exeName)))
return subDir;
// Check one level deeper
foreach (var subSubDir in Directory.GetDirectories(subDir))
{
if (File.Exists(Path.Combine(subSubDir, exeName)))
return subSubDir;
}
}
return null;
}
private string CreateUpdateScript(string sourceDir, string targetDir, string backupDir)
{
var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
var scriptExtension = isWindows ? ".bat" : ".sh";
var scriptPath = Path.Combine(Path.GetTempPath(), $"jellyfin2samsung_update{scriptExtension}");
var exeName = isWindows ? "Jellyfin2Samsung.exe" : "Jellyfin2Samsung";
var processId = Environment.ProcessId;
string scriptContent;
if (isWindows)
{
scriptContent = $@"@echo off
chcp 65001 > nul
echo Waiting for application to close...
:waitloop
tasklist /FI ""PID eq {processId}"" 2>NUL | find /I ""{processId}"" >NUL
if not errorlevel 1 (
timeout /t 1 /nobreak > nul
goto waitloop
)
echo Creating backup...
if exist ""{backupDir}"" rmdir /s /q ""{backupDir}""
mkdir ""{backupDir}""
xcopy ""{targetDir}\*"" ""{backupDir}\"" /E /H /Y /Q
echo Installing update...
xcopy ""{sourceDir}\*"" ""{targetDir}\"" /E /H /Y /Q
echo Starting application...
start """" ""{Path.Combine(targetDir, exeName)}""
echo Cleaning up...
timeout /t 2 /nobreak > nul
rmdir /s /q ""{Path.Combine(Path.GetTempPath(), "Jellyfin2Samsung_Update")}""
del ""%~f0""
";
}
else
{
scriptContent = $@"#!/bin/bash
echo ""Waiting for application to close...""
while kill -0 {processId} 2>/dev/null; do
sleep 1
done
echo ""Creating backup...""
rm -rf ""{backupDir}""
mkdir -p ""{backupDir}""
cp -r ""{targetDir}/""* ""{backupDir}/""
echo ""Installing update...""
cp -rf ""{sourceDir}/""* ""{targetDir}/""
chmod +x ""{Path.Combine(targetDir, exeName)}""
echo ""Starting application...""
nohup ""{Path.Combine(targetDir, exeName)}"" &
echo ""Cleaning up...""
sleep 2
rm -rf ""{Path.Combine(Path.GetTempPath(), "Jellyfin2Samsung_Update")}""
rm -- ""$0""
";
}
File.WriteAllText(scriptPath, scriptContent);
if (!isWindows)
{
// Make script executable on Unix
Process.Start("chmod", $"+x \"{scriptPath}\"")?.WaitForExit();
}
return scriptPath;
}
private void LaunchUpdateScript(string scriptPath)
{
var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
var startInfo = new ProcessStartInfo
{
UseShellExecute = true,
CreateNoWindow = !isWindows, // Show window on Windows for user feedback
WindowStyle = isWindows ? ProcessWindowStyle.Normal : ProcessWindowStyle.Hidden
};
if (isWindows)
{
startInfo.FileName = "cmd.exe";
startInfo.Arguments = $"/c \"{scriptPath}\"";
}
else
{
startInfo.FileName = "/bin/bash";
startInfo.Arguments = scriptPath;
}
Process.Start(startInfo);
}
/// <inheritdoc />
public void OpenReleasesPage()
{
try
{
var psi = new ProcessStartInfo
{
FileName = ReleasesPageUrl,
UseShellExecute = true
};
Process.Start(psi);
}
catch (Exception ex)
{
Trace.WriteLine($"Failed to open releases page: {ex}");
}
}
private static bool IsVersionGreater(string latestVersion, string currentVersion)
{
// Clean version strings
var latestClean = CleanVersionString(latestVersion);
var currentClean = CleanVersionString(currentVersion);
if (Version.TryParse(latestClean, out var latest) &&
Version.TryParse(currentClean, out var current))
{
return latest > current;
}
// Fallback to string comparison
return string.Compare(latestClean, currentClean, StringComparison.OrdinalIgnoreCase) > 0;
}
private static string CleanVersionString(string version)
{
if (string.IsNullOrEmpty(version))
return "0.0.0";
// Remove 'v' prefix
var cleaned = version.TrimStart('v', 'V');
// Remove suffixes like -beta, -alpha, -rc
var dashIndex = cleaned.IndexOf('-');
if (dashIndex > 0)
cleaned = cleaned.Substring(0, dashIndex);
return cleaned;
}
}
}