Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@

### Changed

- **Global Settings now follows the same guided structure as each library.**
Numbered, task-oriented sections give General, Connections, Notifications,
Tools, and Backup a consistent reading order, while the first-run setup
action now lives with backup and restore controls. Settings tabs expose
standard keyboard navigation, mobile actions meet touch-target guidance, and
long tool paths, statuses, and encoder names wrap inside the viewport instead
of overflowing narrow or landscape layouts.
- **Scott's Settings now matches the current reference encoding bundle.** The preset still targets
HEVC/MP4 at CRF 24 with AAC 96 kbps stereo, but now tone-maps HDR to SDR instead of preserving it.
The global tone-map setting continues to choose compatible software or supported hardware.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,8 @@ mounted paths, free space, filesystem/mount relationships, permissions, and medi
or inaccessible storage gets concrete Docker Compose, Unraid, TrueNAS, or local recovery steps and
a real Re-test action. Setup lets you fully configure as many libraries as needed and starts in
**Dry-run mode**. Completing setup never starts a scan or job; review each library’s Candidates,
then scan and queue a small test set deliberately. Use **Run setup again** in the Settings header to
revisit the guided checks without deleting existing configuration.
then scan and queue a small test set deliberately. Use **Settings → Backup → First-run setup → Run
setup again** to revisit the guided checks without deleting existing configuration.
Add libraries from `/data/media`; the hidden work and quarantine directories remain below the same
container mount boundary.
Compose examples are available for every supported runtime:
Expand Down
Binary file modified docs/images/optimisarr-settings-backup-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/optimisarr-settings-connections-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/optimisarr-settings-general-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/optimisarr-settings-notifications-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/optimisarr-settings-tools-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@ the replacement workflow is trustworthy.
a genuinely new database from an upgrade, persists each completed step, resumes after refresh or
restart, accepts duplicate progress writes idempotently, and permits completion only from final
review. Upgraded installations are marked complete and never forced into onboarding. Back retains
applied choices, and the Settings header offers **Run setup again** without deleting configuration.
applied choices, and **Settings → Backup → First-run setup** offers **Run setup again** without
deleting configuration.
Connections remain explicitly skipped on the final review and can be added later, so an optional
provider can never block first use.
- **Five stable, task-oriented steps: done.** One heading and primary action drive:
Expand Down
7 changes: 4 additions & 3 deletions docs/setup/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ replace, or delete a file.
Fresh installations start in dry-run with one concurrent job. Every new library has automatic
enqueue, automatic replacement, and VMAF disabled unless changed explicitly in its editor. Existing
installations upgraded from an older release never see the wizard automatically. To revisit it
without deleting or resetting any configuration, use **Run setup again** in the Settings header.
without deleting or resetting any configuration, use **Settings → Backup → First-run setup → Run
setup again**.

![Settings General tab showing queue, encoder, scan interval, disk threshold, and hardware decode controls](../images/optimisarr-settings-general-dark.png)
![Settings General tab grouping queue controls separately from replacement and cleanup policy](../images/optimisarr-settings-general-dark.png)

## Admin token

Expand Down Expand Up @@ -324,7 +325,7 @@ includes libraries, activity watchers, notification targets, Arr connections,
and provider credentials in plain text. Store it as sensitive material: do not
commit, share, or leave it in an unprotected download directory.

![Backup tab showing export and import controls plus the sensitive-data warning](../images/optimisarr-settings-backup-dark.png)
![Backup tab separating configuration export and import from the first-run setup action](../images/optimisarr-settings-backup-dark.png)

Import validates the complete file before writing, then merges configuration
without deleting existing entries. It intentionally does not include media,
Expand Down
2 changes: 1 addition & 1 deletion web/e2e/notifications.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ async function mockSettings(page: Page, targets: unknown[]) {

async function openNotifications(page: Page) {
await page.goto('/#/settings')
await page.getByRole('button', { name: 'Notifications' }).click()
await page.getByRole('tab', { name: 'Notifications' }).click()
}

test('notification validation shows the provider-specific API reason', async ({ page }) => {
Expand Down
163 changes: 163 additions & 0 deletions web/e2e/settings.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { expect, test, type Page, type Route } from '@playwright/test'

const settings = {
maxConcurrentJobs: 1,
minFreeDiskBytes: 10_737_418_240,
cpuThreadLimit: 0,
libraryScanIntervalHours: 1,
encoderMode: 'Auto',
hardwareDecode: true,
hdrToneMapMode: 'Software',
replacementAllowCrossFilesystem: false,
dryRunMode: true,
replacementQuarantineRetentionDays: 14,
}

const tools = [
{
name: 'FFmpeg',
command: '/usr/lib/jellyfin-ffmpeg/ffmpeg',
available: true,
required: true,
version: 'ffmpeg version 7.1.4-Jellyfin Copyright (c) 2000-2026 the FFmpeg developers',
error: null,
},
{
name: 'FFmpeg (VMAF)',
command: '/usr/local/lib/optimisarr/ffmpeg-vmaf',
available: true,
required: false,
version: 'libvmaf filter available',
error: null,
},
{
name: 'ffprobe',
command: '/usr/lib/jellyfin-ffmpeg/ffprobe',
available: true,
required: true,
version: 'ffprobe version 7.1.4-Jellyfin Copyright (c) 2007-2026 the FFmpeg developers',
error: null,
},
]

const hardware = {
hardwareAccelerators: ['cuda', 'vaapi', 'qsv', 'drm', 'opencl', 'vulkan'],
encoders: [
{ name: 'h264_qsv', codec: 'h264', mode: 'Intel QSV', available: true },
{ name: 'hevc_qsv', codec: 'hevc', mode: 'Intel QSV', available: true },
{ name: 'av1_qsv', codec: 'av1', mode: 'Intel QSV', available: false },
],
nvidiaRuntimeAvailable: false,
driDeviceAvailable: true,
error: null,
}

function json(route: Route, body: unknown, status = 200) {
return route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
}

async function mockSettings(page: Page) {
await page.route('**/api/**', async (route) => {
const path = new URL(route.request().url()).pathname
if (path === '/api/auth/status') return json(route, { required: false })
if (path === '/api/setup') return json(route, {
version: 1, completedStep: 5, currentStep: 5, stepCount: 5, completed: true,
})
if (path === '/api/health') return json(route, { status: 'healthy', service: 'optimisarr', version: 'test' })
if (path === '/api/settings') return json(route, settings)
if (path === '/api/settings/cleanup') return json(route, {
retentionDays: 14, dryRunMode: true, failedOutputCount: 2, failedOutputBytes: 2_147_483_648,
quarantinedOriginalCount: 1, quarantinedOriginalBytes: 4_294_967_296,
planToken: 'test', totalCount: 3, totalBytes: 6_442_450_944,
})
if (path === '/api/activity-watchers' || path === '/api/arr-connections' || path === '/api/notification-targets') {
return json(route, [])
}
if (path === '/api/system/tools') return json(route, { tools })
if (path === '/api/system/hardware') return json(route, { hardware })
return json(route, {})
})
}

test('global settings use the same logical section flow as library configuration', async ({ page }) => {
await mockSettings(page)
await page.goto('/#/settings')

const expectedSections = new Map([
['General', ['Queue', 'Replacement and cleanup']],
['Connections', ['Media servers', 'Download managers']],
['Notifications', ['Notifications']],
['Tools', ['Tools', 'Hardware acceleration', 'Encoders']],
['Backup', ['Backup & restore', 'First-run setup']],
])

for (const [tab, headings] of expectedSections) {
await page.getByRole('tab', { name: tab }).click()
const sections = page.locator('[role="tabpanel"] [data-config-section]')
await expect(sections.getByRole('heading', { level: 2 })).toHaveText(headings)
}

await expect(page.getByRole('button', { name: 'Run setup again' })).toBeVisible()

await page.getByRole('tab', { name: 'General' }).click()
await page.keyboard.press('ArrowRight')
await expect(page.getByRole('tab', { name: 'Connections' })).toHaveAttribute('aria-selected', 'true')
await expect(page.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', 'settings-tab-connections')
})

test('settings and tool capability cards stay within a small mobile viewport', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 })
await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: 'dark' })
await mockSettings(page)
await page.goto('/#/settings')
await page.locator('html').evaluate((element) => {
element.style.fontSize = '125%'
})
const toolsTab = page.getByRole('tab', { name: 'Tools' })
await toolsTab.click()
await expect(toolsTab).toBeInViewport()

const fit = await page.locator('main').evaluate((main) => ({
scrollWidth: main.scrollWidth,
clientWidth: main.clientWidth,
}))
expect(fit.scrollWidth).toBeLessThanOrEqual(fit.clientWidth)

const cards = page.locator('[data-tool-card]')
await expect(cards).toHaveCount(3)
for (const card of await cards.all()) {
const cardFit = await card.evaluate((element) => {
const cardRect = element.getBoundingClientRect()
const mainRect = element.closest('main')!.getBoundingClientRect()
return {
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth,
left: cardRect.left,
right: cardRect.right,
mainLeft: mainRect.left,
mainRight: mainRect.right,
}
})
expect(cardFit.scrollWidth).toBeLessThanOrEqual(cardFit.clientWidth)
expect(cardFit.left).toBeGreaterThanOrEqual(cardFit.mainLeft)
expect(cardFit.right).toBeLessThanOrEqual(cardFit.mainRight)
}

const refreshBox = await page.getByRole('button', { name: 'Refresh' }).boundingBox()
expect(refreshBox?.height).toBeGreaterThanOrEqual(44)
})

test('every settings tab reflows without horizontal page overflow', async ({ page }) => {
await page.setViewportSize({ width: 812, height: 375 })
await mockSettings(page)
await page.goto('/#/settings')

for (const tab of ['General', 'Connections', 'Notifications', 'Tools', 'Backup']) {
await page.getByRole('tab', { name: tab }).click()
const fit = await page.locator('main').evaluate((main) => ({
scrollWidth: main.scrollWidth,
clientWidth: main.clientWidth,
}))
expect(fit.scrollWidth).toBeLessThanOrEqual(fit.clientWidth)
}
})
Loading
Loading