127
scripts/move_immich_deleted_to_recycle.py
Normal file
127
scripts/move_immich_deleted_to_recycle.py
Normal file
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Move Immich soft-deleted files from /opt/immich/el/ and /opt/immich/elv/
|
||||
into the actual recycle bin (/opt/immich/recycle/) with proper DB entries.
|
||||
|
||||
For each file with location='recycle' in file_inventory:
|
||||
1. Move the file to /opt/immich/recycle/<uuid>.<ext>
|
||||
2. Insert a row into recycle_bin
|
||||
3. Delete from file_inventory
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
|
||||
APP_DB_DSN = "postgresql://media_downloader:PNsihOXvvuPwWiIvGlsc9Fh2YmMmB@localhost/media_downloader"
|
||||
RECYCLE_DIR = Path("/opt/immich/recycle")
|
||||
BATCH_SIZE = 500
|
||||
|
||||
|
||||
def main():
|
||||
start = time.time()
|
||||
print("Moving Immich soft-deleted files to recycle bin")
|
||||
print("=" * 60)
|
||||
|
||||
conn = psycopg2.connect(APP_DB_DSN)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Get all recycled entries from el/elv
|
||||
cur.execute("""
|
||||
SELECT id, file_path, filename, file_size, file_hash, created_date
|
||||
FROM file_inventory
|
||||
WHERE (file_path LIKE '/opt/immich/el/%' OR file_path LIKE '/opt/immich/elv/%')
|
||||
AND location = 'recycle'
|
||||
ORDER BY id
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
total = len(rows)
|
||||
print(f" Found {total:,} recycled entries to move")
|
||||
|
||||
if total == 0:
|
||||
print(" Nothing to do.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
RECYCLE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
moved = 0
|
||||
missing = 0
|
||||
errors = 0
|
||||
|
||||
for i, (inv_id, file_path, filename, file_size, file_hash, created_date) in enumerate(rows):
|
||||
src = Path(file_path)
|
||||
|
||||
if not src.exists():
|
||||
# File doesn't exist on disk — just remove from file_inventory
|
||||
cur.execute("DELETE FROM file_inventory WHERE id = %s", (inv_id,))
|
||||
missing += 1
|
||||
if missing <= 5:
|
||||
print(f" MISSING (removed from DB): {file_path}")
|
||||
continue
|
||||
|
||||
# Generate recycle path
|
||||
ext = src.suffix or ""
|
||||
recycle_id = str(uuid.uuid4())
|
||||
recycle_path = RECYCLE_DIR / f"{recycle_id}{ext}"
|
||||
|
||||
try:
|
||||
# Get file mtime before moving
|
||||
mtime = src.stat().st_mtime
|
||||
actual_size = src.stat().st_size
|
||||
|
||||
# Move the file
|
||||
shutil.move(str(src), str(recycle_path))
|
||||
|
||||
# Insert into recycle_bin
|
||||
cur.execute("""
|
||||
INSERT INTO recycle_bin
|
||||
(id, original_path, original_filename, recycle_path,
|
||||
file_extension, file_size, original_mtime,
|
||||
deleted_from, deleted_by, file_hash)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
recycle_id,
|
||||
file_path,
|
||||
filename,
|
||||
str(recycle_path),
|
||||
ext.lstrip(".") if ext else None,
|
||||
actual_size or file_size,
|
||||
mtime,
|
||||
"immich_deleted",
|
||||
"immich_migration",
|
||||
file_hash,
|
||||
))
|
||||
|
||||
# Delete from file_inventory
|
||||
cur.execute("DELETE FROM file_inventory WHERE id = %s", (inv_id,))
|
||||
|
||||
moved += 1
|
||||
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
if errors <= 5:
|
||||
print(f" ERROR moving {file_path}: {e}")
|
||||
|
||||
if (i + 1) % BATCH_SIZE == 0:
|
||||
conn.commit()
|
||||
print(f" Progress: {i + 1:,}/{total:,} — moved: {moved:,}, missing: {missing:,}, errors: {errors:,}")
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
elapsed = time.time() - start
|
||||
print(f"\n DONE in {elapsed:.1f}s:")
|
||||
print(f" Moved to recycle: {moved:,}")
|
||||
print(f" Missing on disk: {missing:,}")
|
||||
print(f" Errors: {errors:,}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user