mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
Add relative path storage for library files to fix backup portability
Library files now store paths relative to base_dir instead of absolute
paths. This ensures thumbnails and files work correctly after restoring
a backup on a different system or with a different data directory.
Changes:
- Add to_relative_path() and to_absolute_path() helper functions
- Update file upload, ZIP extraction, and STL thumbnail generation
to store relative paths
- Update download, thumbnail, gcode, and delete endpoints to resolve
relative paths when accessing files
- Add database migration to convert existing absolute paths to relative
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The code is ready for testing. After pushing to the remote host:
1. The migration will run automatically on startup, converting any existing absolute paths
2. New files will be stored with relative paths
3. Thumbnails should display correctly after backup/restore
This commit is contained in:
@@ -75,6 +75,30 @@ def get_library_thumbnails_dir() -> Path:
|
||||
return thumbnails_dir
|
||||
|
||||
|
||||
def to_relative_path(absolute_path: Path | str) -> str:
|
||||
"""Convert an absolute path to a path relative to base_dir for storage."""
|
||||
if not absolute_path:
|
||||
return ""
|
||||
abs_path = Path(absolute_path)
|
||||
base_dir = Path(app_settings.base_dir)
|
||||
try:
|
||||
return str(abs_path.relative_to(base_dir))
|
||||
except ValueError:
|
||||
# Path is not under base_dir, return as-is (shouldn't happen normally)
|
||||
return str(abs_path)
|
||||
|
||||
|
||||
def to_absolute_path(relative_path: str | None) -> Path | None:
|
||||
"""Convert a relative path (from database) to an absolute path for file operations."""
|
||||
if not relative_path:
|
||||
return None
|
||||
# Handle already-absolute paths (for backwards compatibility during migration)
|
||||
path = Path(relative_path)
|
||||
if path.is_absolute():
|
||||
return path
|
||||
return Path(app_settings.base_dir) / relative_path
|
||||
|
||||
|
||||
def calculate_file_hash(file_path: Path) -> str:
|
||||
"""Calculate SHA256 hash of a file."""
|
||||
sha256_hash = hashlib.sha256()
|
||||
@@ -722,15 +746,15 @@ async def upload_file(
|
||||
if generate_stl_thumbnails:
|
||||
thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
|
||||
|
||||
# Create database entry
|
||||
# Create database entry (store relative paths for portability)
|
||||
library_file = LibraryFile(
|
||||
folder_id=folder_id,
|
||||
filename=filename,
|
||||
file_path=str(file_path),
|
||||
file_path=to_relative_path(file_path),
|
||||
file_type=file_type,
|
||||
file_size=len(content),
|
||||
file_hash=file_hash,
|
||||
thumbnail_path=thumbnail_path,
|
||||
thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
|
||||
file_metadata=metadata if metadata else None,
|
||||
)
|
||||
db.add(library_file)
|
||||
@@ -958,15 +982,15 @@ async def extract_zip_file(
|
||||
if generate_stl_thumbnails:
|
||||
thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
|
||||
|
||||
# Create database entry
|
||||
# Create database entry (store relative paths for portability)
|
||||
library_file = LibraryFile(
|
||||
folder_id=target_folder_id,
|
||||
filename=filename,
|
||||
file_path=str(file_path),
|
||||
file_path=to_relative_path(file_path),
|
||||
file_type=file_type,
|
||||
file_size=len(file_content),
|
||||
file_hash=file_hash,
|
||||
thumbnail_path=thumbnail_path,
|
||||
thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
|
||||
file_metadata=metadata if metadata else None,
|
||||
)
|
||||
db.add(library_file)
|
||||
@@ -1062,9 +1086,9 @@ async def batch_generate_stl_thumbnails(
|
||||
failed = 0
|
||||
|
||||
for stl_file in stl_files:
|
||||
file_path = Path(stl_file.file_path)
|
||||
file_path = to_absolute_path(stl_file.file_path)
|
||||
|
||||
if not file_path.exists():
|
||||
if not file_path or not file_path.exists():
|
||||
results.append(
|
||||
BatchThumbnailResult(
|
||||
file_id=stl_file.id,
|
||||
@@ -1080,8 +1104,8 @@ async def batch_generate_stl_thumbnails(
|
||||
thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
|
||||
|
||||
if thumbnail_path:
|
||||
# Update database
|
||||
stl_file.thumbnail_path = thumbnail_path
|
||||
# Update database with relative path
|
||||
stl_file.thumbnail_path = to_relative_path(thumbnail_path)
|
||||
await db.flush()
|
||||
results.append(
|
||||
BatchThumbnailResult(
|
||||
@@ -1843,10 +1867,12 @@ async def delete_file(file_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
# Delete actual files
|
||||
try:
|
||||
if file.file_path and os.path.exists(file.file_path):
|
||||
os.remove(file.file_path)
|
||||
if file.thumbnail_path and os.path.exists(file.thumbnail_path):
|
||||
os.remove(file.thumbnail_path)
|
||||
abs_file_path = to_absolute_path(file.file_path)
|
||||
abs_thumb_path = to_absolute_path(file.thumbnail_path)
|
||||
if abs_file_path and abs_file_path.exists():
|
||||
abs_file_path.unlink()
|
||||
if abs_thumb_path and abs_thumb_path.exists():
|
||||
abs_thumb_path.unlink()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete file from disk: {e}")
|
||||
|
||||
@@ -1867,11 +1893,12 @@ async def download_file(file_id: int, db: AsyncSession = Depends(get_db)):
|
||||
if not file:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
if not file.file_path or not os.path.exists(file.file_path):
|
||||
abs_path = to_absolute_path(file.file_path)
|
||||
if not abs_path or not abs_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
|
||||
return FastAPIFileResponse(
|
||||
file.file_path,
|
||||
str(abs_path),
|
||||
filename=file.filename,
|
||||
media_type="application/octet-stream",
|
||||
)
|
||||
@@ -1886,11 +1913,12 @@ async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)):
|
||||
if not file:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
if not file.thumbnail_path or not os.path.exists(file.thumbnail_path):
|
||||
abs_thumb_path = to_absolute_path(file.thumbnail_path)
|
||||
if not abs_thumb_path or not abs_thumb_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Thumbnail not found")
|
||||
|
||||
# Detect media type from extension
|
||||
thumb_ext = os.path.splitext(file.thumbnail_path)[1].lower()
|
||||
thumb_ext = abs_thumb_path.suffix.lower()
|
||||
media_types = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
@@ -1900,7 +1928,7 @@ async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)):
|
||||
}
|
||||
media_type = media_types.get(thumb_ext, "image/png")
|
||||
|
||||
return FastAPIFileResponse(file.thumbnail_path, media_type=media_type)
|
||||
return FastAPIFileResponse(str(abs_thumb_path), media_type=media_type)
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/gcode")
|
||||
@@ -1912,17 +1940,18 @@ async def get_gcode(file_id: int, db: AsyncSession = Depends(get_db)):
|
||||
if not file:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
if not file.file_path or not os.path.exists(file.file_path):
|
||||
abs_path = to_absolute_path(file.file_path)
|
||||
if not abs_path or not abs_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
|
||||
if file.file_type == "gcode":
|
||||
return FastAPIFileResponse(file.file_path, media_type="text/plain")
|
||||
return FastAPIFileResponse(str(abs_path), media_type="text/plain")
|
||||
elif file.file_type == "3mf":
|
||||
# Extract gcode from 3mf
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(file.file_path, "r") as zf:
|
||||
with zipfile.ZipFile(str(abs_path), "r") as zf:
|
||||
# Find gcode file
|
||||
gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")]
|
||||
if not gcode_files:
|
||||
@@ -1973,10 +2002,12 @@ async def bulk_delete(data: BulkDeleteRequest, db: AsyncSession = Depends(get_db
|
||||
file = result.scalar_one_or_none()
|
||||
if file:
|
||||
try:
|
||||
if file.file_path and os.path.exists(file.file_path):
|
||||
os.remove(file.file_path)
|
||||
if file.thumbnail_path and os.path.exists(file.thumbnail_path):
|
||||
os.remove(file.thumbnail_path)
|
||||
abs_file_path = to_absolute_path(file.file_path)
|
||||
abs_thumb_path = to_absolute_path(file.thumbnail_path)
|
||||
if abs_file_path and abs_file_path.exists():
|
||||
abs_file_path.unlink()
|
||||
if abs_thumb_path and abs_thumb_path.exists():
|
||||
abs_thumb_path.unlink()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete file from disk: {e}")
|
||||
await db.delete(file)
|
||||
|
||||
@@ -1014,6 +1014,36 @@ async def run_migrations(conn):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Convert absolute paths to relative paths in library_files table
|
||||
# This ensures backup/restore portability across different installations
|
||||
try:
|
||||
base_dir_str = str(settings.base_dir)
|
||||
# Ensure we have a trailing slash for clean replacement
|
||||
if not base_dir_str.endswith("/"):
|
||||
base_dir_str += "/"
|
||||
|
||||
# Update file_path - remove base_dir prefix from absolute paths
|
||||
await conn.execute(
|
||||
text("""
|
||||
UPDATE library_files
|
||||
SET file_path = SUBSTR(file_path, LENGTH(:base_dir) + 1)
|
||||
WHERE file_path LIKE :pattern
|
||||
"""),
|
||||
{"base_dir": base_dir_str, "pattern": base_dir_str + "%"},
|
||||
)
|
||||
|
||||
# Update thumbnail_path - remove base_dir prefix from absolute paths
|
||||
await conn.execute(
|
||||
text("""
|
||||
UPDATE library_files
|
||||
SET thumbnail_path = SUBSTR(thumbnail_path, LENGTH(:base_dir) + 1)
|
||||
WHERE thumbnail_path LIKE :pattern
|
||||
"""),
|
||||
{"base_dir": base_dir_str, "pattern": base_dir_str + "%"},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def seed_notification_templates():
|
||||
"""Seed default notification templates if they don't exist."""
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -23,8 +23,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-CxQsg2-C.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-d5ZW47G8.css">
|
||||
<script type="module" crossorigin src="/assets/index-B9KMl1Nj.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BMToB_z5.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user