Files
Magent/scripts/review_insights_ui.cjs
Assclaw e7e4c9eff3
Magent CI/CD / verify (push) Successful in 10m43s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m9s
Fix viewing-history artwork and add transcode playback metrics
2026-09-08 16:15:16 +12:00

158 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Fixture-only review. No requests reach Jellystat or a live Magent backend.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3114'
const output = process.env.REVIEW_DIR
;(async () => {
const browser = await chromium.launch({ headless: true })
try {
const context = await browser.newContext()
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }])
let mode = 'ready'
let role = 'admin'
let brokenArtwork = false
const periods = []
const mutations = []
let settings = [
{ key: 'jellystat_base_url', value: 'http://jellystat:3000', isSet: true, source: 'environment', sensitive: false },
{ key: 'jellystat_api_key', value: null, isSet: true, source: 'environment', sensitive: true },
]
const daily = Array.from({ length: 31 }, (_, i) => ({ date: new Date(Date.UTC(2026, 7, 8 + i)).toISOString().slice(0, 10), minutes: i % 4 ? 30 + i * 2 : 0 }))
const fixture = (days) => ({
state: mode, is_admin: role === 'admin', days, source: 'Jellystat', timezone: 'UTC', updated_at: '2026-09-07T12:00:00Z',
summary: { minutes: daily.reduce((sum, day) => sum + day.minutes, 0), movies: 8, episodes: 23, plays: 35, active_days: 21, current_streak: 3, longest_streak: 6 },
daily,
top_titles: [{ title: 'Severance', type: 'series', minutes: 460, plays: 10 }, { title: 'Arrival', type: 'movie', minutes: 116, plays: 1 }, { title: 'The Bear', type: 'series', minutes: 91, plays: 3 }],
clients: [{ name: 'Jellyfin Web', minutes: 740 }, { name: 'Jellyfin for Android TV', minutes: 301 }],
methods: [{ name: 'Direct play', minutes: 880 }, { name: 'Transcode', minutes: 161 }],
transcoding: { video_minutes: 100, audio_minutes: 61, hardware_video_minutes: 80, software_video_minutes: 20, unknown_hardware_minutes: 0, unknown_video_minutes: 0, unknown_audio_minutes: 0, hardware: [{ name: 'NVIDIA NVENC', minutes: 80 }], audio_codecs: [{ name: 'AAC', minutes: 61 }], gpu_busy_minutes: null },
recent: [{ id: '1', title: 'Good News About Hell', series: 'Severance', type: 'episode', episode: 'S1 · E1', minutes: 57, played_at: '2026-09-07T10:00:00Z', client: 'Jellyfin Web', method: 'Direct play', artwork_url: '/insights/artwork/series?token=fixture' },
{ id: '2', title: 'Arrival', series: '', type: 'movie', minutes: 116, played_at: '2026-09-06T10:00:00Z', client: 'Jellyfin for Android TV', method: 'Direct play', artwork_url: '/insights/artwork/movie?token=fixture' }],
requests: { total: 3, movies: 2, tv: 1, pending: 1, approved: 2, declined: 0, recent: [{ request_id: 12, title: 'Dune: Part Two', media_type: 'movie', status: 2 }] },
})
await context.route('**/api/**', async (route) => {
const request = route.request()
const url = new URL(request.url())
if (url.pathname === '/api/auth/me') return route.fulfill({ json: { username: 'Fixture viewer', role } })
if (url.pathname.startsWith('/api/insights/artwork/')) {
if (brokenArtwork) return route.fulfill({ status: 404, body: 'Artwork unavailable' })
return route.fulfill({ contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 180"><rect width="120" height="180" fill="#243946"/><path d="M0 150L65 35L120 150" fill="#c8bdaa"/><circle cx="75" cy="42" r="20" fill="#dbe6de"/></svg>' })
}
if (url.pathname === '/api/insights') {
const days = Number(url.searchParams.get('days'))
periods.push(days)
if (mode === 'unauthorized') return route.fulfill({ status: 401, json: { detail: 'Sign in' } })
if (mode === 'unavailable') return route.fulfill({ status: 502, json: { detail: 'Your viewing stats are temporarily unavailable. Please try again shortly.' } })
const result = fixture(days)
if (mode === 'empty') {
result.state = 'ready'
for (const key of Object.keys(result.summary)) result.summary[key] = 0
result.daily = daily.map((day) => ({ ...day, minutes: 0 }))
result.top_titles = result.recent = result.clients = result.methods = []
} else if (mode === 'missing_transcoding') {
for (const key of Object.keys(result.transcoding)) {
if (typeof result.transcoding[key] === 'number') result.transcoding[key] = 0
}
result.transcoding.hardware = result.transcoding.audio_codecs = []
result.transcoding.unknown_video_minutes = result.transcoding.unknown_audio_minutes = 161
} else if (mode !== 'ready') result.summary = null
return route.fulfill({ json: result })
}
if (url.pathname === '/api/admin/settings') {
if (request.method() === 'PUT') {
const body = request.postDataJSON()
mutations.push({ path: url.pathname, body })
settings = settings.map((setting) => Object.hasOwn(body, setting.key) ? { ...setting, value: setting.sensitive ? null : body[setting.key], isSet: true, source: 'database' } : setting)
return route.fulfill({ json: { updated: Object.keys(body).length } })
}
return route.fulfill({ json: { settings } })
}
if (url.pathname === '/api/status/services/jellystat/test') {
mutations.push({ path: url.pathname })
return route.fulfill({ json: { name: 'Jellystat', status: 'up', detail: { connected: true } } })
}
if (url.pathname === '/api/status/services') return route.fulfill({ json: { services: [{ name: 'Jellystat', status: 'up' }] } })
if (url.pathname.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' })
return route.fulfill({ json: {} })
})
const page = await context.newPage()
const errors = []
page.on('pageerror', (error) => errors.push(error.message))
for (const width of [1440, 980, 390, 320]) {
await page.setViewportSize({ width, height: 1000 })
await page.goto(base + '/insights')
await page.getByRole('heading', { name: 'Your viewing rhythm' }).waitFor()
await page.locator('.stats-history').scrollIntoViewIfNeeded()
await page.waitForFunction(() => [...document.querySelectorAll('.stats-media-icon img')].length === 2 && [...document.querySelectorAll('.stats-media-icon img')].every((img) => img.complete && img.naturalWidth > 0))
await page.evaluate(() => window.scrollTo(0, 0))
assert.match(await page.locator('.stats-transcode-metrics').innerText(), /GPU-assisted video[\s\S]*80 min[\s\S]*Audio transcoding[\s\S]*61 min/)
assert.match(await page.locator('.stats-transcoding').innerText(), /GPU busy time is not recorded/)
assert.notEqual(await page.locator('.stats-period [aria-pressed=true]').evaluate((element) => getComputedStyle(element).backgroundColor), await page.locator('.stats-period [aria-pressed=false]').first().evaluate((element) => getComputedStyle(element).backgroundColor), 'Selected period must be visibly different')
await page.locator('.stats-chart-bars button').first().click()
assert.match(await page.locator('.stats-chart-detail').innerText(), /minutes/)
assert.equal(await page.getByRole('link', { name: 'Dune: Part Two' }).getAttribute('href'), '/requests/12')
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `Overflow at ${width}px`)
if (width <= 980) {
await page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: 'Config' }).waitFor({ state: 'visible' })
await page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: 'Stats' }).waitFor({ state: 'visible' })
}
if (output) {
fs.mkdirSync(output, { recursive: true })
await page.screenshot({ path: path.join(output, `insights-${width}.png`), fullPage: true })
}
}
brokenArtwork = true
await page.reload()
await page.locator('.stats-history').scrollIntoViewIfNeeded()
await page.getByText('MV', { exact: true }).waitFor()
await page.waitForFunction(() => document.querySelectorAll('.stats-media-icon img').length === 0)
brokenArtwork = false
mode = 'missing_transcoding'
await page.reload()
await page.getByText('Some stream details are missing:', { exact: false }).waitFor()
assert.equal(await page.locator('.stats-transcoding').getByText('Not recorded', { exact: true }).count(), 3)
mode = 'ready'
await page.getByRole('button', { name: '90 days', exact: true }).click()
await page.getByRole('heading', { name: 'Your viewing rhythm' }).waitFor()
assert.equal(periods.at(-1), 90)
for (const [state, text] of [['empty', 'No viewing history in this period yet.'], ['not_configured', 'Your viewing story starts here'], ['unlinked', 'Link your viewing account'], ['unavailable', 'Stats couldnt load']]) {
mode = state
await page.reload()
await page.getByText(text, { exact: false }).waitFor()
if (state === 'not_configured') assert.equal(await page.getByRole('link', { name: 'Connect Jellystat' }).getAttribute('href'), '/admin/jellystat')
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth))
}
mode = 'not_configured'
role = 'user'
await page.reload()
await page.getByText('Viewing stats will appear here once your administrator connects Jellystat.').waitFor()
assert.equal(await page.getByRole('link', { name: 'Connect Jellystat' }).count(), 0)
mode = 'unauthorized'
await page.reload()
await page.waitForURL('**/login?next=%2Finsights')
role = 'admin'
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 1000 })
await page.goto(base + '/admin/jellystat')
await page.getByRole('heading', { name: 'Jellystat', exact: true }).waitFor()
await page.getByRole('button', { name: 'Test connection', exact: true }).waitFor()
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth))
if (output) await page.screenshot({ path: path.join(output, `jellystat-settings-${width}.png`), fullPage: true })
}
await page.getByRole('button', { name: 'Test connection', exact: true }).click()
await page.getByText('Jellystat connection test passed.').waitFor()
assert(mutations.some((request) => request.path === '/api/status/services/jellystat/test'))
const urlInput = page.locator('#setting-jellystat_base_url')
await urlInput.fill('http://jellystat:3001')
await page.getByRole('button', { name: 'Save changes', exact: true }).click()
await page.getByText('Saved', { exact: true }).first().waitFor()
assert(mutations.some((request) => request.body?.jellystat_base_url === 'http://jellystat:3001'))
assert(mutations.filter((request) => request.body).every((request) => !Object.hasOwn(request.body, 'jellystat_api_key')), 'An unchanged secret must be preserved')
assert.deepEqual(errors, [])
console.log('Insights browser checks passed: desktop/mobile, posters/fallback, transcode totals/missing metadata, chart, period, requests, empty/setup/unlinked/error/auth states, settings save/test, preserved secret.')
} finally { await browser.close() }
})().catch((error) => { console.error(error); process.exit(1) })