Files
imagefind/backend/imagefind/database.py
T

1453 lines
58 KiB
Python

from __future__ import annotations
import configparser
import contextlib
import json
import os
import re
import sqlite3
import threading
import time
import uuid
from collections.abc import Iterable, Iterator, Sequence
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
SCHEMA_VERSION = 24
DEFAULT_TAG_GROUP_ID = "00000000-0000-0000-0000-000000000001"
def utcnow() -> str:
return datetime.now(UTC).isoformat()
class Row:
def __init__(self, columns: Sequence[str], values: Sequence[Any]):
self._columns = list(columns)
self._values = tuple(values)
self._mapping = {column: values[index] for index, column in enumerate(columns)}
def __getitem__(self, key: int | str) -> Any:
if isinstance(key, int):
return self._values[key]
return self._mapping[key]
def __iter__(self):
return iter(self._values)
def __len__(self) -> int:
return len(self._values)
def __eq__(self, other: object) -> bool:
if isinstance(other, tuple):
return self._values == other
return super().__eq__(other)
def keys(self):
return self._mapping.keys()
def items(self):
return self._mapping.items()
def get(self, key: str, default: Any = None) -> Any:
return self._mapping.get(key, default)
class _WriteCoordinator:
slow_transaction_seconds = 0.25
def __init__(self):
self._metrics = threading.Lock()
self._waiters = 0
self._active = 0
self._last_wait_ms = 0.0
self._max_wait_ms = 0.0
self._lock_retry_count = 0
self._slow_transaction_count = 0
def acquire(self) -> float:
acquired_at = time.monotonic()
with self._metrics:
self._active += 1
return acquired_at
def release(self, acquired_at: float) -> None:
duration = max(0.0, time.monotonic() - acquired_at)
with self._metrics:
self._active = max(0, self._active - 1)
if duration >= self.slow_transaction_seconds:
self._slow_transaction_count += 1
def record_lock_retry(self) -> None:
with self._metrics:
self._lock_retry_count += 1
def status(self) -> dict[str, int | float | bool]:
with self._metrics:
return {
"writer_queue_depth": self._waiters,
"writer_active": self._active > 0,
"last_wait_ms": round(self._last_wait_ms, 2),
"max_wait_ms": round(self._max_wait_ms, 2),
"lock_retry_count": self._lock_retry_count,
"slow_transaction_count": self._slow_transaction_count,
}
class _ConnectionPool:
"""Small bounded psycopg pool tailored for the single-process fnOS service.
Keeping this local avoids pulling another runtime package into the offline
fnOS wheelhouse. Connections are always autocommit while idle, so a pool
entry can never retain an MVCC snapshot between requests. Explicit write
transactions are opened only by :meth:`Database.transaction`.
"""
def __init__(
self,
factory,
*,
min_size: int = 1,
max_size: int = 8,
timeout: float = 5.0,
idle_timeout: float = 60.0,
):
self.factory = factory
self.min_size = max(0, min(min_size, max_size))
self.max_size = max(1, max_size)
self.timeout = max(0.1, timeout)
self.idle_timeout = max(1.0, idle_timeout)
self._idle: list[tuple[Any, float]] = []
self._guard = threading.Condition()
self._total = 0
self._in_use = 0
self._waiters = 0
self._wait_count = 0
self._last_wait_ms = 0.0
self._max_wait_ms = 0.0
self._closed = False
@staticmethod
def _usable(connection) -> bool:
return not bool(getattr(connection, "closed", True))
def open(self) -> None:
for _ in range(self.min_size):
with self._guard:
if self._closed or self._total >= self.max_size:
return
self._total += 1
try:
connection = self.factory()
except Exception:
with self._guard:
self._total = max(0, self._total - 1)
self._guard.notify()
raise
with self._guard:
self._idle.append((connection, time.monotonic()))
self._guard.notify()
def acquire(self, timeout: float | None = None):
timeout = self.timeout if timeout is None else max(0.1, timeout)
started = time.monotonic()
deadline = started + timeout
while True:
connection = None
check_health = False
create_connection = False
with self._guard:
if self._closed:
raise RuntimeError("PostgreSQL 连接池已经关闭")
while self._idle:
candidate, released_at = self._idle.pop()
expired = (
self._total > self.min_size
and time.monotonic() - released_at >= self.idle_timeout
)
if expired or not self._usable(candidate):
try:
candidate.close()
finally:
self._total = max(0, self._total - 1)
continue
connection = candidate
check_health = time.monotonic() - released_at >= 5
break
if connection is not None:
self._in_use += 1
waited = max(0.0, (time.monotonic() - started) * 1000)
self._last_wait_ms = waited
self._max_wait_ms = max(self._max_wait_ms, waited)
elif self._total < self.max_size:
self._total += 1
create_connection = True
else:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("等待 PostgreSQL 连接超时")
self._waiters += 1
self._wait_count += 1
try:
self._guard.wait(timeout=remaining)
finally:
self._waiters = max(0, self._waiters - 1)
continue
if connection is not None:
if check_health:
try:
connection.execute("SELECT 1")
except Exception:
self.release(connection, discard=True)
continue
return connection
if create_connection:
break
try:
connection = self.factory()
except Exception:
with self._guard:
self._total = max(0, self._total - 1)
self._guard.notify()
raise
with self._guard:
self._in_use += 1
waited = max(0.0, (time.monotonic() - started) * 1000)
self._last_wait_ms = waited
self._max_wait_ms = max(self._max_wait_ms, waited)
return connection
def release(self, connection, *, discard: bool = False) -> None:
with self._guard:
self._in_use = max(0, self._in_use - 1)
closed = self._closed
if discard or closed or not self._usable(connection):
try:
connection.close()
finally:
with self._guard:
self._total = max(0, self._total - 1)
self._guard.notify()
return
with self._guard:
self._idle.append((connection, time.monotonic()))
self._guard.notify()
def close(self) -> None:
with self._guard:
self._closed = True
self._guard.notify_all()
while True:
with self._guard:
if not self._idle:
break
connection, _ = self._idle.pop()
try:
connection.close()
finally:
with self._guard:
self._total = max(0, self._total - 1)
def status(self) -> dict[str, int | float]:
with self._guard:
return {
"pool_min": self.min_size,
"pool_max": self.max_size,
"pool_size": self._total,
"pool_in_use": self._in_use,
"pool_idle": len(self._idle),
"pool_waiters": self._waiters,
"pool_wait_count": self._wait_count,
"pool_last_wait_ms": round(self._last_wait_ms, 2),
"pool_max_wait_ms": round(self._max_wait_ms, 2),
}
def _load_psycopg():
try:
import psycopg
except ImportError as exc:
raise RuntimeError("ImageFind 需要 psycopg PostgreSQL 客户端运行时") from exc
return psycopg
def _postgres_conf_from_path(path: Path) -> dict[str, str]:
parser = configparser.ConfigParser()
content = path.read_text(encoding="utf-8")
parser.read_string("[postgres]\n" + content)
return {key: value for key, value in parser.items("postgres")}
def _replace_placeholders(sql: str, *, escape_percent: bool = False) -> str:
pieces: list[str] = []
in_single = False
in_double = False
index = 0
while index < len(sql):
char = sql[index]
if char == "'" and not in_double:
in_single = not in_single
pieces.append(char)
elif char == '"' and not in_single:
in_double = not in_double
pieces.append(char)
elif char == "?" and not in_single and not in_double:
pieces.append("%s")
elif char == "%" and escape_percent:
# psycopg uses percent-style parameter binding even when the
# percent sign is part of a quoted LIKE pattern. All percent
# signs reaching this function came from the SQLite-style source
# query (generated placeholders are added in the branch above),
# so quote them before passing a parameter sequence to psycopg.
pieces.append("%%")
else:
pieces.append(char)
index += 1
return "".join(pieces)
def _strip_collations(sql: str) -> str:
return re.sub(r"\s+COLLATE\s+NOCASE", "", sql, flags=re.IGNORECASE)
def _translate_insert_or_ignore(sql: str) -> str:
if re.match(r"^\s*INSERT\s+OR\s+IGNORE\s+INTO\s+", sql, flags=re.IGNORECASE):
sql = re.sub(r"^\s*INSERT\s+OR\s+IGNORE\s+INTO\s+", "INSERT INTO ", sql, flags=re.IGNORECASE)
if "ON CONFLICT" not in sql.upper():
sql = sql.rstrip().rstrip(";") + " ON CONFLICT DO NOTHING"
return sql
def _translate_query(sql: str, params: Sequence[Any] | None) -> tuple[str, Sequence[Any] | None]:
original = sql
stripped = sql.strip()
if not stripped:
return sql, params
upper = stripped.upper()
if upper.startswith("PRAGMA "):
return "SELECT 1", ()
if "SQLITE_MASTER" in upper:
return (
"SELECT 1 WHERE EXISTS ("
"SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=%s"
")",
(params[0] if params else "",),
)
sql = _strip_collations(sql)
sql = _translate_insert_or_ignore(sql)
sql = re.sub(r"^\s*INSERT\s+OR\s+REPLACE\s+INTO\s+", "INSERT INTO ", sql, flags=re.IGNORECASE)
sql = re.sub(r"\bbm25\s*\(\s*text_fts\s*\)", "0.0", sql, flags=re.IGNORECASE)
sql = re.sub(r"\babs\s*\(", "ABS(", sql, flags=re.IGNORECASE)
match_marker = "text_fts MATCH ?"
if match_marker in original:
parameter_index = _placeholder_count(original[: original.index(match_marker)])
sql = sql.replace(
match_marker,
"string_to_array(text_fts.tokens, ' ') && ?::text[]",
)
if params is not None and parameter_index < len(params):
params = list(params)
params[parameter_index] = _fts_tokens(str(params[parameter_index]))
sql = _replace_placeholders(sql, escape_percent=params is not None)
return sql, params
def _placeholder_count(sql: str) -> int:
count = 0
in_single = False
in_double = False
for char in sql:
if char == "'" and not in_double:
in_single = not in_single
elif char == '"' and not in_single:
in_double = not in_double
elif char == "?" and not in_single and not in_double:
count += 1
return count
def _fts_tokens(value: str) -> list[str]:
quoted = [token.replace('""', '"') for token in re.findall(r'"((?:""|[^"])*)"', value)]
if quoted:
return list(dict.fromkeys(token for token in quoted if token))
return list(
dict.fromkeys(
part.strip('"* ')
for part in re.split(r"\s+(?:OR\s+)?", value, flags=re.IGNORECASE)
if part.strip('"* ') and part.upper() != "OR"
)
)
def _split_script(script: str) -> list[str]:
statements: list[str] = []
current: list[str] = []
in_single = False
in_double = False
for char in script:
if char == "'" and not in_double:
in_single = not in_single
elif char == '"' and not in_single:
in_double = not in_double
if char == ";" and not in_single and not in_double:
statement = "".join(current).strip()
if statement:
statements.append(statement)
current = []
else:
current.append(char)
statement = "".join(current).strip()
if statement:
statements.append(statement)
return statements
class Cursor:
def __init__(self, raw):
self._raw = raw
self._columns: list[str] = []
@property
def rowcount(self) -> int:
return self._raw.rowcount
def execute(self, sql: str, params: Sequence[Any] | None = None):
translated, translated_params = _translate_query(sql, params)
try:
self._raw.execute(translated, translated_params)
except Exception as exc:
_raise_sqlite_compatible(exc)
self._columns = [item.name for item in (self._raw.description or [])]
return self
def fetchone(self) -> Row | None:
row = self._raw.fetchone()
return Row(self._columns, row) if row is not None else None
def fetchall(self) -> list[Row]:
return [Row(self._columns, row) for row in self._raw.fetchall()]
def __iter__(self):
for row in self._raw:
yield Row(self._columns, row)
class Connection:
def __init__(self, raw):
self._raw = raw
def execute(self, sql: str, params: Sequence[Any] | None = None) -> Cursor:
cursor = Cursor(self._raw.cursor())
return cursor.execute(sql, params)
def executemany(self, sql: str, rows: Iterable[Sequence[Any]]) -> Cursor:
translated, _ = _translate_query(sql, None)
cursor = Cursor(self._raw.cursor())
try:
cursor._raw.executemany(translated, rows)
except Exception as exc:
_raise_sqlite_compatible(exc)
return cursor
def executescript(self, script: str) -> None:
for statement in _split_script(script):
translated = _translate_ddl(statement)
if translated:
self.execute(translated)
def commit(self) -> None:
self._raw.commit()
def rollback(self) -> None:
self._raw.rollback()
def close(self) -> None:
self._raw.close()
class DatabaseTransientError(RuntimeError):
"""A PostgreSQL failure for which retrying a complete transaction is safe."""
def _sqlstate(exc: BaseException) -> str | None:
current: BaseException | None = exc
while current is not None:
value = getattr(current, "sqlstate", None)
if value:
return str(value)
current = current.__cause__
return None
def _raise_sqlite_compatible(exc: Exception) -> None:
sqlstate = _sqlstate(exc)
name = type(exc).__name__.lower()
message = str(exc)
if sqlstate in {"40001", "40P01", "55P03"}:
raise DatabaseTransientError(message) from exc
if sqlstate and sqlstate.startswith("23"):
raise sqlite3.IntegrityError(message) from exc
if "unique" in name or "unique" in message.lower() or "foreignkey" in name or "integrity" in name:
raise sqlite3.IntegrityError(message) from exc
raise exc
def _translate_ddl(sql: str) -> str:
upper = sql.strip().upper()
if upper.startswith("CREATE VIRTUAL TABLE"):
return "CREATE TABLE IF NOT EXISTS text_fts (entry_id TEXT PRIMARY KEY, tokens TEXT NOT NULL)"
if upper.startswith("CREATE TRIGGER"):
return ""
if upper.startswith("DROP TRIGGER"):
return ""
sql = _strip_collations(sql)
sql = re.sub(r"\bINTEGER\s+PRIMARY\s+KEY\s+CHECK\s*\([^)]+\)", "INTEGER PRIMARY KEY", sql, flags=re.IGNORECASE)
sql = re.sub(r"\bBLOB\b", "BYTEA", sql, flags=re.IGNORECASE)
sql = re.sub(r"\bREAL\b", "DOUBLE PRECISION", sql, flags=re.IGNORECASE)
sql = re.sub(r"\bAUTOINCREMENT\b", "", sql, flags=re.IGNORECASE)
return sql
class Database:
def __init__(self, path: Path):
self.path = path
self._metrics = _WriteCoordinator()
self._pool_guard = threading.Lock()
self._pool: _ConnectionPool | None = None
self._pool_min = max(0, int(os.environ.get("IMAGEFIND_DB_POOL_MIN", "1")))
self._pool_max = max(1, int(os.environ.get("IMAGEFIND_DB_POOL_MAX", "8")))
self._pool_timeout = max(0.1, float(os.environ.get("IMAGEFIND_DB_POOL_TIMEOUT", "5")))
self._pool_idle_timeout = max(1.0, float(os.environ.get("IMAGEFIND_DB_POOL_IDLE_TIMEOUT", "60")))
@property
def postgres_conf_path(self) -> Path:
return Path(os.environ.get("IMAGEFIND_POSTGRES_CONF") or self.path.parent.parent / "postgres-client.conf")
def _connect_raw(self, *, readonly: bool = False, timeout_seconds: int = 15, autocommit: bool = False):
psycopg = _load_psycopg()
if not self.postgres_conf_path.exists():
raise RuntimeError(f"PostgreSQL 凭据不存在:{self.postgres_conf_path}")
conf = _postgres_conf_from_path(self.postgres_conf_path)
# Configure the session in autocommit mode. Otherwise these SET
# statements open an implicit transaction before a caller can choose
# an isolation level for its own explicit BEGIN.
conn = psycopg.connect(
host=conf.get("host", "127.0.0.1"),
port=int(conf.get("port", "15432")),
dbname=conf["database"],
user=conf["username"],
password=conf["password"],
sslmode=conf.get("sslmode", conf.get("sslMode", "disable")).lower(),
connect_timeout=timeout_seconds,
autocommit=True,
)
# PostgreSQL clusters created by third-party fnOS packages should be
# UTF-8, but explicitly selecting the client encoding also keeps the
# SQLite-compatible row API stable against older SQL_ASCII clusters.
# Without it psycopg may expose TEXT values as bytes, which then
# leaks into JSON and search responses.
conn.execute("SET client_encoding TO 'UTF8'")
conn.execute("SET statement_timeout = '120s'")
conn.execute("SET idle_in_transaction_session_timeout = '120s'")
if readonly:
conn.execute("SET default_transaction_read_only = on")
if not autocommit and not readonly:
conn.autocommit = False
return conn
def _connection_pool(self) -> _ConnectionPool:
with self._pool_guard:
if self._pool is None:
self._pool = _ConnectionPool(
lambda: self._connect_raw(timeout_seconds=5, autocommit=True),
min_size=min(self._pool_min, self._pool_max),
max_size=self._pool_max,
timeout=self._pool_timeout,
idle_timeout=self._pool_idle_timeout,
)
self._pool.open()
return self._pool
def connect(self, busy_timeout_ms: int = 5000) -> Connection:
return Connection(self._connect_raw(timeout_seconds=max(1, int(busy_timeout_ms / 1000))))
def close(self) -> None:
with self._pool_guard:
pool = self._pool
self._pool = None
if pool is not None:
pool.close()
@contextlib.contextmanager
def transaction(self) -> Iterator[Connection]:
pool = self._connection_pool()
raw = pool.acquire()
conn = Connection(raw)
acquired_at = self._metrics.acquire()
discard = False
try:
with raw.transaction():
yield conn
except Exception:
discard = bool(getattr(raw, "closed", False))
raise
finally:
self._metrics.release(acquired_at)
pool.release(raw, discard=discard)
@contextlib.contextmanager
def critical_transaction(self, *, timeout_seconds: float = 30.0) -> Iterator[Connection]:
with self.transaction() as conn:
yield conn
@contextlib.contextmanager
def read(self) -> Iterator[Connection]:
pool = self._connection_pool()
raw = pool.acquire()
conn = Connection(raw)
discard = False
try:
raw.execute("SET default_transaction_read_only = on")
yield conn
except Exception:
discard = bool(getattr(raw, "closed", False))
raise
finally:
if not discard:
try:
raw.execute("SET default_transaction_read_only = off")
except Exception:
discard = True
pool.release(raw, discard=discard)
def write_with_retry(self, operation, *, timeout_seconds: float = 30.0):
deadline = time.monotonic() + max(0.1, timeout_seconds)
delay = 0.025
while True:
try:
with self.transaction() as conn:
return operation(conn)
except DatabaseTransientError:
if time.monotonic() >= deadline:
raise
self._metrics.record_lock_retry()
time.sleep(min(delay, max(0, deadline - time.monotonic())))
delay = min(delay * 2, 0.75)
def status(self) -> dict[str, int | float | bool | str]:
with self._pool_guard:
pool = self._pool
pool_status = pool.status() if pool is not None else {
"pool_min": self._pool_min,
"pool_max": self._pool_max,
"pool_size": 0,
"pool_in_use": 0,
"pool_idle": 0,
"pool_waiters": 0,
"pool_wait_count": 0,
"pool_last_wait_ms": 0.0,
"pool_max_wait_ms": 0.0,
}
metrics = self._metrics.status()
return {
"engine": "postgresql",
"journal_mode": "server",
**metrics,
"writer_queue_depth": int(pool_status["pool_waiters"]),
"writer_active": bool(pool_status["pool_in_use"]),
"last_wait_ms": float(pool_status["pool_last_wait_ms"]),
"max_wait_ms": float(pool_status["pool_max_wait_ms"]),
**pool_status,
}
def size_bytes(self) -> int:
with self.read() as conn:
row = conn.execute("SELECT pg_database_size(current_database())").fetchone()
return int(row[0] or 0)
def activity(self) -> dict[str, Any]:
with self.read() as conn:
states = conn.execute(
"SELECT coalesce(state,'unknown') AS state,count(*) AS count,"
"coalesce(max(extract(epoch FROM (clock_timestamp()-xact_start))),0) AS oldest_xact_seconds "
"FROM pg_stat_activity WHERE datname=current_database() AND pid<>pg_backend_pid() GROUP BY state"
).fetchall()
waiting = conn.execute(
"SELECT count(*) FROM pg_stat_activity WHERE datname=current_database() "
"AND pid<>pg_backend_pid() AND state='active' AND wait_event_type='Lock'"
).fetchone()
return {
"states": {
str(row["state"]): {
"count": int(row["count"]),
"oldest_transaction_seconds": round(float(row["oldest_xact_seconds"] or 0), 2),
}
for row in states
},
"waiting": int(waiting[0] or 0),
}
def _current_schema_version(self) -> int:
try:
with self.read() as conn:
row = conn.execute("SELECT value FROM settings WHERE key=?", ("schema_version",)).fetchone()
except Exception:
return 0
if row is None:
return 0
try:
return int(json.loads(row["value"]))
except (TypeError, ValueError, json.JSONDecodeError):
return int(row["value"])
def initialize(self) -> None:
self.close()
with self.transaction() as conn:
conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
conn.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
conn.executescript(POSTGRES_DDL)
conn.execute(
"ALTER TABLE api_tokens ADD COLUMN IF NOT EXISTS scopes_json TEXT NOT NULL DEFAULT '[\"admin\"]'"
)
conn.execute("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 50")
conn.execute("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS diagnostics_json TEXT NOT NULL DEFAULT '{}'")
conn.execute(
"UPDATE jobs SET priority=CASE kind "
"WHEN 'prepare_ai_runtime' THEN -10 WHEN 'install_models' THEN -10 "
"WHEN 'refresh_path' THEN 10 WHEN 'transfer_upload' THEN 10 "
"WHEN 'aria2_download' THEN 10 WHEN 'scan_source' THEN 10 "
"WHEN 'index_video' THEN 10 WHEN 'transcribe_audio' THEN 20 "
"WHEN 'suggest_tags' THEN 40 ELSE 50 END WHERE priority=50"
)
conn.execute(
"UPDATE jobs SET priority=10 "
"WHERE kind='index_video' AND status='queued' AND priority>=20"
)
conn.execute(
"UPDATE jobs SET priority=20 WHERE kind='transcribe_audio' AND status='queued' AND priority=30"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_jobs_queue_priority "
"ON jobs(status,run_after,priority,created_at)"
)
conn.execute(
"ALTER TABLE text_entries ADD COLUMN IF NOT EXISTS search_vector tsvector "
"GENERATED ALWAYS AS (to_tsvector('simple',coalesce(tokens,''))) STORED"
)
conn.execute("ALTER TABLE text_entries ADD COLUMN IF NOT EXISTS language TEXT")
conn.execute("ALTER TABLE text_entries ADD COLUMN IF NOT EXISTS quality_score DOUBLE PRECISION")
conn.execute("ALTER TABLE videos ADD COLUMN IF NOT EXISTS audio_index_revision INTEGER NOT NULL DEFAULT 0")
conn.execute("ALTER TABLE videos ADD COLUMN IF NOT EXISTS audio_detected_language TEXT")
conn.execute("ALTER TABLE videos ADD COLUMN IF NOT EXISTS audio_quality_score DOUBLE PRECISION")
conn.execute(
"ALTER TABLE videos ADD COLUMN IF NOT EXISTS audio_rejected_segments INTEGER NOT NULL DEFAULT 0"
)
conn.execute(
"ALTER TABLE videos ADD COLUMN IF NOT EXISTS audio_quality_flags_json TEXT NOT NULL DEFAULT '[]'"
)
conn.execute(
"ALTER TABLE videos ADD COLUMN IF NOT EXISTS audio_quality_repair_revision INTEGER NOT NULL DEFAULT 0"
)
conn.execute(
"ALTER TABLE videos ADD COLUMN IF NOT EXISTS storage_backend TEXT NOT NULL DEFAULT 'legacy_webdav'"
)
conn.execute("ALTER TABLE videos ADD COLUMN IF NOT EXISTS physical_path TEXT")
conn.execute("ALTER TABLE videos ADD COLUMN IF NOT EXISTS physical_size_bytes BIGINT")
conn.execute(
"ALTER TABLE trash ADD COLUMN IF NOT EXISTS storage_backend TEXT NOT NULL DEFAULT 'legacy_webdav'"
)
conn.execute("ALTER TABLE trash ADD COLUMN IF NOT EXISTS physical_original_path TEXT")
conn.execute("ALTER TABLE trash ADD COLUMN IF NOT EXISTS physical_trash_path TEXT")
conn.execute("ALTER TABLE trash ADD COLUMN IF NOT EXISTS physical_size_bytes BIGINT")
conn.execute("ALTER TABLE uploads ADD COLUMN IF NOT EXISTS phase TEXT NOT NULL DEFAULT 'receiving'")
conn.execute(
"ALTER TABLE uploads ADD COLUMN IF NOT EXISTS storage_backend TEXT NOT NULL DEFAULT 'legacy_webdav'"
)
conn.execute("ALTER TABLE uploads ADD COLUMN IF NOT EXISTS external_task_id TEXT")
conn.execute("ALTER TABLE uploads ADD COLUMN IF NOT EXISTS external_state INTEGER")
conn.execute(
"ALTER TABLE uploads ADD COLUMN IF NOT EXISTS external_progress DOUBLE PRECISION NOT NULL DEFAULT 0"
)
conn.execute("ALTER TABLE uploads ADD COLUMN IF NOT EXISTS external_status TEXT")
conn.execute("ALTER TABLE uploads ADD COLUMN IF NOT EXISTS external_error TEXT")
conn.execute("ALTER TABLE uploads ADD COLUMN IF NOT EXISTS external_started_at TEXT")
conn.execute("ALTER TABLE uploads ADD COLUMN IF NOT EXISTS external_last_progress_at TEXT")
conn.execute("ALTER TABLE uploads ADD COLUMN IF NOT EXISTS external_source_path TEXT")
conn.execute("ALTER TABLE uploads ADD COLUMN IF NOT EXISTS external_staged_path TEXT")
conn.execute("ALTER TABLE uploads ADD COLUMN IF NOT EXISTS external_target_path TEXT")
conn.execute("ALTER TABLE uploads ADD COLUMN IF NOT EXISTS external_local_path TEXT")
conn.execute("ALTER TABLE uploads ADD COLUMN IF NOT EXISTS external_size_bytes BIGINT")
conn.execute("ALTER TABLE uploads ADD COLUMN IF NOT EXISTS external_attempts INTEGER NOT NULL DEFAULT 0")
conn.execute(
"ALTER TABLE uploads ADD COLUMN IF NOT EXISTS recovery_state TEXT NOT NULL DEFAULT 'receiving'"
)
conn.execute("ALTER TABLE uploads ADD COLUMN IF NOT EXISTS recovery_mode TEXT")
conn.execute(
"UPDATE videos v SET storage_backend=u.storage_backend,physical_path=u.external_target_path,"
"physical_size_bytes=coalesce(u.external_size_bytes,v.size_bytes) FROM ("
"SELECT DISTINCT ON (source_id,target_key) source_id,target_key,storage_backend,"
"external_target_path,external_size_bytes FROM uploads "
"WHERE status='completed' AND storage_backend='openlist_native' "
"AND target_key IS NOT NULL AND external_target_path IS NOT NULL "
"ORDER BY source_id,target_key,updated_at DESC) u "
"WHERE v.source_id=u.source_id AND v.source_key=u.target_key "
"AND (v.physical_path IS NULL OR v.storage_backend<>'openlist_native')"
)
conn.execute(
"UPDATE uploads SET phase=CASE status WHEN 'receiving' THEN 'receiving' WHEN 'queued' THEN 'queued' "
"WHEN 'transferring' THEN 'transferring' WHEN 'indexing' THEN 'indexing' "
"WHEN 'completed' THEN 'ai_queued' WHEN 'failed' THEN 'failed' ELSE 'cancelled' END "
"WHERE phase IS NULL OR phase='receiving' AND status<>'receiving' "
"OR status='completed' AND phase<>'ai_queued'"
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_text_entries_search ON text_entries USING GIN(search_vector)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_text_entries_raw_text_trgm "
"ON text_entries USING GIN(lower(raw_text) gin_trgm_ops)"
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_query_images_expiry ON query_images(expires_at)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_videos_available_updated ON videos(available,updated_at DESC,id)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_video_metadata_title_trgm "
"ON video_metadata USING GIN(lower(coalesce(title,'')) gin_trgm_ops)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_people_name_trgm "
"ON people USING GIN(lower(coalesce(name,'')) gin_trgm_ops)"
)
conn.execute(
"CREATE TABLE IF NOT EXISTS job_resources("
"job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,"
"resource_type TEXT NOT NULL,resource_id TEXT NOT NULL,"
"PRIMARY KEY(job_id,resource_type,resource_id))"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_job_resources_lookup "
"ON job_resources(resource_type,resource_id,job_id)"
)
conn.execute(
"INSERT INTO job_resources(job_id,resource_type,resource_id) "
"SELECT id,'video',payload_json::jsonb->>'video_id' FROM jobs "
"WHERE payload_json::jsonb->>'video_id' IS NOT NULL "
"AND payload_json::jsonb->>'video_id'<>'' "
"ON CONFLICT DO NOTHING"
)
conn.execute(
"INSERT INTO job_resources(job_id,resource_type,resource_id) "
"SELECT jobs.id,'video',items.value FROM jobs "
"CROSS JOIN LATERAL jsonb_array_elements_text("
"CASE WHEN jsonb_typeof(payload_json::jsonb->'video_ids')='array' "
"THEN payload_json::jsonb->'video_ids' ELSE '[]'::jsonb END) AS items(value) "
"ON CONFLICT DO NOTHING"
)
conn.execute(
"INSERT INTO tag_groups(id,name,selection_mode,sort_order,created_at,updated_at) "
"VALUES(?,?,'multi',0,?,?) ON CONFLICT DO NOTHING",
(DEFAULT_TAG_GROUP_ID, "未分组", utcnow(), utcnow()),
)
conn.execute(
"INSERT INTO settings(key,value,updated_at) VALUES('schema_version',?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
(str(SCHEMA_VERSION), utcnow()),
)
with self.read() as conn:
conn.execute("SELECT 1 FROM settings LIMIT 1").fetchone()
@staticmethod
def _migrate_collections_v8(conn: Connection) -> None:
"""Materialize legacy ``video_metadata.series`` values as collections.
Runtime storage is PostgreSQL-only, but logical v1 backups and the
legacy ``/series`` API still use the old metadata field as their input.
This idempotent bridge is therefore a data-model compatibility helper,
not a SQLite database migration.
"""
rows = conn.execute(
"SELECT vm.video_id,trim(vm.series) AS series,vm.updated_at,v.created_at "
"FROM video_metadata vm JOIN videos v ON v.id=vm.video_id "
"WHERE vm.series IS NOT NULL AND trim(vm.series)<>'' "
"ORDER BY lower(trim(vm.series)),v.created_at,vm.video_id"
).fetchall()
groups: dict[str, list[Row]] = {}
names: dict[str, str] = {}
for row in rows:
normalized = str(row["series"]).casefold()
groups.setdefault(normalized, []).append(row)
names.setdefault(normalized, str(row["series"]))
for normalized, videos in groups.items():
name = names[normalized]
collection = conn.execute(
"SELECT id,name FROM collections WHERE lower(name)=lower(?)",
(name,),
).fetchone()
if collection:
collection_id = str(collection["id"])
canonical_name = str(collection["name"])
else:
collection_id = str(
uuid.uuid5(uuid.NAMESPACE_URL, f"imagefind:legacy-series:{normalized}")
)
created_at = min(str(row["created_at"]) for row in videos)
updated_at = max(str(row["updated_at"]) for row in videos)
conn.execute(
"INSERT INTO collections(id,name,description,created_at,updated_at) "
"VALUES(?,?,'',?,?) ON CONFLICT DO NOTHING",
(collection_id, name, created_at, updated_at),
)
collection = conn.execute(
"SELECT id,name FROM collections WHERE lower(name)=lower(?)",
(name,),
).fetchone()
if not collection:
continue
collection_id = str(collection["id"])
canonical_name = str(collection["name"])
next_position = int(
conn.execute(
"SELECT coalesce(max(position),-1)+1 FROM collection_videos WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
)
for row in videos:
membership = conn.execute(
"SELECT 1 FROM collection_videos WHERE video_id=?",
(row["video_id"],),
).fetchone()
if not membership:
conn.execute(
"INSERT INTO collection_videos(collection_id,video_id,position,added_at) "
"VALUES(?,?,?,?)",
(collection_id, row["video_id"], next_position, row["created_at"]),
)
next_position += 1
if str(row["series"]) != canonical_name:
conn.execute(
"UPDATE video_metadata SET series=?,updated_at=? WHERE video_id=?",
(canonical_name, row["updated_at"], row["video_id"]),
)
@staticmethod
def _migrate_collection_items_v14(conn: Connection) -> None:
"""Populate hierarchy roots for flat collection memberships."""
rows = conn.execute(
"SELECT cv.collection_id,cv.video_id,cv.position,cv.added_at "
"FROM collection_videos cv LEFT JOIN collection_items ci ON ci.video_id=cv.video_id "
"WHERE ci.id IS NULL ORDER BY cv.collection_id,cv.position,cv.added_at,cv.video_id"
).fetchall()
for row in rows:
item_id = str(
uuid.uuid5(uuid.NAMESPACE_URL, f"imagefind:collection-item:{row['video_id']}")
)
conn.execute(
"INSERT INTO collection_items(id,collection_id,parent_id,kind,name,video_id,position,"
"created_at,updated_at) VALUES(?,?,NULL,'video',NULL,?,?,?,?) ON CONFLICT DO NOTHING",
(
item_id,
row["collection_id"],
row["video_id"],
int(row["position"] or 0),
row["added_at"],
row["added_at"],
),
)
def setting(self, key: str, default: Any = None) -> Any:
with self.read() as conn:
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
if row is None:
return default
try:
return json.loads(row["value"])
except json.JSONDecodeError:
return row["value"]
def set_setting(self, key: str, value: Any) -> None:
encoded = json.dumps(value, ensure_ascii=False)
with self.transaction() as conn:
conn.execute(
"INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
(key, encoded, utcnow()),
)
POSTGRES_DDL = """
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS admin (
id INTEGER PRIMARY KEY CHECK (id = 1),
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
csrf_token TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
auth_source TEXT NOT NULL DEFAULT 'local',
external_user_id TEXT,
external_username TEXT
);
CREATE TABLE IF NOT EXISTS gateway_media_tokens (
token_hash TEXT PRIMARY KEY,
session_token_hash TEXT NOT NULL REFERENCES sessions(token_hash) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_gateway_media_tokens_session ON gateway_media_tokens(session_token_hash,created_at DESC);
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
scopes_json TEXT NOT NULL DEFAULT '["admin"]',
created_at TEXT NOT NULL,
last_used_at TEXT,
revoked_at TEXT
);
CREATE TABLE IF NOT EXISTS sources (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK(kind IN ('local', 'webdav')),
name TEXT NOT NULL,
config_json TEXT NOT NULL,
secret_blob TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'idle',
last_scan_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS videos (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
source_key TEXT NOT NULL,
display_name TEXT NOT NULL,
location TEXT NOT NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
modified_at TEXT,
etag TEXT,
fingerprint TEXT NOT NULL,
duration_ms INTEGER,
width INTEGER,
height INTEGER,
codec TEXT,
container TEXT,
status TEXT NOT NULL DEFAULT 'pending',
available INTEGER NOT NULL DEFAULT 1,
indexed_fingerprint TEXT,
basic_fingerprint TEXT,
visual_model_version TEXT,
ocr_model_version TEXT,
faces_model_version TEXT,
audio_model_version TEXT,
audio_index_revision INTEGER NOT NULL DEFAULT 0,
audio_detected_language TEXT,
audio_quality_score DOUBLE PRECISION,
audio_rejected_segments INTEGER NOT NULL DEFAULT 0,
audio_quality_flags_json TEXT NOT NULL DEFAULT '[]',
audio_quality_repair_revision INTEGER NOT NULL DEFAULT 0,
storage_backend TEXT NOT NULL DEFAULT 'legacy_webdav',
physical_path TEXT,
physical_size_bytes BIGINT,
error TEXT,
seen_scan_id TEXT,
content_sha256 TEXT,
content_fingerprint TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(source_id, source_key)
);
CREATE INDEX IF NOT EXISTS idx_videos_source ON videos(source_id, available);
CREATE INDEX IF NOT EXISTS idx_videos_status ON videos(status);
CREATE TABLE IF NOT EXISTS frames (
id TEXT PRIMARY KEY,
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
timestamp_ms INTEGER NOT NULL,
segment_start_ms INTEGER NOT NULL,
segment_end_ms INTEGER NOT NULL,
thumbnail_path TEXT NOT NULL,
perceptual_hash TEXT,
vector_blob BYTEA,
embedding vector,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_frames_video_time ON frames(video_id, timestamp_ms);
CREATE TABLE IF NOT EXISTS text_entries (
id TEXT PRIMARY KEY,
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
frame_id TEXT REFERENCES frames(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK(kind IN ('ocr','subtitle','audio','filename','path')),
start_ms INTEGER NOT NULL DEFAULT 0,
end_ms INTEGER NOT NULL DEFAULT 0,
raw_text TEXT NOT NULL,
tokens TEXT NOT NULL,
language TEXT,
quality_score DOUBLE PRECISION,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_text_video ON text_entries(video_id, start_ms);
CREATE TABLE IF NOT EXISTS text_fts (
entry_id TEXT PRIMARY KEY,
tokens TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS people (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
normalized_name TEXT NOT NULL,
is_named INTEGER NOT NULL DEFAULT 0,
hidden INTEGER NOT NULL DEFAULT 0,
centroid_blob BYTEA,
face_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS faces (
id TEXT PRIMARY KEY,
frame_id TEXT NOT NULL REFERENCES frames(id) ON DELETE CASCADE,
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
person_id TEXT NOT NULL REFERENCES people(id),
bbox_json TEXT NOT NULL,
confidence DOUBLE PRECISION NOT NULL,
thumbnail_path TEXT,
vector_blob BYTEA,
embedding vector,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_faces_person ON faces(person_id);
CREATE INDEX IF NOT EXISTS idx_faces_video ON faces(video_id);
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
payload_json TEXT NOT NULL,
dedupe_key TEXT,
status TEXT NOT NULL DEFAULT 'queued',
progress DOUBLE PRECISION NOT NULL DEFAULT 0,
message TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
priority INTEGER NOT NULL DEFAULT 50,
run_after TEXT NOT NULL,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
error TEXT,
diagnostics_json TEXT NOT NULL DEFAULT '{}',
lease_owner TEXT,
heartbeat_at TEXT,
pause_reason TEXT,
cancel_requested INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_jobs_queue ON jobs(status, run_after, created_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_dedupe_active
ON jobs(dedupe_key)
WHERE dedupe_key IS NOT NULL AND status IN ('queued','running');
CREATE TABLE IF NOT EXISTS job_resources (
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
PRIMARY KEY(job_id,resource_type,resource_id)
);
CREATE INDEX IF NOT EXISTS idx_job_resources_lookup
ON job_resources(resource_type,resource_id,job_id);
CREATE TABLE IF NOT EXISTS query_images (
id TEXT PRIMARY KEY,
path TEXT NOT NULL,
content_type TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS backup_exports (
id TEXT PRIMARY KEY,
scope TEXT NOT NULL CHECK(scope IN ('keys','full')),
status TEXT NOT NULL DEFAULT 'queued',
filename TEXT,
path TEXT,
size_bytes BIGINT NOT NULL DEFAULT 0,
secret_blob TEXT,
error TEXT,
created_at TEXT NOT NULL,
finished_at TEXT,
expires_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_backup_exports_expiry ON backup_exports(expires_at,status);
CREATE TABLE IF NOT EXISTS collections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
cover_video_id TEXT REFERENCES videos(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_collections_name ON collections(lower(name));
CREATE TABLE IF NOT EXISTS collection_videos (
collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
video_id TEXT NOT NULL UNIQUE REFERENCES videos(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0 CHECK(position>=0),
added_at TEXT NOT NULL,
PRIMARY KEY(collection_id,video_id)
);
CREATE INDEX IF NOT EXISTS idx_collection_videos_order ON collection_videos(collection_id,position,added_at);
CREATE TABLE IF NOT EXISTS collection_items (
id TEXT PRIMARY KEY,
collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
parent_id TEXT REFERENCES collection_items(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK(kind IN ('group','video')),
name TEXT,
video_id TEXT UNIQUE REFERENCES videos(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0 CHECK(position>=0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK(
(kind='group' AND name IS NOT NULL AND video_id IS NULL)
OR (kind='video' AND name IS NULL AND video_id IS NOT NULL)
)
);
CREATE INDEX IF NOT EXISTS idx_collection_items_parent ON collection_items(collection_id,parent_id,position,created_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_collection_items_group_root_name
ON collection_items(collection_id,lower(name))
WHERE kind='group' AND parent_id IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_collection_items_group_child_name
ON collection_items(collection_id,parent_id,lower(name))
WHERE kind='group' AND parent_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS tag_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
selection_mode TEXT NOT NULL DEFAULT 'multi' CHECK(selection_mode IN ('single','multi')),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
group_id TEXT NOT NULL REFERENCES tag_groups(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
ai_enabled INTEGER NOT NULL DEFAULT 0,
ai_method TEXT NOT NULL DEFAULT 'visual' CHECK(ai_method IN ('visual','text')),
ai_description TEXT NOT NULL DEFAULT '',
ai_threshold DOUBLE PRECISION NOT NULL DEFAULT 0.28 CHECK(ai_threshold>=-1 AND ai_threshold<=1),
match_terms_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(group_id,name)
);
CREATE INDEX IF NOT EXISTS idx_tags_group ON tags(group_id,name);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_group_name ON tags(group_id,lower(name));
CREATE TABLE IF NOT EXISTS collection_tags (
collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
PRIMARY KEY(collection_id,tag_id)
);
CREATE INDEX IF NOT EXISTS idx_collection_tags_tag ON collection_tags(tag_id,collection_id);
CREATE TABLE IF NOT EXISTS uploads (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
relative_path TEXT NOT NULL,
filename TEXT NOT NULL,
title TEXT,
collection_id TEXT REFERENCES collections(id) ON DELETE SET NULL,
collection_parent_id TEXT REFERENCES collection_items(id) ON DELETE SET NULL,
tag_ids_json TEXT NOT NULL DEFAULT '[]',
origin TEXT NOT NULL DEFAULT 'browser',
webdav_path TEXT,
size_bytes BIGINT NOT NULL,
chunk_size INTEGER NOT NULL,
total_chunks INTEGER NOT NULL,
received_json TEXT NOT NULL DEFAULT '[]',
bytes_received BIGINT NOT NULL DEFAULT 0,
temp_path TEXT NOT NULL,
conflict TEXT NOT NULL DEFAULT 'rename' CHECK(conflict IN ('rename','replace','skip')),
status TEXT NOT NULL DEFAULT 'receiving'
CHECK(status IN ('receiving','queued','transferring','indexing','completed','failed','cancelled')),
phase TEXT NOT NULL DEFAULT 'receiving',
storage_backend TEXT NOT NULL DEFAULT 'legacy_webdav',
progress DOUBLE PRECISION NOT NULL DEFAULT 0,
message TEXT,
error TEXT,
job_id TEXT,
target_key TEXT,
failure_stage TEXT,
content_sha256 TEXT,
content_sha256_verified INTEGER NOT NULL DEFAULT 0,
deduplicated INTEGER NOT NULL DEFAULT 0,
retry_count INTEGER NOT NULL DEFAULT 0,
next_retry_at TEXT,
transferred_bytes BIGINT NOT NULL DEFAULT 0,
resume_mode TEXT,
external_task_id TEXT,
external_state INTEGER,
external_progress DOUBLE PRECISION NOT NULL DEFAULT 0,
external_status TEXT,
external_error TEXT,
external_started_at TEXT,
external_last_progress_at TEXT,
external_source_path TEXT,
external_staged_path TEXT,
external_target_path TEXT,
external_local_path TEXT,
external_size_bytes BIGINT,
external_attempts INTEGER NOT NULL DEFAULT 0,
recovery_state TEXT NOT NULL DEFAULT 'receiving',
recovery_mode TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_uploads_status ON uploads(status, updated_at);
CREATE INDEX IF NOT EXISTS idx_uploads_created ON uploads(created_at DESC,id DESC);
CREATE INDEX IF NOT EXISTS idx_uploads_status_created ON uploads(status,created_at DESC,id DESC);
CREATE TABLE IF NOT EXISTS trash (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
original_key TEXT NOT NULL,
trash_key TEXT NOT NULL,
display_name TEXT NOT NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
storage_backend TEXT NOT NULL DEFAULT 'legacy_webdav',
physical_original_path TEXT,
physical_trash_path TEXT,
physical_size_bytes BIGINT,
deleted_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_trash_source ON trash(source_id, deleted_at);
CREATE TABLE IF NOT EXISTS downloads (
id TEXT PRIMARY KEY,
gid TEXT,
kind TEXT NOT NULL CHECK(kind IN ('url','magnet','torrent')),
source_uri TEXT NOT NULL,
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
relative_path TEXT NOT NULL DEFAULT '',
staging_path TEXT NOT NULL,
display_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued'
CHECK(status IN ('queued','downloading','paused','transferring','completed','failed','cancelled')),
progress DOUBLE PRECISION NOT NULL DEFAULT 0,
total_bytes BIGINT NOT NULL DEFAULT 0,
completed_bytes BIGINT NOT NULL DEFAULT 0,
download_speed BIGINT NOT NULL DEFAULT 0,
files_json TEXT NOT NULL DEFAULT '[]',
error TEXT,
job_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status,updated_at);
CREATE TABLE IF NOT EXISTS actors (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
aliases_json TEXT NOT NULL DEFAULT '[]',
person_id TEXT REFERENCES people(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_actors_name ON actors(lower(name));
CREATE TABLE IF NOT EXISTS video_metadata (
video_id TEXT PRIMARY KEY REFERENCES videos(id) ON DELETE CASCADE,
title TEXT,
catalog_code TEXT,
studio TEXT,
series TEXT,
release_date TEXT,
description TEXT,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_video_metadata_code ON video_metadata(catalog_code);
CREATE TABLE IF NOT EXISTS video_actors (
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
actor_id TEXT NOT NULL REFERENCES actors(id) ON DELETE CASCADE,
PRIMARY KEY(video_id, actor_id)
);
CREATE TABLE IF NOT EXISTS video_tags (
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY(video_id, tag_id)
);
CREATE TABLE IF NOT EXISTS tag_suggestions (
id TEXT PRIMARY KEY,
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
confidence DOUBLE PRECISION NOT NULL,
evidence_json TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
video_fingerprint TEXT NOT NULL,
tag_revision TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(video_id,tag_id)
);
CREATE INDEX IF NOT EXISTS idx_tag_suggestions_video ON tag_suggestions(video_id,status,confidence DESC);
CREATE TABLE IF NOT EXISTS video_state (
video_id TEXT PRIMARY KEY REFERENCES videos(id) ON DELETE CASCADE,
liked INTEGER NOT NULL DEFAULT 0,
favorited INTEGER NOT NULL DEFAULT 0,
progress_ms INTEGER NOT NULL DEFAULT 0,
completed INTEGER NOT NULL DEFAULT 0,
last_played_at TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS video_markers (
id TEXT PRIMARY KEY,
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
position_ms INTEGER NOT NULL CHECK(position_ms>=0),
title TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(video_id,position_ms)
);
CREATE INDEX IF NOT EXISTS idx_video_markers_time ON video_markers(video_id,position_ms);
CREATE TABLE IF NOT EXISTS video_tombstones (
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
source_key TEXT NOT NULL,
video_id TEXT,
display_name TEXT NOT NULL,
source_deleted INTEGER NOT NULL DEFAULT 0 CHECK(source_deleted IN (0,1)),
deleted_at TEXT NOT NULL,
PRIMARY KEY(source_id,source_key)
);
CREATE INDEX IF NOT EXISTS idx_video_tombstones_deleted ON video_tombstones(deleted_at);
CREATE TABLE IF NOT EXISTS webdav_staging (
virtual_path TEXT PRIMARY KEY,
temp_path TEXT NOT NULL UNIQUE,
size_bytes BIGINT NOT NULL DEFAULT 0,
expected_size BIGINT,
received_bytes BIGINT NOT NULL DEFAULT 0,
content_sha256 TEXT,
content_sha256_verified INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'complete',
error TEXT,
collection_id TEXT REFERENCES collections(id) ON DELETE CASCADE,
collection_parent_id TEXT REFERENCES collection_items(id) ON DELETE SET NULL,
upload_id TEXT REFERENCES uploads(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ingest_guards (
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
target_key TEXT NOT NULL,
upload_id TEXT NOT NULL REFERENCES uploads(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
PRIMARY KEY(source_id,target_key)
);
CREATE INDEX IF NOT EXISTS idx_ingest_guards_upload ON ingest_guards(upload_id);
CREATE TABLE IF NOT EXISTS webdav_upload_receipts (
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
virtual_path TEXT NOT NULL,
content_sha256 TEXT NOT NULL,
upload_id TEXT REFERENCES uploads(id) ON DELETE SET NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(source_id,virtual_path,content_sha256)
);
"""