mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-10 00:08:22 +03:00
feat: import timestops (#1574)
* feat: import timestops * feat: and a page to edit it
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE import_timestop (
|
||||
user_id BIGINT NOT NULL REFERENCES account(id),
|
||||
import_type IMPORT_TYPE NOT NULL,
|
||||
last_score_time TIMESTAMPTZ NOT NULL,
|
||||
PRIMARY KEY (user_id, import_type)
|
||||
);
|
||||
@@ -0,0 +1,227 @@
|
||||
import Card from "#components/layout/page/Card";
|
||||
import ApiError from "#components/util/ApiError";
|
||||
import Loading from "#components/util/Loading";
|
||||
import useApiQuery from "#components/util/query/useApiQuery";
|
||||
import { APIFetchV1 } from "#util/api";
|
||||
import { FormatTime } from "#util/time";
|
||||
import { DateTime } from "luxon";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Alert, Button, Form, Table } from "react-bootstrap";
|
||||
import { useQueryClient } from "react-query";
|
||||
import { type UserDocument } from "tachi-common";
|
||||
|
||||
type ImportTimestopEntry = {
|
||||
importType: string;
|
||||
lastScoreTime: number | null;
|
||||
};
|
||||
|
||||
function toDatetimeLocalValue(ms: number | null): string {
|
||||
if (ms === null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return DateTime.fromMillis(ms).toFormat("yyyy-MM-dd'T'HH:mm");
|
||||
}
|
||||
|
||||
function fromDatetimeLocalValue(value: string): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = DateTime.fromFormat(value, "yyyy-MM-dd'T'HH:mm");
|
||||
|
||||
if (!parsed.isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.toMillis();
|
||||
}
|
||||
|
||||
export default function UserImportTimestopsTab({ reqUser }: { reqUser: UserDocument }) {
|
||||
const queryKey = `/users/${reqUser.id}/import-timestops`;
|
||||
const queryClient = useQueryClient();
|
||||
const { data, error } = useApiQuery<{ timestops: Array<ImportTimestopEntry> }>(queryKey);
|
||||
const [busyImportType, setBusyImportType] = useState<string | null>(null);
|
||||
const [draftTimes, setDraftTimes] = useState<Record<string, string>>({});
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const timestops = useMemo(() => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...data.timestops].sort((a, b) => a.importType.localeCompare(b.importType));
|
||||
}, [data]);
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const invalidate = async () => {
|
||||
await queryClient.invalidateQueries([queryKey]);
|
||||
};
|
||||
|
||||
const onReset = async (importType: string) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Reset the timestop for ${importType}? The next import will fetch all available scores again.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusyImportType(importType);
|
||||
setMessage(null);
|
||||
|
||||
const res = await APIFetchV1(
|
||||
`/users/${reqUser.id}/import-timestops`,
|
||||
{
|
||||
body: JSON.stringify({ importType }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "DELETE",
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
setBusyImportType(null);
|
||||
|
||||
if (!res.success) {
|
||||
setMessage(res.description);
|
||||
return;
|
||||
}
|
||||
|
||||
setDraftTimes((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[importType];
|
||||
return next;
|
||||
});
|
||||
setMessage(`Reset timestop for ${importType}.`);
|
||||
await invalidate();
|
||||
};
|
||||
|
||||
const onSave = async (importType: string) => {
|
||||
const draft =
|
||||
draftTimes[importType] ??
|
||||
toDatetimeLocalValue(
|
||||
timestops.find((entry) => entry.importType === importType)?.lastScoreTime ?? null,
|
||||
);
|
||||
const lastScoreTime = fromDatetimeLocalValue(draft);
|
||||
|
||||
if (lastScoreTime === null) {
|
||||
setMessage("Enter a valid date and time.");
|
||||
return;
|
||||
}
|
||||
|
||||
setBusyImportType(importType);
|
||||
setMessage(null);
|
||||
|
||||
const res = await APIFetchV1(
|
||||
`/users/${reqUser.id}/import-timestops`,
|
||||
{
|
||||
body: JSON.stringify({ importType, lastScoreTime }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PUT",
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
setBusyImportType(null);
|
||||
|
||||
if (!res.success) {
|
||||
setMessage(res.description);
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage(`Updated timestop for ${importType}.`);
|
||||
await invalidate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vstack gap-3">
|
||||
<Card header="Import timestops">
|
||||
<p className="mb-2">
|
||||
API imports track the newest score they have already imported. On the next
|
||||
import, anything at or before that timestamp is skipped.
|
||||
</p>
|
||||
<p className="mb-0">
|
||||
Reset a timestop to re-fetch all scores, or set a custom cursor if you need to
|
||||
re-import from a specific point in time.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{message && <Alert variant="info">{message}</Alert>}
|
||||
|
||||
<Table hover responsive striped>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Import type</th>
|
||||
<th>Current cursor</th>
|
||||
<th>Set cursor</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{timestops.map((entry) => {
|
||||
const draft =
|
||||
draftTimes[entry.importType] ??
|
||||
toDatetimeLocalValue(entry.lastScoreTime);
|
||||
const isBusy = busyImportType === entry.importType;
|
||||
|
||||
return (
|
||||
<tr key={entry.importType}>
|
||||
<td>
|
||||
<code>{entry.importType}</code>
|
||||
</td>
|
||||
<td>
|
||||
{entry.lastScoreTime === null ? (
|
||||
<span className="text-muted">Not set</span>
|
||||
) : (
|
||||
FormatTime(entry.lastScoreTime)
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<Form.Control
|
||||
disabled={isBusy}
|
||||
onChange={(event) =>
|
||||
setDraftTimes((prev) => ({
|
||||
...prev,
|
||||
[entry.importType]: event.target.value,
|
||||
}))
|
||||
}
|
||||
type="datetime-local"
|
||||
value={draft}
|
||||
/>
|
||||
</td>
|
||||
<td className="text-nowrap">
|
||||
<Button
|
||||
className="me-2"
|
||||
disabled={isBusy}
|
||||
onClick={() => void onSave(entry.importType)}
|
||||
size="sm"
|
||||
variant="primary"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isBusy || entry.lastScoreTime === null}
|
||||
onClick={() => void onReset(entry.importType)}
|
||||
size="sm"
|
||||
variant="outline-danger"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,11 @@ import useApiQuery from "#components/util/query/useApiQuery";
|
||||
import SelectLinkButton from "#components/util/SelectLinkButton";
|
||||
import { type FailedImportDataset, type ImportDataset } from "#types/tables";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import { Route, Switch, useLocation } from "react-router-dom";
|
||||
import { type ImportDocument, type ImportTrackerFailed, type UserDocument } from "tachi-common";
|
||||
|
||||
import UserImportTimestopsTab from "./UserImportTimestopsTab";
|
||||
|
||||
export default function UserImportsPage({ reqUser }: { reqUser: UserDocument }) {
|
||||
useSetSubheader(
|
||||
["Users", reqUser.username, "Imports"],
|
||||
@@ -37,6 +39,9 @@ export default function UserImportsPage({ reqUser }: { reqUser: UserDocument })
|
||||
return p;
|
||||
}, [userIntent, importType]);
|
||||
|
||||
const location = useLocation();
|
||||
const onTimestopsTab = location.pathname.endsWith("/imports/timestops");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex justify-content-center btn-group">
|
||||
@@ -46,10 +51,19 @@ export default function UserImportsPage({ reqUser }: { reqUser: UserDocument })
|
||||
<SelectLinkButton to={`/u/${reqUser.username}/imports/failed`}>
|
||||
Recent Failed Imports
|
||||
</SelectLinkButton>
|
||||
<SelectLinkButton to={`/u/${reqUser.username}/imports/timestops`}>
|
||||
Timestops
|
||||
</SelectLinkButton>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
<ImportViewerOptions {...{ userIntent, setUserIntent, importType, setImportType }} />
|
||||
{!onTimestopsTab && (
|
||||
<>
|
||||
<Divider />
|
||||
<ImportViewerOptions
|
||||
{...{ userIntent, setUserIntent, importType, setImportType }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Divider />
|
||||
<Switch>
|
||||
<Route exact path={`/u/${reqUser.username}/imports`}>
|
||||
@@ -59,6 +73,10 @@ export default function UserImportsPage({ reqUser }: { reqUser: UserDocument })
|
||||
<Route path={`/u/${reqUser.username}/imports/failed`}>
|
||||
<ViewRecentFailedImports params={params} reqUser={reqUser} />
|
||||
</Route>
|
||||
|
||||
<Route path={`/u/${reqUser.username}/imports/timestops`}>
|
||||
<UserImportTimestopsTab reqUser={reqUser} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ export { type priv_svc_cg_card_info_service, type default as PrivSvcCgCardInfoTa
|
||||
export { type score_rederive_chart_id, type default as ScoreRederiveTable, type ScoreRederive, type NewScoreRederive, type ScoreRederiveUpdate } from './public/ScoreRederive';
|
||||
export { type class_achievement_row_id, type default as ClassAchievementTable, type ClassAchievement, type NewClassAchievement, type ClassAchievementUpdate } from './public/ClassAchievement';
|
||||
export { type account_id, type default as AccountTable, type Account, type NewAccount, type AccountUpdate } from './public/Account';
|
||||
export { type import_timestop_import_type, type default as ImportTimestopTable, type ImportTimestop, type NewImportTimestop, type ImportTimestopUpdate } from './public/ImportTimestop';
|
||||
export { type import_tracker_import_id, type default as ImportTrackerTable, type ImportTracker, type NewImportTracker, type ImportTrackerUpdate } from './public/ImportTracker';
|
||||
export { type default as SvcFerSettingsTable, type SvcFerSettings, type NewSvcFerSettings, type SvcFerSettingsUpdate } from './public/SvcFerSettings';
|
||||
export { type default as InviteLockTable, type InviteLock, type NewInviteLock, type InviteLockUpdate } from './public/InviteLock';
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { default as PrivSvcCgCardInfoTable } from './PrivSvcCgCardInfo';
|
||||
import type { default as ScoreRederiveTable } from './ScoreRederive';
|
||||
import type { default as ClassAchievementTable } from './ClassAchievement';
|
||||
import type { default as AccountTable } from './Account';
|
||||
import type { default as ImportTimestopTable } from './ImportTimestop';
|
||||
import type { default as ImportTrackerTable } from './ImportTracker';
|
||||
import type { default as SvcFerSettingsTable } from './SvcFerSettings';
|
||||
import type { default as InviteLockTable } from './InviteLock';
|
||||
@@ -79,6 +80,8 @@ export default interface PublicSchema {
|
||||
|
||||
account: AccountTable;
|
||||
|
||||
import_timestop: ImportTimestopTable;
|
||||
|
||||
import_tracker: ImportTrackerTable;
|
||||
|
||||
svc_fer_settings: SvcFerSettingsTable;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DeleteImportTimestop,
|
||||
GetImportTimestop,
|
||||
ListImportTimestops,
|
||||
SetImportTimestop,
|
||||
SetImportTimestopManual,
|
||||
} from "./timestop";
|
||||
|
||||
describe("GetImportTimestop", () => {
|
||||
let userID: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userID } = await seedUser());
|
||||
});
|
||||
|
||||
it("returns null when no row exists", async () => {
|
||||
const result = await GetImportTimestop(userID, "api/eag-iidx");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the stored date after SetImportTimestop", async () => {
|
||||
const time = new Date("2025-03-15T12:00:00.000Z");
|
||||
|
||||
await SetImportTimestop(userID, "api/eag-iidx", time);
|
||||
|
||||
const result = await GetImportTimestop(userID, "api/eag-iidx");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getTime()).toBe(time.getTime());
|
||||
});
|
||||
|
||||
it("is scoped per import type", async () => {
|
||||
const time = new Date("2025-01-01T00:00:00.000Z");
|
||||
|
||||
await SetImportTimestop(userID, "api/eag-iidx", time);
|
||||
|
||||
expect(await GetImportTimestop(userID, "api/eag-sdvx")).toBeNull();
|
||||
});
|
||||
|
||||
it("is scoped per user", async () => {
|
||||
const other = await seedUser({ username: "other_user" });
|
||||
const time = new Date("2025-01-01T00:00:00.000Z");
|
||||
|
||||
await SetImportTimestop(other.id, "api/eag-iidx", time);
|
||||
|
||||
expect(await GetImportTimestop(userID, "api/eag-iidx")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SetImportTimestop", () => {
|
||||
let userID: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userID } = await seedUser());
|
||||
});
|
||||
|
||||
it("inserts a new row when none exists", async () => {
|
||||
const time = new Date("2025-06-01T00:00:00.000Z");
|
||||
|
||||
await SetImportTimestop(userID, "api/flo-sdvx", time);
|
||||
|
||||
const row = await DB.selectFrom("import_timestop")
|
||||
.select(["import_timestop.last_score_time"])
|
||||
.where("import_timestop.user_id", "=", userID)
|
||||
.where("import_timestop.import_type", "=", "api/flo-sdvx")
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(new Date(row!.last_score_time).getTime()).toBe(time.getTime());
|
||||
});
|
||||
|
||||
it("advances the cursor to a later timestamp", async () => {
|
||||
const t1 = new Date("2025-01-01T00:00:00.000Z");
|
||||
const t2 = new Date("2025-06-01T00:00:00.000Z");
|
||||
|
||||
await SetImportTimestop(userID, "api/eag-iidx", t1);
|
||||
await SetImportTimestop(userID, "api/eag-iidx", t2);
|
||||
|
||||
const result = await GetImportTimestop(userID, "api/eag-iidx");
|
||||
|
||||
expect(result!.getTime()).toBe(t2.getTime());
|
||||
});
|
||||
|
||||
it("does not go backwards when called with an earlier timestamp", async () => {
|
||||
const t1 = new Date("2025-06-01T00:00:00.000Z");
|
||||
const t2 = new Date("2025-01-01T00:00:00.000Z");
|
||||
|
||||
await SetImportTimestop(userID, "api/eag-iidx", t1);
|
||||
await SetImportTimestop(userID, "api/eag-iidx", t2);
|
||||
|
||||
const result = await GetImportTimestop(userID, "api/eag-iidx");
|
||||
|
||||
// GREATEST keeps t1
|
||||
expect(result!.getTime()).toBe(t1.getTime());
|
||||
});
|
||||
|
||||
it("only maintains one row per (user, import_type)", async () => {
|
||||
const t1 = new Date("2025-01-01T00:00:00.000Z");
|
||||
const t2 = new Date("2025-06-01T00:00:00.000Z");
|
||||
|
||||
await SetImportTimestop(userID, "api/eag-iidx", t1);
|
||||
await SetImportTimestop(userID, "api/eag-iidx", t2);
|
||||
|
||||
const count = await DB.selectFrom("import_timestop")
|
||||
.select(DB.fn.countAll().as("count"))
|
||||
.where("import_timestop.user_id", "=", userID)
|
||||
.where("import_timestop.import_type", "=", "api/eag-iidx")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(Number(count.count)).toBe(1);
|
||||
});
|
||||
|
||||
it("does not affect other users", async () => {
|
||||
const other = await seedUser({ username: "other_user" });
|
||||
const myTime = new Date("2025-01-01T00:00:00.000Z");
|
||||
const theirTime = new Date("2026-01-01T00:00:00.000Z");
|
||||
|
||||
await SetImportTimestop(userID, "api/eag-iidx", myTime);
|
||||
await SetImportTimestop(other.id, "api/eag-iidx", theirTime);
|
||||
|
||||
const myResult = await GetImportTimestop(userID, "api/eag-iidx");
|
||||
const theirResult = await GetImportTimestop(other.id, "api/eag-iidx");
|
||||
|
||||
expect(myResult!.getTime()).toBe(myTime.getTime());
|
||||
expect(theirResult!.getTime()).toBe(theirTime.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("ListImportTimestops", () => {
|
||||
let userID: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userID } = await seedUser());
|
||||
});
|
||||
|
||||
it("returns all API import types with null when unset", async () => {
|
||||
const result = await ListImportTimestops(userID);
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.every((entry) => entry.lastScoreTime === null)).toBe(true);
|
||||
});
|
||||
|
||||
it("includes stored cursors", async () => {
|
||||
const time = new Date("2025-03-15T12:00:00.000Z");
|
||||
|
||||
await SetImportTimestop(userID, "api/eag-iidx", time);
|
||||
|
||||
const result = await ListImportTimestops(userID);
|
||||
const entry = result.find((e) => e.importType === "api/eag-iidx");
|
||||
|
||||
expect(entry?.lastScoreTime).toBe(time.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeleteImportTimestop", () => {
|
||||
let userID: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userID } = await seedUser());
|
||||
});
|
||||
|
||||
it("removes an existing cursor", async () => {
|
||||
await SetImportTimestop(userID, "api/eag-iidx", new Date("2025-01-01T00:00:00.000Z"));
|
||||
|
||||
await DeleteImportTimestop(userID, "api/eag-iidx");
|
||||
|
||||
expect(await GetImportTimestop(userID, "api/eag-iidx")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SetImportTimestopManual", () => {
|
||||
let userID: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userID } = await seedUser());
|
||||
});
|
||||
|
||||
it("can move the cursor backwards", async () => {
|
||||
const later = new Date("2025-06-01T00:00:00.000Z");
|
||||
const earlier = new Date("2025-01-01T00:00:00.000Z");
|
||||
|
||||
await SetImportTimestop(userID, "api/eag-iidx", later);
|
||||
await SetImportTimestopManual(userID, "api/eag-iidx", earlier);
|
||||
|
||||
const result = await GetImportTimestop(userID, "api/eag-iidx");
|
||||
|
||||
expect(result!.getTime()).toBe(earlier.getTime());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { APIImportTypes, ImportTypes, integer } from "tachi-common";
|
||||
|
||||
import DB from "#services/pg/db";
|
||||
import { sql } from "kysely";
|
||||
import { apiImportTypes } from "tachi-common/constants/import-types";
|
||||
|
||||
export type ImportTimestopEntry = {
|
||||
importType: APIImportTypes;
|
||||
lastScoreTime: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the stored last-score-time cursor for a given user + import type,
|
||||
* or null if no import has been completed yet.
|
||||
*/
|
||||
export async function GetImportTimestop(
|
||||
userID: integer,
|
||||
importType: ImportTypes,
|
||||
): Promise<Date | null> {
|
||||
const row = await DB.selectFrom("import_timestop")
|
||||
.select(["import_timestop.last_score_time"])
|
||||
.where("import_timestop.user_id", "=", userID)
|
||||
.where("import_timestop.import_type", "=", importType)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(row.last_score_time);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upserts the last-score-time cursor for a given user + import type, always
|
||||
* advancing to the greater of the existing value and the new one.
|
||||
*/
|
||||
export async function SetImportTimestop(
|
||||
userID: integer,
|
||||
importType: ImportTypes,
|
||||
time: Date,
|
||||
): Promise<void> {
|
||||
const isoTime = time.toISOString();
|
||||
|
||||
await DB.insertInto("import_timestop")
|
||||
.values({
|
||||
user_id: userID,
|
||||
import_type: importType,
|
||||
last_score_time: isoTime,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.columns(["user_id", "import_type"]).doUpdateSet({
|
||||
last_score_time: sql<string>`GREATEST(EXCLUDED.last_score_time, import_timestop.last_score_time)`,
|
||||
}),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every API import type and its stored cursor (null when unset).
|
||||
*/
|
||||
export async function ListImportTimestops(userID: integer): Promise<Array<ImportTimestopEntry>> {
|
||||
const rows = await DB.selectFrom("import_timestop")
|
||||
.select(["import_timestop.import_type", "import_timestop.last_score_time"])
|
||||
.where("import_timestop.user_id", "=", userID)
|
||||
.execute();
|
||||
|
||||
const byType = new Map(
|
||||
rows.map((row) => [row.import_type, new Date(row.last_score_time).getTime()]),
|
||||
);
|
||||
|
||||
return apiImportTypes.map((importType) => ({
|
||||
importType,
|
||||
lastScoreTime: byType.get(importType) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the stored cursor so the next import starts from scratch.
|
||||
*/
|
||||
export async function DeleteImportTimestop(
|
||||
userID: integer,
|
||||
importType: ImportTypes,
|
||||
): Promise<void> {
|
||||
await DB.deleteFrom("import_timestop")
|
||||
.where("import_timestop.user_id", "=", userID)
|
||||
.where("import_timestop.import_type", "=", importType)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the cursor to an exact timestamp, replacing any existing value.
|
||||
* Unlike {@link SetImportTimestop}, this can move the cursor backwards.
|
||||
*/
|
||||
export async function SetImportTimestopManual(
|
||||
userID: integer,
|
||||
importType: ImportTypes,
|
||||
time: Date,
|
||||
): Promise<void> {
|
||||
const isoTime = time.toISOString();
|
||||
|
||||
await DB.insertInto("import_timestop")
|
||||
.values({
|
||||
user_id: userID,
|
||||
import_type: importType,
|
||||
last_score_time: isoTime,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.columns(["user_id", "import_type"]).doUpdateSet({
|
||||
last_score_time: isoTime,
|
||||
}),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { ScoreImportJob } from "#lib/score-import/worker/types";
|
||||
|
||||
import { LoadImportDocumentById } from "#lib/db-formats/import-document";
|
||||
import { clearPbDirtyForUser } from "#lib/jobs/drain-dirty-queues";
|
||||
import { SetImportTimestop } from "#lib/score-import/framework/common/timestop";
|
||||
import { runWithImportContext } from "#lib/score-import/framework/import-run-context";
|
||||
import {
|
||||
deleteImportRun,
|
||||
@@ -19,6 +20,7 @@ import DB from "#services/pg/db";
|
||||
import { GetMillisecondsSince } from "#utils/misc";
|
||||
import { GetUserWithID } from "#utils/user";
|
||||
import {
|
||||
type APIImportTypes,
|
||||
type GameGroup,
|
||||
GetGameGroupConfig,
|
||||
type GoalImportInfo,
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
type UserDocument,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
import { apiImportTypes } from "tachi-common/constants/import-types";
|
||||
|
||||
import type { ClassProvider } from "../calculated-data/types";
|
||||
import type { ChartIDGameMap, ScoreGameMap } from "../common/types";
|
||||
@@ -238,6 +241,20 @@ export default async function ScoreImportMain<D, C>(
|
||||
|
||||
observeScoreImportDuration(importType, Date.now() - timeStarted);
|
||||
|
||||
// For API imports, advance the per-user timestop cursor so subsequent
|
||||
// imports can stop once they reach already-imported scores.
|
||||
if (apiImportTypes.includes(importType as APIImportTypes)) {
|
||||
const maxTimeAchieved = importInfo
|
||||
.filter((i): i is { success: true } & typeof i => i.success)
|
||||
.map((i) => i.content.score.timeAchieved)
|
||||
.filter((t): t is number => t !== null)
|
||||
.reduce((max, t) => Math.max(max, t), -Infinity);
|
||||
|
||||
if (Number.isFinite(maxTimeAchieved)) {
|
||||
await SetImportTimestop(userID, importType, new Date(maxTimeAchieved));
|
||||
}
|
||||
}
|
||||
|
||||
const loaded = await LoadImportDocumentById(importID);
|
||||
|
||||
if (!loaded) {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { integer } from "tachi-common";
|
||||
|
||||
import { GetImportTimestop } from "#lib/score-import/framework/common/timestop";
|
||||
import { ParseKaiIIDX } from "#lib/score-import/import-types/common/api-kai/iidx/parser";
|
||||
import { GetKaiAuthGuaranteed } from "#utils/queries/auth";
|
||||
|
||||
export async function ParseEagIIDX(userID: integer, log: KtLogger) {
|
||||
const authDoc = await GetKaiAuthGuaranteed(userID, "EAG", log);
|
||||
const [authDoc, lastScoreTime] = await Promise.all([
|
||||
GetKaiAuthGuaranteed(userID, "EAG", log),
|
||||
GetImportTimestop(userID, "api/eag-iidx"),
|
||||
]);
|
||||
|
||||
return ParseKaiIIDX("EAG", authDoc, log);
|
||||
return ParseKaiIIDX("EAG", authDoc, log, undefined, null, lastScoreTime);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { integer } from "tachi-common";
|
||||
|
||||
import { GetImportTimestop } from "#lib/score-import/framework/common/timestop";
|
||||
import { ParseKaiSDVX } from "#lib/score-import/import-types/common/api-kai/sdvx/parser";
|
||||
import { GetKaiAuthGuaranteed } from "#utils/queries/auth";
|
||||
|
||||
export async function ParseEagSDVX(userID: integer, log: KtLogger) {
|
||||
const authDoc = await GetKaiAuthGuaranteed(userID, "EAG", log);
|
||||
const [authDoc, lastScoreTime] = await Promise.all([
|
||||
GetKaiAuthGuaranteed(userID, "EAG", log),
|
||||
GetImportTimestop(userID, "api/eag-sdvx"),
|
||||
]);
|
||||
|
||||
return ParseKaiSDVX("EAG", authDoc, log);
|
||||
return ParseKaiSDVX("EAG", authDoc, log, undefined, null, lastScoreTime);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { integer } from "tachi-common";
|
||||
|
||||
import { GetImportTimestop } from "#lib/score-import/framework/common/timestop";
|
||||
import { ParseKaiIIDX } from "#lib/score-import/import-types/common/api-kai/iidx/parser";
|
||||
import { GetKaiAuthGuaranteed } from "#utils/queries/auth";
|
||||
|
||||
export async function ParseFloIIDX(userID: integer, log: KtLogger) {
|
||||
const authDoc = await GetKaiAuthGuaranteed(userID, "FLO", log);
|
||||
const [authDoc, lastScoreTime] = await Promise.all([
|
||||
GetKaiAuthGuaranteed(userID, "FLO", log),
|
||||
GetImportTimestop(userID, "api/flo-iidx"),
|
||||
]);
|
||||
|
||||
return ParseKaiIIDX("FLO", authDoc, log);
|
||||
return ParseKaiIIDX("FLO", authDoc, log, undefined, null, lastScoreTime);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { integer } from "tachi-common";
|
||||
|
||||
import { GetImportTimestop } from "#lib/score-import/framework/common/timestop";
|
||||
import { ParseKaiSDVX } from "#lib/score-import/import-types/common/api-kai/sdvx/parser";
|
||||
import { GetKaiAuthGuaranteed } from "#utils/queries/auth";
|
||||
|
||||
export async function ParseFloSDVX(userID: integer, log: KtLogger) {
|
||||
const authDoc = await GetKaiAuthGuaranteed(userID, "FLO", log);
|
||||
const [authDoc, lastScoreTime] = await Promise.all([
|
||||
GetKaiAuthGuaranteed(userID, "FLO", log),
|
||||
GetImportTimestop(userID, "api/flo-sdvx"),
|
||||
]);
|
||||
|
||||
return ParseKaiSDVX("FLO", authDoc, log);
|
||||
return ParseKaiSDVX("FLO", authDoc, log, undefined, null, lastScoreTime);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { integer } from "tachi-common";
|
||||
|
||||
import { GetImportTimestop } from "#lib/score-import/framework/common/timestop";
|
||||
import { ParseKaiSDVX } from "#lib/score-import/import-types/common/api-kai/sdvx/parser";
|
||||
import { GetKaiAuthGuaranteed } from "#utils/queries/auth";
|
||||
|
||||
export async function ParseMinSDVX(userID: integer, log: KtLogger) {
|
||||
const authDoc = await GetKaiAuthGuaranteed(userID, "MIN", log);
|
||||
const [authDoc, lastScoreTime] = await Promise.all([
|
||||
GetKaiAuthGuaranteed(userID, "MIN", log),
|
||||
GetImportTimestop(userID, "api/min-sdvx"),
|
||||
]);
|
||||
|
||||
return ParseKaiSDVX("MIN", authDoc, log);
|
||||
return ParseKaiSDVX("MIN", authDoc, log, undefined, null, lastScoreTime);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ParserFunctionReturns } from "#lib/score-import/import-types/commo
|
||||
import type { EmptyObject } from "#utils/types";
|
||||
import type { GamesForGroup, integer } from "tachi-common";
|
||||
|
||||
import { GetImportTimestop } from "#lib/score-import/framework/common/timestop";
|
||||
import { drainMytPlaylogStream } from "#lib/score-import/import-types/common/api-myt/buffer-playlog-stream";
|
||||
import {
|
||||
CreateMytTransport,
|
||||
@@ -15,9 +16,16 @@ import { createClient } from "@connectrpc/connect";
|
||||
import type { MytChunithmScore } from "./types";
|
||||
|
||||
async function* streamPlaylog(userID: integer, log: KtLogger): AsyncIterable<MytChunithmScore> {
|
||||
const profileApiId = await FetchMytTitleAPIID(userID, "chunithm", log);
|
||||
const [profileApiId, lastScoreTime] = await Promise.all([
|
||||
FetchMytTitleAPIID(userID, "chunithm", log),
|
||||
GetImportTimestop(userID, "api/myt-chunithm"),
|
||||
]);
|
||||
|
||||
const client = createClient(ChunithmUser, CreateMytTransport());
|
||||
const request = create(GetPlaylogRequestSchema, { profileApiId });
|
||||
const request = create(GetPlaylogRequestSchema, {
|
||||
profileApiId,
|
||||
lastUserPlayDate: lastScoreTime?.toISOString() ?? undefined,
|
||||
});
|
||||
|
||||
yield* await drainMytPlaylogStream(client.getPlaylog(request), log, {
|
||||
gameLabel: "Chunithm",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ParserFunctionReturns } from "#lib/score-import/import-types/commo
|
||||
import type { EmptyObject } from "#utils/types";
|
||||
import type { GamesForGroup, integer } from "tachi-common";
|
||||
|
||||
import { GetImportTimestop } from "#lib/score-import/framework/common/timestop";
|
||||
import { drainMytPlaylogStream } from "#lib/score-import/import-types/common/api-myt/buffer-playlog-stream";
|
||||
import {
|
||||
CreateMytTransport,
|
||||
@@ -15,9 +16,16 @@ import { createClient } from "@connectrpc/connect";
|
||||
import type { MytMaimaiDxScore } from "./types";
|
||||
|
||||
async function* streamPlaylog(userID: integer, log: KtLogger): AsyncIterable<MytMaimaiDxScore> {
|
||||
const profileApiId = await FetchMytTitleAPIID(userID, "maimaidx", log);
|
||||
const [profileApiId, lastScoreTime] = await Promise.all([
|
||||
FetchMytTitleAPIID(userID, "maimaidx", log),
|
||||
GetImportTimestop(userID, "api/myt-maimaidx"),
|
||||
]);
|
||||
|
||||
const client = createClient(MaimaiUser, CreateMytTransport());
|
||||
const request = create(GetPlaylogRequestSchema, { profileApiId });
|
||||
const request = create(GetPlaylogRequestSchema, {
|
||||
profileApiId,
|
||||
lastUserPlayDate: lastScoreTime?.toISOString() ?? undefined,
|
||||
});
|
||||
|
||||
yield* await drainMytPlaylogStream(client.getPlaylog(request), log, {
|
||||
gameLabel: "maimai DX",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ParserFunctionReturns } from "#lib/score-import/import-types/commo
|
||||
import type { EmptyObject } from "#utils/types";
|
||||
import type { GamesForGroup, integer } from "tachi-common";
|
||||
|
||||
import { GetImportTimestop } from "#lib/score-import/framework/common/timestop";
|
||||
import { drainMytPlaylogStream } from "#lib/score-import/import-types/common/api-myt/buffer-playlog-stream";
|
||||
import {
|
||||
CreateMytTransport,
|
||||
@@ -15,9 +16,16 @@ import { createClient } from "@connectrpc/connect";
|
||||
import type { MytOngekiScore } from "./types";
|
||||
|
||||
async function* streamPlaylog(userID: integer, log: KtLogger): AsyncIterable<MytOngekiScore> {
|
||||
const profileApiId = await FetchMytTitleAPIID(userID, "ongeki", log);
|
||||
const [profileApiId, lastScoreTime] = await Promise.all([
|
||||
FetchMytTitleAPIID(userID, "ongeki", log),
|
||||
GetImportTimestop(userID, "api/myt-ongeki"),
|
||||
]);
|
||||
|
||||
const client = createClient(OngekiUser, CreateMytTransport());
|
||||
const request = create(GetPlaylogRequestSchema, { profileApiId });
|
||||
const request = create(GetPlaylogRequestSchema, {
|
||||
profileApiId,
|
||||
lastUserPlayDate: lastScoreTime?.toISOString() ?? undefined,
|
||||
});
|
||||
|
||||
yield* await drainMytPlaylogStream(client.getPlaylog(request), log, {
|
||||
gameLabel: "Ongeki",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ParserFunctionReturns } from "#lib/score-import/import-types/commo
|
||||
import type { EmptyObject } from "#utils/types";
|
||||
import type { GamesForGroup, integer } from "tachi-common";
|
||||
|
||||
import { GetImportTimestop } from "#lib/score-import/framework/common/timestop";
|
||||
import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error";
|
||||
import { drainMytPlaylogStream } from "#lib/score-import/import-types/common/api-myt/buffer-playlog-stream";
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ async function* streamPlaylog(
|
||||
apiId: string,
|
||||
log: KtLogger,
|
||||
userID: integer,
|
||||
lastScoreTime: Date | null,
|
||||
): AsyncIterable<MytWaccaScore> {
|
||||
const client = createClient(WaccaUser, CreateMytTransport());
|
||||
const request = create(PlaylogRequestSchema, { apiId });
|
||||
@@ -30,12 +32,22 @@ async function* streamPlaylog(
|
||||
userID,
|
||||
});
|
||||
|
||||
const cutoff = lastScoreTime?.getTime() ?? null;
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.info) {
|
||||
log.warn(`Received WACCA playlog stream item with no info - skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cutoff !== null) {
|
||||
const parsed = Date.parse(item.info.userPlayDate);
|
||||
|
||||
if (!Number.isNaN(parsed) && parsed <= cutoff) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
yield item.info;
|
||||
}
|
||||
}
|
||||
@@ -44,7 +56,10 @@ export default async function ParseMytWACCA(
|
||||
userID: integer,
|
||||
log: KtLogger,
|
||||
): Promise<ParserFunctionReturns<MytWaccaScore, EmptyObject, GamesForGroup["wacca"]>> {
|
||||
const titleApiId = await FetchMytTitleAPIID(userID, "wacca", log);
|
||||
const [titleApiId, lastScoreTime] = await Promise.all([
|
||||
FetchMytTitleAPIID(userID, "wacca", log),
|
||||
GetImportTimestop(userID, "api/myt-wacca"),
|
||||
]);
|
||||
|
||||
let classProvider;
|
||||
|
||||
@@ -57,7 +72,7 @@ export default async function ParseMytWACCA(
|
||||
|
||||
return {
|
||||
service: "MYT",
|
||||
iterable: streamPlaylog(titleApiId, log, userID),
|
||||
iterable: streamPlaylog(titleApiId, log, userID, lastScoreTime),
|
||||
context: {},
|
||||
classProvider,
|
||||
gameGroup: "wacca",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
|
||||
import { SELECT_CG_CARD_INFO, ToCGCardInfo } from "#lib/db-formats/cg-card-info";
|
||||
import { GetImportTimestop } from "#lib/score-import/framework/common/timestop";
|
||||
import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error";
|
||||
import DB from "#services/pg/db";
|
||||
import fetch from "node-fetch";
|
||||
import { p, type PrudenceSchema } from "prudence";
|
||||
import { FormatPrError, type integer, type V3Game } from "tachi-common";
|
||||
import { type APIImportTypes, FormatPrError, type integer, type V3Game } from "tachi-common";
|
||||
|
||||
import type { ParserFunctionReturns } from "../types";
|
||||
import type {
|
||||
@@ -106,16 +107,23 @@ const CG_SCHEMAS: Record<CGSupportedGames, PrudenceSchema> = {
|
||||
* Create a CG parser for this supported game. Since all CG parsing code is effectively
|
||||
* identical, this basically just placeholders cgGame and service.
|
||||
*/
|
||||
export function CreateCGParser<T>(cgGame: CGSupportedGames, service: CGServices) {
|
||||
export function CreateCGParser<T extends { dateTime: string }>(
|
||||
cgGame: CGSupportedGames,
|
||||
service: CGServices,
|
||||
importType: APIImportTypes,
|
||||
) {
|
||||
return async (
|
||||
userID: integer,
|
||||
log: KtLogger,
|
||||
): Promise<ParserFunctionReturns<T, CGContext, V3Game>> => {
|
||||
const row = await DB.selectFrom("priv_svc_cg_card_info")
|
||||
.select(SELECT_CG_CARD_INFO)
|
||||
.where("user_id", "=", userID)
|
||||
.where("service", "=", service)
|
||||
.executeTakeFirst();
|
||||
const [row, lastScoreTime] = await Promise.all([
|
||||
DB.selectFrom("priv_svc_cg_card_info")
|
||||
.select(SELECT_CG_CARD_INFO)
|
||||
.where("priv_svc_cg_card_info.user_id", "=", userID)
|
||||
.where("priv_svc_cg_card_info.service", "=", service)
|
||||
.executeTakeFirst(),
|
||||
GetImportTimestop(userID, importType),
|
||||
]);
|
||||
|
||||
const cardInfo = row ? ToCGCardInfo(row) : undefined;
|
||||
|
||||
@@ -137,6 +145,17 @@ export function CreateCGParser<T>(cgGame: CGSupportedGames, service: CGServices)
|
||||
throw new ScoreImportFatalError(400, FormatPrError(err, `Invalid CG ${cgGame} Score.`));
|
||||
}
|
||||
|
||||
const cutoff = lastScoreTime?.getTime() ?? null;
|
||||
|
||||
const filtered =
|
||||
cutoff === null
|
||||
? (scores as Array<T>)
|
||||
: (scores as Array<T>).filter((s) => {
|
||||
const parsed = Date.parse(s.dateTime);
|
||||
|
||||
return Number.isNaN(parsed) || parsed > cutoff;
|
||||
});
|
||||
|
||||
return {
|
||||
service: FormatCGService(service),
|
||||
context: {
|
||||
@@ -144,23 +163,23 @@ export function CreateCGParser<T>(cgGame: CGSupportedGames, service: CGServices)
|
||||
userID: cardInfo.userID,
|
||||
},
|
||||
gameGroup: CGGameToTachiGame(cgGame),
|
||||
iterable: scores as Array<T>,
|
||||
iterable: filtered,
|
||||
classProvider: null,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const ParseCGDevMuseca = CreateCGParser<CGMusecaScore>("msc", "dev");
|
||||
export const ParseCGDevSDVX = CreateCGParser<CGSDVXScore>("sdvx", "dev");
|
||||
export const ParseCGDevJubeat = CreateCGParser<CGJubeatScore>("jb", "dev");
|
||||
export const ParseCGDevPopn = CreateCGParser<CGPopnScore>("popn", "dev");
|
||||
export const ParseCGDevMuseca = CreateCGParser<CGMusecaScore>("msc", "dev", "api/cg-dev-museca");
|
||||
export const ParseCGDevSDVX = CreateCGParser<CGSDVXScore>("sdvx", "dev", "api/cg-dev-sdvx");
|
||||
export const ParseCGDevJubeat = CreateCGParser<CGJubeatScore>("jb", "dev", "api/cg-dev-jubeat");
|
||||
export const ParseCGDevPopn = CreateCGParser<CGPopnScore>("popn", "dev", "api/cg-dev-popn");
|
||||
|
||||
export const ParseCGGanMuseca = CreateCGParser<CGMusecaScore>("msc", "gan");
|
||||
export const ParseCGGanSDVX = CreateCGParser<CGSDVXScore>("sdvx", "gan");
|
||||
export const ParseCGGanJubeat = CreateCGParser<CGJubeatScore>("jb", "gan");
|
||||
export const ParseCGGanPopn = CreateCGParser<CGPopnScore>("popn", "gan");
|
||||
export const ParseCGGanMuseca = CreateCGParser<CGMusecaScore>("msc", "gan", "api/cg-gan-museca");
|
||||
export const ParseCGGanSDVX = CreateCGParser<CGSDVXScore>("sdvx", "gan", "api/cg-gan-sdvx");
|
||||
export const ParseCGGanJubeat = CreateCGParser<CGJubeatScore>("jb", "gan", "api/cg-gan-jubeat");
|
||||
export const ParseCGGanPopn = CreateCGParser<CGPopnScore>("popn", "gan", "api/cg-gan-popn");
|
||||
|
||||
export const ParseCGNagMuseca = CreateCGParser<CGMusecaScore>("msc", "nag");
|
||||
export const ParseCGNagSDVX = CreateCGParser<CGSDVXScore>("sdvx", "nag");
|
||||
export const ParseCGNagJubeat = CreateCGParser<CGJubeatScore>("jb", "nag");
|
||||
export const ParseCGNagPopn = CreateCGParser<CGPopnScore>("popn", "nag");
|
||||
export const ParseCGNagMuseca = CreateCGParser<CGMusecaScore>("msc", "nag", "api/cg-nag-museca");
|
||||
export const ParseCGNagSDVX = CreateCGParser<CGSDVXScore>("sdvx", "nag", "api/cg-nag-sdvx");
|
||||
export const ParseCGNagJubeat = CreateCGParser<CGJubeatScore>("jb", "nag", "api/cg-nag-jubeat");
|
||||
export const ParseCGNagPopn = CreateCGParser<CGPopnScore>("popn", "nag", "api/cg-nag-popn");
|
||||
|
||||
@@ -6,6 +6,7 @@ import nodeFetch from "#utils/fetch";
|
||||
|
||||
import type { KaiContext } from "../types";
|
||||
|
||||
import { applyKaiTimestop } from "../kai-timestop";
|
||||
import { CreateKaiReauthFunction } from "../reauth";
|
||||
import { TraverseKaiAPI } from "../traverse-api";
|
||||
import { KaiTypeToBaseURL } from "../utils";
|
||||
@@ -17,6 +18,7 @@ export async function ParseKaiIIDX(
|
||||
log: KtLogger,
|
||||
fetch = nodeFetch,
|
||||
reauthFn: (() => Promise<string>) | null = null,
|
||||
lastScoreTime: Date | null = null,
|
||||
): Promise<ParserFunctionReturns<unknown, KaiContext, GamesForGroup["iidx"]>> {
|
||||
const baseUrl = KaiTypeToBaseURL(service);
|
||||
|
||||
@@ -27,13 +29,16 @@ export async function ParseKaiIIDX(
|
||||
|
||||
return {
|
||||
service,
|
||||
iterable: TraverseKaiAPI(
|
||||
baseUrl,
|
||||
"/api/iidx/v2/play_history",
|
||||
authDoc.token,
|
||||
log,
|
||||
resolvedReauthFn,
|
||||
fetch,
|
||||
iterable: applyKaiTimestop(
|
||||
TraverseKaiAPI(
|
||||
baseUrl,
|
||||
"/api/iidx/v2/play_history",
|
||||
authDoc.token,
|
||||
log,
|
||||
resolvedReauthFn,
|
||||
fetch,
|
||||
),
|
||||
lastScoreTime,
|
||||
),
|
||||
context: {
|
||||
service,
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { agta } from "#test-utils/misc";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { applyKaiTimestop } from "./kai-timestop";
|
||||
|
||||
function source(items: unknown[]) {
|
||||
return (async function* () {
|
||||
for (const item of items) {
|
||||
yield item;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
const T_OLD = "2024-01-01T00:00:00.000Z"; // 1704067200000
|
||||
const T_NEW = "2025-01-01T00:00:00.000Z"; // 1735689600000
|
||||
const CUTOFF = new Date("2024-06-01T00:00:00.000Z"); // between old and new
|
||||
|
||||
describe("applyKaiTimestop", () => {
|
||||
it("yields all items when lastScoreTime is null", async () => {
|
||||
const items = [{ timestamp: T_OLD }, { timestamp: T_NEW }, { other: "no timestamp" }];
|
||||
|
||||
const result = await agta(applyKaiTimestop(source(items), null));
|
||||
|
||||
expect(result).toStrictEqual(items);
|
||||
});
|
||||
|
||||
it("yields items newer than the cutoff", async () => {
|
||||
const items = [{ timestamp: T_NEW }, { timestamp: T_NEW }];
|
||||
|
||||
const result = await agta(applyKaiTimestop(source(items), CUTOFF));
|
||||
|
||||
expect(result).toStrictEqual(items);
|
||||
});
|
||||
|
||||
it("stops at the first item at or before the cutoff", async () => {
|
||||
const items = [
|
||||
{ timestamp: T_NEW },
|
||||
{ timestamp: T_OLD }, // <= cutoff → stop here
|
||||
{ timestamp: T_NEW }, // would have been yielded but we already stopped
|
||||
];
|
||||
|
||||
const result = await agta(applyKaiTimestop(source(items), CUTOFF));
|
||||
|
||||
// Only the first item (newer) should come through
|
||||
expect(result).toStrictEqual([{ timestamp: T_NEW }]);
|
||||
});
|
||||
|
||||
it("stops immediately when the very first item is at or before the cutoff", async () => {
|
||||
const items = [{ timestamp: T_OLD }, { timestamp: T_NEW }];
|
||||
|
||||
const result = await agta(applyKaiTimestop(source(items), CUTOFF));
|
||||
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("yields items with no timestamp field through without stopping", async () => {
|
||||
const items = [{ other: "field" }, { timestamp: T_NEW }];
|
||||
|
||||
const result = await agta(applyKaiTimestop(source(items), CUTOFF));
|
||||
|
||||
expect(result).toStrictEqual(items);
|
||||
});
|
||||
|
||||
it("yields items with a non-string timestamp field through without stopping", async () => {
|
||||
const items = [{ timestamp: 12345 }, { timestamp: T_NEW }];
|
||||
|
||||
const result = await agta(applyKaiTimestop(source(items), CUTOFF));
|
||||
|
||||
expect(result).toStrictEqual(items);
|
||||
});
|
||||
|
||||
it("yields items with an unparseable timestamp through without stopping", async () => {
|
||||
const items = [{ timestamp: "not-a-date" }, { timestamp: T_NEW }];
|
||||
|
||||
const result = await agta(applyKaiTimestop(source(items), CUTOFF));
|
||||
|
||||
expect(result).toStrictEqual(items);
|
||||
});
|
||||
|
||||
it("stops on an item whose timestamp exactly equals the cutoff", async () => {
|
||||
const items = [
|
||||
{ timestamp: T_NEW },
|
||||
{ timestamp: CUTOFF.toISOString() }, // exactly at cutoff
|
||||
];
|
||||
|
||||
const result = await agta(applyKaiTimestop(source(items), CUTOFF));
|
||||
|
||||
expect(result).toStrictEqual([{ timestamp: T_NEW }]);
|
||||
});
|
||||
|
||||
it("yields an empty source unchanged", async () => {
|
||||
const result = await agta(applyKaiTimestop(source([]), CUTOFF));
|
||||
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Wraps a Kai async iterable and stops yielding once a score's timestamp is
|
||||
* at or before the given lastScoreTime cursor. Kai play_history APIs return
|
||||
* scores newest-first, so the first item whose timestamp is <= lastScoreTime
|
||||
* marks the boundary of already-imported data.
|
||||
*
|
||||
* If lastScoreTime is null the source iterable is yielded through unchanged.
|
||||
*/
|
||||
export async function* applyKaiTimestop(
|
||||
source: AsyncIterable<unknown>,
|
||||
lastScoreTime: Date | null,
|
||||
): AsyncIterable<unknown> {
|
||||
if (!lastScoreTime) {
|
||||
yield* source;
|
||||
return;
|
||||
}
|
||||
|
||||
const cutoff = lastScoreTime.getTime();
|
||||
|
||||
for await (const item of source) {
|
||||
const ts = (item as Record<string, unknown>).timestamp;
|
||||
|
||||
if (typeof ts === "string") {
|
||||
const parsed = Date.parse(ts);
|
||||
|
||||
if (!Number.isNaN(parsed) && parsed <= cutoff) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import nodeFetch from "#utils/fetch";
|
||||
|
||||
import type { KaiContext } from "../types";
|
||||
|
||||
import { applyKaiTimestop } from "../kai-timestop";
|
||||
import { CreateKaiReauthFunction } from "../reauth";
|
||||
import { type KaiAPIReauthFunction, TraverseKaiAPI } from "../traverse-api";
|
||||
import { KaiTypeToBaseURL } from "../utils";
|
||||
@@ -17,6 +18,7 @@ export async function ParseKaiSDVX(
|
||||
log: KtLogger,
|
||||
fetch = nodeFetch,
|
||||
reauthFn: KaiAPIReauthFunction | null = null,
|
||||
lastScoreTime: Date | null = null,
|
||||
): Promise<ParserFunctionReturns<unknown, KaiContext, GamesForGroup["sdvx"]>> {
|
||||
const baseUrl = KaiTypeToBaseURL(service);
|
||||
|
||||
@@ -27,13 +29,16 @@ export async function ParseKaiSDVX(
|
||||
|
||||
return {
|
||||
service,
|
||||
iterable: TraverseKaiAPI(
|
||||
baseUrl,
|
||||
"/api/sdvx/v1/play_history",
|
||||
authDoc.token,
|
||||
log,
|
||||
resolvedReauthFn,
|
||||
fetch,
|
||||
iterable: applyKaiTimestop(
|
||||
TraverseKaiAPI(
|
||||
baseUrl,
|
||||
"/api/sdvx/v1/play_history",
|
||||
authDoc.token,
|
||||
log,
|
||||
resolvedReauthFn,
|
||||
fetch,
|
||||
),
|
||||
lastScoreTime,
|
||||
),
|
||||
context: {
|
||||
service,
|
||||
|
||||
@@ -41,6 +41,7 @@ import "./users/_userID/following/router";
|
||||
import "./users/_userID/notifications/router";
|
||||
import "./users/_userID/sessions/router";
|
||||
import "./users/_userID/imports/router";
|
||||
import "./users/_userID/import-timestops/router";
|
||||
import "./users/_userID/settings/router";
|
||||
import "./users/_userID/integrations/router";
|
||||
import "./users/_userID/integrations/cg/_cgType/router";
|
||||
|
||||
@@ -543,6 +543,34 @@ export const API_V1_SPEC = {
|
||||
output: docArray<ImportTrackerDocument>(),
|
||||
},
|
||||
|
||||
"GET /users/:userID/import-timestops": {
|
||||
description: "List API import timestop cursors for the authenticated user.",
|
||||
input: z.object({}),
|
||||
output: z.strictObject({
|
||||
timestops: z.array(
|
||||
z.strictObject({
|
||||
importType: z.string(),
|
||||
lastScoreTime: z.number().nullable(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
|
||||
"DELETE /users/:userID/import-timestops": {
|
||||
description: "Reset an API import timestop cursor.",
|
||||
input: z.object({ importType: z.string() }),
|
||||
output: empty,
|
||||
},
|
||||
|
||||
"PUT /users/:userID/import-timestops": {
|
||||
description: "Set an API import timestop cursor to a specific timestamp.",
|
||||
input: z.object({
|
||||
importType: z.string(),
|
||||
lastScoreTime: z.number(),
|
||||
}),
|
||||
output: empty,
|
||||
},
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Users /:userID/integrations/myt (Kamaitachi only)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { SetImportTimestop } from "#lib/score-import/framework/common/timestop";
|
||||
import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
afterAll(() => CloseServerConnection());
|
||||
|
||||
async function loginAs(username: string, password = "password123") {
|
||||
const res = await mockApi.post("/api/v1/auth/login").send({
|
||||
username,
|
||||
"!password": password,
|
||||
captcha: "test",
|
||||
});
|
||||
|
||||
return res.headers["set-cookie"] as unknown as string[];
|
||||
}
|
||||
|
||||
describe("GET /api/v1/users/:userID/import-timestops", () => {
|
||||
let cookie: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
await seedUser({ username: "timestop_user", withCredential: true, withSettings: true });
|
||||
await seedUser({
|
||||
username: "other_user",
|
||||
email: "other@example.com",
|
||||
withCredential: true,
|
||||
withSettings: true,
|
||||
});
|
||||
|
||||
cookie = await loginAs("timestop_user");
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
const res = await mockApi.get("/api/v1/users/1/import-timestops");
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 when viewing another user's timestops", async () => {
|
||||
const res = await mockApi.get("/api/v1/users/2/import-timestops").set("Cookie", cookie);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns all API import types for the authenticated user", async () => {
|
||||
await SetImportTimestop(1, "api/eag-iidx", new Date("2025-03-15T12:00:00.000Z"));
|
||||
|
||||
const res = await mockApi.get("/api/v1/users/1/import-timestops").set("Cookie", cookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.body.timestops.length).toBeGreaterThan(0);
|
||||
|
||||
const entry = res.body.body.timestops.find(
|
||||
(e: { importType: string }) => e.importType === "api/eag-iidx",
|
||||
);
|
||||
|
||||
expect(entry.lastScoreTime).toBe(new Date("2025-03-15T12:00:00.000Z").getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/v1/users/:userID/import-timestops", () => {
|
||||
let cookie: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
await seedUser({ username: "timestop_user", withCredential: true, withSettings: true });
|
||||
await SetImportTimestop(1, "api/eag-iidx", new Date("2025-03-15T12:00:00.000Z"));
|
||||
cookie = await loginAs("timestop_user");
|
||||
});
|
||||
|
||||
it("resets a timestop", async () => {
|
||||
const res = await mockApi
|
||||
.delete("/api/v1/users/1/import-timestops")
|
||||
.set("Cookie", cookie)
|
||||
.send({ importType: "api/eag-iidx" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const list = await mockApi.get("/api/v1/users/1/import-timestops").set("Cookie", cookie);
|
||||
const entry = list.body.body.timestops.find(
|
||||
(e: { importType: string }) => e.importType === "api/eag-iidx",
|
||||
);
|
||||
|
||||
expect(entry.lastScoreTime).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects invalid import types", async () => {
|
||||
const res = await mockApi
|
||||
.delete("/api/v1/users/1/import-timestops")
|
||||
.set("Cookie", cookie)
|
||||
.send({ importType: "file/batch-manual" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/v1/users/:userID/import-timestops", () => {
|
||||
let cookie: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
await seedUser({ username: "timestop_user", withCredential: true, withSettings: true });
|
||||
cookie = await loginAs("timestop_user");
|
||||
});
|
||||
|
||||
it("sets a timestop to a specific timestamp", async () => {
|
||||
const lastScoreTime = new Date("2024-06-01T08:30:00.000Z").getTime();
|
||||
|
||||
const res = await mockApi
|
||||
.put("/api/v1/users/1/import-timestops")
|
||||
.set("Cookie", cookie)
|
||||
.send({ importType: "api/myt-ongeki", lastScoreTime });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const list = await mockApi.get("/api/v1/users/1/import-timestops").set("Cookie", cookie);
|
||||
const entry = list.body.body.timestops.find(
|
||||
(e: { importType: string }) => e.importType === "api/myt-ongeki",
|
||||
);
|
||||
|
||||
expect(entry.lastScoreTime).toBe(lastScoreTime);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import {
|
||||
DeleteImportTimestop,
|
||||
ListImportTimestops,
|
||||
SetImportTimestopManual,
|
||||
} from "#lib/score-import/framework/common/timestop";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { type APIImportTypes } from "tachi-common";
|
||||
import { apiImportTypes } from "tachi-common/constants/import-types";
|
||||
|
||||
function assertApiImportType(importType: string): APIImportTypes {
|
||||
if (!apiImportTypes.includes(importType as APIImportTypes)) {
|
||||
throw new ExpectedErr(400, `Invalid import type: ${importType}`);
|
||||
}
|
||||
|
||||
return importType as APIImportTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* List API import timestop cursors for this user.
|
||||
*
|
||||
* @name GET /api/v1/users/:userID/import-timestops
|
||||
*/
|
||||
API_V1_ROUTER.add(
|
||||
"GET /users/:userID/import-timestops",
|
||||
withRequestedUser,
|
||||
withSelf,
|
||||
async ({ ctx }) => {
|
||||
const { requestedUser: user } = ctx;
|
||||
|
||||
const timestops = await ListImportTimestops(user.id);
|
||||
|
||||
return success(`Returned ${timestops.length} import timestops.`, { timestops });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Reset an API import timestop cursor so the next import starts from scratch.
|
||||
*
|
||||
* @param importType - The API import type to reset.
|
||||
*
|
||||
* @name DELETE /api/v1/users/:userID/import-timestops
|
||||
*/
|
||||
API_V1_ROUTER.add(
|
||||
"DELETE /users/:userID/import-timestops",
|
||||
withRequestedUser,
|
||||
withSelf,
|
||||
async ({ input, ctx }) => {
|
||||
const { requestedUser: user } = ctx;
|
||||
const importType = assertApiImportType(input.importType);
|
||||
|
||||
await DeleteImportTimestop(user.id, importType);
|
||||
|
||||
return success(`Reset import timestop for ${importType}.`, {});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Set an API import timestop cursor to a specific timestamp.
|
||||
*
|
||||
* @param importType - The API import type to update.
|
||||
* @param lastScoreTime - Epoch milliseconds for the new cursor.
|
||||
*
|
||||
* @name PUT /api/v1/users/:userID/import-timestops
|
||||
*/
|
||||
API_V1_ROUTER.add(
|
||||
"PUT /users/:userID/import-timestops",
|
||||
withRequestedUser,
|
||||
withSelf,
|
||||
async ({ input, ctx }) => {
|
||||
const { requestedUser: user } = ctx;
|
||||
const importType = assertApiImportType(input.importType);
|
||||
|
||||
if (!Number.isFinite(input.lastScoreTime)) {
|
||||
throw new ExpectedErr(400, "lastScoreTime must be a finite number.");
|
||||
}
|
||||
|
||||
await SetImportTimestopManual(user.id, importType, new Date(input.lastScoreTime));
|
||||
|
||||
return success(`Updated import timestop for ${importType}.`, {});
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user