dfd
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled
This commit is contained in:
140
API/folder.py
140
API/folder.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user