sitegen/src/file-viewer/models/MediaFile.ts

337 lines
9.7 KiB
TypeScript
Raw Normal View History

2025-06-21 16:04:57 -07:00
const db = getDb("cache.sqlite");
db.table(
"media_files",
/* SQL */ `
CREATE TABLE IF NOT EXISTS media_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER,
path TEXT UNIQUE,
kind INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
timestamp_updated INTEGER NOT NULL DEFAULT NOW,
hash TEXT NOT NULL,
size INTEGER NOT NULL,
duration INTEGER NOT NULL DEFAULT 0,
dimensions TEXT NOT NULL DEFAULT "",
contents TEXT NOT NULL,
dirsort TEXT,
processed INTEGER NOT NULL,
FOREIGN KEY (parent_id) REFERENCES media_files(id)
);
-- Index for quickly looking up files by path
CREATE INDEX IF NOT EXISTS media_files_path ON media_files (path);
-- Index for finding directories that need to be processed
CREATE INDEX IF NOT EXISTS media_files_directory_processed ON media_files (kind, processed);
`,
);
export enum MediaFileKind {
directory = 0,
file = 1,
}
export class MediaFile {
id!: number;
parent_id!: number;
/**
* Has leading slash, does not have `/file` prefix.
* @example "/2025/waterfalls/waterfalls.mp3"
*/
path!: string;
kind!: MediaFileKind;
private timestamp!: number;
private timestamp_updated!: number;
/** for mp3/mp4 files, measured in seconds */
duration?: number;
/** for images and videos, the dimensions. Two numbers split by `x` */
dimensions?: string;
/**
* sha1 of
* - files: the contents
* - directories: the JSON array of strings + the content of `readme.txt`
* this is used
* - to inform changes in caching mechanisms (etag, page render cache)
* - as a filename for compressed files (.clover/compressed/<hash>.{gz,zstd})
*/
hash!: string;
/**
* Depends on the file kind.
*
* - For directories, this is the contents of `readme.txt`, if it exists.
* - Otherwise, it is an empty string.
*/
contents!: string;
/**
* For directories, if this is set, it is a JSON-encoded array of the explicit
* sorting order. Derived off of `.dirsort` files.
*/
dirsort!: string | null;
/** in bytes */
size!: number;
/**
* 0 - not processed
* 1 - processed
*
* file: this is for compression
* directory: this is for re-indexing contents
*/
processed!: number;
// -- instance ops --
get date() {
return new Date(this.timestamp);
}
get lastUpdateDate() {
return new Date(this.timestamp_updated);
}
parseDimensions() {
const dimensions = this.dimensions;
if (!dimensions) return null;
const [width, height] = dimensions.split("x").map(Number);
return { width, height };
}
get basename() {
return path.basename(this.path);
}
get basenameWithoutExt() {
return path.basename(this.path, path.extname(this.path));
}
get extension() {
return path.extname(this.path);
}
getChildren() {
return MediaFile.getChildren(this.id)
.filter((file) => !file.basename.startsWith("."));
}
getPublicChildren() {
const children = MediaFile.getChildren(this.id);
if (FilePermissions.getByPrefix(this.path) == 0) {
return children.filter(({ path }) => FilePermissions.getExact(path) == 0);
}
return children;
}
getParent() {
const dirPath = this.path;
if (dirPath === "/") return null;
const parentPath = path.dirname(dirPath);
if (parentPath === dirPath) return null;
const result = MediaFile.getByPath(parentPath);
if (!result) return null;
ASSERT(result.kind === MediaFileKind.directory);
return result;
}
setCompressed(compressed: boolean) {
MediaFile.markCompressed(this.id, compressed);
}
// -- static ops --
static getByPath(filePath: string): MediaFile | null {
const result = getByPathQuery.get(filePath);
if (result) return result;
if (filePath === "/") {
return Object.assign(new MediaFile(), {
id: 0,
parent_id: 0,
path: "/",
kind: MediaFileKind.directory,
timestamp: 0,
timestamp_updated: Date.now(),
hash: "0".repeat(40),
contents: "the file scanner has not been run yet",
dirsort: null,
size: 0,
processed: 1,
});
}
return null;
}
static createFile({
path: filePath,
date,
hash,
size,
duration = 0,
dimensions = "",
content = "",
}: CreateFile) {
createFileQuery.get({
path: filePath,
parentId: MediaFile.getOrPutDirectoryId(path.dirname(filePath)),
timestamp: date.getTime(),
timestampUpdated: Date.now(),
hash,
size,
duration,
dimensions,
contents: content,
});
}
static getOrPutDirectoryId(filePath: string) {
filePath = path.posix.normalize(filePath);
const row = getDirectoryIdQuery.get(filePath) as { id: number };
if (row) return row.id;
let current = filePath;
let parts = [];
let parentId: null | number = 0;
if (filePath === "/") {
return createDirectoryQuery.run(filePath, 0).lastInsertRowid as number;
}
// walk down the path until we find a directory that exists
do {
parts.unshift(path.basename(current));
current = path.dirname(current);
parentId = (getDirectoryIdQuery.get(current) as { id: number })?.id;
} while (parentId == undefined && current !== "/");
if (parentId == undefined) {
parentId = createDirectoryQuery.run({
path: current,
parentId: 0,
}).lastInsertRowid as number;
}
// walk back up the path, creating directories as needed
for (const part of parts) {
current = path.join(current, part);
ASSERT(parentId != undefined);
parentId = createDirectoryQuery.run({ path: current, parentId })
.lastInsertRowid as number;
}
return parentId;
}
static markDirectoryProcessed({
id,
timestamp,
contents,
size,
hash,
dirsort,
}: MarkDirectoryProcessed) {
markDirectoryProcessedQuery.get({
id,
timestamp: timestamp.getTime(),
contents,
dirsort: dirsort ? JSON.stringify(dirsort) : "",
hash,
size,
});
}
static markCompressed(id: number, compressed: boolean) {
markCompressedQuery.run({ id, processed: compressed ? 2 : 1 });
}
static createOrUpdateDirectory(dirPath: string) {
const id = MediaFile.getOrPutDirectoryId(dirPath);
return updateDirectoryQuery.get(id);
}
static getChildren(id: number) {
return getChildrenQuery.array(id);
}
}
// Create a `file` entry with a given path, date, file hash, size, and duration
// If the file already exists, update the date and duration.
// If the file exists and the hash is different, sets `compress` to 0.
interface CreateFile {
path: string;
date: Date;
hash: string;
size: number;
duration?: number;
dimensions?: string;
content?: string;
}
// Set the `processed` flag true and update the metadata for a directory
export interface MarkDirectoryProcessed {
id: number;
timestamp: Date;
contents: string;
size: number;
hash: string;
dirsort: null | string[];
}
export interface DirConfig {
/** Overridden sorting */
sort: string[];
}
// -- queries --
// Get a directory ID by path, creating it if it doesn't exist
const createDirectoryQuery = db.prepare<[{ path: string; parentId: number }]>(
/* SQL */ `
insert into media_files (
path, parent_id, kind, timestamp, hash, size,
duration, dimensions, contents, dirsort, processed)
values (
$path, $parentId, ${MediaFileKind.directory}, 0, '', 0,
0, '', '', '', 0);
`,
);
const getDirectoryIdQuery = db.prepare<[string], { id: number }>(/* SQL */ `
SELECT id FROM media_files WHERE path = ? AND kind = ${MediaFileKind.directory};
`);
const createFileQuery = db.prepare<[{
path: string;
parentId: number;
timestamp: number;
timestampUpdated: number;
hash: string;
size: number;
duration: number;
dimensions: string;
contents: string;
}], void>(/* SQL */ `
insert into media_files (
path, parent_id, kind, timestamp, timestamp_updated, hash,
size, duration, dimensions, contents, processed)
values (
$path, $parentId, ${MediaFileKind.file}, $timestamp, $timestampUpdated,
$hash, $size, $duration, $dimensions, $contents, 0)
on conflict(path) do update set
timestamp = excluded.timestamp,
timestamp_updated = excluded.timestamp_updated,
duration = excluded.duration,
size = excluded.size,
contents = excluded.contents,
processed = case
when media_files.hash != excluded.hash then 0
else media_files.processed
end;
`);
const markCompressedQuery = db.prepare<[{
id: number;
processed: number;
}]>(/* SQL */ `
update media_files set processed = $processed where id = $id;
`);
const getByPathQuery = db.prepare<[string]>(/* SQL */ `
select * from media_files where path = ?;
`).as(MediaFile);
const markDirectoryProcessedQuery = db.prepare<[{
timestamp: number;
contents: string;
dirsort: string;
hash: string;
size: number;
id: number;
}]>(/* SQL */ `
update media_files set
processed = 1,
timestamp = $timestamp,
contents = $contents,
dirsort = $dirsort,
hash = $hash,
size = $size
where id = $id;
`);
const updateDirectoryQuery = db.prepare<[id: number]>(/* SQL */ `
update media_files set processed = 0 where id = ?;
`);
const getChildrenQuery = db.prepare<[id: number]>(/* SQL */ `
select * from media_files where parent_id = ?;
`).as(MediaFile);
import { getDb } from "#sitegen/sqlite";
import * as path from "node:path";
import { FilePermissions } from "./FilePermissions.ts";