Skip to content
Merged
179 changes: 177 additions & 2 deletions e2e/nzb.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { createServer, type Server } from 'node:http'
import { connect as netConnect, createServer as createTcpServer, type AddressInfo } from 'node:net'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { NzbDownloadState, UsenetServerInput } from '../src/shared/types'
import { expect, LAN_ADDRESS, test, type PlexoApp } from './fixtures'
import type { Locator } from '@playwright/test'
import { expect, expectNzb, LAN_ADDRESS, test, type PlexoApp } from './fixtures'
import { NewsServer, type NewsServerOptions } from './newsServer'
import { seededBytes } from './origin'

Expand Down Expand Up @@ -386,6 +387,180 @@ test.describe('NZB downloads', () => {
})
})

/** Speed & data limits, on its Usenet page. */
async function openUsenetPage(plexo: PlexoApp): Promise<Locator> {
await plexo.page.getByRole('button', { name: /^\d+ networks?/ }).click()
await plexo.page.getByRole('button', { name: 'Speed & data limits', exact: true }).click()
const dialog = plexo.page.getByRole('dialog', { name: 'Speed & data limits' })
await dialog.getByRole('button', { name: /^Usenet/ }).click()
return dialog
}

/** An indexer answering every request with `server`'s NZB, as an NZB. */
async function indexerFor(server: NewsServer): Promise<{ link: string; close: () => void }> {
const indexer: Server = createServer((_request, response) => {
response.writeHead(200, {
'Content-Type': 'application/x-nzb',
'Content-Disposition': 'attachment; filename="Indexer.Release.nzb"'
})
response.end(server.nzb())
})
await new Promise<void>((resolve) => indexer.listen(0, '127.0.0.1', resolve))
const { port } = indexer.address() as AddressInfo
return { link: `http://127.0.0.1:${port}/api?t=get&id=1`, close: () => indexer.close() }
}

test.describe('Usenet settings', () => {
test('the switch in Speed & data limits is saved with the server and kept on restart', async ({
plexo
}) => {
const server = await newsServer()
const file = join(plexo.dirs.userData, 'usenet-server.json')
// Saved before there was a switch: on. Its password sealed by a keychain this session can't
// open (another login's, say), so it reads as none.
const password = Buffer.from('sealed elsewhere').toString('base64')
await writeFile(
file,
JSON.stringify({ host: '127.0.0.1', port: server.port, tls: false, connections: 3, password })
)
await plexo.relaunch()
expect((await plexo.api.usenetServer())?.enabled).toBe(true)

let dialog = await openUsenetPage(plexo)
await expect(dialog.getByText(`127.0.0.1:${server.port}`)).toBeVisible()
await dialog.getByRole('switch', { name: 'Usenet downloads' }).click()
// Nothing changes until Save.
expect((await plexo.api.usenetServer())?.enabled).toBe(true)
await dialog.getByRole('button', { name: 'Save', exact: true }).click()
await expect(dialog).toBeHidden()
expect(await plexo.api.usenetServer()).toMatchObject({ enabled: false, connections: 3 })
// Kept for a session that can unseal it.
expect(JSON.parse(await readFile(file, 'utf-8')).password).toBe(password)

await plexo.relaunch()
expect((await plexo.api.usenetServer())?.enabled).toBe(false)
dialog = await openUsenetPage(plexo)
await expect(dialog.getByRole('switch', { name: 'Usenet downloads' })).not.toBeChecked()
// Saving the server from its own form leaves the switch as it is.
await dialog.getByRole('button', { name: 'Change server' }).click()
const form = plexo.page.getByRole('dialog', { name: 'Usenet server' })
await form.getByLabel('Connections').fill('5')
await form.getByRole('button', { name: 'Save' }).click()
await expect(form).toBeHidden()
expect(await plexo.api.usenetServer()).toMatchObject({ enabled: false, connections: 5 })
// Its password field left blank, which can't show a password this session can't unseal.
expect(JSON.parse(await readFile(file, 'utf-8')).password).toBe(password)
})

test('off, an NZB is no Usenet download, and a running one stops connecting and waits', async ({
plexo
}) => {
const server = await newsServer({ delayMs: 40 })
await useServer(plexo, server, { connections: 2 })
const nzb = await nzbOnDisk(server)
const id = await plexo.start(nzb, server.expectedSha())
await plexo.waitUntil((state) => state.kind === 'nzb' && countDone(state) >= 2)

await useServer(plexo, server, { connections: 2, enabled: false })
await expect
.poll(async () => (await plexo.byId(id))?.status, { timeout: 10_000 })
.toBe('queued')
await new Promise((resolve) => setTimeout(resolve, 200))
const served = server.served.length
// Paused and resumed while off, it fails before it connects.
await plexo.api.pauseDownload(id)
await plexo.api.resumeDownload(id)
await expect.poll(async () => (await plexo.byId(id))?.status, { timeout: 10_000 }).toBe('error')
expect((await plexo.byId(id))?.error).toMatch(/Usenet downloads are off/)
expect(server.served.length).toBe(served)

// An NZB file can't be downloaded; one by link is a plain file, for another newsreader.
await expect(plexo.api.probeUrl(nzb)).rejects.toThrow(/Usenet downloads are off/)
const indexer = await indexerFor(server)
try {
const probe = await plexo.api.probeUrl(indexer.link)
expect(probe.kind).toBe('http')
expect(probe.suggestedFileName).toBe('Indexer.Release.nzb')
} finally {
indexer.close()
}

// Back on, the download picks up where it stopped.
await useServer(plexo, server, { connections: 2, enabled: true })
await plexo.api.resumeDownload(id)
await plexo.waitForNzbStatus('completed', 30_000)
})

test('off, running and queued NZBs wait rather than fail, and start once it’s back on', async ({
plexo
}) => {
await plexo.api.updateSettings({ downloadsAtOnce: 1 })
const server = await newsServer({ delayMs: 40 })
await useServer(plexo, server, { connections: 2 })
const running = await plexo.start(
await nzbOnDisk(server, 'First.Release.nzb'),
server.expectedSha()
)
await plexo.waitUntil((state) => state.id === running && countDone(expectNzb(state)) >= 2)
const queued = await plexo.start(
await nzbOnDisk(server, 'Second.Release.nzb'),
server.expectedSha()
)
expect((await plexo.byId(queued))?.status).toBe('queued')

await useServer(plexo, server, { connections: 2, enabled: false })
// The running one goes back in the queue, which makes room (see pause): the queued one still
// waits.
await expect
.poll(async () => (await plexo.byId(running))?.status, { timeout: 10_000 })
.toBe('queued')
await new Promise((resolve) => setTimeout(resolve, 200))
const waiting = expectNzb((await plexo.byId(queued))!)
expect(waiting.status).toBe('queued')
expect(countDone(waiting)).toBe(0)
expect(waiting.streams).toHaveLength(0)
expect((await plexo.byId(running))?.status).toBe('queued')

// Back on, both start without being asked.
await useServer(plexo, server, { connections: 2, enabled: true })
for (const id of [running, queued]) {
await expect
.poll(async () => (await plexo.byId(id))?.status, { timeout: 30_000 })
.toBe('completed')
}
})

test('the news server is set up from Speed & data limits', async ({ plexo }) => {
const server = await newsServer()
const dialog = await openUsenetPage(plexo)
await expect(dialog.getByText('No news server set up')).toBeVisible()
await expect(dialog.getByRole('switch', { name: 'Usenet downloads' })).toBeDisabled()
await dialog.getByRole('button', { name: 'Set up server' }).click()

const form = plexo.page.getByRole('dialog', { name: 'Usenet server' })
await form.getByLabel('Server').fill('127.0.0.1')
await form.getByRole('switch').click()
await form.getByLabel('Port').fill(String(server.port))
await form.getByLabel('Connections').fill('2')
await form.getByRole('button', { name: 'Save' }).click()
await expect(form).toBeHidden()

await expect(dialog.getByText(`127.0.0.1:${server.port}`)).toBeVisible()
await expect(dialog.getByRole('switch', { name: 'Usenet downloads' })).toBeChecked()
// Saved already: the server's own Save is what counts, not Cancel here.
await dialog.getByRole('button', { name: 'Cancel' }).click()
expect(await plexo.api.usenetServer()).toMatchObject({
enabled: true,
host: '127.0.0.1',
port: server.port,
tls: false,
connections: 2
})
await plexo.start(await nzbOnDisk(server), server.expectedSha())
await plexo.waitForNzbStatus('completed', 30_000)
})
})

function countDone(state: NzbDownloadState): number {
return state.blocks.filter((block) => block.status === 'completed').length
}
44 changes: 36 additions & 8 deletions src/main/download/downloadManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ import {
type ParsedNzb
} from './nzb/nzbFile'
import { NzbTransfer } from './nzb/nzbTransfer'
import { loadUsenetServer } from './nzb/usenetServer'
import { loadUsenetServer, USENET_OFF, usenetEnabled } from './nzb/usenetServer'
import {
clearSpeeds,
delay,
Expand Down Expand Up @@ -437,6 +437,8 @@ export class DownloadManager {
private awakeBlocker: number | null = null
/** How many downloads may run at once; the rest wait, queued (see pump). */
private downloadsAtOnce = DOWNLOADS_AT_ONCE.default
/** The Usenet switch, as last saved: while it's off, queued NZBs wait rather than start. */
private usenetOn = true
/** Speed and data limits, for every download together. */
readonly limits = new Limits(() => this.limitsChanged())

Expand All @@ -445,12 +447,14 @@ export class DownloadManager {
private networks: NetworkMonitor,
private readonly telegram: TelegramServiceApi
) {
this.initialization = Promise.all([this.restorePersistedDownloads(), this.limits.loaded]).then(
async () => {
// Read after recovery, including any settings saved while the manifests were loading.
await this.configureSettings(await loadSettings())
}
)
this.initialization = Promise.all([
this.restorePersistedDownloads(),
this.limits.loaded,
usenetEnabled().then((on) => (this.usenetOn = on))
]).then(async () => {
// Read after recovery, including any settings saved while the manifests were loading.
await this.configureSettings(await loadSettings())
})
}

/** Takes up what the user set: how many run at once, and the limits. One running past a
Expand Down Expand Up @@ -613,6 +617,8 @@ export class DownloadManager {
.sort((a, b) => (a.state.queuedAt ?? 0) - (b.state.queuedAt ?? 0))
for (const runtime of queued) {
if (this.runningCount() >= this.downloadsAtOnce) return
// Each would only fail on the switch, making room for the next to fail too.
if (runtime.kind === 'nzb' && !this.usenetOn) continue
this.begin(runtime)
}
}
Expand Down Expand Up @@ -1323,6 +1329,7 @@ export class DownloadManager {
if (!nzbFile) throw new Error('Add the NZB again to start downloading.')
const server = await loadUsenetServer()
if (!server) throw new Error('Set up a news server to download from Usenet.')
if (!server.enabled) throw new Error(USENET_OFF)
const nzb = parseNzb(nzbFile)
const usable = compatibleInterfaces(
selected,
Expand Down Expand Up @@ -1745,6 +1752,24 @@ export class DownloadManager {
}
}

/** The Usenet switch was saved. Off, the NZBs running stop rather than keep connecting and go
* back in the queue, as the download schedule does, to wait with the queued ones (see pump); a
* paused one resumed fails before it connects (see NzbTransfer.reconcile). On, the queue
* starts. */
usenetSwitched(on: boolean): void {
this.usenetOn = on
if (on) {
this.pump()
return
}
for (const runtime of this.runtimes.values()) {
if (runtime.kind !== 'nzb') continue
void this.pause(runtime.state.id, true).catch((error) =>
console.error('[plexo] Usenet switched off', error)
)
}
}

/** The computer woke from sleep. Its sockets are likely dead, though nothing will say so until
* a stall watchdog runs out, and every judgement made by the clock (a silent network, a
* crawling connection) spans the sleep. All of it starts over. */
Expand Down Expand Up @@ -2215,7 +2240,10 @@ export class DownloadManager {
runtime.state.status = 'error'
runtime.state.error = message
runtime.state.resumable = !discard
this.notify('Download failed', `${runtime.state.fileName}: ${describeError(message)}`)
// Switched off by the user, who needs no telling; the download's error still says so.
if (message !== USENET_OFF) {
this.notify('Download failed', `${runtime.state.fileName}: ${describeError(message)}`)
}
this.stopRun(runtime)
}

Expand Down
6 changes: 5 additions & 1 deletion src/main/download/nzb/nzbTransfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
} from '../transfer'
import { ArticleMissingError, NntpAuthError, NntpConnection, NntpRefusedError } from './nntp'
import type { NzbSegment } from './nzbFile'
import type { UsenetServerConfig } from './usenetServer'
import { USENET_OFF, type UsenetServerConfig } from './usenetServer'
import { decodeYenc, YencError } from './yenc'

// Set PLEXO_DEBUG=1 to log every article's outcome.
Expand Down Expand Up @@ -120,6 +120,10 @@ export class NzbTransfer implements Transfer {
this.host.failDownload('Set up a news server in Plexo to download from Usenet.')
return
}
if (!server.enabled) {
this.host.failDownload(USENET_OFF)
return
}
this.server = server
this.allowed = server.connections
this.host.reconcile()
Expand Down
29 changes: 25 additions & 4 deletions src/main/download/nzb/usenetServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface UsenetServerConfig extends Omit<UsenetServer, 'hasPassword' | '
export const MAX_CONNECTIONS = 100
export const DEFAULT_CONNECTIONS = 8

export const USENET_OFF = 'Usenet downloads are off. Turn them on in Speed & data limits.'

function serverPath(): string {
return join(app.getPath('userData'), 'usenet-server.json')
}
Expand Down Expand Up @@ -42,6 +44,8 @@ export function checkedServer(value: unknown): Omit<UsenetServerConfig, 'passwor
const username = typeof value.username === 'string' ? value.username.trim() : ''
if (/[\r\n]/.test(username)) return null
return {
// Saved before there was a switch: on.
enabled: value.enabled !== false,
host: host.replace(/^\[|\]$/g, ''),
port: Number.isInteger(port) && port > 0 && port < 65536 ? port : tls ? 563 : 119,
tls,
Expand Down Expand Up @@ -75,15 +79,26 @@ export function viewOf(server: UsenetServerConfig): UsenetServer {
return { ...rest, hasPassword: password !== '', ...(!canSeal() && { passwordInMemory: true }) }
}

/** The server as `input` sets it: the password it gives, or the one saved when it gives none. */
/** Whether NZBs download from Usenet: on until switched off, which takes a server set up. */
export async function usenetEnabled(): Promise<boolean> {
// Only the switch: no password to unseal for it.
return checkedServer(await readJson(serverPath()).catch(() => undefined))?.enabled !== false
}

/** The server as `input` sets it: the password and switch it gives, or the ones saved when it
* gives none. */
export async function serverFromInput(input: UsenetServerInput): Promise<UsenetServerConfig> {
const server = checkedServer(input)
if (!server) throw new Error('Enter the news server’s address')
if (input.password !== undefined && /[\r\n]/.test(input.password)) {
throw new Error('A password can’t span lines')
}
const password = input.password ?? (await loadUsenetServer())?.password ?? ''
return { ...server, password }
const saved = await loadUsenetServer()
return {
...server,
enabled: input.enabled === undefined ? (saved?.enabled ?? true) : server.enabled,
password: input.password ?? saved?.password ?? ''
}
}

export async function saveUsenetServer(input: UsenetServerInput): Promise<UsenetServer> {
Expand All @@ -98,6 +113,12 @@ export async function saveUsenetServer(input: UsenetServerInput): Promise<Usenet
}
}
inMemory = sealed === undefined ? password || undefined : undefined
await updateJson(serverPath(), () => ({ ...rest, ...(sealed && { password: sealed }) }))
await updateJson(serverPath(), (current) => {
// No password given and none to seal: the stored one stays, for a session that can unseal it
// (one with the keyring this one lacks).
const stored = input.password === undefined && isRecord(current) ? current.password : undefined
const kept = sealed ?? (typeof stored === 'string' ? stored : undefined)
return { ...rest, ...(kept && { password: kept }) }
})
return viewOf(server)
}
Loading
Loading