From bf3395a6fa19b827c16b847d4dc208e2032408d0 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 5 Feb 2026 04:45:26 +0100 Subject: [PATCH] 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 --- package/secubox/luci-app-jellyfin/Makefile | 3 + .../luci-static/resources/jellyfin/wizard.css | 205 +++++++++ .../resources/view/jellyfin/overview.js | 404 +++++++++++++++++- .../root/usr/libexec/rpcd/luci.jellyfin | 125 +++++- 4 files changed, 735 insertions(+), 2 deletions(-) create mode 100644 package/secubox/luci-app-jellyfin/htdocs/luci-static/resources/jellyfin/wizard.css diff --git a/package/secubox/luci-app-jellyfin/Makefile b/package/secubox/luci-app-jellyfin/Makefile index a3bdd083..5f249915 100644 --- a/package/secubox/luci-app-jellyfin/Makefile +++ b/package/secubox/luci-app-jellyfin/Makefile @@ -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 diff --git a/package/secubox/luci-app-jellyfin/htdocs/luci-static/resources/jellyfin/wizard.css b/package/secubox/luci-app-jellyfin/htdocs/luci-static/resources/jellyfin/wizard.css new file mode 100644 index 00000000..b7b29dc4 --- /dev/null +++ b/package/secubox/luci-app-jellyfin/htdocs/luci-static/resources/jellyfin/wizard.css @@ -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; + } +} diff --git a/package/secubox/luci-app-jellyfin/htdocs/luci-static/resources/view/jellyfin/overview.js b/package/secubox/luci-app-jellyfin/htdocs/luci-static/resources/view/jellyfin/overview.js index 90f5918a..f9102edc 100644 --- a/package/secubox/luci-app-jellyfin/htdocs/luci-static/resources/view/jellyfin/overview.js +++ b/package/secubox/luci-app-jellyfin/htdocs/luci-static/resources/view/jellyfin/overview.js @@ -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.')) + ]); } }); diff --git a/package/secubox/luci-app-jellyfin/root/usr/libexec/rpcd/luci.jellyfin b/package/secubox/luci-app-jellyfin/root/usr/libexec/rpcd/luci.jellyfin index 6df1e18e..01b319d1 100644 --- a/package/secubox/luci-app-jellyfin/root/usr/libexec/rpcd/luci.jellyfin +++ b/package/secubox/luci-app-jellyfin/root/usr/libexec/rpcd/luci.jellyfin @@ -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