Initial commit.
This commit is contained in:
commit
628e5c3823
26 changed files with 3478 additions and 0 deletions
113
ocado_grocy/images.py
Normal file
113
ocado_grocy/images.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Copy saved Ocado product photos into Grocy's native product pictures."""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from urllib.parse import quote, urlsplit
|
||||
|
||||
import click
|
||||
import requests
|
||||
|
||||
from .grocy import Grocy, imported_products
|
||||
from .history import private_json
|
||||
from .product_metadata import read_metadata
|
||||
|
||||
|
||||
def image_type(data):
|
||||
if data.startswith(b'\xff\xd8\xff'):
|
||||
return 'jpg'
|
||||
if data.startswith(b'\x89PNG\r\n\x1a\n'):
|
||||
return 'png'
|
||||
if data.startswith(b'RIFF') and data[8:12] == b'WEBP':
|
||||
return 'webp'
|
||||
raise ValueError('Image response is not JPEG, PNG or WebP')
|
||||
|
||||
|
||||
def file_path(filename):
|
||||
return '/files/productpictures/' + quote(base64.b64encode(filename.encode()).decode(), safe='')
|
||||
|
||||
|
||||
def add_picture(client, downloader, product):
|
||||
if product.get('picture_file_name'):
|
||||
return 'existing_picture'
|
||||
metadata = read_metadata(product.get('description'))
|
||||
url = metadata.get('image_url')
|
||||
if not url:
|
||||
return 'no_image_url'
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme != 'https' or parsed.hostname not in {'www.ocado.com', 'ocado.com'}:
|
||||
raise ValueError('Expected an HTTPS Ocado image URL')
|
||||
# Separate session: never send the Grocy API key to the image host.
|
||||
response = downloader.get(url, timeout=30, allow_redirects=False)
|
||||
response.raise_for_status()
|
||||
if response.status_code != 200:
|
||||
raise ValueError('Image URL did not return HTTP 200')
|
||||
data = response.content
|
||||
extension = image_type(data)
|
||||
if len(data) > 10_000_000:
|
||||
raise ValueError('Image exceeds 10 MB')
|
||||
filename = f"ocado-{int(product['id'])}-{hashlib.sha256(data).hexdigest()[:16]}.{extension}"
|
||||
current = client.get(f"/objects/products/{product['id']}")
|
||||
if not current:
|
||||
return 'deleted_product'
|
||||
if current.get('picture_file_name'):
|
||||
return 'existing_picture'
|
||||
if read_metadata(current.get('description')).get('Ocado product ID') != metadata.get('Ocado product ID'):
|
||||
raise ValueError('Product identity changed')
|
||||
path = file_path(filename)
|
||||
uploaded = client.session.put(client.url + path, data=data,
|
||||
headers={'Content-Type':'application/octet-stream'},
|
||||
timeout=client.timeout, allow_redirects=False)
|
||||
uploaded.raise_for_status()
|
||||
if uploaded.status_code not in (200, 201, 204):
|
||||
raise ValueError('Unexpected upload response')
|
||||
# Check the stored file before setting the product's picture reference.
|
||||
stored = client.session.get(client.url + path, timeout=client.timeout, allow_redirects=False)
|
||||
stored.raise_for_status()
|
||||
if stored.status_code != 200 or stored.content != data:
|
||||
raise ValueError('Uploaded image verification failed')
|
||||
client.request('PUT', f"/objects/products/{product['id']}", {'picture_file_name':filename})
|
||||
return 'updated'
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option('--config', type=click.Path(path_type=Path), default=Path('config.toml'))
|
||||
@click.option('--report', type=click.Path(path_type=Path), default=Path('history/images/report.json'))
|
||||
@click.option('--apply', is_flag=True, help='Upload and assign missing product pictures.')
|
||||
@click.option('--order-id', 'order_ids', multiple=True, help='Limit to products imported from these orders.')
|
||||
def main(config, report, apply, order_ids):
|
||||
"""Add Ocado images to journal-linked products; keep existing pictures."""
|
||||
settings = tomllib.loads(config.read_text())
|
||||
client = Grocy(**settings['grocy'])
|
||||
state = config.parent / settings.get('import', {}).get('state_file', 'imports.sqlite3')
|
||||
products = imported_products(client, state, order_ids)
|
||||
return run_images(client, products, report, apply=apply)
|
||||
|
||||
|
||||
def run_images(client, products, report, *, apply=True, raise_errors=True):
|
||||
downloader = requests.Session()
|
||||
downloader.headers['User-Agent'] = 'ocado-grocy/0.1'
|
||||
results = []
|
||||
private_json(report, {'apply':apply, 'products':results})
|
||||
for product in products:
|
||||
row = {'product_id':product['id'], 'name':product['name']}
|
||||
try:
|
||||
if apply:
|
||||
row['status'] = add_picture(client, downloader, product)
|
||||
else:
|
||||
row['status'] = 'existing_picture' if product.get('picture_file_name') else 'ready' if read_metadata(product.get('description')).get('image_url') else 'no_image_url'
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
row.update(status='error', error=str(exc))
|
||||
results.append(row)
|
||||
private_json(report, {'apply':apply, 'products':results})
|
||||
click.echo(f"{product['id']} {row['status']}: {product['name']}")
|
||||
counts = {s:sum(r['status']==s for r in results) for s in sorted({r['status'] for r in results})}
|
||||
click.echo(json.dumps(counts))
|
||||
if counts.get('error') and raise_errors:
|
||||
raise click.ClickException(f"{counts['error']} image updates failed; see {report}")
|
||||
return {'products':results, 'counts':counts}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue