Duncan Tourolle d3fbaef417
All checks were successful
Build Plugin / build (push) Successful in 2m21s
Allow multiple library folders
2025-12-19 23:29:25 +01:00

651 lines
32 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>JellyLMS</title>
<style>
.sync-group {
border: 2px solid #00a4dc;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
background: rgba(0, 164, 220, 0.05);
}
.sync-group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.sync-group-players {
color: #ccc;
}
.player-sync-row {
display: flex;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #333;
}
.player-sync-row:last-child {
border-bottom: none;
}
.player-sync-checkbox {
margin-right: 12px;
}
.player-sync-name {
flex: 1;
font-weight: 500;
}
.player-sync-status {
display: flex;
align-items: center;
gap: 6px;
color: #888;
font-size: 0.9em;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.on { background: #52b54b; }
.status-dot.standby { background: #f9a825; }
.status-dot.off { background: #f44336; }
.sync-actions {
margin-top: 15px;
display: flex;
gap: 10px;
align-items: center;
}
#syncStatus {
margin-left: 10px;
}
</style>
</head>
<body>
<div id="JellyLmsConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<h2>JellyLMS Configuration</h2>
<p>Configure the connection between Jellyfin and Logitech Media Server (LMS) for multi-room audio playback.</p>
<form id="JellyLmsConfigForm">
<div class="verticalSection">
<h3>LMS Server Settings</h3>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="LmsServerUrl">LMS Server URL</label>
<input id="LmsServerUrl" name="LmsServerUrl" type="url" is="emby-input" />
<div class="fieldDescription">The URL of your LMS server (e.g., http://192.168.1.100:9000)</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="LmsUsername">LMS Username (optional)</label>
<input id="LmsUsername" name="LmsUsername" type="text" is="emby-input" />
<div class="fieldDescription">Username if LMS authentication is enabled</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="LmsPassword">LMS Password (optional)</label>
<input id="LmsPassword" name="LmsPassword" type="password" is="emby-input" />
<div class="fieldDescription">Password if LMS authentication is enabled</div>
</div>
<div>
<button is="emby-button" type="button" id="btnTestConnection" class="raised button-alt block emby-button">
<span>Test Connection</span>
</button>
<div id="connectionStatus" style="margin-top: 10px;"></div>
</div>
</div>
<div class="verticalSection">
<h3>Jellyfin Server Settings</h3>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="JellyfinServerUrl">Jellyfin Server URL</label>
<input id="JellyfinServerUrl" name="JellyfinServerUrl" type="url" is="emby-input" />
<div class="fieldDescription">The URL that LMS will use to stream audio from Jellyfin. This must be accessible from the LMS server (e.g., http://192.168.1.4:8096).</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="JellyfinApiKey">Jellyfin API Key</label>
<input id="JellyfinApiKey" name="JellyfinApiKey" type="password" is="emby-input" />
<div class="fieldDescription">API key for LMS to authenticate with Jellyfin. Create one in Dashboard > API Keys.</div>
</div>
</div>
<div class="verticalSection">
<h3>Playback Settings</h3>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ConnectionTimeoutSeconds">Connection Timeout (seconds)</label>
<input id="ConnectionTimeoutSeconds" name="ConnectionTimeoutSeconds" type="number" is="emby-input" min="5" max="60" />
<div class="fieldDescription">Timeout for LMS API requests</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableAutoSync" name="EnableAutoSync" type="checkbox" is="emby-checkbox" />
<span>Enable Auto-Sync</span>
</label>
<div class="fieldDescription checkboxFieldDescription">Automatically sync players when playing to multiple devices</div>
</div>
<div class="inputContainer" style="margin-top: 15px;">
<label class="inputLabel inputLabelUnfocused" for="DefaultPlayerMac">Default Player</label>
<select is="emby-select" id="DefaultPlayerMac" name="DefaultPlayerMac" class="emby-select-withcolor emby-select">
<option value="">None</option>
</select>
<div class="fieldDescription">Default player to use when none is specified</div>
</div>
</div>
<div class="verticalSection">
<h3>Direct File Access (Optional)</h3>
<p class="fieldDescription">If LMS and Jellyfin share the same storage (e.g., NAS), enable direct file access for native seeking support. This provides smooth seeking without audio restart.</p>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="UseDirectFilePath" name="UseDirectFilePath" type="checkbox" is="emby-checkbox" />
<span>Enable Direct File Access</span>
</label>
<div class="fieldDescription checkboxFieldDescription">When enabled, LMS will access files directly instead of streaming via HTTP</div>
</div>
<div id="directPathSettings" style="margin-top: 15px;">
<h4 style="margin-bottom: 10px;">Path Mappings</h4>
<p class="fieldDescription">Map Jellyfin paths to LMS paths. Add multiple mappings if your music and podcasts are in different locations.</p>
<div style="margin-bottom: 15px;">
<button is="emby-button" type="button" id="btnDiscoverPaths" class="raised button-alt emby-button">
<span>Discover Jellyfin Paths</span>
</button>
<span id="discoverStatus" style="margin-left: 10px;"></span>
</div>
<div id="discoveredPaths" style="display: none; margin-bottom: 15px; padding: 10px; background: rgba(0,0,0,0.2); border-radius: 4px;">
<strong>Detected Jellyfin paths:</strong>
<ul id="detectedPrefixList" style="margin: 5px 0; padding-left: 20px;"></ul>
<div class="fieldDescription">Click a path to use it as the Jellyfin path in a new mapping</div>
</div>
<div id="pathMappingsList">
<!-- Dynamic path mappings will be added here -->
</div>
<div style="margin-top: 10px;">
<button is="emby-button" type="button" id="btnAddMapping" class="raised button-alt emby-button">
<span>+ Add Mapping</span>
</button>
</div>
</div>
</div>
<div class="verticalSection">
<h3>Player Sync</h3>
<p class="fieldDescription">Select players to sync together for multi-room audio. Synced players play in perfect sync.</p>
<div id="currentSyncGroups">
<!-- Existing sync groups shown here -->
</div>
<div id="playerSyncList">
<p>Loading players...</p>
</div>
<div class="sync-actions">
<button is="emby-button" type="button" id="btnSyncSelected" class="raised button-submit emby-button" disabled>
<span>Sync Selected</span>
</button>
<button is="emby-button" type="button" id="btnRefreshPlayers" class="raised button-alt emby-button">
<span>Refresh</span>
</button>
<span id="syncStatus"></span>
</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
</button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var JellyLmsConfig = {
pluginUniqueId: 'a5b8c9d0-1e2f-3a4b-5c6d-7e8f9a0b1c2d',
players: [],
syncGroups: []
};
function getStatusClass(player) {
if (!player.IsConnected) return 'off';
return player.IsPoweredOn ? 'on' : 'standby';
}
function getStatusText(player) {
if (!player.IsConnected) return 'Disconnected';
return player.IsPoweredOn ? 'On' : 'Standby';
}
function loadPlayers() {
var defaultSelect = document.querySelector('#DefaultPlayerMac');
var currentDefault = defaultSelect.value;
ApiClient.ajax({
url: ApiClient.getUrl('JellyLms/Players', { refresh: true }),
type: 'GET',
dataType: 'json'
}).then(function(players) {
JellyLmsConfig.players = players || [];
defaultSelect.innerHTML = '<option value="">None</option>';
players.forEach(function(player) {
var option = document.createElement('option');
option.value = player.MacAddress;
option.text = player.Name;
if (player.MacAddress === currentDefault) {
option.selected = true;
}
defaultSelect.appendChild(option);
});
renderPlayerList();
}).catch(function(err) {
document.querySelector('#playerSyncList').innerHTML = '<p style="color: red;">Error loading players. Check LMS connection.</p>';
console.error('Error loading players:', err);
});
}
function loadSyncGroups() {
ApiClient.ajax({
url: ApiClient.getUrl('JellyLms/SyncGroups'),
type: 'GET',
dataType: 'json'
}).then(function(groups) {
JellyLmsConfig.syncGroups = groups || [];
renderSyncGroups();
renderPlayerList();
}).catch(function(err) {
console.error('Error loading sync groups:', err);
});
}
function renderSyncGroups() {
var container = document.querySelector('#currentSyncGroups');
var groups = JellyLmsConfig.syncGroups;
if (!groups || groups.length === 0) {
container.innerHTML = '';
return;
}
var html = '';
groups.forEach(function(group) {
var playerNames = [];
var masterPlayer = JellyLmsConfig.players.find(function(p) { return p.MacAddress === group.MasterMac; });
if (masterPlayer) playerNames.push(masterPlayer.Name);
group.SlaveMacs.forEach(function(mac) {
var player = JellyLmsConfig.players.find(function(p) { return p.MacAddress === mac; });
if (player) playerNames.push(player.Name);
});
html += '<div class="sync-group">';
html += '<div class="sync-group-header">';
html += '<span class="sync-group-players">' + playerNames.join(' + ') + '</span>';
html += '<button is="emby-button" type="button" class="raised button-alt emby-button btnUnsyncGroup" data-master="' + group.MasterMac + '">';
html += '<span>Unsync</span>';
html += '</button>';
html += '</div>';
html += '</div>';
});
container.innerHTML = html;
container.querySelectorAll('.btnUnsyncGroup').forEach(function(btn) {
btn.addEventListener('click', function() {
unsyncGroup(this.getAttribute('data-master'));
});
});
}
function renderPlayerList() {
var container = document.querySelector('#playerSyncList');
var players = JellyLmsConfig.players;
if (!players || players.length === 0) {
container.innerHTML = '<p>No players found. Make sure LMS is running.</p>';
updateSyncButton();
return;
}
// Get MACs of already synced players
var syncedMacs = new Set();
JellyLmsConfig.syncGroups.forEach(function(group) {
syncedMacs.add(group.MasterMac);
group.SlaveMacs.forEach(function(mac) { syncedMacs.add(mac); });
});
// Only show unsynced players
var unsyncedPlayers = players.filter(function(p) {
return !syncedMacs.has(p.MacAddress);
});
if (unsyncedPlayers.length === 0) {
container.innerHTML = '<p>All players are synced.</p>';
updateSyncButton();
return;
}
var html = '';
unsyncedPlayers.forEach(function(player) {
var statusClass = getStatusClass(player);
html += '<div class="player-sync-row">';
html += '<input type="checkbox" class="player-sync-checkbox" data-mac="' + player.MacAddress + '" id="sync-' + player.MacAddress + '">';
html += '<label class="player-sync-name" for="sync-' + player.MacAddress + '">' + player.Name + '</label>';
html += '<div class="player-sync-status">';
html += '<span class="status-dot ' + statusClass + '"></span>';
html += '<span>' + getStatusText(player) + '</span>';
html += '</div>';
html += '</div>';
});
container.innerHTML = html;
// Add change listeners
container.querySelectorAll('.player-sync-checkbox').forEach(function(cb) {
cb.addEventListener('change', updateSyncButton);
});
updateSyncButton();
}
function getSelectedMacs() {
var checkboxes = document.querySelectorAll('.player-sync-checkbox:checked');
var macs = [];
checkboxes.forEach(function(cb) {
macs.push(cb.getAttribute('data-mac'));
});
return macs;
}
function updateSyncButton() {
var btn = document.querySelector('#btnSyncSelected');
var selected = getSelectedMacs();
btn.disabled = selected.length < 2;
}
function syncSelected() {
var macs = getSelectedMacs();
if (macs.length < 2) return;
var statusDiv = document.querySelector('#syncStatus');
statusDiv.innerHTML = '<span style="color: orange;">Syncing...</span>';
// First MAC becomes master (arbitrary, user doesn't need to know)
var masterMac = macs[0];
var slaveMacs = macs.slice(1);
ApiClient.ajax({
url: ApiClient.getUrl('JellyLms/SyncGroups'),
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
MasterMac: masterMac,
SlaveMacs: slaveMacs
})
}).then(function() {
statusDiv.innerHTML = '<span style="color: green;">Synced!</span>';
setTimeout(function() {
statusDiv.innerHTML = '';
loadSyncGroups();
}, 1500);
}).catch(function(err) {
statusDiv.innerHTML = '<span style="color: red;">Failed to sync.</span>';
console.error('Error syncing:', err);
});
}
function unsyncGroup(masterMac) {
var statusDiv = document.querySelector('#syncStatus');
statusDiv.innerHTML = '<span style="color: orange;">Unsyncing...</span>';
ApiClient.ajax({
url: ApiClient.getUrl('JellyLms/SyncGroups/' + encodeURIComponent(masterMac)),
type: 'DELETE'
}).then(function() {
statusDiv.innerHTML = '';
loadSyncGroups();
}).catch(function(err) {
statusDiv.innerHTML = '<span style="color: red;">Failed to unsync.</span>';
console.error('Error unsyncing:', err);
});
}
function testConnection() {
var statusDiv = document.querySelector('#connectionStatus');
statusDiv.innerHTML = '<span style="color: orange;">Testing connection...</span>';
ApiClient.ajax({
url: ApiClient.getUrl('JellyLms/TestConnection'),
type: 'POST',
dataType: 'json'
}).then(function(result) {
if (result.IsConnected) {
statusDiv.innerHTML = '<span style="color: green;">Connected! Found ' + result.PlayerCount + ' player(s).</span>';
loadPlayers();
loadSyncGroups();
} else {
statusDiv.innerHTML = '<span style="color: red;">Connection failed: ' + (result.LastError || 'Unknown error') + '</span>';
}
}).catch(function(err) {
statusDiv.innerHTML = '<span style="color: red;">Connection failed. Check the server URL.</span>';
console.error('Connection test failed:', err);
});
}
// Path Mapping Functions
function renderPathMappings(mappings) {
var container = document.querySelector('#pathMappingsList');
if (!mappings || mappings.length === 0) {
container.innerHTML = '<p class="fieldDescription">No path mappings configured. Click "Discover Jellyfin Paths" to get started.</p>';
return;
}
var html = '';
mappings.forEach(function(mapping, index) {
html += '<div class="path-mapping-row" style="display: flex; gap: 10px; align-items: flex-end; margin-bottom: 10px; padding: 10px; background: rgba(0,0,0,0.1); border-radius: 4px;">';
html += '<div style="flex: 1;">';
html += '<label class="inputLabel inputLabelUnfocused">Jellyfin Path</label>';
html += '<input type="text" is="emby-input" class="mapping-jellyfin-path" data-index="' + index + '" value="' + (mapping.JellyfinPath || '') + '" placeholder="/media/music" />';
html += '</div>';
html += '<div style="flex: 1;">';
html += '<label class="inputLabel inputLabelUnfocused">LMS Path</label>';
html += '<input type="text" is="emby-input" class="mapping-lms-path" data-index="' + index + '" value="' + (mapping.LmsPath || '') + '" placeholder="/mnt/music" />';
html += '</div>';
html += '<button is="emby-button" type="button" class="raised button-alt emby-button btnRemoveMapping" data-index="' + index + '" style="margin-bottom: 0;">';
html += '<span>Remove</span>';
html += '</button>';
html += '</div>';
});
container.innerHTML = html;
// Add remove handlers
container.querySelectorAll('.btnRemoveMapping').forEach(function(btn) {
btn.addEventListener('click', function() {
var idx = parseInt(this.getAttribute('data-index'));
JellyLmsConfig.pathMappings.splice(idx, 1);
renderPathMappings(JellyLmsConfig.pathMappings);
});
});
// Update stored mappings when inputs change
container.querySelectorAll('.mapping-jellyfin-path, .mapping-lms-path').forEach(function(input) {
input.addEventListener('change', function() {
var idx = parseInt(this.getAttribute('data-index'));
if (this.classList.contains('mapping-jellyfin-path')) {
JellyLmsConfig.pathMappings[idx].JellyfinPath = this.value;
} else {
JellyLmsConfig.pathMappings[idx].LmsPath = this.value;
}
});
});
}
function addPathMapping(jellyfinPath, lmsPath) {
JellyLmsConfig.pathMappings = JellyLmsConfig.pathMappings || [];
JellyLmsConfig.pathMappings.push({
JellyfinPath: jellyfinPath || '',
LmsPath: lmsPath || ''
});
renderPathMappings(JellyLmsConfig.pathMappings);
}
function discoverPaths() {
var statusDiv = document.querySelector('#discoverStatus');
statusDiv.innerHTML = '<span style="color: orange;">Discovering...</span>';
ApiClient.ajax({
url: ApiClient.getUrl('JellyLms/DiscoverPaths'),
type: 'GET',
dataType: 'json'
}).then(function(result) {
statusDiv.innerHTML = '';
var discoveredDiv = document.querySelector('#discoveredPaths');
var prefixList = document.querySelector('#detectedPrefixList');
if (result.DetectedPrefixes && result.DetectedPrefixes.length > 0) {
discoveredDiv.style.display = 'block';
var html = '';
result.DetectedPrefixes.forEach(function(prefix) {
html += '<li><a href="#" class="detected-prefix-link" data-path="' + prefix + '" style="color: #00a4dc;">' + prefix + '</a></li>';
});
prefixList.innerHTML = html;
// Add click handlers to use detected paths
prefixList.querySelectorAll('.detected-prefix-link').forEach(function(link) {
link.addEventListener('click', function(e) {
e.preventDefault();
addPathMapping(this.getAttribute('data-path'), '');
});
});
} else {
discoveredDiv.style.display = 'block';
prefixList.innerHTML = '<li>No audio files found in library</li>';
}
}).catch(function(err) {
statusDiv.innerHTML = '<span style="color: red;">Discovery failed</span>';
console.error('Path discovery failed:', err);
});
}
function getPathMappingsFromUI() {
var mappings = [];
document.querySelectorAll('.path-mapping-row').forEach(function(row) {
var jellyfinPath = row.querySelector('.mapping-jellyfin-path').value;
var lmsPath = row.querySelector('.mapping-lms-path').value;
if (jellyfinPath || lmsPath) {
mappings.push({ JellyfinPath: jellyfinPath, LmsPath: lmsPath });
}
});
return mappings;
}
document.querySelector('#JellyLmsConfigPage')
.addEventListener('pageshow', function() {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(JellyLmsConfig.pluginUniqueId).then(function (config) {
document.querySelector('#LmsServerUrl').value = config.LmsServerUrl || 'http://localhost:9000';
document.querySelector('#LmsUsername').value = config.LmsUsername || '';
document.querySelector('#LmsPassword').value = config.LmsPassword || '';
document.querySelector('#JellyfinServerUrl').value = config.JellyfinServerUrl || 'http://localhost:8096';
document.querySelector('#JellyfinApiKey').value = config.JellyfinApiKey || '';
document.querySelector('#ConnectionTimeoutSeconds').value = config.ConnectionTimeoutSeconds || 10;
document.querySelector('#EnableAutoSync').checked = config.EnableAutoSync !== false;
document.querySelector('#DefaultPlayerMac').value = config.DefaultPlayerMac || '';
document.querySelector('#UseDirectFilePath').checked = config.UseDirectFilePath || false;
// Load path mappings (new list format, with fallback to legacy single mapping)
JellyLmsConfig.pathMappings = config.PathMappings || [];
// If no list mappings but legacy single mapping exists, show it
if (JellyLmsConfig.pathMappings.length === 0 && config.JellyfinMediaPath && config.LmsMediaPath) {
JellyLmsConfig.pathMappings = [{
JellyfinPath: config.JellyfinMediaPath,
LmsPath: config.LmsMediaPath
}];
}
renderPathMappings(JellyLmsConfig.pathMappings);
Dashboard.hideLoadingMsg();
loadPlayers();
loadSyncGroups();
});
});
document.querySelector('#btnTestConnection')
.addEventListener('click', function() {
ApiClient.getPluginConfiguration(JellyLmsConfig.pluginUniqueId).then(function (config) {
config.LmsServerUrl = document.querySelector('#LmsServerUrl').value;
config.LmsUsername = document.querySelector('#LmsUsername').value;
config.LmsPassword = document.querySelector('#LmsPassword').value;
ApiClient.updatePluginConfiguration(JellyLmsConfig.pluginUniqueId, config).then(function() {
testConnection();
});
});
});
document.querySelector('#btnRefreshPlayers')
.addEventListener('click', function() {
loadPlayers();
loadSyncGroups();
});
document.querySelector('#btnSyncSelected')
.addEventListener('click', function() {
syncSelected();
});
document.querySelector('#JellyLmsConfigForm')
.addEventListener('submit', function(e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(JellyLmsConfig.pluginUniqueId).then(function (config) {
config.LmsServerUrl = document.querySelector('#LmsServerUrl').value;
config.LmsUsername = document.querySelector('#LmsUsername').value;
config.LmsPassword = document.querySelector('#LmsPassword').value;
config.JellyfinServerUrl = document.querySelector('#JellyfinServerUrl').value;
config.JellyfinApiKey = document.querySelector('#JellyfinApiKey').value;
config.ConnectionTimeoutSeconds = parseInt(document.querySelector('#ConnectionTimeoutSeconds').value) || 10;
config.EnableAutoSync = document.querySelector('#EnableAutoSync').checked;
config.DefaultPlayerMac = document.querySelector('#DefaultPlayerMac').value;
config.UseDirectFilePath = document.querySelector('#UseDirectFilePath').checked;
// Save path mappings (clear legacy single mapping when using list)
config.PathMappings = getPathMappingsFromUI();
config.JellyfinMediaPath = '';
config.LmsMediaPath = '';
ApiClient.updatePluginConfiguration(JellyLmsConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
e.preventDefault();
return false;
});
document.querySelector('#btnDiscoverPaths')
.addEventListener('click', function() {
discoverPaths();
});
document.querySelector('#btnAddMapping')
.addEventListener('click', function() {
addPathMapping('', '');
});
</script>
</div>
</body>
</html>