Refactor: Consolidate helpers, standardize JSON library, and improve code organization

Summary
  - Introduced centralized helper classes to eliminate code duplication and improve maintainability
  - Standardized JSON serialization from Newtonsoft.Json to System.Text.Json
  - Refactored large methods into focused, testable units
  - Added pre-compiled regex patterns for better performance

New Helper Classes

  | File | Purpose |
  |------|---------|
  | Helpers/Core/Constants.cs | Centralized magic strings, error codes, ports, app identifiers |
  | Helpers/Core/UrlHelper.cs | URL normalization (NormalizeServerUrl, CombineUrl, IsValidHttpUrl) |
  | Helpers/Core/PlatformService.cs | Cross-platform OS detection and platform-specific operations |
  | Helpers/Core/RegexPatterns.cs | Pre-compiled regex patterns for version parsing, Tizen capabilities, HTML, etc. |
  | Helpers/Core/JsonSerializerOptionsProvider.cs | Centralized JsonSerializerOptions configurations |

Key Changes

TizenInstallerService Refactoring
  Split 215-line InstallPackageAsync into focused methods:
  - PrepareDeviceAsync() – Device preparation and diagnostics
  - GetDeviceInfoAsync() – Device information collection
  - HandleCertificateAsync() – Certificate handling logic
  - ApplyConfigurationAsync() – Configuration application
  - HandleInstallationResultAsync() – Result processing

JSON Library Migration
  Migrated from Newtonsoft.Json to System.Text.Json:
  - Models/GitHubRelease.cs
  - Helpers/Core/AddLatestRelease.cs
  - Helpers/API/TizenApiClient.cs
  - Services/SamsungLoginService.cs
  - Services/TizenInstallerService.cs
  - ViewModels/MainWindowViewModel.cs

Code Quality Improvements
  - Replaced 13× duplicate TrimEnd('/') calls with UrlHelper
  - Unified OS detection via PlatformService (previously mixed OperatingSystem.* and RuntimeInformation.*)
  - Used Random.Shared instead of new Random() for thread safety
  - Eliminated magic strings/numbers via Constants class
This commit is contained in:
kingchenc
2026-01-11 22:32:13 +01:00
parent b9979a9aec
commit c193ccedd2
26 changed files with 1757 additions and 642 deletions

View File

@@ -1,4 +1,5 @@
using Jellyfin2Samsung.Models;
using Jellyfin2Samsung.Helpers.Core;
using Jellyfin2Samsung.Models;
using System;
using System.Collections.Generic;
using System.Diagnostics;
@@ -29,7 +30,7 @@ namespace Jellyfin2Samsung.Helpers.API
return !string.IsNullOrEmpty(AppSettings.Default.JellyfinFullUrl) &&
!string.IsNullOrEmpty(AppSettings.Default.JellyfinAccessToken) &&
!string.IsNullOrEmpty(AppSettings.Default.JellyfinUserId) &&
IsValidUrl($"{AppSettings.Default.JellyfinFullUrl}/Users");
UrlHelper.IsValidHttpUrl($"{AppSettings.Default.JellyfinFullUrl}/Users");
}
/// <summary>
@@ -41,51 +42,40 @@ namespace Jellyfin2Samsung.Helpers.API
!string.IsNullOrEmpty(AppSettings.Default.JellyfinUserId);
}
public static bool IsValidUrl(string url)
{
return Uri.TryCreate(url, UriKind.Absolute, out var uriResult)
&& (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
}
public async Task<List<JellyfinPluginInfo>> GetInstalledPluginsAsync(string serverUrl)
{
var list = new List<JellyfinPluginInfo>();
try
{
string url = serverUrl.TrimEnd('/') + "/Plugins";
Trace.WriteLine("Fetching installed plugins from: " + url);
string url = UrlHelper.CombineUrl(serverUrl, "/Plugins");
Trace.WriteLine("Fetching installed plugins from: " + url);
var json = await _httpClient.GetStringAsync(url);
var parsed = JsonSerializer.Deserialize<List<JellyfinPluginInfo>>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
var parsed = JsonSerializer.Deserialize<List<JellyfinPluginInfo>>(json, JsonSerializerOptionsProvider.Default);
if (parsed != null)
list.AddRange(parsed);
}
catch (Exception ex)
{
Trace.WriteLine("Failed to fetch /Plugins: " + ex);
Trace.WriteLine("Failed to fetch /Plugins: " + ex);
}
return list;
}
public async Task<JellyfinPublicSystemInfo?> GetPublicSystemInfoAsync(string serverUrl)
{
try
{
string url = serverUrl.TrimEnd('/') + "/System/Info/Public";
Trace.WriteLine("Fetching Jellyfin public system info from: " + url);
string url = UrlHelper.CombineUrl(serverUrl, "/System/Info/Public");
Trace.WriteLine("Fetching Jellyfin public system info from: " + url);
var json = await _httpClient.GetStringAsync(url);
var info = JsonSerializer.Deserialize<JellyfinPublicSystemInfo>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return info;
return JsonSerializer.Deserialize<JellyfinPublicSystemInfo>(json, JsonSerializerOptionsProvider.Default);
}
catch (Exception ex)
{
Trace.WriteLine("Failed to fetch /System/Info/Public: " + ex);
Trace.WriteLine("Failed to fetch /System/Info/Public: " + ex);
return null;
}
}
@@ -98,13 +88,14 @@ namespace Jellyfin2Samsung.Helpers.API
try
{
SetupHeaders();
var serverUrl = UrlHelper.NormalizeServerUrl(AppSettings.Default.JellyfinFullUrl);
foreach (string userId in userIds.Where(u => !string.IsNullOrWhiteSpace(u)))
{
try
{
// Fetch user info
var getUserResponse = await _httpClient.GetAsync($"{AppSettings.Default.JellyfinFullUrl}/Users/{userId}");
var getUserResponse = await _httpClient.GetAsync($"{serverUrl}/Users/{userId}");
getUserResponse.EnsureSuccessStatusCode();
var userJson = await getUserResponse.Content.ReadAsStringAsync();
@@ -114,13 +105,13 @@ namespace Jellyfin2Samsung.Helpers.API
userNode["EnableAutoLogin"] = AppSettings.Default.UserAutoLogin;
var userContent = new StringContent(
userNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true }),
userNode.ToJsonString(JsonSerializerOptionsProvider.Indented),
Encoding.UTF8,
"application/json");
Constants.Api.JsonContentType);
using (userContent)
{
var userResponse = await _httpClient.PostAsync($"{AppSettings.Default.JellyfinFullUrl}/Users?userId={userId}", userContent);
var userResponse = await _httpClient.PostAsync($"{serverUrl}/Users?userId={userId}", userContent);
userResponse.EnsureSuccessStatusCode();
}
@@ -135,10 +126,10 @@ namespace Jellyfin2Samsung.Helpers.API
EnableNextEpisodeAutoPlay = AppSettings.Default.AutoPlayNextEpisode,
};
var configJson = JsonSerializer.Serialize(userConfig, new JsonSerializerOptions { WriteIndented = true });
var configJson = JsonSerializer.Serialize(userConfig, JsonSerializerOptionsProvider.Indented);
using var configContent = new StringContent(configJson, Encoding.UTF8, "application/json");
var configResponse = await _httpClient.PostAsync($"{AppSettings.Default.JellyfinFullUrl}/Users/Configuration?userId={userId}", configContent);
using var configContent = new StringContent(configJson, Encoding.UTF8, Constants.Api.JsonContentType);
var configResponse = await _httpClient.PostAsync($"{serverUrl}/Users/Configuration?userId={userId}", configContent);
configResponse.EnsureSuccessStatusCode();
}
catch (Exception userEx)
@@ -160,8 +151,9 @@ namespace Jellyfin2Samsung.Helpers.API
private void SetupHeaders()
{
_httpClient.DefaultRequestHeaders.Clear();
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("SamsungJellyfinInstaller/1.0");
_httpClient.DefaultRequestHeaders.Add("Authorization", $"MediaBrowser Token=\"{AppSettings.Default.JellyfinAccessToken}\"");
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(Constants.Api.UserAgent);
_httpClient.DefaultRequestHeaders.Add("Authorization",
string.Format(Constants.Api.MediaBrowserAuthHeader, AppSettings.Default.JellyfinAccessToken));
}
/// <summary>
@@ -172,12 +164,11 @@ namespace Jellyfin2Samsung.Helpers.API
{
try
{
var serverUrl = AppSettings.Default.JellyfinFullUrl.TrimEnd('/');
var serverUrl = UrlHelper.NormalizeServerUrl(AppSettings.Default.JellyfinFullUrl);
var authUrl = $"{serverUrl}/Users/AuthenticateByName";
_httpClient.DefaultRequestHeaders.Clear();
_httpClient.DefaultRequestHeaders.Add("X-Emby-Authorization",
"MediaBrowser Client=\"Samsung Jellyfin Installer\", Device=\"PC\", DeviceId=\"samsungjellyfin\", Version=\"1.0.0\"");
_httpClient.DefaultRequestHeaders.Add("X-Emby-Authorization", Constants.Api.EmbyAuthHeader);
var authPayload = new
{
@@ -186,7 +177,7 @@ namespace Jellyfin2Samsung.Helpers.API
};
var json = JsonSerializer.Serialize(authPayload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var content = new StringContent(json, Encoding.UTF8, Constants.Api.JsonContentType);
var response = await _httpClient.PostAsync(authUrl, content);
@@ -226,7 +217,7 @@ namespace Jellyfin2Samsung.Helpers.API
try
{
SetupHeaders();
var serverUrl = AppSettings.Default.JellyfinFullUrl.TrimEnd('/');
var serverUrl = UrlHelper.NormalizeServerUrl(AppSettings.Default.JellyfinFullUrl);
var usersUrl = $"{serverUrl}/Users";
Trace.WriteLine($"[LoadUsers] Fetching users from: {usersUrl}");
@@ -276,8 +267,7 @@ namespace Jellyfin2Samsung.Helpers.API
{
try
{
var serverUrl = AppSettings.Default.JellyfinFullUrl.TrimEnd('/');
var testUrl = $"{serverUrl}/System/Info/Public";
var testUrl = UrlHelper.CombineUrl(AppSettings.Default.JellyfinFullUrl, "/System/Info/Public");
_httpClient.DefaultRequestHeaders.Clear();
var response = await _httpClient.GetAsync(testUrl);
@@ -289,4 +279,4 @@ namespace Jellyfin2Samsung.Helpers.API
}
}
}
}
}

View File

@@ -1,10 +1,11 @@
using Jellyfin2Samsung.Interfaces;
using Jellyfin2Samsung.Helpers.Core;
using Jellyfin2Samsung.Interfaces;
using Jellyfin2Samsung.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
namespace Jellyfin2Samsung.Helpers.API
@@ -13,6 +14,7 @@ namespace Jellyfin2Samsung.Helpers.API
{
private readonly IDialogService _dialogService;
private readonly HttpClient _httpClient;
public TizenApiClient(
HttpClient httpClient,
IDialogService dialogService)
@@ -25,40 +27,51 @@ namespace Jellyfin2Samsung.Helpers.API
{
try
{
string url = $"http://{device.IpAddress}:8001/api/v2/";
string url = $"http://{device.IpAddress}:{Constants.Ports.SamsungTvApiPort}/api/v2/";
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
string jsonContent = await response.Content.ReadAsStringAsync();
JObject jsonObject = JObject.Parse(jsonContent);
var jsonObject = JsonNode.Parse(jsonContent);
var deviceNode = jsonObject?["device"];
if (deviceNode == null)
{
return CreateFallbackDevice(device);
}
return new NetworkDevice
{
IpAddress = jsonObject["device"]?["ip"]?.ToString(),
DeviceName = WebUtility.HtmlDecode(jsonObject["device"]?["name"]?.ToString()),
ModelName = jsonObject["device"]?["modelName"].ToString(),
Manufacturer = jsonObject["device"]?["type"]?.ToString(),
DeveloperMode = jsonObject["device"]?["developerMode"]?.ToString() ?? string.Empty,
DeveloperIP = jsonObject["device"]?["developerIP"]?.ToString() ?? string.Empty
IpAddress = deviceNode["ip"]?.GetValue<string>() ?? device.IpAddress,
DeviceName = WebUtility.HtmlDecode(deviceNode["name"]?.GetValue<string>() ?? string.Empty),
ModelName = deviceNode["modelName"]?.GetValue<string>() ?? string.Empty,
Manufacturer = deviceNode["type"]?.GetValue<string>() ?? string.Empty,
DeveloperMode = deviceNode["developerMode"]?.GetValue<string>() ?? string.Empty,
DeveloperIP = deviceNode["developerIP"]?.GetValue<string>() ?? string.Empty
};
}
catch (HttpRequestException ex)
{
await _dialogService.ShowErrorAsync(
$"Error connecting to Samsung TV at {device.IpAddress}: {ex}");
$"Error connecting to Samsung TV at {device.IpAddress}: {ex.Message}");
}
catch (JsonException ex)
{
await _dialogService.ShowErrorAsync(
$"Error parsing JSON response: {ex}");
$"Error parsing JSON response: {ex.Message}");
}
catch (Exception ex)
{
await _dialogService.ShowErrorAsync(
$"Unexpected error: {ex}");
$"Unexpected error: {ex.Message}");
}
return CreateFallbackDevice(device);
}
private static NetworkDevice CreateFallbackDevice(NetworkDevice device)
{
return new NetworkDevice
{
IpAddress = device.IpAddress,

View File

@@ -101,7 +101,7 @@ namespace Jellyfin2Samsung.Helpers
{
get
{
var baseUrl = JellyfinIP?.TrimEnd('/') ?? "";
var baseUrl = Core.UrlHelper.NormalizeServerUrl(JellyfinIP);
var basePath = JellyfinBasePath?.Trim('/') ?? "";
if (string.IsNullOrEmpty(basePath))

View File

@@ -1,9 +1,8 @@
using Jellyfin2Samsung.Models;
using Newtonsoft.Json;
using Jellyfin2Samsung.Models;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
namespace Jellyfin2Samsung.Helpers.Core
@@ -17,40 +16,49 @@ namespace Jellyfin2Samsung.Helpers.Core
_httpClient = httpClient;
}
public async Task<GitHubRelease?> GetLatestReleaseAsync(
string url,
string displayName,
JsonSerializerSettings settings)
public async Task<GitHubRelease?> GetLatestReleaseAsync(string url, string displayName)
{
using var stream = await _httpClient.GetStreamAsync(url);
using var sr = new StreamReader(stream);
using var reader = new JsonTextReader(sr);
var serializer = JsonSerializer.Create(settings);
GitHubRelease? latest = null;
if (reader.Read())
try
{
// Case 1: API returns array (normal GitHub behavior)
if (reader.TokenType == JsonToken.StartArray)
using var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync();
// Try to parse as array first (normal GitHub releases endpoint)
try
{
reader.Read(); // move to first element
latest = serializer.Deserialize<GitHubRelease>(reader);
var releases = await JsonSerializer.DeserializeAsync<List<GitHubRelease>>(
stream,
JsonSerializerOptionsProvider.Default);
var latest = releases?.Count > 0 ? releases[0] : null;
if (latest != null)
latest.Name = displayName;
return latest;
}
// Case 2: API returns single object
else if (reader.TokenType == JsonToken.StartObject)
catch (JsonException)
{
latest = serializer.Deserialize<GitHubRelease>(reader);
// If array parsing fails, try parsing as single object
stream.Position = 0;
var latest = await JsonSerializer.DeserializeAsync<GitHubRelease>(
stream,
JsonSerializerOptionsProvider.Default);
if (latest != null)
latest.Name = displayName;
return latest;
}
}
if (latest == null)
catch (Exception ex)
{
System.Diagnostics.Trace.WriteLine($"Failed to fetch release from {url}: {ex}");
return null;
latest.Name = displayName;
return latest;
}
}
}
}
}

View File

@@ -0,0 +1,235 @@
namespace Jellyfin2Samsung.Helpers.Core
{
/// <summary>
/// Centralized constants for the application.
/// Eliminates magic strings and numbers scattered throughout the codebase.
/// </summary>
public static class Constants
{
/// <summary>
/// Application identifiers and names.
/// </summary>
public static class AppIdentifiers
{
public const string JellyfinAppName = "Jellyfin";
public const string Jelly2SamsDefault = "Jelly2Sams (default)";
public const string Jelly2Sams = "Jelly2Sams";
public const string CustomWgtFile = "Custom WGT File";
}
/// <summary>
/// Tizen installation error codes returned by the SDB tool.
/// </summary>
public static class TizenErrorCodes
{
public const string DownloadFailed116 = "download failed[116]";
public const string InstallFailed118012 = "install failed[118012]";
public const string InstallFailed118Minus12 = "install failed[118, -12]";
public const string InstallFailed118 = "install failed[118]";
public const string Installing100 = "installing[100]";
public const string InstallCompleted = "install completed";
public const string ResignFailed = "Re-sign failed";
public const string Failed = "failed";
}
/// <summary>
/// Default values used throughout the application.
/// </summary>
public static class Defaults
{
public const string TizenOsVersion = "7.0";
public const string SdkToolPath = "/opt/usr/apps/tmp";
public const string HomeDeveloperPath = "/home/developer";
public const string TizenSdbDefaultVersion = "v1.0.0";
public const int SamsungLoginTimeoutMinutes = 5;
public const int NetworkScanTimeoutMs = 1000;
public const int HttpRequestTimeoutSeconds = 15;
public const int WebSocketMonitorDelaySeconds = 10;
}
/// <summary>
/// Tizen version thresholds for feature compatibility.
/// </summary>
public static class TizenVersions
{
public const string CertificateRequired = "7.0";
public const string PushInstallMax = "4.0";
public const string IntermediateVersion = "3.0";
}
/// <summary>
/// Network ports used by the application.
/// </summary>
public static class Ports
{
public const int TizenDevPort = 26101;
public const int SamsungTvApiPort = 8001;
public const int SamsungLoginCallbackPort = 4794;
}
/// <summary>
/// File extensions and patterns.
/// </summary>
public static class FilePatterns
{
public const string WgtExtension = ".wgt";
public const string TpkExtension = ".tpk";
public const string P12Extension = ".p12";
public const string CsrExtension = ".csr";
public const string CerExtension = ".cer";
public const string CrtExtension = ".crt";
public const string JsExtension = ".js";
public const string CssExtension = ".css";
public const string WgtPattern = "*.wgt";
public const string TpkPattern = "*.tpk";
}
/// <summary>
/// Platform-specific binary names.
/// </summary>
public static class PlatformBinaries
{
public const string TizenSdbWindowsPattern = "TizenSdb*.exe";
public const string TizenSdbLinuxPattern = "TizenSdb*_linux";
public const string TizenSdbMacOsPattern = "TizenSdb*_macos";
public const string WindowsExtension = ".exe";
public const string LinuxSuffix = "_linux";
public const string MacOsSuffix = "_macos";
public const string EsbuildWindows = "win-x64";
public const string EsbuildLinux = "linux-x64";
public const string EsbuildMacOs = "osx-universal";
public const string EsbuildExecutable = "esbuild";
public const string EsbuildExecutableWindows = "esbuild.exe";
}
/// <summary>
/// HTTP and API related constants.
/// </summary>
public static class Api
{
public const string UserAgent = "SamsungJellyfinInstaller/1.0";
public const string MediaBrowserAuthHeader = "MediaBrowser Token=\"{0}\"";
public const string EmbyAuthHeader = "MediaBrowser Client=\"Samsung Jellyfin Installer\", Device=\"PC\", DeviceId=\"samsungjellyfin\", Version=\"1.0.0\"";
public const string JsonContentType = "application/json";
}
/// <summary>
/// Samsung API endpoints and OAuth constants.
/// </summary>
public static class Samsung
{
public const string LoopbackHost = "localhost";
public const string CallbackPath = "/signin/callback";
public const string OAuthClientId = "v285zxnl3h";
public const string OAuthState = "accountcheckdogeneratedstatetext";
public const string TokenType = "TOKEN";
public const string SignInGateUrl = "https://account.samsung.com/accounts/be1dce529476c1a6d407c4c7578c31bd/signInGate";
public const string PlatformVd = "VD";
public const string PrivilegeLevelPublic = "Public";
public const string DeveloperTypeIndividual = "Individual";
}
/// <summary>
/// Certificate related constants.
/// </summary>
public static class Certificate
{
public const string AuthorFileName = "author.p12";
public const string DistributorFileName = "distributor.p12";
public const string PasswordFileName = "password.txt";
public const string DeviceProfileFileName = "device-profile.xml";
public const string AuthorCsrFileName = "author.csr";
public const string DistributorCsrFileName = "distributor.csr";
public const string SignedAuthorCerFileName = "signed_author.cer";
public const string SignedDistributorCerFileName = "signed_distributor.cer";
public const string AuthorCaFileName = "vd_tizen_dev_author_ca.cer";
public const string DistributorCaFileName = "vd_tizen_dev_public2.crt";
public const string KeyAlias = "usercertificate";
public const string CsrSubjectAuthor = "C=, ST=, L=, O=, OU=, CN=Jelly2Sams";
public const string CsrSubjectDistributorTemplate = "CN=TizenSDK, OU=, O=, L=, ST=, C=, emailAddress={0}";
public const string SigningAlgorithm = "SHA256withRSA";
public const int RsaKeySize = 2048;
}
/// <summary>
/// Jellyfin web app paths and file names.
/// </summary>
public static class JellyfinWeb
{
public const string IndexHtml = "index.html";
public const string ConfigJson = "config.json";
public const string WwwFolder = "www";
public const string PluginCacheFolder = "plugin_cache";
public const string CredentialsStorageKey = "jellyfin_credentials";
}
/// <summary>
/// Configuration update modes.
/// </summary>
public static class ConfigUpdateModes
{
public const string None = "None";
public const string Server = "Server";
public const string Browser = "Browser";
public const string User = "User";
public const string All = "All";
}
/// <summary>
/// Random string generation character sets.
/// </summary>
public static class CharacterSets
{
public const string AlphaLower = "abcdefghijklmnopqrstuvwxyz";
public const string AlphaUpper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
public const string Alpha = AlphaLower + AlphaUpper;
public const string AlphaNumeric = Alpha + "0123456789";
}
/// <summary>
/// Localization keys used for status messages.
/// </summary>
public static class LocalizationKeys
{
public const string InstallTizenSdb = "InstallTizenSdb";
public const string DiagnoseTv = "diagnoseTv";
public const string AlreadyInstalled = "alreadyInstalled";
public const string DeleteExistingVersion = "deleteExistingVersion";
public const string DeleteExistingFailed = "deleteExistingFailed";
public const string DeleteExistingSuccess = "deleteExistingSuccess";
public const string DeleteExistingNotAllowed = "deleteExistingNotAllowed";
public const string ConnectingToDevice = "ConnectingToDevice";
public const string TvNameNotFound = "TvNameNotFound";
public const string TvDuidNotFound = "TvDuidNotFound";
public const string SamsungLogin = "SamsungLogin";
public const string CreatingCertificateProfile = "CreatingCertificateProfile";
public const string PackageAndSign = "packageAndSign";
public const string InstallingPackage = "InstallingPackage";
public const string InstallationFailed = "InstallationFailed";
public const string InstallationSuccessful = "InstallationSuccessful";
public const string InsufficientSpace = "insufficientSpace";
public const string AuthorMismatch = "AuthorMismatch";
public const string ModifyConfigRequired = "modiyConfigRequired";
public const string FailedTizenSdb = "FailedTizenSdb";
public const string CheckingTizenSdb = "CheckingTizenSdb";
public const string ScanningNetwork = "ScanningNetwork";
public const string InitializationFailed = "InitializationFailed";
public const string NoDevicesFound = "NoDevicesFound";
public const string NoDevicesFoundRetry = "NoDevicesFoundRetry";
public const string Ready = "Ready";
public const string FailedLoadingReleases = "FailedLoadingReleases";
public const string InvalidDeviceIp = "InvalidDeviceIp";
public const string LblOther = "lblOther";
public const string IpNotListed = "IpNotListed";
}
/// <summary>
/// Esbuild transpilation settings.
/// </summary>
public static class Esbuild
{
public const string TempFolderName = "J2S_Esbuild";
public const string TargetEs2015 = "es2015";
}
}
}

View File

@@ -1,7 +1,6 @@
using System;
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
@@ -14,27 +13,7 @@ namespace Jellyfin2Samsung.Helpers.Core
try
{
string baseDir = AppContext.BaseDirectory;
string relPath;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
relPath = Path.Combine(AppSettings.EsbuildPath, "win-x64", "esbuild.exe");
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
relPath = Path.Combine(AppSettings.EsbuildPath, "linux-x64", "esbuild");
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
relPath = Path.Combine(AppSettings.EsbuildPath, "osx-universal", "esbuild");
}
else
{
return null;
}
string fullPath = Path.Combine(baseDir, relPath);
return File.Exists(fullPath) ? fullPath : null;
return PlatformService.GetEsbuildPath(Path.Combine(baseDir, AppSettings.EsbuildPath));
}
catch
{
@@ -53,22 +32,22 @@ namespace Jellyfin2Samsung.Helpers.Core
string? esbuildPath = GetEsbuildPath();
if (string.IsNullOrEmpty(esbuildPath))
{
Trace.WriteLine($"esbuild binary not found, skipping transpile for {relPathForLog ?? "unknown"}");
Trace.WriteLine($"esbuild binary not found, skipping transpile for {relPathForLog ?? "unknown"}");
return js;
}
string tempRoot = Path.Combine(Path.GetTempPath(), "J2S_Esbuild");
string tempRoot = Path.Combine(Path.GetTempPath(), Constants.Esbuild.TempFolderName);
Directory.CreateDirectory(tempRoot);
string inputPath = Path.Combine(tempRoot, Guid.NewGuid().ToString("N") + ".js");
string outputPath = Path.Combine(tempRoot, Guid.NewGuid().ToString("N") + ".js");
string inputPath = Path.Combine(tempRoot, Guid.NewGuid().ToString("N") + Constants.FilePatterns.JsExtension);
string outputPath = Path.Combine(tempRoot, Guid.NewGuid().ToString("N") + Constants.FilePatterns.JsExtension);
await File.WriteAllTextAsync(inputPath, js, Encoding.UTF8);
var psi = new ProcessStartInfo
{
FileName = esbuildPath,
Arguments = $"\"{inputPath}\" --outfile=\"{outputPath}\" --target=es2015",
Arguments = $"\"{inputPath}\" --outfile=\"{outputPath}\" --target={Constants.Esbuild.TargetEs2015}",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
@@ -85,7 +64,7 @@ namespace Jellyfin2Samsung.Helpers.Core
if (proc.ExitCode != 0 || !File.Exists(outputPath))
{
Trace.WriteLine($"esbuild failed for {relPathForLog ?? "unknown"} (exit {proc.ExitCode}): {stderr}");
Trace.WriteLine($"esbuild failed for {relPathForLog ?? "unknown"} (exit {proc.ExitCode}): {stderr}");
return js;
}
@@ -101,14 +80,14 @@ namespace Jellyfin2Samsung.Helpers.Core
// ignore cleanup errors
}
Trace.WriteLine($"Transpiled {relPathForLog ?? "unknown"} via esbuild");
Trace.WriteLine($"Transpiled {relPathForLog ?? "unknown"} via esbuild");
return transpiled;
}
catch (Exception ex)
{
Trace.WriteLine($"esbuild transpile error for {relPathForLog ?? "unknown"}: {ex}");
Trace.WriteLine($"esbuild transpile error for {relPathForLog ?? "unknown"}: {ex}");
return js;
}
}
}
}
}

View File

@@ -56,9 +56,9 @@ namespace Jellyfin2Samsung.Helpers.Core
var baseName = Path.GetFileNameWithoutExtension(originalPath);
var extension = Path.GetExtension(originalPath);
var random = new Random();
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
var randomSuffix = new string(Enumerable.Range(0, 4).Select(_ => chars[random.Next(chars.Length)]).ToArray());
var randomSuffix = new string(Enumerable.Range(0, 4)
.Select(_ => Constants.CharacterSets.Alpha[Random.Shared.Next(Constants.CharacterSets.Alpha.Length)])
.ToArray());
var newFileName = $"{baseName}{randomSuffix}{extension}";
var newFilePath = Path.Combine(directory ?? Environment.CurrentDirectory, newFileName);
@@ -76,11 +76,8 @@ namespace Jellyfin2Samsung.Helpers.Core
public List<ExtensionEntry> ParseExtensions(string output)
{
var extensions = new List<ExtensionEntry>();
var regex = new Regex(
@"Index\s*:\s*(\d+)\s+Name\s*:\s*(.*?)\s+Repository\s*:\s*.*?\s+Id\s*:\s*.*?\s+Vendor\s*:\s*.*?\s+Description\s*:\s*.*?\s+Default\s*:\s*.*?\s+Activate\s*:\s*(true|false)",
RegexOptions.Singleline);
foreach (Match match in regex.Matches(output))
foreach (Match match in RegexPatterns.Extension.ExtensionEntry.Matches(output))
{
extensions.Add(new ExtensionEntry
{
@@ -112,13 +109,7 @@ namespace Jellyfin2Samsung.Helpers.Core
using (var reader = new StreamReader(configEntry.Open(), Encoding.UTF8))
configContent = await reader.ReadToEndAsync();
// Regex to extract the package prefix before .Jellyfin
var regex = new Regex(
@"<tizen:application\s+id=""(?<pkg>[A-Za-z0-9]+)\.Jellyfin""\s+package=""\k<pkg>""",
RegexOptions.Multiline
);
var match = regex.Match(configContent);
var match = RegexPatterns.WgtConfig.TizenApplicationId.Match(configContent);
return match.Success ? match.Groups["pkg"].Value : null;
}
public static async Task<bool> ModifyWgtPackageId(string wgtPath)
@@ -149,10 +140,8 @@ namespace Jellyfin2Samsung.Helpers.Core
configContent = await reader.ReadToEndAsync();
// Replace old package ID with the new one
var regex = new Regex(
$@"<tizen:application\s+id=""{oldPkg}\.Jellyfin""\s+package=""{oldPkg}""",
RegexOptions.Multiline
);
var pattern = RegexPatterns.WgtConfig.CreatePackageIdReplacePattern(oldPkg);
var regex = new Regex(pattern, RegexOptions.Multiline);
var newConfig = regex.Replace(configContent, m =>
m.Value.Replace(oldPkg, newPkg)
@@ -171,11 +160,9 @@ namespace Jellyfin2Samsung.Helpers.Core
}
private static string GenerateRandomString(int length)
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
var random = new Random();
var sb = new StringBuilder(length);
for (int i = 0; i < length; i++)
sb.Append(chars[random.Next(chars.Length)]);
sb.Append(Constants.CharacterSets.AlphaNumeric[Random.Shared.Next(Constants.CharacterSets.AlphaNumeric.Length)]);
return sb.ToString();
}
}

View File

@@ -0,0 +1,70 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Jellyfin2Samsung.Helpers.Core
{
/// <summary>
/// Provides centralized, pre-configured JsonSerializerOptions for consistent JSON serialization
/// throughout the application. This eliminates inconsistent settings and improves performance
/// by reusing options instances.
/// </summary>
public static class JsonSerializerOptionsProvider
{
/// <summary>
/// Default options for general JSON serialization/deserialization.
/// - Case-insensitive property matching
/// - Camel case naming policy
/// - Ignores null values when writing
/// </summary>
public static JsonSerializerOptions Default { get; } = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>
/// Options for pretty-printed JSON output (e.g., config files, logs).
/// Same as Default but with indentation enabled.
/// </summary>
public static JsonSerializerOptions Indented { get; } = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true
};
/// <summary>
/// Options optimized for GitHub API responses.
/// Uses snake_case naming policy to match GitHub's JSON format.
/// </summary>
public static JsonSerializerOptions GitHub { get; } = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>
/// Options for strict JSON parsing (no case-insensitive matching).
/// Use when exact property name matching is required.
/// </summary>
public static JsonSerializerOptions Strict { get; } = new()
{
PropertyNameCaseInsensitive = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>
/// Options for web/API responses with standard web conventions.
/// </summary>
public static JsonSerializerOptions Web { get; } = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}
}

View File

@@ -0,0 +1,195 @@
using System;
using System.IO;
using System.Security.Cryptography.X509Certificates;
namespace Jellyfin2Samsung.Helpers.Core
{
/// <summary>
/// Provides centralized platform detection and platform-specific operations.
/// Eliminates duplicate OperatingSystem.* and RuntimeInformation.* checks throughout the codebase.
/// </summary>
public static class PlatformService
{
/// <summary>
/// Represents the current operating system platform.
/// </summary>
public enum Platform
{
Windows,
Linux,
MacOS,
Unknown
}
/// <summary>
/// Gets the current platform.
/// </summary>
public static Platform CurrentPlatform
{
get
{
if (OperatingSystem.IsWindows())
return Platform.Windows;
if (OperatingSystem.IsLinux())
return Platform.Linux;
if (OperatingSystem.IsMacOS())
return Platform.MacOS;
return Platform.Unknown;
}
}
/// <summary>
/// Returns true if the current platform is Windows.
/// </summary>
public static bool IsWindows => OperatingSystem.IsWindows();
/// <summary>
/// Returns true if the current platform is Linux.
/// </summary>
public static bool IsLinux => OperatingSystem.IsLinux();
/// <summary>
/// Returns true if the current platform is macOS.
/// </summary>
public static bool IsMacOS => OperatingSystem.IsMacOS();
/// <summary>
/// Returns true if the current platform is Unix-like (Linux or macOS).
/// </summary>
public static bool IsUnixLike => IsLinux || IsMacOS;
/// <summary>
/// Gets the appropriate TizenSdb search pattern for the current platform.
/// </summary>
/// <returns>The file search pattern for TizenSdb binary.</returns>
/// <exception cref="PlatformNotSupportedException">Thrown when the platform is not supported.</exception>
public static string GetTizenSdbSearchPattern()
{
return CurrentPlatform switch
{
Platform.Windows => Constants.PlatformBinaries.TizenSdbWindowsPattern,
Platform.Linux => Constants.PlatformBinaries.TizenSdbLinuxPattern,
Platform.MacOS => Constants.PlatformBinaries.TizenSdbMacOsPattern,
_ => throw new PlatformNotSupportedException("Unsupported operating system")
};
}
/// <summary>
/// Gets the TizenSdb file name with version for the current platform.
/// </summary>
/// <param name="version">The version string to include in the file name.</param>
/// <returns>The platform-specific file name.</returns>
/// <exception cref="PlatformNotSupportedException">Thrown when the platform is not supported.</exception>
public static string GetTizenSdbFileName(string version)
{
return CurrentPlatform switch
{
Platform.Windows => $"TizenSdb_{version}{Constants.PlatformBinaries.WindowsExtension}",
Platform.Linux => $"TizenSdb_{version}{Constants.PlatformBinaries.LinuxSuffix}",
Platform.MacOS => $"TizenSdb_{version}{Constants.PlatformBinaries.MacOsSuffix}",
_ => throw new PlatformNotSupportedException("Unsupported operating system")
};
}
/// <summary>
/// Gets the platform identifier for matching GitHub release assets.
/// </summary>
/// <returns>The platform identifier string used in asset file names.</returns>
/// <exception cref="PlatformNotSupportedException">Thrown when the platform is not supported.</exception>
public static string GetAssetPlatformIdentifier()
{
return CurrentPlatform switch
{
Platform.Windows => "exe",
Platform.Linux => "linux",
Platform.MacOS => "macos",
_ => throw new PlatformNotSupportedException("Unsupported operating system")
};
}
/// <summary>
/// Gets the esbuild binary path relative to the esbuild directory.
/// </summary>
/// <param name="esbuildBasePath">The base path to the esbuild directory.</param>
/// <returns>The full path to the esbuild binary, or null if not supported.</returns>
public static string? GetEsbuildPath(string esbuildBasePath)
{
string relativePath = CurrentPlatform switch
{
Platform.Windows => Path.Combine(
Constants.PlatformBinaries.EsbuildWindows,
Constants.PlatformBinaries.EsbuildExecutableWindows),
Platform.Linux => Path.Combine(
Constants.PlatformBinaries.EsbuildLinux,
Constants.PlatformBinaries.EsbuildExecutable),
Platform.MacOS => Path.Combine(
Constants.PlatformBinaries.EsbuildMacOs,
Constants.PlatformBinaries.EsbuildExecutable),
_ => null
};
if (relativePath == null)
return null;
string fullPath = Path.Combine(esbuildBasePath, relativePath);
return File.Exists(fullPath) ? fullPath : null;
}
/// <summary>
/// Gets the ARP command arguments for the current platform.
/// </summary>
/// <param name="ipAddress">The IP address to query.</param>
/// <returns>The ARP command arguments.</returns>
public static string GetArpArguments(string ipAddress)
{
return IsWindows ? $"-a {ipAddress}" : $"-n {ipAddress}";
}
/// <summary>
/// Gets the appropriate X509KeyStorageFlags for the current platform.
/// </summary>
/// <returns>The platform-appropriate key storage flags.</returns>
public static X509KeyStorageFlags GetX509KeyStorageFlags()
{
return IsWindows
? X509KeyStorageFlags.EphemeralKeySet
: X509KeyStorageFlags.PersistKeySet;
}
/// <summary>
/// Gets firewall configuration help text for the current platform.
/// </summary>
/// <param name="port">The port number to include in instructions.</param>
/// <returns>Platform-specific firewall configuration instructions.</returns>
public static string GetFirewallHelpText(int port)
{
return CurrentPlatform switch
{
Platform.Windows =>
$"Windows:\n" +
$" netstat -ano | findstr {port}\n\n" +
$" New-NetFirewallRule -DisplayName \"Jellyfin2Samsung Logs\" \\\n" +
$" -Direction Inbound -Protocol TCP -LocalPort {port} -Action Allow\n",
Platform.Linux =>
$"Linux:\n sudo ufw allow {port}/tcp\n sudo ufw reload\n",
Platform.MacOS =>
"macOS:\n" +
" System Settings -> Network -> Firewall -> Options\n" +
" Allow incoming connections for Jellyfin2Samsung\n",
_ => "Ensure your firewall allows inbound TCP connections.\n"
};
}
/// <summary>
/// Determines if the file needs executable permissions set (Unix-like systems only).
/// </summary>
/// <returns>True if chmod is needed, false otherwise.</returns>
public static bool RequiresExecutablePermissions()
{
return IsUnixLike;
}
}
}

View File

@@ -122,7 +122,7 @@ namespace Jellyfin2Samsung.Helpers.Core
public async Task MakeExecutableAsync(string filePath)
{
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
if (PlatformService.RequiresExecutablePermissions())
{
try
{

View File

@@ -0,0 +1,318 @@
using System.Text.RegularExpressions;
namespace Jellyfin2Samsung.Helpers.Core
{
/// <summary>
/// Centralized regex patterns used throughout the application.
/// Pre-compiled patterns improve performance for frequently used expressions.
/// </summary>
public static partial class RegexPatterns
{
/// <summary>
/// Patterns for version parsing and extraction.
/// </summary>
public static class Version
{
/// <summary>
/// Pattern to extract version from a file name (e.g., "TizenSdb_v1.0.0.exe" -> "v1.0.0").
/// </summary>
public const string FileNameVersionPattern = @"_([v]?\d+\.\d+\.\d+)";
/// <summary>
/// Pre-compiled regex for file name version extraction.
/// </summary>
public static readonly Regex FileNameVersion = new(FileNameVersionPattern, RegexOptions.Compiled);
}
/// <summary>
/// Patterns for Tizen device capability parsing.
/// </summary>
public static class TizenCapability
{
/// <summary>
/// Pattern to extract platform version from Tizen capability output.
/// </summary>
public const string PlatformVersionPattern = @"platform_version:\s*([\d.]+)";
/// <summary>
/// Pattern to extract SDK tool path from Tizen capability output.
/// </summary>
public const string SdkToolPathPattern = @"sdk_toolpath:\s*([^\r\n]+)";
/// <summary>
/// Pattern to detect app uninstall test failure in Tizen diagnose output.
/// </summary>
public const string AppUninstallFailedPattern = @"Testing '0 vd_appuninstall test':\s*FAILED";
/// <summary>
/// Pre-compiled regex for platform version extraction.
/// </summary>
public static readonly Regex PlatformVersion = new(PlatformVersionPattern, RegexOptions.Compiled);
/// <summary>
/// Pre-compiled regex for SDK tool path extraction.
/// </summary>
public static readonly Regex SdkToolPath = new(SdkToolPathPattern, RegexOptions.Compiled);
/// <summary>
/// Pre-compiled regex for app uninstall test failure detection.
/// </summary>
public static readonly Regex AppUninstallFailed = new(
AppUninstallFailedPattern,
RegexOptions.Compiled | RegexOptions.IgnoreCase);
}
/// <summary>
/// Patterns for Tizen app information parsing.
/// </summary>
public static class TizenApp
{
/// <summary>
/// Pattern template to extract app block by title from Tizen apps output.
/// Use string.Format or interpolation with the app title.
/// </summary>
public const string AppBlockByTitleTemplate = @"(^\s*-+app_title\s*=\s*{0}.*?)(?=^\s*-+app_title|\Z)";
/// <summary>
/// Pattern to extract Tizen app ID from an app block.
/// </summary>
public const string AppTizenIdPattern = @"app_tizen_id\s*=\s*([A-Za-z0-9._]+)";
/// <summary>
/// Pattern to extract Tizen app ID with delimiter.
/// </summary>
public const string AppTizenIdWithDelimiterPattern = @"app_tizen_id\s*=\s*([A-Za-z0-9._-]+?)(?=-{3,})";
/// <summary>
/// Pre-compiled regex for app Tizen ID extraction.
/// </summary>
public static readonly Regex AppTizenId = new(
AppTizenIdPattern,
RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>
/// Pre-compiled regex for app Tizen ID with delimiter extraction.
/// </summary>
public static readonly Regex AppTizenIdWithDelimiter = new(
AppTizenIdWithDelimiterPattern,
RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>
/// Creates a regex to find an app block by title.
/// </summary>
/// <param name="appTitle">The app title to search for.</param>
/// <returns>A regex instance for matching the app block.</returns>
public static Regex CreateAppBlockByTitleRegex(string appTitle)
{
var escapedTitle = Regex.Escape(appTitle);
var pattern = string.Format(AppBlockByTitleTemplate, escapedTitle);
return new Regex(
pattern,
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Multiline);
}
}
/// <summary>
/// Patterns for WGT package config.xml parsing.
/// </summary>
public static class WgtConfig
{
/// <summary>
/// Pattern to extract package ID from Tizen application element in config.xml.
/// </summary>
public const string TizenApplicationIdPattern =
@"<tizen:application\s+id=""(?<pkg>[A-Za-z0-9]+)\.Jellyfin""\s+package=""\k<pkg>""";
/// <summary>
/// Pre-compiled regex for Tizen application ID extraction.
/// </summary>
public static readonly Regex TizenApplicationId = new(
TizenApplicationIdPattern,
RegexOptions.Compiled | RegexOptions.Multiline);
/// <summary>
/// Creates a pattern to match and replace a specific package ID in config.xml.
/// </summary>
/// <param name="packageId">The package ID to match.</param>
/// <returns>The pattern string.</returns>
public static string CreatePackageIdReplacePattern(string packageId)
{
return $@"<tizen:application\s+id=""{packageId}\.Jellyfin""\s+package=""{packageId}""";
}
}
/// <summary>
/// Patterns for HTML parsing and manipulation.
/// </summary>
public static class Html
{
/// <summary>
/// Pattern to extract href from link elements.
/// </summary>
public const string LinkHrefPattern = @"<link[^>]+href=[""']([^""']+)[""'][^>]*>";
/// <summary>
/// Pattern to extract src from script elements.
/// </summary>
public const string ScriptSrcPattern = @"<script[^>]+src=[""']([^""']+)[""'][^>]*>[\s\S]*?<\/script>";
/// <summary>
/// Pre-compiled regex for link href extraction.
/// </summary>
public static readonly Regex LinkHref = new(
LinkHrefPattern,
RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>
/// Pre-compiled regex for script src extraction.
/// </summary>
public static readonly Regex ScriptSrc = new(
ScriptSrcPattern,
RegexOptions.Compiled | RegexOptions.IgnoreCase);
}
/// <summary>
/// Patterns for network and MAC address parsing.
/// </summary>
public static class Network
{
/// <summary>
/// Pattern to extract MAC address from ARP output.
/// </summary>
public const string MacAddressPattern = @"([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})";
/// <summary>
/// Pre-compiled regex for MAC address extraction.
/// </summary>
public static readonly Regex MacAddress = new(MacAddressPattern, RegexOptions.Compiled);
}
/// <summary>
/// Patterns for command line argument parsing.
/// </summary>
public static class CommandLine
{
/// <summary>
/// Pattern to parse command line arguments (handles quoted strings).
/// </summary>
public const string ArgumentsPattern = @"[\""].+?[\""]|[^ ]+";
/// <summary>
/// Pre-compiled regex for argument parsing.
/// </summary>
public static readonly Regex Arguments = new(ArgumentsPattern, RegexOptions.Compiled);
}
/// <summary>
/// Patterns for build info markdown parsing.
/// </summary>
public static class BuildInfo
{
/// <summary>
/// Pattern to extract versions table from markdown.
/// </summary>
public const string VersionsTablePattern = @"## Versions\s*\n(?<table>(\|[^\n]+\n)+)";
/// <summary>
/// Pattern to extract applications table from markdown.
/// </summary>
public const string ApplicationsTablePattern =
@"\|\s*🧩 Application\s*\|\s*📝 Description\s*\|\s*🔗 Repository\s*\|\s*\n(?<table>(\|[^\n]+\n)+)";
/// <summary>
/// Pattern to extract table rows with 2 columns.
/// </summary>
public const string TableRow2ColumnsPattern = @"^\|([^|]+)\|([^|]+)\|";
/// <summary>
/// Pattern to extract table rows with 3 columns.
/// </summary>
public const string TableRow3ColumnsPattern = @"^\|([^|]+)\|([^|]+)\|([^|]+)\|";
/// <summary>
/// Pattern to remove markdown bold formatting.
/// </summary>
public const string MarkdownBoldPattern = @"\*\*(.*?)\*\*";
/// <summary>
/// Pattern to remove emoji characters.
/// </summary>
public const string EmojiRangePattern = @"[\u2600-\u27BF]";
/// <summary>
/// Pre-compiled regex for versions table extraction.
/// </summary>
public static readonly Regex VersionsTable = new(
VersionsTablePattern,
RegexOptions.Compiled | RegexOptions.Multiline);
/// <summary>
/// Pre-compiled regex for applications table extraction.
/// </summary>
public static readonly Regex ApplicationsTable = new(
ApplicationsTablePattern,
RegexOptions.Compiled | RegexOptions.Multiline);
/// <summary>
/// Pre-compiled regex for 2-column table rows.
/// </summary>
public static readonly Regex TableRow2Columns = new(
TableRow2ColumnsPattern,
RegexOptions.Compiled | RegexOptions.Multiline);
/// <summary>
/// Pre-compiled regex for 3-column table rows.
/// </summary>
public static readonly Regex TableRow3Columns = new(
TableRow3ColumnsPattern,
RegexOptions.Compiled | RegexOptions.Multiline);
/// <summary>
/// Pre-compiled regex for markdown bold removal.
/// </summary>
public static readonly Regex MarkdownBold = new(MarkdownBoldPattern, RegexOptions.Compiled);
/// <summary>
/// Pre-compiled regex for emoji removal.
/// </summary>
public static readonly Regex EmojiRange = new(EmojiRangePattern, RegexOptions.Compiled);
}
/// <summary>
/// Patterns for plugin configuration parsing.
/// </summary>
public static class PluginConfig
{
/// <summary>
/// Pattern to extract defaultSkin value from JSON config.
/// </summary>
public const string DefaultSkinPattern = @"""defaultSkin""\s*:\s*""([^""]+)""";
/// <summary>
/// Pre-compiled regex for defaultSkin extraction.
/// </summary>
public static readonly Regex DefaultSkin = new(
DefaultSkinPattern,
RegexOptions.Compiled | RegexOptions.IgnoreCase);
}
/// <summary>
/// Patterns for extension parsing in Tizen package manager output.
/// </summary>
public static class Extension
{
/// <summary>
/// Pattern to parse extension entries from package manager output.
/// </summary>
public const string ExtensionEntryPattern =
@"Index\s*:\s*(\d+)\s+Name\s*:\s*(.*?)\s+Repository\s*:\s*.*?\s+Id\s*:\s*.*?\s+Vendor\s*:\s*.*?\s+Description\s*:\s*.*?\s+Default\s*:\s*.*?\s+Activate\s*:\s*(true|false)";
/// <summary>
/// Pre-compiled regex for extension entry parsing.
/// </summary>
public static readonly Regex ExtensionEntry = new(
ExtensionEntryPattern,
RegexOptions.Compiled | RegexOptions.Singleline);
}
}
}

View File

@@ -0,0 +1,94 @@
using System;
namespace Jellyfin2Samsung.Helpers.Core
{
/// <summary>
/// Provides URL manipulation utilities.
/// Centralizes URL normalization to eliminate duplicate TrimEnd('/') calls across the codebase.
/// </summary>
public static class UrlHelper
{
/// <summary>
/// Normalizes a server URL by removing trailing slashes.
/// </summary>
/// <param name="url">The URL to normalize.</param>
/// <returns>The normalized URL without trailing slashes, or empty string if null.</returns>
public static string NormalizeServerUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url))
return string.Empty;
return url.TrimEnd('/');
}
/// <summary>
/// Combines a base URL with a path segment, ensuring proper slash handling.
/// </summary>
/// <param name="baseUrl">The base URL (trailing slash will be removed).</param>
/// <param name="path">The path to append (leading slash will be added if missing).</param>
/// <returns>The combined URL.</returns>
public static string CombineUrl(string? baseUrl, string? path)
{
var normalizedBase = NormalizeServerUrl(baseUrl);
if (string.IsNullOrWhiteSpace(path))
return normalizedBase;
var normalizedPath = path.TrimStart('/');
return string.IsNullOrEmpty(normalizedBase)
? normalizedPath
: $"{normalizedBase}/{normalizedPath}";
}
/// <summary>
/// Creates an absolute URI from a base server URL and a relative or absolute path.
/// </summary>
/// <param name="serverUrl">The base server URL.</param>
/// <param name="relativeOrAbsolutePath">A relative path or absolute URL.</param>
/// <returns>The absolute URI.</returns>
public static Uri GetAbsoluteUri(string serverUrl, string relativeOrAbsolutePath)
{
if (Uri.IsWellFormedUriString(relativeOrAbsolutePath, UriKind.Absolute))
return new Uri(relativeOrAbsolutePath);
var baseUri = new Uri(NormalizeServerUrl(serverUrl) + "/");
return new Uri(baseUri, relativeOrAbsolutePath.TrimStart('/'));
}
/// <summary>
/// Validates if a string is a valid HTTP or HTTPS URL.
/// </summary>
/// <param name="url">The URL to validate.</param>
/// <returns>True if the URL is valid, false otherwise.</returns>
public static bool IsValidHttpUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url))
return false;
return Uri.TryCreate(url, UriKind.Absolute, out var uriResult)
&& (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
}
/// <summary>
/// Extracts the file name from a URL path.
/// </summary>
/// <param name="url">The URL to extract the file name from.</param>
/// <returns>The file name, or empty string if not found.</returns>
public static string GetFileNameFromUrl(string url)
{
if (string.IsNullOrWhiteSpace(url))
return string.Empty;
try
{
var uri = new Uri(url);
return System.IO.Path.GetFileName(uri.LocalPath);
}
catch
{
return string.Empty;
}
}
}
}

View File

@@ -72,7 +72,7 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Patches
config["servers"] = servers;
}
var serverUrl = AppSettings.Default.JellyfinFullUrl.TrimEnd('/');
var serverUrl = UrlHelper.NormalizeServerUrl(AppSettings.Default.JellyfinFullUrl);
// Avoid duplicates
if (!servers.Any(s => s?.GetValue<string>() == serverUrl))
@@ -91,7 +91,7 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Patches
{
var accessToken = AppSettings.Default.JellyfinAccessToken;
var userId = AppSettings.Default.JellyfinUserId;
var serverUrl = AppSettings.Default.JellyfinFullUrl.TrimEnd('/');
var serverUrl = UrlHelper.NormalizeServerUrl(AppSettings.Default.JellyfinFullUrl);
var serverId = AppSettings.Default.JellyfinServerId;
var localAddress = AppSettings.Default.JellyfinServerLocalAddress;

View File

@@ -72,7 +72,7 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Plugins
{
try
{
var url = serverUrl.TrimEnd('/') + "/web/index.html";
var url = UrlHelper.CombineUrl(serverUrl, "/web/index.html");
Trace.WriteLine($"▶ Fetching server index.html: {url}");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
@@ -219,10 +219,7 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Plugins
}
private static Uri GetAbsoluteUri(string serverUrl, string relOrAbs)
{
if (Uri.IsWellFormedUriString(relOrAbs, UriKind.Absolute))
return new Uri(relOrAbs);
return new Uri(new Uri(serverUrl.TrimEnd('/') + "/"), relOrAbs.TrimStart('/'));
return UrlHelper.GetAbsoluteUri(serverUrl, relOrAbs);
}
private void AppendScriptOnce(StringBuilder js, string scriptTag, string src)
{

View File

@@ -1,4 +1,5 @@
using Jellyfin2Samsung.Helpers.Jellyfin.Plugins;
using Jellyfin2Samsung.Helpers.Core;
using Jellyfin2Samsung.Helpers.Jellyfin.Plugins;
using Jellyfin2Samsung.Interfaces;
using System;
using System.Collections.Generic;
@@ -243,10 +244,7 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Plugins.KefinTweaks
return null;
var json = File.ReadAllText(configPath);
var match = Regex.Match(
json,
@"""defaultSkin""\s*:\s*""([^""]+)""",
RegexOptions.IgnoreCase);
var match = RegexPatterns.PluginConfig.DefaultSkin.Match(json);
return match.Success ? match.Groups[1].Value : null;
}

View File

@@ -80,7 +80,7 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Plugins
{
try
{
string url = serverUrl.TrimEnd('/') + rel;
string url = UrlHelper.CombineUrl(serverUrl, rel);
Trace.WriteLine($" → Fetch: {url}");
string js = await _httpClient.GetStringAsync(url);

View File

@@ -1,29 +1,29 @@
using Newtonsoft.Json;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
namespace Jellyfin2Samsung.Models
{
public class GitHubRelease
{
[JsonProperty("url")]
public string Url { get; set; }
[JsonProperty("name")]
public string Name { get; set; }
[JsonPropertyName("url")]
public string Url { get; set; } = string.Empty;
[JsonProperty("tag_name")]
public string TagName { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonProperty("published_at")]
public string PublishedAt { get; set; }
[JsonPropertyName("tag_name")]
public string TagName { get; set; } = string.Empty;
[JsonProperty("assets")]
public List<Asset> Assets { get; set; } = new List<Asset>();
[JsonPropertyName("published_at")]
public string PublishedAt { get; set; } = string.Empty;
[JsonPropertyName("assets")]
public List<Asset> Assets { get; set; } = new();
[JsonIgnore]
public string? PrimaryDownloadUrl => Assets?.FirstOrDefault()?.DownloadUrl;
public string PrimaryDownloadUrl => Assets?.FirstOrDefault()?.DownloadUrl;
[JsonConstructor]
public GitHubRelease()
{
}
@@ -31,30 +31,33 @@ namespace Jellyfin2Samsung.Models
public class Asset
{
[JsonProperty("name")]
public string FileName { get; set; }
[JsonPropertyName("name")]
public string FileName { get; set; } = string.Empty;
[JsonProperty("browser_download_url")]
public string DownloadUrl { get; set; }
[JsonPropertyName("browser_download_url")]
public string DownloadUrl { get; set; } = string.Empty;
[JsonProperty("size")]
[JsonPropertyName("size")]
public long Size { get; set; }
[JsonIgnore]
public string DisplayText => $"{FileName} ({FormatFileSize(Size)})";
private string FormatFileSize(long bytes)
private static string FormatFileSize(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB" };
string[] sizes = ["B", "KB", "MB", "GB"];
int order = 0;
double len = bytes;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len /= 1024;
}
return $"{len:0.##} {sizes[order]}";
}
[JsonConstructor]
public Asset()
{
}

View File

@@ -1,4 +1,5 @@
using Jellyfin2Samsung.Helpers;
using Jellyfin2Samsung.Helpers.Core;
using Jellyfin2Samsung.Interfaces;
using Jellyfin2Samsung.Models;
using System;
@@ -19,8 +20,6 @@ namespace Jellyfin2Samsung.Services
{
private readonly ITizenInstallerService _tizenInstaller;
private static readonly HttpClient _httpClient = new HttpClient();
private const int tvPort = 26101;
private const int scanTimeoutMs = 1000;
public NetworkService(ITizenInstallerService tizenInstaller)
{
@@ -36,13 +35,13 @@ namespace Jellyfin2Samsung.Services
{
try
{
using var cts = new CancellationTokenSource(scanTimeoutMs);
using var cts = new CancellationTokenSource(Constants.Defaults.NetworkScanTimeoutMs);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
cts.Token, cancellationToken);
if (await IsPortOpenAsync(ip, tvPort, linkedCts.Token))
if (await IsPortOpenAsync(ip, Constants.Ports.TizenDevPort, linkedCts.Token))
{
if (await IsPortOpenAsync(ip, 8001, linkedCts.Token))
if (await IsPortOpenAsync(ip, Constants.Ports.SamsungTvApiPort, linkedCts.Token))
{
var manufacturer = await GetManufacturerFromIp(ip);
var device = new NetworkDevice
@@ -87,10 +86,10 @@ namespace Jellyfin2Samsung.Services
{
try
{
using var cts = new CancellationTokenSource(scanTimeoutMs);
using var cts = new CancellationTokenSource(Constants.Defaults.NetworkScanTimeoutMs);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
cts.Token, cancellationToken);
if (await IsPortOpenAsync(ip, tvPort, linkedCts.Token))
if (await IsPortOpenAsync(ip, Constants.Ports.TizenDevPort, linkedCts.Token))
{
var manufacturer = await GetManufacturerFromIp(ip);
var device = new NetworkDevice
@@ -111,7 +110,7 @@ namespace Jellyfin2Samsung.Services
catch { /* Ignore scan failures */ }
})));
Trace.WriteLine($"Scan complete! Found {foundDevices.Count} devices with port {tvPort} open.");
Trace.WriteLine($"Scan complete! Found {foundDevices.Count} devices with port {Constants.Ports.TizenDevPort} open.");
return foundDevices;
}
public IEnumerable<IPAddress> GetRelevantLocalIPs(bool virtualScan = false)
@@ -186,7 +185,7 @@ namespace Jellyfin2Samsung.Services
private static async Task<string?> GetMacAddressFromIp(string ipAddress)
{
string arpArgs = OperatingSystem.IsWindows() ? $"-a {ipAddress}" : $"-n {ipAddress}";
string arpArgs = PlatformService.GetArpArguments(ipAddress);
try
{
@@ -206,7 +205,7 @@ namespace Jellyfin2Samsung.Services
string output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
var match = Regex.Match(output, @"([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})");
var match = RegexPatterns.Network.MacAddress.Match(output);
return match.Success ? match.Value : null;
}
catch

View File

@@ -1,14 +1,13 @@
using Avalonia.Controls;
using Jellyfin2Samsung.Helpers.Core;
using Jellyfin2Samsung.Models;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json;
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
@@ -17,13 +16,12 @@ namespace Jellyfin2Samsung.Services
{
public class SamsungLoginService
{
private IWebHost _callbackServer;
private const string LoopbackHost = "localhost";
private const int FixedPort = 4794;
private IWebHost? _callbackServer;
private string _callbackUrl => $"http://{LoopbackHost}:{FixedPort}/signin/callback";
private string CallbackUrl =>
$"http://{Constants.Samsung.LoopbackHost}:{Constants.Ports.SamsungLoginCallbackPort}{Constants.Samsung.CallbackPath}";
public Action<SamsungAuth> CallbackReceived;
public Action<SamsungAuth>? CallbackReceived;
public static Task<SamsungAuth> PerformSamsungLoginAsync()
{
@@ -50,15 +48,15 @@ namespace Jellyfin2Samsung.Services
await service.StartCallbackServer();
string loginUrl =
$"https://account.samsung.com/accounts/be1dce529476c1a6d407c4c7578c31bd/signInGate" +
$"?locale=&clientId=v285zxnl3h" +
$"&redirect_uri={HttpUtility.UrlEncode(service._callbackUrl)}" +
$"&state=accountcheckdogeneratedstatetext" +
$"&tokenType=TOKEN";
$"{Constants.Samsung.SignInGateUrl}" +
$"?locale=&clientId={Constants.Samsung.OAuthClientId}" +
$"&redirect_uri={HttpUtility.UrlEncode(service.CallbackUrl)}" +
$"&state={Constants.Samsung.OAuthState}" +
$"&tokenType={Constants.Samsung.TokenType}";
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
Process.Start(new ProcessStartInfo
{
FileName = loginUrl,
UseShellExecute = true
@@ -84,17 +82,18 @@ namespace Jellyfin2Samsung.Services
{
_callbackServer = new WebHostBuilder()
.UseKestrel()
.UseUrls($"http://{LoopbackHost}:{FixedPort}")
.UseUrls($"http://{Constants.Samsung.LoopbackHost}:{Constants.Ports.SamsungLoginCallbackPort}")
.Configure(app =>
{
app.Run(async context =>
{
if (context.Request.Path == "/signin/callback" && context.Request.Method == "POST")
if (context.Request.Path == Constants.Samsung.CallbackPath &&
context.Request.Method == "POST")
{
string body = await new StreamReader(context.Request.Body).ReadToEndAsync();
string state = null;
string codeEncoded = null;
string? state = null;
string? codeEncoded = null;
var parts = body.Split('&', StringSplitOptions.RemoveEmptyEntries);
@@ -119,7 +118,10 @@ namespace Jellyfin2Samsung.Services
try
{
var auth = JsonConvert.DeserializeObject<SamsungAuth>(codeEncoded);
var auth = JsonSerializer.Deserialize<SamsungAuth>(
codeEncoded,
JsonSerializerOptionsProvider.Default);
if (auth != null)
{
auth.state = state;
@@ -134,7 +136,7 @@ namespace Jellyfin2Samsung.Services
{
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
await context.Response.WriteAsync(
$"[CallbackServer] JSON parse error: {ex}\n\nDecoded JSON:\n{codeEncoded}");
$"[CallbackServer] JSON parse error: {ex.Message}\n\nDecoded JSON:\n{codeEncoded}");
return;
}
@@ -151,8 +153,8 @@ namespace Jellyfin2Samsung.Services
await _callbackServer.StartAsync();
System.Diagnostics.Trace.WriteLine(
$"[SamsungLoginService] Bound to http://{LoopbackHost}:{FixedPort}");
Trace.WriteLine(
$"[SamsungLoginService] Bound to http://{Constants.Samsung.LoopbackHost}:{Constants.Ports.SamsungLoginCallbackPort}");
}
public async Task StopCallbackServer()
@@ -165,4 +167,4 @@ namespace Jellyfin2Samsung.Services
}
}
}
}
}

View File

@@ -1,5 +1,6 @@
using Jellyfin2Samsung.Extensions;
using Jellyfin2Samsung.Helpers;
using Jellyfin2Samsung.Helpers.Core;
using Jellyfin2Samsung.Helpers.Tizen.Certificate;
using Jellyfin2Samsung.Interfaces;
using Org.BouncyCastle.Asn1;
@@ -291,7 +292,7 @@ namespace Jellyfin2Samsung.Services
}
// --- Optional sanity check ---
if (OperatingSystem.IsWindows())
if (PlatformService.IsWindows)
{
try
{
@@ -319,10 +320,7 @@ namespace Jellyfin2Samsung.Services
}
private static X509KeyStorageFlags GetX509KeyStorageFlags()
{
if (OperatingSystem.IsWindows())
return X509KeyStorageFlags.EphemeralKeySet;
return X509KeyStorageFlags.PersistKeySet;
return PlatformService.GetX509KeyStorageFlags();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
using Fleck;
using Fleck;
using Jellyfin2Samsung.Helpers.Core;
using Jellyfin2Samsung.Models;
using System;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
@@ -84,7 +84,7 @@ namespace Jellyfin2Samsung.Services
{
try
{
await Task.Delay(TimeSpan.FromSeconds(10), token);
await Task.Delay(TimeSpan.FromSeconds(Constants.Defaults.WebSocketMonitorDelaySeconds), token);
if (_connection != null || token.IsCancellationRequested)
return;
@@ -94,37 +94,9 @@ namespace Jellyfin2Samsung.Services
onMessage(
"[No incoming connections detected]\n" +
"If the TV cannot connect, your firewall may be blocking this port.\n\n" +
GetFirewallHelpText(port));
PlatformService.GetFirewallHelpText(port));
}
catch (TaskCanceledException) { }
}
private static string GetFirewallHelpText(int port)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return
"Windows:\n" +
$" netstat -ano | findstr {port}\n\n" +
$" New-NetFirewallRule -DisplayName \"Jellyfin2Samsung Logs\" \\\n" +
$" -Direction Inbound -Protocol TCP -LocalPort {port} -Action Allow\n";
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
return
$"Linux:\n sudo ufw allow {port}/tcp\n sudo ufw reload\n";
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
return
"macOS:\n" +
" System Settings → Network → Firewall → Options\n" +
" Allow incoming connections for Jellyfin2Samsung\n";
}
return "Ensure your firewall allows inbound TCP connections.\n";
}
}
}

View File

@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Jellyfin2Samsung.Helpers;
using Jellyfin2Samsung.Helpers.Core;
using Jellyfin2Samsung.Models;
namespace Jellyfin2Samsung.ViewModels
@@ -64,24 +65,20 @@ namespace Jellyfin2Samsung.ViewModels
// Remove markdown formatting like **bold**, emoji, etc.
private static string CleanText(string input)
{
var text = Regex.Replace(input, @"\*\*(.*?)\*\*", "$1");
text = Regex.Replace(text, @"[\u2600-\u27BF]", ""); // emoji range
var text = RegexPatterns.BuildInfo.MarkdownBold.Replace(input, "$1");
text = RegexPatterns.BuildInfo.EmojiRange.Replace(text, "");
return text.Trim();
}
private void ParseVersionsTable(string md, ObservableCollection<BuildVersion> target)
{
var match = Regex.Match(md,
@"## Versions\s*\n(?<table>(\|[^\n]+\n)+)",
RegexOptions.Multiline);
var match = RegexPatterns.BuildInfo.VersionsTable.Match(md);
if (!match.Success) return;
var table = match.Groups["table"].Value;
var rows = Regex.Matches(table,
@"^\|([^|]+)\|([^|]+)\|",
RegexOptions.Multiline);
var rows = RegexPatterns.BuildInfo.TableRow2Columns.Matches(table);
bool headerSkipped = false;
@@ -110,17 +107,13 @@ namespace Jellyfin2Samsung.ViewModels
private void ParseApplicationsTable(string md, ObservableCollection<BuildVersion> target)
{
var match = Regex.Match(md,
@"\|\s*🧩 Application\s*\|\s*📝 Description\s*\|\s*🔗 Repository\s*\|\s*\n(?<table>(\|[^\n]+\n)+)",
RegexOptions.Multiline);
var match = RegexPatterns.BuildInfo.ApplicationsTable.Match(md);
if (!match.Success) return;
var table = match.Groups["table"].Value;
var rows = Regex.Matches(table,
@"^\|([^|]+)\|([^|]+)\|([^|]+)\|",
RegexOptions.Multiline);
var rows = RegexPatterns.BuildInfo.TableRow3Columns.Matches(table);
bool headerSkipped = false;

View File

@@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Jellyfin2Samsung.Helpers;
using Jellyfin2Samsung.Helpers.API;
using Jellyfin2Samsung.Helpers.Core;
using Jellyfin2Samsung.Helpers.Tizen.Certificate;
using Jellyfin2Samsung.Interfaces;
using Jellyfin2Samsung.Models;
@@ -510,7 +511,7 @@ namespace Jellyfin2Samsung.ViewModels
JellyfinServerIp = uri.Host;
SelectedJellyfinPort = uri.IsDefaultPort ? (uri.Scheme == "https" ? "443" : "80") : uri.Port.ToString();
var path = uri.AbsolutePath.TrimEnd('/');
var path = UrlHelper.NormalizeServerUrl(uri.AbsolutePath);
if (!string.IsNullOrEmpty(path) && path != "/")
{
jellyfinBasePath = path;
@@ -541,7 +542,7 @@ namespace Jellyfin2Samsung.ViewModels
SelectedJellyfinPort = uri.IsDefaultPort ? (uri.Scheme == "https" ? "443" : "80") : uri.Port.ToString();
// Extract the path portion (without triggering this handler again)
var path = uri.AbsolutePath.TrimEnd('/');
var path = UrlHelper.NormalizeServerUrl(uri.AbsolutePath);
AppSettings.Default.JellyfinBasePath = path;
jellyfinBasePath = path; // Set backing field directly to avoid recursion
OnPropertyChanged(nameof(JellyfinBasePath));

View File

@@ -5,15 +5,12 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Jellyfin2Samsung.Helpers;
using Jellyfin2Samsung.Helpers.API;
using Jellyfin2Samsung.Helpers.Converters;
using Jellyfin2Samsung.Helpers.Core;
using Jellyfin2Samsung.Helpers.Tizen.Devices;
using Jellyfin2Samsung.Interfaces;
using Jellyfin2Samsung.Models;
using Jellyfin2Samsung.Views;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
@@ -411,36 +408,28 @@ namespace Jellyfin2Samsung.ViewModels
try
{
var settings = new JsonSerializerSettings
{
MissingMemberHandling = MissingMemberHandling.Ignore,
Converters = { new SingleOrArrayConverter<GitHubRelease>() }
};
// 1⃣ Do ALL async / HTTP work first
var list = new List<GitHubRelease>();
async Task fetch(string url, string name)
{
var release = await _addLatestRelease.GetLatestReleaseAsync(url, name, settings);
var release = await _addLatestRelease.GetLatestReleaseAsync(url, name);
if (release != null)
list.Add(release);
}
await fetch(AppSettings.Default.ReleasesUrl, "Jellyfin");
await fetch(AppSettings.Default.ReleasesUrl, Constants.AppIdentifiers.JellyfinAppName);
await fetch(AppSettings.Default.MoonfinRelease, "Moonfin");
await fetch(AppSettings.Default.JellyfinAvRelease, "Jellyfin - AVPlay");
await fetch(AppSettings.Default.JellyfinLegacy, "Jellyfin - Legacy");
await fetch(AppSettings.Default.CommunityRelease, "Tizen Community");
// 2⃣ ONE UI-thread update, just like your original working version
Releases.Clear();
foreach (var r in list)
Releases.Add(r);
Releases.Add(new GitHubRelease
{
Name = "Custom WGT File",
Name = Constants.AppIdentifiers.CustomWgtFile,
TagName = string.Empty,
PublishedAt = string.Empty,
Url = string.Empty,
@@ -450,7 +439,7 @@ namespace Jellyfin2Samsung.ViewModels
catch (Exception ex)
{
Trace.WriteLine(ex);
await _dialogService.ShowErrorAsync($"{L("FailedLoadingReleases")} {ex}");
await _dialogService.ShowErrorAsync($"{L(Constants.LocalizationKeys.FailedLoadingReleases)} {ex}");
}
finally
{

View File

@@ -66,11 +66,11 @@
Foreground="#2C3E50"/>
<!-- Language Section -->
<Grid ColumnDefinitions="150,*" ColumnSpacing="12">
<Grid ColumnDefinitions="180,*" ColumnSpacing="12">
<TextBlock Grid.Column="0"
Text="{Binding LblLanguage}"
VerticalAlignment="Center"
Foreground="#34495E"/>
Classes="label"
VerticalAlignment="Center"/>
<ComboBox Grid.Column="1"
Classes="clean"
ItemsSource="{Binding AvailableLanguages}"
@@ -85,11 +85,11 @@
</Grid>
<!-- Certificate Section -->
<Grid ColumnDefinitions="150,*" ColumnSpacing="12">
<Grid ColumnDefinitions="180,*" ColumnSpacing="12">
<TextBlock Grid.Column="0"
Text="{Binding LblCertificate}"
VerticalAlignment="Center"
Foreground="#34495E"/>
Classes="label"
VerticalAlignment="Center"/>
<ComboBox Grid.Column="1"
Classes="clean"
ItemsSource="{Binding AvailableCertificates}"
@@ -104,15 +104,15 @@
</Grid>
<!-- Local Device IP -->
<Grid ColumnDefinitions="150,*" ColumnSpacing="12">
<Grid ColumnDefinitions="180,*" ColumnSpacing="12">
<TextBlock Grid.Column="0"
Text="{Binding LblLocalIP}"
VerticalAlignment="Center"
Foreground="#34495E"/>
Classes="label"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="1"
Text="{Binding LocalIP, Mode=TwoWay}"
VerticalAlignment="Center"
Foreground="#7F8C8D"/>
Classes="subtitle"
VerticalAlignment="Center"/>
</Grid>
</StackPanel>
@@ -124,8 +124,8 @@
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="12">
<TextBlock Grid.Column="0"
Text="{Binding LblTryOverwrite}"
VerticalAlignment="Center"
Foreground="#34495E"/>
Classes="label"
VerticalAlignment="Center"/>
<ToggleSwitch Grid.Column="1"
IsChecked="{Binding TryOverwrite}"
VerticalAlignment="Center"/>
@@ -135,8 +135,8 @@
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="12">
<TextBlock Grid.Column="0"
Text="{Binding LblLaunchOnInstall}"
VerticalAlignment="Center"
Foreground="#34495E"/>
Classes="label"
VerticalAlignment="Center"/>
<ToggleSwitch Grid.Column="1"
IsChecked="{Binding OpenAfterInstall}"
VerticalAlignment="Center"/>
@@ -146,8 +146,8 @@
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="12">
<TextBlock Grid.Column="0"
Text="{Binding LblRememberIp}"
VerticalAlignment="Center"
Foreground="#34495E"/>
Classes="label"
VerticalAlignment="Center"/>
<ToggleSwitch Grid.Column="1"
IsChecked="{Binding RememberCustomIP}"
VerticalAlignment="Center"/>
@@ -157,8 +157,8 @@
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="12">
<TextBlock Grid.Column="0"
Text="{Binding LblDeletePrevious}"
VerticalAlignment="Center"
Foreground="#34495E"/>
Classes="label"
VerticalAlignment="Center"/>
<ToggleSwitch Grid.Column="1"
IsChecked="{Binding DeletePreviousInstall}"
VerticalAlignment="Center"/>
@@ -168,8 +168,8 @@
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="12">
<TextBlock Grid.Column="0"
Text="{Binding LblForceLogin}"
VerticalAlignment="Center"
Foreground="#34495E"/>
Classes="label"
VerticalAlignment="Center"/>
<ToggleSwitch Grid.Column="1"
IsChecked="{Binding ForceSamsungLogin}"
VerticalAlignment="Center"/>
@@ -179,8 +179,8 @@
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="12">
<TextBlock Grid.Column="0"
Text="{Binding LblRTL}"
VerticalAlignment="Center"
Foreground="#34495E"/>
Classes="label"
VerticalAlignment="Center"/>
<ToggleSwitch Grid.Column="1"
IsChecked="{Binding RtlReading}"
VerticalAlignment="Center"/>
@@ -190,8 +190,8 @@
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="12">
<TextBlock Grid.Column="0"
Text="{Binding LblKeepWGTFile}"
VerticalAlignment="Center"
Foreground="#34495E"/>
Classes="label"
VerticalAlignment="Center"/>
<ToggleSwitch Grid.Column="1"
IsChecked="{Binding KeepWGTFile}"
VerticalAlignment="Center"/>