Files
site-servaki-online/save_generations_yadisk.py
T

111 lines
5.0 KiB
Python

"""Archive every site image generation, prompt and published output to Yandex Disk.
Run with --plan first, then --copy. Existing files are verified, never replaced.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import uuid
ROOT = Path(__file__).resolve().parent
YANDEX_SITE = Path(r'D:\YandexDisk\servaki.online\лендинги\servaki.online site')
GROUPS = ('artwork', 'assets', 'content', 'public')
MEDIA_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.mp4', '.webm'}
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open('rb') as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
digest.update(chunk)
return digest.hexdigest()
def planned_files(source: Path, raw: Path):
files = []
for dirname in GROUPS:
directory = source / dirname
if not directory.is_dir():
raise RuntimeError(f'Missing source directory: {directory}')
files.extend((path, path.relative_to(source)) for path in directory.rglob('*')
if path.is_file() and not path.is_symlink())
if not raw.is_dir():
raise RuntimeError(f'Missing image-generation directory: {raw}')
files.extend((path, Path('imagegen-raw') / path.name) for path in raw.iterdir()
if path.is_file() and not path.is_symlink()
and path.suffix.lower() in MEDIA_EXTENSIONS)
files.append((source / 'README.md', Path('SITE-BUILD.md')))
if len({relative.as_posix() for _, relative in files}) != len(files):
raise RuntimeError('Duplicate archive destination')
return sorted(files, key=lambda item: item[1].as_posix())
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--copy', action='store_true', help='copy and verify files')
parser.add_argument('--source', type=Path, default=ROOT)
parser.add_argument('--raw-dir', type=Path, required=True,
help='directory containing raw generated media for this release')
parser.add_argument('--target', type=Path, required=True,
help='new release folder under the Yandex Disk site directory')
args = parser.parse_args()
base = YANDEX_SITE.resolve()
target = args.target.resolve()
if not base.is_dir() or not target.is_relative_to(base) or target == base:
raise RuntimeError('Target must be a subfolder of the existing Yandex Disk site folder')
files = planned_files(args.source.resolve(), args.raw_dir.resolve())
total = sum(source.stat().st_size for source, _ in files)
counts = {group: sum(relative.parts[0] == group for _, relative in files)
for group in (*GROUPS, 'imagegen-raw')}
print(json.dumps({'target': str(target), 'files': len(files), 'bytes': total,
'groups': counts, 'mode': 'copy' if args.copy else 'plan'}, ensure_ascii=False))
if not args.copy:
return
target.mkdir(parents=True, exist_ok=True)
entries = []
artwork_hashes = {}
for source, relative in files:
expected = sha256(source)
if relative.parts[0] == 'artwork':
artwork_hashes.setdefault(expected, []).append(relative.as_posix())
destination = target / relative
destination.parent.mkdir(parents=True, exist_ok=True)
if destination.exists():
if sha256(destination) != expected:
raise RuntimeError(f'Archive conflict: {destination}')
else:
temporary = destination.with_name(destination.name + '.part-' + uuid.uuid4().hex[:8])
try:
shutil.copy2(source, temporary)
if sha256(temporary) != expected:
raise RuntimeError(f'Copy checksum mismatch: {relative}')
os.replace(temporary, destination)
finally:
if temporary.exists():
temporary.unlink()
entries.append({'path': relative.as_posix(), 'bytes': source.stat().st_size,
'sha256': expected})
raw_matches = {entry['path']: artwork_hashes.get(entry['sha256'], [])
for entry in entries if entry['path'].startswith('imagegen-raw/')}
manifest = {'site': 'servaki.online', 'release_date': '2026-09-28',
'files': entries, 'raw_matches_in_artwork': raw_matches}
destination = target / 'MANIFEST.json'
temporary = target / ('MANIFEST.json.part-' + uuid.uuid4().hex[:8])
temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
os.replace(temporary, destination)
for entry in entries:
if sha256(target / entry['path']) != entry['sha256']:
raise RuntimeError(f'Final checksum mismatch: {entry["path"]}')
print(json.dumps({'verified_files': len(entries), 'verified_bytes': total,
'raw_matched_to_artwork': sum(bool(paths) for paths in raw_matches.values())},
ensure_ascii=False))
if __name__ == '__main__':
main()