mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
806c56a99b
- Replace syncHost/syncSnapshotPath/… with syncCliTokens: string[] passed verbatim to --sync-cli sync - Move sync CLI validation from launcher to parseSyncCliTokens (app-side single owner) - Extract resolveImmersionDbPath to shared db-path.ts importable by both launcher and app - Delete driver.ts; inline types into libsql-driver.ts and hard-wire openLibsqlSyncDb - Drop OpenSyncDb injection from mergeSnapshotIntoDb and createDbSnapshot
89 lines
2.9 KiB
TypeScript
89 lines
2.9 KiB
TypeScript
import { Database } from 'bun:sqlite';
|
|
import type { HistoryVideoRow } from './history-types.js';
|
|
import { resolveImmersionDbPath } from '../src/core/services/stats-sync/db-path.js';
|
|
import {
|
|
isReadonlyWalRetryError,
|
|
withReadonlyWalRetry,
|
|
} from '../src/core/services/stats-sync/wal-retry.js';
|
|
|
|
export { isReadonlyWalRetryError, resolveImmersionDbPath, withReadonlyWalRetry };
|
|
|
|
interface RawHistoryRow {
|
|
video_id: number;
|
|
source_path: string | null;
|
|
parsed_title: string | null;
|
|
parsed_season: number | null;
|
|
parsed_episode: number | null;
|
|
anime_title: string | null;
|
|
last_watched_ms: number | bigint | null;
|
|
cover_blob_hash: string | null;
|
|
}
|
|
|
|
export function queryLocalWatchHistory(dbPath: string): HistoryVideoRow[] {
|
|
return withReadonlyWalRetry(dbPath, (options) => readHistoryRows(dbPath, options));
|
|
}
|
|
|
|
function tableExists(db: Database, tableName: string): boolean {
|
|
return Boolean(
|
|
db.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(tableName),
|
|
);
|
|
}
|
|
|
|
function readHistoryRows(
|
|
dbPath: string,
|
|
options: { readonly?: boolean; readwrite?: boolean; create?: boolean },
|
|
): HistoryVideoRow[] {
|
|
const db = new Database(dbPath, options);
|
|
try {
|
|
const hasMediaArt = tableExists(db, 'imm_media_art');
|
|
const coverSelect = hasMediaArt
|
|
? `COALESCE(
|
|
ma.cover_blob_hash,
|
|
(SELECT ma2.cover_blob_hash
|
|
FROM imm_media_art ma2
|
|
JOIN imm_videos v2 ON v2.video_id = ma2.video_id
|
|
WHERE v2.anime_id = v.anime_id AND ma2.cover_blob_hash IS NOT NULL
|
|
LIMIT 1)
|
|
) AS cover_blob_hash`
|
|
: 'NULL AS cover_blob_hash';
|
|
const coverJoin = hasMediaArt ? 'LEFT JOIN imm_media_art ma ON ma.video_id = v.video_id' : '';
|
|
const rows = db
|
|
.query<RawHistoryRow>(
|
|
`
|
|
SELECT
|
|
v.video_id,
|
|
v.source_path,
|
|
v.parsed_title,
|
|
v.parsed_season,
|
|
v.parsed_episode,
|
|
COALESCE(a.title_romaji, a.canonical_title) AS anime_title,
|
|
MAX(CAST(s.started_at_ms AS INTEGER)) AS last_watched_ms,
|
|
${coverSelect}
|
|
FROM imm_sessions s
|
|
JOIN imm_videos v ON v.video_id = s.video_id
|
|
LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
|
|
${coverJoin}
|
|
WHERE v.source_type = 1 AND v.source_path IS NOT NULL AND v.source_path != ''
|
|
GROUP BY v.video_id
|
|
ORDER BY last_watched_ms DESC
|
|
`,
|
|
)
|
|
.all();
|
|
|
|
return rows
|
|
.filter((row) => typeof row.source_path === 'string' && row.source_path.length > 0)
|
|
.map((row) => ({
|
|
videoId: row.video_id,
|
|
sourcePath: row.source_path!,
|
|
parsedTitle: row.parsed_title,
|
|
parsedSeason: row.parsed_season,
|
|
parsedEpisode: row.parsed_episode,
|
|
animeTitle: row.anime_title,
|
|
lastWatchedMs: Number(row.last_watched_ms ?? 0),
|
|
coverBlobHash: row.cover_blob_hash,
|
|
}));
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|