dfd
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled

This commit is contained in:
nose
2025-12-24 22:15:54 -08:00
parent df24a0cb44
commit f410edb91e
11 changed files with 868 additions and 46 deletions

View File

@@ -16,7 +16,7 @@ import logging
import subprocess
import shutil
from datetime import datetime
from pathlib import Path
from pathlib import Path, PurePosixPath
from typing import Optional, Dict, Any, List, Tuple, Set
from SYS.utils import sha256_file
@@ -159,10 +159,42 @@ class API_folder_store:
self.db_path = self.library_root / self.DB_NAME
self.connection: Optional[sqlite3.Connection] = None
self._init_db()
def _normalize_input_path(self, file_path: Path) -> Path:
p = Path(file_path).expanduser()
if not p.is_absolute():
p = self.library_root / p
return p
def _to_db_file_path(self, file_path: Path) -> str:
"""Convert an on-disk file path to a DB-stored relative path (POSIX separators)."""
p = self._normalize_input_path(file_path)
p_abs = p.resolve()
root_abs = self.library_root.resolve()
rel = p_abs.relative_to(root_abs)
rel_posix = PurePosixPath(*rel.parts).as_posix()
rel_posix = str(rel_posix or "").strip()
if not rel_posix or rel_posix == ".":
raise ValueError(f"Invalid relative path for DB storage: {file_path}")
return rel_posix
def _from_db_file_path(self, db_file_path: str) -> Path:
"""Convert a DB-stored relative path (POSIX separators) into an absolute path."""
rel_str = str(db_file_path or "").strip()
if not rel_str:
raise ValueError("Missing DB file_path")
rel_parts = PurePosixPath(rel_str).parts
return self.library_root / Path(*rel_parts)
def _init_db(self) -> None:
"""Initialize database connection and create tables if needed."""
try:
# Ensure the library root exists; sqlite cannot create parent dirs.
try:
self.library_root.mkdir(parents=True, exist_ok=True)
except Exception as exc:
raise RuntimeError(f"Cannot create/open library root directory: {self.library_root}: {exc}") from exc
# Use check_same_thread=False to allow multi-threaded access
# This is safe because we're not sharing connections across threads;
# each thread will get its own cursor
@@ -530,15 +562,29 @@ class API_folder_store:
The file hash (primary key)
"""
try:
str_path = str(file_path.resolve())
logger.debug(f"[get_or_create_file_entry] Looking up: {str_path}")
abs_path = self._normalize_input_path(file_path)
db_path = self._to_db_file_path(abs_path)
logger.debug(f"[get_or_create_file_entry] Looking up: {db_path}")
# If hash not provided, compute it
if not file_hash:
file_hash = sha256_file(file_path)
file_hash = sha256_file(abs_path)
logger.debug(f"[get_or_create_file_entry] Computed hash: {file_hash}")
cursor = self.connection.cursor()
# Prefer existing entry by path (file_path is UNIQUE in schema).
cursor.execute("SELECT hash FROM files WHERE file_path = ?", (db_path,))
row = cursor.fetchone()
if row and row[0]:
existing_hash = str(row[0])
if existing_hash != file_hash:
logger.debug(
f"[get_or_create_file_entry] Found existing file_path with different hash: path={db_path} existing={existing_hash} computed={file_hash}"
)
else:
logger.debug(f"[get_or_create_file_entry] Found existing file_path: {db_path} -> {existing_hash}")
return existing_hash
# Check if file entry exists
cursor.execute("SELECT hash FROM files WHERE hash = ?", (file_hash,))
@@ -549,16 +595,26 @@ class API_folder_store:
return file_hash
logger.debug(f"[get_or_create_file_entry] File entry not found, creating new one")
stat = file_path.stat()
cursor.execute("""
INSERT INTO files (hash, file_path, file_modified)
VALUES (?, ?, ?)
""", (file_hash, str_path, stat.st_mtime))
stat = abs_path.stat()
try:
cursor.execute("""
INSERT INTO files (hash, file_path, file_modified)
VALUES (?, ?, ?)
""", (file_hash, db_path, stat.st_mtime))
except sqlite3.IntegrityError:
# Most likely: UNIQUE constraint on file_path. Re-fetch and return.
cursor.execute("SELECT hash FROM files WHERE file_path = ?", (db_path,))
row2 = cursor.fetchone()
if row2 and row2[0]:
existing_hash = str(row2[0])
logger.debug(f"[get_or_create_file_entry] Recovered from UNIQUE(file_path): {db_path} -> {existing_hash}")
return existing_hash
raise
logger.debug(f"[get_or_create_file_entry] Created new file entry for hash: {file_hash}")
# Auto-create title tag
filename_without_ext = file_path.stem
filename_without_ext = abs_path.stem
if filename_without_ext:
# Normalize underscores to spaces for consistency
title_value = filename_without_ext.replace("_", " ").strip()
@@ -579,7 +635,8 @@ class API_folder_store:
def get_file_hash(self, file_path: Path) -> Optional[str]:
"""Get the file hash for a file path, or None if not found."""
try:
str_path = str(file_path.resolve())
abs_path = self._normalize_input_path(file_path)
str_path = self._to_db_file_path(abs_path)
cursor = self.connection.cursor()
cursor.execute("SELECT hash FROM files WHERE file_path = ?", (str_path,))
row = cursor.fetchone()
@@ -761,10 +818,11 @@ class API_folder_store:
def save_metadata(self, file_path: Path, metadata: Dict[str, Any]) -> None:
"""Save metadata for a file."""
try:
str_path = str(file_path.resolve())
logger.debug(f"[save_metadata] Starting save for: {str_path}")
abs_path = self._normalize_input_path(file_path)
db_path = self._to_db_file_path(abs_path)
logger.debug(f"[save_metadata] Starting save for: {db_path}")
file_hash = self.get_or_create_file_entry(file_path, metadata.get('hash'))
file_hash = self.get_or_create_file_entry(abs_path, metadata.get('hash'))
logger.debug(f"[save_metadata] Got/created file_hash: {file_hash}")
cursor = self.connection.cursor()
@@ -815,10 +873,11 @@ class API_folder_store:
def save_file_info(self, file_path: Path, metadata: Dict[str, Any], tags: List[str]) -> None:
"""Save metadata and tags for a file in a single transaction."""
try:
str_path = str(file_path.resolve())
logger.debug(f"[save_file_info] Starting save for: {str_path}")
abs_path = self._normalize_input_path(file_path)
db_path = self._to_db_file_path(abs_path)
logger.debug(f"[save_file_info] Starting save for: {db_path}")
file_hash = self.get_or_create_file_entry(file_path, metadata.get('hash'))
file_hash = self.get_or_create_file_entry(abs_path, metadata.get('hash'))
cursor = self.connection.cursor()
@@ -898,10 +957,11 @@ class API_folder_store:
def save_tags(self, file_path: Path, tags: List[str]) -> None:
"""Save tags for a file, replacing all existing tags."""
try:
str_path = str(file_path.resolve())
logger.debug(f"[save_tags] Starting save for: {str_path}")
abs_path = self._normalize_input_path(file_path)
db_path = self._to_db_file_path(abs_path)
logger.debug(f"[save_tags] Starting save for: {db_path}")
file_hash = self.get_or_create_file_entry(file_path)
file_hash = self.get_or_create_file_entry(abs_path)
logger.debug(f"[save_tags] Got/created file_hash: {file_hash}")
cursor = self.connection.cursor()
@@ -923,7 +983,7 @@ class API_folder_store:
""", (file_hash, existing_title[0]))
logger.debug(f"[save_tags] Preserved existing title tag")
elif not existing_title and not new_title_provided:
filename_without_ext = file_path.stem
filename_without_ext = abs_path.stem
if filename_without_ext:
# Normalize underscores to spaces for consistency
title_value = filename_without_ext.replace("_", " ").strip()
@@ -1325,7 +1385,16 @@ class API_folder_store:
LIMIT ?
""", (tag, limit))
return cursor.fetchall()
rows = cursor.fetchall() or []
results: List[tuple] = []
for row in rows:
try:
file_hash = str(row[0])
db_path = str(row[1])
results.append((file_hash, str(self._from_db_file_path(db_path))))
except Exception:
continue
return results
except Exception as e:
logger.error(f"Error searching by tag '{tag}': {e}", exc_info=True)
return []
@@ -1340,7 +1409,7 @@ class API_folder_store:
""", (file_hash,))
row = cursor.fetchone()
return Path(row[0]) if row else None
return self._from_db_file_path(row[0]) if row else None
except Exception as e:
logger.error(f"Error searching by hash '{file_hash}': {e}", exc_info=True)
return None
@@ -1357,8 +1426,10 @@ class API_folder_store:
def rename_file(self, old_path: Path, new_path: Path) -> None:
"""Rename a file in the database, preserving all metadata."""
try:
str_old_path = str(old_path.resolve())
str_new_path = str(new_path.resolve())
abs_old = self._normalize_input_path(old_path)
abs_new = self._normalize_input_path(new_path)
str_old_path = self._to_db_file_path(abs_old)
str_new_path = self._to_db_file_path(abs_new)
cursor = self.connection.cursor()
cursor.execute("""
@@ -1380,7 +1451,11 @@ class API_folder_store:
removed_count = 0
for file_hash, file_path in cursor.fetchall():
if not Path(file_path).exists():
try:
abs_path = self._from_db_file_path(file_path)
except Exception:
abs_path = Path(file_path)
if not abs_path.exists():
cursor.execute("DELETE FROM files WHERE hash = ?", (file_hash,))
removed_count += 1
@@ -1399,7 +1474,8 @@ class API_folder_store:
deleted hash.
"""
try:
str_path = str(file_path.resolve())
abs_path = self._normalize_input_path(file_path)
str_path = self._to_db_file_path(abs_path)
cursor = self.connection.cursor()
# Get the hash first (for logging)
@@ -2272,7 +2348,7 @@ class LocalLibraryInitializer:
cursor = self.db.connection.cursor()
cursor.execute(
"UPDATE files SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE hash = ?",
(str(target_path.resolve()), file_hash),
(self.db._to_db_file_path(target_path), file_hash),
)
except Exception as exc:
logger.debug(f"Failed to reset DB path to canonical file for {file_hash}: {exc}")
@@ -2309,7 +2385,7 @@ class LocalLibraryInitializer:
cursor = self.db.connection.cursor()
cursor.execute(
"UPDATE files SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE hash = ?",
(str(target_path.resolve()), file_hash),
(self.db._to_db_file_path(target_path), file_hash),
)
except Exception:
pass
@@ -2371,7 +2447,11 @@ class LocalLibraryInitializer:
result = {}
for file_hash, file_path in cursor.fetchall():
normalized = str(Path(file_path).resolve()).lower()
try:
abs_path = self.db._from_db_file_path(file_path)
except Exception:
abs_path = Path(file_path)
normalized = str(abs_path.resolve()).lower()
result[normalized] = file_hash
return result