feat: improve metadata sync compatibility across Flashpoint editions
- Replace hardcoded edition check with runtime schema introspection to detect available game columns dynamically - Move GameMetadataSource type to PreferencesService for reuse across sync and update services - Add hasMetadataSource() check instead of edition-based feature gating - Handle orphaned parent references by nullifying parentGameId when parent doesn't exist in batch or database - Disable foreign key checks during bulk insert/delete operations to match Flashpoint Launcher behavior - Build combined Ruffle changelog showing all changes since current version instead of just latest release - Add skeleton loading state to MetadataUpdateCard during initial fetch - Reduce per-game cycle warning spam to single summary log
This commit is contained in:
@@ -49,16 +49,18 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
// Includes edition info so frontend knows whether sync is available
|
||||
// Includes hasMetadataSource so frontend knows whether sync is available
|
||||
router.get(
|
||||
'/metadata',
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const edition = metadataUpdateService.getEdition();
|
||||
logger.debug(`[Updates API] Checking for metadata updates... (edition: ${edition})`);
|
||||
const hasMetadataSource = await metadataUpdateService.hasMetadataSource();
|
||||
logger.debug(
|
||||
`[Updates API] Checking for metadata updates... (hasMetadataSource: ${hasMetadataSource})`
|
||||
);
|
||||
const metadataInfo = await metadataUpdateService.getMetadataUpdateInfo();
|
||||
res.json({ ...metadataInfo, edition });
|
||||
res.json({ ...metadataInfo, hasMetadataSource });
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -443,6 +443,18 @@ export class DatabaseService {
|
||||
return this.db !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get column names for a table using PRAGMA table_info
|
||||
* Used for runtime schema introspection (e.g., edition-safe metadata sync)
|
||||
*/
|
||||
static getTableColumns(tableName: string): Set<string> {
|
||||
const db = this.getDatabase();
|
||||
const columns = db
|
||||
.prepare(`PRAGMA table_info(${tableName})`)
|
||||
.all() as Array<{ name: string }>;
|
||||
return new Set(columns.map((col) => col.name));
|
||||
}
|
||||
|
||||
static getStatus(): {
|
||||
connected: boolean;
|
||||
sourcePath: string;
|
||||
|
||||
@@ -4,7 +4,11 @@ import axios from 'axios';
|
||||
import { config } from '../config';
|
||||
import { logger } from '../utils/logger';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import { GameMetadataSource } from './MetadataUpdateService';
|
||||
import {
|
||||
PreferencesService,
|
||||
GameMetadataSource,
|
||||
FlashpointPreferences,
|
||||
} from './PreferencesService';
|
||||
import { SyncStatusService } from './SyncStatusService';
|
||||
|
||||
interface SyncResult {
|
||||
@@ -71,21 +75,11 @@ interface ApiPlatform {
|
||||
Deleted?: boolean;
|
||||
}
|
||||
|
||||
interface MetadataSourceTimestamp {
|
||||
actualUpdateTime?: string;
|
||||
latestUpdateTime?: string;
|
||||
}
|
||||
|
||||
interface MetadataSource {
|
||||
name: string;
|
||||
games: MetadataSourceTimestamp;
|
||||
tags: MetadataSourceTimestamp;
|
||||
platforms?: MetadataSourceTimestamp;
|
||||
}
|
||||
|
||||
interface FlashpointPreferences {
|
||||
gameMetadataSources: MetadataSource[];
|
||||
[key: string]: unknown; // Allow other properties from preferences.json
|
||||
/** Column mapping for game table - maps DB column to value extractor from API game */
|
||||
interface GameColumnMapping {
|
||||
dbCol: string;
|
||||
getValue: (game: ApiGame) => string | number | null;
|
||||
excludeFromUpdate?: boolean; // If true, don't include in ON CONFLICT UPDATE clause
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,16 +89,61 @@ interface FlashpointPreferences {
|
||||
*/
|
||||
export class MetadataSyncService {
|
||||
private static readonly ALLOWED_HOSTS = ['fpfss.unstable.life', 'fpfss.flashpointarchive.org'];
|
||||
|
||||
/** All possible game columns with their value extractors */
|
||||
private static readonly GAME_COLUMN_MAPPINGS: GameColumnMapping[] = [
|
||||
{ dbCol: 'id', getValue: (g) => g.id, excludeFromUpdate: true },
|
||||
{ dbCol: 'parentGameId', getValue: (g) => g.parent_game_id ?? null },
|
||||
{ dbCol: 'title', getValue: (g) => g.title ?? '' },
|
||||
{ dbCol: 'alternateTitles', getValue: (g) => g.alternate_titles ?? '' },
|
||||
{ dbCol: 'series', getValue: (g) => g.series ?? '' },
|
||||
{ dbCol: 'developer', getValue: (g) => g.developer ?? '' },
|
||||
{ dbCol: 'publisher', getValue: (g) => g.publisher ?? '' },
|
||||
{ dbCol: 'platformName', getValue: (g) => g.platform ?? g.platform_name ?? '' },
|
||||
{ dbCol: 'playMode', getValue: (g) => g.play_mode ?? '' },
|
||||
{ dbCol: 'status', getValue: (g) => g.status ?? '' },
|
||||
{ dbCol: 'broken', getValue: (g) => (g.broken ? 1 : 0) },
|
||||
{ dbCol: 'extreme', getValue: (g) => (g.extreme ? 1 : 0) },
|
||||
{ dbCol: 'notes', getValue: (g) => g.notes ?? '' },
|
||||
{ dbCol: 'source', getValue: (g) => g.source ?? '' },
|
||||
{ dbCol: 'applicationPath', getValue: (g) => g.application_path ?? '' },
|
||||
{ dbCol: 'launchCommand', getValue: (g) => g.launch_command ?? '' },
|
||||
{ dbCol: 'releaseDate', getValue: (g) => g.release_date ?? '' },
|
||||
{ dbCol: 'version', getValue: (g) => g.version ?? '' },
|
||||
{ dbCol: 'originalDescription', getValue: (g) => g.original_description ?? '' },
|
||||
{ dbCol: 'language', getValue: (g) => g.language ?? '' },
|
||||
{ dbCol: 'orderTitle', getValue: (g) => g.order_title ?? g.title ?? '' },
|
||||
{ dbCol: 'library', getValue: (g) => g.library ?? 'arcade' },
|
||||
{ dbCol: 'tagsStr', getValue: (g) => g.tags_str ?? '' },
|
||||
{ dbCol: 'dateAdded', getValue: (g) => g.date_added ?? new Date().toISOString(), excludeFromUpdate: true },
|
||||
{ dbCol: 'dateModified', getValue: (g) => g.date_modified ?? new Date().toISOString() },
|
||||
{ dbCol: 'activeDataId', getValue: (g) => g.active_data_id ?? null },
|
||||
{ dbCol: 'activeDataOnDisk', getValue: (g) => (g.active_data_on_disk ? 1 : 0) },
|
||||
{ dbCol: 'archiveState', getValue: (g) => g.archive_state ?? 0 },
|
||||
{ dbCol: 'ruffleSupport', getValue: (g) => g.ruffle_support ?? '' },
|
||||
];
|
||||
|
||||
private preferencesPath: string;
|
||||
private syncStatusService: SyncStatusService;
|
||||
private availableGameColumns: Set<string> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.preferencesPath = path.join(config.flashpointPath, 'preferences.json');
|
||||
this.syncStatusService = SyncStatusService.getInstance();
|
||||
}
|
||||
|
||||
private getEdition(): string {
|
||||
return config.flashpointEdition;
|
||||
/** Get available columns in the game table (cached per sync) */
|
||||
private getAvailableGameColumns(): Set<string> {
|
||||
if (!this.availableGameColumns) {
|
||||
this.availableGameColumns = DatabaseService.getTableColumns('game');
|
||||
}
|
||||
return this.availableGameColumns;
|
||||
}
|
||||
|
||||
/** Get active column mappings (only columns that exist in the database) */
|
||||
private getActiveColumnMappings(): GameColumnMapping[] {
|
||||
const availableCols = this.getAvailableGameColumns();
|
||||
return MetadataSyncService.GAME_COLUMN_MAPPINGS.filter((m) => availableCols.has(m.dbCol));
|
||||
}
|
||||
|
||||
private validateBaseUrl(baseUrl: string): void {
|
||||
@@ -122,19 +161,6 @@ export class MetadataSyncService {
|
||||
}
|
||||
|
||||
async syncMetadata(alreadyStarted = false): Promise<SyncResult> {
|
||||
// Ultimate edition does not support metadata sync
|
||||
if (this.getEdition() === 'ultimate') {
|
||||
logger.warn('[MetadataSync] Metadata sync is not available for Ultimate edition');
|
||||
return {
|
||||
success: false,
|
||||
gamesUpdated: 0,
|
||||
gamesDeleted: 0,
|
||||
tagsUpdated: 0,
|
||||
platformsUpdated: 0,
|
||||
error: 'Metadata sync is not available for Flashpoint Ultimate edition',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
const startTime = new Date();
|
||||
let gamesUpdated = 0;
|
||||
let gamesDeleted = 0;
|
||||
@@ -144,12 +170,27 @@ export class MetadataSyncService {
|
||||
let latestTagDate: string | null = null;
|
||||
let latestPlatformDate: string | null = null;
|
||||
|
||||
// Reset column cache at start of sync (schema may have changed)
|
||||
this.availableGameColumns = null;
|
||||
|
||||
try {
|
||||
// Mark sync as started (skip if already started atomically)
|
||||
if (!alreadyStarted) {
|
||||
this.syncStatusService.startSync();
|
||||
}
|
||||
|
||||
// Check available columns and log any that will be skipped
|
||||
const activeColumns = this.getActiveColumnMappings();
|
||||
const allColumns = MetadataSyncService.GAME_COLUMN_MAPPINGS.map((m) => m.dbCol);
|
||||
const activeColumnNames = new Set(activeColumns.map((m) => m.dbCol));
|
||||
const skippedColumns = allColumns.filter((c) => !activeColumnNames.has(c));
|
||||
if (skippedColumns.length > 0) {
|
||||
logger.info(
|
||||
`[MetadataSync] Skipping columns not in database schema: ${skippedColumns.join(', ')}`
|
||||
);
|
||||
}
|
||||
logger.debug(`[MetadataSync] Using ${activeColumns.length} of ${allColumns.length} columns`);
|
||||
|
||||
// Read preferences file
|
||||
this.syncStatusService.updateProgress('reading-config', 5, 'Reading preferences...');
|
||||
const preferencesContent = await fs.readFile(this.preferencesPath, 'utf-8');
|
||||
@@ -472,22 +513,63 @@ export class MetadataSyncService {
|
||||
/**
|
||||
* Apply game updates to database (upsert)
|
||||
* Chunks batches to stay under SQLite's SQLITE_MAX_VARIABLE_NUMBER (default 999).
|
||||
* With 29 columns per game, max ~30 games per chunk (30 * 29 = 870 < 999).
|
||||
*/
|
||||
private static readonly MAX_GAMES_PER_CHUNK = 30; // 30 * 29 = 870 params, under 999 limit
|
||||
|
||||
private async applyGames(games: ApiGame[]): Promise<void> {
|
||||
if (games.length === 0) return;
|
||||
|
||||
// Sort games to ensure parents are inserted before children
|
||||
// This prevents FOREIGN KEY constraint errors on parentGameId
|
||||
const sortedGames = this.sortGamesByParentDependency(games);
|
||||
|
||||
const db = DatabaseService.getDatabase();
|
||||
|
||||
// Get existing game IDs to check parent references
|
||||
const gameIdsInBatch = new Set(games.map((g) => g.id));
|
||||
const parentIdsToCheck = new Set<string>();
|
||||
|
||||
// Collect parent IDs that need to be verified in database
|
||||
games.forEach((game) => {
|
||||
if (game.parent_game_id && !gameIdsInBatch.has(game.parent_game_id)) {
|
||||
parentIdsToCheck.add(game.parent_game_id);
|
||||
}
|
||||
});
|
||||
|
||||
// Check which parents exist in database
|
||||
const existingParentIds = new Set<string>();
|
||||
if (parentIdsToCheck.size > 0) {
|
||||
const parentIdArray = Array.from(parentIdsToCheck);
|
||||
const BATCH_SIZE = 500;
|
||||
for (let i = 0; i < parentIdArray.length; i += BATCH_SIZE) {
|
||||
const batch = parentIdArray.slice(i, i + BATCH_SIZE);
|
||||
const placeholders = batch.map(() => '?').join(', ');
|
||||
const rows = db.prepare(`SELECT id FROM game WHERE id IN (${placeholders})`).all(batch) as Array<{ id: string }>;
|
||||
rows.forEach((row) => existingParentIds.add(row.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Nullify parentGameId for games referencing non-existent parents
|
||||
const processedGames = games.map((game) => {
|
||||
if (game.parent_game_id) {
|
||||
const parentInBatch = gameIdsInBatch.has(game.parent_game_id);
|
||||
const parentInDb = existingParentIds.has(game.parent_game_id);
|
||||
if (!parentInBatch && !parentInDb) {
|
||||
logger.debug(
|
||||
`[MetadataSync] Nullifying parentGameId for game ${game.id} - parent ${game.parent_game_id} does not exist`
|
||||
);
|
||||
return { ...game, parent_game_id: undefined };
|
||||
}
|
||||
}
|
||||
return game;
|
||||
});
|
||||
|
||||
// Sort games to ensure parents are inserted before children (within this batch)
|
||||
// This prevents FOREIGN KEY constraint errors on parentGameId
|
||||
const sortedGames = this.sortGamesByParentDependency(processedGames);
|
||||
|
||||
// Calculate max games per chunk based on active columns
|
||||
// SQLite default SQLITE_MAX_VARIABLE_NUMBER is 999
|
||||
const activeColumnCount = this.getActiveColumnMappings().length;
|
||||
const maxGamesPerChunk = Math.floor(999 / activeColumnCount);
|
||||
|
||||
// Process in chunks to stay under SQLite variable limit
|
||||
for (let i = 0; i < sortedGames.length; i += MetadataSyncService.MAX_GAMES_PER_CHUNK) {
|
||||
const chunk = sortedGames.slice(i, i + MetadataSyncService.MAX_GAMES_PER_CHUNK);
|
||||
for (let i = 0; i < sortedGames.length; i += maxGamesPerChunk) {
|
||||
const chunk = sortedGames.slice(i, i + maxGamesPerChunk);
|
||||
this.applyGamesChunk(db, chunk);
|
||||
}
|
||||
|
||||
@@ -496,97 +578,58 @@ export class MetadataSyncService {
|
||||
|
||||
/**
|
||||
* Apply a single chunk of games to the database
|
||||
* Uses dynamic column selection based on available schema
|
||||
*
|
||||
* Note: Foreign key checks are temporarily disabled during insert to handle
|
||||
* circular parent dependencies (e.g., game A -> B -> A). This is a common
|
||||
* pattern for bulk metadata sync operations.
|
||||
*/
|
||||
private applyGamesChunk(
|
||||
db: ReturnType<typeof DatabaseService.getDatabase>,
|
||||
chunk: ApiGame[]
|
||||
): void {
|
||||
const placeholders = chunk
|
||||
.map(
|
||||
() =>
|
||||
'(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.join(', ');
|
||||
const activeColumns = this.getActiveColumnMappings();
|
||||
|
||||
// Build column names list
|
||||
const columnNames = activeColumns.map((m) => m.dbCol).join(', ');
|
||||
|
||||
// Build placeholders for each game row
|
||||
const rowPlaceholder = `(${activeColumns.map(() => '?').join(', ')})`;
|
||||
const placeholders = chunk.map(() => rowPlaceholder).join(', ');
|
||||
|
||||
// Build values array using only active columns
|
||||
const values: (string | number | null)[] = [];
|
||||
chunk.forEach((game) => {
|
||||
values.push(
|
||||
game.id,
|
||||
game.parent_game_id ?? null,
|
||||
game.title ?? '',
|
||||
game.alternate_titles ?? '',
|
||||
game.series ?? '',
|
||||
game.developer ?? '',
|
||||
game.publisher ?? '',
|
||||
game.platform ?? game.platform_name ?? '',
|
||||
game.play_mode ?? '',
|
||||
game.status ?? '',
|
||||
game.broken ? 1 : 0,
|
||||
game.extreme ? 1 : 0,
|
||||
game.notes ?? '',
|
||||
game.source ?? '',
|
||||
game.application_path ?? '',
|
||||
game.launch_command ?? '',
|
||||
game.release_date ?? '',
|
||||
game.version ?? '',
|
||||
game.original_description ?? '',
|
||||
game.language ?? '',
|
||||
game.order_title ?? game.title ?? '',
|
||||
game.library ?? 'arcade',
|
||||
game.tags_str ?? '',
|
||||
game.date_added ?? new Date().toISOString(),
|
||||
game.date_modified ?? new Date().toISOString(),
|
||||
game.active_data_id ?? null,
|
||||
game.active_data_on_disk ? 1 : 0,
|
||||
game.archive_state ?? 0,
|
||||
game.ruffle_support ?? ''
|
||||
);
|
||||
activeColumns.forEach((col) => {
|
||||
values.push(col.getValue(game));
|
||||
});
|
||||
});
|
||||
|
||||
// Build UPDATE SET clause (exclude columns marked with excludeFromUpdate)
|
||||
const updateCols = activeColumns
|
||||
.filter((m) => !m.excludeFromUpdate)
|
||||
.map((m) => `${m.dbCol} = excluded.${m.dbCol}`)
|
||||
.join(',\n ');
|
||||
|
||||
const sql = `
|
||||
INSERT INTO game (
|
||||
id, parentGameId, title, alternateTitles, series, developer, publisher,
|
||||
platformName, playMode, status, broken, extreme, notes, source,
|
||||
applicationPath, launchCommand, releaseDate, version,
|
||||
originalDescription, language, orderTitle, library, tagsStr,
|
||||
dateAdded, dateModified, activeDataId, activeDataOnDisk,
|
||||
archiveState, ruffleSupport
|
||||
) VALUES ${placeholders}
|
||||
INSERT INTO game (${columnNames})
|
||||
VALUES ${placeholders}
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
parentGameId = excluded.parentGameId,
|
||||
title = excluded.title,
|
||||
alternateTitles = excluded.alternateTitles,
|
||||
series = excluded.series,
|
||||
developer = excluded.developer,
|
||||
publisher = excluded.publisher,
|
||||
platformName = excluded.platformName,
|
||||
playMode = excluded.playMode,
|
||||
status = excluded.status,
|
||||
broken = excluded.broken,
|
||||
extreme = excluded.extreme,
|
||||
notes = excluded.notes,
|
||||
source = excluded.source,
|
||||
applicationPath = excluded.applicationPath,
|
||||
launchCommand = excluded.launchCommand,
|
||||
releaseDate = excluded.releaseDate,
|
||||
version = excluded.version,
|
||||
originalDescription = excluded.originalDescription,
|
||||
language = excluded.language,
|
||||
orderTitle = excluded.orderTitle,
|
||||
library = excluded.library,
|
||||
tagsStr = excluded.tagsStr,
|
||||
dateModified = excluded.dateModified,
|
||||
activeDataId = excluded.activeDataId,
|
||||
activeDataOnDisk = excluded.activeDataOnDisk,
|
||||
archiveState = excluded.archiveState,
|
||||
ruffleSupport = excluded.ruffleSupport
|
||||
${updateCols}
|
||||
`;
|
||||
|
||||
// Wrap in a transaction for atomicity
|
||||
db.transaction(() => {
|
||||
const stmt = db.prepare(sql);
|
||||
stmt.run(values);
|
||||
})();
|
||||
// Disable FK checks before transaction (handles circular parent refs like A -> B -> A)
|
||||
// Note: PRAGMA foreign_keys must be set outside of a transaction
|
||||
db.pragma('foreign_keys = OFF');
|
||||
try {
|
||||
db.transaction(() => {
|
||||
const stmt = db.prepare(sql);
|
||||
stmt.run(values);
|
||||
})();
|
||||
} finally {
|
||||
// Re-enable FK checks
|
||||
db.pragma('foreign_keys = ON');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -598,6 +641,7 @@ export class MetadataSyncService {
|
||||
const sorted: ApiGame[] = [];
|
||||
const visited = new Set<string>();
|
||||
const visiting = new Set<string>(); // Track games currently being visited to detect cycles
|
||||
let cycleCount = 0;
|
||||
|
||||
// Build game map
|
||||
games.forEach((game) => gameMap.set(game.id, game));
|
||||
@@ -609,9 +653,7 @@ export class MetadataSyncService {
|
||||
|
||||
// Circular dependency detected - break the cycle by skipping parent
|
||||
if (visiting.has(game.id)) {
|
||||
logger.warn(
|
||||
`[MetadataSync] Circular parent dependency detected for game: ${game.id} (${game.title})`
|
||||
);
|
||||
cycleCount++;
|
||||
visited.add(game.id);
|
||||
sorted.push(game);
|
||||
return;
|
||||
@@ -635,33 +677,36 @@ export class MetadataSyncService {
|
||||
// Process all games
|
||||
games.forEach((game) => addGame(game));
|
||||
|
||||
// Log cycle summary once (not per-game)
|
||||
if (cycleCount > 0) {
|
||||
logger.debug(`[MetadataSync] Found ${cycleCount} circular parent references in batch (handled)`);
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete games from database
|
||||
* Handles parent-child relationships to avoid FOREIGN KEY constraint errors
|
||||
* Disables FK checks to handle references from other tables (game_data, additional_app, etc.)
|
||||
*/
|
||||
private deleteGames(gameIds: string[]): void {
|
||||
if (!Array.isArray(gameIds) || gameIds.length === 0) return;
|
||||
const db = DatabaseService.getDatabase();
|
||||
|
||||
// Wrap all batches in a single transaction to maintain consistency.
|
||||
// If the process crashes mid-way, all changes are rolled back.
|
||||
const BATCH_SIZE = 500;
|
||||
db.transaction(() => {
|
||||
for (let i = 0; i < gameIds.length; i += BATCH_SIZE) {
|
||||
const batch = gameIds.slice(i, i + BATCH_SIZE);
|
||||
const placeholders = batch.map(() => '?').join(', ');
|
||||
|
||||
// Orphan children first (prevents FK violation)
|
||||
db.prepare(
|
||||
`UPDATE game SET parentGameId = NULL WHERE parentGameId IN (${placeholders})`
|
||||
).run(batch);
|
||||
// Then delete
|
||||
db.prepare(`DELETE FROM game WHERE id IN (${placeholders})`).run(batch);
|
||||
}
|
||||
})();
|
||||
// Disable FK checks before transaction (matches Flashpoint Launcher approach)
|
||||
db.pragma('foreign_keys = OFF');
|
||||
try {
|
||||
const BATCH_SIZE = 500;
|
||||
db.transaction(() => {
|
||||
for (let i = 0; i < gameIds.length; i += BATCH_SIZE) {
|
||||
const batch = gameIds.slice(i, i + BATCH_SIZE);
|
||||
const placeholders = batch.map(() => '?').join(', ');
|
||||
db.prepare(`DELETE FROM game WHERE id IN (${placeholders})`).run(batch);
|
||||
}
|
||||
})();
|
||||
} finally {
|
||||
db.pragma('foreign_keys = ON');
|
||||
}
|
||||
}
|
||||
|
||||
private async updatePreferencesTimestamps(
|
||||
@@ -675,39 +720,40 @@ export class MetadataSyncService {
|
||||
): Promise<void> {
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const sources = preferences.gameMetadataSources;
|
||||
|
||||
// Guard: should never happen since caller already verified sources exist
|
||||
if (!sources || sources.length === 0) {
|
||||
logger.warn('[MetadataSync] No gameMetadataSources to update');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the source timestamps
|
||||
const sourceIndex = preferences.gameMetadataSources.findIndex((s) => s.name === source.name);
|
||||
const sourceIndex = sources.findIndex((s) => s.name === source.name);
|
||||
if (sourceIndex >= 0) {
|
||||
// Update actualUpdateTime to NOW (when we actually synced)
|
||||
preferences.gameMetadataSources[sourceIndex].games.actualUpdateTime = now;
|
||||
preferences.gameMetadataSources[sourceIndex].tags.actualUpdateTime = now;
|
||||
if (preferences.gameMetadataSources[sourceIndex].platforms) {
|
||||
preferences.gameMetadataSources[sourceIndex].platforms.actualUpdateTime = now;
|
||||
sources[sourceIndex].games.actualUpdateTime = now;
|
||||
sources[sourceIndex].tags.actualUpdateTime = now;
|
||||
if (sources[sourceIndex].platforms) {
|
||||
sources[sourceIndex].platforms.actualUpdateTime = now;
|
||||
}
|
||||
|
||||
// Update latestUpdateTime to the latest game/tag/platform dateModified
|
||||
// This is what the update check uses to determine what's new
|
||||
if (latestDates.gamesLatestDate) {
|
||||
preferences.gameMetadataSources[sourceIndex].games.latestUpdateTime =
|
||||
latestDates.gamesLatestDate;
|
||||
sources[sourceIndex].games.latestUpdateTime = latestDates.gamesLatestDate;
|
||||
logger.info(
|
||||
`[MetadataSync] Updated games latestUpdateTime to: ${latestDates.gamesLatestDate}`
|
||||
);
|
||||
}
|
||||
if (latestDates.tagsLatestDate) {
|
||||
preferences.gameMetadataSources[sourceIndex].tags.latestUpdateTime =
|
||||
latestDates.tagsLatestDate;
|
||||
sources[sourceIndex].tags.latestUpdateTime = latestDates.tagsLatestDate;
|
||||
logger.info(
|
||||
`[MetadataSync] Updated tags latestUpdateTime to: ${latestDates.tagsLatestDate}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
latestDates.platformsLatestDate &&
|
||||
preferences.gameMetadataSources[sourceIndex].platforms
|
||||
) {
|
||||
preferences.gameMetadataSources[sourceIndex].platforms.latestUpdateTime =
|
||||
latestDates.platformsLatestDate;
|
||||
if (latestDates.platformsLatestDate && sources[sourceIndex].platforms) {
|
||||
sources[sourceIndex].platforms.latestUpdateTime = latestDates.platformsLatestDate;
|
||||
logger.info(
|
||||
`[MetadataSync] Updated platforms latestUpdateTime to: ${latestDates.platformsLatestDate}`
|
||||
);
|
||||
@@ -717,6 +763,9 @@ export class MetadataSyncService {
|
||||
// Write back to preferences.json
|
||||
await fs.writeFile(this.preferencesPath, JSON.stringify(preferences, null, 2), 'utf-8');
|
||||
|
||||
// Invalidate preferences cache so next read gets updated values
|
||||
PreferencesService.invalidateCache();
|
||||
|
||||
logger.info(`[MetadataSync] Updated preferences.json timestamps. actualUpdateTime: ${now}`);
|
||||
} catch (error) {
|
||||
logger.error('[MetadataSync] Error updating preferences:', error);
|
||||
|
||||
@@ -1,28 +1,8 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import axios from 'axios';
|
||||
import { config } from '../config';
|
||||
import { logger } from '../utils/logger';
|
||||
import { PreferencesService, GameMetadataSource } from './PreferencesService';
|
||||
|
||||
export interface GameMetadataSource {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
games: {
|
||||
actualUpdateTime: string;
|
||||
latestDeleteTime: string;
|
||||
latestUpdateTime: string;
|
||||
};
|
||||
tags: {
|
||||
actualUpdateTime: string;
|
||||
latestDeleteTime: string;
|
||||
latestUpdateTime: string;
|
||||
};
|
||||
platforms?: {
|
||||
actualUpdateTime: string;
|
||||
latestDeleteTime: string;
|
||||
latestUpdateTime: string;
|
||||
};
|
||||
}
|
||||
export { GameMetadataSource } from './PreferencesService';
|
||||
|
||||
export interface MetadataUpdateInfo {
|
||||
hasUpdates: boolean;
|
||||
@@ -36,15 +16,12 @@ export interface MetadataUpdateInfo {
|
||||
}
|
||||
|
||||
export class MetadataUpdateService {
|
||||
private preferencesPath: string;
|
||||
|
||||
constructor() {
|
||||
// Preferences file is in the Flashpoint root directory
|
||||
this.preferencesPath = path.join(config.flashpointPath, 'preferences.json');
|
||||
}
|
||||
|
||||
getEdition(): string {
|
||||
return config.flashpointEdition;
|
||||
/**
|
||||
* Check if a metadata source is configured in preferences.json
|
||||
* This determines whether metadata sync feature is available
|
||||
*/
|
||||
async hasMetadataSource(): Promise<boolean> {
|
||||
return PreferencesService.hasMetadataSource();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,20 +31,7 @@ export class MetadataUpdateService {
|
||||
*/
|
||||
async getMetadataUpdateInfo(): Promise<MetadataUpdateInfo> {
|
||||
try {
|
||||
// Ultimate edition does not support metadata sync
|
||||
if (this.getEdition() === 'ultimate') {
|
||||
return {
|
||||
hasUpdates: false,
|
||||
gamesUpdateAvailable: false,
|
||||
tagsUpdateAvailable: false,
|
||||
};
|
||||
}
|
||||
// Read preferences file
|
||||
const preferencesContent = await fs.readFile(this.preferencesPath, 'utf-8');
|
||||
const preferences = JSON.parse(preferencesContent);
|
||||
|
||||
// Get game metadata sources (usually Flashpoint Archive FPFSS)
|
||||
const sources: GameMetadataSource[] = preferences.gameMetadataSources || [];
|
||||
const sources = await PreferencesService.getGameMetadataSources();
|
||||
|
||||
if (sources.length === 0) {
|
||||
logger.warn('[MetadataUpdate] No game metadata sources found in preferences');
|
||||
@@ -163,10 +127,7 @@ export class MetadataUpdateService {
|
||||
*/
|
||||
async checkRemoteUpdates(): Promise<{ latestUpdateTime: string } | null> {
|
||||
try {
|
||||
// Read current metadata source from preferences
|
||||
const preferencesContent = await fs.readFile(this.preferencesPath, 'utf-8');
|
||||
const preferences = JSON.parse(preferencesContent);
|
||||
const sources: GameMetadataSource[] = preferences.gameMetadataSources || [];
|
||||
const sources = await PreferencesService.getGameMetadataSources();
|
||||
|
||||
if (sources.length === 0) {
|
||||
return null;
|
||||
|
||||
@@ -9,6 +9,26 @@ export interface GameDataSource {
|
||||
arguments: string[];
|
||||
}
|
||||
|
||||
export interface GameMetadataSource {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
games: {
|
||||
actualUpdateTime: string;
|
||||
latestDeleteTime: string;
|
||||
latestUpdateTime: string;
|
||||
};
|
||||
tags: {
|
||||
actualUpdateTime: string;
|
||||
latestDeleteTime: string;
|
||||
latestUpdateTime: string;
|
||||
};
|
||||
platforms?: {
|
||||
actualUpdateTime: string;
|
||||
latestDeleteTime: string;
|
||||
latestUpdateTime: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FlashpointPreferences {
|
||||
gameDataSources: GameDataSource[];
|
||||
dataPacksFolderPath: string;
|
||||
@@ -21,6 +41,9 @@ export interface FlashpointPreferences {
|
||||
onDemandBaseUrl?: string;
|
||||
onDemandImagesCompressed?: boolean;
|
||||
|
||||
// Metadata sync preferences
|
||||
gameMetadataSources?: GameMetadataSource[];
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -128,6 +151,20 @@ export class PreferencesService {
|
||||
return prefs.gameDataSources || [];
|
||||
}
|
||||
|
||||
static async getGameMetadataSources(): Promise<GameMetadataSource[]> {
|
||||
const prefs = await this.getPreferences();
|
||||
return prefs.gameMetadataSources || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a valid metadata source is configured.
|
||||
* Returns true if gameMetadataSources has at least one entry with a baseUrl.
|
||||
*/
|
||||
static async hasMetadataSource(): Promise<boolean> {
|
||||
const sources = await this.getGameMetadataSources();
|
||||
return sources.length > 0 && !!sources[0]?.baseUrl;
|
||||
}
|
||||
|
||||
static async getDataPacksPath(): Promise<string> {
|
||||
const prefs = await this.getPreferences();
|
||||
const dataPacksPath = prefs.dataPacksFolderPath || 'Data/Games';
|
||||
|
||||
@@ -60,7 +60,11 @@ export class RuffleService {
|
||||
}
|
||||
}
|
||||
|
||||
async getLatestVersion(): Promise<{
|
||||
/**
|
||||
* Get latest Ruffle version info from GitHub releases
|
||||
* @param currentVersion Optional current version to build combined changelog from
|
||||
*/
|
||||
async getLatestVersion(currentVersion?: string | null): Promise<{
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
checksumUrl: string | null;
|
||||
@@ -68,8 +72,8 @@ export class RuffleService {
|
||||
changelog: string;
|
||||
}> {
|
||||
try {
|
||||
// Get latest nightly release (prereleases only)
|
||||
const response = await axios.get(`${this.githubApiUrl}?per_page=1`, {
|
||||
// Fetch more releases to build combined changelog (covers ~1 month of nightly releases)
|
||||
const response = await axios.get(`${this.githubApiUrl}?per_page=30`, {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
@@ -77,33 +81,47 @@ export class RuffleService {
|
||||
},
|
||||
});
|
||||
|
||||
const release = response.data[0];
|
||||
if (!release) {
|
||||
const releases = response.data;
|
||||
if (!releases || releases.length === 0) {
|
||||
throw new Error('No releases found');
|
||||
}
|
||||
|
||||
// Find web-selfhosted asset
|
||||
const asset = release.assets.find((a: GitHubAsset) => a.name.includes('web-selfhosted.zip'));
|
||||
// Find the latest release with web-selfhosted.zip asset
|
||||
let latestRelease = null;
|
||||
let latestAsset = null;
|
||||
|
||||
if (!asset) {
|
||||
throw new Error('web-selfhosted.zip not found in release');
|
||||
for (const release of releases) {
|
||||
const asset = release.assets.find((a: GitHubAsset) =>
|
||||
a.name.includes('web-selfhosted.zip')
|
||||
);
|
||||
if (asset) {
|
||||
latestRelease = release;
|
||||
latestAsset = asset;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!latestRelease || !latestAsset) {
|
||||
throw new Error('web-selfhosted.zip not found in any release');
|
||||
}
|
||||
|
||||
// Look for corresponding checksum file
|
||||
// Ruffle releases may include files like:
|
||||
// - ruffle-nightly-2024_01_01-web-selfhosted.zip.sha256
|
||||
// - SHA256SUMS (contains all checksums)
|
||||
const checksumAsset = release.assets.find(
|
||||
const checksumAsset = latestRelease.assets.find(
|
||||
(a: GitHubAsset) =>
|
||||
a.name === `${asset.name}.sha256` || a.name === 'SHA256SUMS' || a.name === 'checksums.txt'
|
||||
a.name === `${latestAsset.name}.sha256` ||
|
||||
a.name === 'SHA256SUMS' ||
|
||||
a.name === 'checksums.txt'
|
||||
);
|
||||
|
||||
// Build combined changelog from all releases since currentVersion
|
||||
const changelog = this.buildCombinedChangelog(releases, currentVersion);
|
||||
|
||||
return {
|
||||
version: release.tag_name.replace('nightly-', ''),
|
||||
downloadUrl: asset.browser_download_url,
|
||||
version: latestRelease.tag_name.replace('nightly-', ''),
|
||||
downloadUrl: latestAsset.browser_download_url,
|
||||
checksumUrl: checksumAsset?.browser_download_url ?? null,
|
||||
publishedAt: release.published_at,
|
||||
changelog: release.body || 'No changelog available.',
|
||||
publishedAt: latestRelease.published_at,
|
||||
changelog,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error fetching latest Ruffle version:', error);
|
||||
@@ -111,6 +129,66 @@ export class RuffleService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a combined changelog from all releases since the current version
|
||||
* @param releases Array of GitHub releases (newest first)
|
||||
* @param currentVersion Optional current version string
|
||||
* @returns Combined changelog markdown with version headers
|
||||
*/
|
||||
private buildCombinedChangelog(
|
||||
releases: Array<{ tag_name: string; body: string | null; assets: GitHubAsset[] }>,
|
||||
currentVersion?: string | null
|
||||
): string {
|
||||
// If no current version, just return the latest changelog
|
||||
if (!currentVersion) {
|
||||
logger.debug('[RuffleService] No current version, returning latest changelog only');
|
||||
return releases[0]?.body || 'No changelog available.';
|
||||
}
|
||||
|
||||
const normalizedCurrent = this.normalizeVersion(currentVersion);
|
||||
logger.debug(`[RuffleService] Current version: ${currentVersion} -> normalized: ${normalizedCurrent}`);
|
||||
|
||||
// Filter releases that have web-selfhosted.zip and are newer than current version
|
||||
const newerReleases = releases.filter((release) => {
|
||||
// Must have web-selfhosted asset
|
||||
const hasAsset = release.assets.some((a: GitHubAsset) =>
|
||||
a.name.includes('web-selfhosted.zip')
|
||||
);
|
||||
if (!hasAsset) {
|
||||
logger.debug(`[RuffleService] Release ${release.tag_name} skipped - no web-selfhosted.zip`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must be newer than current version
|
||||
const releaseVersion = this.normalizeVersion(release.tag_name);
|
||||
const isNewer = releaseVersion > normalizedCurrent;
|
||||
logger.debug(`[RuffleService] Release ${release.tag_name} -> ${releaseVersion}, isNewer: ${isNewer}`);
|
||||
return isNewer;
|
||||
});
|
||||
|
||||
logger.info(`[RuffleService] Found ${newerReleases.length} releases newer than ${normalizedCurrent}`);
|
||||
|
||||
if (newerReleases.length === 0) {
|
||||
return releases[0]?.body || 'No changelog available.';
|
||||
}
|
||||
|
||||
// Sort by version descending (newest first)
|
||||
const sortedReleases = [...newerReleases].sort((a, b) => {
|
||||
const versionA = this.normalizeVersion(a.tag_name);
|
||||
const versionB = this.normalizeVersion(b.tag_name);
|
||||
return versionB.localeCompare(versionA);
|
||||
});
|
||||
|
||||
// Build combined changelog with version headers
|
||||
const changelogParts = sortedReleases.map((release) => {
|
||||
const version = release.tag_name;
|
||||
const body = release.body?.trim() || 'No changelog available.';
|
||||
return `## ${version}\n\n${body}`;
|
||||
});
|
||||
|
||||
return changelogParts.join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
async checkForUpdate(): Promise<{
|
||||
currentVersion: string | null;
|
||||
latestVersion: string;
|
||||
@@ -119,7 +197,8 @@ export class RuffleService {
|
||||
publishedAt?: string;
|
||||
}> {
|
||||
const currentVersion = this.getCurrentVersion();
|
||||
const latest = await this.getLatestVersion();
|
||||
// Pass current version to get combined changelog of all updates since that version
|
||||
const latest = await this.getLatestVersion(currentVersion);
|
||||
|
||||
// Normalize versions to same format (YYYY-MM-DD) for comparison
|
||||
const normalizedCurrent = currentVersion ? this.normalizeVersion(currentVersion) : null;
|
||||
|
||||
@@ -191,7 +191,7 @@ export function MetadataUpdateCard() {
|
||||
<Database size={24} className="text-primary" />
|
||||
<h2 className="text-xl font-semibold">Game Metadata</h2>
|
||||
</div>
|
||||
{metadataInfo?.edition !== 'ultimate' ? (
|
||||
{metadataInfo?.hasMetadataSource ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -210,22 +210,39 @@ export function MetadataUpdateCard() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Ultimate Edition: No metadata sync available */}
|
||||
{metadataInfo?.edition === 'ultimate' ? (
|
||||
{/* Skeleton Loading State */}
|
||||
{!metadataInfo ? (
|
||||
<div className="bg-muted border border-border rounded-lg p-4 animate-pulse">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="w-5 h-5 bg-accent rounded-full flex-shrink-0 mt-0.5"></div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="h-5 bg-accent rounded w-48"></div>
|
||||
<div className="h-4 bg-accent rounded w-64"></div>
|
||||
<div className="h-3 bg-accent rounded w-40"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-accent rounded-lg flex-shrink-0"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* No metadata source configured: Sync not available */}
|
||||
{metadataInfo && !metadataInfo.hasMetadataSource ? (
|
||||
<div className="bg-muted border border-border rounded-lg p-4 flex items-center gap-3">
|
||||
<Info size={20} className="text-muted-foreground flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-foreground font-medium">Metadata sync not available</p>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Flashpoint Ultimate edition does not include a metadata source server. Metadata sync
|
||||
is only available with Flashpoint Infinity.
|
||||
No metadata source is configured. Metadata sync requires a configured metadata source
|
||||
in the Flashpoint preferences.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Infinity Edition: Metadata sync UI */}
|
||||
{metadataInfo?.edition !== 'ultimate' ? (
|
||||
{/* Metadata source configured: Sync UI */}
|
||||
{metadataInfo?.hasMetadataSource ? (
|
||||
<>
|
||||
{/* Error Message */}
|
||||
{error ? (
|
||||
@@ -234,27 +251,8 @@ export function MetadataUpdateCard() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Skeleton Loading State */}
|
||||
{!metadataInfo ? (
|
||||
<div className="bg-muted border border-border rounded-lg p-4 animate-pulse">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="w-5 h-5 bg-accent rounded-full flex-shrink-0 mt-0.5"></div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="h-5 bg-accent rounded w-48"></div>
|
||||
<div className="h-4 bg-accent rounded w-64"></div>
|
||||
<div className="h-3 bg-accent rounded w-40"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-24 h-10 bg-accent rounded-lg flex-shrink-0"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Metadata Update Info */}
|
||||
{metadataInfo ? (
|
||||
<div>
|
||||
{metadataInfo.gamesUpdateAvailable ? (
|
||||
{metadataInfo.gamesUpdateAvailable ? (
|
||||
<div className="bg-primary/10 border border-primary rounded-lg p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
@@ -338,9 +336,7 @@ export function MetadataUpdateCard() {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface MetadataUpdateInfo {
|
||||
tagsUpdateCount?: number;
|
||||
lastCheckedTime?: string;
|
||||
lastUpdateTime?: string;
|
||||
edition?: string;
|
||||
hasMetadataSource?: boolean;
|
||||
}
|
||||
|
||||
export const updatesApi = {
|
||||
|
||||
Reference in New Issue
Block a user