Polish synchronization script

This commit is contained in:
har0ke 2024-11-10 14:34:21 +01:00
parent 0f91c3e941
commit 03201efc3b
2 changed files with 119 additions and 141 deletions

View File

@ -3,22 +3,27 @@ from pytable.models import Image, ImageFlags, FilmRoll
from datetime import datetime from datetime import datetime
import peewee as pw import peewee as pw
import os import os
import shutil
from dataclasses import dataclass from dataclasses import dataclass
from typing import Tuple, Optional, Generic, TypeVar, Iterable from typing import Tuple, Optional, Generic, TypeVar, Iterable
from vmgr.actions import Trash
import subprocess
import shlex
T = TypeVar('T') T = TypeVar('T')
Range = Tuple[Optional[T], Optional[T]] Range = Tuple[Optional[T], Optional[T]]
class Filter:
class Filter:
def __call__(self, image: Image): def __call__(self, image: Image):
return self.matches(image) return self.matches(image)
def matches(self, image: Image) -> bool: def matches(self, image: Image) -> bool:
... return True
def __str__(self) -> str:
return 'All'
@dataclass @dataclass
class LocalRegion(Filter): class LocalRegion(Filter):
@ -41,6 +46,9 @@ class LocalRegion(Filter):
return False return False
return True return True
def __str__(self) -> str:
return f"LocalRange[({self.date_range[0] and self.date_range[0].strftime("%Y%m%d")}, {self.date_range[1] and self.date_range[1].strftime("%Y%m%d")}), ({self.stars_range[0]}, {self.stars_range[1]})]"
@dataclass @dataclass
class Or(Filter): class Or(Filter):
@ -49,6 +57,9 @@ class Or(Filter):
def matches(self, image: Image) -> bool: def matches(self, image: Image) -> bool:
return any(f(image) for f in self.filters) return any(f(image) for f in self.filters)
def __str__(self) -> str:
return f"Or([\n\t{"\n\t".join((str(f) + ',' for f in self.filters))}\n])"
@dataclass @dataclass
class And(Filter): class And(Filter):
@ -57,31 +68,18 @@ class And(Filter):
def matches(self, image: Image) -> bool: def matches(self, image: Image) -> bool:
return all(f(image) for f in self.filters) return all(f(image) for f in self.filters)
def __str__(self) -> str:
return f"And([\n\t{"\n\t".join(('<' + str(f) + '>,' for f in self.filters))}\n])"
class Config: class Config:
def __init__(self) -> None: def __init__(self, d_root: str | None = None, d_local: str | None=None, dry_run=False) -> None:
self.filter = Or([ self.d_root = d_root or "/home/oke/Pictures/Darktable"
LocalRegion((datetime(2024, 6, 14), None), (0, None)), self.d_local = d_local or "/home/oke/Pictures/DarktableLocal"
LocalRegion((datetime(2024, 6, 22, 14), datetime(2024, 6, 22, 14, 24))) self.dry_run = dry_run
])
self.d_root = "/home/oke/Pictures/Darktable" def image_local_folder(self, image: Image) -> str:
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 folder = image.film.folder
if not folder.startswith(self.d_root): if not folder.startswith(self.d_root):
raise NotImplementedError() raise NotImplementedError()
@ -137,8 +135,9 @@ class FileInfo:
class SyncManager: class SyncManager:
def __init__(self, config) -> None: def __init__(self, config: Config, filter: Filter) -> None:
self._config = config self._config = config
self._filter = filter
self._files: Dict[str, SyncReq] = {} self._files: Dict[str, SyncReq] = {}
def register_file(self, fn: str, requirement: SyncReq): def register_file(self, fn: str, requirement: SyncReq):
@ -147,7 +146,7 @@ class SyncManager:
self._files[fn] = requirement self._files[fn] = requirement
def register_image(self, image: Image): def register_image(self, image: Image):
required = self._config.should_be_local(image) required = self._filter(image)
self.register_file( self.register_file(
self._config.image_local_image(image), self._config.image_local_image(image),
SyncReq.make(required, False) SyncReq.make(required, False)
@ -169,33 +168,20 @@ class SyncManager:
result[req].append(file) result[req].append(file)
return result return result
config = Config() def write_to(fn, file_list, realtive_to):
lines = list(sorted(map(
sync_manager = SyncManager(config) lambda fn: os.path.relpath(fn, realtive_to) + "\n",
file_list
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(fn, "w") as f:
f.writelines(lines)
with open("required.txt", "w") as f: def synchronize_from_remote(config, required_files):
f.writelines(actions[SyncReq.REQUIRED]) write_to("required.txt", required_files, config.d_local)
import subprocess user = "oke"
user = "oke" host = "192.168.20.2"
host = "192.168.20.2" args = [
args = [
"rsync", "rsync",
# "--dry-run",
"--info=progress", "--info=progress",
"--ignore-existing", "--ignore-existing",
"-av", "-av",
@ -203,95 +189,85 @@ args = [
"required.txt", "required.txt",
f"{user}@{host}:lenovo-darktable{config.d_local}", f"{user}@{host}:lenovo-darktable{config.d_local}",
os.path.abspath(config.d_local) os.path.abspath(config.d_local)
] ]
import shlex if config.dry_run:
print(shlex.join(args)) args.append('--dry-run')
subprocess.call(args) print(shlex.join(args))
subprocess.call(args)
with open("required_optional.txt", "w") as f: def remove_unnessecary(config: Config, to_remove):
f.writelines(actions[SyncReq.REQUIRED_SOFT]) remove_list_fn = "sync_remove.txt"
try:
with open("remove.txt", "w") as f: reply = input("There are %d files to remove. Want to see them [type: y]? " % len(to_remove))
f.writelines(actions[SyncReq.EXISTS]) if reply == 'y':
write_to(remove_list_fn, to_remove, config.d_local)
subprocess.call(['vim', '-R', remove_list_fn])
if not config.dry_run:
reply = input("There are %d files to remove. Want to DELETE them [type: YES]? " % len(to_remove))
if reply == 'YES':
for f in to_remove:
Trash(f).run()
finally:
if os.path.exists(remove_list_fn):
os.remove(remove_list_fn)
exit() def main():
for fn in actions[SyncReq.REQUIRED_SOFT]: import argparse
if not os.path.exists(fn): parser = argparse.ArgumentParser()
print("FETCH opt: ", fn) parser.add_argument("--dry-run", action='store_true')
options, filter_args = parser.parse_known_args()
for fn in sync_manager.files_to_fetch_required(): filter = None
print("FETCH: ", fn) while filter_args:
if filter_args[0] != '--sync':
print(f"Unexpected arg {filter_args[0]}")
exit(1)
try:
next_sync = filter_args.index('--sync', 1)
args = filter_args[:next_sync]
filter_args = filter_args[next_sync:]
except ValueError:
args = filter_args
filter_args = []
for fn in sync_manager.files_to_remove(): filter_parser = argparse.ArgumentParser()
print("RM: ", fn) filter_parser.add_argument("--from", dest='from_date', type=lambda s: datetime.strptime(s, '%Y%m%d'), default=None)
n_total = 0 filter_parser.add_argument("--to", dest='to_date', type=lambda s: datetime.strptime(s, '%Y%m%d'), default=None)
n_not_needed = 0 filter_parser.add_argument("--min-stars", type=int, default=None)
needs_removal = [] filter_parser.add_argument("--max-stars", type=int, default=None)
extra_files_to_remove = []
bytes_to_remove = 0
needs_fetching = [] filter_options = filter_parser.parse_args(args[1:])
parsed_filter = LocalRegion((filter_options.from_date, filter_options.to_date), (filter_options.min_stars, filter_options.max_stars))
for file, usages in file_usages.items(): if filter:
if not file.startswith(d_root): filter = Or([
print("SKIPPNG", file) filter,
continue parsed_filter
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: else:
n_not_needed += 1 filter = parsed_filter
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 not filter:
if os.path.exists(extra_fn): print("Nothing to be synchronized")
extra_files_to_remove.append(extra_fn) exit()
bytes_to_remove += os.stat(extra_fn).st_size print(filter)
config = Config(dry_run=options.dry_run)
files_to_remove = list(sorted(needs_removal + extra_files_to_remove)) sync_manager = SyncManager(config, filter)
needs_fetching = sorted(needs_fetching) query = Image.filter()
import pprint pw.prefetch(query, FilmRoll)
print("Files to fetch: ") for image in query:
pprint.pprint(needs_fetching) sync_manager.register_image(image)
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))) actions = sync_manager.partition()
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... : ") required_files = actions[SyncReq.REQUIRED]
synchronize_from_remote(config, required_files)
if reply == 'YES': to_remove = [l for l in actions[SyncReq.EXISTS] if os.path.exists(l)]
remove_unnessecary(config, to_remove)
for fn in files_to_remove: if __name__ == "__main__":
assert fn.startswith(d_local) main()
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!")

View File

@ -116,8 +116,9 @@ class Trash(UndoableAction[str]):
def __str__(self) -> str: def __str__(self) -> str:
return f"Trashing {self.args}" return f"Trashing {self.args}"
def run(self, model: VideoModel): def run(self, model: VideoModel = None):
file = os.path.abspath(os.path.expanduser(self.args)) file = os.path.abspath(os.path.expanduser(self.args))
assert os.path.exists(file)
i = 0 i = 0
suffix = "" suffix = ""
while True: while True:
@ -141,6 +142,7 @@ class Trash(UndoableAction[str]):
suffix = f"_{i}" suffix = f"_{i}"
continue continue
os.rename(file, trash_fn) os.rename(file, trash_fn)
if model:
index, video = model.find_video(Video(self.args)) index, video = model.find_video(Video(self.args))
if index: if index:
model.remove(index) model.remove(index)