Compare commits
5 Commits
a697529c8c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a30f5a6eca | |||
| ee14efbd48 | |||
| 82de38f02a | |||
| 19e05bd013 | |||
| 0f73a6b537 |
@@ -3,3 +3,13 @@ DB_PORT=5432
|
|||||||
DB_USER=vladimir.buzalka
|
DB_USER=vladimir.buzalka
|
||||||
DB_PASSWORD=Vlado7309208104++
|
DB_PASSWORD=Vlado7309208104++
|
||||||
DB_NAME=fotky_buzalkovi
|
DB_NAME=fotky_buzalkovi
|
||||||
|
|
||||||
|
IMMICH_URL=http://192.168.1.76:8888
|
||||||
|
IMMICH_API_KEY=UQV5PS1Td50hKOZTItnXEcXVkfQSUxcUH0XHZYxc
|
||||||
|
|
||||||
|
SSH_HOST=192.168.1.76
|
||||||
|
SSH_USER=root
|
||||||
|
SSH_PASSWORD=7309208104
|
||||||
|
# Cesta zálohy v DB (Tower1 local) -> NFS mount na tower
|
||||||
|
ZALOHA_SRC_PREFIX=/mnt/user/ZalohaVsechObrazku
|
||||||
|
ZALOHA_DST_PREFIX=/mnt/remotes/TOWER1.LAN_ZalohaVsechObrazku
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
immich_upload.py — nahrava wanted fotky z DB fotky_buzalkovi do Immiche.
|
||||||
|
Cte primo ze zalohy (NFS), nahrava KOPII pres Immich API. Zalohu nesaha.
|
||||||
|
Resumable: tabulka immich_upload (zaloha_id -> asset id, status).
|
||||||
|
|
||||||
|
Spousti se v kontejneru python-runner:
|
||||||
|
docker exec python-runner python3 /scripts/immich_upload.py --like '...%' --limit 200
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", dbname="fotky_buzalkovi")
|
||||||
|
IMMICH = "http://192.168.1.76:8888"
|
||||||
|
APIKEY = "UQV5PS1Td50hKOZTItnXEcXVkfQSUxcUH0XHZYxc"
|
||||||
|
|
||||||
|
SRC_PREFIX = "/mnt/user/ZalohaVsechObrazku" # jak je cesta v DB
|
||||||
|
DST_PREFIX = "/mnt/ZalohaVsechObrazku" # jak ji vidi kontejner
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--like", default=None, help="filtr na z.cesta_zalohy LIKE")
|
||||||
|
ap.add_argument("--category", default=None, help="filtr na p.category")
|
||||||
|
ap.add_argument("--limit", type=int, default=0)
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
conn = psycopg.connect(**DB, autocommit=True)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS immich_upload(
|
||||||
|
zaloha_id bigint PRIMARY KEY,
|
||||||
|
immich_id uuid,
|
||||||
|
status text,
|
||||||
|
uploaded_at timestamptz DEFAULT now()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
SELECT z.id, z.cesta_zalohy, p.taken_at
|
||||||
|
FROM photos p
|
||||||
|
JOIN zaloha_obrazku z ON p.zaloha_id = z.id
|
||||||
|
LEFT JOIN immich_upload iu ON iu.zaloha_id = z.id
|
||||||
|
WHERE p.wanted = TRUE AND iu.zaloha_id IS NULL
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
if args.category:
|
||||||
|
sql += " AND p.category = %s"; params.append(args.category)
|
||||||
|
if args.like:
|
||||||
|
sql += " AND z.cesta_zalohy LIKE %s"; params.append(args.like)
|
||||||
|
sql += " ORDER BY z.cesta_zalohy"
|
||||||
|
if args.limit:
|
||||||
|
sql += " LIMIT %s"; params.append(args.limit)
|
||||||
|
|
||||||
|
cur.execute(sql, params)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
total = len(rows)
|
||||||
|
print(f"K nahrani (jeste nenahrano): {total:,}")
|
||||||
|
if args.dry_run:
|
||||||
|
miss = sum(1 for _, c, _ in rows if not os.path.isfile(c.replace(SRC_PREFIX, DST_PREFIX, 1)))
|
||||||
|
print(f"DRY-RUN: chybejicich souboru: {miss}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
sess = requests.Session()
|
||||||
|
sess.headers.update({"x-api-key": APIKEY, "Accept": "application/json"})
|
||||||
|
|
||||||
|
created = dup = err = 0
|
||||||
|
for i, (zid, cesta, taken) in enumerate(rows, 1):
|
||||||
|
path = cesta.replace(SRC_PREFIX, DST_PREFIX, 1)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
err += 1
|
||||||
|
print("MISSING:", path)
|
||||||
|
continue
|
||||||
|
st = os.stat(path)
|
||||||
|
fmod = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc)
|
||||||
|
fcreated = taken if taken else fmod
|
||||||
|
if fcreated.tzinfo is None:
|
||||||
|
fcreated = fcreated.replace(tzinfo=timezone.utc)
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
r = sess.post(
|
||||||
|
IMMICH + "/api/assets",
|
||||||
|
files={"assetData": (os.path.basename(path), fh, "application/octet-stream")},
|
||||||
|
data={
|
||||||
|
"deviceAssetId": f"fb-{zid}",
|
||||||
|
"deviceId": "fotky-buzalkovi",
|
||||||
|
"fileCreatedAt": fcreated.isoformat(),
|
||||||
|
"fileModifiedAt": fmod.isoformat(),
|
||||||
|
"isFavorite": "false",
|
||||||
|
},
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
j = r.json()
|
||||||
|
status = j.get("status")
|
||||||
|
aid = j.get("id")
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO immich_upload(zaloha_id, immich_id, status)
|
||||||
|
VALUES (%s,%s,%s)
|
||||||
|
ON CONFLICT (zaloha_id) DO UPDATE
|
||||||
|
SET immich_id=EXCLUDED.immich_id, status=EXCLUDED.status, uploaded_at=now()""",
|
||||||
|
(zid, aid, status),
|
||||||
|
)
|
||||||
|
if status == "created":
|
||||||
|
created += 1
|
||||||
|
elif status == "duplicate":
|
||||||
|
dup += 1
|
||||||
|
else:
|
||||||
|
err += 1
|
||||||
|
print("? neznamy status:", status, j)
|
||||||
|
except Exception as e:
|
||||||
|
err += 1
|
||||||
|
print("ERR", zid, repr(e))
|
||||||
|
if i % 50 == 0 or i == total:
|
||||||
|
print(f"... {i}/{total} created={created} dup={dup} err={err}", flush=True)
|
||||||
|
|
||||||
|
print(f"\nHOTOVO. created={created} dup={dup} err={err}")
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""
|
||||||
|
mark_cache_unwanted.py — Označí celé adresáře Tower/appdata a Tower/Sabnzbd
|
||||||
|
jako nechceme (cache PhotoPrism/Immich + usenet download cache).
|
||||||
|
|
||||||
|
wanted=FALSE, category='Odpad-cache'
|
||||||
|
|
||||||
|
Idempotentní: díky filtru `category IS DISTINCT FROM 'Odpad-cache'` přepíše
|
||||||
|
jen řádky, které ještě nejsou označené (vč. těch, co camera-rules omylem
|
||||||
|
označily jako Rodina — jde o duplicitní cache kopie, ne originály).
|
||||||
|
|
||||||
|
Jediná DB session, autocommit — žádné paralelní běhy (deadlock).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
DIRS = [
|
||||||
|
("Tower/appdata (PhotoPrism/Immich cache)",
|
||||||
|
"/mnt/user/ZalohaVsechObrazku/Tower/appdata%"),
|
||||||
|
("Tower/Sabnzbd (usenet download cache)",
|
||||||
|
"/mnt/user/ZalohaVsechObrazku/Tower/Sabnzbd%"),
|
||||||
|
]
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for popis, like in DIRS:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p
|
||||||
|
SET wanted = FALSE,
|
||||||
|
category = 'Odpad-cache'
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id = z.id
|
||||||
|
AND z.cesta_zalohy LIKE %s
|
||||||
|
AND p.category IS DISTINCT FROM 'Odpad-cache'
|
||||||
|
""", (like,))
|
||||||
|
print(f" {popis}: {cur.rowcount:,} řádků aktualizováno")
|
||||||
|
total += cur.rowcount
|
||||||
|
|
||||||
|
print(f"\nHotovo. Celkem aktualizováno: {total:,} řádků.")
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
mark_colddata_junk.py — Označí jasně odpadní podsložky v Tower1/#ColdData
|
||||||
|
jako nechceme. NEoznačuje SynologyMaly, Honza, VladkoSoubory, Qnap, Tatinek,
|
||||||
|
DedupPhotos (ty se zkoumají zvlášť).
|
||||||
|
|
||||||
|
Kategorie:
|
||||||
|
Porno*, MadelineIsWicked -> 'Porno'
|
||||||
|
000 TORENT OBRAZKY, MoMA, Dali,
|
||||||
|
hudební alba, eBooky -> 'Odpad-torrent'
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
BASE = "/mnt/user/ZalohaVsechObrazku/Tower1/#ColdData/"
|
||||||
|
|
||||||
|
# (popis, LIKE vzor, kategorie)
|
||||||
|
RULES = [
|
||||||
|
("Porno", BASE + "Porno/%", "Porno"),
|
||||||
|
("Porno1", BASE + "Porno1/%", "Porno"),
|
||||||
|
("MadelineIsWicked", BASE + "MadelineIsWicked/%", "Porno"),
|
||||||
|
("000 TORENT OBRAZKY",BASE + "000 TORENT OBRAZKY/%","Odpad-torrent"),
|
||||||
|
("Museum of Modern Art NY", BASE + "Museum of Modern Art NY/%", "Odpad-torrent"),
|
||||||
|
]
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for popis, like, kat in RULES:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p
|
||||||
|
SET wanted = FALSE, category = %s
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id = z.id
|
||||||
|
AND z.cesta_zalohy LIKE %s
|
||||||
|
AND p.category IS DISTINCT FROM %s
|
||||||
|
""", (kat, like, kat))
|
||||||
|
print(f" {popis:28s} -> {kat:14s}: {cur.rowcount:,}")
|
||||||
|
total += cur.rowcount
|
||||||
|
|
||||||
|
print(f"\nHotovo. Celkem: {total:,} řádků označeno jako odpad.")
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""
|
||||||
|
mark_colddata_zbytek.py — Zbytek #ColdData (vše dosud neroztříděné):
|
||||||
|
DropBox (software help obrázky), hudební alba, Salvador Dali, eBooky,
|
||||||
|
Truecrypt, Delikatesy ... -> odpad.
|
||||||
|
wanted=FALSE, category='Odpad-cache'
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p
|
||||||
|
SET wanted = FALSE, category = 'Odpad-cache'
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id = z.id
|
||||||
|
AND z.cesta_zalohy LIKE '/mnt/user/ZalohaVsechObrazku/Tower1/#ColdData/%'
|
||||||
|
AND p.category IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
print(f"#ColdData zbytek -> Odpad-cache: {cur.rowcount:,} řádků")
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""
|
||||||
|
mark_dedup_rodina.py — DedupPhotos (Samsung S7 Edge 2019, 285 ks).
|
||||||
|
Ověřeno: všechny unikátní, žádné duplikáty jinde -> Rodina.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p
|
||||||
|
SET wanted = TRUE, category = 'Rodina'
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id = z.id
|
||||||
|
AND z.cesta_zalohy LIKE %s
|
||||||
|
AND (p.wanted = FALSE OR p.category IS DISTINCT FROM 'Rodina')
|
||||||
|
""", ('/mnt/user/ZalohaVsechObrazku/Tower1/#ColdData/DedupPhotos/%',))
|
||||||
|
|
||||||
|
print(f"DedupPhotos -> Rodina: {cur.rowcount:,} řádků")
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
mark_ltbs_212.py — Potvrzeno uživatelem:
|
||||||
|
LTBS -> Rodina (rodinné fotky, názvy obsahují BUZALKA)
|
||||||
|
212 -> Odpad-dokumenty (skeny smluv Confidentiality Agreement)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
B = "/mnt/user/ZalohaVsechObrazku/Tower1/#ColdData/"
|
||||||
|
RULES = [
|
||||||
|
("LTBS", B + "LTBS/%", True, "Rodina"),
|
||||||
|
("212", B + "212/%", False, "Odpad-dokumenty"),
|
||||||
|
]
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||||||
|
cur = conn.cursor()
|
||||||
|
for popis, like, wanted, kat in RULES:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p SET wanted=%s, category=%s
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id=z.id AND z.cesta_zalohy LIKE %s
|
||||||
|
AND (p.wanted IS DISTINCT FROM %s OR p.category IS DISTINCT FROM %s)
|
||||||
|
""", (wanted, kat, like, wanted, kat))
|
||||||
|
print(f" {popis:6s} -> {kat:16s} (wanted={wanted}): {cur.rowcount:,}")
|
||||||
|
conn.close()
|
||||||
|
print("Hotovo.")
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""
|
||||||
|
mark_pomoc.py — Tower1/#ColdData/pomoc, potvrzeno uživatelem:
|
||||||
|
Samsung GT-S5230 (s EXIF) -> Rodina
|
||||||
|
porno snapshoty (bez EXIF) -> Porno
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
P = "/mnt/user/ZalohaVsechObrazku/Tower1/#ColdData/pomoc/%"
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Samsung (s EXIF) -> Rodina
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p SET wanted=TRUE, category='Rodina'
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id=z.id AND z.cesta_zalohy LIKE %s
|
||||||
|
AND p.camera_make IS NOT NULL
|
||||||
|
AND (p.wanted=FALSE OR p.category IS DISTINCT FROM 'Rodina')
|
||||||
|
""", (P,))
|
||||||
|
print(f" pomoc Samsung -> Rodina: {cur.rowcount:,}")
|
||||||
|
|
||||||
|
# porno (bez EXIF) -> Porno
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p SET wanted=FALSE, category='Porno'
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id=z.id AND z.cesta_zalohy LIKE %s
|
||||||
|
AND p.camera_make IS NULL
|
||||||
|
AND p.category IS DISTINCT FROM 'Porno'
|
||||||
|
""", (P,))
|
||||||
|
print(f" pomoc porno -> Porno : {cur.rowcount:,}")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
print("Hotovo.")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""
|
||||||
|
mark_qnap_exif_odpad.py — Qnap fotky s EXIF jsou CD inlety/obaly -> odpad.
|
||||||
|
Potvrzeno uživatelem přes náhled (81 ks).
|
||||||
|
wanted=FALSE, category='Odpad-obaly'
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p
|
||||||
|
SET wanted = FALSE, category = 'Odpad-obaly'
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id = z.id
|
||||||
|
AND z.cesta_zalohy LIKE %s
|
||||||
|
AND p.camera_make IS NOT NULL
|
||||||
|
AND p.category IS DISTINCT FROM 'Odpad-obaly'
|
||||||
|
""", ('/mnt/user/ZalohaVsechObrazku/Tower1/#ColdData/Qnap/%',))
|
||||||
|
|
||||||
|
print(f"Qnap (EXIF) -> Odpad-obaly: {cur.rowcount:,} řádků")
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""
|
||||||
|
mark_qnap_nepotrebne.py — Qnap_nepotrebne (COMPLETED TORRENTS: komiksy,
|
||||||
|
učebnice, software) -> odpad. Potvrzeno uživatelem.
|
||||||
|
wanted=FALSE, category='Odpad-torrent'
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p
|
||||||
|
SET wanted = FALSE, category = 'Odpad-torrent'
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id = z.id
|
||||||
|
AND z.cesta_zalohy LIKE %s
|
||||||
|
AND p.category IS DISTINCT FROM 'Odpad-torrent'
|
||||||
|
""", ('/mnt/user/ZalohaVsechObrazku/Tower1/#ColdData/Qnap_nepotrebne/%',))
|
||||||
|
|
||||||
|
print(f"Qnap_nepotrebne -> Odpad-torrent: {cur.rowcount:,} řádků")
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""
|
||||||
|
mark_qnap_zbytek_odpad.py — Zbytek Qnap bez EXIF (obaly alb, downloads,
|
||||||
|
kurzové thumbnaily) -> odpad. Potvrzeno uživatelem.
|
||||||
|
wanted=FALSE, category='Odpad-cache'
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p
|
||||||
|
SET wanted = FALSE, category = 'Odpad-cache'
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id = z.id
|
||||||
|
AND z.cesta_zalohy LIKE %s
|
||||||
|
AND p.camera_make IS NULL
|
||||||
|
AND p.category IS DISTINCT FROM 'Odpad-cache'
|
||||||
|
""", ('/mnt/user/ZalohaVsechObrazku/Tower1/#ColdData/Qnap/%',))
|
||||||
|
|
||||||
|
print(f"Qnap (zbytek bez EXIF) -> Odpad-cache: {cur.rowcount:,} řádků")
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
mark_synology_photo_rodina.py — Označí celý rodinný fotoarchiv
|
||||||
|
Tower1/#ColdData/SynologyMaly/photo/ jako Rodina.
|
||||||
|
|
||||||
|
Potvrzeno uživatelem přes náhled. Foťáky 1998-2015 (Canon S40/A40/IXUS,
|
||||||
|
Nikon D80, iPhony, Lumia...). 99 % má EXIF, archiv je unikátní (bez duplikátů).
|
||||||
|
|
||||||
|
wanted=TRUE, category='Rodina'
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p
|
||||||
|
SET wanted = TRUE, category = 'Rodina'
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id = z.id
|
||||||
|
AND z.cesta_zalohy LIKE %s
|
||||||
|
AND (p.wanted = FALSE OR p.category IS DISTINCT FROM 'Rodina')
|
||||||
|
""", ('/mnt/user/ZalohaVsechObrazku/Tower1/#ColdData/SynologyMaly/photo/%',))
|
||||||
|
|
||||||
|
print(f"SynologyMaly/photo -> Rodina: {cur.rowcount:,} řádků (wanted=TRUE)")
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
mark_synology_rodina.py — #Synology fotoarchivy potvrzené uživatelem -> Rodina:
|
||||||
|
#SERVER/Y/HD02#FOTKY (~30 789)
|
||||||
|
#SERVER/Y/HD02#FOTKY MAMKA (~1 167)
|
||||||
|
DropboxFotky (~14 882)
|
||||||
|
HD02#DVD OSOBNI (~5 834)
|
||||||
|
Fotky (~2 138)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
S = "/mnt/user/ZalohaVsechObrazku/Tower1/#Synology/Public/"
|
||||||
|
LIKES = [
|
||||||
|
("HD02#FOTKY", S + "#SERVER/Y/HD02#FOTKY/%"),
|
||||||
|
("HD02#FOTKY MAMKA", S + "#SERVER/Y/HD02#FOTKY MAMKA/%"),
|
||||||
|
("DropboxFotky", S + "DropboxFotky/%"),
|
||||||
|
("HD02#DVD OSOBNI", S + "HD02#DVD OSOBNI/%"),
|
||||||
|
("Fotky", S + "Fotky/%"),
|
||||||
|
]
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for popis, like in LIKES:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p SET wanted=TRUE, category='Rodina'
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id=z.id AND z.cesta_zalohy LIKE %s
|
||||||
|
AND (p.wanted=FALSE OR p.category IS DISTINCT FROM 'Rodina')
|
||||||
|
""", (like,))
|
||||||
|
print(f" {popis:18s}: {cur.rowcount:,}")
|
||||||
|
total += cur.rowcount
|
||||||
|
|
||||||
|
print(f"\nHotovo. Celkem nově/změněno na Rodina: {total:,}")
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
mark_synology_x_odpad.py — Označí Tower1/#ColdData/SynologyMaly/Public/X/
|
||||||
|
jako odpad (skenovaná série publikací e0xxx, 3744x5616, bez EXIF).
|
||||||
|
Potvrzeno uživatelem přes náhled.
|
||||||
|
|
||||||
|
wanted=FALSE, category='Odpad-sken'
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p
|
||||||
|
SET wanted = FALSE, category = 'Odpad-sken'
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id = z.id
|
||||||
|
AND z.cesta_zalohy LIKE %s
|
||||||
|
AND p.category IS DISTINCT FROM 'Odpad-sken'
|
||||||
|
""", ('/mnt/user/ZalohaVsechObrazku/Tower1/#ColdData/SynologyMaly/Public/X/%',))
|
||||||
|
|
||||||
|
print(f"SynologyMaly/Public/X -> Odpad-sken: {cur.rowcount:,} řádků")
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""
|
||||||
|
mark_synology_zbytek.py — Dokončí SynologyMaly:
|
||||||
|
Public/Dropbox -> Rodina (chceme, potvrzeno uživatelem)
|
||||||
|
music/web/video -> Odpad-cache (obaly, web obrázky)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
BASE = "/mnt/user/ZalohaVsechObrazku/Tower1/#ColdData/SynologyMaly/"
|
||||||
|
|
||||||
|
RULES = [
|
||||||
|
("Public/Dropbox", BASE + "Public/Dropbox/%", True, "Rodina"),
|
||||||
|
("music", BASE + "music/%", False, "Odpad-cache"),
|
||||||
|
("web", BASE + "web/%", False, "Odpad-cache"),
|
||||||
|
("video", BASE + "video/%", False, "Odpad-cache"),
|
||||||
|
]
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
for popis, like, wanted, kat in RULES:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p
|
||||||
|
SET wanted = %s, category = %s
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id = z.id
|
||||||
|
AND z.cesta_zalohy LIKE %s
|
||||||
|
AND (p.wanted IS DISTINCT FROM %s OR p.category IS DISTINCT FROM %s)
|
||||||
|
""", (wanted, kat, like, wanted, kat))
|
||||||
|
print(f" {popis:16s} -> {kat:12s} (wanted={wanted}): {cur.rowcount:,}")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
print("Hotovo.")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""
|
||||||
|
mark_tatinek_cast1.py — Tatinek U, rozhodnuté části (potvrzeno uživatelem):
|
||||||
|
Fractal (iPhone 13) -> Rodina (chceme)
|
||||||
|
DarthAnihilator + Screenshots-> Odpad-screenshot
|
||||||
|
Duplicates/Compass (dokumenty)-> Odpad-dokumenty
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
T = "/mnt/user/ZalohaVsechObrazku/Tower1/#ColdData/Tatinek U/"
|
||||||
|
|
||||||
|
RULES = [
|
||||||
|
("Fractal (iPhone)", T + "D/!!!Days/%Fractal%", True, "Rodina"),
|
||||||
|
("DarthAnihilator", T + "DarthAnihilator/%", False, "Odpad-screenshot"),
|
||||||
|
("Screenshots", T + "Screenshots/%", False, "Odpad-screenshot"),
|
||||||
|
("Compass dokumenty", T + "Duplicates/Compass/%",False, "Odpad-dokumenty"),
|
||||||
|
]
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
for popis, like, wanted, kat in RULES:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p
|
||||||
|
SET wanted = %s, category = %s
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id = z.id
|
||||||
|
AND z.cesta_zalohy LIKE %s
|
||||||
|
AND (p.wanted IS DISTINCT FROM %s OR p.category IS DISTINCT FROM %s)
|
||||||
|
""", (wanted, kat, like, wanted, kat))
|
||||||
|
print(f" {popis:20s} -> {kat:18s} (wanted={wanted}): {cur.rowcount:,}")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
print("Hotovo.")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
mark_tatinek_zbytek.py — Zbytek Tatinek U (produktové letáky, Sudoku,
|
||||||
|
screenshoty, web obrázky) -> odpad. Potvrzeno uživatelem.
|
||||||
|
Vše krom Fractal/DarthAnihilator/Screenshots/Compass -> Odpad-cache.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p
|
||||||
|
SET wanted = FALSE, category = 'Odpad-cache'
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id = z.id
|
||||||
|
AND z.cesta_zalohy LIKE '/mnt/user/ZalohaVsechObrazku/Tower1/#ColdData/Tatinek U/%'
|
||||||
|
AND z.cesta_zalohy NOT LIKE '%Fractal%'
|
||||||
|
AND z.cesta_zalohy NOT LIKE '%/DarthAnihilator/%'
|
||||||
|
AND z.cesta_zalohy NOT LIKE '%/Screenshots/%'
|
||||||
|
AND z.cesta_zalohy NOT LIKE '%/Duplicates/Compass/%'
|
||||||
|
AND p.category IS DISTINCT FROM 'Odpad-cache'
|
||||||
|
""")
|
||||||
|
|
||||||
|
print(f"Tatinek U (zbytek) -> Odpad-cache: {cur.rowcount:,} řádků")
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
mark_torrents_unwanted.py — Označí celé Tower/Torrents a Tower1/Torrents
|
||||||
|
jako nechceme (stažený torrent obsah: komiksy, vystřihovánky, speleologie,
|
||||||
|
porno, textury...). Žádné rodinné originály (ověřeno pixel-hashem + adresáři).
|
||||||
|
|
||||||
|
wanted=FALSE, category='Odpad-torrent'
|
||||||
|
|
||||||
|
Idempotentní + jedna session, autocommit (žádné paralelní běhy → bez deadlocku).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
DIRS = [
|
||||||
|
("Tower/Torrents", "/mnt/user/ZalohaVsechObrazku/Tower/Torrents%"),
|
||||||
|
("Tower1/Torrents", "/mnt/user/ZalohaVsechObrazku/Tower1/Torrents%"),
|
||||||
|
]
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for popis, like in DIRS:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p
|
||||||
|
SET wanted = FALSE,
|
||||||
|
category = 'Odpad-torrent'
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id = z.id
|
||||||
|
AND z.cesta_zalohy LIKE %s
|
||||||
|
AND p.category IS DISTINCT FROM 'Odpad-torrent'
|
||||||
|
""", (like,))
|
||||||
|
print(f" {popis}: {cur.rowcount:,} řádků")
|
||||||
|
total += cur.rowcount
|
||||||
|
|
||||||
|
print(f"\nHotovo. Celkem: {total:,} řádků → wanted=FALSE, category='Odpad-torrent'")
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
mark_webs.py — #Synology/.../#SERVER/Y/WEBS (starý web Buzalka.cz/.com):
|
||||||
|
galerijní alba (g1/Albums, g2data/albums) -> Rodina (potvrzeno uživatelem)
|
||||||
|
zbytek (web šablony, moduly, skiny, e-shop) -> Odpad-cache
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
WEBS = "/mnt/user/ZalohaVsechObrazku/Tower1/#Synology/Public/#SERVER/Y/WEBS/%"
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Galerijní alba -> Rodina
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p SET wanted=TRUE, category='Rodina'
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id=z.id
|
||||||
|
AND z.cesta_zalohy LIKE %s
|
||||||
|
AND (z.cesta_zalohy LIKE '%%/Albums/%%' OR z.cesta_zalohy LIKE '%%/g2data/albums/%%')
|
||||||
|
AND (p.wanted=FALSE OR p.category IS DISTINCT FROM 'Rodina')
|
||||||
|
""", (WEBS,))
|
||||||
|
print(f" WEBS galerie -> Rodina: {cur.rowcount:,}")
|
||||||
|
|
||||||
|
# Zbytek WEBS -> Odpad-cache
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p SET wanted=FALSE, category='Odpad-cache'
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id=z.id
|
||||||
|
AND z.cesta_zalohy LIKE %s
|
||||||
|
AND z.cesta_zalohy NOT LIKE '%%/Albums/%%'
|
||||||
|
AND z.cesta_zalohy NOT LIKE '%%/g2data/albums/%%'
|
||||||
|
AND p.category IS DISTINCT FROM 'Odpad-cache'
|
||||||
|
""", (WEBS,))
|
||||||
|
print(f" WEBS zbytek -> Odpad-cache: {cur.rowcount:,}")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
print("Hotovo.")
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""
|
||||||
|
mark_xperie_vladko.py — Osobní složky v #ColdData, chceme zachovat:
|
||||||
|
Honza fotky z Xperie -> kamarad Jan Luxemburk -> category 'Kamaradi'
|
||||||
|
VladkoSoubory -> syn Vladimir Buzalka ml -> category 'Rodina'
|
||||||
|
Obojí wanted=TRUE (potvrzeno uživatelem).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
BASE = "/mnt/user/ZalohaVsechObrazku/Tower1/#ColdData/"
|
||||||
|
|
||||||
|
RULES = [
|
||||||
|
("Honza fotky z Xperie", BASE + "Honza fotky z Xperie/%", "Kamaradi"),
|
||||||
|
("VladkoSoubory", BASE + "VladkoSoubory/%", "Rodina"),
|
||||||
|
]
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
for popis, like, kat in RULES:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p
|
||||||
|
SET wanted = TRUE, category = %s
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id = z.id
|
||||||
|
AND z.cesta_zalohy LIKE %s
|
||||||
|
AND (p.wanted = FALSE OR p.category IS DISTINCT FROM %s)
|
||||||
|
""", (kat, like, kat))
|
||||||
|
print(f" {popis:22s} -> {kat:10s}: {cur.rowcount:,}")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
print("Hotovo.")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""
|
||||||
|
migrate_add_wanted.py — přidá sloupec wanted BOOLEAN NOT NULL DEFAULT FALSE do tabulky photos.
|
||||||
|
Bezpečné spustit vícekrát (idempotentní).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
SQL = """
|
||||||
|
ALTER TABLE photos
|
||||||
|
ADD COLUMN IF NOT EXISTS wanted BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
"""
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.autocommit = True
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
print("Přidávám sloupec wanted…")
|
||||||
|
cur.execute(SQL)
|
||||||
|
print("Hotovo.")
|
||||||
|
|
||||||
|
cur.execute("SELECT COUNT(*) FROM photos WHERE wanted = FALSE")
|
||||||
|
n = cur.fetchone()[0]
|
||||||
|
print(f"Fotek s wanted=FALSE: {n:,}")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
migrate_category.py — přidá sloupec category do photos (idempotentní)
|
||||||
|
a provede konkrétní UPDATE podle pravidel.
|
||||||
|
"""
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.autocommit = True
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# 1. Přidat sloupec category pokud neexistuje
|
||||||
|
cur.execute("""
|
||||||
|
ALTER TABLE photos
|
||||||
|
ADD COLUMN IF NOT EXISTS category VARCHAR(100);
|
||||||
|
""")
|
||||||
|
print("Sloupec category OK.")
|
||||||
|
|
||||||
|
# 2. BolyMedia SG520 → wanted=TRUE, category='Fotopast'
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos
|
||||||
|
SET wanted = TRUE,
|
||||||
|
category = 'Fotopast'
|
||||||
|
WHERE camera_make = 'BolyMedia'
|
||||||
|
AND camera_model = 'SG520';
|
||||||
|
""")
|
||||||
|
print(f"SG520 aktualizováno: {cur.rowcount:,} řádků → wanted=TRUE, category='Fotopast'")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""
|
||||||
|
preview_sample.py — zobrazí náhled fotek podle ID z databáze.
|
||||||
|
|
||||||
|
Použití:
|
||||||
|
python preview_sample.py 101 202 303 ...
|
||||||
|
python preview_sample.py --file ids.txt # jedno ID na řádek
|
||||||
|
python preview_sample.py --file ids.txt --thumb 400
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import webbrowser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# ── DB ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
# ── Cesty ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
NAS_LINUX_PREFIX = "/mnt/user/ZalohaVsechObrazku/"
|
||||||
|
NAS_WIN_UNC = r"\\Tower1\ZalohaVsechObrazku\\"
|
||||||
|
|
||||||
|
|
||||||
|
def linux_to_win(p: str) -> Path:
|
||||||
|
rel = p.removeprefix(NAS_LINUX_PREFIX).replace("/", "\\")
|
||||||
|
return Path(NAS_WIN_UNC + rel)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Načtení dat z DB ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
QUERY = """
|
||||||
|
SELECT
|
||||||
|
z.id,
|
||||||
|
z.cesta_zalohy,
|
||||||
|
z.velikost,
|
||||||
|
p.taken_at,
|
||||||
|
p.taken_at_source,
|
||||||
|
p.camera_make,
|
||||||
|
p.camera_model,
|
||||||
|
p.width,
|
||||||
|
p.height,
|
||||||
|
p.megapixels,
|
||||||
|
p.gps_lat,
|
||||||
|
p.gps_lon,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(e.error_code, ', ')
|
||||||
|
FROM photo_errors e WHERE e.photo_id = p.id),
|
||||||
|
''
|
||||||
|
) AS chyby
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
JOIN photos p ON p.zaloha_id = z.id
|
||||||
|
WHERE z.id = ANY(%s)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_rows(ids: list[int]) -> list:
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(QUERY, (ids,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
conn.close()
|
||||||
|
# zachovej původní pořadí ID
|
||||||
|
order = {rid: i for i, rid in enumerate(ids)}
|
||||||
|
return sorted(rows, key=lambda r: order.get(r[0], 9999))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Thumbnail ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def make_thumb_b64(win_path: Path, size: int) -> str | None:
|
||||||
|
try:
|
||||||
|
img = Image.open(win_path)
|
||||||
|
img.thumbnail((size, size), Image.LANCZOS)
|
||||||
|
if img.mode not in ("RGB", "L"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="JPEG", quality=82)
|
||||||
|
return base64.b64encode(buf.getvalue()).decode()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTML ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def format_size(n) -> str:
|
||||||
|
if n is None:
|
||||||
|
return "?"
|
||||||
|
for unit in ("B", "kB", "MB", "GB"):
|
||||||
|
if n < 1024:
|
||||||
|
return f"{n:.0f} {unit}"
|
||||||
|
n /= 1024
|
||||||
|
return f"{n:.1f} GB"
|
||||||
|
|
||||||
|
|
||||||
|
def render_html(rows: list, ids: list[int], thumb_size: int) -> str:
|
||||||
|
cards = []
|
||||||
|
ok = 0
|
||||||
|
|
||||||
|
for i, row in enumerate(rows, 1):
|
||||||
|
(rid, cesta, velikost, taken_at, taken_src,
|
||||||
|
make_, model, width, height, mp, lat, lon, chyby) = row
|
||||||
|
|
||||||
|
win_path = linux_to_win(cesta)
|
||||||
|
b64 = make_thumb_b64(win_path, thumb_size)
|
||||||
|
|
||||||
|
if b64:
|
||||||
|
ok += 1
|
||||||
|
img_tag = f'<img src="data:image/jpeg;base64,{b64}" alt="foto">'
|
||||||
|
card_cls = "card"
|
||||||
|
else:
|
||||||
|
img_tag = '<div class="no-img">⚠ soubor nedostupný<br><small>zkontroluj přístup na Tower1</small></div>'
|
||||||
|
card_cls = "card broken"
|
||||||
|
|
||||||
|
camera = " ".join(filter(None, [make_, model])) or "<em>neznámá</em>"
|
||||||
|
date_str = taken_at.strftime("%Y-%m-%d %H:%M") if taken_at else "?"
|
||||||
|
res = f"{width}×{height}" if width and height else "?"
|
||||||
|
mp_str = f"{mp:.1f} Mpx" if mp else ""
|
||||||
|
gps_link = (
|
||||||
|
f'<a href="https://maps.google.com/?q={lat},{lon}" target="_blank">📍 {lat:.4f}, {lon:.4f}</a>'
|
||||||
|
if lat and lon else ""
|
||||||
|
)
|
||||||
|
chyby_html = f'<div class="err">{chyby}</div>' if chyby else ""
|
||||||
|
path_short = str(win_path).replace(NAS_WIN_UNC, "…\\")
|
||||||
|
|
||||||
|
cards.append(f"""
|
||||||
|
<div class="{card_cls}">
|
||||||
|
<div class="thumb">{img_tag}</div>
|
||||||
|
<div class="meta">
|
||||||
|
<div class="idx">id={rid} #{i}</div>
|
||||||
|
<div><strong>{date_str}</strong> <span class="src">({taken_src})</span></div>
|
||||||
|
<div>📷 {camera}</div>
|
||||||
|
<div>{res} {mp_str} {format_size(velikost)}</div>
|
||||||
|
{"<div>" + gps_link + "</div>" if gps_link else ""}
|
||||||
|
{chyby_html}
|
||||||
|
<div class="path" title="{win_path}">{path_short}</div>
|
||||||
|
</div>
|
||||||
|
</div>""")
|
||||||
|
|
||||||
|
missing_ids = set(ids) - {r[0] for r in rows}
|
||||||
|
missing_note = ""
|
||||||
|
if missing_ids:
|
||||||
|
missing_note = f'<div class="warn">ID nenalezena v DB: {sorted(missing_ids)}</div>'
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="cs">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Preview fotek ({len(rows)})</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: sans-serif; background: #1a1a2e; color: #eee; margin: 0; padding: 16px; }}
|
||||||
|
h1 {{ color: #e0c97f; margin-bottom: 4px; }}
|
||||||
|
.info {{ color: #aaa; margin-bottom: 16px; font-size: .9em; }}
|
||||||
|
.warn {{ color: #f99; margin-bottom: 12px; }}
|
||||||
|
.grid {{ display: flex; flex-wrap: wrap; gap: 16px; }}
|
||||||
|
.card {{ background: #16213e; border-radius: 8px; overflow: hidden;
|
||||||
|
width: {thumb_size + 24}px; box-shadow: 0 2px 8px #0006; }}
|
||||||
|
.card.broken {{ opacity: .55; }}
|
||||||
|
.thumb {{ display:flex; align-items:center; justify-content:center;
|
||||||
|
background:#0f3460; min-height: 120px; }}
|
||||||
|
.thumb img {{ max-width:100%; max-height:{thumb_size}px; display:block; }}
|
||||||
|
.no-img {{ color:#f88; padding:20px; text-align:center; font-size:.85em; }}
|
||||||
|
.meta {{ padding: 8px 12px; font-size: .78em; line-height: 1.75; }}
|
||||||
|
.meta strong {{ font-size: 1em; }}
|
||||||
|
.idx {{ float:right; color:#666; font-size:.75em; }}
|
||||||
|
.src {{ color:#888; }}
|
||||||
|
.err {{ color:#f99; }}
|
||||||
|
.path {{ color:#555; word-break:break-all; margin-top:4px; font-size:.7em; }}
|
||||||
|
a {{ color:#7ec8e3; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Preview fotek</h1>
|
||||||
|
<div class="info">
|
||||||
|
Celkem ID: <strong>{len(ids)}</strong> ·
|
||||||
|
Načteno z DB: <strong>{len(rows)}</strong> ·
|
||||||
|
Obrázky dostupné: <strong>{ok}/{len(rows)}</strong>
|
||||||
|
</div>
|
||||||
|
{missing_note}
|
||||||
|
<div class="grid">
|
||||||
|
{"".join(cards)}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Preview fotek podle ID z DB")
|
||||||
|
parser.add_argument("ids", nargs="*", type=int, help="ID fotek (zaloha_obrazku.id)")
|
||||||
|
parser.add_argument("--file", "-f", help="Soubor s ID (jedno na řádek)")
|
||||||
|
parser.add_argument("--thumb", "-t", type=int, default=320, help="Velikost thumbnailů v px (default 320)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
ids = list(args.ids)
|
||||||
|
|
||||||
|
if args.file:
|
||||||
|
with open(args.file) as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if line and line.isdigit():
|
||||||
|
ids.append(int(line))
|
||||||
|
|
||||||
|
if not ids:
|
||||||
|
print("Žádná ID. Zadej je jako argumenty nebo přes --file.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Načítám {len(ids)} fotek z DB…")
|
||||||
|
rows = fetch_rows(ids)
|
||||||
|
print(f"Generuji thumbnaily (--thumb {args.thumb}px)…")
|
||||||
|
html = render_html(rows, ids, args.thumb)
|
||||||
|
|
||||||
|
tmp = tempfile.NamedTemporaryFile(
|
||||||
|
suffix=".html", delete=False, mode="w", encoding="utf-8"
|
||||||
|
)
|
||||||
|
tmp.write(html)
|
||||||
|
tmp.close()
|
||||||
|
print(f"Otevírám: {tmp.name}")
|
||||||
|
webbrowser.open(f"file:///{tmp.name}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
preview_server.py — HTTP server pro náhled fotek z DB.
|
||||||
|
Spustit v Docker kontejneru python-runner na Tower.
|
||||||
|
|
||||||
|
Spuštění:
|
||||||
|
docker exec -d python-runner python /scripts/preview_server.py
|
||||||
|
|
||||||
|
API:
|
||||||
|
GET /preview?ids=101,202,303
|
||||||
|
GET /preview?ids=101,202,303&thumb=400
|
||||||
|
GET /health
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import socketserver
|
||||||
|
import urllib.parse
|
||||||
|
from http.server import BaseHTTPRequestHandler
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# ── Konfigurace ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
PORT = 8765
|
||||||
|
|
||||||
|
DB = dict(
|
||||||
|
host="192.168.1.76", port=5432,
|
||||||
|
user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++",
|
||||||
|
dbname="fotky_buzalkovi"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prefix v DB (jak to zapsal collect_pictures.py na Tower1)
|
||||||
|
NAS_LINUX_PREFIX = "/mnt/user/ZalohaVsechObrazku/"
|
||||||
|
# Cesta uvnitř kontejneru (NFS mount přidaný v Unraid Docker UI)
|
||||||
|
NAS_CONTAINER_PATH = "/mnt/ZalohaVsechObrazku/"
|
||||||
|
|
||||||
|
# ── DB dotaz ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
QUERY = """
|
||||||
|
SELECT
|
||||||
|
z.id,
|
||||||
|
z.cesta_zalohy,
|
||||||
|
z.velikost,
|
||||||
|
p.taken_at,
|
||||||
|
p.taken_at_source,
|
||||||
|
p.camera_make,
|
||||||
|
p.camera_model,
|
||||||
|
p.width,
|
||||||
|
p.height,
|
||||||
|
p.megapixels,
|
||||||
|
p.gps_lat,
|
||||||
|
p.gps_lon,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(e.error_code, ', ')
|
||||||
|
FROM photo_errors e WHERE e.photo_id = p.id),
|
||||||
|
''
|
||||||
|
) AS chyby
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
JOIN photos p ON p.zaloha_id = z.id
|
||||||
|
WHERE z.id = ANY(%s)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def linux_to_container(p: str) -> str:
|
||||||
|
rel = p.removeprefix(NAS_LINUX_PREFIX)
|
||||||
|
return NAS_CONTAINER_PATH + rel
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_rows(ids: list[int]) -> list:
|
||||||
|
conn = psycopg.connect(**DB)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(QUERY, (ids,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
conn.close()
|
||||||
|
order = {rid: i for i, rid in enumerate(ids)}
|
||||||
|
return sorted(rows, key=lambda r: order.get(r[0], 9999))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Thumbnaily ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def make_thumb_b64(path: str, size: int) -> str | None:
|
||||||
|
try:
|
||||||
|
img = Image.open(path)
|
||||||
|
img.thumbnail((size, size), Image.LANCZOS)
|
||||||
|
if img.mode not in ("RGB", "L"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="JPEG", quality=82)
|
||||||
|
return base64.b64encode(buf.getvalue()).decode()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTML ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def format_size(n) -> str:
|
||||||
|
if n is None:
|
||||||
|
return "?"
|
||||||
|
for unit in ("B", "kB", "MB", "GB"):
|
||||||
|
if n < 1024:
|
||||||
|
return f"{n:.0f} {unit}"
|
||||||
|
n /= 1024
|
||||||
|
return f"{n:.1f} GB"
|
||||||
|
|
||||||
|
|
||||||
|
def render_html(rows: list, ids: list[int], thumb_size: int) -> str:
|
||||||
|
cards = []
|
||||||
|
ok = 0
|
||||||
|
|
||||||
|
for i, row in enumerate(rows, 1):
|
||||||
|
(rid, cesta, velikost, taken_at, taken_src,
|
||||||
|
make_, model, width, height, mp, lat, lon, chyby) = row
|
||||||
|
|
||||||
|
container_path = linux_to_container(cesta)
|
||||||
|
b64 = make_thumb_b64(container_path, thumb_size)
|
||||||
|
|
||||||
|
if b64:
|
||||||
|
ok += 1
|
||||||
|
img_tag = f'<img src="data:image/jpeg;base64,{b64}" alt="foto" data-id="{rid}" onclick="openLightbox(this)">'
|
||||||
|
card_cls = "card"
|
||||||
|
else:
|
||||||
|
img_tag = (
|
||||||
|
'<div class="no-img">⚠ soubor nedostupný</div>'
|
||||||
|
)
|
||||||
|
card_cls = "card broken"
|
||||||
|
|
||||||
|
camera = " ".join(filter(None, [make_, model])) or "<em>neznámá</em>"
|
||||||
|
date_str = taken_at.strftime("%Y-%m-%d %H:%M") if taken_at else "?"
|
||||||
|
res = f"{width}×{height}" if width and height else "?"
|
||||||
|
mp_str = f"{mp:.1f} Mpx" if mp else ""
|
||||||
|
gps_link = (
|
||||||
|
f'<a href="https://maps.google.com/?q={lat},{lon}" target="_blank">'
|
||||||
|
f'📍 {lat:.4f}, {lon:.4f}</a>'
|
||||||
|
if lat and lon else ""
|
||||||
|
)
|
||||||
|
chyby_html = f'<div class="err">{chyby}</div>' if chyby else ""
|
||||||
|
|
||||||
|
cards.append(f"""
|
||||||
|
<div class="{card_cls}">
|
||||||
|
<div class="thumb">{img_tag}</div>
|
||||||
|
<div class="meta">
|
||||||
|
<div class="idx">id={rid} #{i}</div>
|
||||||
|
<div><strong>{date_str}</strong> <span class="src">({taken_src})</span></div>
|
||||||
|
<div>📷 {camera}</div>
|
||||||
|
<div>{res} {mp_str} {format_size(velikost)}</div>
|
||||||
|
{"<div>" + gps_link + "</div>" if gps_link else ""}
|
||||||
|
{chyby_html}
|
||||||
|
<div class="path" title="{container_path}">{container_path}</div>
|
||||||
|
</div>
|
||||||
|
</div>""")
|
||||||
|
|
||||||
|
missing_ids = set(ids) - {r[0] for r in rows}
|
||||||
|
missing_note = ""
|
||||||
|
if missing_ids:
|
||||||
|
missing_note = f'<div class="warn">ID nenalezena v DB: {sorted(missing_ids)}</div>'
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="cs">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Preview fotek ({len(rows)})</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: sans-serif; background: #1a1a2e; color: #eee; margin: 0; padding: 16px; }}
|
||||||
|
h1 {{ color: #e0c97f; margin-bottom: 4px; }}
|
||||||
|
.info {{ color: #aaa; margin-bottom: 16px; font-size: .9em; }}
|
||||||
|
.warn {{ color: #f99; margin-bottom: 12px; }}
|
||||||
|
.grid {{ display: flex; flex-wrap: wrap; gap: 16px; }}
|
||||||
|
.card {{ background: #16213e; border-radius: 8px; overflow: hidden;
|
||||||
|
width: {thumb_size + 24}px; box-shadow: 0 2px 8px #0006; }}
|
||||||
|
.card.broken {{ opacity: .55; }}
|
||||||
|
.thumb {{ display:flex; align-items:center; justify-content:center;
|
||||||
|
background:#0f3460; min-height: 120px; }}
|
||||||
|
.thumb img {{ max-width:100%; max-height:{thumb_size}px; display:block; }}
|
||||||
|
.no-img {{ color:#f88; padding:20px; text-align:center; font-size:.85em; }}
|
||||||
|
.meta {{ padding: 8px 12px; font-size: .78em; line-height: 1.75; }}
|
||||||
|
.meta strong {{ font-size: 1em; }}
|
||||||
|
.idx {{ float:right; color:#666; font-size:.75em; }}
|
||||||
|
.src {{ color:#888; }}
|
||||||
|
.err {{ color:#f99; }}
|
||||||
|
.path {{ color:#555; word-break:break-all; margin-top:4px; font-size:.7em; }}
|
||||||
|
a {{ color:#7ec8e3; }}
|
||||||
|
.thumb img {{ cursor: zoom-in; }}
|
||||||
|
/* Lightbox */
|
||||||
|
#lightbox {{
|
||||||
|
display: none; position: fixed; inset: 0;
|
||||||
|
background: #000c; z-index: 1000;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
}}
|
||||||
|
#lightbox.active {{ display: flex; }}
|
||||||
|
#lightbox img {{
|
||||||
|
max-width: 95vw; max-height: 95vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 0 40px #000a;
|
||||||
|
cursor: zoom-out;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="lightbox" onclick="closeLightbox()">
|
||||||
|
<img id="lightbox-img" src="" alt="" onclick="event.stopPropagation()">
|
||||||
|
<div id="lightbox-spin" style="display:none;color:#fff;font-size:2em;">⏳</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function openLightbox(thumb) {{
|
||||||
|
var lb = document.getElementById('lightbox');
|
||||||
|
var lbImg = document.getElementById('lightbox-img');
|
||||||
|
var spin = document.getElementById('lightbox-spin');
|
||||||
|
var id = thumb.dataset.id;
|
||||||
|
lbImg.src = '';
|
||||||
|
lbImg.style.display = 'none';
|
||||||
|
spin.style.display = 'block';
|
||||||
|
lb.classList.add('active');
|
||||||
|
var full = new Image();
|
||||||
|
full.onload = function() {{
|
||||||
|
lbImg.src = full.src;
|
||||||
|
lbImg.style.display = 'block';
|
||||||
|
spin.style.display = 'none';
|
||||||
|
}};
|
||||||
|
full.src = '/image?id=' + id;
|
||||||
|
}}
|
||||||
|
function closeLightbox() {{
|
||||||
|
document.getElementById('lightbox').classList.remove('active');
|
||||||
|
document.getElementById('lightbox-img').src = '';
|
||||||
|
}}
|
||||||
|
document.addEventListener('keydown', e => {{ if (e.key === 'Escape') closeLightbox(); }});
|
||||||
|
</script>
|
||||||
|
<h1>Preview fotek</h1>
|
||||||
|
<div class="info">
|
||||||
|
Celkem ID: <strong>{len(ids)}</strong> ·
|
||||||
|
Načteno z DB: <strong>{len(rows)}</strong> ·
|
||||||
|
Obrázky dostupné: <strong>{ok}/{len(rows)}</strong>
|
||||||
|
</div>
|
||||||
|
{missing_note}
|
||||||
|
<div class="grid">
|
||||||
|
{"".join(cards)}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTTP handler ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
print(f"[{self.address_string()}] {format % args}", flush=True)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
|
||||||
|
# ── /health ──────────────────────────────────────────────────────────
|
||||||
|
if parsed.path == "/health":
|
||||||
|
self._text(200, "OK")
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── /image ───────────────────────────────────────────────────────────
|
||||||
|
if parsed.path == "/image":
|
||||||
|
params = urllib.parse.parse_qs(parsed.query)
|
||||||
|
try:
|
||||||
|
zid = int(params.get("id", [""])[0])
|
||||||
|
except ValueError:
|
||||||
|
self._text(400, "Chybí id")
|
||||||
|
return
|
||||||
|
conn = psycopg.connect(**DB)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT z.cesta_zalohy FROM zaloha_obrazku z JOIN photos p ON p.zaloha_id = z.id WHERE z.id = %s",
|
||||||
|
(zid,)
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
self._text(404, "ID nenalezeno")
|
||||||
|
return
|
||||||
|
path = linux_to_container(row[0])
|
||||||
|
try:
|
||||||
|
data = open(path, "rb").read()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "image/jpeg")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
except Exception as ex:
|
||||||
|
self._text(404, f"Soubor nedostupný: {ex}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── /preview ─────────────────────────────────────────────────────────
|
||||||
|
if parsed.path != "/preview":
|
||||||
|
self._text(404, "Not found. Použij /preview?ids=101,202,303")
|
||||||
|
return
|
||||||
|
|
||||||
|
params = urllib.parse.parse_qs(parsed.query)
|
||||||
|
ids_raw = params.get("ids", [""])[0]
|
||||||
|
thumb = int(params.get("thumb", ["320"])[0])
|
||||||
|
|
||||||
|
try:
|
||||||
|
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
|
||||||
|
except ValueError:
|
||||||
|
self._text(400, "Chybné IDs — očekávaný formát: ?ids=101,202,303")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not ids:
|
||||||
|
self._text(400, "Chybí IDs — např. /preview?ids=101,202,303")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f" → {len(ids)} IDs, thumb={thumb}px", flush=True)
|
||||||
|
rows = fetch_rows(ids)
|
||||||
|
html = render_html(rows, ids, thumb)
|
||||||
|
|
||||||
|
encoded = html.encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(encoded)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(encoded)
|
||||||
|
|
||||||
|
def _text(self, code: int, msg: str):
|
||||||
|
body = msg.encode("utf-8")
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(f"Preview server běží na http://0.0.0.0:{PORT}", flush=True)
|
||||||
|
print(f"Test: http://tower:{PORT}/health", flush=True)
|
||||||
|
print(f"Použití: http://tower:{PORT}/preview?ids=101,202,303&thumb=400", flush=True)
|
||||||
|
socketserver.TCPServer.allow_reuse_address = True
|
||||||
|
with socketserver.TCPServer(("", PORT), Handler) as httpd:
|
||||||
|
httpd.serve_forever()
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""
|
||||||
|
restore_maminka.py — Vrátí jedinečné iPhone fotky z
|
||||||
|
Tower/appdata/photoprism/sidecar/Maminka zpět na wanted=TRUE, category='Rodina'.
|
||||||
|
|
||||||
|
Tyto soubory jsem omylem označil jako Odpad-cache, ačkoli jde o jediné kopie
|
||||||
|
rodinných fotek v záloze (žádný originál jinde neexistuje).
|
||||||
|
|
||||||
|
Filtr camera_make IS NOT NULL = bereme jen skutečné fotky, ne případné
|
||||||
|
technické soubory v té složce.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE photos p
|
||||||
|
SET wanted = TRUE,
|
||||||
|
category = 'Rodina'
|
||||||
|
FROM zaloha_obrazku z
|
||||||
|
WHERE p.zaloha_id = z.id
|
||||||
|
AND z.cesta_zalohy LIKE %s
|
||||||
|
AND p.camera_make IS NOT NULL
|
||||||
|
""", ('/mnt/user/ZalohaVsechObrazku/Tower/appdata/photoprism/sidecar/Maminka%',))
|
||||||
|
|
||||||
|
print(f"Vráceno na Rodina: {cur.rowcount:,} řádků (wanted=TRUE, category='Rodina')")
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""
|
||||||
|
update_wanted.py — označuje fotky wanted=TRUE a nastavuje category.
|
||||||
|
Spusť a uprav seznam pravidel podle potřeby.
|
||||||
|
"""
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.autocommit = True
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
rules = [
|
||||||
|
# (popis, SQL WHERE, category)
|
||||||
|
# ── Samsung ──────────────────────────────────────────────────────────────
|
||||||
|
(
|
||||||
|
"Samsung SM-G935F / A520F / A525F / J500FN / I8190N / S7560 / S5230",
|
||||||
|
"camera_make IN ('samsung', 'SAMSUNG', 'Samsung') AND camera_model IN "
|
||||||
|
"('SM-G935F','SM-A520F','SM-A525F','SM-J500FN','GT-I8190N','GT-S7560','GT-S5230')",
|
||||||
|
"Rodina",
|
||||||
|
),
|
||||||
|
# ── Profesionální / cizí ─────────────────────────────────────────────────
|
||||||
|
(
|
||||||
|
"Nikon D5 / D4 / D700 / D300 / D800 / D500",
|
||||||
|
"camera_make = 'NIKON CORPORATION' AND camera_model IN "
|
||||||
|
"('NIKON D5','NIKON D4','NIKON D700','NIKON D300','NIKON D800','NIKON D500')",
|
||||||
|
"Rodina",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Canon EOS-1D X III / 450D / 600D / R6 / 5D IV / 5D / 40D / 20D",
|
||||||
|
"camera_make = 'Canon' AND camera_model IN "
|
||||||
|
"('Canon EOS-1D X Mark III','Canon EOS 450D','Canon EOS 600D','Canon EOS R6',"
|
||||||
|
"'Canon EOS 5D Mark IV','Canon EOS 5D','Canon EOS 40D','Canon EOS 20D')",
|
||||||
|
"Rodina",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Sony ILCE-1 (Alpha 1)",
|
||||||
|
"camera_make = 'SONY' AND camera_model = 'ILCE-1'",
|
||||||
|
"Rodina",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Panasonic DC-S5",
|
||||||
|
"camera_make = 'Panasonic' AND camera_model = 'DC-S5'",
|
||||||
|
"Rodina",
|
||||||
|
),
|
||||||
|
# ── Ostatní kompakty ─────────────────────────────────────────────────────
|
||||||
|
(
|
||||||
|
"Panasonic DMC-FZ5",
|
||||||
|
"camera_make = 'Panasonic' AND camera_model = 'DMC-FZ5'",
|
||||||
|
"Rodina",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Canon PowerShot S40",
|
||||||
|
"camera_make = 'Canon' AND camera_model = 'Canon PowerShot S40'",
|
||||||
|
"Rodina",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Canon PowerShot A40",
|
||||||
|
"camera_make = 'Canon' AND camera_model = 'Canon PowerShot A40'",
|
||||||
|
"Rodina",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Panasonic DMC-FX33",
|
||||||
|
"camera_make = 'Panasonic' AND camera_model = 'DMC-FX33'",
|
||||||
|
"Rodina",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"NIKON D80",
|
||||||
|
"camera_make = 'NIKON CORPORATION' AND camera_model = 'NIKON D80'",
|
||||||
|
"Rodina",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"GoPro HERO6 Black",
|
||||||
|
"camera_make = 'GoPro' AND camera_model = 'HERO6 Black'",
|
||||||
|
"Rodina",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Microsoft Lumia 930",
|
||||||
|
"camera_make = 'Microsoft' AND camera_model = 'Lumia 930'",
|
||||||
|
"Rodina",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Panasonic DMC-FX3",
|
||||||
|
"camera_make = 'Panasonic' AND camera_model = 'DMC-FX3'",
|
||||||
|
"Rodina",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Sony DSC-T77",
|
||||||
|
"camera_make = 'SONY' AND camera_model = 'DSC-T77'",
|
||||||
|
"Rodina",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Olympus C120/D380",
|
||||||
|
"camera_make = 'OLYMPUS OPTICAL CO.,LTD' AND camera_model = 'C120,D380'",
|
||||||
|
"Rodina",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Sony DSC-WX50",
|
||||||
|
"camera_make = 'SONY' AND camera_model = 'DSC-WX50'",
|
||||||
|
"Rodina",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Sony CYBERSHOT",
|
||||||
|
"camera_make = 'SONY' AND camera_model = 'CYBERSHOT'",
|
||||||
|
"Rodina",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Casio EX-Z6",
|
||||||
|
"camera_make = 'CASIO COMPUTER CO.,LTD.' AND camera_model = 'EX-Z6'",
|
||||||
|
"Rodina",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Olympus C765UZ",
|
||||||
|
"camera_make = 'OLYMPUS CORPORATION' AND camera_model = 'C765UZ'",
|
||||||
|
"Rodina",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Nikon E990",
|
||||||
|
"camera_make = 'NIKON' AND camera_model = 'E990'",
|
||||||
|
"Rodina",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
for popis, where, category in rules:
|
||||||
|
cur.execute(f"""
|
||||||
|
UPDATE photos
|
||||||
|
SET wanted = TRUE,
|
||||||
|
category = %s
|
||||||
|
WHERE {where}
|
||||||
|
AND (wanted = FALSE OR category IS DISTINCT FROM %s)
|
||||||
|
""", (category, category))
|
||||||
|
print(f"{popis}: {cur.rowcount:,} řádků → wanted=TRUE, category='{category}'")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
print("Hotovo.")
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""update_wanted_batch2.py — označí další vlnu foťáků wanted=TRUE, category=Rodina"""
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
DB = dict(host="192.168.1.76", port=5432, user="vladimir.buzalka",
|
||||||
|
password="Vlado7309208104++", database="fotky_buzalkovi")
|
||||||
|
|
||||||
|
# (make, model)
|
||||||
|
CAMERAS = [
|
||||||
|
("Canon", "Canon DIGITAL IXUS 30"),
|
||||||
|
("Xiaomi", "2201117TY"),
|
||||||
|
("SONY", "DSC-H70"),
|
||||||
|
("Panasonic", "DMC-FZ20"),
|
||||||
|
("Panasonic", "DMC-FZ50"),
|
||||||
|
("Panasonic", "NV-GS180"),
|
||||||
|
("EASTMAN KODAK COMPANY", "KODAK C875 ZOOM DIGITAL CAMERA"),
|
||||||
|
("SONY", "D2005"),
|
||||||
|
("OLYMPUS IMAGING CORP.", "XZ-10"),
|
||||||
|
("OLYMPUS CORPORATION", "C460ZdelSol"),
|
||||||
|
("Canon", "Canon EOS DIGITAL REBEL"),
|
||||||
|
("HTC", "HTC HD2 T8585"),
|
||||||
|
("OLYMPUS OPTICAL CO.,LTD", "C4040Z"),
|
||||||
|
("NIKON", "E950"),
|
||||||
|
("NIKON", "E5400"),
|
||||||
|
("Canon", "Canon PowerShot S300"),
|
||||||
|
("Emgeton", "Flexaret Mini"),
|
||||||
|
("SONY", "DCR-PC115E"),
|
||||||
|
("Canon", "Canon PowerShot A720 IS"),
|
||||||
|
("SONY", "HDR-TG3E"),
|
||||||
|
("Canon", "Canon PowerShot A75"),
|
||||||
|
("OLYMPUS OPTICAL CO.,LTD", "C3030Z"),
|
||||||
|
("FUJIFILM", "FinePix F20"),
|
||||||
|
("vivo", "vivo X100 Pro"),
|
||||||
|
("NIKON", "E3200"),
|
||||||
|
("Canon", "Canon PowerShot S3 IS"),
|
||||||
|
("Canon", "Canon PowerShot G3"),
|
||||||
|
("OLYMPUS OPTICAL CO.,LTD", "X-2,C-50Z"),
|
||||||
|
("Canon", "Canon PowerShot S2 IS"),
|
||||||
|
("Canon", "Canon PowerShot S500"),
|
||||||
|
("Canon", "Canon DIGITAL IXUS 400"),
|
||||||
|
("OLYMPUS OPTICAL CO.,LTD", "C5050Z"),
|
||||||
|
]
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB)
|
||||||
|
conn.autocommit = True
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
placeholders = ",".join(["(%s,%s)"] * len(CAMERAS))
|
||||||
|
params = [v for pair in CAMERAS for v in pair]
|
||||||
|
cur.execute(f"""
|
||||||
|
UPDATE photos
|
||||||
|
SET wanted = TRUE,
|
||||||
|
category = 'Rodina'
|
||||||
|
WHERE (camera_make, camera_model) IN ({placeholders})
|
||||||
|
AND wanted = FALSE
|
||||||
|
""", params)
|
||||||
|
|
||||||
|
print(f"Označeno: {cur.rowcount:,} řádků → wanted=TRUE, category='Rodina'")
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# FotkyBuzalkovi — pracovní deník
|
||||||
|
|
||||||
|
> Živý dokument. Zapisujeme sem co jsme zjistili, co jsme rozhodli a co je na řadě.
|
||||||
|
> Technická reference → `CONTEXT.md`, návrh architektury → `NAVRH.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 2026-06-04
|
||||||
|
|
||||||
|
### Stav DB na začátku
|
||||||
|
|
||||||
|
| Tabulka | Řádky |
|
||||||
|
|---------|-------|
|
||||||
|
| `zaloha_obrazku` | 1 717 182 |
|
||||||
|
| `zdrojove_soubory` | 3 573 846 |
|
||||||
|
| `photos` | 1 717 175 |
|
||||||
|
| `photo_errors` | 3 185 319 |
|
||||||
|
|
||||||
|
Všechny fotky mají `processing_status = 'pending'` — pipeline doběhla, další zpracování nezačalo.
|
||||||
|
|
||||||
|
### Co jsme zjistili
|
||||||
|
|
||||||
|
**Problém 1 — V záloze je spousta odpadu, ne jen rodinné fotky.**
|
||||||
|
Pipeline sebrala vše co má příponu `.jpg/.jpeg` — včetně:
|
||||||
|
- PhotoPrism cache thumbnailů (`appdata/photoprism/cache/`) — 229 521 ks, <10 kB
|
||||||
|
- Plex / Immich cache
|
||||||
|
- MP3 a LP obaly
|
||||||
|
- DVD obaly, eBook obálky
|
||||||
|
- ABC vystřihovánky (skenované na 1200 DPI → soubory 60–100 MB)
|
||||||
|
- Reprodukce obrazů z torrentů (Raffael, Rembrandt... v muzejní kvalitě)
|
||||||
|
- Stažené obrázky z webu (Dropbox/!!!Days/Stefajir/...)
|
||||||
|
- Windows AppData (Kindle covers, .NET watermark...)
|
||||||
|
|
||||||
|
**Problém 2 — Rok pořízení je hodně porušený.**
|
||||||
|
- Rok 2024 má 985 754 fotek (>57 % všech) — zřejmě chybný fallback na mtime místo EXIF
|
||||||
|
- Rok 1863, 2031–4501 — garbage v EXIF
|
||||||
|
- Rok 2026 má 93 492 — suspektní
|
||||||
|
|
||||||
|
**Sloupec `wanted`:**
|
||||||
|
Přidán `photos.wanted BOOLEAN NOT NULL DEFAULT FALSE` — všech 1 717 175 fotek má FALSE.
|
||||||
|
Účel: budeme označovat fotky které chceme zachovat / zpracovat.
|
||||||
|
|
||||||
|
### Nástroje
|
||||||
|
|
||||||
|
- `00 PictureCollector/preview_sample.py` — zobrazí náhled fotek podle ID
|
||||||
|
Použití: `python preview_sample.py 101 202 303 ...`
|
||||||
|
Claude vybere ID přes MCP dotazy, předá příkaz ke spuštění.
|
||||||
|
|
||||||
|
- `00 PictureCollector/migrate_add_wanted.py` — přidal sloupec `wanted` (idempotentní)
|
||||||
|
|
||||||
|
### Rozhodnutí
|
||||||
|
|
||||||
|
#### Pravidla vyloučení cest — část 1 (2026-06-04)
|
||||||
|
|
||||||
|
Tyto cesty **nechceme** — `wanted` zůstane FALSE, nezpracovávat:
|
||||||
|
|
||||||
|
| Vzor cesty (obsahuje) | Důvod | Počet |
|
||||||
|
|---|---|---|
|
||||||
|
| `Torrents/Downloads/OOPS!!! International` | porno screenshoty | ~7 105 |
|
||||||
|
| `Torrents/Downloads/Tampons Pads Period` | porno | ~9 600 |
|
||||||
|
| `#ColdData/Porno/` | porno screenlists | — |
|
||||||
|
| `Porno1/` | porno | ~2 730 |
|
||||||
|
| `#ColdData/000 TORENT OBRAZKY/National Geographic Wallpapers` | stažené wallpapery | ~7 188 |
|
||||||
|
| `#ColdData/000 TORENT OBRAZKY/[OnlyFans]` | OnlyFans | ~1 377 |
|
||||||
|
| `#ColdData/000 TORENT OBRAZKY/Great Painters` | reprodukce obrazů | — |
|
||||||
|
| `UltraCC/` a obsahuje `/jpg` | Hot Wheels katalog a jiné torrent obrázky | ~3 484 |
|
||||||
|
| `Magentic/Runtime/UserPhotos/css` | webové ikonky | ~1 034 |
|
||||||
|
| `.Icecream Ebook Reader/` | obrázky z epub knih | — |
|
||||||
|
| `photoprism/sidecar/` | XMP sidecar soubory | — |
|
||||||
|
|
||||||
|
> Otevřené: appdata/photoprism/cache, Immich thumbs, MP3/LP obaly, eBooks — vyřeší se v další části pravidel.
|
||||||
|
|
||||||
|
### Schéma — nové sloupce v `photos`
|
||||||
|
|
||||||
|
| Sloupec | Typ | Popis |
|
||||||
|
|---|---|---|
|
||||||
|
| `wanted` | `BOOLEAN NOT NULL DEFAULT FALSE` | chceme tuto fotku zachovat/zpracovat |
|
||||||
|
| `category` | `VARCHAR(100)` | kategorie: Fotopast, Rodina, Skeny, … |
|
||||||
|
|
||||||
|
### Označené kategorie
|
||||||
|
|
||||||
|
| Kamera / kritérium | wanted | category | Počet |
|
||||||
|
|---|---|---|---|
|
||||||
|
| BolyMedia SG520 | TRUE | Fotopast | 42 688 |
|
||||||
|
| Apple iPhones + iPad (viz update_wanted.py) | TRUE | Rodina | — |
|
||||||
|
| 15 kompaktů (Panasonic, Canon, GoPro, …) | TRUE | Rodina | — |
|
||||||
|
| Samsung + profesionální (Nikon D5/D4…) | TRUE | Rodina | — |
|
||||||
|
| 32 dalších fotoaparátů (update_wanted_batch2.py) | TRUE | Rodina | 13 056 |
|
||||||
|
|
||||||
|
### Vyloučené skenery (wanted=FALSE, category='Skener-nechceme')
|
||||||
|
|
||||||
|
| Skener | Počet | Poznámka |
|
||||||
|
|---|---|---|
|
||||||
|
| CanoScan 8800F (`make IS NULL`) | 1 567 | Skeny, nechceme |
|
||||||
|
|
||||||
|
> Ostatní skenery (LiDE 300/210/100, 4400F, HP Scanjet G2710, pls3015, djf300/2100/2200, MP210/190) — **dosud nerozhodnuto**.
|
||||||
|
|
||||||
|
### Na řadě
|
||||||
|
|
||||||
|
- [ ] Prozkoumat co přesně je v záloze — jaký podíl jsou skutečné rodinné fotky
|
||||||
|
- [ ] Rozhodnout jak filtrovat odpad (path blacklist? size? absence kamery?)
|
||||||
|
- [ ] Vyřešit problém s roky — proč 57 % fotek padá do 2024
|
||||||
|
- [ ] Označit první várku fotek jako `wanted = TRUE`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 2026-06-05
|
||||||
|
|
||||||
|
### Nástroje — nový web preview na Toweru
|
||||||
|
|
||||||
|
- **`00 PictureCollector/preview_server.py`** — HTTP server běžící v Docker kontejneru
|
||||||
|
`python-runner` na Toweru. Renderuje thumbnaily lokálně (rychlé), má lightbox
|
||||||
|
(klik = plné rozlišení přes `/image?id=`).
|
||||||
|
- URL: `http://192.168.1.76:8766/preview?ids=101,202,303&thumb=320`
|
||||||
|
- Kontejner má mount `/mnt/remotes/TOWER1.LAN_ZalohaVsechObrazku` → `/mnt/ZalohaVsechObrazku:ro`
|
||||||
|
- Knihovny v image: `psycopg` (v3!), `Pillow` (zabakováno přes `docker commit`)
|
||||||
|
- **MCP konektor `fotky-buzalkovi`** — `query/tables/describe_table/stats`. SQL dotazy
|
||||||
|
pouštím přímo, bez mezikroku.
|
||||||
|
|
||||||
|
### Pracovní metodika (důležité!)
|
||||||
|
|
||||||
|
- **NEoznačovat nechtěné proaktivně** — vždy jen po explicitním pokynu uživatele.
|
||||||
|
- Před každým hromadným označením složky za odpad: **ověřit jedinečnost přes
|
||||||
|
pixel/file-hash** (`sha256_pixels`, `sha256_file`). V cache/odpadních složkách se
|
||||||
|
můžou skrývat unikátní rodinné fotky (viz „Maminka" níže).
|
||||||
|
- UPDATE pouštět vždy jako **jedna session, autocommit** — paralelní běhy způsobují
|
||||||
|
deadlock. Před každým během kontrola `pg_stat_activity` na zaseklé UPDATE.
|
||||||
|
- Marker skripty: `mark_*.py` v `00 PictureCollector/` (idempotentní přes filtr
|
||||||
|
`category IS DISTINCT FROM ...`).
|
||||||
|
|
||||||
|
### Kategorie (rozšířený číselník)
|
||||||
|
|
||||||
|
| category | wanted | význam |
|
||||||
|
|---|---|---|
|
||||||
|
| `Rodina` | TRUE | rodinné fotky |
|
||||||
|
| `Kamaradi` | TRUE | fotky kamarádů (Jan Luxemburk – Xperie) |
|
||||||
|
| `Fotopast` | TRUE | fotopast |
|
||||||
|
| `Odpad-cache` | FALSE | cache aplikací, obaly, web obrázky |
|
||||||
|
| `Odpad-torrent` | FALSE | stažené torrenty (komiksy, vystřihovánky, software) |
|
||||||
|
| `Odpad-sken` | FALSE | skenované publikace (série stránek) |
|
||||||
|
| `Odpad-obaly` | FALSE | CD inlety/obaly s EXIF |
|
||||||
|
| `Odpad-dokumenty` | FALSE | skeny dokumentů/smluv |
|
||||||
|
| `Odpad-screenshot` | FALSE | screenshoty z her/aplikací |
|
||||||
|
| `Porno` | FALSE | porno |
|
||||||
|
| `Skener-nechceme` | FALSE | skenery (viz session 2026-06-04) |
|
||||||
|
|
||||||
|
### Zpracované velké složky
|
||||||
|
|
||||||
|
| Složka | Fotek | Výsledek |
|
||||||
|
|---|---:|---|
|
||||||
|
| **Tower/appdata** | 1 006 032 | odpad (Odpad-cache); **zachráněno 2 274** fotek Maminky (iPhone 12) z `photoprism/sidecar/Maminka` → Rodina |
|
||||||
|
| **Tower/Sabnzbd** | 48 442 | celé odpad (usenet downloads, profi Nikon D5/D4 = stažené, ne rodina) |
|
||||||
|
| **Tower/Torrents + Tower1/Torrents** | 100 938 | celé odpad (Odpad-torrent) |
|
||||||
|
| **Tower1/#ColdData** | 84 374 | roztříděno 100 %, viz níže |
|
||||||
|
| **Tower1/#Synology** | 66 462 | rozpracováno (~95 %), viz níže |
|
||||||
|
|
||||||
|
### #Synology — rozpracováno (Public/...)
|
||||||
|
|
||||||
|
Velký rodinný NAS fotoarchiv. Hash-check potvrdil, že `HD02#FOTKY` je z 99,6 %
|
||||||
|
**unikátní** (jen 132 fotek má kopii jinde) — není to duplikát jiné zálohy.
|
||||||
|
|
||||||
|
**Rodina (chceme) — HOTOVO:**
|
||||||
|
- `#SERVER/Y/HD02#FOTKY` (30 789) — hlavní archiv 2008–2020
|
||||||
|
- `#SERVER/Y/HD02#FOTKY MAMKA` (1 167)
|
||||||
|
- `DropboxFotky` (14 882)
|
||||||
|
- `HD02#DVD OSOBNI` (5 834)
|
||||||
|
- `Fotky` (2 138)
|
||||||
|
- `#SERVER/Y/WEBS` galerijní alba (906) — starý web Buzalka.com/Gallery2,
|
||||||
|
alba akcí (USA 2006, lyžování Stoderzinken 2007, Orlík, Střetávka…)
|
||||||
|
|
||||||
|
**Odpad — HOTOVO:**
|
||||||
|
- `#SERVER/Y/WEBS` zbytek (2 153) — web šablony/skiny/moduly Buzalka.cz e-shop → Odpad-cache
|
||||||
|
|
||||||
|
**JEŠTĚ NEROZHODNUTO (zbývá ~9 200):**
|
||||||
|
- `AFotkyFotky` (4 550, 2 245 EXIF) — pravděpodobně fotky
|
||||||
|
- `#SERVER/JNJ` (1 835, 45 modelů) — smíšené
|
||||||
|
- `#SERVER/E` (1 629) — smíšené
|
||||||
|
- `VideoMichalkaiCloud` (277) — Michalka iCloud, pravděpodobně rodina
|
||||||
|
- `GoPro` (70) — pravděpodobně rodina
|
||||||
|
- drobnosti (`###StatSoft`, `#SOFTWARE`, `HD02#AUDIO CD A DVD`, `#Kurzy`, `X`…) ~220 → odpad
|
||||||
|
|
||||||
|
### #ColdData — detailní rozpad
|
||||||
|
|
||||||
|
**Rodina (chceme):**
|
||||||
|
- `SynologyMaly/photo` (24 198) + `SynologyMaly/Public/Dropbox` (447) — rodinný archiv 1998–2015 (Canon S40/A40/IXUS, Nikon D80, iPhony, Lumia). Unikátní (bez duplikátů).
|
||||||
|
- `VladkoSoubory` (871) — syn Vladimír Buzalka ml.
|
||||||
|
- `DedupPhotos` (285) — Samsung S7 Edge 2019, ověřeno unikátní.
|
||||||
|
- `LTBS` (40) — rodina (názvy souborů obsahují BUZALKA).
|
||||||
|
- `Tatinek U/.../Fractal` (19) — iPhone 13, táta.
|
||||||
|
- `pomoc` Samsung GT-S5230 (26) — rodina.
|
||||||
|
|
||||||
|
**Kamaradi (chceme):** `Honza fotky z Xperie` (2 047) — kamarád Jan Luxemburk.
|
||||||
|
|
||||||
|
**Odpad:**
|
||||||
|
- `Porno`, `Porno1`, `MadelineIsWicked` → Porno
|
||||||
|
- `000 TORENT OBRAZKY`, `Qnap_nepotrebne` (X-Men komiksy, učebnice, software) → Odpad-torrent
|
||||||
|
- `SynologyMaly/Public/X` (9 134, série e0xxx skenů) → Odpad-sken
|
||||||
|
- `Qnap` (MP3 obaly, downloads, 81 EXIF CD inletů) → Odpad-cache/Odpad-obaly
|
||||||
|
- `Museum of Modern Art NY`, hudební alba, Salvador Dali, eBooky, Truecrypt, `DropBox` (software help obrázky) → Odpad-cache
|
||||||
|
- `Tatinek U` Compass dokumenty + `212` smlouvy → Odpad-dokumenty; DarthAnihilator/Screenshots → Odpad-screenshot
|
||||||
|
- `pomoc` LegalPorno/Kink snapshoty (28) → Porno
|
||||||
|
|
||||||
|
**Výsledek #ColdData:** Rodina 25 889 · Kamaradi 2 047 · Porno 23 502 · Odpad-torrent 21 300 · Odpad-sken 9 134 · Odpad-cache 2 269 · ostatní odpad 228.
|
||||||
|
|
||||||
|
### Poučení
|
||||||
|
|
||||||
|
- **Cache/download složky ≠ čistý odpad.** PhotoPrism si do `sidecar/` ukládá unikátní
|
||||||
|
JPEG kopie fotek, jejichž originály v záloze nejsou → před smazáním vždy hash-check.
|
||||||
|
- Camera-rules (např. „všechny Nikon D5/D4 = Rodina") dávají **false-positives** ve
|
||||||
|
stažených složkách → path má přednost, ale ověřit obsah.
|
||||||
|
|
||||||
|
### Na řadě
|
||||||
|
|
||||||
|
- [ ] **Dokončit #Synology** — zbývá ~9 200: `AFotkyFotky`, `JNJ`, `E`,
|
||||||
|
`VideoMichalkaiCloud`, `GoPro` (náhledy → rozhodnout), drobnosti → odpad
|
||||||
|
- [ ] Velké zbývající: `TW22/D` (63 706), `Tower1/#Pomoc` (34 500), `Tower1/#Synologymaly` (29 934)
|
||||||
|
- [ ] Dokončit Canony (a pak ostatní značky foťáků)
|
||||||
|
- [ ] „BEZ KAMERY" skupina (path-based)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backlog otevřených otázek
|
||||||
|
|
||||||
|
1. Co s "sirotky" bez EXIF — `mtime` / odmítnout / označit?
|
||||||
|
2. Při shodě `sha256_pixels` — přeskočit / sloučit metadata / uložit oba?
|
||||||
|
3. Storage layout — nechat in-place / `archiv/YYYY/MM/` / content-addressable?
|
||||||
|
4. Jak poznat "rodinná fotka" od odpadu bez ruční kontroly?
|
||||||
+297
-23
@@ -1,14 +1,19 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
FotkyBuzalkovi MCP Server
|
FotkyBuzalkovi MCP Server
|
||||||
Poskytuje nástroje pro dotazování PostgreSQL databáze fotky_buzalkovi.
|
Poskytuje nástroje pro dotazování PostgreSQL databáze fotky_buzalkovi
|
||||||
|
a nahrávání fotek do Immich.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import paramiko
|
||||||
import psycopg2
|
import psycopg2
|
||||||
import psycopg2.extras
|
import psycopg2.extras
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@@ -27,6 +32,19 @@ DB_CONFIG = {
|
|||||||
"dbname": os.getenv("DB_NAME", "fotky_buzalkovi"),
|
"dbname": os.getenv("DB_NAME", "fotky_buzalkovi"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IMMICH_URL = os.getenv("IMMICH_URL", "http://192.168.1.76:8888")
|
||||||
|
IMMICH_API_KEY = os.getenv("IMMICH_API_KEY", "")
|
||||||
|
|
||||||
|
SSH_HOST = os.getenv("SSH_HOST", "192.168.1.76")
|
||||||
|
SSH_USER = os.getenv("SSH_USER", "root")
|
||||||
|
SSH_PASSWORD = os.getenv("SSH_PASSWORD", "")
|
||||||
|
ZALOHA_SRC_PREFIX = os.getenv("ZALOHA_SRC_PREFIX", "/mnt/user/ZalohaVsechObrazku")
|
||||||
|
ZALOHA_DST_PREFIX = os.getenv("ZALOHA_DST_PREFIX", "/mnt/remotes/TOWER1.LAN_ZalohaVsechObrazku")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DB helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def get_conn():
|
def get_conn():
|
||||||
return psycopg2.connect(**DB_CONFIG)
|
return psycopg2.connect(**DB_CONFIG)
|
||||||
@@ -41,6 +59,133 @@ def run_query(sql: str, params=None, limit: int = 500):
|
|||||||
return [dict(r) for r in rows], cur.description
|
return [dict(r) for r in rows], cur.description
|
||||||
|
|
||||||
|
|
||||||
|
def build_pending_query(camera_model=None, category=None, hostname=None,
|
||||||
|
date_from=None, date_to=None, limit=20):
|
||||||
|
"""Sestaví SQL dotaz pro nenahrané fotky."""
|
||||||
|
sql = """
|
||||||
|
SELECT
|
||||||
|
z.id AS zaloha_id,
|
||||||
|
z.cesta_zalohy,
|
||||||
|
z.nazev_souboru,
|
||||||
|
p.camera_make,
|
||||||
|
p.camera_model,
|
||||||
|
p.taken_at,
|
||||||
|
p.category,
|
||||||
|
p.file_size
|
||||||
|
FROM photos p
|
||||||
|
JOIN zaloha_obrazku z ON p.zaloha_id = z.id
|
||||||
|
LEFT JOIN immich_upload iu ON iu.zaloha_id = z.id
|
||||||
|
WHERE iu.zaloha_id IS NULL
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
if camera_model:
|
||||||
|
sql += " AND p.camera_model ILIKE %s"
|
||||||
|
params.append(f"%{camera_model}%")
|
||||||
|
if category:
|
||||||
|
sql += " AND p.category = %s"
|
||||||
|
params.append(category)
|
||||||
|
if hostname:
|
||||||
|
sql += " AND z.cesta_zalohy LIKE %s"
|
||||||
|
params.append(f"/mnt/user/ZalohaVsechObrazku/{hostname}/%")
|
||||||
|
if date_from:
|
||||||
|
sql += " AND p.taken_at >= %s"
|
||||||
|
params.append(date_from)
|
||||||
|
if date_to:
|
||||||
|
sql += " AND p.taken_at <= %s"
|
||||||
|
params.append(date_to)
|
||||||
|
sql += " ORDER BY p.taken_at NULLS LAST, z.id"
|
||||||
|
sql += f" LIMIT {int(limit)}"
|
||||||
|
return sql, params
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Immich upload (synchronous — volá se přes asyncio.to_thread)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _do_upload(rows: list[dict]) -> dict:
|
||||||
|
"""
|
||||||
|
Nahraje soubory z rows do Immich přes SFTP + HTTP multipart.
|
||||||
|
rows: seznam diktů se zaloha_id, cesta_zalohy, nazev_souboru, taken_at
|
||||||
|
Vrátí {'created': n, 'duplicate': n, 'error': n, 'details': [...]}
|
||||||
|
"""
|
||||||
|
ssh = paramiko.SSHClient()
|
||||||
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
ssh.connect(SSH_HOST, username=SSH_USER, password=SSH_PASSWORD)
|
||||||
|
sftp = ssh.open_sftp()
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB_CONFIG)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
created = dup = err = 0
|
||||||
|
details = []
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
zid = row["zaloha_id"]
|
||||||
|
fname = row["nazev_souboru"]
|
||||||
|
cesta = row["cesta_zalohy"]
|
||||||
|
taken_at = row.get("taken_at")
|
||||||
|
nfs_path = cesta.replace(ZALOHA_SRC_PREFIX, ZALOHA_DST_PREFIX, 1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with sftp.open(nfs_path, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
|
||||||
|
fcreated = taken_at.isoformat() if taken_at else "2000-01-01T00:00:00+00:00"
|
||||||
|
|
||||||
|
boundary = "fotky_buzalkovi_boundary"
|
||||||
|
body = (
|
||||||
|
f"--{boundary}\r\nContent-Disposition: form-data; name=\"deviceAssetId\"\r\n\r\nfb-{zid}\r\n"
|
||||||
|
f"--{boundary}\r\nContent-Disposition: form-data; name=\"deviceId\"\r\n\r\nfotky-buzalkovi\r\n"
|
||||||
|
f"--{boundary}\r\nContent-Disposition: form-data; name=\"fileCreatedAt\"\r\n\r\n{fcreated}\r\n"
|
||||||
|
f"--{boundary}\r\nContent-Disposition: form-data; name=\"fileModifiedAt\"\r\n\r\n{fcreated}\r\n"
|
||||||
|
f"--{boundary}\r\nContent-Disposition: form-data; name=\"isFavorite\"\r\n\r\nfalse\r\n"
|
||||||
|
f"--{boundary}\r\nContent-Disposition: form-data; name=\"assetData\"; filename=\"{fname}\"\r\nContent-Type: application/octet-stream\r\n\r\n"
|
||||||
|
).encode() + data + f"\r\n--{boundary}--\r\n".encode()
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{IMMICH_URL}/api/assets",
|
||||||
|
data=body,
|
||||||
|
headers={
|
||||||
|
"x-api-key": IMMICH_API_KEY,
|
||||||
|
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=300) as r:
|
||||||
|
resp = json.load(r)
|
||||||
|
|
||||||
|
status = resp.get("status", "error")
|
||||||
|
aid = resp.get("id")
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO immich_upload(zaloha_id, immich_id, status, uploaded_at)
|
||||||
|
VALUES (%s, %s, %s, now())
|
||||||
|
ON CONFLICT (zaloha_id) DO UPDATE
|
||||||
|
SET immich_id=EXCLUDED.immich_id, status=EXCLUDED.status, uploaded_at=now()""",
|
||||||
|
(zid, aid, status),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if status == "created":
|
||||||
|
created += 1
|
||||||
|
elif status == "duplicate":
|
||||||
|
dup += 1
|
||||||
|
else:
|
||||||
|
err += 1
|
||||||
|
|
||||||
|
details.append({"file": fname, "status": status, "immich_id": str(aid)})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
err += 1
|
||||||
|
details.append({"file": fname, "status": "error", "error": str(e)})
|
||||||
|
|
||||||
|
sftp.close()
|
||||||
|
ssh.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {"created": created, "duplicate": dup, "error": err, "details": details}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Server
|
# Server
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -97,13 +242,89 @@ async def list_tools() -> list[Tool]:
|
|||||||
),
|
),
|
||||||
inputSchema={"type": "object", "properties": {}},
|
inputSchema={"type": "object", "properties": {}},
|
||||||
),
|
),
|
||||||
|
Tool(
|
||||||
|
name="immich_find_pending",
|
||||||
|
description=(
|
||||||
|
"Najde fotky, které ještě nebyly nahrány do Immich. "
|
||||||
|
"Lze filtrovat podle kamery, kategorie, hostname zdroje a rozsahu data. "
|
||||||
|
"Vrátí seznam čekajících fotek s cestami a metadaty."
|
||||||
|
),
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"camera_model": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Filtr na model fotoaparátu/telefonu (ILIKE, např. 'iPhone 16')",
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Filtr na kategorii fotky (např. 'rodina', 'dovolena')",
|
||||||
|
},
|
||||||
|
"hostname": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Filtr na hostname zdroje (např. 'TW22', 'tower')",
|
||||||
|
},
|
||||||
|
"date_from": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Datum od (ISO formát, např. '2025-01-01')",
|
||||||
|
},
|
||||||
|
"date_to": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Datum do (ISO formát, např. '2025-12-31')",
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Max. počet vrácených řádků (default 20, max 500)",
|
||||||
|
"default": 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="immich_upload",
|
||||||
|
description=(
|
||||||
|
"Nahraje fotky do Immich a zapíše výsledek do tabulky immich_upload. "
|
||||||
|
"Přeskočí fotky, které už jsou nahrané (resumable). "
|
||||||
|
"Lze filtrovat stejně jako immich_find_pending. "
|
||||||
|
"POZOR: skutečně nahrává soubory — ověř kritéria nejdřív přes immich_find_pending."
|
||||||
|
),
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"camera_model": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Filtr na model fotoaparátu/telefonu (ILIKE, např. 'iPhone 16')",
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Filtr na kategorii fotky",
|
||||||
|
},
|
||||||
|
"hostname": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Filtr na hostname zdroje (např. 'TW22')",
|
||||||
|
},
|
||||||
|
"date_from": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Datum od (ISO formát)",
|
||||||
|
},
|
||||||
|
"date_to": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Datum do (ISO formát)",
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Kolik fotek nahrát v tomto běhu (default 10, max 200)",
|
||||||
|
"default": 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@server.call_tool()
|
@server.call_tool()
|
||||||
async def call_tool(name: str, arguments: dict):
|
async def call_tool(name: str, arguments: dict):
|
||||||
|
|
||||||
# Ochrana — jen SELECT
|
|
||||||
def check_readonly(sql: str):
|
def check_readonly(sql: str):
|
||||||
normalized = sql.strip().upper()
|
normalized = sql.strip().upper()
|
||||||
for bad in ("INSERT", "UPDATE", "DELETE", "DROP", "TRUNCATE", "ALTER", "CREATE"):
|
for bad in ("INSERT", "UPDATE", "DELETE", "DROP", "TRUNCATE", "ALTER", "CREATE"):
|
||||||
@@ -111,6 +332,10 @@ async def call_tool(name: str, arguments: dict):
|
|||||||
raise ValueError(f"Pouze SELECT dotazy jsou povoleny. Nalezeno: {bad}")
|
raise ValueError(f"Pouze SELECT dotazy jsou povoleny. Nalezeno: {bad}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Původní read-only nástroje #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
if name == "query":
|
if name == "query":
|
||||||
sql = arguments["sql"]
|
sql = arguments["sql"]
|
||||||
check_readonly(sql)
|
check_readonly(sql)
|
||||||
@@ -166,47 +391,97 @@ async def call_tool(name: str, arguments: dict):
|
|||||||
cur.execute("SELECT COUNT(*) AS total FROM photos")
|
cur.execute("SELECT COUNT(*) AS total FROM photos")
|
||||||
results["photos_total"] = cur.fetchone()["total"]
|
results["photos_total"] = cur.fetchone()["total"]
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("SELECT COUNT(*) AS total FROM photos WHERE taken_at IS NOT NULL")
|
||||||
SELECT COUNT(*) AS total FROM photos
|
|
||||||
WHERE taken_at IS NOT NULL
|
|
||||||
""")
|
|
||||||
results["photos_with_taken_at"] = cur.fetchone()["total"]
|
results["photos_with_taken_at"] = cur.fetchone()["total"]
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("SELECT COUNT(*) AS total FROM photos WHERE gps_lat IS NOT NULL")
|
||||||
SELECT COUNT(*) AS total FROM photos
|
|
||||||
WHERE gps_lat IS NOT NULL
|
|
||||||
""")
|
|
||||||
results["photos_with_gps"] = cur.fetchone()["total"]
|
results["photos_with_gps"] = cur.fetchone()["total"]
|
||||||
|
|
||||||
|
cur.execute("SELECT COUNT(*) AS total FROM immich_upload")
|
||||||
|
results["immich_uploaded_total"] = cur.fetchone()["total"]
|
||||||
|
|
||||||
|
cur.execute("SELECT status, COUNT(*) AS cnt FROM immich_upload GROUP BY status ORDER BY cnt DESC")
|
||||||
|
results["immich_upload_by_status"] = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT camera_model, COUNT(*) AS cnt
|
SELECT camera_model, COUNT(*) AS cnt
|
||||||
FROM photos
|
FROM photos WHERE camera_model IS NOT NULL
|
||||||
WHERE camera_model IS NOT NULL
|
GROUP BY camera_model ORDER BY cnt DESC LIMIT 10
|
||||||
GROUP BY camera_model
|
|
||||||
ORDER BY cnt DESC
|
|
||||||
LIMIT 10
|
|
||||||
""")
|
""")
|
||||||
results["top_cameras"] = [dict(r) for r in cur.fetchall()]
|
results["top_cameras"] = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT EXTRACT(YEAR FROM taken_at)::int AS rok, COUNT(*) AS cnt
|
SELECT EXTRACT(YEAR FROM taken_at)::int AS rok, COUNT(*) AS cnt
|
||||||
FROM photos
|
FROM photos WHERE taken_at IS NOT NULL
|
||||||
WHERE taken_at IS NOT NULL
|
GROUP BY rok ORDER BY rok
|
||||||
GROUP BY rok
|
|
||||||
ORDER BY rok
|
|
||||||
""")
|
""")
|
||||||
results["photos_by_year"] = [dict(r) for r in cur.fetchall()]
|
results["photos_by_year"] = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT processing_status, COUNT(*) AS cnt
|
SELECT processing_status, COUNT(*) AS cnt
|
||||||
FROM photos
|
FROM photos GROUP BY processing_status ORDER BY cnt DESC
|
||||||
GROUP BY processing_status
|
|
||||||
ORDER BY cnt DESC
|
|
||||||
""")
|
""")
|
||||||
results["processing_status"] = [dict(r) for r in cur.fetchall()]
|
results["processing_status"] = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
return [TextContent(type="text", text=json.dumps(results, ensure_ascii=False, default=str, indent=2))]
|
return [TextContent(type="text", text=json.dumps(results, ensure_ascii=False, default=str, indent=2))]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Nové Immich nástroje #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
elif name == "immich_find_pending":
|
||||||
|
limit = min(int(arguments.get("limit", 20)), 500)
|
||||||
|
sql, params = build_pending_query(
|
||||||
|
camera_model=arguments.get("camera_model"),
|
||||||
|
category=arguments.get("category"),
|
||||||
|
hostname=arguments.get("hostname"),
|
||||||
|
date_from=arguments.get("date_from"),
|
||||||
|
date_to=arguments.get("date_to"),
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
rows, _ = run_query(sql, params=params, limit=limit)
|
||||||
|
|
||||||
|
# Přidej celkový počet čekajících (bez LIMIT)
|
||||||
|
count_sql = sql.replace(
|
||||||
|
f" ORDER BY p.taken_at NULLS LAST, z.id\n LIMIT {limit}", ""
|
||||||
|
)
|
||||||
|
count_sql = f"SELECT COUNT(*) AS total FROM ({sql.rsplit('LIMIT', 1)[0]}) sub"
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(count_sql, params)
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"pending_total": total,
|
||||||
|
"returned": len(rows),
|
||||||
|
"rows": rows,
|
||||||
|
}
|
||||||
|
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, default=str, indent=2))]
|
||||||
|
|
||||||
|
elif name == "immich_upload":
|
||||||
|
limit = min(int(arguments.get("limit", 10)), 200)
|
||||||
|
sql, params = build_pending_query(
|
||||||
|
camera_model=arguments.get("camera_model"),
|
||||||
|
category=arguments.get("category"),
|
||||||
|
hostname=arguments.get("hostname"),
|
||||||
|
date_from=arguments.get("date_from"),
|
||||||
|
date_to=arguments.get("date_to"),
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
rows, _ = run_query(sql, params=params, limit=limit)
|
||||||
|
if not rows:
|
||||||
|
return [TextContent(type="text", text="Žádné fotky k nahrání pro zadaná kritéria.")]
|
||||||
|
|
||||||
|
# Spusť upload v threadu (blocking I/O)
|
||||||
|
result = await asyncio.to_thread(_do_upload, rows)
|
||||||
|
summary = (
|
||||||
|
f"Nahráno: {result['created']} nových, "
|
||||||
|
f"{result['duplicate']} duplikátů, "
|
||||||
|
f"{result['error']} chyb.\n\n"
|
||||||
|
+ json.dumps(result["details"], ensure_ascii=False, indent=2)
|
||||||
|
)
|
||||||
|
return [TextContent(type="text", text=summary)]
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return [TextContent(type="text", text=f"Neznámý nástroj: {name}")]
|
return [TextContent(type="text", text=f"Neznámý nástroj: {name}")]
|
||||||
|
|
||||||
@@ -228,5 +503,4 @@ async def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import asyncio
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
|
|||||||
Reference in New Issue
Block a user