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_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_BIN) ./root/usr/libexec/rpcd/luci.jellyfin $(1)/usr/libexec/rpcd/
|
||||
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: {}
|
||||
});
|
||||
|
||||
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({
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
uci.load('jellyfin'),
|
||||
callStatus()
|
||||
callStatus(),
|
||||
callGetWizardStatus(),
|
||||
callGetMediaPaths()
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var status = data[1] || {};
|
||||
var wizardStatus = data[2] || {};
|
||||
var mediaPaths = data[3] || {};
|
||||
var self = this;
|
||||
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'),
|
||||
_('Free media server for streaming movies, TV shows, music, and photos.'));
|
||||
|
||||
@ -390,5 +450,347 @@ return view.extend({
|
||||
};
|
||||
|
||||
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
|
||||
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)
|
||||
case "$2" in
|
||||
@ -179,6 +179,129 @@ case "$1" in
|
||||
json_add_string "logs" "$logs"
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user