pytable/synchronise.py
2024-09-02 20:45:46 +02:00

298 lines
8.3 KiB
Python

from pytable.models import Image, ImageFlags, FilmRoll
from datetime import datetime
import peewee as pw
import os
import shutil
from dataclasses import dataclass
from typing import Tuple, Optional, Generic, TypeVar, Iterable
T = TypeVar('T')
Range = Tuple[Optional[T], Optional[T]]
class Filter:
def __call__(self, image: Image):
return self.matches(image)
def matches(self, image: Image) -> bool:
...
@dataclass
class LocalRegion(Filter):
date_range: Range[datetime] = (None, None)
stars_range: Range[int] = (None, None)
def matches(self, image: Image) -> bool:
stars = image.stars if not image.flag(ImageFlags.REJECTED) else -1
if self.stars_range[0] is not None and stars < self.stars_range[0]:
return False
if self.stars_range[1] is not None and stars > self.stars_range[1]:
return False
dt = (image.datetime_taken or image.datetime_imported)
if self.date_range[0] is not None and dt < self.date_range[0]:
return False
if self.date_range[1] is not None and dt > self.date_range[1]:
return False
return True
@dataclass
class Or(Filter):
filters: Iterable[Filter]
def matches(self, image: Image) -> bool:
return any(f(image) for f in self.filters)
@dataclass
class And(Filter):
filters: Iterable[Filter]
def matches(self, image: Image) -> bool:
return all(f(image) for f in self.filters)
class Config:
def __init__(self) -> None:
self.filter = Or([
LocalRegion((datetime(2024, 6, 14), None), (0, None)),
LocalRegion((datetime(2024, 6, 22, 14), datetime(2024, 6, 22, 14, 24)))
])
self.d_root = "/home/oke/Pictures/Darktable"
self.d_local = "/home/oke/Pictures/DarktableLocal"
self.d_remote = "/home/oke/Pictures/DarktableRemote"
@staticmethod
def _is_older_than(image: Image, date: datetime):
return (image.datetime_taken or image.datetime_imported) < date
def should_not_be_local(self, image: Image):
return not self.filter(image)
def should_be_local(self, image: Image):
return self.filter(image)
def image_local_folder(self, image: Image):
folder = image.film.folder
if not folder.startswith(self.d_root):
raise NotImplementedError()
assert folder.startswith(self.d_root)
return folder.replace(self.d_root, self.d_local)
def image_local_image(self, image: Image) -> str:
return os.path.join(self.image_local_folder(image), image.filename)
def image_local_image_xmp(self, image: Image) -> str:
base, ext = os.path.splitext(image.filename)
version = ("_%02d" % image.version) if image.version != 0 else ""
xmp_filename = base + version + ext + '.xmp'
return os.path.join(self.image_local_folder(image), xmp_filename)
def image_local_iphone_movie(self, image: Image) -> str:
base, _ = os.path.splitext(image.filename)
return os.path.join(self.image_local_folder(image), "." + base + ".mov")
def potential_extras(self, image):
yield self.image_local_iphone_movie(image)
from dataclasses import dataclass
from typing import Dict, List, Optional
from enum import Enum
class SyncReq(Enum):
EXISTS = 0
REQUIRED_SOFT = 1
REQUIRED = 2
@staticmethod
def max(a: "SyncReq", b: "SyncReq"):
if a.value < b.value:
return b
return a
@staticmethod
def required(v: 'SyncReq'):
return v == SyncReq.REQUIRED or v == SyncReq.REQUIRED_SOFT
@staticmethod
def make(required, optional):
if required:
if optional:
return SyncReq.REQUIRED_SOFT
return SyncReq.REQUIRED
return SyncReq.EXISTS
@dataclass
class FileInfo:
required: bool = False
optional: bool = False
class SyncManager:
def __init__(self, config) -> None:
self._config = config
self._files: Dict[str, SyncReq] = {}
def register_file(self, fn: str, requirement: SyncReq):
if fn in self._files:
requirement = SyncReq.max(self._files[fn], requirement)
self._files[fn] = requirement
def register_image(self, image: Image):
required = self._config.should_be_local(image)
self.register_file(
self._config.image_local_image(image),
SyncReq.make(required, False)
)
self.register_file(
self._config.image_local_image_xmp(image),
SyncReq.make(required, False)
)
for extra in self._config.potential_extras(image):
self.register_file(
extra,
SyncReq.make(required, True)
)
def partition(self):
result = {k: [] for k in SyncReq}
for file, req in self._files.items():
result[req].append(file)
return result
config = Config()
sync_manager = SyncManager(config)
query = Image.filter()
pw.prefetch(query, FilmRoll)
for image in query:
sync_manager.register_image(image)
actions = sync_manager.partition()
for key in actions:
actions[key] = list(sorted(map(
lambda fn: os.path.relpath(fn, config.d_local) + "\n",
actions[key]
)))
with open("required.txt", "w") as f:
f.writelines(actions[SyncReq.REQUIRED])
import subprocess
user = "oke"
host = "192.168.20.2"
args = [
"rsync",
# "--dry-run",
"--info=progress",
"--ignore-existing",
"-av",
"--files-from",
"required.txt",
f"{user}@{host}:lenovo-darktable{config.d_local}",
os.path.abspath(config.d_local)
]
import shlex
print(shlex.join(args))
subprocess.call(args)
with open("required_optional.txt", "w") as f:
f.writelines(actions[SyncReq.REQUIRED_SOFT])
with open("remove.txt", "w") as f:
f.writelines(actions[SyncReq.EXISTS])
exit()
for fn in actions[SyncReq.REQUIRED_SOFT]:
if not os.path.exists(fn):
print("FETCH opt: ", fn)
for fn in sync_manager.files_to_fetch_required():
print("FETCH: ", fn)
for fn in sync_manager.files_to_remove():
print("RM: ", fn)
n_total = 0
n_not_needed = 0
needs_removal = []
extra_files_to_remove = []
bytes_to_remove = 0
needs_fetching = []
for file, usages in file_usages.items():
if not file.startswith(d_root):
print("SKIPPNG", file)
continue
assert file.startswith(d_root)
remote_file = file.replace(d_root, d_remote)
local_file = file.replace(d_root, d_local)
if usages > 0:
if not os.path.exists(local_file):
needs_fetching.append((remote_file, local_file))
else:
n_not_needed += 1
if os.path.exists(local_file):
needs_removal.append(local_file)
bytes_to_remove += os.stat(local_file).st_size
for extra_fn in potential_extras(local_file):
if os.path.exists(extra_fn):
extra_files_to_remove.append(extra_fn)
bytes_to_remove += os.stat(extra_fn).st_size
files_to_remove = list(sorted(needs_removal + extra_files_to_remove))
needs_fetching = sorted(needs_fetching)
import pprint
print("Files to fetch: ")
pprint.pprint(needs_fetching)
print("Extra files to remove:")
pprint.pprint(extra_files_to_remove)
print("First files to remove:")
pprint.pprint(needs_removal[:10])
print("Last files to remove:")
pprint.pprint(needs_removal[-10:])
with open("remove.txt", "w") as f:
pprint.pprint(sorted(set(needs_removal).union(extra_files_to_remove)), f)
print("Not needed: %6d / %6d (%3d%%)" % (n_not_needed, len(file_usages), 100 * n_not_needed / len(file_usages)))
print()
print("Not yet deleted: %6d / %6d (%3d%%)" % (len(needs_removal), n_not_needed, 100 * len(needs_removal) / n_not_needed))
print("Bytes to free: %5.2fGB" % (bytes_to_remove / 1024. / 1024 / 1024))
print("Total files to remove: %d" % len(files_to_remove))
print()
print("Total files to fetch: %3d" % len(needs_fetching))
print()
reply = input("Type YES to remove all these files. This is not reversible... : ")
if reply == 'YES':
for fn in files_to_remove:
assert fn.startswith(d_local)
os.remove(fn)
for f, t in needs_fetching:
assert os.path.exists(f), "Backup corruped, file not found: " + f
assert not os.path.exists(t)
print(f, t)
shutil.copy(f, t)
else:
print("You did not type 'YES'. Not doing anything!")