feat(creator-packs): backend rail for gated creator content packs
Phase 1 of Creator Packs: the app-side download rail for per-creator video ZIMs served from the private R2 bucket behind the entitlement Worker. No UI yet (Phase 2). - Thread optional requestHeaders through the download util + job so the ZIM fetch can carry `Authorization: Bearer <APP_KEY>` on both the HEAD probe and the GET stream (Worker streams the ZIM directly, gated). - Add resourceMetadata.skip_embedding and gate the RAG/EmbedFileJob branch on it so video ZIMs are never sent to the knowledge base. - New creator_packs ManifestType + creatorPacksSpecSchema (display metadata only, no url) + CollectionManifestService wiring and getCreatorPacksWithStatus() status join. - collections/creator-packs.json catalog with the two seed packs. - CreatorPackService.installPack: ensure Kiwix (auto-install if absent), resolve the stable Worker URL, dispatch the gated download with skip_embedding. Typed result codes. - CreatorPacksController + GET/POST /api/creator-packs routes. - CREATOR_PACKS_APP_KEY / CREATOR_PACKS_WORKER_BASE env (release-injected key; app degrades to a clear not_configured error when unset). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6a4f02dd46
commit
ac9640e64b
|
|
@ -22,4 +22,9 @@ REDIS_PORT=6379
|
||||||
# Storage path for NOMAD content (ZIM files, maps, etc.)
|
# Storage path for NOMAD content (ZIM files, maps, etc.)
|
||||||
# On Windows dev, use an absolute path like: C:/nomad-storage
|
# On Windows dev, use an absolute path like: C:/nomad-storage
|
||||||
# On Linux production, use: /opt/project-nomad/storage
|
# On Linux production, use: /opt/project-nomad/storage
|
||||||
NOMAD_STORAGE_PATH=/opt/project-nomad/storage
|
NOMAD_STORAGE_PATH=/opt/project-nomad/storage
|
||||||
|
# Creator Packs (gated content downloads). Official release builds inject
|
||||||
|
# CREATOR_PACKS_APP_KEY at build time; leave unset for a build that can't install
|
||||||
|
# packs. CREATOR_PACKS_WORKER_BASE overrides the entitlement Worker origin.
|
||||||
|
# CREATOR_PACKS_APP_KEY=
|
||||||
|
# CREATOR_PACKS_WORKER_BASE=https://nomad-packs-worker.chris-556.workers.dev
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { CreatorPackService } from '#services/creator_pack_service'
|
||||||
|
import { DownloadService } from '#services/download_service'
|
||||||
|
import { QueueService } from '#services/queue_service'
|
||||||
|
import logger from '@adonisjs/core/services/logger'
|
||||||
|
import type { HttpContext } from '@adonisjs/core/http'
|
||||||
|
|
||||||
|
export default class CreatorPacksController {
|
||||||
|
private creatorPackService = new CreatorPackService()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalog with per-pack status, plus live progress for any in-flight pack
|
||||||
|
* downloads (ZIM download jobs — filtered client-side by collection_ref once
|
||||||
|
* the UI needs it; for now the raw zim job list is enough for progress).
|
||||||
|
*/
|
||||||
|
async index({ response }: HttpContext) {
|
||||||
|
try {
|
||||||
|
const packs = await this.creatorPackService.listPacksWithStatus()
|
||||||
|
const downloadService = new DownloadService(QueueService.getInstance())
|
||||||
|
const downloads = await downloadService.listDownloadJobs('zim')
|
||||||
|
return { packs, downloads }
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('[CreatorPacksController] Failed to list creator packs:', error?.message || error)
|
||||||
|
return response.status(500).send({ message: 'Failed to load creator packs' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async install({ params, response }: HttpContext) {
|
||||||
|
const packId = params.id as string
|
||||||
|
const result = await this.creatorPackService.installPack(packId)
|
||||||
|
|
||||||
|
switch (result.code) {
|
||||||
|
case 'dispatched':
|
||||||
|
return response.status(202).send({
|
||||||
|
message: 'Pack download started',
|
||||||
|
filename: result.filename,
|
||||||
|
})
|
||||||
|
case 'already_installed':
|
||||||
|
return { message: 'Pack is already installed' }
|
||||||
|
case 'already_downloading':
|
||||||
|
return { message: 'Pack download is already in progress' }
|
||||||
|
case 'not_found':
|
||||||
|
return response.status(404).send({ message: `Creator pack not found: ${packId}` })
|
||||||
|
case 'not_configured':
|
||||||
|
return response.status(503).send({
|
||||||
|
message: 'Creator Packs are not configured on this server',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -67,7 +67,7 @@ export class RunDownloadJob {
|
||||||
}
|
}
|
||||||
|
|
||||||
async handle(job: Job) {
|
async handle(job: Job) {
|
||||||
const { url, filepath, timeout, allowedMimeTypes, forceNew, filetype, resourceMetadata } =
|
const { url, filepath, timeout, allowedMimeTypes, forceNew, filetype, resourceMetadata, requestHeaders } =
|
||||||
job.data as RunDownloadJobParams
|
job.data as RunDownloadJobParams
|
||||||
|
|
||||||
// Register abort controller for this job
|
// Register abort controller for this job
|
||||||
|
|
@ -110,6 +110,7 @@ export class RunDownloadJob {
|
||||||
timeout,
|
timeout,
|
||||||
allowedMimeTypes,
|
allowedMimeTypes,
|
||||||
forceNew,
|
forceNew,
|
||||||
|
requestHeaders,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
onProgress(progress) {
|
onProgress(progress) {
|
||||||
const progressPercent = (progress.downloadedBytes / (progress.totalBytes || 1)) * 100
|
const progressPercent = (progress.downloadedBytes / (progress.totalBytes || 1)) * 100
|
||||||
|
|
@ -208,9 +209,11 @@ export class RunDownloadJob {
|
||||||
const zimService = new ZimService(dockerService)
|
const zimService = new ZimService(dockerService)
|
||||||
await zimService.downloadRemoteSuccessCallback([url], true)
|
await zimService.downloadRemoteSuccessCallback([url], true)
|
||||||
|
|
||||||
// Only touch the knowledge base if AI Assistant (Ollama) is installed
|
// Only touch the knowledge base if AI Assistant (Ollama) is installed.
|
||||||
|
// skip_embedding opts a ZIM out entirely — Creator Pack video ZIMs are
|
||||||
|
// media galleries, not text, so they must never be embedded or KB-reconciled.
|
||||||
const ollamaUrl = await dockerService.getServiceURL('nomad_ollama')
|
const ollamaUrl = await dockerService.getServiceURL('nomad_ollama')
|
||||||
if (ollamaUrl) {
|
if (ollamaUrl && !resourceMetadata?.skip_embedding) {
|
||||||
// A content UPDATE replaces a prior file at a DIFFERENT path
|
// A content UPDATE replaces a prior file at a DIFFERENT path
|
||||||
// (version is in the filename). A fresh install has no prior row;
|
// (version is in the filename). A fresh install has no prior row;
|
||||||
// a same-version re-download keeps the same path. The two cases
|
// a same-version re-download keeps the same path. The two cases
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import InstalledResource from '#models/installed_resource'
|
||||||
import WikipediaSelection from '#models/wikipedia_selection'
|
import WikipediaSelection from '#models/wikipedia_selection'
|
||||||
import { QueueService } from './queue_service.js'
|
import { QueueService } from './queue_service.js'
|
||||||
import { RunDownloadJob } from '#jobs/run_download_job'
|
import { RunDownloadJob } from '#jobs/run_download_job'
|
||||||
import { zimCategoriesSpecSchema, mapsSpecSchema, wikipediaSpecSchema } from '#validators/curated_collections'
|
import { zimCategoriesSpecSchema, mapsSpecSchema, wikipediaSpecSchema, creatorPacksSpecSchema } from '#validators/curated_collections'
|
||||||
import {
|
import {
|
||||||
ensureDirectoryExists,
|
ensureDirectoryExists,
|
||||||
listDirectoryContents,
|
listDirectoryContents,
|
||||||
|
|
@ -19,8 +19,10 @@ import type {
|
||||||
ManifestType,
|
ManifestType,
|
||||||
ZimCategoriesSpec,
|
ZimCategoriesSpec,
|
||||||
MapsSpec,
|
MapsSpec,
|
||||||
|
CreatorPacksSpec,
|
||||||
CategoryWithStatus,
|
CategoryWithStatus,
|
||||||
CollectionWithStatus,
|
CollectionWithStatus,
|
||||||
|
CreatorPackWithStatus,
|
||||||
SpecResource,
|
SpecResource,
|
||||||
SpecTier,
|
SpecTier,
|
||||||
} from '../../types/collections.js'
|
} from '../../types/collections.js'
|
||||||
|
|
@ -29,12 +31,14 @@ const SPEC_URLS: Record<ManifestType, string> = {
|
||||||
zim_categories: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/kiwix-categories.json',
|
zim_categories: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/kiwix-categories.json',
|
||||||
maps: 'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/collections/maps.json',
|
maps: 'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/collections/maps.json',
|
||||||
wikipedia: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/wikipedia.json',
|
wikipedia: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/wikipedia.json',
|
||||||
|
creator_packs: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/creator-packs.json',
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALIDATORS: Record<ManifestType, any> = {
|
const VALIDATORS: Record<ManifestType, any> = {
|
||||||
zim_categories: zimCategoriesSpecSchema,
|
zim_categories: zimCategoriesSpecSchema,
|
||||||
maps: mapsSpecSchema,
|
maps: mapsSpecSchema,
|
||||||
wikipedia: wikipediaSpecSchema,
|
wikipedia: wikipediaSpecSchema,
|
||||||
|
creator_packs: creatorPacksSpecSchema,
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CollectionManifestService {
|
export class CollectionManifestService {
|
||||||
|
|
@ -189,6 +193,39 @@ export class CollectionManifestService {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-pack install status for Creator Packs. Packs are single ZIMs, so this is
|
||||||
|
* a one-resource-per-pack join (simpler than the tiered category logic):
|
||||||
|
* catalog ⋈ InstalledResource (resource_type 'zim', matched on resource_id) ⋈
|
||||||
|
* the in-flight download queue. `available_update_version` is set when an
|
||||||
|
* installed pack's version trails the catalog (a creator published a rebuild).
|
||||||
|
*/
|
||||||
|
async getCreatorPacksWithStatus(): Promise<CreatorPackWithStatus[]> {
|
||||||
|
const spec = await this.getSpecWithFallback<CreatorPacksSpec>('creator_packs')
|
||||||
|
if (!spec) return []
|
||||||
|
|
||||||
|
const installedResources = await InstalledResource.query().where('resource_type', 'zim')
|
||||||
|
const installedMap = new Map(installedResources.map((r) => [r.resource_id, r]))
|
||||||
|
const inFlightIds = await this.getInFlightZimResourceIds()
|
||||||
|
|
||||||
|
return spec.packs.map((pack) => {
|
||||||
|
const installed = installedMap.get(pack.resource_id)
|
||||||
|
if (installed) {
|
||||||
|
const hasUpdate = installed.version !== pack.version
|
||||||
|
return {
|
||||||
|
...pack,
|
||||||
|
status: 'installed' as const,
|
||||||
|
installed_version: installed.version,
|
||||||
|
...(hasUpdate ? { available_update_version: pack.version } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inFlightIds.has(pack.resource_id)) {
|
||||||
|
return { ...pack, status: 'downloading' as const }
|
||||||
|
}
|
||||||
|
return { ...pack, status: 'available' as const }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Tier resolution ----
|
// ---- Tier resolution ----
|
||||||
|
|
||||||
static resolveTierResources(tier: SpecTier, allTiers: SpecTier[]): SpecResource[] {
|
static resolveTierResources(tier: SpecTier, allTiers: SpecTier[]): SpecResource[] {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import env from '#start/env'
|
||||||
|
import logger from '@adonisjs/core/services/logger'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { DockerService } from '#services/docker_service'
|
||||||
|
import { CollectionManifestService } from '#services/collection_manifest_service'
|
||||||
|
import { RunDownloadJob } from '#jobs/run_download_job'
|
||||||
|
import { ZIM_STORAGE_PATH } from '../utils/fs.js'
|
||||||
|
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||||
|
import type { CreatorPacksSpec, CreatorPackWithStatus } from '../../types/collections.js'
|
||||||
|
|
||||||
|
// ZIMs report as octet-stream from the Worker (Content-Type is set explicitly to
|
||||||
|
// application/octet-stream in worker/src/index.js). Mirror the ZimService allowlist.
|
||||||
|
const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream']
|
||||||
|
|
||||||
|
// Default entitlement Worker origin. Overridable via CREATOR_PACKS_WORKER_BASE so
|
||||||
|
// we can move to a branded packs.projectnomad.us domain without a code change.
|
||||||
|
const DEFAULT_WORKER_BASE = 'https://nomad-packs-worker.chris-556.workers.dev'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of an install request. Discriminated on `code` so the controller can
|
||||||
|
* map each case to an HTTP status without string-matching thrown messages.
|
||||||
|
* (See feedback: typed failure codes over ad-hoc Error parsing.)
|
||||||
|
*/
|
||||||
|
export type InstallPackResult =
|
||||||
|
| { code: 'dispatched'; filename: string }
|
||||||
|
| { code: 'already_installed' }
|
||||||
|
| { code: 'already_downloading' }
|
||||||
|
| { code: 'not_found' }
|
||||||
|
| { code: 'not_configured' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creator Packs install rail. Diverges from the curated-collections rail in
|
||||||
|
* exactly one way: instead of a static manifest URL, the ZIM is fetched from the
|
||||||
|
* entitlement Worker with a bearer `Authorization` header. From RunDownloadJob
|
||||||
|
* onward everything is the shared rail (resumable download → InstalledResource →
|
||||||
|
* Kiwix rebuildFromDisk). Video ZIMs opt out of KB embedding via skip_embedding.
|
||||||
|
*/
|
||||||
|
export class CreatorPackService {
|
||||||
|
private dockerService: DockerService
|
||||||
|
private manifestService: CollectionManifestService
|
||||||
|
|
||||||
|
constructor(dockerService?: DockerService, manifestService?: CollectionManifestService) {
|
||||||
|
this.dockerService = dockerService ?? new DockerService()
|
||||||
|
this.manifestService = manifestService ?? new CollectionManifestService()
|
||||||
|
}
|
||||||
|
|
||||||
|
private get workerBase(): string {
|
||||||
|
return (env.get('CREATOR_PACKS_WORKER_BASE') || DEFAULT_WORKER_BASE).replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable, per-version download URL. Not signed — the gate is the auth header. */
|
||||||
|
packUrl(id: string, version: string): string {
|
||||||
|
return `${this.workerBase}/packs/${id}_${version}.zim`
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPacksWithStatus(): Promise<CreatorPackWithStatus[]> {
|
||||||
|
return this.manifestService.getCreatorPacksWithStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install a pack by catalog id: ensure Kiwix is present (auto-install if not),
|
||||||
|
* then dispatch the gated ZIM download onto the shared rail.
|
||||||
|
*/
|
||||||
|
async installPack(packId: string): Promise<InstallPackResult> {
|
||||||
|
const appKey = env.get('CREATOR_PACKS_APP_KEY')
|
||||||
|
if (!appKey) {
|
||||||
|
logger.error('[CreatorPackService] CREATOR_PACKS_APP_KEY is not set; cannot install packs')
|
||||||
|
return { code: 'not_configured' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const spec = await this.manifestService.getSpecWithFallback<CreatorPacksSpec>('creator_packs')
|
||||||
|
const pack = spec?.packs.find((p) => p.id === packId)
|
||||||
|
if (!pack) {
|
||||||
|
return { code: 'not_found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { default: InstalledResource } = await import('#models/installed_resource')
|
||||||
|
const existing = await InstalledResource.query()
|
||||||
|
.where('resource_type', 'zim')
|
||||||
|
.where('resource_id', pack.resource_id)
|
||||||
|
.first()
|
||||||
|
if (existing && existing.version === pack.version) {
|
||||||
|
return { code: 'already_installed' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = this.packUrl(pack.id, pack.version)
|
||||||
|
if (await RunDownloadJob.getActiveByUrl(url)) {
|
||||||
|
return { code: 'already_downloading' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kiwix is a hard prerequisite — it's what serves the pack. Auto-install it if
|
||||||
|
// absent (its own preinstall bootstraps a mini-wiki ZIM so the container can
|
||||||
|
// start). Best-effort: if the install can't be kicked off we still download the
|
||||||
|
// pack so the bytes are on disk; Kiwix hot-reloads it once installed.
|
||||||
|
await this.ensureKiwixInstalled()
|
||||||
|
|
||||||
|
const filename = `${pack.id}_${pack.version}.zim`
|
||||||
|
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
|
||||||
|
|
||||||
|
await RunDownloadJob.dispatch({
|
||||||
|
url,
|
||||||
|
filepath,
|
||||||
|
timeout: 30000,
|
||||||
|
allowedMimeTypes: ZIM_MIME_TYPES,
|
||||||
|
forceNew: true,
|
||||||
|
filetype: 'zim',
|
||||||
|
title: pack.name,
|
||||||
|
totalBytes: pack.size_mb ? pack.size_mb * 1024 * 1024 : undefined,
|
||||||
|
requestHeaders: { Authorization: `Bearer ${appKey}` },
|
||||||
|
resourceMetadata: {
|
||||||
|
resource_id: pack.resource_id,
|
||||||
|
version: pack.version,
|
||||||
|
collection_ref: 'creator-packs',
|
||||||
|
skip_embedding: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(`[CreatorPackService] Dispatched download for pack ${pack.id} (${filename})`)
|
||||||
|
return { code: 'dispatched', filename }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureKiwixInstalled(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const kiwixUrl = await this.dockerService.getServiceURL(SERVICE_NAMES.KIWIX)
|
||||||
|
if (kiwixUrl) return // already installed and resolvable
|
||||||
|
|
||||||
|
logger.info('[CreatorPackService] Kiwix not installed; auto-installing before pack download')
|
||||||
|
const result = await this.dockerService.createContainerPreflight(SERVICE_NAMES.KIWIX)
|
||||||
|
if (!result.success) {
|
||||||
|
// e.g. "already installing" — not fatal, the pack download proceeds regardless.
|
||||||
|
logger.warn(`[CreatorPackService] Kiwix preflight did not start: ${result.message}`)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.warn(
|
||||||
|
`[CreatorPackService] Kiwix auto-install failed (continuing with pack download): ${error?.message || error}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ export async function doResumableDownload({
|
||||||
onComplete,
|
onComplete,
|
||||||
forceNew = false,
|
forceNew = false,
|
||||||
allowedMimeTypes,
|
allowedMimeTypes,
|
||||||
|
requestHeaders,
|
||||||
}: DoResumableDownloadParams): Promise<string> {
|
}: DoResumableDownloadParams): Promise<string> {
|
||||||
const dirname = path.dirname(filepath)
|
const dirname = path.dirname(filepath)
|
||||||
await ensureDirectoryExists(dirname)
|
await ensureDirectoryExists(dirname)
|
||||||
|
|
@ -41,10 +42,12 @@ export async function doResumableDownload({
|
||||||
appendMode = true
|
appendMode = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file info with HEAD request first
|
// Get file info with HEAD request first. Gated sources (Creator Packs) require
|
||||||
|
// the auth header on the HEAD too, or the probe 401s before the GET is reached.
|
||||||
const headResponse = await axios.head(url, {
|
const headResponse = await axios.head(url, {
|
||||||
signal,
|
signal,
|
||||||
timeout,
|
timeout,
|
||||||
|
headers: requestHeaders,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Some upstream hosts (notably download.kiwix.org for .zim files) don't set a
|
// Some upstream hosts (notably download.kiwix.org for .zim files) don't set a
|
||||||
|
|
@ -88,7 +91,10 @@ export async function doResumableDownload({
|
||||||
appendMode = false
|
appendMode = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers: Record<string, string> = {}
|
// Seed with any caller-supplied headers (e.g. Creator Packs' Authorization
|
||||||
|
// bearer) so every GET attempt below — including the range-restart retry —
|
||||||
|
// carries them. The Range header composes on top.
|
||||||
|
const headers: Record<string, string> = { ...requestHeaders }
|
||||||
if (supportsRangeRequests && startByte > 0) {
|
if (supportsRangeRequests && startByte > 0) {
|
||||||
headers.Range = `bytes=${startByte}-`
|
headers.Range = `bytes=${startByte}-`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,30 @@ export const wikipediaSpecSchema = vine.object({
|
||||||
).minLength(1),
|
).minLength(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---- Creator Packs spec (versioned) ----
|
||||||
|
//
|
||||||
|
// Display metadata only — deliberately NO `url` field. The ZIM bytes are resolved
|
||||||
|
// through the entitlement Worker at install time (CreatorPackService), never from
|
||||||
|
// this public catalog. See project_content_creator_packs.
|
||||||
|
export const creatorPacksSpecSchema = vine.object({
|
||||||
|
spec_version: vine.string(),
|
||||||
|
packs: vine.array(
|
||||||
|
vine.object({
|
||||||
|
id: vine.string(),
|
||||||
|
name: vine.string(),
|
||||||
|
creator: vine.string(),
|
||||||
|
description: vine.string(),
|
||||||
|
version: vine.string(),
|
||||||
|
resource_id: vine.string(),
|
||||||
|
video_count: vine.number().min(0),
|
||||||
|
size_mb: vine.number().min(0),
|
||||||
|
license_id: vine.string(),
|
||||||
|
poster_url: vine.string().url().optional(),
|
||||||
|
logo_url: vine.string().url().optional(),
|
||||||
|
})
|
||||||
|
).minLength(1),
|
||||||
|
})
|
||||||
|
|
||||||
// ---- Wikipedia validators (used by ZimService) ----
|
// ---- Wikipedia validators (used by ZimService) ----
|
||||||
|
|
||||||
export const wikipediaOptionSchema = vine.object({
|
export const wikipediaOptionSchema = vine.object({
|
||||||
|
|
|
||||||
|
|
@ -62,4 +62,18 @@ export default await Env.create(new URL('../', import.meta.url), {
|
||||||
|----------------------------------------------------------
|
|----------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
NOMAD_API_URL: Env.schema.string.optional(),
|
NOMAD_API_URL: Env.schema.string.optional(),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|----------------------------------------------------------
|
||||||
|
| Creator Packs (gated content downloads)
|
||||||
|
|----------------------------------------------------------
|
||||||
|
| CREATOR_PACKS_APP_KEY is the shared bearer key the entitlement Worker
|
||||||
|
| requires; official release builds inject it at build time (it is NOT
|
||||||
|
| committed to source). When unset, pack installs fail with a clear
|
||||||
|
| "not configured" error and the rest of the app is unaffected.
|
||||||
|
| CREATOR_PACKS_WORKER_BASE overrides the default Worker origin (e.g. to
|
||||||
|
| point at a branded packs.projectnomad.us domain later).
|
||||||
|
*/
|
||||||
|
CREATOR_PACKS_APP_KEY: Env.schema.string.optional(),
|
||||||
|
CREATOR_PACKS_WORKER_BASE: Env.schema.string.optional(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import SettingsController from '#controllers/settings_controller'
|
||||||
import SupplyDepotController from '#controllers/supply_depot_controller'
|
import SupplyDepotController from '#controllers/supply_depot_controller'
|
||||||
import SystemController from '#controllers/system_controller'
|
import SystemController from '#controllers/system_controller'
|
||||||
import CollectionUpdatesController from '#controllers/collection_updates_controller'
|
import CollectionUpdatesController from '#controllers/collection_updates_controller'
|
||||||
|
import CreatorPacksController from '#controllers/creator_packs_controller'
|
||||||
import ZimController from '#controllers/zim_controller'
|
import ZimController from '#controllers/zim_controller'
|
||||||
import router from '@adonisjs/core/services/router'
|
import router from '@adonisjs/core/services/router'
|
||||||
import transmit from '@adonisjs/transmit/services/main'
|
import transmit from '@adonisjs/transmit/services/main'
|
||||||
|
|
@ -225,6 +226,13 @@ router
|
||||||
})
|
})
|
||||||
.prefix('/api/zim')
|
.prefix('/api/zim')
|
||||||
|
|
||||||
|
router
|
||||||
|
.group(() => {
|
||||||
|
router.get('/', [CreatorPacksController, 'index'])
|
||||||
|
router.post('/:id/install', [CreatorPacksController, 'install'])
|
||||||
|
})
|
||||||
|
.prefix('/api/creator-packs')
|
||||||
|
|
||||||
router
|
router
|
||||||
.group(() => {
|
.group(() => {
|
||||||
router.post('/run', [BenchmarkController, 'run'])
|
router.post('/run', [BenchmarkController, 'run'])
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,45 @@ export type WikipediaSpec = {
|
||||||
options: WikipediaOption[]
|
options: WikipediaOption[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ManifestType = 'zim_categories' | 'maps' | 'wikipedia'
|
// ---- Creator Packs spec ----
|
||||||
|
//
|
||||||
|
// A creator pack is a branded ZIM of a creator's curated YouTube videos, served
|
||||||
|
// offline through Kiwix. The public catalog carries display metadata ONLY — there
|
||||||
|
// is NO download url here. The bytes live in a private R2 bucket behind an
|
||||||
|
// entitlement Worker; CreatorPackService resolves the (stable) Worker URL and
|
||||||
|
// sends the auth header at install time. See project_content_creator_packs.
|
||||||
|
export type CreatorPack = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
creator: string
|
||||||
|
description: string
|
||||||
|
/** YYYY-MM; combines with id as `<id>_<version>.zim` (parseZimFilename). */
|
||||||
|
version: string
|
||||||
|
/** InstalledResource.resource_id used for install/status reconciliation. */
|
||||||
|
resource_id: string
|
||||||
|
video_count: number
|
||||||
|
size_mb: number
|
||||||
|
license_id: string
|
||||||
|
/** Optional card art (Phase 2). The Kiwix grid icon is baked into the ZIM. */
|
||||||
|
poster_url?: string
|
||||||
|
logo_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreatorPacksSpec = {
|
||||||
|
spec_version: string
|
||||||
|
packs: CreatorPack[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreatorPackStatus = 'installed' | 'downloading' | 'available'
|
||||||
|
|
||||||
|
export type CreatorPackWithStatus = CreatorPack & {
|
||||||
|
status: CreatorPackStatus
|
||||||
|
installed_version?: string
|
||||||
|
/** Set when an installed pack has a newer catalog version available. */
|
||||||
|
available_update_version?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ManifestType = 'zim_categories' | 'maps' | 'wikipedia' | 'creator_packs'
|
||||||
|
|
||||||
export type ResourceStatus = 'installed' | 'not_installed' | 'update_available'
|
export type ResourceStatus = 'installed' | 'not_installed' | 'update_available'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,13 @@ export type DoResumableDownloadParams = {
|
||||||
onProgress?: (progress: DoResumableDownloadProgress) => void
|
onProgress?: (progress: DoResumableDownloadProgress) => void
|
||||||
onComplete?: (url: string, path: string) => void | Promise<void>
|
onComplete?: (url: string, path: string) => void | Promise<void>
|
||||||
forceNew?: boolean
|
forceNew?: boolean
|
||||||
|
/**
|
||||||
|
* Extra HTTP request headers sent on BOTH the HEAD probe and the GET stream.
|
||||||
|
* Used by Creator Packs to carry the `Authorization: Bearer <APP_KEY>` the
|
||||||
|
* entitlement Worker requires; the Range header still composes on top for
|
||||||
|
* resumed downloads. Kept generic so any gated source can reuse it.
|
||||||
|
*/
|
||||||
|
requestHeaders?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DoResumableDownloadWithRetryParams = DoResumableDownloadParams & {
|
export type DoResumableDownloadWithRetryParams = DoResumableDownloadParams & {
|
||||||
|
|
@ -48,6 +55,13 @@ export type RunDownloadJobParams = Omit<
|
||||||
* `failed` handler) so manual downloads never touch the counter.
|
* `failed` handler) so manual downloads never touch the counter.
|
||||||
*/
|
*/
|
||||||
auto?: boolean
|
auto?: boolean
|
||||||
|
/**
|
||||||
|
* Skip the RAG/knowledge-base ingestion branch in RunDownloadJob.onComplete.
|
||||||
|
* Set for video ZIMs (Creator Packs) — they are media galleries, not text,
|
||||||
|
* and must never be dispatched to EmbedFileJob or the replacement-reconcile
|
||||||
|
* path. The Kiwix rebuild still runs so the pack appears in the grid.
|
||||||
|
*/
|
||||||
|
skip_embedding?: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"spec_version": "1",
|
||||||
|
"packs": [
|
||||||
|
{
|
||||||
|
"id": "project-nomad",
|
||||||
|
"name": "Project NOMAD",
|
||||||
|
"creator": "Project NOMAD",
|
||||||
|
"description": "Official Project NOMAD videos, packaged for offline viewing on your NOMAD.",
|
||||||
|
"version": "2026-07",
|
||||||
|
"resource_id": "project-nomad",
|
||||||
|
"video_count": 5,
|
||||||
|
"size_mb": 178,
|
||||||
|
"license_id": "nomad-creator-pack-1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "crosstalk-solutions",
|
||||||
|
"name": "Crosstalk Solutions",
|
||||||
|
"creator": "Chris Sherwood",
|
||||||
|
"description": "Chris Sherwood's networking, UniFi & home lab videos, for offline NOMAD.",
|
||||||
|
"version": "2026-07",
|
||||||
|
"resource_id": "crosstalk-solutions",
|
||||||
|
"video_count": 30,
|
||||||
|
"size_mb": 2899,
|
||||||
|
"license_id": "nomad-creator-pack-1.0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue