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";
|