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

437 lines
12 KiB
TypeScript
Raw Normal View History

2025-06-21 16:04:57 -07:00
const db = getDb("cache.sqlite");
db.table(
"media_files",
/* SQL */ `
2025-06-27 19:40:19 -07:00
create table 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 current_timestamp,
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,
processors text not null default "",
foreign key (parent_id) references media_files(id)
2025-06-21 16:04:57 -07:00
);
2025-06-27 19:40:19 -07:00
-- index for quickly looking up files by path
create index media_files_path on media_files (path);
-- index for quickly looking up children
create index media_files_parent_id on media_files (parent_id);
-- index for quickly looking up recursive file children
create index media_files_file_children on media_files (kind, path);
-- index for finding directories that need to be processed
create index media_files_directory_processed on media_files (kind, processed);
2025-06-21 16:04:57 -07:00
`,
);
export enum MediaFileKind {
directory = 0,
file = 1,
}
export class MediaFile {
id!: number;
2025-06-27 19:40:19 -07:00
parent_id!: number | null;
2025-06-21 16:04:57 -07:00
/**
* 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
2025-06-27 19:40:19 -07:00
* non-zero - processed
2025-06-21 16:04:57 -07:00
*
2025-06-27 19:40:19 -07:00
* file: a bit-field of the processors.
2025-06-21 16:04:57 -07:00
* directory: this is for re-indexing contents
*/
processed!: number;
2025-06-27 19:40:19 -07:00
processors!: string;
2025-06-21 16:04:57 -07:00
// -- 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;
}
2025-06-27 19:40:19 -07:00
setProcessed(processed: number) {
setProcessedQuery.run({ id: this.id, processed });
this.processed = processed;
}
setProcessors(processed: number, processors: string) {
setProcessorsQuery.run({ id: this.id, processed, processors });
this.processed = processed;
this.processors = processors;
}
setDuration(duration: number) {
setDurationQuery.run({ id: this.id, duration });
this.duration = duration;
}
setDimensions(dimensions: string) {
setDimensionsQuery.run({ id: this.id, dimensions });
this.dimensions = dimensions;
}
setContents(contents: string) {
setContentsQuery.run({ id: this.id, contents });
this.contents = contents;
}
getRecursiveFileChildren() {
if (this.kind !== MediaFileKind.directory) return [];
return getChildrenFilesRecursiveQuery.array(this.path + "/");
}
delete() {
deleteCascadeQuery.run({ id: this.id });
2025-06-21 16:04:57 -07:00
}
// -- 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,
2025-06-27 19:40:19 -07:00
parent_id: null,
2025-06-21 16:04:57 -07:00
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,
2025-06-27 19:40:19 -07:00
duration,
dimensions,
contents,
2025-06-21 16:04:57 -07:00
}: CreateFile) {
2025-06-27 19:40:19 -07:00
ASSERT(
!filePath.includes("\\") && filePath.startsWith("/"),
`Invalid path: ${filePath}`,
);
return createFileQuery.getNonNull({
2025-06-21 16:04:57 -07:00
path: filePath,
parentId: MediaFile.getOrPutDirectoryId(path.dirname(filePath)),
timestamp: date.getTime(),
timestampUpdated: Date.now(),
hash,
size,
duration,
dimensions,
2025-06-27 19:40:19 -07:00
contents,
2025-06-21 16:04:57 -07:00
});
}
static getOrPutDirectoryId(filePath: string) {
2025-06-27 19:40:19 -07:00
ASSERT(
!filePath.includes("\\") && filePath.startsWith("/"),
`Invalid path: ${filePath}`,
);
filePath = path.normalize(filePath);
const row = getDirectoryIdQuery.get(filePath);
2025-06-21 16:04:57 -07:00
if (row) return row.id;
let current = filePath;
let parts = [];
2025-06-27 19:40:19 -07:00
let parentId: null | number = null;
2025-06-21 16:04:57 -07:00
if (filePath === "/") {
2025-06-27 19:40:19 -07:00
return createDirectoryQuery.getNonNull({
path: filePath,
parentId,
}).id;
2025-06-21 16:04:57 -07:00
}
2025-06-27 19:40:19 -07:00
// walk up the path until we find a directory that exists
2025-06-21 16:04:57 -07:00
do {
parts.unshift(path.basename(current));
current = path.dirname(current);
2025-06-27 19:40:19 -07:00
parentId = getDirectoryIdQuery.get(current)?.id ?? null;
2025-06-21 16:04:57 -07:00
} while (parentId == undefined && current !== "/");
if (parentId == undefined) {
2025-06-27 19:40:19 -07:00
parentId = createDirectoryQuery.getNonNull({
2025-06-21 16:04:57 -07:00
path: current,
2025-06-27 19:40:19 -07:00
parentId,
}).id;
2025-06-21 16:04:57 -07:00
}
2025-06-27 19:40:19 -07:00
// walk back down the path, creating directories as needed
2025-06-21 16:04:57 -07:00
for (const part of parts) {
current = path.join(current, part);
ASSERT(parentId != undefined);
2025-06-27 19:40:19 -07:00
parentId = createDirectoryQuery.getNonNull({
path: current,
parentId,
}).id;
2025-06-21 16:04:57 -07:00
}
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,
});
}
2025-06-27 19:40:19 -07:00
static setProcessed(id: number, processed: number) {
setProcessedQuery.run({ id, processed });
2025-06-21 16:04:57 -07:00
}
static createOrUpdateDirectory(dirPath: string) {
const id = MediaFile.getOrPutDirectoryId(dirPath);
return updateDirectoryQuery.get(id);
}
static getChildren(id: number) {
return getChildrenQuery.array(id);
}
2025-06-27 19:40:19 -07:00
static db = db;
2025-06-21 16:04:57 -07:00
}
// 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;
2025-06-27 19:40:19 -07:00
duration: number;
dimensions: string;
contents: string;
2025-06-21 16:04:57 -07:00
}
// 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
2025-06-27 19:40:19 -07:00
const createDirectoryQuery = db.prepare<
[{ path: string; parentId: number | null }],
{ id: number }
>(
2025-06-21 16:04:57 -07:00
/* SQL */ `
insert into media_files (
path, parent_id, kind, timestamp, hash, size,
duration, dimensions, contents, dirsort, processed)
values (
$path, $parentId, ${MediaFileKind.directory}, 0, '', 0,
2025-06-27 19:40:19 -07:00
0, '', '', '', 0)
returning id;
2025-06-21 16:04:57 -07:00
`,
);
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
2025-06-27 19:40:19 -07:00
end
returning *;
`).as(MediaFile);
const setProcessedQuery = db.prepare<[{
2025-06-21 16:04:57 -07:00
id: number;
processed: number;
}]>(/* SQL */ `
update media_files set processed = $processed where id = $id;
`);
2025-06-27 19:40:19 -07:00
const setProcessorsQuery = db.prepare<[{
id: number;
processed: number;
processors: string;
}]>(/* SQL */ `
update media_files set
processed = $processed,
processors = $processors
where id = $id;
`);
const setDurationQuery = db.prepare<[{
id: number;
duration: number;
}]>(/* SQL */ `
update media_files set duration = $duration where id = $id;
`);
const setDimensionsQuery = db.prepare<[{
id: number;
dimensions: string;
}]>(/* SQL */ `
update media_files set dimensions = $dimensions where id = $id;
`);
const setContentsQuery = db.prepare<[{
id: number;
contents: string;
}]>(/* SQL */ `
update media_files set contents = $contents where id = $id;
`);
2025-06-21 16:04:57 -07:00
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);
2025-06-27 19:40:19 -07:00
const getChildrenFilesRecursiveQuery = db.prepare<[dir: string]>(/* SQL */ `
select * from media_files
where path like ? || '%'
and kind = ${MediaFileKind.file}
`).as(MediaFile);
const deleteCascadeQuery = db.prepare<[{ id: number }]>(/* SQL */ `
with recursive items as (
select id, parent_id from media_files where id = $id
union all
select p.id, p.parent_id
from media_files p
join items c on p.id = c.parent_id
where p.parent_id is not null
and not exists (
select 1 from media_files child
where child.parent_id = p.id
and child.id <> c.id
)
)
delete from media_files
where id in (select id from items)
`);
2025-06-21 16:04:57 -07:00
import { getDb } from "#sitegen/sqlite";
2025-06-27 19:40:19 -07:00
import * as path from "node:path/posix";
2025-06-21 16:04:57 -07:00
import { FilePermissions } from "./FilePermissions.ts";