fix: rework content tier system to dynamically determine install status

Removes the InstalledTier model and instead checks presence of files on-the-fly. Avoid broken state by handling on the server-side vs. marking as installed by client-side API call
This commit is contained in:
Jake Turner 2026-02-04 22:55:04 -08:00 committed by Jake Turner
parent fcc749ec57
commit 36b6d8ed7a
9 changed files with 87 additions and 78 deletions

View File

@ -3,7 +3,6 @@ import {
downloadCollectionValidator,
filenameParamValidator,
remoteDownloadWithMetadataValidator,
saveInstalledTierValidator,
selectWikipediaValidator,
} from '#validators/common'
import { listRemoteZimValidator } from '#validators/zim'
@ -56,12 +55,6 @@ export default class ZimController {
return { success }
}
async saveInstalledTier({ request }: HttpContext) {
const payload = await request.validateUsing(saveInstalledTierValidator)
await this.zimService.saveInstalledTier(payload.categorySlug, payload.tierSlug)
return { success: true }
}
async delete({ request, response }: HttpContext) {
const payload = await request.validateUsing(filenameParamValidator)

View File

@ -1,21 +0,0 @@
import { DateTime } from 'luxon'
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
export default class InstalledTier extends BaseModel {
static namingStrategy = new SnakeCaseNamingStrategy()
@column({ isPrimary: true })
declare id: number
@column()
declare category_slug: string
@column()
declare tier_slug: string
@column.dateTime({ autoCreate: true })
declare created_at: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updated_at: DateTime
}

View File

@ -22,7 +22,6 @@ import vine from '@vinejs/vine'
import { curatedCategoriesFileSchema, curatedCollectionsFileSchema, wikipediaOptionsFileSchema } from '#validators/curated_collections'
import CuratedCollection from '#models/curated_collection'
import CuratedCollectionResource from '#models/curated_collection_resource'
import InstalledTier from '#models/installed_tier'
import WikipediaSelection from '#models/wikipedia_selection'
import ZimFileMetadata from '#models/zim_file_metadata'
import { RunDownloadJob } from '#jobs/run_download_job'
@ -329,29 +328,65 @@ export class ZimService implements IZimService {
data,
});
// Look up installed tiers for all categories
const installedTiers = await InstalledTier.all()
const installedTierMap = new Map(
installedTiers.map((t) => [t.category_slug, t.tier_slug])
// Dynamically determine installed tier for each category
const categoriesWithStatus = await Promise.all(
validated.categories.map(async (category) => {
const installedTierSlug = await this.getInstalledTierForCategory(category)
return {
...category,
installedTierSlug,
}
})
)
// Add installedTierSlug to each category
return validated.categories.map((category) => ({
...category,
installedTierSlug: installedTierMap.get(category.slug),
}))
return categoriesWithStatus
} catch (error) {
logger.error(`[ZimService] Failed to fetch curated categories:`, error)
throw new Error('Failed to fetch curated categories or invalid format was received')
}
}
async saveInstalledTier(categorySlug: string, tierSlug: string): Promise<void> {
await InstalledTier.updateOrCreate(
{ category_slug: categorySlug },
{ tier_slug: tierSlug }
)
logger.info(`[ZimService] Saved installed tier: ${categorySlug} -> ${tierSlug}`)
/**
* Dynamically determines which tier is installed for a category by checking
* which tier's resources are all downloaded. Returns the highest tier that
* is fully installed (considering that higher tiers include lower tier resources)
*/
private async getInstalledTierForCategory(category: CuratedCategory): Promise<string | undefined> {
const { files: diskFiles } = await this.list()
const diskFilenames = new Set(diskFiles.map((f) => f.name))
// Get all CuratedCollectionResources marked as downloaded
const downloadedResources = await CuratedCollectionResource.query()
.where('downloaded', true)
.select('url')
const downloadedUrls = new Set(downloadedResources.map((r) => r.url))
// Check each tier from highest to lowest (assuming tiers are ordered from low to high)
// We check in reverse to find the highest fully-installed tier
const reversedTiers = [...category.tiers].reverse()
for (const tier of reversedTiers) {
const allResourcesInstalled = tier.resources.every((resource) => {
// Check if resource is marked as downloaded in database
if (downloadedUrls.has(resource.url)) {
return true
}
// Fallback: check if file exists on disk (for resources not tracked in CuratedCollectionResource)
const filename = resource.url.split('/').pop()
if (filename && diskFilenames.has(filename)) {
return true
}
return false
})
if (allResourcesInstalled && tier.resources.length > 0) {
return tier.slug
}
}
return undefined
}
async listCuratedCollections(): Promise<CuratedCollectionWithStatus[]> {
@ -372,18 +407,26 @@ export class ZimService implements IZimService {
})
for (const collection of validated.collections) {
const collectionResult = await CuratedCollection.updateOrCreate(
{ slug: collection.slug },
const { resources, ...restCollection } = collection; // we'll handle resources separately
// Upsert the collection itself
await CuratedCollection.updateOrCreate(
{ slug: restCollection.slug },
{
...collection,
...restCollection,
type: 'zim',
}
)
logger.info(`[ZimService] Upserted curated collection: ${collection.slug}`)
logger.info(`[ZimService] Upserted curated collection: ${restCollection.slug}`)
// Upsert collection's resources
const resourcesResult = await CuratedCollectionResource.updateOrCreateMany('url', resources.map((res) => ({
...res,
curated_collection_slug: restCollection.slug, // add the foreign key
})))
await collectionResult.related('resources').createMany(collection.resources)
logger.info(
`[ZimService] Upserted ${collection.resources.length} resources for collection: ${collection.slug}`
`[ZimService] Upserted ${resourcesResult.length} resources for collection: ${restCollection.slug}`
)
}

View File

@ -56,13 +56,6 @@ export const downloadCollectionValidator = vine.compile(
})
)
export const saveInstalledTierValidator = vine.compile(
vine.object({
categorySlug: vine.string().trim().minLength(1),
tierSlug: vine.string().trim().minLength(1),
})
)
export const selectWikipediaValidator = vine.compile(
vine.object({
optionId: vine.string().trim().minLength(1),

View File

@ -0,0 +1,20 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'installed_tiers'
async up() {
this.schema.dropTableIfExists(this.tableName)
}
async down() {
// Recreate the table if we need to rollback
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('category_slug').notNullable().unique()
table.string('tier_slug').notNullable()
table.timestamp('created_at', { useTz: true })
table.timestamp('updated_at', { useTz: true })
})
}
}

View File

@ -354,16 +354,6 @@ class API {
})()
}
async saveInstalledTier(categorySlug: string, tierSlug: string) {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean }>('/zim/save-installed-tier', {
categorySlug,
tierSlug,
})
return response.data
})()
}
async listDocs() {
return catchInternal(async () => {
const response = await this.client.get<Array<{ title: string; slug: string }>>('/docs/list')

View File

@ -403,12 +403,6 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
await Promise.all(downloadPromises)
// Save installed tiers for each selected category
const tierSavePromises = Array.from(selectedTiers.entries()).map(
([categorySlug, tier]) => api.saveInstalledTier(categorySlug, tier.slug)
)
await Promise.all(tierSavePromises)
// Select Wikipedia option if one was chosen
if (selectedWikipedia && selectedWikipedia !== wikipediaState?.currentSelection?.optionId) {
await api.selectWikipedia(selectedWikipedia)

View File

@ -241,15 +241,12 @@ export default function ZimRemoteExplorer() {
// Get all resources for this tier (including inherited ones)
const resources = getAllResourcesForTier(tier, category.tiers)
// Download each resource and save the installed tier
// Download each resource
try {
for (const resource of resources) {
await api.downloadRemoteZimFile(resource.url)
}
// Save the installed tier
await api.saveInstalledTier(category.slug, tier.slug)
addNotification({
message: `Started downloading ${resources.length} files from "${category.name} - ${tier.name}"`,
type: 'success',

View File

@ -149,7 +149,7 @@ router
router.post('/fetch-latest-collections', [ZimController, 'fetchLatestCollections'])
router.post('/download-remote', [ZimController, 'downloadRemote'])
router.post('/download-collection', [ZimController, 'downloadCollection'])
router.post('/save-installed-tier', [ZimController, 'saveInstalledTier'])
router.get('/wikipedia', [ZimController, 'getWikipediaState'])
router.post('/wikipedia/select', [ZimController, 'selectWikipedia'])
router.delete('/:filename', [ZimController, 'delete'])