mirror of
https://github.com/Jellyfin2Samsung/Samsung-Jellyfin-Installer.git
synced 2026-03-01 11:21:12 +03:00
485 lines
18 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|