180 lines
6.3 KiB
TypeScript
180 lines
6.3 KiB
TypeScript
import logger from '@adonisjs/core/services/logger'
|
|
import axios from 'axios'
|
|
import { DateTime } from 'luxon'
|
|
import InstalledResource from '#models/installed_resource'
|
|
import { RunDownloadJob } from '../jobs/run_download_job.js'
|
|
import { ZIM_STORAGE_PATH } from '../utils/fs.js'
|
|
import { join } from 'path'
|
|
import type {
|
|
ResourceUpdateInfo,
|
|
ContentUpdateCheckResult,
|
|
} from '../../types/collections.js'
|
|
import { KiwixCatalogService, reconcileResourceUpdateState } from './kiwix_catalog_service.js'
|
|
|
|
const MAP_STORAGE_PATH = '/storage/maps'
|
|
|
|
const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream']
|
|
const PMTILES_MIME_TYPES = ['application/vnd.pmtiles', 'application/octet-stream']
|
|
|
|
export class CollectionUpdateService {
|
|
/**
|
|
* Check every installed resource against the upstream catalogs locally (Kiwix
|
|
* OPDS for ZIMs, GitHub for maps) — no longer routed through the external
|
|
* project-nomad-api. Side-effect: persists each resource's available-update
|
|
* state (version + cool-off anchor) so the auto-updater can act on it later.
|
|
*/
|
|
async checkForUpdates(): Promise<ContentUpdateCheckResult> {
|
|
const installed = await InstalledResource.all()
|
|
if (installed.length === 0) {
|
|
return {
|
|
updates: [],
|
|
checked_at: new Date().toISOString(),
|
|
}
|
|
}
|
|
|
|
try {
|
|
const catalog = new KiwixCatalogService()
|
|
const latestByKey = await catalog.getLatestForResources(
|
|
installed.map((r) => ({ resource_id: r.resource_id, resource_type: r.resource_type }))
|
|
)
|
|
|
|
const now = DateTime.now()
|
|
const updates: ResourceUpdateInfo[] = []
|
|
for (const resource of installed) {
|
|
const latest = latestByKey.get(`${resource.resource_type}:${resource.resource_id}`) ?? null
|
|
await reconcileResourceUpdateState(resource, latest, now)
|
|
|
|
if (latest && latest.version > resource.version) {
|
|
updates.push({
|
|
resource_id: resource.resource_id,
|
|
resource_type: resource.resource_type,
|
|
installed_version: resource.version,
|
|
latest_version: latest.version,
|
|
download_url: latest.download_url,
|
|
size_bytes: latest.size_bytes || undefined,
|
|
})
|
|
}
|
|
}
|
|
|
|
logger.info(
|
|
`[CollectionUpdateService] Local update check complete: ${updates.length} update(s) available`
|
|
)
|
|
|
|
const enriched = await this.enrichWithSizes(updates)
|
|
return {
|
|
updates: enriched,
|
|
checked_at: new Date().toISOString(),
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Unknown error during update check'
|
|
logger.error(`[CollectionUpdateService] Failed to check for updates: ${message}`)
|
|
return {
|
|
updates: [],
|
|
checked_at: new Date().toISOString(),
|
|
error: 'Failed to check for content updates. Please try again later.',
|
|
}
|
|
}
|
|
}
|
|
|
|
async applyUpdate(
|
|
update: ResourceUpdateInfo,
|
|
options?: { auto?: boolean }
|
|
): Promise<{ success: boolean; jobId?: string; error?: string }> {
|
|
// Check if a download is already in progress for this URL
|
|
const existingJob = await RunDownloadJob.getByUrl(update.download_url)
|
|
if (existingJob) {
|
|
const state = await existingJob.getState()
|
|
if (state === 'active' || state === 'waiting' || state === 'delayed') {
|
|
return {
|
|
success: false,
|
|
error: `A download is already in progress for ${update.resource_id}`,
|
|
}
|
|
}
|
|
}
|
|
|
|
const filename = this.buildFilename(update)
|
|
const filepath = this.buildFilepath(update, filename)
|
|
|
|
const result = await RunDownloadJob.dispatch({
|
|
url: update.download_url,
|
|
filepath,
|
|
timeout: 30000,
|
|
allowedMimeTypes:
|
|
update.resource_type === 'zim' ? ZIM_MIME_TYPES : PMTILES_MIME_TYPES,
|
|
forceNew: true,
|
|
filetype: update.resource_type,
|
|
title: update.resource_id,
|
|
totalBytes: update.size_bytes,
|
|
resourceMetadata: {
|
|
resource_id: update.resource_id,
|
|
version: update.latest_version,
|
|
collection_ref: null,
|
|
auto: options?.auto ?? false,
|
|
},
|
|
})
|
|
|
|
if (!result || !result.job) {
|
|
return { success: false, error: 'Failed to dispatch download job' }
|
|
}
|
|
|
|
logger.info(
|
|
`[CollectionUpdateService] Dispatched update download for ${update.resource_id}: ${update.installed_version} → ${update.latest_version}`
|
|
)
|
|
|
|
return { success: true, jobId: result.job.id }
|
|
}
|
|
|
|
async applyAllUpdates(
|
|
updates: ResourceUpdateInfo[]
|
|
): Promise<{ results: Array<{ resource_id: string; success: boolean; jobId?: string; error?: string }> }> {
|
|
const results = await Promise.all(
|
|
updates.map(async (update) => {
|
|
const result = await this.applyUpdate(update)
|
|
return { resource_id: update.resource_id, ...result }
|
|
})
|
|
)
|
|
|
|
return { results }
|
|
}
|
|
|
|
/**
|
|
* Fetch Content-Length for each update URL in parallel. HEAD failures are non-fatal —
|
|
* the update row just renders without a size. Bounded to HEAD_TIMEOUT_MS so a slow
|
|
* mirror doesn't block the whole check.
|
|
*/
|
|
private async enrichWithSizes(updates: ResourceUpdateInfo[]): Promise<ResourceUpdateInfo[]> {
|
|
const HEAD_TIMEOUT_MS = 5000
|
|
|
|
return await Promise.all(
|
|
updates.map(async (update) => {
|
|
if (update.size_bytes) return update // Trust upstream if it already gave us one
|
|
try {
|
|
const head = await axios.head(update.download_url, {
|
|
timeout: HEAD_TIMEOUT_MS,
|
|
maxRedirects: 5,
|
|
validateStatus: (s) => s >= 200 && s < 400,
|
|
})
|
|
const len = Number(head.headers['content-length'])
|
|
return Number.isFinite(len) && len > 0 ? { ...update, size_bytes: len } : update
|
|
} catch {
|
|
return update
|
|
}
|
|
})
|
|
)
|
|
}
|
|
|
|
private buildFilename(update: ResourceUpdateInfo): string {
|
|
if (update.resource_type === 'zim') {
|
|
return `${update.resource_id}_${update.latest_version}.zim`
|
|
}
|
|
return `${update.resource_id}_${update.latest_version}.pmtiles`
|
|
}
|
|
|
|
private buildFilepath(update: ResourceUpdateInfo, filename: string): string {
|
|
if (update.resource_type === 'zim') {
|
|
return join(process.cwd(), ZIM_STORAGE_PATH, filename)
|
|
}
|
|
return join(process.cwd(), MAP_STORAGE_PATH, 'pmtiles', filename)
|
|
}
|
|
}
|