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:
CyberMind-FR 2026-02-05 04:45:26 +01:00
parent b75fbd516c
commit bf3395a6fa
4 changed files with 735 additions and 2 deletions

View File

@ -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

View File

@ -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;
}
}

View File

@ -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.'))
]);
}
});

View File

@ -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