feat(jellyfin): Add post-install setup wizard
- Add 4-step modal wizard for first-time configuration - Step 1: Welcome with Docker/container status checks - Step 2: Add/remove media library paths with type presets - Step 3: Network configuration (domain, HAProxy, ACME) - Step 4: Complete with link to Jellyfin Web UI - Add RPCD methods: get_wizard_status, set_wizard_complete, add_media_path, remove_media_path, get_media_paths - Auto-trigger wizard when installed but not configured - Add wizard.css with step indicators and form styling - Update Makefile to install CSS resources Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b75fbd516c
commit
bf3395a6fa
@ -22,6 +22,9 @@ define Package/luci-app-jellyfin/install
|
|||||||
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/jellyfin
|
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/jellyfin
|
||||||
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/jellyfin/*.js $(1)/www/luci-static/resources/view/jellyfin/
|
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/jellyfin/*.js $(1)/www/luci-static/resources/view/jellyfin/
|
||||||
|
|
||||||
|
$(INSTALL_DIR) $(1)/www/luci-static/resources/jellyfin
|
||||||
|
$(INSTALL_DATA) ./htdocs/luci-static/resources/jellyfin/*.css $(1)/www/luci-static/resources/jellyfin/
|
||||||
|
|
||||||
$(INSTALL_DIR) $(1)/usr/libexec/rpcd
|
$(INSTALL_DIR) $(1)/usr/libexec/rpcd
|
||||||
$(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.jellyfin $(1)/usr/libexec/rpcd/
|
$(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.jellyfin $(1)/usr/libexec/rpcd/
|
||||||
endef
|
endef
|
||||||
|
|||||||
@ -0,0 +1,205 @@
|
|||||||
|
/* Jellyfin Setup Wizard Styles */
|
||||||
|
|
||||||
|
.jf-wizard {
|
||||||
|
min-width: 500px;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-wizard-steps {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-wizard-step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-wizard-step.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-wizard-step.completed {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-step-num {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #333;
|
||||||
|
color: #888;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-wizard-step.active .jf-step-num {
|
||||||
|
background: #00a4dc;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-wizard-step.completed .jf-step-num {
|
||||||
|
background: #27ae60;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-step-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-wizard-step.active .jf-step-label {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-wizard-content {
|
||||||
|
min-height: 200px;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-wizard-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status checks */
|
||||||
|
.jf-status-check {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-check-ok {
|
||||||
|
color: #27ae60;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-check-pending {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Media list */
|
||||||
|
.jf-media-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-media-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-media-item:hover {
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-media-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-media-name {
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-media-path {
|
||||||
|
color: #888;
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form groups */
|
||||||
|
.jf-form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-form-group input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-form-group input[type="text"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #00a4dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-form-group input[type="text"]::placeholder {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox style */
|
||||||
|
.jf-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-checkbox input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.jf-wizard {
|
||||||
|
min-width: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-wizard-steps {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-step-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-media-item {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jf-media-path {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -66,18 +66,78 @@ var callLogs = rpc.declare({
|
|||||||
expect: {}
|
expect: {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var callGetWizardStatus = rpc.declare({
|
||||||
|
object: 'luci.jellyfin',
|
||||||
|
method: 'get_wizard_status',
|
||||||
|
expect: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
var callSetWizardComplete = rpc.declare({
|
||||||
|
object: 'luci.jellyfin',
|
||||||
|
method: 'set_wizard_complete',
|
||||||
|
params: ['complete'],
|
||||||
|
expect: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
var callAddMediaPath = rpc.declare({
|
||||||
|
object: 'luci.jellyfin',
|
||||||
|
method: 'add_media_path',
|
||||||
|
params: ['path', 'name', 'type'],
|
||||||
|
expect: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
var callRemoveMediaPath = rpc.declare({
|
||||||
|
object: 'luci.jellyfin',
|
||||||
|
method: 'remove_media_path',
|
||||||
|
params: ['section'],
|
||||||
|
expect: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
var callGetMediaPaths = rpc.declare({
|
||||||
|
object: 'luci.jellyfin',
|
||||||
|
method: 'get_media_paths',
|
||||||
|
expect: {}
|
||||||
|
});
|
||||||
|
|
||||||
return view.extend({
|
return view.extend({
|
||||||
load: function() {
|
load: function() {
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
uci.load('jellyfin'),
|
uci.load('jellyfin'),
|
||||||
callStatus()
|
callStatus(),
|
||||||
|
callGetWizardStatus(),
|
||||||
|
callGetMediaPaths()
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|
||||||
render: function(data) {
|
render: function(data) {
|
||||||
var status = data[1] || {};
|
var status = data[1] || {};
|
||||||
|
var wizardStatus = data[2] || {};
|
||||||
|
var mediaPaths = data[3] || {};
|
||||||
|
var self = this;
|
||||||
var m, s, o;
|
var m, s, o;
|
||||||
|
|
||||||
|
// Store for wizard access
|
||||||
|
this.status = status;
|
||||||
|
this.wizardData = {
|
||||||
|
mediaPaths: (mediaPaths.paths || []).slice(),
|
||||||
|
domain: status.domain || '',
|
||||||
|
haproxy: status.haproxy || false,
|
||||||
|
acme: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load wizard CSS
|
||||||
|
if (!document.querySelector('link[href*="jellyfin/wizard.css"]')) {
|
||||||
|
var link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = L.resource('jellyfin/wizard.css');
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-show wizard on first run
|
||||||
|
if (wizardStatus.show_wizard) {
|
||||||
|
setTimeout(L.bind(this.showSetupWizard, this), 500);
|
||||||
|
}
|
||||||
|
|
||||||
m = new form.Map('jellyfin', _('Jellyfin Media Server'),
|
m = new form.Map('jellyfin', _('Jellyfin Media Server'),
|
||||||
_('Free media server for streaming movies, TV shows, music, and photos.'));
|
_('Free media server for streaming movies, TV shows, music, and photos.'));
|
||||||
|
|
||||||
@ -390,5 +450,347 @@ return view.extend({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return m.render();
|
return m.render();
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ---- Setup Wizard ---- */
|
||||||
|
showSetupWizard: function() {
|
||||||
|
this.wizardStep = 1;
|
||||||
|
this.updateWizardModal();
|
||||||
|
},
|
||||||
|
|
||||||
|
updateWizardModal: function() {
|
||||||
|
var self = this;
|
||||||
|
var steps = ['Welcome', 'Media', 'Network', 'Complete'];
|
||||||
|
|
||||||
|
var content = E('div', { 'class': 'jf-wizard' }, [
|
||||||
|
// Progress indicator
|
||||||
|
E('div', { 'class': 'jf-wizard-steps' }, steps.map(function(label, idx) {
|
||||||
|
var stepNum = idx + 1;
|
||||||
|
var cls = 'jf-wizard-step';
|
||||||
|
if (stepNum < self.wizardStep) cls += ' completed';
|
||||||
|
if (stepNum === self.wizardStep) cls += ' active';
|
||||||
|
return E('div', { 'class': cls }, [
|
||||||
|
E('span', { 'class': 'jf-step-num' }, String(stepNum)),
|
||||||
|
E('span', { 'class': 'jf-step-label' }, label)
|
||||||
|
]);
|
||||||
|
})),
|
||||||
|
// Step content
|
||||||
|
E('div', { 'class': 'jf-wizard-content' }, this.renderWizardStep())
|
||||||
|
]);
|
||||||
|
|
||||||
|
ui.showModal(_('Jellyfin Setup'), [
|
||||||
|
content,
|
||||||
|
E('div', { 'class': 'jf-wizard-buttons' }, this.renderWizardButtons())
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
|
||||||
|
renderWizardStep: function() {
|
||||||
|
switch (this.wizardStep) {
|
||||||
|
case 1: return this.renderStepWelcome();
|
||||||
|
case 2: return this.renderStepMedia();
|
||||||
|
case 3: return this.renderStepNetwork();
|
||||||
|
case 4: return this.renderStepComplete();
|
||||||
|
}
|
||||||
|
return E('div', {}, 'Unknown step');
|
||||||
|
},
|
||||||
|
|
||||||
|
renderWizardButtons: function() {
|
||||||
|
var self = this;
|
||||||
|
var buttons = [];
|
||||||
|
|
||||||
|
if (this.wizardStep > 1) {
|
||||||
|
buttons.push(E('button', {
|
||||||
|
'class': 'btn',
|
||||||
|
'click': function() { self.wizardStep--; self.updateWizardModal(); }
|
||||||
|
}, _('Back')));
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons.push(E('button', {
|
||||||
|
'class': 'btn',
|
||||||
|
'click': function() { ui.hideModal(); }
|
||||||
|
}, _('Skip Setup')));
|
||||||
|
|
||||||
|
if (this.wizardStep < 4) {
|
||||||
|
buttons.push(E('button', {
|
||||||
|
'class': 'btn cbi-button-action',
|
||||||
|
'click': L.bind(this.nextWizardStep, this)
|
||||||
|
}, _('Next')));
|
||||||
|
} else {
|
||||||
|
buttons.push(E('button', {
|
||||||
|
'class': 'btn cbi-button-action',
|
||||||
|
'click': L.bind(this.finishWizard, this)
|
||||||
|
}, _('Finish Setup')));
|
||||||
|
}
|
||||||
|
|
||||||
|
return buttons;
|
||||||
|
},
|
||||||
|
|
||||||
|
nextWizardStep: function() {
|
||||||
|
// Save data from current step before advancing
|
||||||
|
if (this.wizardStep === 3) {
|
||||||
|
var domain = document.getElementById('jf-domain');
|
||||||
|
var haproxy = document.getElementById('jf-haproxy');
|
||||||
|
var acme = document.getElementById('jf-acme');
|
||||||
|
if (domain) this.wizardData.domain = domain.value;
|
||||||
|
if (haproxy) this.wizardData.haproxy = haproxy.checked;
|
||||||
|
if (acme) this.wizardData.acme = acme.checked;
|
||||||
|
}
|
||||||
|
this.wizardStep++;
|
||||||
|
this.updateWizardModal();
|
||||||
|
},
|
||||||
|
|
||||||
|
finishWizard: function() {
|
||||||
|
var self = this;
|
||||||
|
ui.hideModal();
|
||||||
|
ui.showModal(_('Finishing Setup...'), [
|
||||||
|
E('p', { 'class': 'spinning' }, _('Saving configuration...'))
|
||||||
|
]);
|
||||||
|
|
||||||
|
callSetWizardComplete(1).then(function() {
|
||||||
|
ui.hideModal();
|
||||||
|
ui.addNotification(null, E('p', {}, _('Jellyfin setup complete!')), 'info');
|
||||||
|
window.location.href = window.location.pathname + '?' + Date.now();
|
||||||
|
}).catch(function() {
|
||||||
|
ui.hideModal();
|
||||||
|
ui.addNotification(null, E('p', {}, _('Failed to save wizard status.')), 'danger');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
renderStepWelcome: function() {
|
||||||
|
var running = this.status && this.status.container_status === 'running';
|
||||||
|
var installed = this.status && this.status.container_status !== 'not_installed';
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
var items = [
|
||||||
|
E('p', { 'style': 'font-size: 16px; margin-bottom: 16px;' },
|
||||||
|
_('Welcome to Jellyfin! This wizard will help you configure your media server.'))
|
||||||
|
];
|
||||||
|
|
||||||
|
// Docker check
|
||||||
|
items.push(E('div', { 'class': 'jf-status-check' }, [
|
||||||
|
E('span', { 'class': this.status.docker_available ? 'jf-check-ok' : 'jf-check-pending' },
|
||||||
|
this.status.docker_available ? '\u2713' : '\u25CB'),
|
||||||
|
' Docker is ' + (this.status.docker_available ? 'available' : 'not available')
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Container check
|
||||||
|
items.push(E('div', { 'class': 'jf-status-check', 'style': 'margin-top: 8px;' }, [
|
||||||
|
E('span', { 'class': running ? 'jf-check-ok' : 'jf-check-pending' },
|
||||||
|
running ? '\u2713' : '\u25CB'),
|
||||||
|
' Jellyfin container is ' + (running ? 'running' : (installed ? 'stopped' : 'not installed'))
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
if (!installed && this.status.docker_available) {
|
||||||
|
items.push(E('button', {
|
||||||
|
'class': 'btn cbi-button-action',
|
||||||
|
'style': 'margin-top: 12px;',
|
||||||
|
'click': function() {
|
||||||
|
ui.hideModal();
|
||||||
|
ui.showModal(_('Installing...'), [
|
||||||
|
E('p', { 'class': 'spinning' }, _('Pulling Docker image and configuring...'))
|
||||||
|
]);
|
||||||
|
callInstall().then(function(res) {
|
||||||
|
ui.hideModal();
|
||||||
|
if (res && res.success) {
|
||||||
|
self.status.container_status = 'running';
|
||||||
|
self.showSetupWizard();
|
||||||
|
} else {
|
||||||
|
ui.addNotification(null, E('p', {}, _('Installation failed: ') + (res.output || 'Unknown')), 'danger');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, _('Install Jellyfin')));
|
||||||
|
} else if (installed && !running) {
|
||||||
|
items.push(E('button', {
|
||||||
|
'class': 'btn cbi-button-action',
|
||||||
|
'style': 'margin-top: 12px;',
|
||||||
|
'click': function() {
|
||||||
|
callStart().then(function() {
|
||||||
|
self.status.container_status = 'running';
|
||||||
|
self.updateWizardModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, _('Start Jellyfin')));
|
||||||
|
}
|
||||||
|
|
||||||
|
return E('div', {}, items);
|
||||||
|
},
|
||||||
|
|
||||||
|
renderStepMedia: function() {
|
||||||
|
var self = this;
|
||||||
|
var paths = this.wizardData.mediaPaths || [];
|
||||||
|
|
||||||
|
var items = [
|
||||||
|
E('p', {}, _('Add your media library folders:'))
|
||||||
|
];
|
||||||
|
|
||||||
|
// Path list
|
||||||
|
if (paths.length > 0) {
|
||||||
|
items.push(E('div', { 'class': 'jf-media-list' }, paths.map(function(p) {
|
||||||
|
return E('div', { 'class': 'jf-media-item' }, [
|
||||||
|
E('span', { 'class': 'jf-media-icon' }, self.getMediaIcon(p.type)),
|
||||||
|
E('span', { 'class': 'jf-media-name' }, p.name),
|
||||||
|
E('span', { 'class': 'jf-media-path' }, p.path),
|
||||||
|
E('button', {
|
||||||
|
'class': 'btn btn-sm',
|
||||||
|
'style': 'padding: 2px 8px;',
|
||||||
|
'click': L.bind(self.removeMediaPath, self, p.section)
|
||||||
|
}, '\u00D7')
|
||||||
|
]);
|
||||||
|
})));
|
||||||
|
} else {
|
||||||
|
items.push(E('p', { 'style': 'color: #888; font-style: italic;' },
|
||||||
|
_('No media libraries configured yet.')));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new path form
|
||||||
|
items.push(E('div', { 'style': 'margin-top: 16px; display: flex; gap: 8px; flex-wrap: wrap;' }, [
|
||||||
|
E('select', { 'id': 'media-type', 'style': 'width: 110px; padding: 6px;' }, [
|
||||||
|
E('option', { 'value': 'movies' }, 'Movies'),
|
||||||
|
E('option', { 'value': 'tvshows' }, 'TV Shows'),
|
||||||
|
E('option', { 'value': 'music' }, 'Music'),
|
||||||
|
E('option', { 'value': 'photos' }, 'Photos')
|
||||||
|
]),
|
||||||
|
E('input', {
|
||||||
|
'id': 'media-name', 'type': 'text', 'placeholder': _('Name'),
|
||||||
|
'style': 'width: 120px; padding: 6px;'
|
||||||
|
}),
|
||||||
|
E('input', {
|
||||||
|
'id': 'media-path', 'type': 'text', 'placeholder': '/srv/media/movies',
|
||||||
|
'style': 'flex: 1; min-width: 150px; padding: 6px;'
|
||||||
|
}),
|
||||||
|
E('button', {
|
||||||
|
'class': 'btn cbi-button-action',
|
||||||
|
'click': L.bind(this.addMediaPath, this)
|
||||||
|
}, _('Add'))
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Presets
|
||||||
|
items.push(E('div', { 'style': 'margin-top: 8px;' }, [
|
||||||
|
E('span', { 'style': 'color: #888; font-size: 12px;' }, _('Presets: ')),
|
||||||
|
E('button', { 'class': 'btn btn-sm', 'style': 'font-size: 11px;', 'click': function() {
|
||||||
|
document.getElementById('media-path').value = '/srv/media';
|
||||||
|
}}, '/srv/media'),
|
||||||
|
' ',
|
||||||
|
E('button', { 'class': 'btn btn-sm', 'style': 'font-size: 11px;', 'click': function() {
|
||||||
|
document.getElementById('media-path').value = '/mnt/smbfs';
|
||||||
|
}}, '/mnt/smbfs')
|
||||||
|
]));
|
||||||
|
|
||||||
|
return E('div', {}, items);
|
||||||
|
},
|
||||||
|
|
||||||
|
getMediaIcon: function(type) {
|
||||||
|
switch(type) {
|
||||||
|
case 'movies': return '\u{1F3AC}';
|
||||||
|
case 'tvshows': return '\u{1F4FA}';
|
||||||
|
case 'music': return '\u{1F3B5}';
|
||||||
|
case 'photos': return '\u{1F4F7}';
|
||||||
|
default: return '\u{1F4C1}';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addMediaPath: function() {
|
||||||
|
var self = this;
|
||||||
|
var typeEl = document.getElementById('media-type');
|
||||||
|
var nameEl = document.getElementById('media-name');
|
||||||
|
var pathEl = document.getElementById('media-path');
|
||||||
|
|
||||||
|
var type = typeEl ? typeEl.value : 'movies';
|
||||||
|
var name = nameEl ? nameEl.value : '';
|
||||||
|
var path = pathEl ? pathEl.value : '';
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
ui.addNotification(null, E('p', {}, _('Path is required')), 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!name) {
|
||||||
|
name = type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
callAddMediaPath(path, name, type).then(function(res) {
|
||||||
|
if (res && res.success) {
|
||||||
|
self.wizardData.mediaPaths.push({
|
||||||
|
section: res.section,
|
||||||
|
path: path,
|
||||||
|
name: name,
|
||||||
|
type: type
|
||||||
|
});
|
||||||
|
if (nameEl) nameEl.value = '';
|
||||||
|
if (pathEl) pathEl.value = '';
|
||||||
|
self.updateWizardModal();
|
||||||
|
} else {
|
||||||
|
ui.addNotification(null, E('p', {}, _('Failed to add path: ') + (res.error || 'Unknown')), 'danger');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeMediaPath: function(section) {
|
||||||
|
var self = this;
|
||||||
|
callRemoveMediaPath(section).then(function(res) {
|
||||||
|
if (res && res.success) {
|
||||||
|
self.wizardData.mediaPaths = self.wizardData.mediaPaths.filter(function(p) {
|
||||||
|
return p.section !== section;
|
||||||
|
});
|
||||||
|
self.updateWizardModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
renderStepNetwork: function() {
|
||||||
|
return E('div', {}, [
|
||||||
|
E('p', {}, _('Configure network access (optional):')),
|
||||||
|
|
||||||
|
E('div', { 'class': 'jf-form-group' }, [
|
||||||
|
E('label', {}, _('Domain (for HTTPS access)')),
|
||||||
|
E('input', {
|
||||||
|
'id': 'jf-domain', 'type': 'text',
|
||||||
|
'placeholder': 'jellyfin.example.com',
|
||||||
|
'value': this.wizardData.domain || '',
|
||||||
|
'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; border-radius: 4px; color: #fff;'
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
|
||||||
|
E('div', { 'class': 'jf-form-group' }, [
|
||||||
|
E('label', { 'class': 'jf-checkbox' }, [
|
||||||
|
E('input', {
|
||||||
|
'id': 'jf-haproxy', 'type': 'checkbox',
|
||||||
|
'checked': this.wizardData.haproxy
|
||||||
|
}),
|
||||||
|
' ' + _('Enable HAProxy reverse proxy')
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
|
||||||
|
E('div', { 'class': 'jf-form-group' }, [
|
||||||
|
E('label', { 'class': 'jf-checkbox' }, [
|
||||||
|
E('input', {
|
||||||
|
'id': 'jf-acme', 'type': 'checkbox',
|
||||||
|
'checked': this.wizardData.acme
|
||||||
|
}),
|
||||||
|
' ' + _('Request SSL certificate (ACME)')
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
|
||||||
|
E('p', { 'style': 'color: #888; font-size: 12px; margin-top: 16px;' },
|
||||||
|
_('You can configure these settings later from the Network & Domain section.'))
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
|
||||||
|
renderStepComplete: function() {
|
||||||
|
var port = (this.status && this.status.port) || 8096;
|
||||||
|
return E('div', { 'style': 'text-align: center;' }, [
|
||||||
|
E('div', { 'style': 'font-size: 48px; margin-bottom: 16px; color: #27ae60;' }, '\u2713'),
|
||||||
|
E('h3', { 'style': 'margin: 0 0 16px 0;' }, _('Setup Complete!')),
|
||||||
|
E('p', {}, _('Jellyfin is ready. Open the web interface to complete initial configuration:')),
|
||||||
|
E('a', {
|
||||||
|
'href': 'http://' + window.location.hostname + ':' + port,
|
||||||
|
'target': '_blank',
|
||||||
|
'class': 'btn cbi-button-action',
|
||||||
|
'style': 'margin-top: 16px; display: inline-block;'
|
||||||
|
}, _('Open Jellyfin Web UI')),
|
||||||
|
E('p', { 'style': 'color: #888; font-size: 12px; margin-top: 24px;' },
|
||||||
|
_('Click "Finish Setup" to dismiss this wizard.'))
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,7 +8,7 @@ CONFIG="jellyfin"
|
|||||||
|
|
||||||
case "$1" in
|
case "$1" in
|
||||||
list)
|
list)
|
||||||
echo '{"status":{},"start":{},"stop":{},"restart":{},"install":{},"uninstall":{},"update":{},"configure_haproxy":{},"backup":{},"restore":{"path":"str"},"logs":{"lines":"int"}}'
|
echo '{"status":{},"start":{},"stop":{},"restart":{},"install":{},"uninstall":{},"update":{},"configure_haproxy":{},"backup":{},"restore":{"path":"str"},"logs":{"lines":"int"},"get_wizard_status":{},"set_wizard_complete":{"complete":"int"},"add_media_path":{"path":"str","name":"str","type":"str"},"remove_media_path":{"section":"str"},"get_media_paths":{}}'
|
||||||
;;
|
;;
|
||||||
call)
|
call)
|
||||||
case "$2" in
|
case "$2" in
|
||||||
@ -179,6 +179,129 @@ case "$1" in
|
|||||||
json_add_string "logs" "$logs"
|
json_add_string "logs" "$logs"
|
||||||
json_dump
|
json_dump
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
get_wizard_status)
|
||||||
|
json_init
|
||||||
|
|
||||||
|
# Check if container exists and running
|
||||||
|
installed=0
|
||||||
|
running=0
|
||||||
|
if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "^${CONTAINER}$"; then
|
||||||
|
installed=1
|
||||||
|
docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${CONTAINER}$" && running=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if any media paths configured
|
||||||
|
media_count=0
|
||||||
|
for section in $(uci show ${CONFIG} 2>/dev/null | grep "=media$" | cut -d'.' -f2 | cut -d'=' -f1); do
|
||||||
|
media_count=$((media_count + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check if wizard completed
|
||||||
|
wizard_complete=$(uci -q get ${CONFIG}.main.wizard_complete)
|
||||||
|
|
||||||
|
json_add_boolean "installed" "$installed"
|
||||||
|
json_add_boolean "running" "$running"
|
||||||
|
json_add_int "media_count" "$media_count"
|
||||||
|
json_add_boolean "wizard_complete" "${wizard_complete:-0}"
|
||||||
|
|
||||||
|
# Show wizard if installed but not complete
|
||||||
|
show=0
|
||||||
|
[ "$installed" = "1" ] && [ "${wizard_complete:-0}" != "1" ] && show=1
|
||||||
|
json_add_boolean "show_wizard" "$show"
|
||||||
|
|
||||||
|
json_dump
|
||||||
|
;;
|
||||||
|
|
||||||
|
set_wizard_complete)
|
||||||
|
read -r input
|
||||||
|
complete=$(echo "$input" | jsonfilter -e '@.complete' 2>/dev/null)
|
||||||
|
|
||||||
|
uci set ${CONFIG}.main.wizard_complete="${complete:-1}"
|
||||||
|
uci commit ${CONFIG}
|
||||||
|
|
||||||
|
json_init
|
||||||
|
json_add_boolean "success" 1
|
||||||
|
json_dump
|
||||||
|
;;
|
||||||
|
|
||||||
|
add_media_path)
|
||||||
|
read -r input
|
||||||
|
path=$(echo "$input" | jsonfilter -e '@.path' 2>/dev/null)
|
||||||
|
name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null)
|
||||||
|
type=$(echo "$input" | jsonfilter -e '@.type' 2>/dev/null)
|
||||||
|
|
||||||
|
if [ -z "$path" ]; then
|
||||||
|
json_init
|
||||||
|
json_add_boolean "success" 0
|
||||||
|
json_add_string "error" "Path required"
|
||||||
|
json_dump
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generate unique section name
|
||||||
|
section="media_$(echo "${name:-library}" | tr ' ' '_' | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9_')"
|
||||||
|
|
||||||
|
# Ensure unique
|
||||||
|
count=1
|
||||||
|
base_section="$section"
|
||||||
|
while uci -q get "${CONFIG}.${section}" >/dev/null 2>&1; do
|
||||||
|
section="${base_section}_${count}"
|
||||||
|
count=$((count + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
uci set "${CONFIG}.${section}=media"
|
||||||
|
uci set "${CONFIG}.${section}.path=$path"
|
||||||
|
uci set "${CONFIG}.${section}.name=${name:-Library}"
|
||||||
|
uci set "${CONFIG}.${section}.type=${type:-movies}"
|
||||||
|
uci commit ${CONFIG}
|
||||||
|
|
||||||
|
json_init
|
||||||
|
json_add_boolean "success" 1
|
||||||
|
json_add_string "section" "$section"
|
||||||
|
json_dump
|
||||||
|
;;
|
||||||
|
|
||||||
|
remove_media_path)
|
||||||
|
read -r input
|
||||||
|
section=$(echo "$input" | jsonfilter -e '@.section' 2>/dev/null)
|
||||||
|
|
||||||
|
if [ -z "$section" ]; then
|
||||||
|
json_init
|
||||||
|
json_add_boolean "success" 0
|
||||||
|
json_add_string "error" "Section required"
|
||||||
|
json_dump
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
uci delete "${CONFIG}.${section}" 2>/dev/null
|
||||||
|
uci commit ${CONFIG}
|
||||||
|
|
||||||
|
json_init
|
||||||
|
json_add_boolean "success" 1
|
||||||
|
json_dump
|
||||||
|
;;
|
||||||
|
|
||||||
|
get_media_paths)
|
||||||
|
json_init
|
||||||
|
json_add_array "paths"
|
||||||
|
|
||||||
|
for section in $(uci show ${CONFIG} 2>/dev/null | grep "=media$" | cut -d'.' -f2 | cut -d'=' -f1); do
|
||||||
|
path=$(uci -q get "${CONFIG}.${section}.path")
|
||||||
|
name=$(uci -q get "${CONFIG}.${section}.name")
|
||||||
|
type=$(uci -q get "${CONFIG}.${section}.type")
|
||||||
|
|
||||||
|
json_add_object ""
|
||||||
|
json_add_string "section" "$section"
|
||||||
|
json_add_string "path" "$path"
|
||||||
|
json_add_string "name" "${name:-$section}"
|
||||||
|
json_add_string "type" "${type:-movies}"
|
||||||
|
json_close_object
|
||||||
|
done
|
||||||
|
|
||||||
|
json_close_array
|
||||||
|
json_dump
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user