mirror of
https://github.com/Jellyfin2Samsung/Samsung-Jellyfin-Installer.git
synced 2026-03-01 11:21:12 +03:00
Merge branch 'beta' into l10n_beta
This commit is contained in:
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>();
|
||||
|
||||
94
Jellyfin2Samsung-CrossOS/Helpers/Core/GitHubAuthHandler.cs
Normal file
94
Jellyfin2Samsung-CrossOS/Helpers/Core/GitHubAuthHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(" }");
|
||||
|
||||
@@ -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'
|
||||
}";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
25
README.md
25
README.md
@@ -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 @@ That’s 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 it’s 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
3
crowdin.yml
Normal 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
81
shell.nix
Normal 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
|
||||
'';
|
||||
}
|
||||
Reference in New Issue
Block a user