Merge branch 'beta' into l10n_beta

This commit is contained in:
Patrick
2026-04-16 09:35:00 +02:00
committed by GitHub
16 changed files with 1045 additions and 205 deletions

View File

@@ -75,10 +75,14 @@ namespace Jellyfin2Samsung
services.AddSingleton<IUpdaterService, UpdaterService>();
services.AddSingleton<IUpdateDialogService, UpdateDialogService>();
// HttpClient (configured ONCE)
// HttpClient (configured ONCE, with GitHub auth if available)
services.AddSingleton(sp =>
{
var client = new HttpClient
var appSettings = sp.GetRequiredService<AppSettings>();
var token = Helpers.Core.GitHubAuthHandler.ResolveToken(appSettings);
var handler = new Helpers.Core.GitHubAuthHandler(token);
var client = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(30)
};
@@ -86,7 +90,6 @@ namespace Jellyfin2Samsung
client.DefaultRequestHeaders.UserAgent.ParseAdd("SamsungJellyfinInstaller/1.1");
client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
return client;
});

View File

@@ -35,6 +35,7 @@ namespace Jellyfin2Samsung.Helpers
public string Certificate { get; set; } = "Jelly2Sams";
public bool DeletePreviousInstall { get; set; } = false;
public string UserCustomIP { get; set; } = "";
public string SavedNetworkInterfaceName { get; set; } = "";
public bool ForceSamsungLogin { get; set; } = false;
public bool RTLReading { get; set; } = false;
public string JellyfinIP { get; set; } = "";
@@ -45,6 +46,7 @@ namespace Jellyfin2Samsung.Helpers
public string JellyfinAccessToken { get; set; } = "";
public string JellyfinServerId { get; set; } = "";
public string JellyfinServerLocalAddress { get; set; } = "";
public string JellyfinServerName { get; set; } = "";
public string AudioLanguagePreference { get; set; } = "";
public string SubtitleLanguagePreference { get; set; } = "";
public bool EnableBackdrops { get; set; } = false;
@@ -72,6 +74,7 @@ namespace Jellyfin2Samsung.Helpers
public bool PatchYoutubePlugin { get; set; } = false;
public string CustomCss { get; set; } = "";
public bool DarkMode { get; set; } = false;
public string GitHubToken { get; set; } = "";
public string LocalYoutubeServer { get; set; } = string.Empty;
// ----- Updater settings -----
@@ -82,7 +85,7 @@ namespace Jellyfin2Samsung.Helpers
// ----- Application-scoped settings (readonly at runtime) -----
public string ReleasesUrl { get; set; } = "https://api.github.com/repos/jeppevinkel/jellyfin-tizen-builds/releases";
public string AuthorEndpoint { get; set; } = "https://dev.tizen.samsung.com/apis/v2/authors";
public string AppVersion { get; set; } = "v2.2.0.4";
public string AppVersion { get; set; } = "v2.2.0.7";
public string TizenSdb { get; set; } = "https://api.github.com/repos/PatrickSt1991/tizen-sdb/releases";
public string JellyfinAvRelease { get; set; } = "https://api.github.com/repos/PatrickSt1991/tizen-jellyfin-avplay/releases";
public string JellyfinAvReleaseFork { get; set; } = "https://api.github.com/repos/asamahy/tizen-jellyfin-avplay/releases";

View File

@@ -17,61 +17,40 @@ namespace Jellyfin2Samsung.Helpers.Core
private static readonly string[] tpkItem = ["*.tpk"];
private static readonly string[] allItem = ["*.wgt", "*.tpk"];
public async Task<string?> BrowseWgtFilesAsync(IStorageProvider storageProvider)
public async Task<string?> BrowseWgtFilesAsync(IStorageProvider storageProvider)
{
var fileTypes = new List<FilePickerFileType>
{
new("WGT Files")
{
var fileTypes = new List<FilePickerFileType>
{
new("WGT Files")
{
Patterns = wgtItem
},
new("TPK Files")
{
Patterns = tpkItem
},
new("All Supported Files")
{
Patterns = allItem
}
};
var options = new FilePickerOpenOptions
{
Title = "Select WGT/TPK File",
FileTypeFilter = fileTypes,
AllowMultiple = true
};
var files = await storageProvider.OpenFilePickerAsync(options);
if (files?.Any() == true)
{
var newPaths = new List<string>();
foreach (var file in files)
{
var originalPath = file.Path.LocalPath;
var directory = Path.GetDirectoryName(originalPath);
var baseName = Path.GetFileNameWithoutExtension(originalPath);
var extension = Path.GetExtension(originalPath);
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);
File.Copy(originalPath, newFilePath, overwrite: true);
newPaths.Add(newFilePath);
}
return string.Join(";", newPaths);
}
return null;
Patterns = wgtItem
},
new("TPK Files")
{
Patterns = tpkItem
},
new("All Supported Files")
{
Patterns = allItem
}
};
var options = new FilePickerOpenOptions
{
Title = "Select WGT/TPK File",
FileTypeFilter = fileTypes,
AllowMultiple = true
};
var files = await storageProvider.OpenFilePickerAsync(options);
if (files?.Any() == true)
return string.Join(";", files.Select(f => f.Path.LocalPath));
return null;
}
public List<ExtensionEntry> ParseExtensions(string output)
{
var extensions = new List<ExtensionEntry>();

View File

@@ -0,0 +1,94 @@
using System;
using System.Diagnostics;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin2Samsung.Helpers.Core
{
public class GitHubAuthHandler : DelegatingHandler
{
private readonly string? _token;
public GitHubAuthHandler(string? token)
: base(new HttpClientHandler())
{
_token = token;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
if (!string.IsNullOrEmpty(_token) && IsGitHubRequest(request.RequestUri))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
}
return base.SendAsync(request, cancellationToken);
}
private static bool IsGitHubRequest(Uri? uri)
{
if (uri == null) return false;
var host = uri.Host;
return host.Equals("api.github.com", StringComparison.OrdinalIgnoreCase)
|| host.Equals("raw.githubusercontent.com", StringComparison.OrdinalIgnoreCase)
|| host.Equals("github.com", StringComparison.OrdinalIgnoreCase);
}
public static string? ResolveToken(AppSettings settings)
{
// 1. Explicit setting
if (!string.IsNullOrWhiteSpace(settings.GitHubToken))
{
Trace.TraceInformation("[GitHubAuth] Using token from app settings");
return settings.GitHubToken.Trim();
}
// 2. Environment variable
var envToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN");
if (!string.IsNullOrWhiteSpace(envToken))
{
Trace.TraceInformation("[GitHubAuth] Using token from GITHUB_TOKEN environment variable");
return envToken.Trim();
}
// 3. GitHub CLI (gh auth token)
try
{
var psi = new ProcessStartInfo
{
FileName = "gh",
Arguments = "auth token",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(psi);
if (process != null)
{
var output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit(5000);
if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output))
{
Trace.TraceInformation("[GitHubAuth] Using token from GitHub CLI (gh auth token)");
return output;
}
}
}
catch
{
// gh CLI not installed or not authenticated — ignore
}
// 4. No token available — unauthenticated requests
Trace.TraceInformation("[GitHubAuth] No token found — using unauthenticated requests");
return null;
}
}
}

View File

@@ -1,4 +1,4 @@
using Jellyfin2Samsung.Helpers.Core;
using Jellyfin2Samsung.Helpers.Core;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -27,22 +27,22 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Fixes
.Distinct()
.ToList();
// V16: Simple YT object protection (no script blocking)
// V17: YT object protection + trailer fallback for non-English metadata
string injected = """
(function () {
if (window.__YT_FIX_V16__) return;
window.__YT_FIX_V16__ = true;
if (window.__YT_FIX_V17__) return;
window.__YT_FIX_V17__ = true;
var SERVICE_BASE = 'http://localhost:8123';
var currentPlayerInstance = null;
function sLog(msg, data) {
try {
var xhr = new XMLHttpRequest();
xhr.open('POST', SERVICE_BASE + '/log', true);
xhr.setRequestHeader('Content-Type', 'application/json');
var cleanData = (data && typeof data === 'object') ? JSON.stringify(data) : (data || '');
xhr.send(JSON.stringify({args: ['[V16]', msg, cleanData]}));
xhr.send(JSON.stringify({args: ['[V17]', msg, cleanData]}));
} catch(e) {}
}
@@ -60,11 +60,11 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Fixes
function CustomPlayer(idOrEl, cfg) {
var self = this;
currentPlayerInstance = this;
var videoId = '';
if (typeof cfg === 'string') videoId = cfg;
else if (cfg && typeof cfg === 'object') videoId = cfg.videoId || cfg.id || '';
this._state = -1;
this._currentTime = 0;
this._duration = 0;
@@ -79,20 +79,20 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Fixes
var container = (typeof idOrEl === 'string') ? document.getElementById(idOrEl) : idOrEl;
this._container = container;
var iframe = document.createElement('iframe');
this._iframe = iframe;
// Message handler for iframe communication
this._messageHandler = function(ev) {
if (self._destroyed) return;
var m = ev.data;
if (!m || !m.__ytbridge) return;
if (m.type === 'ready') {
self._ready = true;
sLog('IFRAME_READY');
// HIDE SPINNER AGAIN (in case it reappeared)
try {
if (window.Loading && window.Loading.hide) {
@@ -103,15 +103,15 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Fixes
spinners[i].classList.remove('mdlSpinnerActive');
}
} catch(e) {}
if (cfg.events && cfg.events.onReady) cfg.events.onReady({ target: self });
// Process queued commands
while(self._queue.length) {
var q = self._queue.shift();
self._send(q.cmd, q.val);
while(self._queue.length) {
var q = self._queue.shift();
self._send(q.cmd, q.val);
}
// ENSURE AUTOPLAY: Jellyfin expects video to start playing immediately
// The YouTube iframe should auto-play, but we'll trigger it again to be sure
setTimeout(function() {
@@ -133,9 +133,9 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Fixes
}
}
};
window.addEventListener('message', this._messageHandler);
function mount() {
if (!container || self._destroyed) return;
sLog('MOUNTING', { extractedId: videoId });
@@ -164,7 +164,7 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Fixes
iframe.style.cssText = 'width:100vw; height:100vh; border:0; background:#000; position:fixed; top:0; left:0; z-index:2147483647;';
iframe.setAttribute('allow', 'autoplay; encrypted-media; fullscreen');
iframe.src = SERVICE_BASE + '/player.html?videoId=' + encodeURIComponent(videoId);
container.innerHTML = '';
container.appendChild(iframe);
@@ -181,9 +181,9 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Fixes
this._send = function(cmd, val) {
if (this._destroyed) return;
if (!this._ready) {
this._queue.push({cmd:cmd, val:val});
return;
if (!this._ready) {
this._queue.push({cmd:cmd, val:val});
return;
}
if (this._iframe && this._iframe.contentWindow) {
this._iframe.contentWindow.postMessage({ __ytbridge_cmd: true, cmd: cmd, val: val }, '*');
@@ -191,71 +191,71 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Fixes
};
// API Methods matching YT.Player interface
this.playVideo = function() {
this.playVideo = function() {
sLog('CMD_PLAY');
this._send('play');
this._send('play');
};
this.pauseVideo = function() {
this.pauseVideo = function() {
sLog('CMD_PAUSE');
this._send('pause');
this._send('pause');
};
this.stopVideo = function() {
this.stopVideo = function() {
sLog('CMD_STOP');
this._send('stop');
this._send('stop');
};
this.seekTo = function(s, allowSeekAhead) {
this.seekTo = function(s, allowSeekAhead) {
sLog('CMD_SEEK', s);
this._send('seek', s * 1000);
this._send('seek', s * 1000);
};
this.setVolume = function(v) {
this._volume = v;
this._send('volume', v);
this.setVolume = function(v) {
this._volume = v;
this._send('volume', v);
};
this.getVolume = function() {
return this._volume;
this.getVolume = function() {
return this._volume;
};
this.getCurrentTime = function() {
return this._currentTime;
this.getCurrentTime = function() {
return this._currentTime;
};
this.getDuration = function() {
return this._duration;
this.getDuration = function() {
return this._duration;
};
this.getPlayerState = function() {
return this._state;
this.getPlayerState = function() {
return this._state;
};
this.mute = function() {
this._send('mute', true);
};
this.unMute = function() {
this._send('mute', false);
};
this.isMuted = function() {
return this._muted || false;
};
this.setSize = function(width, height) {
// Size is already 100vw/100vh, no action needed
sLog('SET_SIZE', { w: width, h: height });
};
// CRITICAL: Proper cleanup to prevent background playback
this.destroy = function() {
this.destroy = function() {
sLog('DESTROY_CALLED');
if (this._destroyed) return;
this._destroyed = true;
// Stop playback first
if (this._iframe && this._iframe.contentWindow) {
try {
@@ -264,19 +264,19 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Fixes
sLog('DESTROY_STOP_ERR', e.message);
}
}
// Clean up event listeners
if (this._messageHandler) {
window.removeEventListener('message', this._messageHandler);
this._messageHandler = null;
}
// Disconnect observer
if (this._observer) {
this._observer.disconnect();
this._observer = null;
}
// Remove iframe from DOM
if (this._iframe) {
if (this._iframe.parentNode) {
@@ -284,20 +284,20 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Fixes
}
this._iframe = null;
}
// Clear container
if (this._container) {
this._container.innerHTML = '';
this._container = null;
}
// Clear queue
this._queue = [];
if (currentPlayerInstance === this) {
currentPlayerInstance = null;
}
sLog('DESTROYED');
};
@@ -309,13 +309,13 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Fixes
// ========================================================================
var customYT = {
Player: CustomPlayer,
PlayerState: {
UNSTARTED: -1,
ENDED: 0,
PLAYING: 1,
PAUSED: 2,
BUFFERING: 3,
CUED: 5
PlayerState: {
UNSTARTED: -1,
ENDED: 0,
PLAYING: 1,
PAUSED: 2,
BUFFERING: 3,
CUED: 5
},
loaded: 1,
__CUSTOM__: true
@@ -328,7 +328,7 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Fixes
configurable: false,
enumerable: true
});
sLog('YT_PROTECTED');
// Trigger ready callback if it exists
@@ -342,18 +342,18 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Fixes
// ========================================================================
// NAVIGATION CLEANUP - Hook into Jellyfin's router (FIXED)
// ========================================================================
var lastPath = window.location.pathname;
// Listen for Jellyfin page changes
document.addEventListener('viewshow', function() {
var currentPath = window.location.pathname;
sLog('VIEW_SHOW_EVENT', { lastPath: lastPath, currentPath: currentPath });
// Only cleanup if we actually navigated away (path changed)
if (currentPath !== lastPath) {
lastPath = currentPath;
// If we're navigating away from video page, ensure cleanup
if (currentPath !== '/video' && currentPlayerInstance) {
sLog('NAV_CLEANUP_TRIGGER');
@@ -367,7 +367,7 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Fixes
sLog('VIEW_SHOW_SAME_PATH');
}
});
// Also listen for back button via popstate
window.addEventListener('popstate', function() {
sLog('POPSTATE_EVENT');
@@ -384,6 +384,189 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Fixes
}, 100);
});
// ========================================================================
// TRAILER FALLBACK - Find trailers for non-English metadata languages
// ========================================================================
var _tfTimer = null;
var _tfTmdbKey = '';
var _tfKeyFetched = false;
// Fetch TMDB API key from Jellyfin server (works for admin users, silent fail for others)
function _tfFetchTmdbKey(api, cb) {
if (_tfKeyFetched) return cb();
_tfKeyFetched = true;
var xhr = new XMLHttpRequest();
xhr.open('GET', api.serverAddress() + '/Plugins', true);
xhr.setRequestHeader('Authorization', 'MediaBrowser Token="' + api.accessToken() + '"');
xhr.onload = function() {
if (xhr.status !== 200) { sLog('TF_KEY_NO_PLUGINS'); return cb(); }
try {
var plugins = JSON.parse(xhr.responseText);
var tmdbPlugin = null;
for (var i = 0; i < plugins.length; i++) {
if (plugins[i].Name && plugins[i].Name.indexOf('TMDb') !== -1) { tmdbPlugin = plugins[i]; break; }
}
if (!tmdbPlugin) { sLog('TF_KEY_NO_TMDB_PLUGIN'); return cb(); }
var xhr2 = new XMLHttpRequest();
xhr2.open('GET', api.serverAddress() + '/Plugins/' + tmdbPlugin.Id + '/Configuration', true);
xhr2.setRequestHeader('Authorization', 'MediaBrowser Token="' + api.accessToken() + '"');
xhr2.onload = function() {
if (xhr2.status === 200) {
try {
var cfg = JSON.parse(xhr2.responseText);
if (cfg.TmdbApiKey) { _tfTmdbKey = cfg.TmdbApiKey; sLog('TF_KEY_CUSTOM', _tfTmdbKey.substring(0, 8) + '...'); }
else { sLog('TF_KEY_DEFAULT'); }
} catch(e) {}
}
cb();
};
xhr2.onerror = function() { cb(); };
xhr2.send();
} catch(e) { cb(); }
};
xhr.onerror = function() { cb(); };
xhr.send();
}
function _tfCheck() {
if (_tfTimer) clearTimeout(_tfTimer);
_tfTimer = setTimeout(_tfDoCheck, 2000);
}
function _tfDoCheck() {
var hash = window.location.hash || window.location.href;
if (hash.indexOf('details') === -1 && hash.indexOf('item') === -1) return;
var existing = document.querySelector('.btnPlayTrailer, [data-action="playtrailer"]');
if (existing) { sLog('TF_HAS_TRAILER'); return; }
var match = hash.match(/[?&]id=([^&]+)/);
if (!match) return;
var itemId = match[1];
var api = window.ApiClient;
if (!api) { sLog('TF_NO_API'); return; }
// Fetch TMDB key first (cached after first call), then proceed
_tfFetchTmdbKey(api, function() {
sLog('TF_CHECK', { itemId: itemId });
var xhr = new XMLHttpRequest();
var url = api.serverAddress() + '/Users/' + api.getCurrentUserId() + '/Items/' + itemId + '?Fields=ProviderIds,RemoteTrailers';
xhr.open('GET', url, true);
xhr.setRequestHeader('Authorization', 'MediaBrowser Token="' + api.accessToken() + '"');
xhr.onload = function() {
if (xhr.status !== 200) return;
try {
var item = JSON.parse(xhr.responseText);
if (item.Type !== 'Movie' && item.Type !== 'Series') return;
if (item.RemoteTrailers && item.RemoteTrailers.length > 0) { sLog('TF_HAS_REMOTE'); return; }
if (item.LocalTrailerCount && item.LocalTrailerCount > 0) { sLog('TF_HAS_LOCAL'); return; }
var tmdbId = (item.ProviderIds && item.ProviderIds.Tmdb) || '';
var title = item.Name || '';
var year = item.ProductionYear || '';
var lang = (document.documentElement.lang || navigator.language || 'en').split('-')[0];
if (!tmdbId && !title) return;
sLog('TF_SEARCH', { tmdbId: tmdbId, title: title, lang: lang });
var svcUrl = SERVICE_BASE + '/trailer?tmdbId=' + encodeURIComponent(tmdbId) + '&title=' + encodeURIComponent(title) + '&year=' + encodeURIComponent(year) + '&lang=' + encodeURIComponent(lang);
if (_tfTmdbKey) svcUrl += '&tmdbKey=' + encodeURIComponent(_tfTmdbKey);
var xhr2 = new XMLHttpRequest();
xhr2.open('GET', svcUrl, true);
xhr2.onload = function() {
if (xhr2.status !== 200) return;
try {
var r = JSON.parse(xhr2.responseText);
if (r.videoKey) {
sLog('TF_FOUND', { key: r.videoKey, source: r.source });
_tfInjectBtn(r.videoKey);
} else { sLog('TF_NOT_FOUND'); }
} catch(e) { sLog('TF_ERR', e.message); }
};
xhr2.send();
} catch(e) { sLog('TF_ITEM_ERR', e.message); }
};
xhr.send();
});
}
function _tfInjectBtn(videoKey) {
var container = document.querySelector('.mainDetailButtons, .detailButtons');
if (!container || document.querySelector('.btnTrailerInjected')) return;
var btn = document.createElement('button');
btn.setAttribute('is', 'emby-button');
btn.setAttribute('type', 'button');
btn.className = 'button-flat btnPlayTrailer btnTrailerInjected detailButton emby-button';
var icon = document.createElement('span');
icon.className = 'material-icons detailButton-icon';
icon.textContent = 'theaters';
var wrap = document.createElement('span');
wrap.className = 'detailButton-content';
var txt = document.createElement('span');
txt.className = 'button-text';
txt.textContent = 'Trailer';
wrap.appendChild(txt);
btn.appendChild(icon);
btn.appendChild(wrap);
btn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
sLog('TF_PLAY', { key: videoKey });
_tfPlayOverlay(videoKey);
});
var first = container.querySelector('button, .detailButton');
if (first && first.nextSibling) container.insertBefore(btn, first.nextSibling);
else container.appendChild(btn);
sLog('TF_BTN_INJECTED');
}
function _tfPlayOverlay(videoKey) {
var overlay = document.createElement('div');
overlay.id = 'trailerOverlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:2147483647;background:#000;';
var iframe = document.createElement('iframe');
iframe.style.cssText = 'width:100%;height:100%;border:0;';
iframe.setAttribute('allow', 'autoplay; encrypted-media; fullscreen');
iframe.src = SERVICE_BASE + '/player.html?videoId=' + encodeURIComponent(videoKey);
overlay.appendChild(iframe);
document.body.appendChild(overlay);
var msgH = function(ev) {
if (!ev.data || !ev.data.__ytbridge) return;
if (ev.data.type === 'state' && ev.data.data === 0) _tfClose();
};
window.addEventListener('message', msgH);
var keyH = function(ev) {
if (ev.keyCode === 10009 || ev.keyCode === 27 || ev.keyCode === 8) {
ev.preventDefault(); ev.stopPropagation(); _tfClose();
}
};
document.addEventListener('keydown', keyH, true);
function _tfClose() {
window.removeEventListener('message', msgH);
document.removeEventListener('keydown', keyH, true);
try { if (iframe.contentWindow) iframe.contentWindow.postMessage({ __ytbridge_cmd: true, cmd: 'stop' }, '*'); } catch(e) {}
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
sLog('TF_CLOSED');
}
}
document.addEventListener('viewshow', _tfCheck);
sLog('INIT_COMPLETE');
})();
""".Replace("http://localhost:8123", $"http://localhost:{servicePort}");
@@ -391,12 +574,12 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Fixes
foreach (var file in candidates)
{
var content = await File.ReadAllTextAsync(file);
if (content.Contains("__YT_FIX_V16__")) continue;
if (content.Contains("__YT_FIX_V17__")) continue;
await File.WriteAllTextAsync(file, injected + "\n" + content, utf8NoBom);
}
}
// 2. CREATE THE NODE.JS SERVICE (unchanged, but added mute support)
// 2. CREATE THE NODE.JS SERVICE (with trailer fallback endpoint)
public async Task CreateYouTubeResolverAsync(PackageWorkspace ws)
{
int servicePort = await ResolveServicePortAsync(ws);
@@ -407,11 +590,13 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Fixes
string serviceJsContent = """
var http = require('http');
var https = require('https');
var urlMod = require('url');
var PORT = 8123;
var LISTEN_HOST = '0.0.0.0';
var LISTEN_HOST = '0.0.0.0';
var LOGS = [];
var TMDB_KEY = '4219e299c89411838049ab0dab19ebd5'; // fallback key from Jellyfin TmdbUtils.cs, used when runtime key extraction fails
function log(msg, data) {
var line = new Date().toISOString() + ' ' + msg + ' ' + (data ? JSON.stringify(data) : '');
@@ -434,6 +619,118 @@ function write(res, code, contentType, body, additionalHeaders) {
res.end(body);
}
// ========================================================================
// TRAILER FALLBACK HELPERS - TMDB + DuckDuckGo Lite
// ========================================================================
function httpsGet(reqUrl, cb) {
var done = false;
function finish(err, status, body) { if (done) return; done = true; cb(err, status, body); }
var parsed = urlMod.parse(reqUrl);
var opts = {
hostname: parsed.hostname,
path: parsed.path,
port: 443,
method: 'GET',
rejectUnauthorized: false,
headers: { 'User-Agent': 'JellyfinSamsungTV/1.0' }
};
var req = https.request(opts, function(resp) {
var body = '';
resp.on('data', function(c) { body += c; });
resp.on('end', function() { finish(null, resp.statusCode, body); });
});
req.on('error', function(e) { finish(e); });
req.setTimeout(8000, function() { req.abort(); });
req.end();
}
function fetchTmdbTrailers(tmdbId, lang, apiKey, cb) {
if (!tmdbId) return cb(null, { langKey: null, enKey: null });
var key = apiKey || TMDB_KEY;
var u = 'https://api.themoviedb.org/3/movie/' + tmdbId +
'?api_key=' + key +
'&language=' + lang +
'&append_to_response=videos' +
'&include_video_language=' + lang + ',en,null';
log('TMDB_FETCH ' + u);
httpsGet(u, function(err, status, body) {
if (err || status !== 200) { log('TMDB_ERR ' + (err ? err.message : status)); return cb(null, { langKey: null, enKey: null }); }
try {
var data = JSON.parse(body);
var vids = (data.videos && data.videos.results) || [];
var trailers = [];
for (var i = 0; i < vids.length; i++) {
if (vids[i].site === 'YouTube' && (vids[i].type === 'Trailer' || vids[i].type === 'Teaser')) trailers.push(vids[i]);
}
var langPick = null, enPick = null;
for (var j = 0; j < trailers.length; j++) {
if (!langPick && trailers[j].iso_639_1 === lang) langPick = trailers[j];
if (!enPick && trailers[j].iso_639_1 === 'en') enPick = trailers[j];
}
log('TMDB_RESULT langKey=' + (langPick ? langPick.key : 'null') + ' enKey=' + (enPick ? enPick.key : 'null'));
cb(null, {
langKey: langPick ? langPick.key : null,
enKey: enPick ? enPick.key : null
});
} catch(e) { log('TMDB_PARSE_ERR ' + e.message); cb(null, { langKey: null, enKey: null }); }
});
}
function searchDdg(title, year, lang, cb) {
var langMap = __LANG_MAP__;
var langName = langMap[lang] || '';
var langKeywords = langName.toLowerCase().split(' ');
var q = title + (year ? ' ' + year : '') + ' Trailer ' + langName + ' site:youtube.com';
var encoded = encodeURIComponent(q).replace(/%20/g, '+');
var u = 'https://lite.duckduckgo.com/lite/?q=' + encoded;
log('DDG_FETCH q=' + q);
httpsGet(u, function(err, status, body) {
if (err || status !== 200) { log('DDG_ERR ' + (err ? err.message : status)); return cb(null, { langKey: null, fallbackKey: null }); }
var decoded = body.replace(/%2F/gi, '/').replace(/%3F/gi, '?').replace(/%3D/gi, '=').replace(/%26/gi, '&');
// Extract results with titles: find <a> tags followed by youtube URLs
var linkRe = /<a[^>]+href="([^"]*youtube\.com\/watch\?v=([a-zA-Z0-9_\-]{11})[^"]*)"[^>]*>([^<]*)<\/a>/gi;
var m, seen = {}, results = [];
while ((m = linkRe.exec(decoded)) !== null) {
if (!seen[m[2]]) {
seen[m[2]] = true;
results.push({ key: m[2], title: m[3] });
}
}
// Also catch URL-encoded links
var encRe = /href="[^"]*youtube\.com%2Fwatch%3Fv%3D([a-zA-Z0-9_\-]{11})[^"]*"[^>]*>([^<]*)<\/a>/gi;
while ((m = encRe.exec(body)) !== null) {
if (!seen[m[1]]) {
seen[m[1]] = true;
results.push({ key: m[1], title: m[2] });
}
}
if (!results.length) { log('DDG_NO_RESULTS'); return cb(null, { langKey: null, fallbackKey: null }); }
// Split: language-matched vs non-matched results
var langMatch = null;
var fallback = null;
for (var i = 0; i < results.length; i++) {
var t = results[i].title.toLowerCase();
var isLangMatch = false;
for (var j = 0; j < langKeywords.length; j++) {
if (langKeywords[j] && t.indexOf(langKeywords[j]) !== -1) { isLangMatch = true; break; }
}
if (isLangMatch && !langMatch) langMatch = results[i];
if (!isLangMatch && !fallback) fallback = results[i];
if (langMatch && fallback) break;
}
log('DDG_FOUND langKey=' + (langMatch ? langMatch.key : 'null') + ' fallbackKey=' + (fallback ? fallback.key : 'null') + ' total=' + results.length);
cb(null, { langKey: langMatch ? langMatch.key : null, fallbackKey: fallback ? fallback.key : null });
});
}
// ========================================================================
var PLAYER_HTML = `
<!doctype html>
<html>
@@ -453,22 +750,22 @@ var PLAYER_HTML = `
var player;
var autoplayAttempted = false;
window.onYouTubeIframeAPIReady = function() {
player = new YT.Player('player', {
height: '100%', width: '100%', videoId: VID,
playerVars: {
'autoplay': 1,
'controls': 0,
'enablejsapi': 1,
playerVars: {
'autoplay': 1,
'controls': 0,
'enablejsapi': 1,
'origin': 'http://localhost:8123',
'playsinline': 1,
'mute': 0
},
events: {
'onReady': function(ev) {
'onReady': function(ev) {
post('ready');
// Ensure autoplay starts
if (!autoplayAttempted) {
autoplayAttempted = true;
@@ -478,9 +775,9 @@ var PLAYER_HTML = `
}
}, 100);
}
setInterval(function(){
if(player && player.getCurrentTime)
setInterval(function(){
if(player && player.getCurrentTime)
post('time', null, player.getCurrentTime()*1000, player.getDuration()*1000, player.getPlayerState());
}, 500);
},
@@ -511,14 +808,14 @@ var PLAYER_HTML = `
function handler(req, res) {
var u = urlMod.parse(req.url, true);
if (req.method === 'OPTIONS') return write(res, 204, 'text/plain', '');
if (u.pathname === '/log') {
var body = '';
req.on('data', function(c) { body += c; });
req.on('end', function() {
try {
var j = JSON.parse(body);
log(j.args ? j.args.join(' ') : 'LOG');
try {
var j = JSON.parse(body);
log(j.args ? j.args.join(' ') : 'LOG');
} catch(e){}
write(res, 200, 'application/json', '{}');
});
@@ -526,22 +823,61 @@ function handler(req, res) {
}
if (u.pathname === '/debug/logs') return write(res, 200, 'application/json', JSON.stringify({logs: LOGS}));
if (u.pathname === '/player.html') {
return write(res, 200, 'text/html', PLAYER_HTML, { 'Referrer-Policy': 'no-referrer-when-downgrade' });
}
// ====================================================================
// TRAILER FALLBACK ENDPOINT
// 1 TMDB call fetches both lang + en, then: lang(TMDB) -> lang(DDG) -> en(CACHED) -> en(CACHED)
// ====================================================================
if (u.pathname === '/trailer') {
var tId = u.query.tmdbId || '';
var tTitle = u.query.title || '';
var tYear = u.query.year || '';
var tLang = u.query.lang || 'en';
var tKey = u.query.tmdbKey || '';
log('TRAILER_REQ tmdbId=' + tId + ' title=' + tTitle + ' lang=' + tLang + ' customKey=' + (tKey ? 'yes' : 'no'));
// Single TMDB call: fetches both user-language and English trailers
fetchTmdbTrailers(tId, tLang, tKey, function(e1, tmdb) {
// Step 1: TMDB user-language trailer (cached)
if (tmdb.langKey) return write(res, 200, 'application/json', JSON.stringify({videoKey:tmdb.langKey, source:'tmdb_'+tLang}));
// Single DDG call: returns both language-matched and fallback keys
searchDdg(tTitle, tYear, tLang, function(e2, ddg) {
// Step 2: DDG language-matched trailer (cached)
if (ddg.langKey) return write(res, 200, 'application/json', JSON.stringify({videoKey:ddg.langKey, source:'ddg_'+tLang}));
if (tLang !== 'en') {
// Step 3: TMDB English fallback (cached from step 1, no extra call)
if (tmdb.enKey) return write(res, 200, 'application/json', JSON.stringify({videoKey:tmdb.enKey, source:'tmdb_en_fallback'}));
// Step 4: DDG non-language result as English fallback (cached from step 2, no extra call)
if (ddg.fallbackKey) return write(res, 200, 'application/json', JSON.stringify({videoKey:ddg.fallbackKey, source:'ddg_en_fallback'}));
}
write(res, 200, 'application/json', JSON.stringify({videoKey:null, source:null}));
});
});
return;
}
return write(res, 404, 'text/plain', 'Not Found');
}
var server = http.createServer(handler);
server.listen(PORT, LISTEN_HOST, function() { log('SERVER LISTENING ' + LISTEN_HOST + ':' + PORT); });
""".Replace("var PORT = 8123", $"var PORT = {servicePort}")
.Replace("'origin': 'http://localhost:8123'", $"'origin': 'http://localhost:{servicePort}'");
.Replace("'origin': 'http://localhost:8123'", $"'origin': 'http://localhost:{servicePort}'")
.Replace("__LANG_MAP__", TrailerLanguageMap.JsObject);
await File.WriteAllTextAsync(serviceJsPath, serviceJsContent, utf8NoBom);
}
// 3. UPDATE CONFIG.XML (unchanged)
// 3. UPDATE CONFIG.XML
public async Task UpdateCorsAsync(PackageWorkspace ws)
{
int servicePort = await ResolveServicePortAsync(ws);
@@ -596,4 +932,4 @@ server.listen(PORT, LISTEN_HOST, function() { log('SERVER LISTENING ' + LISTEN_H
doc.Save(path);
}
}
}
}

View File

@@ -76,6 +76,18 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Patches
if (!servers.Any(s => s?.GetValue<string>() == serverUrl))
servers.Add(serverUrl);
// Add LocalAddress (IP-based) as fallback when the primary URL uses mDNS (.local)
// Samsung TVs (Tizen) cannot reliably resolve mDNS hostnames, especially after network disruptions
var localAddress = UrlHelper.NormalizeServerUrl(AppSettings.Default.JellyfinServerLocalAddress);
if (!string.IsNullOrEmpty(localAddress) &&
localAddress != serverUrl &&
UrlHelper.IsValidHttpUrl(localAddress) &&
!servers.Any(s => s?.GetValue<string>() == localAddress))
{
servers.Add(localAddress);
Trace.WriteLine($"[UpdateServerAddress] Added LocalAddress fallback: {localAddress}");
}
await File.WriteAllTextAsync(path, config.ToJsonString());
}
@@ -92,6 +104,7 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Patches
var serverUrl = UrlHelper.NormalizeServerUrl(AppSettings.Default.JellyfinFullUrl);
var serverId = AppSettings.Default.JellyfinServerId;
var localAddress = AppSettings.Default.JellyfinServerLocalAddress;
var serverName = AppSettings.Default.JellyfinServerName;
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(serverUrl))
{
@@ -99,19 +112,21 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Patches
return;
}
// If server ID is not stored, try to fetch it now
if (string.IsNullOrEmpty(serverId))
// If server ID or server name is not stored, try to fetch it now
if (string.IsNullOrEmpty(serverId) || string.IsNullOrEmpty(serverName))
{
Trace.WriteLine("[InjectAutoLogin] Server ID not cached, fetching from server...");
Trace.WriteLine("[InjectAutoLogin] Server ID/Name not cached, fetching from server...");
var serverInfo = await _apiClient.GetPublicSystemInfoAsync(serverUrl);
if (serverInfo != null && !string.IsNullOrEmpty(serverInfo.Id))
{
serverId = serverInfo.Id;
localAddress = serverInfo.LocalAddress ?? "";
serverName = serverInfo.ServerName ?? "";
AppSettings.Default.JellyfinServerId = serverId;
AppSettings.Default.JellyfinServerLocalAddress = localAddress;
AppSettings.Default.JellyfinServerName = serverName;
AppSettings.Default.Save();
Trace.WriteLine($"[InjectAutoLogin] Fetched and stored server ID: {serverId}");
Trace.WriteLine($"[InjectAutoLogin] Fetched and stored server ID: {serverId}, Name: {serverName}");
}
else
{
@@ -138,6 +153,7 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Patches
credentialsScript.AppendLine($" var serverUrl = '{HtmlUtils.EscapeJsString(serverUrl)}';");
credentialsScript.AppendLine($" var serverId = '{HtmlUtils.EscapeJsString(serverId)}';");
credentialsScript.AppendLine($" var localAddress = '{HtmlUtils.EscapeJsString(localAddress)}';");
credentialsScript.AppendLine($" var serverName = '{HtmlUtils.EscapeJsString(serverName)}';");
credentialsScript.AppendLine($" var userId = '{HtmlUtils.EscapeJsString(userId)}';");
credentialsScript.AppendLine($" var accessToken = '{HtmlUtils.EscapeJsString(accessToken)}';");
credentialsScript.AppendLine();
@@ -145,6 +161,7 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Patches
credentialsScript.AppendLine(" // Using real server ID (GUID) from /System/Info/Public");
credentialsScript.AppendLine(" var credentials = {");
credentialsScript.AppendLine(" Servers: [{");
credentialsScript.AppendLine(" Name: serverName || serverUrl,");
credentialsScript.AppendLine(" ManualAddress: serverUrl,");
credentialsScript.AppendLine(" LocalAddress: localAddress || serverUrl,");
credentialsScript.AppendLine(" Id: serverId,");
@@ -157,7 +174,7 @@ namespace Jellyfin2Samsung.Helpers.Jellyfin.Patches
credentialsScript.AppendLine(" // Store in localStorage");
credentialsScript.AppendLine(" localStorage.setItem('jellyfin_credentials', JSON.stringify(credentials));");
credentialsScript.AppendLine();
credentialsScript.AppendLine(" console.log('[Auto-Login] Credentials injected for server: ' + serverUrl + ' with ID: ' + serverId);");
credentialsScript.AppendLine(" console.log('[Auto-Login] Credentials injected for server: ' + serverName + ' (' + serverUrl + ') with ID: ' + serverId);");
credentialsScript.AppendLine(" } catch(e) {");
credentialsScript.AppendLine(" console.error('[Auto-Login] Failed to inject credentials:', e);");
credentialsScript.AppendLine(" }");

View File

@@ -0,0 +1,103 @@
namespace Jellyfin2Samsung.Helpers.Jellyfin.Fixes
{
/// <summary>
/// Maps ISO 639 language codes to search keywords for DuckDuckGo Lite trailer search.
/// Covers all languages supported by Jellyfin's localization system.
/// Each entry contains the English name + native name for maximum DDG search match rate.
/// </summary>
public static class TrailerLanguageMap
{
public const string JsObject = @"{
ab:'Abkhaz Аԥсуа',
af:'Afrikaans',
ar:'Arabic العربية',
as:'Assamese অসমীয়া',
be:'Belarusian Беларуская',
bg:'Bulgarian Български',
bn:'Bengali বাংলা',
ca:'Catalan Català',
chr:'Cherokee ᏣᎳᎩ',
cs:'Czech Český',
cy:'Welsh Cymraeg',
da:'Danish Dansk',
de:'Deutsch German',
el:'Greek Ελληνικά',
en:'English',
enm:'English',
eo:'Esperanto',
es:'Spanish Español',
et:'Estonian Eesti',
eu:'Basque Euskara',
fa:'Persian Farsi فارسی',
fi:'Finnish Suomi',
fil:'Filipino Tagalog',
fo:'Faroese Føroyskt',
fr:'French Français',
ga:'Irish Gaeilge',
gl:'Galician Galego',
gsw:'Swiss German Schweizerdeutsch',
he:'Hebrew עברית',
hi:'Hindi हिन्दी',
hr:'Croatian Hrvatski',
ht:'Haitian Creole Kreyòl',
hu:'Hungarian Magyar',
hy:'Armenian Հայերեն',
id:'Indonesian Bahasa',
is:'Icelandic Íslenska',
it:'Italian Italiano',
ja:'Japanese 日本語',
jbo:'Lojban',
ka:'Georgian ქართული',
kab:'Kabyle Taqbaylit',
kk:'Kazakh Қазақша',
km:'Khmer ភាសាខ្មែរ',
kn:'Kannada ಕನ್ನಡ',
ko:'Korean 한국어',
kw:'Cornish Kernewek',
ky:'Kyrgyz Кыргызча',
lb:'Luxembourgish Lëtzebuergesch',
lt:'Lithuanian Lietuvių',
lv:'Latvian Latviešu',
lzh:'Chinese 中文',
mi:'Maori Māori',
mk:'Macedonian Македонски',
ml:'Malayalam മലയാളം',
mn:'Mongolian Монгол',
mr:'Marathi मराठी',
ms:'Malay Melayu',
mt:'Maltese Malti',
my:'Burmese Myanmar မြန်မာ',
nb:'Norwegian Norsk',
ne:'Nepali नेपाली',
nl:'Dutch Nederlands',
nn:'Norwegian Norsk',
oc:'Occitan',
or:'Odia ଓଡ଼ିଆ',
pa:'Punjabi ਪੰਜਾਬੀ',
pl:'Polish Polski',
pr:'English',
pt:'Portuguese Português',
ro:'Romanian Română',
ru:'Russian Русский',
si:'Sinhala සිංහල',
sk:'Slovak Slovenský',
sl:'Slovenian Slovenščina',
sn:'Shona',
sq:'Albanian Shqip',
sr:'Serbian Српски',
sv:'Swedish Svenska',
sw:'Swahili Kiswahili',
ta:'Tamil தமிழ்',
te:'Telugu తెలుగు',
th:'Thai ไทย',
tr:'Turkish Türkçe',
ug:'Uyghur ئۇيغۇرچە',
uk:'Ukrainian Українська',
ur:'Urdu اردو',
uz:'Uzbek Oʻzbekcha',
vi:'Vietnamese Tiếng Việt',
zh:'Chinese 中文',
zu:'Zulu isiZulu'
}";
}
}

View File

@@ -94,6 +94,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Tmds.DBus.Protocol" Version="0.21.3" />
<AvaloniaResource Update="Assets\esbuild\linux-x64\esbuild">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</AvaloniaResource>

View File

@@ -69,18 +69,20 @@ namespace Jellyfin2Samsung.Services
public async Task<IEnumerable<NetworkDevice>> FindTizenTvsAsync(CancellationToken cancellationToken = default, bool virtualScan = false)
{
var foundDevices = new List<NetworkDevice>();
var localIps = GetRelevantLocalIPs(virtualScan);
var localInfos = GetLocalNetworkInfos(virtualScan);
var lockObject = new object();
// Group by network prefix to avoid scanning the same network multiple times
var uniqueNetworks = localIps
.Select(ip => GetNetworkPrefix(ip))
.Distinct()
// Deduplicate by actual network address so overlapping interfaces don't double-scan
var uniqueNetworks = localInfos
.Select(info => (
Network: GetNetworkAddress(info.Address, info.Mask),
Broadcast: GetBroadcastAddress(info.Address, info.Mask)
))
.DistinctBy(r => r.Network.ToString())
.ToList();
await Task.WhenAll(uniqueNetworks.SelectMany(networkPrefix =>
Enumerable.Range(1, 254)
.Select(i => $"{networkPrefix}.{i}")
await Task.WhenAll(uniqueNetworks.SelectMany(range =>
GetHostAddresses(range.Network, range.Broadcast)
.Select(async ip =>
{
try
@@ -168,10 +170,103 @@ namespace Jellyfin2Samsung.Services
}
}
private string GetNetworkPrefix(IPAddress ip)
// Returns all local interface IPs with their actual subnet masks.
// Falls back to /24 for the user-supplied custom IP since its mask can't be discovered.
private List<(IPAddress Address, IPAddress Mask)> GetLocalNetworkInfos(bool virtualScan = false)
{
var infos = NetworkInterface.GetAllNetworkInterfaces()
.Where(ni => ni.OperationalStatus == OperationalStatus.Up)
.Where(ni =>
virtualScan ||
ni.NetworkInterfaceType == NetworkInterfaceType.Ethernet ||
ni.NetworkInterfaceType == NetworkInterfaceType.Wireless80211)
.SelectMany(ni => ni.GetIPProperties().UnicastAddresses)
.Where(ua => ua.Address.AddressFamily == AddressFamily.InterNetwork)
.Where(ua => !IPAddress.IsLoopback(ua.Address))
.Where(ua => ua.IPv4Mask != null && !ua.IPv4Mask.Equals(IPAddress.Any))
.Select(ua => (Address: ua.Address, Mask: ua.IPv4Mask))
.ToList();
if (!string.IsNullOrEmpty(AppSettings.Default.UserCustomIP) &&
IPAddress.TryParse(AppSettings.Default.UserCustomIP, out var customIp))
{
// Reuse the mask from a local interface whose network contains the custom IP;
// otherwise fall back to /24 so we still scan the right /24 segment.
var fallback = IPAddress.Parse("255.255.255.0");
var matchingMask = infos
.FirstOrDefault(i =>
GetNetworkAddress(i.Address, i.Mask).Equals(GetNetworkAddress(customIp, i.Mask)))
.Mask ?? fallback;
infos.Add((customIp, matchingMask));
}
return infos;
}
private static IPAddress GetNetworkAddress(IPAddress ip, IPAddress mask)
{
var ipBytes = ip.GetAddressBytes();
var maskBytes = mask.GetAddressBytes();
var result = new byte[4];
for (int i = 0; i < 4; i++)
result[i] = (byte)(ipBytes[i] & maskBytes[i]);
return new IPAddress(result);
}
private static IPAddress GetBroadcastAddress(IPAddress ip, IPAddress mask)
{
var ipBytes = ip.GetAddressBytes();
var maskBytes = mask.GetAddressBytes();
var result = new byte[4];
for (int i = 0; i < 4; i++)
result[i] = (byte)(ipBytes[i] | (byte)~maskBytes[i]);
return new IPAddress(result);
}
// Enumerates usable host addresses for a subnet (excludes network and broadcast addresses).
// Caps at 1022 hosts (/22) to keep scans practical; larger subnets are narrowed to the
// /24 block that contains the network address.
private static IEnumerable<string> GetHostAddresses(IPAddress networkAddress, IPAddress broadcastAddress)
{
uint netInt = IpToUInt(networkAddress);
uint broadInt = IpToUInt(broadcastAddress);
uint hostCount = broadInt - netInt - 1;
if (hostCount > 1022)
{
// Narrow to /24 to avoid scanning thousands of addresses
var bytes = networkAddress.GetAddressBytes();
netInt = IpToUInt(new IPAddress(new byte[] { bytes[0], bytes[1], bytes[2], 0 }));
broadInt = netInt + 255;
}
for (uint i = netInt + 1; i < broadInt; i++)
yield return UIntToIp(i);
}
private static uint IpToUInt(IPAddress ip)
{
var bytes = ip.GetAddressBytes();
return $"{bytes[0]}.{bytes[1]}.{bytes[2]}";
if (BitConverter.IsLittleEndian) Array.Reverse(bytes);
return BitConverter.ToUInt32(bytes, 0);
}
private static string UIntToIp(uint value)
{
var bytes = BitConverter.GetBytes(value);
if (BitConverter.IsLittleEndian) Array.Reverse(bytes);
return $"{bytes[0]}.{bytes[1]}.{bytes[2]}.{bytes[3]}";
}
// Looks up the subnet mask assigned to a local interface IP.
private static IPAddress? GetMaskForLocalIp(IPAddress target)
{
return NetworkInterface.GetAllNetworkInterfaces()
.Where(ni => ni.OperationalStatus == OperationalStatus.Up)
.SelectMany(ni => ni.GetIPProperties().UnicastAddresses)
.Where(ua => ua.Address.AddressFamily == AddressFamily.InterNetwork)
.FirstOrDefault(ua => ua.Address.Equals(target))
?.IPv4Mask;
}
public async Task<string?> GetManufacturerFromIp(string ipAddress)
@@ -248,17 +343,22 @@ namespace Jellyfin2Samsung.Services
}
public bool IsDifferentSubnet(string ip1, string ip2)
{
if (!IPAddress.TryParse(ip1, out var a) ||
!IPAddress.TryParse(ip2, out var b))
return false; // or true, depending on how strict you want to be
if (!IPAddress.TryParse(ip1, out var a) || !IPAddress.TryParse(ip2, out var b))
return false;
// Use the actual mask from the local interface; fall back to /24 if not found
var mask = GetMaskForLocalIp(a) ?? IPAddress.Parse("255.255.255.0");
var aBytes = a.GetAddressBytes();
var bBytes = b.GetAddressBytes();
var maskBytes = mask.GetAddressBytes();
// /24 subnet → first 3 octets must match
return aBytes[0] != bBytes[0]
|| aBytes[1] != bBytes[1]
|| aBytes[2] != bBytes[2];
for (int i = 0; i < 4; i++)
{
if ((aBytes[i] & maskBytes[i]) != (bBytes[i] & maskBytes[i]))
return true;
}
return false;
}
public Task<IReadOnlyList<NetworkInterfaceOption>> GetNetworkInterfaceOptionsAsync()
{

View File

@@ -173,7 +173,10 @@ namespace Jellyfin2Samsung.Services
{
throw new TimeoutException(
"GitHub rate limit reached while checking for Tizen SDB.\n\n" +
"Please try again later.",
"To avoid this, set a GitHub Personal Access Token (PAT) in settings,\n" +
"or set the GITHUB_TOKEN environment variable,\n" +
"or install the GitHub CLI (gh) and run 'gh auth login'.\n\n" +
"Alternatively, please try again later.",
ex
);
}
@@ -380,8 +383,7 @@ namespace Jellyfin2Samsung.Services
if (string.IsNullOrEmpty(tvDuid))
return null;
string tizenOs = await FetchTizenOsAsync(tvIpAddress);
string sdkToolPath = await FetchSdkPathAsync(tvIpAddress);
var (tizenOs, sdkToolPath) = await FetchCapabilitiesAsync(tvIpAddress);
if (string.IsNullOrEmpty(tizenOs))
tizenOs = Constants.Defaults.TizenOsVersion;
@@ -655,18 +657,17 @@ namespace Jellyfin2Samsung.Services
return output.Output.Split('\n', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim() ?? string.Empty;
}
private async Task<string> FetchTizenOsAsync(string tvIpAddress)
private async Task<(string tizenOs, string sdkToolPath)> FetchCapabilitiesAsync(string tvIpAddress)
{
var output = await _processHelper.RunCommandAsync(TizenSdbPath!, $"capability {tvIpAddress}");
var match = RegexPatterns.TizenCapability.PlatformVersion.Match(output.Output);
return match.Success ? match.Groups[1].Value.Trim() : string.Empty;
}
private async Task<string> FetchSdkPathAsync(string tvIpAddress)
{
var output = await _processHelper.RunCommandAsync(TizenSdbPath!, $"capability {tvIpAddress}");
var match = RegexPatterns.TizenCapability.SdkToolPath.Match(output.Output);
return match.Success ? match.Groups[1].Value.Trim() : Constants.Defaults.SdkToolPath;
var versionMatch = RegexPatterns.TizenCapability.PlatformVersion.Match(output.Output);
string tizenOs = versionMatch.Success ? versionMatch.Groups[1].Value.Trim() : string.Empty;
var pathMatch = RegexPatterns.TizenCapability.SdkToolPath.Match(output.Output);
string sdkToolPath = pathMatch.Success ? pathMatch.Groups[1].Value.Trim() : Constants.Defaults.SdkToolPath;
return (tizenOs, sdkToolPath);
}
private async Task<string> GetTvDuidAsync(string tvIpAddress)

View File

@@ -137,6 +137,9 @@ namespace Jellyfin2Samsung.ViewModels
[ObservableProperty]
private bool canOpenDebugWindow;
[ObservableProperty]
private bool showMdnsWarning = false;
[ObservableProperty]
private string selectedServerInputMode = "IP : Port";
@@ -180,6 +183,12 @@ namespace Jellyfin2Samsung.ViewModels
[ObservableProperty]
private bool darkMode;
[ObservableProperty]
private string gitHubToken = string.Empty;
[ObservableProperty]
private bool showGitHubToken = false;
[ObservableProperty]
private NetworkInterfaceOption? selectedNetworkInterface;
@@ -291,6 +300,7 @@ namespace Jellyfin2Samsung.ViewModels
public string LblValidateCss => _localizationService.GetString("lblValidateCss");
public string LblCssValidationStatus => _localizationService.GetString("lblCssValidationStatus");
public string LblClearCss => _localizationService.GetString("lblClearCss");
public string LblMdnsWarning => _localizationService.GetString("lblMdnsWarning");
// Main Settings Tab labels
@@ -307,6 +317,9 @@ namespace Jellyfin2Samsung.ViewModels
public string LblRTL => _localizationService.GetString("lblRTL");
public string LblKeepWGTFile => _localizationService.GetString("lblKeepWGTFile");
public string LblSettingsHeader => _localizationService.GetString("lblSettings");
public string LblGitHubToken => _localizationService.GetString("lblGitHubToken");
public string LblGitHubTokenHint => _localizationService.GetString("lblGitHubTokenHint");
public char GitHubTokenPasswordChar => ShowGitHubToken ? '\0' : '*';
public bool CanLogin => ServerValidated &&
!string.IsNullOrWhiteSpace(JellyfinUsername) &&
@@ -421,6 +434,7 @@ namespace Jellyfin2Samsung.ViewModels
OnPropertyChanged(nameof(LblCssHint));
OnPropertyChanged(nameof(LblValidateCss));
OnPropertyChanged(nameof(LblCssValidationStatus));
OnPropertyChanged(nameof(LblMdnsWarning));
// Main Settings Tab labels
OnPropertyChanged(nameof(LblTabMainSettings));
OnPropertyChanged(nameof(LblMainSettings));
@@ -435,6 +449,8 @@ namespace Jellyfin2Samsung.ViewModels
OnPropertyChanged(nameof(LblRTL));
OnPropertyChanged(nameof(LblKeepWGTFile));
OnPropertyChanged(nameof(LblSettingsHeader));
OnPropertyChanged(nameof(LblGitHubToken));
OnPropertyChanged(nameof(LblGitHubTokenHint));
}
partial void OnAudioLanguagePreferenceChanged(string? value)
@@ -523,6 +539,8 @@ namespace Jellyfin2Samsung.ViewModels
OnPropertyChanged(nameof(JellyfinBasePath));
AppSettings.Default.Save();
CheckForMdnsHostname(uri.Host);
// Auto-validate the server connection
_ = AutoValidateServerAsync();
}
@@ -669,6 +687,7 @@ namespace Jellyfin2Samsung.ViewModels
AppSettings.Default.JellyfinUserId = "";
AppSettings.Default.JellyfinServerId = "";
AppSettings.Default.JellyfinServerLocalAddress = "";
AppSettings.Default.JellyfinServerName = "";
AppSettings.Default.IsJellyfinAdmin = false;
AppSettings.Default.Save();
@@ -742,6 +761,12 @@ namespace Jellyfin2Samsung.ViewModels
Trace.WriteLine($"[ServerID] Stored server LocalAddress: {serverInfo.LocalAddress}");
}
if (!string.IsNullOrEmpty(serverInfo.ServerName))
{
AppSettings.Default.JellyfinServerName = serverInfo.ServerName;
Trace.WriteLine($"[ServerID] Stored server name: {serverInfo.ServerName}");
}
AppSettings.Default.Save();
}
}
@@ -1192,6 +1217,7 @@ namespace Jellyfin2Samsung.ViewModels
SelectedJellyfinProtocol = uri.Scheme;
JellyfinServerIp = uri.Host;
SelectedJellyfinPort = uri.Port.ToString();
CheckForMdnsHostname(uri.Host);
}
else
{
@@ -1280,9 +1306,21 @@ namespace Jellyfin2Samsung.ViewModels
Trace.WriteLine($"Updated Jellyfin IP: {AppSettings.Default.JellyfinIP}");
AppSettings.Default.Save();
UpdateServerIpStatus();
CheckForMdnsHostname(JellyfinServerIp);
}
}
/// <summary>
/// Checks if the given hostname is an mDNS (.local) address and shows a warning.
/// Samsung TVs (Tizen) cannot reliably resolve mDNS hostnames, which causes
/// the server to appear as "undefined" on the TV after network disruptions.
/// </summary>
private void CheckForMdnsHostname(string? hostname)
{
ShowMdnsWarning = !string.IsNullOrEmpty(hostname) &&
hostname.EndsWith(".local", StringComparison.OrdinalIgnoreCase);
}
private void UpdateServerIpStatus()
{
ServerIpSet = !string.IsNullOrEmpty(AppSettings.Default.JellyfinIP) ||
@@ -1316,6 +1354,7 @@ namespace Jellyfin2Samsung.ViewModels
OpenAfterInstall = AppSettings.Default.OpenAfterInstall;
KeepWGTFile = AppSettings.Default.KeepWGTFile;
DarkMode = AppSettings.Default.DarkMode;
GitHubToken = AppSettings.Default.GitHubToken ?? string.Empty;
}
private async Task LoadNetworkInterfacesAsync()
@@ -1331,10 +1370,17 @@ namespace Jellyfin2Samsung.ViewModels
foreach (var ni in interfaces)
NetworkInterfaces.Add(ni);
// Restore previous selection if possible
// Restore previous selection: match by name first (stable across DHCP changes),
// fall back to IP match, then default to first interface
var savedName = AppSettings.Default.SavedNetworkInterfaceName;
var savedIp = AppSettings.Default.LocalIp;
SelectedNetworkInterface =
NetworkInterfaces.FirstOrDefault(i =>
i.IpAddress == AppSettings.Default.LocalIp)
(!string.IsNullOrEmpty(savedName)
? NetworkInterfaces.FirstOrDefault(i => i.Name == savedName)
: null)
?? (!string.IsNullOrEmpty(savedIp)
? NetworkInterfaces.FirstOrDefault(i => i.IpAddress == savedIp)
: null)
?? NetworkInterfaces.FirstOrDefault();
});
}
@@ -1398,6 +1444,7 @@ namespace Jellyfin2Samsung.ViewModels
LocalIP = value.IpAddress;
AppSettings.Default.LocalIp = value.IpAddress;
AppSettings.Default.SavedNetworkInterfaceName = value.Name;
AppSettings.Default.Save();
}
@@ -1481,6 +1528,17 @@ namespace Jellyfin2Samsung.ViewModels
_themeService.SetTheme(value);
}
partial void OnGitHubTokenChanged(string value)
{
AppSettings.Default.GitHubToken = value;
AppSettings.Default.Save();
}
partial void OnShowGitHubTokenChanged(bool value)
{
OnPropertyChanged(nameof(GitHubTokenPasswordChar));
}
// ========== End Main Settings Methods ==========
public void Dispose()

View File

@@ -481,8 +481,7 @@ namespace Jellyfin2Samsung.ViewModels
if (!AppSettings.Default.KeepWGTFile)
_packageHelper.CleanupDownloadedPackage(customPath);
AppSettings.Default.CustomWgtPath = string.Empty;
AppSettings.Default.Save();
CustomWgtPath = string.Empty;
}
else
{

View File

@@ -142,6 +142,41 @@
HorizontalAlignment="Stretch"/>
</Grid>
<!-- GitHub Token (PAT) -->
<Grid ColumnDefinitions="180,*" ColumnSpacing="12">
<TextBlock Grid.Column="0"
Text="{Binding LblGitHubToken}"
Classes="label"
VerticalAlignment="Center"/>
<Grid Grid.Column="1" ColumnDefinitions="*,Auto" ColumnSpacing="8">
<TextBox Grid.Column="0"
Text="{Binding GitHubToken}"
PasswordChar="{Binding GitHubTokenPasswordChar}"
Watermark="ghp_xxxxxxxxxxxxxxxxxxxx"
Classes="clean"
HorizontalAlignment="Stretch"/>
<ToggleButton Grid.Column="1"
IsChecked="{Binding ShowGitHubToken}"
VerticalAlignment="Center"
Width="36"
Height="36"
CornerRadius="4"
Padding="0"
ToolTip.Tip="Show/Hide token">
<Panel>
<fa:SymbolIcon Symbol="View" Width="16" Height="16" IsVisible="{Binding !ShowGitHubToken}"/>
<fa:SymbolIcon Symbol="Cancel" Width="16" Height="16" IsVisible="{Binding ShowGitHubToken}"/>
</Panel>
</ToggleButton>
</Grid>
</Grid>
<TextBlock Text="{Binding LblGitHubTokenHint}"
FontSize="11"
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}"
FontStyle="Italic"
TextWrapping="Wrap"
Margin="180,0,0,0"/>
</StackPanel>
<Border Height="1" Background="{DynamicResource SystemControlForegroundBaseMediumLowBrush}" Margin="0,4"/>
@@ -373,6 +408,18 @@
</StackPanel>
</Grid>
<!-- mDNS (.local) Warning -->
<Border IsVisible="{Binding ShowMdnsWarning}"
Background="#FFF3CD"
CornerRadius="4"
Padding="10,8"
Margin="0,4,0,0">
<TextBlock Text="{Binding LblMdnsWarning}"
Foreground="#856404"
FontSize="12"
TextWrapping="Wrap"/>
</Border>
</StackPanel>
<Border Height="1" Background="{DynamicResource SystemControlForegroundBaseMediumLowBrush}" Margin="0,4"/>
@@ -507,7 +554,7 @@
</Button>
<ToggleSwitch Grid.Column="1"
IsChecked="{Binding EnableDevLogs, Mode=TwoWay}"
IsEnabled="{Binding IsAuthenticated}"/>
IsEnabled="{Binding ServerIpSet}"/>
</Grid>
</Grid>
@@ -519,7 +566,7 @@
VerticalAlignment="Center"/>
<ToggleSwitch Grid.Column="1"
IsChecked="{Binding UseServerScripts}"
IsEnabled="{Binding IsAuthenticated}"/>
IsEnabled="{Binding ServerIpSet}"/>
</Grid>
<!-- Fix YouTube 153 -->
@@ -529,7 +576,7 @@
Classes="label"
VerticalAlignment="Center"/>
<ToggleSwitch Grid.Column="1"
IsEnabled="{Binding IsAuthenticated}"
IsEnabled="{Binding ServerIpSet}"
IsChecked="{Binding PatchYoutubePlugin}"/>
</Grid>

View File

@@ -31,7 +31,7 @@
| Channel | Version | Notes |
|------------|---------------------------------------------------------------------|------------------------------|
| **Stable** | [v2.2.0.4](https://github.com/Jellyfin2Samsung/Samsung-Jellyfin-Installer/releases/tag/v2.2.0.4) | Recommended for most users |
| **Stable** | [v2.2.0.7](https://github.com/Jellyfin2Samsung/Samsung-Jellyfin-Installer/releases/tag/v2.2.0.7) | Recommended for most users |
| **Beta** | [N/A](#) | Includes new features |
<!-- versions:end -->
@@ -56,6 +56,8 @@ Thats it. No manual certificate handling required in most cases.
🎥 Full walkthrough:
https://www.youtube.com/watch?v=_8mSV5pW-ic
**NixOS:** Clone the [`Samsung2Jellyfin` branch](https://github.com/Jellyfin2Samsung/Samsung-Jellyfin-Installer.git) and run `nix-shell` — the shell environment will automatically build and launch the tool.
---
## 📚 Documentation
@@ -88,11 +90,24 @@ https://github.com/PatrickSt1991/tizen-community-packages
## 🛠️ Support & Contributing
- Bug reports & feature requests: [Issues](../../issues)
- Ideas & questions: [Discussions](../../discussions)
- Chat: https://discord.gg/7mga3zh8Cv
Contributions of all kinds are welcome — whether its bug reports, feature requests, code, documentation, or translations.
Contributions are welcome — issues, PRs, translations, or documentation.
- Bug reports & feature requests: [Issues](../../issues)
- Ideas, feedback & questions: [Discussions](../../discussions)
- Community chat: [Discord](https://discord.gg/7mga3zh8Cv)
## 🌍 Translations
Want to help translate **Jellyfin2Samsung**? Community translations are always appreciated.
You can contribute here:
- [Transifex](https://app.transifex.com/madebypatrick/jellyfin2samsung)
- [Crowdin](https://crowdin.com/project/jellyfin2samsung)
You can help by translating missing strings, improving existing translations, or reviewing your language.
Translation updates are synced back into this repository automatically.
---

3
crowdin.yml Normal file
View File

@@ -0,0 +1,3 @@
files:
- source: /Jellyfin2Samsung-CrossOS/Assets/Localization/en.json
translation: /Jellyfin2Samsung-CrossOS/Assets/Localization/%two_letters_code%.json

81
shell.nix Normal file
View File

@@ -0,0 +1,81 @@
{ pkgs ? import <nixpkgs> {} }:
let
krb5WithUnversionedLib = pkgs.runCommand "krb5-unversioned-so" {} ''
mkdir -p $out/lib
ln -s ${pkgs.krb5}/lib/libgssapi_krb5.so.2 $out/lib/libgssapi_krb5.so
'';
# FHS environment that provides /lib, /lib64, /usr/lib etc.
# so dynamically linked binaries (TizenSdb, .NET native bits) just work.
fhs = pkgs.buildFHSEnv {
name = "jellyfin2samsung-fhs";
targetPkgs = p: with p; [
# Build and Run
dotnet-sdk_8
patchelf
file
stdenv.cc.cc.lib
openssl
icu
zlib
libgcc.lib
krb5
krb5WithUnversionedLib
# Fonts and Rendering
fontconfig
freetype
libGL
# X11 tools
xorg.libX11
xorg.libICE
xorg.libSM
xorg.libXext
xorg.libXcursor
xorg.libXi
xorg.libXrandr
xorg.libXrender
xorg.libXinerama
xorg.libXcomposite
xorg.libXdamage
xorg.libXfixes
xorg.libXtst
# Some other packages that may be needed if not installed system wide
nmap
iproute2
curl
wget
xdg-utils
];
runScript = pkgs.writeShellScript "jellyfin2samsung-entry" ''
export DOTNET_CLI_TELEMETRY_OPTOUT=1
export DOTNET_NOLOGO=1
dotnet publish Jellyfin2Samsung-CrossOS/Jellyfin2Samsung.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
-p:PublishSingleFile=false \
-p:PublishTrimmed=false
Jellyfin2Samsung-CrossOS/bin/Release/net8.0/linux-x64/publish/Jellyfin2Samsung
'';
};
in
pkgs.mkShell {
name = "jellyfin2samsung";
buildInputs = [
fhs
];
shellHook = ''
jellyfin2samsung-fhs
'';
}