Compare commits

..

3 Commits

Author SHA1 Message Date
administrator a30f5a6eca notebookVb 2026-06-06 10:17:15 +02:00
administrator ee14efbd48 notebookVb 2026-06-06 06:26:42 +02:00
administrator 82de38f02a notebookVb 2026-06-05 07:03:21 +02:00
24 changed files with 1580 additions and 23 deletions
+10
View File
@@ -3,3 +3,13 @@ DB_PORT=5432
DB_USER=vladimir.buzalka
DB_PASSWORD=Vlado7309208104++
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
+125
View File
@@ -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()
+48
View File
@@ -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()
+27
View File
@@ -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()
+32
View File
@@ -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.")
+41
View File
@@ -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.")
+40
View File
@@ -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()
+44
View File
@@ -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.")
+38
View File
@@ -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.")
+338
View File
@@ -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} &nbsp;#{i}</div>
<div><strong>{date_str}</strong> <span class="src">({taken_src})</span></div>
<div>📷 {camera}</div>
<div>{res} {mp_str} &nbsp; {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> &nbsp;·&nbsp;
Načteno z DB: <strong>{len(rows)}</strong> &nbsp;·&nbsp;
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()
+34
View File
@@ -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()
+127
View File
@@ -82,6 +82,18 @@ Tyto cesty **nechceme** — `wanted` zůstane FALSE, nezpracovávat:
| 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ě
@@ -92,6 +104,121 @@ Tyto cesty **nechceme** — `wanted` zůstane FALSE, nezpracovávat:
---
## 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 20082020
- `#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 19982015 (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?
+297 -23
View File
@@ -1,14 +1,19 @@
#!/usr/bin/env python3
"""
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 os
import sys
import urllib.request
from pathlib import Path
import paramiko
import psycopg2
import psycopg2.extras
from dotenv import load_dotenv
@@ -27,6 +32,19 @@ DB_CONFIG = {
"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():
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
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
# ---------------------------------------------------------------------------
@@ -97,13 +242,89 @@ async def list_tools() -> list[Tool]:
),
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()
async def call_tool(name: str, arguments: dict):
# Ochrana — jen SELECT
def check_readonly(sql: str):
normalized = sql.strip().upper()
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}")
try:
# ------------------------------------------------------------------ #
# Původní read-only nástroje #
# ------------------------------------------------------------------ #
if name == "query":
sql = arguments["sql"]
check_readonly(sql)
@@ -166,47 +391,97 @@ async def call_tool(name: str, arguments: dict):
cur.execute("SELECT COUNT(*) AS total FROM photos")
results["photos_total"] = cur.fetchone()["total"]
cur.execute("""
SELECT COUNT(*) AS total FROM photos
WHERE taken_at IS NOT NULL
""")
cur.execute("SELECT COUNT(*) AS total FROM photos WHERE taken_at IS NOT NULL")
results["photos_with_taken_at"] = cur.fetchone()["total"]
cur.execute("""
SELECT COUNT(*) AS total FROM photos
WHERE gps_lat IS NOT NULL
""")
cur.execute("SELECT COUNT(*) AS total FROM photos WHERE gps_lat IS NOT NULL")
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("""
SELECT camera_model, COUNT(*) AS cnt
FROM photos
WHERE camera_model IS NOT NULL
GROUP BY camera_model
ORDER BY cnt DESC
LIMIT 10
FROM photos WHERE camera_model IS NOT NULL
GROUP BY camera_model ORDER BY cnt DESC LIMIT 10
""")
results["top_cameras"] = [dict(r) for r in cur.fetchall()]
cur.execute("""
SELECT EXTRACT(YEAR FROM taken_at)::int AS rok, COUNT(*) AS cnt
FROM photos
WHERE taken_at IS NOT NULL
GROUP BY rok
ORDER BY rok
FROM photos WHERE taken_at IS NOT NULL
GROUP BY rok ORDER BY rok
""")
results["photos_by_year"] = [dict(r) for r in cur.fetchall()]
cur.execute("""
SELECT processing_status, COUNT(*) AS cnt
FROM photos
GROUP BY processing_status
ORDER BY cnt DESC
FROM photos GROUP BY processing_status ORDER BY cnt DESC
""")
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))]
# ------------------------------------------------------------------ #
# 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:
return [TextContent(type="text", text=f"Neznámý nástroj: {name}")]
@@ -228,5 +503,4 @@ async def main():
if __name__ == "__main__":
import asyncio
asyncio.run(main())