This commit is contained in:
chriscrosstalk 2026-07-15 16:25:53 -05:00 committed by GitHub
commit fc9b0d97c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1073 additions and 42 deletions

View File

@ -52,3 +52,4 @@ jobs:
VERSION=${{ inputs.version }}
BUILD_DATE=${{ github.event.workflow_run.created_at }}
VCS_REF=${{ github.sha }}
CREATOR_PACKS_APP_KEY=${{ secrets.CREATOR_PACKS_APP_KEY }}

View File

@ -73,6 +73,17 @@ LABEL org.opencontainers.image.title="Project N.O.M.A.D" \
org.opencontainers.image.licenses="Apache-2.0"
ENV NODE_ENV=production
# Creator Packs entitlement key, injected into OFFICIAL release builds at build
# time (--build-arg CREATOR_PACKS_APP_KEY=... from the CREATOR_PACKS_APP_KEY CI
# secret; see build-primary-image.yml). Baked as an ENV so admin/start/env.ts
# reads it at runtime. Empty by default, so builds from source (and any build
# without the secret) ship UNCONFIGURED and hide the Creator Packs UI. The key
# lands in this public image layer (extractable — the accepted ceiling); rotate
# via `wrangler secret put APP_KEY` + a new image if it leaks.
ARG CREATOR_PACKS_APP_KEY=""
ENV CREATOR_PACKS_APP_KEY=$CREATOR_PACKS_APP_KEY
WORKDIR /app
COPY --from=production-deps /app/node_modules /app/node_modules
COPY --from=build /app/build /app

View File

@ -22,4 +22,9 @@ REDIS_PORT=6379
# Storage path for NOMAD content (ZIM files, maps, etc.)
# On Windows dev, use an absolute path like: C:/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

View File

@ -0,0 +1,62 @@
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 configured = this.creatorPackService.isConfigured()
const packs = await this.creatorPackService.listPacksWithStatus()
const downloadService = new DownloadService(QueueService.getInstance())
const downloads = await downloadService.listDownloadJobs('zim')
return { configured, 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',
})
}
}
async uninstall({ params, response }: HttpContext) {
const packId = params.id as string
const result = await this.creatorPackService.uninstallPack(packId)
switch (result.code) {
case 'uninstalled':
return { message: 'Pack uninstalled', filename: result.filename }
case 'not_installed':
return response.status(404).send({ message: `Creator pack is not installed: ${packId}` })
}
}
}

View File

@ -99,6 +99,10 @@ export default class SettingsController {
return inertia.render('settings/zim/remote-explorer')
}
async creatorPacks({ inertia }: HttpContext) {
return inertia.render('settings/creator-packs')
}
async benchmark({ inertia }: HttpContext) {
const latestResult = await this.benchmarkService.getLatestResult()
const status = this.benchmarkService.getStatus()

View File

@ -67,7 +67,7 @@ export class RunDownloadJob {
}
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
// Register abort controller for this job
@ -110,6 +110,7 @@ export class RunDownloadJob {
timeout,
allowedMimeTypes,
forceNew,
requestHeaders,
signal: abortController.signal,
onProgress(progress) {
const progressPercent = (progress.downloadedBytes / (progress.totalBytes || 1)) * 100
@ -208,9 +209,11 @@ export class RunDownloadJob {
const zimService = new ZimService(dockerService)
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')
if (ollamaUrl) {
if (ollamaUrl && !resourceMetadata?.skip_embedding) {
// A content UPDATE replaces a prior file at a DIFFERENT path
// (version is in the filename). A fresh install has no prior row;
// a same-version re-download keeps the same path. The two cases

View File

@ -8,7 +8,7 @@ import InstalledResource from '#models/installed_resource'
import WikipediaSelection from '#models/wikipedia_selection'
import { QueueService } from './queue_service.js'
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 {
ensureDirectoryExists,
listDirectoryContents,
@ -19,8 +19,10 @@ import type {
ManifestType,
ZimCategoriesSpec,
MapsSpec,
CreatorPacksSpec,
CategoryWithStatus,
CollectionWithStatus,
CreatorPackWithStatus,
SpecResource,
SpecTier,
} 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',
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',
creator_packs: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/creator-packs.json',
}
const VALIDATORS: Record<ManifestType, any> = {
zim_categories: zimCategoriesSpecSchema,
maps: mapsSpecSchema,
wikipedia: wikipediaSpecSchema,
creator_packs: creatorPacksSpecSchema,
}
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 ----
static resolveTierResources(tier: SpecTier, allTiers: SpecTier[]): SpecResource[] {

View File

@ -0,0 +1,178 @@
import env from '#start/env'
import logger from '@adonisjs/core/services/logger'
import { join } from 'node:path'
import { DockerService } from '#services/docker_service'
import { ZimService } from '#services/zim_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' }
export type UninstallPackResult =
| { code: 'uninstalled'; filename: string }
| { code: 'not_installed' }
/**
* 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(/\/+$/, '')
}
/**
* Whether this build can actually install packs i.e. the release-injected
* app key is present. Forks built from source have no key; the UI uses this to
* HIDE the Creator Packs surfaces entirely rather than show install buttons
* that would 503. The public catalog still lists, but nothing is installable.
*/
isConfigured(): boolean {
return !!env.get('CREATOR_PACKS_APP_KEY')
}
/** 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 }
}
/**
* Uninstall an installed pack: delete the ZIM (which via ZimService.delete also
* removes it from the Kiwix library and clears its InstalledResource row).
* Uses the INSTALLED version (not the catalog's) so we remove the file that's
* actually on disk even if a newer version has since been published.
*/
async uninstallPack(packId: string): Promise<UninstallPackResult> {
const { default: InstalledResource } = await import('#models/installed_resource')
const installed = await InstalledResource.query()
.where('resource_type', 'zim')
.where('resource_id', packId)
.first()
if (!installed) {
return { code: 'not_installed' }
}
const filename = `${packId}_${installed.version}.zim`
const zimService = new ZimService(this.dockerService)
await zimService.delete(filename)
logger.info(`[CreatorPackService] Uninstalled pack ${packId} (${filename})`)
return { code: 'uninstalled', 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}`
)
}
}
}

View File

@ -24,6 +24,7 @@ export async function doResumableDownload({
onComplete,
forceNew = false,
allowedMimeTypes,
requestHeaders,
}: DoResumableDownloadParams): Promise<string> {
const dirname = path.dirname(filepath)
await ensureDirectoryExists(dirname)
@ -41,10 +42,12 @@ export async function doResumableDownload({
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, {
signal,
timeout,
headers: requestHeaders,
})
// Some upstream hosts (notably download.kiwix.org for .zim files) don't set a
@ -88,7 +91,10 @@ export async function doResumableDownload({
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) {
headers.Range = `bytes=${startByte}-`
}

View File

@ -68,6 +68,31 @@ export const wikipediaSpecSchema = vine.object({
).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(),
banner_url: vine.string().url().optional(),
poster_url: vine.string().url().optional(),
logo_url: vine.string().url().optional(),
})
).minLength(1),
})
// ---- Wikipedia validators (used by ZimService) ----
export const wikipediaOptionSchema = vine.object({

View File

@ -0,0 +1,119 @@
import { useState } from 'react'
import { formatBytes } from '~/lib/util'
import type { CreatorPackWithStatus } from '../../types/collections'
import classNames from 'classnames'
import {
IconChevronRight,
IconCircleCheck,
IconLoader2,
IconMovie,
IconTrash,
} from '@tabler/icons-react'
export interface CreatorPackCardProps {
pack: CreatorPackWithStatus
/** In-session wizard selection highlight (before anything is installed). */
selected?: boolean
onClick?: (pack: CreatorPackWithStatus) => void
/** When set, an installed pack shows an uninstall control (settings surface only). */
onUninstall?: (pack: CreatorPackWithStatus) => void
}
const CreatorPackCard: React.FC<CreatorPackCardProps> = ({ pack, selected, onClick, onUninstall }) => {
const isInstalled = pack.status === 'installed'
const isDownloading = pack.status === 'downloading'
const hasUpdate = !!pack.available_update_version
const sizeBytes = pack.size_mb * 1024 * 1024
// Installed packs are inert unless a newer version is available (click = update).
const clickable = selected || !isInstalled || hasUpdate
const highlighted = selected || isDownloading || (isInstalled && !hasUpdate)
// Prefer a catalog-supplied banner (future remote creators); otherwise the
// banner bundled with the app by pack id. Both are the branded 1060x175 art we
// build into the ZIM. Fall back to a simple header only if the image is absent.
const [bannerFailed, setBannerFailed] = useState(false)
const bannerSrc = pack.banner_url || `/creator-packs/${pack.id}.webp`
const statusBadge = selected ? (
<span className="flex items-center text-lime-600 dark:text-lime-400 text-sm font-medium">
<IconCircleCheck className="w-5 h-5 mr-1" />
Selected
</span>
) : isDownloading ? (
<span className="flex items-center text-lime-600 dark:text-lime-400 text-sm font-medium">
<IconLoader2 className="w-5 h-5 mr-1 animate-spin" />
Downloading
</span>
) : isInstalled ? (
<span className="flex items-center text-lime-600 dark:text-lime-400 text-sm font-medium">
<IconCircleCheck className="w-5 h-5 mr-1" />
Installed
</span>
) : (
<span className="flex items-center text-text-muted text-sm font-medium">
Install
<IconChevronRight className="w-5 h-5 ml-1" />
</span>
)
return (
<div
className={classNames(
'flex flex-col rounded-lg overflow-hidden bg-surface-primary border shadow-sm transition-shadow',
highlighted ? 'border-lime-400 border-2' : 'border-border-subtle',
clickable ? 'cursor-pointer hover:shadow-lg' : 'opacity-70 cursor-not-allowed'
)}
onClick={() => {
if (!clickable) return
onClick?.(pack)
}}
>
{!bannerFailed ? (
<img
src={bannerSrc}
alt={pack.name}
className="w-full block aspect-[1060/175] object-cover"
onError={() => setBannerFailed(true)}
/>
) : (
<div className="flex items-center gap-2 bg-desert-green text-white px-5 py-6">
<IconMovie className="w-6 h-6 shrink-0" />
<h3 className="text-lg font-semibold truncate">{pack.name}</h3>
</div>
)}
<div className="flex items-center justify-between gap-3 px-5 py-3">
<div className="min-w-0">
<p className="text-sm text-text-secondary truncate">
{pack.video_count} videos &middot; {formatBytes(sizeBytes, 0)}
</p>
{hasUpdate && (
<span className="inline-block mt-1 text-xs px-2 py-0.5 rounded bg-lime-500/20 text-lime-700 dark:text-lime-300">
Update available
</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{statusBadge}
{onUninstall && isInstalled && (
<button
type="button"
title="Uninstall pack"
aria-label="Uninstall pack"
className="p-1 rounded text-text-muted hover:text-red-500 hover:bg-red-500/10 transition-colors"
onClick={(e) => {
e.stopPropagation()
onUninstall(pack)
}}
>
<IconTrash className="w-5 h-5" />
</button>
)}
</div>
</div>
</div>
)
}
export default CreatorPackCard

View File

@ -0,0 +1,163 @@
import { useState } from 'react'
import { IconMovie } from '@tabler/icons-react'
import api from '~/lib/api'
import useCreatorPacks from '~/hooks/useCreatorPacks'
import useDownloads from '~/hooks/useDownloads'
import { useNotifications } from '~/context/NotificationContext'
import CreatorPackCard from '~/components/CreatorPackCard'
import StyledModal from '~/components/StyledModal'
import { formatBytes } from '~/lib/util'
import type { CreatorPackWithStatus } from '../../types/collections'
// Canonical Creator Pack License (one license across the seed packs). Opened in a
// new tab from the install modal; install is an online action so an external link
// is fine. A per-pack catalog `license_url` can supersede this later if needed.
const LICENSE_URL =
'https://github.com/Crosstalk-Solutions/project-nomad/blob/main/collections/creator-pack-license.md'
export interface CreatorPacksSectionProps {
/** Show uninstall controls on installed packs (the settings "manage" surface). */
allowUninstall?: boolean
}
/**
* Install-on-click grid of Creator Packs + confirm modals. Shared by the Content
* Explorer block and the /settings/creator-packs page. Renders NOTHING when the
* build isn't configured (fork / key unset) a fork never sees a broken install
* button. The Easy Setup wizard does NOT use this (it needs selection semantics,
* not install-on-click) and drives CreatorPackCard itself.
*/
const CreatorPacksSection: React.FC<CreatorPacksSectionProps> = ({ allowUninstall }) => {
const { configured, packs, invalidate: invalidateCreatorPacks } = useCreatorPacks()
const { invalidate: invalidateDownloads } = useDownloads({ filetype: 'zim' })
const { addNotification } = useNotifications()
const [packToInstall, setPackToInstall] = useState<CreatorPackWithStatus | null>(null)
const [installing, setInstalling] = useState(false)
const [packToUninstall, setPackToUninstall] = useState<CreatorPackWithStatus | null>(null)
const [uninstalling, setUninstalling] = useState(false)
if (!configured) return null
const handleConfirmInstall = async () => {
if (!packToInstall) return
setInstalling(true)
try {
await api.installCreatorPack(packToInstall.id)
addNotification({ message: `Started installing "${packToInstall.name}"`, type: 'success' })
invalidateCreatorPacks()
invalidateDownloads()
setPackToInstall(null)
} catch (error) {
console.error('Error installing creator pack:', error)
addNotification({ message: 'An error occurred while starting the install.', type: 'error' })
} finally {
setInstalling(false)
}
}
const handleConfirmUninstall = async () => {
if (!packToUninstall) return
setUninstalling(true)
try {
await api.uninstallCreatorPack(packToUninstall.id)
addNotification({ message: `Uninstalled "${packToUninstall.name}"`, type: 'success' })
invalidateCreatorPacks()
invalidateDownloads()
setPackToUninstall(null)
} catch (error) {
console.error('Error uninstalling creator pack:', error)
addNotification({ message: 'An error occurred while uninstalling.', type: 'error' })
} finally {
setUninstalling(false)
}
}
return (
<>
<div className="flex items-center gap-3 mt-8 mb-4">
<div className="w-10 h-10 rounded-full bg-surface-primary border border-border-subtle flex items-center justify-center shadow-sm">
<IconMovie className="w-6 h-6 text-text-primary" />
</div>
<div>
<h3 className="text-xl font-semibold text-text-primary">Creator Packs</h3>
<p className="text-sm text-text-muted">
Branded video collections from creators, for offline viewing
</p>
</div>
</div>
{packs.length > 0 ? (
<div className="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-6">
{packs.map((pack) => (
<CreatorPackCard
key={pack.id}
pack={pack}
onClick={setPackToInstall}
onUninstall={allowUninstall ? setPackToUninstall : undefined}
/>
))}
</div>
) : (
<p className="text-text-muted mt-4">No creator packs available.</p>
)}
<StyledModal
open={!!packToInstall}
title={packToInstall ? `Install ${packToInstall.name}?` : 'Install Creator Pack'}
onClose={() => !installing && setPackToInstall(null)}
onCancel={() => setPackToInstall(null)}
onConfirm={handleConfirmInstall}
confirmText={packToInstall?.available_update_version ? 'Update pack' : 'Install pack'}
confirmIcon="IconDownload"
confirmLoading={installing}
icon={<IconMovie className="w-6 h-6" />}
>
{packToInstall && (
<div className="space-y-3 text-text-secondary">
<p>
{packToInstall.video_count} videos from {packToInstall.creator}, about{' '}
{formatBytes(packToInstall.size_mb * 1024 * 1024, 0)}. It will download in the
background and appear in Kiwix when ready.
</p>
<p className="text-sm text-text-muted">
Licensed content personal use, not for redistribution.{' '}
<a
href={LICENSE_URL}
target="_blank"
rel="noreferrer"
className="text-desert-green underline hover:no-underline"
onClick={(e) => e.stopPropagation()}
>
View license
</a>
</p>
</div>
)}
</StyledModal>
<StyledModal
open={!!packToUninstall}
title={packToUninstall ? `Uninstall ${packToUninstall.name}?` : 'Uninstall Creator Pack'}
onClose={() => !uninstalling && setPackToUninstall(null)}
onCancel={() => setPackToUninstall(null)}
onConfirm={handleConfirmUninstall}
confirmText="Uninstall pack"
confirmIcon="IconTrash"
confirmVariant="danger"
confirmLoading={uninstalling}
icon={<IconMovie className="w-6 h-6" />}
>
{packToUninstall && (
<p className="text-text-secondary">
This removes the downloaded videos (
{formatBytes(packToUninstall.size_mb * 1024 * 1024, 0)}) from this NOMAD. You can
reinstall the pack anytime.
</p>
)}
</StyledModal>
</>
)
}
export default CreatorPacksSection

View File

@ -0,0 +1,45 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import api from '~/lib/api'
export const CREATOR_PACKS_KEY = 'creator-packs'
/**
* Shared, cached source of Creator Packs state for every surface (Content
* Explorer block, settings page, settings nav gate, Easy Setup wizard step).
*
* `configured` reflects whether this build carries the release-injected app key.
* When false (forks / key unset) the surfaces HIDE themselves the catalog is
* public so packs would otherwise list with install buttons that only 503.
*
* Polls while a pack download is in flight so the card status flips promptly;
* otherwise refetches lazily.
*/
const useCreatorPacks = (enabled = true) => {
const queryClient = useQueryClient()
const queryData = useQuery({
queryKey: [CREATOR_PACKS_KEY],
queryFn: () => api.getCreatorPacks(),
refetchInterval: (query) => {
const anyDownloading = query.state.data?.packs?.some((p) => p.status === 'downloading')
return anyDownloading ? 2000 : false
},
refetchOnWindowFocus: false,
staleTime: 60_000,
enabled,
})
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: [CREATOR_PACKS_KEY] })
}
return {
...queryData,
configured: queryData.data?.configured ?? false,
packs: queryData.data?.packs ?? [],
downloads: queryData.data?.downloads ?? [],
invalidate,
}
}
export default useCreatorPacks

View File

@ -8,6 +8,7 @@ import {
IconGavel,
IconHeart,
IconMapRoute,
IconMovie,
IconSettings,
IconWand,
IconZoom
@ -16,11 +17,15 @@ import { usePage } from '@inertiajs/react'
import StyledSidebar from '~/components/StyledSidebar'
import { getServiceLink } from '~/lib/navigation'
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
import useCreatorPacks from '~/hooks/useCreatorPacks'
import { SERVICE_NAMES } from '../../constants/service_names'
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
const aiAssistantInstallStatus = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)
// Only show the Creator Packs entry on builds that can actually install packs
// (release-injected key present) — a fork built from source has no key.
const { configured: creatorPacksConfigured } = useCreatorPacks()
const navigation = [
...(aiAssistantInstallStatus.isInstalled ? [{ name: aiAssistantName, href: '/settings/models', icon: IconWand, current: false }] : []),
@ -28,6 +33,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
{ name: 'Benchmark', href: '/settings/benchmark', icon: IconChartBar, current: false },
{ name: 'Content Explorer', href: '/settings/zim/remote-explorer', icon: IconZoom, current: false },
{ name: 'Content Manager', href: '/settings/zim', icon: IconFolder, current: false },
...(creatorPacksConfigured ? [{ name: 'Creator Packs', href: '/settings/creator-packs', icon: IconMovie, current: false }] : []),
{ name: 'Maps Manager', href: '/settings/maps', icon: IconMapRoute, current: false },
{
name: 'Service Logs & Metrics',

View File

@ -6,7 +6,7 @@ import { AppAutoUpdateStatus, AutoUpdateStatus, CheckLatestVersionResult, Conten
import { DownloadJobWithProgress, WikipediaState } from '../../types/downloads'
import type { Country, CountryCode, CountryGroup, MapExtractPreflight } from '../../types/maps'
import { EmbedJobWithProgress, FileWarningsResult, StoredFileInfo } from '../../types/rag'
import type { CategoryWithStatus, CollectionWithStatus, ContentUpdateCheckResult, ResourceUpdateInfo } from '../../types/collections'
import type { CategoryWithStatus, CollectionWithStatus, ContentUpdateCheckResult, CreatorPackWithStatus, ResourceUpdateInfo } from '../../types/collections'
import { catchInternal } from './util'
import { NomadChatResponse, NomadInstalledModel, NomadOllamaModel, OllamaChatRequest } from '../../types/ollama'
import BenchmarkResult from '#models/benchmark_result'
@ -697,6 +697,35 @@ class API {
})()
}
async getCreatorPacks() {
return catchInternal(async () => {
const response = await this.client.get<{
configured: boolean
packs: CreatorPackWithStatus[]
downloads: DownloadJobWithProgress[]
}>('/creator-packs')
return response.data
})()
}
async installCreatorPack(id: string) {
return catchInternal(async () => {
const response = await this.client.post<{ message: string; filename?: string }>(
`/creator-packs/${id}/install`
)
return response.data
})()
}
async uninstallCreatorPack(id: string) {
return catchInternal(async () => {
const response = await this.client.delete<{ message: string; filename?: string }>(
`/creator-packs/${id}`
)
return response.data
})()
}
async listDocs() {
return catchInternal(async () => {
const response = await this.client.get<Array<{ title: string; slug: string }>>('/docs/list')

View File

@ -7,6 +7,7 @@ import api from '~/lib/api'
import { ServiceSlim } from '../../../types/services'
import CuratedCollectionCard from '~/components/CuratedCollectionCard'
import CategoryCard from '~/components/CategoryCard'
import CreatorPackCard from '~/components/CreatorPackCard'
import TierSelectionModal from '~/components/TierSelectionModal'
import WikipediaSelector from '~/components/WikipediaSelector'
import LoadingSpinner from '~/components/LoadingSpinner'
@ -15,6 +16,7 @@ import { IconCheck, IconChevronDown, IconChevronUp, IconCpu, IconBooks } from '@
import StorageProjectionBar from '~/components/StorageProjectionBar'
import { useNotifications } from '~/context/NotificationContext'
import useInternetStatus from '~/hooks/useInternetStatus'
import useCreatorPacks from '~/hooks/useCreatorPacks'
import { useSystemInfo } from '~/hooks/useSystemInfo'
import { getPrimaryDiskInfo } from '~/hooks/useDiskDisplayData'
import classNames from 'classnames'
@ -106,7 +108,19 @@ const ADDITIONAL_TOOLS: Capability[] = [
},
]
type WizardStep = 1 | 2 | 3 | 4 | 5
// Stable step IDs. Creator Packs (4) and AI (5) are BOTH optional, so the set of
// active steps is computed at runtime (see `activeSteps`) and navigation walks
// that ordered list rather than doing hardcoded skip math.
type WizardStep = 1 | 2 | 3 | 4 | 5 | 6
const STEP_LABELS: Record<WizardStep, string> = {
1: 'Apps',
2: 'Maps',
3: 'Content',
4: 'Creator Packs',
5: 'AI',
6: 'Review',
}
const CURATED_MAP_COLLECTIONS_KEY = 'curated-map-collections'
const CURATED_CATEGORIES_KEY = 'curated-categories'
@ -121,6 +135,7 @@ export default function EasySetupWizard(props: {
const [currentStep, setCurrentStep] = useState<WizardStep>(1)
const [selectedServices, setSelectedServices] = useState<string[]>([])
const [selectedMapCollections, setSelectedMapCollections] = useState<string[]>([])
const [selectedCreatorPacks, setSelectedCreatorPacks] = useState<string[]>([])
const [selectedAiModels, setSelectedAiModels] = useState<string[]>([])
// Auto-index policy for the AI Assistant Knowledge Base. Defaults to
// 'Always' so a new user who keeps the default behavior gets the "just
@ -149,10 +164,13 @@ export default function EasySetupWizard(props: {
const { isOnline } = useInternetStatus()
const queryClient = useQueryClient()
const { data: systemInfo } = useSystemInfo({ enabled: true })
// Creator Packs are hidden entirely on builds without the release-injected key.
const { configured: creatorPacksConfigured, packs: creatorPacks } = useCreatorPacks()
const anySelectionMade =
selectedServices.length > 0 ||
selectedMapCollections.length > 0 ||
selectedCreatorPacks.length > 0 ||
selectedTiers.size > 0 ||
selectedAiModels.length > 0 ||
(selectedWikipedia !== null && selectedWikipedia !== 'none')
@ -212,12 +230,29 @@ export default function EasySetupWizard(props: {
[selectedServices, installedServices, remoteOllamaEnabled]
)
// Ordered list of the steps actually shown to THIS user. Creator Packs (4) and
// AI (5) are both optional; everything else is fixed. Navigation walks this
// list (see handleNext/handleBack), so a step's absence needs no skip math.
const activeSteps = useMemo<WizardStep[]>(() => {
const steps: WizardStep[] = [1, 2, 3]
if (creatorPacksConfigured) steps.push(4)
if (isAiInSetup) steps.push(5)
steps.push(6)
return steps
}, [creatorPacksConfigured, isAiInSetup])
const toggleMapCollection = (slug: string) => {
setSelectedMapCollections((prev) =>
prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]
)
}
const toggleCreatorPack = (id: string) => {
setSelectedCreatorPacks((prev) =>
prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id]
)
}
const toggleAiModel = (modelName: string) => {
setSelectedAiModels((prev) =>
prev.includes(modelName) ? prev.filter((m) => m !== modelName) : [...prev, modelName]
@ -280,6 +315,14 @@ export default function EasySetupWizard(props: {
})
}
// Add creator packs
selectedCreatorPacks.forEach((id) => {
const pack = creatorPacks.find((p) => p.id === id)
if (pack) {
totalBytes += pack.size_mb * 1024 * 1024
}
})
// Add AI models
if (recommendedModels) {
selectedAiModels.forEach((modelName) => {
@ -315,10 +358,12 @@ export default function EasySetupWizard(props: {
}, [
selectedTiers,
selectedMapCollections,
selectedCreatorPacks,
selectedAiModels,
selectedWikipedia,
categories,
mapCollections,
creatorPacks,
recommendedModels,
wikipediaState,
])
@ -326,10 +371,9 @@ export default function EasySetupWizard(props: {
// Get primary disk/filesystem info for storage projection
const storageInfo = getPrimaryDiskInfo(systemInfo?.disk, systemInfo?.fsSize)
// Final step number (4 when AI is off, 5 when AI is on). Centralizing this
// here so canProceedToNextStep / handleNext / handleBack / the bottom-bar
// Next-vs-Finish switch all read the same value.
const finalStep: WizardStep = isAiInSetup ? 5 : 4
// The review step is always the last active step. Read by canProceedToNextStep
// and the bottom-bar Next-vs-Finish switch.
const finalStep: WizardStep = activeSteps[activeSteps.length - 1]
const canProceedToNextStep = () => {
if (!isOnline) return false // Must be online to proceed
@ -337,18 +381,18 @@ export default function EasySetupWizard(props: {
return currentStep < finalStep
}
// Navigate to the next/previous ACTIVE step relative to the current one. Using
// a value comparison (not indexOf) keeps nav correct even if currentStep goes
// momentarily stale — e.g. the user disables AI while standing on the AI step,
// dropping it from activeSteps; the next click still lands on the right step.
const handleNext = () => {
if (currentStep >= finalStep) return
// Skip the AI step (4) on forward nav when isAiInSetup is false.
const next = currentStep === 3 && !isAiInSetup ? 5 : currentStep + 1
setCurrentStep(next as WizardStep)
const next = activeSteps.find((s) => s > currentStep)
if (next !== undefined) setCurrentStep(next)
}
const handleBack = () => {
if (currentStep <= 1) return
// Skip the AI step (4) on back nav when isAiInSetup is false.
const prev = currentStep === 5 && !isAiInSetup ? 3 : currentStep - 1
setCurrentStep(prev as WizardStep)
const prev = [...activeSteps].reverse().find((s) => s < currentStep)
if (prev !== undefined) setCurrentStep(prev)
}
const handleFinish = async () => {
@ -408,6 +452,7 @@ export default function EasySetupWizard(props: {
const downloadPromises = [
...selectedMapCollections.map((slug) => api.downloadMapCollection(slug)),
...categoryTierPromises,
...selectedCreatorPacks.map((id) => api.installCreatorPack(id)),
...selectedAiModels.map((modelName) => api.downloadModel(modelName)),
]
@ -471,24 +516,14 @@ export default function EasySetupWizard(props: {
const renderStepIndicator = () => {
// `step` is the stable WizardStep value (1=Apps, 2=Maps, 3=Content,
// 4=AI, 5=Review). `displayNumber` is the sequential position shown in
// the dot (always 1..N) so users see "1 2 3 4" when AI is off and
// "1 2 3 4 5" when AI is on, with no gap.
const baseSteps: Array<{ step: WizardStep; label: string }> = isAiInSetup
? [
{ step: 1, label: 'Apps' },
{ step: 2, label: 'Maps' },
{ step: 3, label: 'Content' },
{ step: 4, label: 'AI' },
{ step: 5, label: 'Review' },
]
: [
{ step: 1, label: 'Apps' },
{ step: 2, label: 'Maps' },
{ step: 3, label: 'Content' },
{ step: 5, label: 'Review' },
]
const steps = baseSteps.map((s, idx) => ({ ...s, displayNumber: idx + 1 }))
// 4=Creator Packs, 5=AI, 6=Review). Only the ACTIVE steps are shown, and
// `displayNumber` is the sequential position in the dot (always 1..N) so
// there's no gap when Creator Packs and/or AI are absent.
const steps = activeSteps.map((step, idx) => ({
step,
label: STEP_LABELS[step],
displayNumber: idx + 1,
}))
return (
<nav aria-label="Progress" className="px-6 pt-6">
@ -988,6 +1023,43 @@ export default function EasySetupWizard(props: {
)
}
const renderCreatorPacks = () => {
// Creator Packs step. Only present when this build is configured (see
// activeSteps); a fork built without the key never reaches it. Selection is
// by pack id, held in selectedCreatorPacks; installs fire in handleFinish.
return (
<div className="space-y-6">
<div className="text-center mb-6">
<h2 className="text-3xl font-bold text-text-primary mb-2">Stock Creator Packs</h2>
<p className="text-text-secondary">
Branded video collections from creators, downloaded for offline viewing in Kiwix.
</p>
</div>
{creatorPacks.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{creatorPacks.map((pack) => (
<CreatorPackCard
key={pack.id}
pack={pack}
selected={selectedCreatorPacks.includes(pack.id)}
onClick={
pack.status === 'installed' && !pack.available_update_version
? undefined
: () => isOnline && toggleCreatorPack(pack.id)
}
/>
))}
</div>
) : (
<div className="text-center py-12">
<p className="text-text-secondary text-lg">No creator packs available right now.</p>
</div>
)}
</div>
)
}
const renderStep4 = () => {
// AI step (issue #905). Only rendered when isAiInSetup is true; otherwise
// the wizard's step array drops it and forward/back nav jumps Content → Review.
@ -1135,6 +1207,7 @@ export default function EasySetupWizard(props: {
const hasSelections =
selectedServices.length > 0 ||
selectedMapCollections.length > 0 ||
selectedCreatorPacks.length > 0 ||
selectedTiers.size > 0 ||
selectedAiModels.length > 0 ||
(selectedWikipedia !== null && selectedWikipedia !== 'none')
@ -1197,6 +1270,25 @@ export default function EasySetupWizard(props: {
</div>
)}
{selectedCreatorPacks.length > 0 && (
<div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-text-primary mb-4">
Creator Packs to Install ({selectedCreatorPacks.length})
</h3>
<ul className="space-y-2">
{selectedCreatorPacks.map((id) => {
const pack = creatorPacks.find((p) => p.id === id)
return (
<li key={id} className="flex items-center">
<IconCheck size={20} className="text-desert-green mr-2" />
<span className="text-text-primary">{pack?.name || id}</span>
</li>
)
})}
</ul>
</div>
)}
{selectedTiers.size > 0 && (
<div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-text-primary mb-4">
@ -1335,8 +1427,9 @@ export default function EasySetupWizard(props: {
{currentStep === 1 && renderStep1()}
{currentStep === 2 && renderStep2()}
{currentStep === 3 && renderStep3()}
{currentStep === 4 && isAiInSetup && renderStep4()}
{currentStep === 5 && renderStep5()}
{currentStep === 4 && creatorPacksConfigured && renderCreatorPacks()}
{currentStep === 5 && isAiInSetup && renderStep4()}
{currentStep === 6 && renderStep5()}
<div className="flex justify-between mt-8 pt-4 border-t border-desert-stone-light">
<div className="flex space-x-4 items-center">
@ -1361,6 +1454,12 @@ export default function EasySetupWizard(props: {
, {selectedMapCollections.length} map region
{selectedMapCollections.length !== 1 && 's'}, {selectedTiers.size}{' '}
content categor{selectedTiers.size !== 1 ? 'ies' : 'y'},{' '}
{creatorPacksConfigured && (
<>
{selectedCreatorPacks.length} creator pack
{selectedCreatorPacks.length !== 1 && 's'},{' '}
</>
)}
{selectedAiModels.length} AI model{selectedAiModels.length !== 1 && 's'} selected
</p>
</div>

View File

@ -0,0 +1,37 @@
import { Head } from '@inertiajs/react'
import SettingsLayout from '~/layouts/SettingsLayout'
import CreatorPacksSection from '~/components/CreatorPacksSection'
import ActiveDownloads from '~/components/ActiveDownloads'
import useCreatorPacks from '~/hooks/useCreatorPacks'
export default function CreatorPacksPage() {
const { configured } = useCreatorPacks()
return (
<SettingsLayout>
<Head title="Creator Packs" />
<div className="xl:pl-72 w-full">
<main className="px-12 py-6">
<h1 className="text-4xl font-semibold mb-2">Creator Packs</h1>
<p className="text-text-muted mb-4">
Install branded video collections from creators. Packs download in the background and
play offline through Kiwix.
</p>
{configured ? (
<>
<CreatorPacksSection allowUninstall />
<div className="mt-10">
<ActiveDownloads filetype="zim" withHeader />
</div>
</>
) : (
<p className="text-text-muted mt-4">
Creator Packs aren&apos;t available on this build.
</p>
)}
</main>
</div>
</SettingsLayout>
)
}

View File

@ -33,6 +33,7 @@ import {
} from '@tabler/icons-react'
import useDebounce from '~/hooks/useDebounce'
import CategoryCard from '~/components/CategoryCard'
import CreatorPacksSection from '~/components/CreatorPacksSection'
import TierSelectionModal from '~/components/TierSelectionModal'
import WikipediaSelector from '~/components/WikipediaSelector'
import StyledSectionHeader from '~/components/StyledSectionHeader'
@ -506,6 +507,9 @@ export default function ZimRemoteExplorer() {
</div>
) : null}
{/* Creator Packs (hidden entirely when this build isn't configured) */}
<CreatorPacksSection />
{/* Tiered Category Collections */}
<div className="flex items-center gap-3 mt-8 mb-4">
<div className="w-10 h-10 rounded-full bg-surface-primary border border-border-subtle flex items-center justify-center shadow-sm">

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -62,4 +62,18 @@ export default await Env.create(new URL('../', import.meta.url), {
|----------------------------------------------------------
*/
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(),
})

View File

@ -19,6 +19,7 @@ import SettingsController from '#controllers/settings_controller'
import SupplyDepotController from '#controllers/supply_depot_controller'
import SystemController from '#controllers/system_controller'
import CollectionUpdatesController from '#controllers/collection_updates_controller'
import CreatorPacksController from '#controllers/creator_packs_controller'
import ZimController from '#controllers/zim_controller'
import router from '@adonisjs/core/services/router'
import transmit from '@adonisjs/transmit/services/main'
@ -55,6 +56,7 @@ router
router.get('/update', [SettingsController, 'update'])
router.get('/zim', [SettingsController, 'zim'])
router.get('/zim/remote-explorer', [SettingsController, 'zimRemote'])
router.get('/creator-packs', [SettingsController, 'creatorPacks'])
router.get('/benchmark', [SettingsController, 'benchmark'])
router.get('/support', [SettingsController, 'support'])
router.get('/advanced', [SettingsController, 'advanced'])
@ -225,6 +227,14 @@ router
})
.prefix('/api/zim')
router
.group(() => {
router.get('/', [CreatorPacksController, 'index'])
router.post('/:id/install', [CreatorPacksController, 'install'])
router.delete('/:id', [CreatorPacksController, 'uninstall'])
})
.prefix('/api/creator-packs')
router
.group(() => {
router.post('/run', [BenchmarkController, 'run'])

View File

@ -58,7 +58,51 @@ export type WikipediaSpec = {
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 branded 1060x175 banner (the card hero). When omitted, the app
* uses the banner it bundles by pack id (`/creator-packs/<id>.webp`). The
* Kiwix grid icon is baked into the ZIM separately.
*/
banner_url?: string
/** Optional card art. */
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'

View File

@ -7,6 +7,13 @@ export type DoResumableDownloadParams = {
onProgress?: (progress: DoResumableDownloadProgress) => void
onComplete?: (url: string, path: string) => void | Promise<void>
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 & {
@ -48,6 +55,13 @@ export type RunDownloadJobParams = Omit<
* `failed` handler) so manual downloads never touch the counter.
*/
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
}
}

View File

@ -0,0 +1,88 @@
# Project NOMAD Creator Pack License
**Version 1.0** &nbsp;·&nbsp; License ID: `nomad-creator-pack-1.0`
> **DRAFT — pending legal review.** This document is a starting point drafted for
> convenience, not legal advice, and has not been reviewed by an attorney. The
> terms below describe the intended arrangement; have counsel review and finalize
> before relying on it. Nothing here is a binding offer until finalized.
---
## 1. What this covers
This license governs **Creator Pack content** — the curated video collections
(and their titles, descriptions, thumbnails, and packaging) distributed through
Project NOMAD as "Creator Packs." It does **not** cover the Project NOMAD
software itself, which is licensed separately under Apache License 2.0. Where the
two ever appear to conflict, the Apache 2.0 license governs the software and this
license governs the pack content.
## 2. Ownership
Each Creator retains all copyright and other rights in their own videos and
associated materials. Distribution through Project NOMAD does not transfer
ownership. "Creator" means the individual or organization whose content a pack
contains (for example, Crosstalk Solutions or Project NOMAD).
## 3. Grant to Project NOMAD
Each Creator grants Project NOMAD (Crosstalk Solutions, LLC) a non-exclusive,
revocable right to reproduce, package, host, and distribute their Creator Pack
**through the official Project NOMAD distribution channel only**. This grant does
not permit Project NOMAD to sublicense the content for redistribution outside
that official channel.
## 4. License to end users
Subject to these terms, an end user who installs a Creator Pack on their own
Project NOMAD server is granted a **personal, non-commercial, non-transferable,
non-exclusive** license to store and view that pack's content offline on their
own device(s) for their own use and that of their household or immediate
organization.
## 5. Restrictions
Except as expressly permitted above, you may **not**:
1. **Redistribute or re-host** any Creator Pack or its contents — including
copying pack files to another server, mirror, CDN, bucket, torrent, or file
share, or making them available for download by others.
2. **Bundle or ship** Creator Pack content with any fork, derivative, or
third-party distribution of Project NOMAD or any other product.
3. **Serve** Creator Pack content from any distribution channel other than the
official Project NOMAD channel, or circumvent the entitlement controls that
gate access to it.
4. **Sell, rent, sublicense, or commercially exploit** the content, or use it to
train machine-learning models.
5. **Remove or alter** creator branding, attribution, or license notices.
For clarity: the Apache 2.0 license on the Project NOMAD **software** permits
forking the software, but it grants **no rights** to the Creator Pack **content**,
which remains governed exclusively by this license. A fork may not distribute or
serve Creator Packs.
## 6. Termination
This license terminates automatically if you breach it, and Project NOMAD or a
Creator may revoke it at any time. On termination you must stop using and delete
the affected Creator Pack content. Sections 2, 5, 7, and 8 survive termination.
## 7. No warranty
Creator Pack content is provided **"as is," without warranty of any kind**,
express or implied, including merchantability, fitness for a particular purpose,
and non-infringement.
## 8. Limitation of liability
To the maximum extent permitted by law, neither Project NOMAD, Crosstalk
Solutions, LLC, nor any Creator is liable for any indirect, incidental, special,
consequential, or punitive damages arising from the Creator Pack content or this
license.
---
*Questions about this license or Creator Pack participation: contact Project NOMAD
via https://www.projectnomad.us. Governing law and venue to be specified on legal
review.*

View File

@ -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"
}
]
}