Phase 2: market data layer
AODP price client (chunked, throttled, proxy-aware), snapshot storage with freshness ages, manual price overrides that outrank API data, async refresh job with progress, Market Data + Settings pages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
7ba275dd77
commit
7ff3e6ec95
11 changed files with 832 additions and 2 deletions
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -3422,6 +3422,15 @@
|
||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "8.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
|
||||||
|
"integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.18.2",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
|
|
@ -3729,7 +3738,8 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.2.0",
|
"better-sqlite3": "^12.2.0",
|
||||||
"fastify": "^5.4.0"
|
"fastify": "^5.4.0",
|
||||||
|
"undici": "^8.10.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.2.0",
|
"better-sqlite3": "^12.2.0",
|
||||||
"fastify": "^5.4.0"
|
"fastify": "^5.4.0",
|
||||||
|
"undici": "^8.10.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,30 @@ CREATE TABLE IF NOT EXISTS recipe_inputs (
|
||||||
PRIMARY KEY (recipe_id, input_item)
|
PRIMARY KEY (recipe_id, input_item)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipe_inputs_item ON recipe_inputs(input_item);
|
CREATE INDEX IF NOT EXISTS idx_recipe_inputs_item ON recipe_inputs(input_item);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS market_prices (
|
||||||
|
item TEXT NOT NULL,
|
||||||
|
city TEXT NOT NULL,
|
||||||
|
quality INTEGER NOT NULL DEFAULT 1,
|
||||||
|
sell_price_min REAL,
|
||||||
|
sell_min_date TEXT, -- when AODP last observed this sell order (UTC ISO)
|
||||||
|
buy_price_max REAL,
|
||||||
|
buy_max_date TEXT,
|
||||||
|
fetched_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (item, city, quality)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_market_prices_city ON market_prices(city);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS price_overrides (
|
||||||
|
item TEXT NOT NULL,
|
||||||
|
city TEXT NOT NULL,
|
||||||
|
quality INTEGER NOT NULL DEFAULT 1,
|
||||||
|
sell_price REAL,
|
||||||
|
buy_price REAL,
|
||||||
|
note TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (item, city, quality)
|
||||||
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
let db: Database.Database | null = null;
|
let db: Database.Database | null = null;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { API_PORT } from './config.js';
|
||||||
import { getDb } from './db/index.js';
|
import { getDb } from './db/index.js';
|
||||||
import { gamedataRoutes } from './routes/gamedata.js';
|
import { gamedataRoutes } from './routes/gamedata.js';
|
||||||
import { itemRoutes } from './routes/items.js';
|
import { itemRoutes } from './routes/items.js';
|
||||||
|
import { marketRoutes } from './routes/market.js';
|
||||||
|
|
||||||
const app = Fastify({ logger: { level: 'info' } });
|
const app = Fastify({ logger: { level: 'info' } });
|
||||||
|
|
||||||
|
|
@ -11,6 +12,7 @@ getDb(); // initialize schema on startup
|
||||||
app.get('/api/health', async () => ({ ok: true }));
|
app.get('/api/health', async () => ({ ok: true }));
|
||||||
await app.register(gamedataRoutes);
|
await app.register(gamedataRoutes);
|
||||||
await app.register(itemRoutes);
|
await app.register(itemRoutes);
|
||||||
|
await app.register(marketRoutes);
|
||||||
|
|
||||||
await app.listen({ port: API_PORT, host: '127.0.0.1' });
|
await app.listen({ port: API_PORT, host: '127.0.0.1' });
|
||||||
console.log(`API listening on http://127.0.0.1:${API_PORT}`);
|
console.log(`API listening on http://127.0.0.1:${API_PORT}`);
|
||||||
|
|
|
||||||
116
server/src/market/client.ts
Normal file
116
server/src/market/client.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
/** Client for the Albion Online Data Project (AODP) REST API. */
|
||||||
|
import { fetch as undiciFetch, ProxyAgent } from 'undici';
|
||||||
|
import { getMeta } from '../db/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AODP is blocked in some regions. If the user's VPN is browser-only, they can
|
||||||
|
* instead set a proxy (Settings page, or the HTTPS_PROXY env var) and the app
|
||||||
|
* routes its market requests through it.
|
||||||
|
*/
|
||||||
|
function getProxyUrl(): string | null {
|
||||||
|
return getMeta('proxy_url') || process.env.HTTPS_PROXY || process.env.HTTP_PROXY || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxyAwareFetch(url: string, timeoutMs: number): Promise<{ ok: boolean; status: number; json(): Promise<unknown> }> {
|
||||||
|
const proxyUrl = getProxyUrl();
|
||||||
|
const signal = AbortSignal.timeout(timeoutMs);
|
||||||
|
if (proxyUrl) {
|
||||||
|
return undiciFetch(url, { signal, dispatcher: new ProxyAgent(proxyUrl) });
|
||||||
|
}
|
||||||
|
return fetch(url, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SERVER_HOSTS: Record<string, string> = {
|
||||||
|
europe: 'europe.albion-online-data.com',
|
||||||
|
americas: 'west.albion-online-data.com',
|
||||||
|
asia: 'east.albion-online-data.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Locations with player markets we track by default. */
|
||||||
|
export const CITIES = [
|
||||||
|
'Bridgewatch',
|
||||||
|
'Caerleon',
|
||||||
|
'Fort Sterling',
|
||||||
|
'Lymhurst',
|
||||||
|
'Martlock',
|
||||||
|
'Thetford',
|
||||||
|
'Brecilien',
|
||||||
|
'Black Market',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface AodpPriceRow {
|
||||||
|
item_id: string;
|
||||||
|
city: string;
|
||||||
|
quality: number;
|
||||||
|
sell_price_min: number;
|
||||||
|
sell_price_min_date: string;
|
||||||
|
sell_price_max: number;
|
||||||
|
sell_price_max_date: string;
|
||||||
|
buy_price_max: number;
|
||||||
|
buy_price_max_date: string;
|
||||||
|
buy_price_min: number;
|
||||||
|
buy_price_min_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 100; // item ids per request (keeps URLs short)
|
||||||
|
const REQUEST_DELAY_MS = 500; // stay well under AODP's ~180 req/min limit
|
||||||
|
const REQUEST_TIMEOUT_MS = 20000;
|
||||||
|
|
||||||
|
/** AODP uses 0001-01-01T00:00:00 as "never observed". */
|
||||||
|
export function normalizeAodpDate(d: string | null | undefined): string | null {
|
||||||
|
if (!d || d.startsWith('0001-')) return null;
|
||||||
|
return d.endsWith('Z') ? d : `${d}Z`; // AODP timestamps are UTC without the Z
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AodpUnreachableError extends Error {
|
||||||
|
constructor(cause: unknown) {
|
||||||
|
super(
|
||||||
|
'Market data API (albion-online-data.com) is unreachable. ' +
|
||||||
|
'If you are in a region where it is blocked, check that your VPN is on. ' +
|
||||||
|
`(${cause instanceof Error ? cause.message : String(cause)})`,
|
||||||
|
);
|
||||||
|
this.name = 'AodpUnreachableError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchPricesOptions {
|
||||||
|
server: string; // key of SERVER_HOSTS
|
||||||
|
items: string[];
|
||||||
|
cities?: string[];
|
||||||
|
qualities?: number[];
|
||||||
|
onProgress?: (done: number, total: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPrices(opts: FetchPricesOptions): Promise<AodpPriceRow[]> {
|
||||||
|
const host = SERVER_HOSTS[opts.server] ?? SERVER_HOSTS.europe;
|
||||||
|
const cities = opts.cities ?? [...CITIES];
|
||||||
|
const qualities = opts.qualities ?? [1, 2, 3, 4, 5];
|
||||||
|
|
||||||
|
const chunks: string[][] = [];
|
||||||
|
for (let i = 0; i < opts.items.length; i += CHUNK_SIZE) {
|
||||||
|
chunks.push(opts.items.slice(i, i + CHUNK_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
const all: AodpPriceRow[] = [];
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
const url =
|
||||||
|
`https://${host}/api/v2/stats/prices/${encodeURIComponent(chunks[i].join(','))}` +
|
||||||
|
`?locations=${encodeURIComponent(cities.join(','))}` +
|
||||||
|
`&qualities=${qualities.join(',')}`;
|
||||||
|
try {
|
||||||
|
const res = await proxyAwareFetch(url, REQUEST_TIMEOUT_MS);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const rows = (await res.json()) as AodpPriceRow[];
|
||||||
|
all.push(...rows);
|
||||||
|
} catch (e) {
|
||||||
|
throw new AodpUnreachableError(e);
|
||||||
|
}
|
||||||
|
opts.onProgress?.(i + 1, chunks.length);
|
||||||
|
if (i < chunks.length - 1) await sleep(REQUEST_DELAY_MS);
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
249
server/src/market/service.ts
Normal file
249
server/src/market/service.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
import { getDb, getMeta, setMeta } from '../db/index.js';
|
||||||
|
import {
|
||||||
|
AodpUnreachableError,
|
||||||
|
CITIES,
|
||||||
|
fetchPrices,
|
||||||
|
normalizeAodpDate,
|
||||||
|
SERVER_HOSTS,
|
||||||
|
} from './client.js';
|
||||||
|
|
||||||
|
export function getAlbionServer(): string {
|
||||||
|
const s = getMeta('albion_server');
|
||||||
|
return s && s in SERVER_HOSTS ? s : 'europe';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAlbionServer(server: string): void {
|
||||||
|
if (!(server in SERVER_HOSTS)) throw new Error(`unknown server: ${server}`);
|
||||||
|
setMeta('albion_server', server);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Refresh job (long-running; started async, polled by the UI)
|
||||||
|
|
||||||
|
export interface RefreshJob {
|
||||||
|
status: 'idle' | 'running' | 'done' | 'error';
|
||||||
|
totalChunks: number;
|
||||||
|
doneChunks: number;
|
||||||
|
itemCount: number;
|
||||||
|
rowsUpserted: number;
|
||||||
|
error?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
finishedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let job: RefreshJob = { status: 'idle', totalChunks: 0, doneChunks: 0, itemCount: 0, rowsUpserted: 0 };
|
||||||
|
|
||||||
|
export function getRefreshJob(): RefreshJob {
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshOptions {
|
||||||
|
minTier?: number;
|
||||||
|
maxTier?: number;
|
||||||
|
cities?: string[];
|
||||||
|
items?: string[]; // explicit list overrides tier-based selection
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Items worth pricing: everything that appears in a recipe (as output or input). */
|
||||||
|
function selectRecipeRelevantItems(minTier: number, maxTier: number): string[] {
|
||||||
|
const rows = getDb()
|
||||||
|
.prepare(
|
||||||
|
`SELECT DISTINCT i.unique_name AS name
|
||||||
|
FROM items i
|
||||||
|
WHERE i.tier BETWEEN ? AND ?
|
||||||
|
AND (
|
||||||
|
EXISTS (SELECT 1 FROM recipes r WHERE r.output_item = i.unique_name)
|
||||||
|
OR EXISTS (SELECT 1 FROM recipe_inputs ri WHERE ri.input_item = i.unique_name)
|
||||||
|
)`,
|
||||||
|
)
|
||||||
|
.all(minTier, maxTier) as { name: string }[];
|
||||||
|
return rows.map((r) => r.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startRefresh(opts: RefreshOptions): { started: boolean; reason?: string } {
|
||||||
|
if (job.status === 'running') return { started: false, reason: 'a refresh is already running' };
|
||||||
|
|
||||||
|
const items =
|
||||||
|
opts.items && opts.items.length > 0
|
||||||
|
? opts.items
|
||||||
|
: selectRecipeRelevantItems(opts.minTier ?? 2, opts.maxTier ?? 8);
|
||||||
|
if (items.length === 0) return { started: false, reason: 'no items match the selection' };
|
||||||
|
|
||||||
|
const cities = opts.cities && opts.cities.length > 0 ? opts.cities : [...CITIES];
|
||||||
|
job = {
|
||||||
|
status: 'running',
|
||||||
|
totalChunks: Math.ceil(items.length / 100),
|
||||||
|
doneChunks: 0,
|
||||||
|
itemCount: items.length,
|
||||||
|
rowsUpserted: 0,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const rows = await fetchPrices({
|
||||||
|
server: getAlbionServer(),
|
||||||
|
items,
|
||||||
|
cities,
|
||||||
|
onProgress: (done, total) => {
|
||||||
|
job.doneChunks = done;
|
||||||
|
job.totalChunks = total;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const upsert = db.prepare(`
|
||||||
|
INSERT INTO market_prices (item, city, quality, sell_price_min, sell_min_date, buy_price_max, buy_max_date, fetched_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(item, city, quality) DO UPDATE SET
|
||||||
|
sell_price_min = excluded.sell_price_min,
|
||||||
|
sell_min_date = excluded.sell_min_date,
|
||||||
|
buy_price_max = excluded.buy_price_max,
|
||||||
|
buy_max_date = excluded.buy_max_date,
|
||||||
|
fetched_at = excluded.fetched_at
|
||||||
|
`);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
let upserted = 0;
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const r of rows) {
|
||||||
|
const sellDate = normalizeAodpDate(r.sell_price_min_date);
|
||||||
|
const buyDate = normalizeAodpDate(r.buy_price_max_date);
|
||||||
|
if (!sellDate && !buyDate) continue; // never observed anywhere
|
||||||
|
upsert.run(
|
||||||
|
r.item_id,
|
||||||
|
r.city,
|
||||||
|
r.quality,
|
||||||
|
r.sell_price_min > 0 ? r.sell_price_min : null,
|
||||||
|
sellDate,
|
||||||
|
r.buy_price_max > 0 ? r.buy_price_max : null,
|
||||||
|
buyDate,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
upserted++;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
job.rowsUpserted = upserted;
|
||||||
|
job.status = 'done';
|
||||||
|
job.finishedAt = new Date().toISOString();
|
||||||
|
setMeta('market_last_refresh', job.finishedAt);
|
||||||
|
} catch (e) {
|
||||||
|
job.status = 'error';
|
||||||
|
job.finishedAt = new Date().toISOString();
|
||||||
|
job.error = e instanceof AodpUnreachableError ? e.message : String(e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return { started: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reads (always merge manual overrides over API data)
|
||||||
|
|
||||||
|
export interface MergedPrice {
|
||||||
|
item: string;
|
||||||
|
city: string;
|
||||||
|
quality: number;
|
||||||
|
sellPrice: number | null;
|
||||||
|
buyPrice: number | null;
|
||||||
|
sellSource: 'api' | 'manual' | null;
|
||||||
|
buySource: 'api' | 'manual' | null;
|
||||||
|
sellAgeHours: number | null; // null for manual overrides / missing
|
||||||
|
buyAgeHours: number | null;
|
||||||
|
note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ageHours(iso: string | null): number | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const t = Date.parse(iso);
|
||||||
|
if (!Number.isFinite(t)) return null;
|
||||||
|
return Math.max(0, (Date.now() - t) / 3600000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPricesForItem(item: string): MergedPrice[] {
|
||||||
|
const db = getDb();
|
||||||
|
const api = db.prepare('SELECT * FROM market_prices WHERE item = ?').all(item) as any[];
|
||||||
|
const overrides = db.prepare('SELECT * FROM price_overrides WHERE item = ?').all(item) as any[];
|
||||||
|
|
||||||
|
const byKey = new Map<string, MergedPrice>();
|
||||||
|
for (const r of api) {
|
||||||
|
byKey.set(`${r.city}|${r.quality}`, {
|
||||||
|
item,
|
||||||
|
city: r.city,
|
||||||
|
quality: r.quality,
|
||||||
|
sellPrice: r.sell_price_min,
|
||||||
|
buyPrice: r.buy_price_max,
|
||||||
|
sellSource: r.sell_price_min != null ? 'api' : null,
|
||||||
|
buySource: r.buy_price_max != null ? 'api' : null,
|
||||||
|
sellAgeHours: ageHours(r.sell_min_date),
|
||||||
|
buyAgeHours: ageHours(r.buy_max_date),
|
||||||
|
note: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const o of overrides) {
|
||||||
|
const key = `${o.city}|${o.quality}`;
|
||||||
|
const existing = byKey.get(key) ?? {
|
||||||
|
item,
|
||||||
|
city: o.city,
|
||||||
|
quality: o.quality,
|
||||||
|
sellPrice: null,
|
||||||
|
buyPrice: null,
|
||||||
|
sellSource: null,
|
||||||
|
buySource: null,
|
||||||
|
sellAgeHours: null,
|
||||||
|
buyAgeHours: null,
|
||||||
|
note: null,
|
||||||
|
};
|
||||||
|
if (o.sell_price != null) {
|
||||||
|
existing.sellPrice = o.sell_price;
|
||||||
|
existing.sellSource = 'manual';
|
||||||
|
existing.sellAgeHours = ageHours(o.updated_at);
|
||||||
|
}
|
||||||
|
if (o.buy_price != null) {
|
||||||
|
existing.buyPrice = o.buy_price;
|
||||||
|
existing.buySource = 'manual';
|
||||||
|
existing.buyAgeHours = ageHours(o.updated_at);
|
||||||
|
}
|
||||||
|
existing.note = o.note ?? existing.note;
|
||||||
|
byKey.set(key, existing);
|
||||||
|
}
|
||||||
|
return [...byKey.values()].sort((a, b) => a.city.localeCompare(b.city) || a.quality - b.quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setOverride(o: {
|
||||||
|
item: string;
|
||||||
|
city: string;
|
||||||
|
quality?: number;
|
||||||
|
sellPrice?: number | null;
|
||||||
|
buyPrice?: number | null;
|
||||||
|
note?: string | null;
|
||||||
|
}): void {
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO price_overrides (item, city, quality, sell_price, buy_price, note, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(item, city, quality) DO UPDATE SET
|
||||||
|
sell_price = excluded.sell_price,
|
||||||
|
buy_price = excluded.buy_price,
|
||||||
|
note = excluded.note,
|
||||||
|
updated_at = excluded.updated_at`,
|
||||||
|
)
|
||||||
|
.run(o.item, o.city, o.quality ?? 1, o.sellPrice ?? null, o.buyPrice ?? null, o.note ?? null, new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteOverride(item: string, city: string, quality = 1): void {
|
||||||
|
getDb().prepare('DELETE FROM price_overrides WHERE item = ? AND city = ? AND quality = ?').run(item, city, quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function marketSummary() {
|
||||||
|
const db = getDb();
|
||||||
|
const n = (sql: string) => (db.prepare(sql).get() as { n: number }).n;
|
||||||
|
return {
|
||||||
|
server: getAlbionServer(),
|
||||||
|
lastRefresh: getMeta('market_last_refresh'),
|
||||||
|
pricedRows: n('SELECT COUNT(*) AS n FROM market_prices'),
|
||||||
|
pricedItems: n('SELECT COUNT(DISTINCT item) AS n FROM market_prices'),
|
||||||
|
overrides: n('SELECT COUNT(*) AS n FROM price_overrides'),
|
||||||
|
job: getRefreshJob(),
|
||||||
|
};
|
||||||
|
}
|
||||||
90
server/src/routes/market.ts
Normal file
90
server/src/routes/market.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { getDb, getMeta, setMeta } from '../db/index.js';
|
||||||
|
import { CITIES, SERVER_HOSTS } from '../market/client.js';
|
||||||
|
import {
|
||||||
|
deleteOverride,
|
||||||
|
getPricesForItem,
|
||||||
|
getRefreshJob,
|
||||||
|
marketSummary,
|
||||||
|
setAlbionServer,
|
||||||
|
setOverride,
|
||||||
|
startRefresh,
|
||||||
|
} from '../market/service.js';
|
||||||
|
|
||||||
|
export async function marketRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get('/api/market/summary', async () => marketSummary());
|
||||||
|
|
||||||
|
app.get('/api/market/cities', async () => ({ cities: CITIES }));
|
||||||
|
|
||||||
|
app.get('/api/market/refresh/status', async () => getRefreshJob());
|
||||||
|
|
||||||
|
app.post('/api/market/refresh', async (req, reply) => {
|
||||||
|
const body = (req.body ?? {}) as {
|
||||||
|
minTier?: number;
|
||||||
|
maxTier?: number;
|
||||||
|
cities?: string[];
|
||||||
|
items?: string[];
|
||||||
|
};
|
||||||
|
const result = startRefresh(body);
|
||||||
|
if (!result.started) return reply.code(409).send({ error: result.reason });
|
||||||
|
return { started: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/market/prices/:item', async (req) => {
|
||||||
|
const { item } = req.params as { item: string };
|
||||||
|
return { prices: getPricesForItem(item) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/market/override', async (req, reply) => {
|
||||||
|
const body = req.body as {
|
||||||
|
item?: string;
|
||||||
|
city?: string;
|
||||||
|
quality?: number;
|
||||||
|
sellPrice?: number | null;
|
||||||
|
buyPrice?: number | null;
|
||||||
|
note?: string | null;
|
||||||
|
};
|
||||||
|
if (!body?.item || !body?.city) return reply.code(400).send({ error: 'item and city are required' });
|
||||||
|
setOverride({
|
||||||
|
item: body.item,
|
||||||
|
city: body.city,
|
||||||
|
quality: body.quality,
|
||||||
|
sellPrice: body.sellPrice,
|
||||||
|
buyPrice: body.buyPrice,
|
||||||
|
note: body.note,
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/market/override', async (req, reply) => {
|
||||||
|
const { item, city, quality } = req.query as { item?: string; city?: string; quality?: string };
|
||||||
|
if (!item || !city) return reply.code(400).send({ error: 'item and city are required' });
|
||||||
|
deleteOverride(item, city, quality ? Number(quality) : 1);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/settings', async () => ({
|
||||||
|
albionServer: marketSummary().server,
|
||||||
|
availableServers: Object.keys(SERVER_HOSTS),
|
||||||
|
proxyUrl: getMeta('proxy_url') ?? '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.put('/api/settings', async (req, reply) => {
|
||||||
|
const { albionServer, proxyUrl } = (req.body ?? {}) as { albionServer?: string; proxyUrl?: string };
|
||||||
|
if (albionServer) {
|
||||||
|
try {
|
||||||
|
setAlbionServer(albionServer);
|
||||||
|
} catch (e) {
|
||||||
|
return reply.code(400).send({ error: String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (proxyUrl !== undefined) {
|
||||||
|
if (proxyUrl === '') {
|
||||||
|
getDb().prepare("DELETE FROM meta WHERE key = 'proxy_url'").run();
|
||||||
|
} else {
|
||||||
|
setMeta('proxy_url', proxyUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import ItemsPage from './pages/ItemsPage';
|
import ItemsPage from './pages/ItemsPage';
|
||||||
import GameDataPage from './pages/GameDataPage';
|
import GameDataPage from './pages/GameDataPage';
|
||||||
|
import MarketPage from './pages/MarketPage';
|
||||||
|
import SettingsPage from './pages/SettingsPage';
|
||||||
|
|
||||||
const PAGES = [
|
const PAGES = [
|
||||||
|
{ id: 'market', label: 'Market Data', component: MarketPage },
|
||||||
{ id: 'items', label: 'Item Browser', component: ItemsPage },
|
{ id: 'items', label: 'Item Browser', component: ItemsPage },
|
||||||
{ id: 'gamedata', label: 'Game Data', component: GameDataPage },
|
{ id: 'gamedata', label: 'Game Data', component: GameDataPage },
|
||||||
|
{ id: 'settings', label: 'Settings', component: SettingsPage },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|
|
||||||
254
web/src/pages/MarketPage.tsx
Normal file
254
web/src/pages/MarketPage.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface Job {
|
||||||
|
status: 'idle' | 'running' | 'done' | 'error';
|
||||||
|
totalChunks: number;
|
||||||
|
doneChunks: number;
|
||||||
|
itemCount: number;
|
||||||
|
rowsUpserted: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Summary {
|
||||||
|
server: string;
|
||||||
|
lastRefresh: string | null;
|
||||||
|
pricedRows: number;
|
||||||
|
pricedItems: number;
|
||||||
|
overrides: number;
|
||||||
|
job: Job;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Price {
|
||||||
|
item: string;
|
||||||
|
city: string;
|
||||||
|
quality: number;
|
||||||
|
sellPrice: number | null;
|
||||||
|
buyPrice: number | null;
|
||||||
|
sellSource: 'api' | 'manual' | null;
|
||||||
|
buySource: 'api' | 'manual' | null;
|
||||||
|
sellAgeHours: number | null;
|
||||||
|
buyAgeHours: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgeBadge({ hours, source }: { hours: number | null; source: string | null }) {
|
||||||
|
if (source === 'manual') return <span className="tag" style={{ color: '#7fb3ff' }}>manual</span>;
|
||||||
|
if (hours == null) return <span className="tag">—</span>;
|
||||||
|
const label = hours < 1 ? `${Math.round(hours * 60)}m` : hours < 48 ? `${Math.round(hours)}h` : `${Math.round(hours / 24)}d`;
|
||||||
|
const color = hours < 6 ? '#7fd18a' : hours < 24 ? '#e0c069' : '#e08869';
|
||||||
|
return <span className="tag" style={{ color }}>{label} old</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (n: number | null) => (n == null ? '—' : n.toLocaleString('en-US'));
|
||||||
|
|
||||||
|
export default function MarketPage() {
|
||||||
|
const [summary, setSummary] = useState<Summary | null>(null);
|
||||||
|
const [minTier, setMinTier] = useState(2);
|
||||||
|
const [maxTier, setMaxTier] = useState(8);
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [results, setResults] = useState<{ unique_name: string; name_en: string | null }[]>([]);
|
||||||
|
const [selected, setSelected] = useState<{ unique_name: string; name_en: string | null } | null>(null);
|
||||||
|
const [prices, setPrices] = useState<Price[]>([]);
|
||||||
|
const [editing, setEditing] = useState<string | null>(null); // "city|quality"
|
||||||
|
const [editSell, setEditSell] = useState('');
|
||||||
|
const [editBuy, setEditBuy] = useState('');
|
||||||
|
const pollRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const loadSummary = useCallback(() => {
|
||||||
|
fetch('/api/market/summary').then((r) => r.json()).then(setSummary);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSummary();
|
||||||
|
}, [loadSummary]);
|
||||||
|
|
||||||
|
// poll while a refresh runs
|
||||||
|
useEffect(() => {
|
||||||
|
if (summary?.job.status === 'running' && pollRef.current == null) {
|
||||||
|
pollRef.current = window.setInterval(loadSummary, 2000);
|
||||||
|
}
|
||||||
|
if (summary?.job.status !== 'running' && pollRef.current != null) {
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
pollRef.current = null;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (pollRef.current != null) clearInterval(pollRef.current);
|
||||||
|
pollRef.current = null;
|
||||||
|
};
|
||||||
|
}, [summary?.job.status, loadSummary]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!q.trim()) return setResults([]);
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
fetch(`/api/items/search?q=${encodeURIComponent(q)}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => setResults(d.results ?? []));
|
||||||
|
}, 250);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [q]);
|
||||||
|
|
||||||
|
const loadPrices = useCallback((item: string) => {
|
||||||
|
fetch(`/api/market/prices/${encodeURIComponent(item)}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => setPrices(d.prices ?? []));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const select = (it: { unique_name: string; name_en: string | null }) => {
|
||||||
|
setSelected(it);
|
||||||
|
setResults([]);
|
||||||
|
setQ('');
|
||||||
|
loadPrices(it.unique_name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRefresh = async () => {
|
||||||
|
await fetch('/api/market/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ minTier, maxTier }),
|
||||||
|
});
|
||||||
|
loadSummary();
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveOverride = async (p: Price) => {
|
||||||
|
await fetch('/api/market/override', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
item: p.item,
|
||||||
|
city: p.city,
|
||||||
|
quality: p.quality,
|
||||||
|
sellPrice: editSell === '' ? null : Number(editSell),
|
||||||
|
buyPrice: editBuy === '' ? null : Number(editBuy),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setEditing(null);
|
||||||
|
loadPrices(p.item);
|
||||||
|
loadSummary();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearOverride = async (p: Price) => {
|
||||||
|
await fetch(`/api/market/override?item=${encodeURIComponent(p.item)}&city=${encodeURIComponent(p.city)}&quality=${p.quality}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
setEditing(null);
|
||||||
|
loadPrices(p.item);
|
||||||
|
loadSummary();
|
||||||
|
};
|
||||||
|
|
||||||
|
const job = summary?.job;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Market Data</h2>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<p>
|
||||||
|
Server: <strong>{summary?.server ?? '…'}</strong> · {summary?.pricedItems ?? 0} items priced (
|
||||||
|
{summary?.pricedRows ?? 0} rows) · {summary?.overrides ?? 0} manual overrides · last refresh:{' '}
|
||||||
|
{summary?.lastRefresh ? new Date(summary.lastRefresh).toLocaleString() : 'never'}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<label>
|
||||||
|
Tier <select value={minTier} onChange={(e) => setMinTier(Number(e.target.value))}>
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 8].map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
to <select value={maxTier} onChange={(e) => setMaxTier(Number(e.target.value))}>
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 8].map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button className="primary" disabled={job?.status === 'running'} onClick={startRefresh}>
|
||||||
|
{job?.status === 'running' ? 'Refreshing…' : 'Download prices'}
|
||||||
|
</button>
|
||||||
|
{job?.status === 'running' && (
|
||||||
|
<span className="muted">
|
||||||
|
{job.doneChunks}/{job.totalChunks} batches ({job.itemCount} items)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{job?.status === 'done' && <span className="muted">Last run stored {job.rowsUpserted} price rows.</span>}
|
||||||
|
</div>
|
||||||
|
{job?.status === 'error' && (
|
||||||
|
<p style={{ color: '#e08869' }}>
|
||||||
|
{job.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Price lookup</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search an item to see its prices…"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
{results.length > 0 && (
|
||||||
|
<table style={{ marginTop: 10 }}>
|
||||||
|
<tbody>
|
||||||
|
{results.slice(0, 10).map((r) => (
|
||||||
|
<tr key={r.unique_name} className="clickable" onClick={() => select(r)}>
|
||||||
|
<td>{r.name_en ?? r.unique_name}</td>
|
||||||
|
<td className="muted">{r.unique_name}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<>
|
||||||
|
<h4>
|
||||||
|
{selected.name_en ?? selected.unique_name} <span className="tag">{selected.unique_name}</span>
|
||||||
|
</h4>
|
||||||
|
{prices.length === 0 && (
|
||||||
|
<p className="muted">No price data yet — run a price download above, or add a manual price.</p>
|
||||||
|
)}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>City</th><th>Q</th><th>Sell (min)</th><th></th><th>Buy (max)</th><th></th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{prices.map((p) => {
|
||||||
|
const key = `${p.city}|${p.quality}`;
|
||||||
|
return (
|
||||||
|
<tr key={key}>
|
||||||
|
<td>{p.city}</td>
|
||||||
|
<td>{p.quality}</td>
|
||||||
|
<td>{fmt(p.sellPrice)}</td>
|
||||||
|
<td><AgeBadge hours={p.sellAgeHours} source={p.sellSource} /></td>
|
||||||
|
<td>{fmt(p.buyPrice)}</td>
|
||||||
|
<td><AgeBadge hours={p.buyAgeHours} source={p.buySource} /></td>
|
||||||
|
<td>
|
||||||
|
{editing === key ? (
|
||||||
|
<span style={{ display: 'inline-flex', gap: 6 }}>
|
||||||
|
<input type="number" placeholder="sell" value={editSell} style={{ width: 90 }}
|
||||||
|
onChange={(e) => setEditSell(e.target.value)} />
|
||||||
|
<input type="number" placeholder="buy" value={editBuy} style={{ width: 90 }}
|
||||||
|
onChange={(e) => setEditBuy(e.target.value)} />
|
||||||
|
<button className="primary" onClick={() => saveOverride(p)}>Save</button>
|
||||||
|
<button onClick={() => clearOverride(p)}>Clear</button>
|
||||||
|
<button onClick={() => setEditing(null)}>✕</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => {
|
||||||
|
setEditing(key);
|
||||||
|
setEditSell(p.sellSource === 'manual' && p.sellPrice != null ? String(p.sellPrice) : '');
|
||||||
|
setEditBuy(p.buySource === 'manual' && p.buyPrice != null ? String(p.buyPrice) : '');
|
||||||
|
}}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
web/src/pages/SettingsPage.tsx
Normal file
69
web/src/pages/SettingsPage.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
albionServer: string;
|
||||||
|
availableServers: string[];
|
||||||
|
proxyUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [settings, setSettings] = useState<Settings | null>(null);
|
||||||
|
const [proxyUrl, setProxyUrl] = useState('');
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/settings')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((s: Settings) => {
|
||||||
|
setSettings(s);
|
||||||
|
setProxyUrl(s.proxyUrl);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const save = async (patch: Partial<{ albionServer: string; proxyUrl: string }>) => {
|
||||||
|
await fetch('/api/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
const s = await fetch('/api/settings').then((r) => r.json());
|
||||||
|
setSettings(s);
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!settings) return <p className="muted">Loading…</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<div className="card">
|
||||||
|
<h3>Albion server</h3>
|
||||||
|
<p className="muted">Which game server's market prices to download.</p>
|
||||||
|
<select value={settings.albionServer} onChange={(e) => save({ albionServer: e.target.value })}>
|
||||||
|
{settings.availableServers.map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h3>Proxy for market data (optional)</h3>
|
||||||
|
<p className="muted">
|
||||||
|
The market data API (albion-online-data.com) is blocked in some countries. If your VPN covers the whole
|
||||||
|
system, leave this empty. If it is browser-only, enter an HTTP proxy URL here (e.g.
|
||||||
|
http://127.0.0.1:8080) and market downloads will go through it.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="http://host:port (empty = direct connection)"
|
||||||
|
value={proxyUrl}
|
||||||
|
onChange={(e) => setProxyUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="primary" onClick={() => save({ proxyUrl })}>Save</button>
|
||||||
|
</div>
|
||||||
|
{saved && <p style={{ color: '#7fd18a' }}>Saved.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -74,6 +74,17 @@ button.primary {
|
||||||
}
|
}
|
||||||
button.primary:disabled { opacity: 0.5; cursor: default; }
|
button.primary:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
button:not(.primary) {
|
||||||
|
background: #2b323e;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
button:not(.primary):hover { background: #333c4b; }
|
||||||
|
|
||||||
table { border-collapse: collapse; width: 100%; }
|
table { border-collapse: collapse; width: 100%; }
|
||||||
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--border); }
|
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--border); }
|
||||||
th { color: var(--muted); font-weight: 600; font-size: 13px; }
|
th { color: var(--muted); font-weight: 600; font-size: 13px; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue