111 lines
5.0 KiB
Python
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()
|