From b553c957f3dc4c0d110662f5a082ec20501d6597 Mon Sep 17 00:00:00 2001 From: dinlo Date: Sun, 31 May 2026 18:43:18 +0800 Subject: [PATCH] Initial commit Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 45 +++ .../__pycache__/db_manager.cpython-312.pyc | Bin 0 -> 14524 bytes database/__pycache__/schema.cpython-312.pyc | Bin 0 -> 1890 bytes database/db_manager.py | 231 ++++++++++++ database/schema.py | 58 +++ main.py | 55 +++ metadata/__pycache__/parser.cpython-312.pyc | Bin 0 -> 10696 bytes metadata/parser.py | 172 +++++++++ settings.json | 6 + test_integration.py | 23 ++ ui/__pycache__/main_window.cpython-312.pyc | Bin 0 -> 27244 bytes ui/__pycache__/styles.cpython-312.pyc | Bin 0 -> 2051 bytes ui/main_window.py | 354 ++++++++++++++++++ ui/styles.py | 96 +++++ .../__pycache__/center_grid.cpython-312.pyc | Bin 0 -> 20959 bytes .../__pycache__/left_panel.cpython-312.pyc | Bin 0 -> 4358 bytes .../__pycache__/right_panel.cpython-312.pyc | Bin 0 -> 13107 bytes .../settings_dialog.cpython-312.pyc | Bin 0 -> 5754 bytes ui/widgets/center_grid.py | 285 ++++++++++++++ ui/widgets/left_panel.py | 57 +++ ui/widgets/right_panel.py | 186 +++++++++ ui/widgets/settings_dialog.py | 74 ++++ utils/__pycache__/api_client.cpython-312.pyc | Bin 0 -> 1800 bytes utils/__pycache__/logger.cpython-312.pyc | Bin 0 -> 1890 bytes utils/__pycache__/settings.cpython-312.pyc | Bin 0 -> 2061 bytes utils/api_client.py | 27 ++ utils/logger.py | 33 ++ utils/settings.py | 35 ++ .../__pycache__/fs_watcher.cpython-312.pyc | Bin 0 -> 9412 bytes watcher/fs_watcher.py | 142 +++++++ 30 files changed, 1879 insertions(+) create mode 100644 README.md create mode 100644 database/__pycache__/db_manager.cpython-312.pyc create mode 100644 database/__pycache__/schema.cpython-312.pyc create mode 100644 database/db_manager.py create mode 100644 database/schema.py create mode 100644 main.py create mode 100644 metadata/__pycache__/parser.cpython-312.pyc create mode 100644 metadata/parser.py create mode 100644 settings.json create mode 100644 test_integration.py create mode 100644 ui/__pycache__/main_window.cpython-312.pyc create mode 100644 ui/__pycache__/styles.cpython-312.pyc create mode 100644 ui/main_window.py create mode 100644 ui/styles.py create mode 100644 ui/widgets/__pycache__/center_grid.cpython-312.pyc create mode 100644 ui/widgets/__pycache__/left_panel.cpython-312.pyc create mode 100644 ui/widgets/__pycache__/right_panel.cpython-312.pyc create mode 100644 ui/widgets/__pycache__/settings_dialog.cpython-312.pyc create mode 100644 ui/widgets/center_grid.py create mode 100644 ui/widgets/left_panel.py create mode 100644 ui/widgets/right_panel.py create mode 100644 ui/widgets/settings_dialog.py create mode 100644 utils/__pycache__/api_client.cpython-312.pyc create mode 100644 utils/__pycache__/logger.cpython-312.pyc create mode 100644 utils/__pycache__/settings.cpython-312.pyc create mode 100644 utils/api_client.py create mode 100644 utils/logger.py create mode 100644 utils/settings.py create mode 100644 watcher/__pycache__/fs_watcher.cpython-312.pyc create mode 100644 watcher/fs_watcher.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d6e012 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# ComfyGallery + +Галерея изображений для ComfyUI с автоматическим отслеживанием папок и базой данных метаданных. + +## Описание + +ComfyGallery - это приложение на PyQt6 для управления и просмотра изображений, сгенерированных в ComfyUI. Приложение автоматически отслеживает указанные папки, сохраняет метаданные изображений в SQLite базу данных и предоставляет удобный интерфейс для просмотра. + +## Возможности + +- 📁 Автоматическое отслеживание папок с изображениями +- 💾 База данных SQLite для хранения метаданных +- 🖼️ Удобный интерфейс для просмотра галереи +- 🔍 Извлечение метаданных из PNG файлов + +## Установка + +```bash +pip install PyQt6 Pillow watchdog +``` + +## Использование + +```bash +python main.py +``` + +Настройте отслеживаемые папки в файле `settings.json`: + +```json +{ + "tracked_paths": [ + "C:/path/to/your/comfyui/output" + ] +} +``` + +## Структура проекта + +- `main.py` - точка входа приложения +- `database/` - модули работы с БД +- `watcher/` - файловый мониторинг +- `ui/` - интерфейс приложения +- `utils/` - вспомогательные утилиты +- `metadata/` - обработка метаданных изображений diff --git a/database/__pycache__/db_manager.cpython-312.pyc b/database/__pycache__/db_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7cf62b274332d102e836721d36575b17eb78d533 GIT binary patch literal 14524 zcmeHOYj7Lab>78d@ghiq1Ro%2k}Hui1%{$!OO6yhtdJliQ=&mvq-2Sf1%ucH2^t{i zUCSo&MWF_9cdtdvw_k8EO=N$Z9Wu=3G`KgP!`W0tse%u376Fd%#02Iz)&1!4z9WzH z1rHA&_YWQk1dsVokEk`i_F}D# z#SbDeW30%GnZ$cUR^lQi(R7g?GfTY4U1Z+KHXXBwX2}Y5{6+JaO>7md5+~XuyX0ts z7EqVXMJ%EndRNeT>|qui(8DQKNDk2neJbIv3i8!brJIGE?)Z|wm{t}rP!#RO>uCMjkL z>xvHjtFp`-^Ew;wn&fI|p;iT>)6rx!6pLPvg5tQ>q?#2e7NM02-bN5U+UMJM=7b{2 z${8^lkIH9eE3CxiexY85$2M(iwQ(>_Y0DnNw{+dV^r_LT&Dy zdiAmj7++6g!0@7ad8PUZA&RnWSFCOi|g-UD8a74O;Y) z6#IwV6_#b}FgbD!+@Vpg$)Al=G6yea7`-arO3!sN^1*pivPQq2s?~G(vj_{J)Vsb4 zxRWxy%6!wDe;0Tz7d#h!{-PuQT+aha&6}Pzony~2=eg6&Iksa{?{kjP+y5DNj`ebZ z*>1Q8`Q!8pWPyB-{D52+(qAC&lDEiTLBTt4eupfiUxY$|TqZ9GADQ+EUZ-kRo{mM6 z(k|5s2sI6BPFu59>AV!4O-eG(fyy0EoKq|0#JON36iz1OIhFUzazd8vSj$V8S8Z}4 z78?(RpHlg7ETKqVPIhCp83$HbNkNGb1VOID((2%ZlniF?39xyg$*^_{t5ArP??Un~ z%&(qfepc;%!JIKU?XB;-o39+YeCX=#KRxiR1IzB6FAS_YT`vxOWoV_TXT`bW{q}ob z``jy^dtqS3dCzKX!&i@fP{p|FR$X;h9G4w`;LLDv^{YFTjH~g*`LE3Xf-&1`-*?tx zf70T=A^uHlF?QoD896~tK1NQQB2A}PoMZ1*yK#WZ%at!xuR3c!&e);X#~(QF8u&ky z2jPLA=X`GV%YBu7+nL3>I>;|>clYn*7WeP%-^DHM;vv7Zm+RlpEbXuNxwxAyY_D2m zDLE@o7l$=2a=)KZ*mIz+PXpW_cIsk~bm{|5l@`{m^V|l)YD#e+ zlBWE-oJMiup5^8^?VJ}ouQ@O}>xVm%KT1CbqVhca{VsV6ga~8_1c$t95G^WF3+XSz z`8xR_q~C`&c(p*KYoBn~>ySHPFx3*Cl@$<=%B(~wVL3XJl<_mk_~Im1=a`JiZf{zx zslQ#bEnTy1rKaoJ?$xH&*IHg_dA0SDW!2Si+trzNb*^mt^onbb;d0N4YcDSSipv!* zRjxL+d|1KMZoggAm9FXf_ztZ9tPq*(O(;aeUl}HCVIoQ-93l5itT-p%cY8k0@KF7c zCK;~2UCh_}oPAx~Vw(-}i(P!*PU~V1hxwf}-R17z&MjFy{TdT^(An1#>l-|C&ikR{u1MLtJiw^xx^OlrF=_oT6h(m<^HAXT| z<#b{GcKV%ICS{TNlttul=}rUL44aC@djxcDS@F(k*C|v9MF;5OZS&S-PNz?KiWbYa9ln(uf}Iv%@M8yl+M*Ks5-za2-!^ejo$vwei5Brb%VVvZoa8NiTBgvZG4M~AYCqF`L z5hidiFU9ede}?2a=Dq6XRd?h2&8^=MukK%NyYF_}o^;!u z<+jhWOV1jcYFl`+GWB@$b5EgdAOO}F#^cMjbkdKsrgkK`r5Rj?p4CVDo`93J4_#oqu z^8J`}VnTTa!U6>Yah=Ku^=f15mCs-P{I!~NWA}%4 zgH$^VQcXDy-6$NpHB643A!jB@bc#$qMVewO&Ul{dtf5f9>FRH07EOJo{wi+CY=?`b zD!#wby5#0C-$>JDx3894+R@^x~1m4N~_WlC96AW#wB zz+Y_ZLBhNp?zTREu>*gxi_`@58{sdG4PnAUVZu=YCLGT?D1U*o0VeoB=nF8f4o#j4 z(*j-t>jYR$ZVmMa`Wn)58r{J))FaR}-sP?PZg2Ia zxB8a1`j^`d6I)BhRJR$XWvlmOYMF-it2WYoa3Oi)1gSfAr@jIhm&eGsa@cDmR9g(t zyVU@_c|vuR96L*n21zhP#=|5ek|t@z87U%E-*@%zU>2=?uKq3DQllL%mbUQy-PWah zIm~y{bcfrwjl0RT_ynL;Jmh~%s2Jw|1EK0HAXF3)wOy7jP5GS`?s*C&xqwTd;nX0* zd!Y4Uc`u~iEcv0LiwwmepdE0j6q6sf&TZgva@42QD4ZHMGE`6EINyZ?h|k8Nxhdk~ zCl5bPjy$pAI-8TeUz7O^5dSGMaGIQ!NMwRYlcXuS;+)D;A9+7KuxiCG4Hg>J8JChF z;CXYgM5xeSZ7bHtp1uuOf9C6%eq z+5F4Y^KWF0wPorp8`N7jsJCrUZ{MKaq1Q(o26n|wtz*$DY0Rxy$J&$g=1k#Er0Ag5 z9zL0~Yn9x}Ia4gKrOL9Ks;av-Rn>ve5Udo3K{@8KMz(~D6TNG~_L5DpPj(@LiAYYw zXOh7wB{AJ2oJ+`0MPlF>(Js-jl8nOEMr+g~lpStbng|uNR3u3RDwYtXSP-dxkD!F& zGvN1wOj4RrdW3LfqU3Oerm7rEE6Qw+w1KE@ma`eYgOOJmipw*yg3o5`T~JZJf6;d~ zj!;xpevG0SFpg1FRelVdG-F3Pi6tiqmr#CkjeYW^>)c0M3dU~w>L`0Rx@rn~&}CG% zJuEL{>vF42tuV~6T3Ik*YIV`q2LoZ@I!D*HB&v@5OM?eedpVV-Ppooq=h4|A0Ehy8 zoV9$SPc!``q}IAm6D^;A4St&Ep$M%X?~r%WUrm3>sILbHG}?XGYcDcys2unj%Nsni z%Kb1rjC=%>4J;d#N(h8sM-wCGE~rStP)Zf31oX#j+t^T%F;xSV-pVuytDer=p6zMR z_G_)no;~@i_GQm!a#zH=Z`rfI+~u`~WzWuB<+Xd3J)h2BH7$E~<*yo-J@@CY>X$wD zW$G*H8}4+p0*61q)YRwQK|a>t4;o+%j6H8GnIMx1GBHDDWTGUAJWHCMS#h4rQ--W( z$k)Ss-Nz#tZ?r)kDY@5ra~p?wFHL*g1D)K>LoEaC+>hIN$g5683d!Ng;M21ZC{f@J zLen#)7Zyq{@?i}BhaEvCxfRF?S`gW9a^TP@?HB^H4wyvXU&NeO+SBebt${QZSX0+^ zA`LJvTEnIZu$w*smRWGaYBH0U_j9w zK(;~y3O+e?0@Rix$ZpUQ;6BqlVf0gM&rg9cGU<68}XHW($K$^GsNL&zg<8O%?69*e}4g$ zoxopr!e?z94c-M-q(x1f7A-+*|!!K?^X9;SEqUj$zduIzYdx$#hX$3s`c-!Q*C@=ZJ0@ld+) z(1-hhqd>sQzyD$%cmSjOtp?G=ip+GNzFNcEFV~lNb#waz!#{97yHN zAO8&}$|pAqn5bERD{B@&{a@&7H3veM%VGC#2Dd>|8O;VonjiN4#hMI@iWJzo%p%@P z_ny!zzqP_K^lc($H8VoIx7^llLw@jylzUdO19<=vai}CH&!QJ(BY=fXp0?RTH}~76 zwt08$@??zRmQP_)7-d)Bzoxsq{046fn|DBRmkHOU17c7FyHPZzTxibQZ;R^|afFv3 z!M$GXX51!L*z38NhSp0*@3-%E4XMn7yd_(<9e z5SI6moFb>6B&R|oq{relX|Z^@pc5LU>gwx;SUgi-8@E_zhx}q2-`8bb?BFopMbmC~ z|5k4CU`u~1x75l*UbX4eOSS3i6k>iT9S$22CKrz~wpk3rK)CfV40b$aFwC4{3j*az zw*!oXof8}3^1CLuBR*%*q2paiQzy5MsWTTDTPlP-8`zeMcqtXz3Xvmd>MS4FW&unq z3~aLoE^N=nqfu2(qtdkbKzYL*2ZWbt!gt|YCB-k>JxAP2Hlo39g=S71zemtlLDu$J zR>-qBp!o&Lwi=y12D4Fw1vK`pv;_#`;xHWUUGf8rj{9Mu!zOP4vK1MC%G-Xj!EO{4 z&cd_E`123>Nlfw}LGj`4=r;H4449K4!Uf+_2MGhCDpCE9FtGA%NDwDn8{33P`{1p^ zWb|f!~$$vKOi$-Z?Tiq5Y<%R~(nowg5kfV!Vy`Qx@E12ZO337z9N&3!g8jI)lJs zLouyJi&?^-RLJ<<>2#{*@kAm(I6slYlLX{BPF`oNkQJ6L~ayJ`>WeBhw@yrz6T5Hp=XVF|PUOP}c{tL){Ax1D)Ws zU|txx(Xnvq?J9bCYxf$Ke()&U!8crkyTR#32R+?Nl2OngXUN$QIUP!mj;DviYgnH- z%DQ>aH7>)z>AG#9ZXtYQ*TUr6ZRwp4uVGoHy9OR%SB8Pp0t^PHTW)$HCr@kVv*UNL zSo_NOfxR_UIjdIO_{XB-THLB7IX43q7CNokJmuz2b!2~3hmCP9sLt6WXyab(mvveg zeYO6hsa_>KDaAwd2QB&Ndu?i`aDH>st=bM~OYb3h1}b2-83osU<*frT{Y%MFO-Vlld+-oQ9kZoqjHL-R zb3M5kB02;nJTO5=Q}#PB;nX^+IysK8Ppv6mYIYH!7U?11$2KsPCNxZ`*-p>uEgdQi zEqSmSB90e<4(m{dnhdi)TXk%aIR|P`h0HBkGGwU+E82hJ~>Bb9PKk! zA9*;{A{KTkLVR|IPQMeKS&~)LW)|Zo)(p4Hu7)TPSN1u$)341Th#ZdEi3orY}ksK z%`WC+T0G1}7*sjWI!GZm=HSD0yFq!*oo_U;E4S-@@oUm&6s-miW;&{4f3SF0wUX** zor>>STcbnPrg%TT6&;7&%DP94PZD8bxi>@7+heJx6cf|k9I*yzk~4H7NSq!Fr-}39 z)pX)mJcTVbC5-WM>X((w8N)L6)$s~9C9cB6_C>gk$Jx)<@|$jlu-VhK9nXontR!)d zlCB(h{ZRwCte8gKxD^~Dg1e+Q#2$JPj>e1Itp47+csE*PH1TL8&)Lt_e5q9WzP5QO atX`F0J}qAg%U8#xg|+fGVI_LRF8l_aoDl#3 literal 0 HcmV?d00001 diff --git a/database/db_manager.py b/database/db_manager.py new file mode 100644 index 0000000..875b884 --- /dev/null +++ b/database/db_manager.py @@ -0,0 +1,231 @@ +# database/db_manager.py +import sqlite3 +from pathlib import Path +from typing import Optional, List, Dict, Any +from contextlib import contextmanager + +from utils.logger import logger +from database.schema import PRAGMA_FOREIGN_KEYS, CREATE_TABLES_SQL, CREATE_INDEXES_SQL + +class DBManager: + def __init__(self, db_path: str = "comfygallery.db"): + self.db_path = db_path + self._initialize_db() + + @contextmanager + def _get_connection(self): + conn = None + try: + conn = sqlite3.connect(self.db_path) + conn.execute(PRAGMA_FOREIGN_KEYS) + conn.row_factory = sqlite3.Row + yield conn + except sqlite3.Error as e: + logger.error(f"Ошибка соединения с БД {self.db_path}: {e}") + if conn: + conn.rollback() + raise + finally: + if conn: + conn.close() + + def _initialize_db(self) -> bool: + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.executescript(CREATE_TABLES_SQL) + cursor.executescript(CREATE_INDEXES_SQL) + conn.commit() + return True + except sqlite3.Error as e: + logger.critical(f"Критическая ошибка инициализации БД: {e}") + return False + + def add_folder(self, folder_path: str, parent_id: Optional[int] = None) -> Optional[int]: + if not folder_path: + return None + normalized_path = str(Path(folder_path).resolve().as_posix()) + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO folders (path, parent_id) VALUES (?, ?) " + "ON CONFLICT(path) DO UPDATE SET parent_id=excluded.parent_id RETURNING id", + (normalized_path, parent_id) + ) + result = cursor.fetchone() + if not result: + cursor.execute("SELECT id FROM folders WHERE path = ?", (normalized_path,)) + result = cursor.fetchone() + conn.commit() + return result[0] if result else None + except sqlite3.Error as e: + logger.error(f"Ошибка при добавлении папки {normalized_path}: {e}") + return None + + def remove_folder_by_path(self, folder_path: str) -> bool: + normalized_path = str(Path(folder_path).resolve().as_posix()) + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM folders WHERE path = ?", (normalized_path,)) + conn.commit() + return cursor.rowcount > 0 + except sqlite3.Error as e: + logger.error(f"Ошибка при удалении папки {normalized_path} из БД: {e}") + return False + + def register_file(self, folder_id: int, filename: str, filepath: str, size: int, mtime: float) -> Optional[int]: + if not filename or not filepath: + return None + normalized_filepath = str(Path(filepath).resolve().as_posix()) + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + INSERT INTO files (folder_id, filename, filepath, size, mtime) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(filepath) DO UPDATE SET + size = excluded.size, + mtime = excluded.mtime, + folder_id = excluded.folder_id + RETURNING id + """, + (folder_id, filename, normalized_filepath, size, mtime) + ) + result = cursor.fetchone() + conn.commit() + return result[0] if result else None + except sqlite3.Error as e: + logger.error(f"Ошибка регистрации файла {normalized_filepath}: {e}") + return None + + def remove_file_by_path(self, filepath: str) -> bool: + normalized_filepath = str(Path(filepath).resolve().as_posix()) + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM files WHERE filepath = ?", (normalized_filepath,)) + conn.commit() + return cursor.rowcount > 0 + except sqlite3.Error as e: + logger.error(f"Ошибка удаления файла {normalized_filepath} из БД: {e}") + return False + + def save_metadata(self, file_id: int, meta_payload: Dict[str, Any]) -> bool: + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + INSERT INTO metadata ( + file_id, prompt_json, workflow_json, positive_prompt, + negative_prompt, seed, model_name, sampler, steps, cfg + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(file_id) DO UPDATE SET + prompt_json = excluded.prompt_json, + workflow_json = excluded.workflow_json, + positive_prompt = excluded.positive_prompt, + negative_prompt = excluded.negative_prompt, + seed = excluded.seed, + model_name = excluded.model_name, + sampler = excluded.sampler, + steps = excluded.steps, + cfg = excluded.cfg + """, + ( + file_id, + meta_payload.get("prompt_json"), + meta_payload.get("workflow_json"), + meta_payload.get("positive_prompt"), + meta_payload.get("negative_prompt"), + meta_payload.get("seed"), + meta_payload.get("model_name"), + meta_payload.get("sampler"), + meta_payload.get("steps"), + meta_payload.get("cfg") + ) + ) + conn.commit() + return True + except sqlite3.Error as e: + logger.error(f"Ошибка при сохранении метаданных файла ID {file_id}: {e}") + return False + + def get_files_in_folder(self, folder_path: str, search_query: str = "") -> List[Dict[str, Any]]: + normalized_path = str(Path(folder_path).resolve().as_posix()) + try: + with self._get_connection() as conn: + cursor = conn.cursor() + if search_query: + like_query = f"%{search_query}%" + cursor.execute( + """ + SELECT f.id, f.filename, f.filepath, f.rating, f.favorite, + CASE WHEN m.workflow_json IS NOT NULL AND m.workflow_json != '' THEN 1 ELSE 0 END as has_workflow + FROM files f + JOIN folders fo ON f.folder_id = fo.id + LEFT JOIN metadata m ON f.id = m.file_id + WHERE fo.path = ? AND (f.filename LIKE ? OR m.positive_prompt LIKE ? OR m.negative_prompt LIKE ?) + ORDER BY f.filename ASC + """, + (normalized_path, like_query, like_query, like_query) + ) + else: + cursor.execute( + """ + SELECT f.id, f.filename, f.filepath, f.rating, f.favorite, + CASE WHEN m.workflow_json IS NOT NULL AND m.workflow_json != '' THEN 1 ELSE 0 END as has_workflow + FROM files f + JOIN folders fo ON f.folder_id = fo.id + LEFT JOIN metadata m ON f.id = m.file_id + WHERE fo.path = ? + ORDER BY f.filename ASC + """, + (normalized_path,) + ) + return [dict(row) for row in cursor.fetchall()] + except sqlite3.Error as e: + logger.error(f"Ошибка получения файлов в папке {normalized_path}: {e}") + return [] + + def get_file_details(self, file_id: int) -> Optional[Dict[str, Any]]: + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + SELECT f.id, f.filename, f.filepath, f.rating, f.favorite, + m.prompt_json, m.workflow_json, m.positive_prompt, m.negative_prompt, + m.seed, m.model_name, m.sampler, m.steps, m.cfg + FROM files f + LEFT JOIN metadata m ON f.id = m.file_id + WHERE f.id = ? + """, + (file_id,) + ) + row = cursor.fetchone() + return dict(row) if row else None + except sqlite3.Error as e: + logger.error(f"Ошибка получения деталей файла ID {file_id}: {e}") + return None + + def update_file_details(self, file_id: int, positive: str, negative: str, rating: int) -> bool: + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("UPDATE files SET rating = ? WHERE id = ?", (rating, file_id)) + cursor.execute( + """ + UPDATE metadata + SET positive_prompt = ?, negative_prompt = ? + WHERE file_id = ? + """, + (positive, negative, file_id) + ) + conn.commit() + return True + except sqlite3.Error as e: + logger.error(f"Ошибка при сохранении правок для файла ID {file_id}: {e}") + return False \ No newline at end of file diff --git a/database/schema.py b/database/schema.py new file mode 100644 index 0000000..85b2679 --- /dev/null +++ b/database/schema.py @@ -0,0 +1,58 @@ +# database/schema.py +PRAGMA_FOREIGN_KEYS = "PRAGMA foreign_keys = ON;" + +CREATE_TABLES_SQL = """ +CREATE TABLE IF NOT EXISTS folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT UNIQUE NOT NULL, + parent_id INTEGER, + last_scanned TIMESTAMP, + FOREIGN KEY (parent_id) REFERENCES folders(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + folder_id INTEGER NOT NULL, + filename TEXT NOT NULL, + filepath TEXT UNIQUE NOT NULL, + size INTEGER NOT NULL, + mtime REAL NOT NULL, + rating INTEGER DEFAULT 0 CHECK (rating >= 0 AND rating <= 5), + favorite INTEGER DEFAULT 0 CHECK (favorite IN (0, 1)), + FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS metadata ( + file_id INTEGER PRIMARY KEY, + prompt_json TEXT, + workflow_json TEXT, + positive_prompt TEXT, + negative_prompt TEXT, + seed INTEGER, + model_name TEXT, + sampler TEXT, + steps INTEGER, + cfg REAL, + FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL +); + +CREATE TABLE IF NOT EXISTS file_tags ( + file_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (file_id, tag_id), + FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +); +""" + +CREATE_INDEXES_SQL = """ +CREATE INDEX IF NOT EXISTS idx_folders_path ON folders(path); +CREATE INDEX IF NOT EXISTS idx_files_filepath ON files(filepath); +CREATE INDEX IF NOT EXISTS idx_files_folder_id ON files(folder_id); +CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name); +""" \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..682c4d6 --- /dev/null +++ b/main.py @@ -0,0 +1,55 @@ +# main.py +import sys +import os +from pathlib import Path +from PyQt6.QtWidgets import QApplication + +# Явное добавление текущей директории в пути поиска модулей Python +project_root = os.path.dirname(os.path.abspath(__file__)) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +from database.db_manager import DBManager +from watcher.fs_watcher import FolderWatcher +from ui.main_window import MainWindow +from utils.settings import load_settings + +def main(): + db_path = "comfygallery.db" + + # 1. Инициализация БД + db_manager = DBManager(db_path) + + # 2. Загрузка конфигурационного файла (JSON) + settings = load_settings() + tracked_paths = settings.get("tracked_paths", []) + + # Гарантируем, что папки существуют + for path_str in tracked_paths: + Path(path_str).mkdir(parents=True, exist_ok=True) + + # 3. Инициализация и запуск фонового вотчера + watcher = FolderWatcher(db_manager) + watcher.start_monitoring(tracked_paths) + + # 4. Запуск GUI приложения + app = QApplication(sys.argv) + + window = MainWindow(db_manager, watcher) + + # Загружаем список путей в дерево + if tracked_paths: + window.left_panel.set_tracked_folders(tracked_paths) + window.left_panel.set_root_path(tracked_paths[0]) + window.load_folder_images(tracked_paths[0]) + + window.show() + + try: + sys.exit(app.exec()) + finally: + # Корректное завершение фоновых потоков при выходе из приложения + watcher.stop_monitoring() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/metadata/__pycache__/parser.cpython-312.pyc b/metadata/__pycache__/parser.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c898508c694bb91962a4b891a1ed1da82eeb48ff GIT binary patch literal 10696 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 literal 0 HcmV?d00001 diff --git a/metadata/parser.py b/metadata/parser.py new file mode 100644 index 0000000..d667ccf --- /dev/null +++ b/metadata/parser.py @@ -0,0 +1,172 @@ +# metadata/parser.py +import json +import logging +from pathlib import Path +from typing import Optional, Dict, Any, Tuple +from PIL import Image +import piexif +import piexif.helper + +logger = logging.getLogger("ComfyGallery.MetadataParser") + +class MetadataParser: + @staticmethod + def extract_raw_metadata(filepath: str) -> Tuple[Optional[str], Optional[str]]: + path = Path(filepath) + if not path.exists(): + return None, None + suffix = path.suffix.lower() + if suffix in ('.png', '.webp'): + return MetadataParser._extract_from_png_webp(path) + elif suffix in ('.jpg', '.jpeg'): + return MetadataParser._extract_from_jpeg(path) + return None, None + + @staticmethod + def _extract_from_png_webp(path: Path) -> Tuple[Optional[str], Optional[str]]: + try: + with Image.open(path) as img: + info = img.info + prompt = info.get("prompt") + workflow = info.get("workflow") + if not prompt and "comment" in info: + prompt = info.get("comment") + return prompt, workflow + except Exception as e: + logger.error(f"Ошибка чтения PNG/WebP метаданных {path.name}: {e}") + return None, None + + @staticmethod + def _extract_from_jpeg(path: Path) -> Tuple[Optional[str], Optional[str]]: + try: + with Image.open(path) as img: + if "exif" not in img.info: + return None, None + exif_dict = piexif.load(img.info["exif"]) + user_comment_bytes = exif_dict.get("Exif", {}).get(piexif.ExifIFD.UserComment, b"") + if not user_comment_bytes: + return None, None + try: + comment_str = piexif.helper.UserComment.load(user_comment_bytes) + except ValueError: + comment_str = user_comment_bytes.decode('utf-8', errors='ignore') + if comment_str.startswith("{"): + try: + data = json.loads(comment_str) + prompt = data.get("prompt") + workflow = data.get("workflow") + if isinstance(prompt, dict): + prompt = json.dumps(prompt) + if isinstance(workflow, dict): + workflow = json.dumps(workflow) + return prompt, workflow + except json.JSONDecodeError: + return comment_str, None + return None, None + except Exception as e: + logger.error(f"Ошибка чтения JPEG EXIF {path.name}: {e}") + return None, None + + @classmethod + def parse_comfy_parameters(cls, prompt_json_str: Optional[str]) -> Dict[str, Any]: + result = { + "positive_prompt": None, "negative_prompt": None, "seed": None, + "model_name": None, "sampler": None, "steps": None, "cfg": None + } + if not prompt_json_str: + return result + try: + prompt_graph = json.loads(prompt_json_str) + if not isinstance(prompt_graph, dict): + return result + except json.JSONDecodeError: + return result + + sampler_node = None + for node_id, node in prompt_graph.items(): + class_type = node.get("class_type", "") + if "KSampler" in class_type: + sampler_node = node + break + + if sampler_node: + inputs = sampler_node.get("inputs", {}) + result["seed"] = inputs.get("seed") or inputs.get("noise_seed") + result["steps"] = inputs.get("steps") + result["cfg"] = inputs.get("cfg") + result["sampler"] = inputs.get("sampler_name") + result["positive_prompt"] = cls._trace_conditioning(inputs.get("positive"), prompt_graph) + result["negative_prompt"] = cls._trace_conditioning(inputs.get("negative"), prompt_graph) + result["model_name"] = cls._trace_model(inputs.get("model"), prompt_graph) + else: + positives = [] + for node in prompt_graph.values(): + if node.get("class_type") == "CLIPTextEncode": + text = node.get("inputs", {}).get("text", "") + if text and len(text.strip()) > 0: + positives.append(text.strip()) + if positives: + result["positive_prompt"] = "\n---\n".join(positives) + + def clean_string(val) -> Optional[str]: + if val is None: return None + if isinstance(val, list): + if all(isinstance(x, str) for x in val): + return "\n".join(val) + return json.dumps(val) + if isinstance(val, dict): return json.dumps(val) + return str(val) + + def clean_int(val) -> Optional[int]: + if val is None or isinstance(val, (list, dict)): return None + try: return int(val) + except (ValueError, TypeError): return None + + def clean_float(val) -> Optional[float]: + if val is None or isinstance(val, (list, dict)): return None + try: return float(val) + except (ValueError, TypeError): return None + + result["positive_prompt"] = clean_string(result["positive_prompt"]) + result["negative_prompt"] = clean_string(result["negative_prompt"]) + result["model_name"] = clean_string(result["model_name"]) + result["sampler"] = clean_string(result["sampler"]) + result["seed"] = clean_int(result["seed"]) + result["steps"] = clean_int(result["steps"]) + result["cfg"] = clean_float(result["cfg"]) + return result + + @classmethod + def _trace_conditioning(cls, link: Optional[list], graph: dict) -> Optional[str]: + if not link or not isinstance(link, list) or len(link) < 1: + return None + node_id = str(link[0]) + node = graph.get(node_id) + if not node: + return None + class_type = node.get("class_type", "") + inputs = node.get("inputs", {}) + if class_type in ("CLIPTextEncode", "CLIPTextEncodeSDXL", "CLIPTextEncodeSequence"): + return inputs.get("text") or inputs.get("text_g") + if "Conditioning" in class_type: + for key, val in inputs.items(): + if isinstance(val, list) and len(val) >= 1: + text = cls._trace_conditioning(val, graph) + if text: return text + return None + + @classmethod + def _trace_model(cls, link: Optional[list], graph: dict) -> Optional[str]: + if not link or not isinstance(link, list) or len(link) < 1: + return None + node_id = str(link[0]) + node = graph.get(node_id) + if not node: + return None + class_type = node.get("class_type", "") + inputs = node.get("inputs", {}) + if "CheckpointLoader" in class_type: + return inputs.get("ckpt_name") + elif "LoraLoader" in class_type or "ModelMerge" in class_type: + return cls._trace_model(inputs.get("model"), graph) + return None \ No newline at end of file diff --git a/settings.json b/settings.json new file mode 100644 index 0000000..0a6e709 --- /dev/null +++ b/settings.json @@ -0,0 +1,6 @@ +{ + "comfyui_url": "http://192.168.1.151:8000", + "tracked_paths": [ + "C:\\Users\\dimir\\proects\\ComfyGallery\\test_output" + ] +} \ No newline at end of file diff --git a/test_integration.py b/test_integration.py new file mode 100644 index 0000000..c5b795f --- /dev/null +++ b/test_integration.py @@ -0,0 +1,23 @@ +# test_integration.py +import time +from database.db_manager import DBManager +from watcher.fs_watcher import FolderWatcher + +if __name__ == "__main__": + # Инициализируем менеджер БД (создаст comfygallery.db в корне) + db = DBManager("comfygallery.db") + + # Создаем вотчер + watcher = FolderWatcher(db) + + # Запускаем отслеживание тестовой папки + watch_dir = "./test_output" + watcher.start_monitoring(watch_dir) + + print(f"Слежение запущено за '{watch_dir}'. Поместите туда PNG/JPG из ComfyUI...") + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + print("Остановка мониторинга...") + watcher.stop_monitoring() \ No newline at end of file diff --git a/ui/__pycache__/main_window.cpython-312.pyc b/ui/__pycache__/main_window.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b38fc623b1a6dd8b59b5df3e9012bf6d0d7f5019 GIT binary patch literal 27244 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 KFE|!p&Hg{vMH#CA literal 0 HcmV?d00001 diff --git a/ui/__pycache__/styles.cpython-312.pyc b/ui/__pycache__/styles.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..563398de0e82ec0c92fed06bbe211190637aebc8 GIT binary patch literal 2051 zcmcgt-A)rh6y8;$qzPQ`1e0(V&e z?=$!;K7ri&0wj3lm1nlQbeI0%h4GxE+3wDq`ObH~bLRWpT*(3Z^=0AxkNb}E)BefK zO&_ji;o+-8owrWkUA{vau&{J^K)5{SlB!emsf55<#Q)IIDweeDE1@(-i(b|GR_VNS zc+6>sMYk`tk+SWqVtp%#-c~Xyw@JW7AETu@>nMhfo-M7Q9nFbYL54_EHcaz&ZoT1W z3?pwf>?IiLq2N)(G{#*bsbG`QRBP79Zo*xq`5B-gBHe$=Y1GANJw2tN8GFOoG+LMk zKET;hngSx^g*{}H;1n%ZsrCHlvL=+r2BWnbXJE`D767D=Oi#&%RKb&MGH~fe!vymq zMknvB(6tTX4Zvh|X(yJ%d%-2!r+i9bt83Qt*%XpprPlMGi^*C&goIKqJ1NC^g^WRh z+scnkme44>-ouS`I?!Z;v<9-oI3)5~4x@Vt6&WLQ;(<@j0pt%y?Gg}PL|PfNF>z`h zc#-SP#0KxKdDsN8c!}OD8Gk3CmWm=3U?i1Hr56z0;j*lGryF5Z&7?I%if@;PO*~8e zraXq&%Gu#?NT%|99at}#E+TK!dY<8ehzj*d$~P*a4Bh0RvZo|;ATcmh0wt9Ri4ROc z@m|HIyBh<2a&qndrOcD^CapeB>-JqT4Nuvia^yib~9e6nK9GG zM6Q~9Drl)))%!nOO{573sry<9VfzloW5oUIiJf)KuotFWUY754#6w{cW`=>&n`GpG z(dJ$y*7FiBYV^GHa@oC@f4uYh`AOsH{=xpqVZHv%xw4n=@5&x-9>MKqnv@4zH$$yp zZkr}>-R?gjLNMKL#=L2wzF_7-*uR+F4it?Ad!+9{0DG02IrzD}>$>jc%sY2s=JULs Hfj55uH-=|7 literal 0 HcmV?d00001 diff --git a/ui/main_window.py b/ui/main_window.py new file mode 100644 index 0000000..885c3d8 --- /dev/null +++ b/ui/main_window.py @@ -0,0 +1,354 @@ +# ui/main_window.py +import os +import json +import logging +import subprocess +from pathlib import Path +import requests +from PyQt6.QtWidgets import ( + QMainWindow, QSplitter, QWidget, QHBoxLayout, QVBoxLayout, + QStatusBar, QMessageBox, QApplication, QMenu, QInputDialog, QLineEdit, QPushButton +) +from PyQt6.QtCore import Qt, QModelIndex, QMimeData, QUrl, QThread, pyqtSignal + +from ui.styles import DARK_THEME_QSS +from ui.widgets.left_panel import LeftPanel +from ui.widgets.center_grid import CenterGrid +from ui.widgets.right_panel import RightPanel +from ui.widgets.settings_dialog import SettingsDialog + +from database.db_manager import DBManager +from watcher.fs_watcher import FolderWatcher +from utils.api_client import ComfyAPIClient +from utils.settings import load_settings, save_settings + +FilePathRole = Qt.ItemDataRole.UserRole + 2 + +logger = logging.getLogger("ComfyGallery.MainWindow") + + +class ComfySendWorker(QThread): + finished = pyqtSignal(bool, str) # (success, message) + + def __init__(self, api_client: ComfyAPIClient, prompt_json_str: str): + super().__init__() + self.api_client = api_client + self.prompt_json_str = prompt_json_str + + def run(self): + logger.info("Запущена фоновая задача отправки промта в ComfyUI.") + try: + prompt_data = json.loads(self.prompt_json_str) + payload = {"prompt": prompt_data} + url = f"{self.api_client.base_url}/prompt" + + logger.debug(f"Отправка POST запроса на {url}") + response = requests.post(url, json=payload, timeout=5) + + if response.status_code == 200: + res_data = response.json() + prompt_id = res_data.get("prompt_id", "Неизвестно") + logger.info(f"Задача успешно принята сервером ComfyUI. ID задачи: {prompt_id}") + self.finished.emit( + True, + f"Промт успешно добавлен в очередь генерации ComfyUI!\n\nID задачи: {prompt_id}" + ) + else: + logger.error(f"Сервер ComfyUI отклонил запрос. Код: {response.status_code}, Ответ: {response.text}") + self.finished.emit( + False, + f"Сервер ComfyUI вернул ошибку {response.status_code}:\n{response.text}" + ) + except json.JSONDecodeError as je: + logger.error(f"Не удалось распарсить prompt_json метаданных: {je}") + self.finished.emit(False, f"Ошибка метаданных изображения (неверный формат JSON):\n{je}") + except requests.exceptions.RequestException as re: + logger.error(f"Сетевое исключение при отправке на {self.api_client.base_url}: {re}") + self.finished.emit( + False, + f"Не удалось подключиться к ComfyUI по адресу {self.api_client.base_url}.\n" + f"Убедитесь, что сервер ComfyUI запущен на этом порту.\n\nПодробности:\n{re}" + ) + except Exception as e: + logger.error(f"Непредвиденная ошибка в сетевом воркере: {e}") + self.finished.emit(False, f"Произошла непредвиденная ошибка при отправке: {e}") + + +class MainWindow(QMainWindow): + def __init__(self, db_manager: DBManager, watcher: FolderWatcher): + super().__init__() + self.db_manager = db_manager + self.watcher = watcher + + self.settings = load_settings() + self.current_root_path = None + self.api_client = ComfyAPIClient(self.settings["comfyui_url"]) + + self.setWindowTitle("ComfyGallery") + self.resize(1300, 850) + self.setStyleSheet(DARK_THEME_QSS) + + self.init_ui() + self.bind_events() + + self.left_panel.set_tracked_folders(self.settings["tracked_paths"]) + logger.info("Главное окно успешно инициализировано.") + + def init_ui(self): + central_widget = QWidget() + self.setCentralWidget(central_widget) + + main_layout = QVBoxLayout(central_widget) + main_layout.setContentsMargins(8, 8, 8, 8) + + top_bar = QHBoxLayout() + self.settings_btn = QPushButton("Настройки подключения и папок") + top_bar.addWidget(self.settings_btn) + top_bar.addStretch() + main_layout.addLayout(top_bar) + + self.splitter = QSplitter(Qt.Orientation.Horizontal) + self.left_panel = LeftPanel() + self.center_grid = CenterGrid() + self.right_panel = RightPanel() + + self.splitter.addWidget(self.left_panel) + self.splitter.addWidget(self.center_grid) + self.splitter.addWidget(self.right_panel) + + self.splitter.setSizes([230, 720, 350]) + main_layout.addWidget(self.splitter) + + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + self.status_bar.showMessage("Готов к работе") + + def bind_events(self): + self.left_panel.tree_view.clicked.connect(self._on_subfolder_selected) + self.left_panel.root_folder_changed.connect(self.load_folder_images) + self.center_grid.list_view.selectionModel().selectionChanged.connect(self._on_image_selected) + + self.center_grid.list_view.copy_pressed.connect(self._on_copy_file) + self.center_grid.list_view.delete_pressed.connect(self._on_delete_files) + + # Контекстное меню + self.center_grid.list_view.customContextMenuRequested.connect(self._on_context_menu) + + self.right_panel.save_clicked.connect(self._on_save_metadata) + self.right_panel.send_to_comfy_clicked.connect(self._on_send_to_comfy) + self.left_panel.search_changed.connect(self._on_search) + + self.settings_btn.clicked.connect(self._open_settings) + + # Автоматическое реактивное обновление + self.watcher.signals.file_added.connect(self._on_file_added_externally) + self.watcher.signals.file_removed.connect(self._on_file_removed_externally) + + def load_folder_images(self, folder_path: str, search_query: str = ""): + logger.info(f"Загрузка изображений из папки: {folder_path} (фильтр: '{search_query}')") + self.current_root_path = folder_path + self.center_grid.path_label.setText(f"Папка: {folder_path}") + + files = self.db_manager.get_files_in_folder(folder_path, search_query) + self.center_grid.model.set_files(files) + self.status_bar.showMessage(f"Файлов отображено: {len(files)}") + + def _on_subfolder_selected(self, index: QModelIndex): + folder_path = self.left_panel.folder_model.filePath(index) + self.load_folder_images(folder_path) + + def _on_image_selected(self): + indexes = self.center_grid.list_view.selectedIndexes() + if not indexes: + self.right_panel.clear_fields() + return + + idx = indexes[0] + file_id = self.center_grid.model.data(idx, Qt.ItemDataRole.UserRole + 1) + if file_id: + logger.debug(f"Выделен файл с ID: {file_id}") + details = self.db_manager.get_file_details(file_id) + self.right_panel.display_metadata(details) + + def _on_search(self, query: str): + if self.current_root_path: + self.load_folder_images(self.current_root_path, query) + + # --- РЕАКТИВНЫЕ СОБЫТИЯ WATCHDOG --- + + def _on_file_added_externally(self, filepath: str): + logger.debug(f"Событие Watcher: обнаружен новый файл {filepath}") + file_dir = str(Path(filepath).parent.resolve().as_posix()) + current_dir = str(Path(self.current_root_path).resolve().as_posix()) if self.current_root_path else "" + + if file_dir == current_dir: + self.load_folder_images(self.current_root_path) + + def _on_file_removed_externally(self, filepath: str): + logger.debug(f"Событие Watcher: файл удален {filepath}") + file_dir = str(Path(filepath).parent.resolve().as_posix()) + current_dir = str(Path(self.current_root_path).resolve().as_posix()) if self.current_root_path else "" + + if file_dir == current_dir: + self.load_folder_images(self.current_root_path) + + # --- КОНТЕКСТНОЕ МЕНЮ И CRUD --- + + def _on_context_menu(self, position): + indexes = self.center_grid.list_view.selectedIndexes() + if not indexes: return + + menu = QMenu(self) + + # Новый пункт контекстного меню + show_action = menu.addAction("Показать на диске") + show_action.setEnabled(len(indexes) == 1) + + menu.addSeparator() + + rename_action = menu.addAction("Переименовать") + rename_action.setEnabled(len(indexes) == 1) + + delete_action = menu.addAction("Удалить выбранные") + + action = menu.exec(self.center_grid.list_view.mapToGlobal(position)) + + if action == show_action: + idx = indexes[0] + filepath = self.center_grid.model.data(idx, FilePathRole) + self._show_in_explorer(filepath) + + elif action == rename_action: + idx = indexes[0] + filepath = self.center_grid.model.data(idx, FilePathRole) + filename = self.center_grid.model.data(idx, Qt.ItemDataRole.DisplayRole) + + new_name, ok = QInputDialog.getText( + self, "Переименование", f"Новое имя файла для {filename}:", + QLineEdit.EchoMode.Normal, filename + ) + if ok and new_name.strip() and new_name != filename: + self._rename_file(filepath, new_name.strip()) + + elif action == delete_action: + paths = [self.center_grid.model.data(idx, FilePathRole) for idx in indexes] + self._on_delete_files(paths) + + def _show_in_explorer(self, filepath: str): + """ Открывает Проводник Windows и подсвечивает (выделяет) данный файл. """ + norm_path = os.path.normpath(filepath) + if not os.path.exists(norm_path): + logger.warning(f"Попытка показать несуществующий файл: {norm_path}") + return + + logger.info(f"Открытие проводника для файла: {norm_path}") + try: + # Команда explorer /select открывает папку и фокусом выделяет файл + subprocess.run(f'explorer /select,"{norm_path}"', shell=True) + except Exception as e: + logger.error(f"Не удалось открыть Проводник Windows: {e}") + + def _rename_file(self, old_filepath: str, new_filename: str): + old_path = Path(old_filepath) + new_filepath = old_path.parent / new_filename + + if not new_filepath.suffix: + new_filepath = new_filepath.with_suffix(old_path.suffix) + + logger.info(f"Переименование файла: {old_path.name} -> {new_filepath.name}") + try: + os.rename(str(old_path), str(new_filepath)) + self.status_bar.showMessage(f"Файл успешно переименован: {new_filepath.name}") + if self.current_root_path: + self.load_folder_images(self.current_root_path) + except Exception as e: + logger.error(f"Ошибка переименования файла {old_path.name}: {e}") + QMessageBox.critical(self, "Ошибка", f"Не удалось переименовать файл: {e}") + + def _on_copy_file(self, filepath: str): + logger.info(f"Копирование файла в буфер обмена: {filepath}") + if os.path.exists(filepath): + mime_data = QMimeData() + mime_data.setUrls([QUrl.fromLocalFile(filepath)]) + QApplication.clipboard().setMimeData(mime_data) + self.status_bar.showMessage("Изображение скопировано в буфер обмена.") + + def _on_delete_files(self, filepaths: list): + if not filepaths: return + + logger.info(f"Запрос удаления группы файлов: {len(filepaths)} шт.") + confirm = QMessageBox.question( + self, "Удаление", f"Вы действительно хотите безвозвратно удалить {len(filepaths)} файлов?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + if confirm == QMessageBox.StandardButton.Yes: + deleted_count = 0 + for path in filepaths: + try: + if os.path.exists(path): + os.remove(path) + deleted_count += 1 + logger.debug(f"Файл успешно удален физически: {path}") + except Exception as e: + logger.error(f"Ошибка физического удаления файла {path}: {e}") + self.status_bar.showMessage(f"Удалено файлов с диска: {deleted_count}") + if self.current_root_path: + self.load_folder_images(self.current_root_path) + + # --- НАСТРОЙКИ --- + + def _open_settings(self): + logger.info("Открытие диалогового окна настроек.") + dialog = SettingsDialog(self.settings, self) + if dialog.exec() == SettingsDialog.DialogCode.Accepted: + save_settings(self.settings) + logger.info("Пользователь сохранил новые настройки.") + + self.api_client = ComfyAPIClient(self.settings["comfyui_url"]) + self.left_panel.set_tracked_folders(self.settings["tracked_paths"]) + + self.status_bar.showMessage("Перезапуск службы отслеживания...") + self.watcher.stop_monitoring() + self.watcher.start_monitoring(self.settings["tracked_paths"]) + + if self.settings["tracked_paths"]: + first_path = self.settings["tracked_paths"][0] + self.left_panel.set_root_path(first_path) + self.load_folder_images(first_path) + + self.status_bar.showMessage("Настройки успешно применены.") + + # --- ВЗАИМОДЕЙСТВИЕ С COMFYUI --- + + def _on_save_metadata(self, file_id: int, payload: dict): + logger.info(f"Сохранение измененных пользователем метаданных для ID {file_id}") + success = self.db_manager.update_file_details( + file_id, payload["positive_prompt"], payload["negative_prompt"], payload["rating"] + ) + if success: + self.status_bar.showMessage("Параметры успешно обновлены.") + details = self.db_manager.get_file_details(file_id) + self.right_panel.display_metadata(details) + if self.current_root_path: + self.load_folder_images(self.current_root_path) + else: + logger.error(f"Не удалось обновить метаданные в БД для ID {file_id}") + QMessageBox.critical(self, "Ошибка", "Не удалось сохранить изменения в БД.") + + def _on_send_to_comfy(self, prompt_json: str): + self.status_bar.showMessage("Отправка промта в ComfyUI...") + self.right_panel.send_btn.setEnabled(False) + + # Создаем и запускаем фоновый поток воркера + self.comfy_sender = ComfySendWorker(self.api_client, prompt_json) + self.comfy_sender.finished.connect(self._on_send_finished) + self.comfy_sender.start() + + def _on_send_finished(self, success: bool, message: str): + self.right_panel.send_btn.setEnabled(True) + if success: + self.status_bar.showMessage("Промт успешно добавлен в очередь ComfyUI!") + QMessageBox.information(self, "Успешно отправлено", message) + else: + self.status_bar.showMessage("Не удалось отправить промт.") + QMessageBox.critical(self, "Ошибка сети / API", message) \ No newline at end of file diff --git a/ui/styles.py b/ui/styles.py new file mode 100644 index 0000000..58d752e --- /dev/null +++ b/ui/styles.py @@ -0,0 +1,96 @@ +# ui/styles.py +DARK_THEME_QSS = """ +QMainWindow { + background-color: #121212; +} +QWidget { + background-color: #121212; + color: #e0e0e0; + font-family: 'Segoe UI', Arial, sans-serif; + font-size: 13px; +} +QSplitter::handle { + background-color: #252525; +} +QSplitter::handle:horizontal { + width: 6px; +} +QTreeView { + background-color: #1e1e1e; + border: 1px solid #2d2d2d; + border-radius: 4px; +} +QTreeView::item:hover { + background-color: #2a2a2a; +} +QTreeView::item:selected { + background-color: #0d47a1; + color: #ffffff; +} +QLineEdit { + background-color: #242424; + border: 1px solid #3d3d3d; + border-radius: 4px; + padding: 6px; + color: #ffffff; +} +QLineEdit:focus { + border: 1px solid #1976d2; +} +QTabWidget::pane { + border: 1px solid #2d2d2d; + background-color: #1a1a1a; + border-radius: 4px; +} +QTabBar::tab { + background-color: #151515; + color: #888888; + padding: 8px 16px; + border: 1px solid #2d2d2d; + border-bottom: none; + margin-right: 2px; +} +QTabBar::tab:selected { + background-color: #1a1a1a; + color: #ffffff; + border-bottom: 2px solid #1976d2; +} +QTextEdit { + background-color: #181818; + border: 1px solid #2d2d2d; + border-radius: 4px; + color: #e0e0e0; + font-family: 'Consolas', monospace; +} +QPushButton { + background-color: #242424; + border: 1px solid #3d3d3d; + border-radius: 4px; + padding: 8px 14px; + color: #ffffff; + font-weight: 500; +} +QPushButton:hover { + background-color: #2d2d2d; + border-color: #555555; +} +QPushButton:pressed { + background-color: #1976d2; +} +QComboBox { + background-color: #242424; + border: 1px solid #3d3d3d; + border-radius: 4px; + padding: 4px; + color: #ffffff; +} +QScrollBar:vertical { + border: none; + background-color: #121212; + width: 8px; +} +QScrollBar::handle:vertical { + background-color: #3a3a3a; + border-radius: 4px; +} +""" \ No newline at end of file diff --git a/ui/widgets/__pycache__/center_grid.cpython-312.pyc b/ui/widgets/__pycache__/center_grid.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f85c679e84ebc03bf08299117c27a16fc8d0b0ba GIT binary patch literal 20959 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 V-q$^NmTspzE|mX{0*5TN{|ntnD+&Mr literal 0 HcmV?d00001 diff --git a/ui/widgets/__pycache__/left_panel.cpython-312.pyc b/ui/widgets/__pycache__/left_panel.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bfeea3d43d30c85d57841132c3e565a7da9d9210 GIT binary patch literal 4358 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 bTf#Yl|IV%W#+`OL_BwvC_*;%9Xs7=Ni+m$2 literal 0 HcmV?d00001 diff --git a/ui/widgets/__pycache__/settings_dialog.cpython-312.pyc b/ui/widgets/__pycache__/settings_dialog.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..94a5ab7ef1fffdc6a78c5f112db01437cb31cd53 GIT binary patch literal 5754 zcmb7IO>i4U7VeQoa@&?A%YU&GJ5v0Eqa-#q1Y#Q;lh}c!HfxfK!9j6qN@xa0LO*6^ zL}Hg+gaC_OBqZ6NvQz9XruINmIbhg?G9&~z?O`v|C1uCV`asok**#?wSYS{4dZgCK zUWFy2Qupib_g??J?)Sd#zf@F|A^7^wR7Jxsg#Jzo>~rLZJC6e~k3=MLF%;*5T-+A4 z#qB|RoDcGGN6-;>2A%PeU`ZSYalABG${`zi0*SWQkZ6~<%l53EVA&)eaQuT70;NXD z!J$YfmK-WkQ5~}E`hRew)bqB8w$G91LZehQW6SsA`03>=PY`j6S>z) zgLXRbATQb>b%;EqPSF8r$t*g-1@R#iC^6iJBvp+h#+7V`VWyY%!o#1I?<_#_TW%hy zkY~R+`!qTO%Q|hl%w=mhG|f#T!NxeIQM827RxWY{{3u##wJ=JwiT2AmEua%c%UO$+ zU(S&nnnVG9IJFDP+V8a`@(MXcPHH!_kF`&1LKcOLlSTr4ka}q$p#jLNFt&Nf`OIX>KJaIMk5I^d3+?I#w5cjOG;!)GD=L& zR3yL~yduS(H!8y^S(Xy2kQa4MglL6PDhT0NNKphqp)2e6`%nI7&tTu!(~2Z3V`3y8 zk;f+Fq!d<_vB6~g`N=0jv6v)Jj-?`F$IZQ=j4`ExXzsbriAlK|d$hI?JD~SivqGZz6b;*7$S=KLf?H<#Xld1{bO`xQUir zbFMS)JD>oCbB3lga>C!-8pmK0z{be8O(SOD8E%@37H%g~5FwSFMmU9xm*pH%^P7el z88#JNF7t!!Kxee>E4D>|6THCsFX~xegWz;q8_pU>@v#^0|Nlu9%$R4qEQc9;*_aAu zoIe=%Fb~H#Vh3jk9c@Br#s<<=OuLmwYb}~lg$MbxkB7rt%yz}~70hJ=)5qpAV^}kE!ZJ z-~IP@Kd`H_3x3^wJzZU0sa`7o2MCj|XxAVjexxmHA8JcLT_4KfW9MrlYCvp7noAxfsv#3+UJfQUW;u&e|E=!kSem8kS~QLb3#H4 zV3<(?I-XRexJ(0;OyiB=f+};)!gFfEC<(_RbQOR|CK3>@A+$3F!y`-a=B{ELx%C2bAIW%hUvA2dv3b!yJbgJJ$X%_+jqg2!GW(g-S>9ChU-@w+qJFFr5nSudUamiJ1&ms9lJ@# z?hGDS!SyA0QX_KW=c z<(JAcc;^bP%F1W3&!p+l7e?MWdf{jWZ!fG7E(jUikssE=#+$fRrZK2EsOyA{PzdUx zc~lP~xo?`nw_H7idh!Vi=75W&r*GX?4DB;#%N*elI%^Bt#!=XIbbAiK6w_3`PBj!Q z029qa3yX{ObKZ8kgGV!VfJWT3{UuIryvtaN0LAG%T&2#K{=zm6{xi1YC}0~-?S$^M zUoj7s^@TTzdfK&~Fo0!xbv0_h8|^y*9tf^uLlGHHe^Q<_9C9czF3B|X8;)=?1s4fE z0^Z5sRqDrDpgzE79Rn64vgvmWE@C(+lfrHqITt_y!c_e_7m)W|UC^q$3<)@a>dkuf zgQWVwr6H|vB3=FBEdH{*dbNM==R5yA@Wp^Wc$f?xP7ek%{YTO@W2FDcGIwrdUN|r4 zwE~ zqxl<()p(cQ^0$=&&is{_ZE6mk?%`p(T;hhoRHh{>;I0S;mdrq-7+4LxAkmm(*yZH$ zfIVkV#$<4cLNP_&l?R|OwQ0Q_3*rpD5i3KGoJ1?t8|DYj59rlhq`E6z{Qy`J>`?bN zZ$p7^Z6&VO#gX@fcLlwzhqU!9d(&-?+;lzqFL1RRf5cO0rl+{e;uL{F%oN3zn6;T^ zs<$qJ#jbqR`-nF7AVbfiS`M={KN!tVy!x>onW2h!pJJNwmj~A48t?NXvo7#HcIBtc z&|Wx0`o2=^Iw$gv!a%0sMW$%0!)!&xE`4nNbo~l^nRA%ot66}39{?I$N<9Q!tizpv z(Eu=B2ekJQV9`^4KyGhlz20Yl_2^T~o7(lx)X%9T1&P;y?^A85fR7@@EOew;l(v!A z0qUKik2Ijn56xGyA`F%;b;|T&D8ua*1SB5P?_Vj13;7?p`s2GP_6Tt6u3{-tUqlOveB0n<#R~jn7OOY zCgu{ldkb-INxNHgcYwG9Y4`S7`9*$>Ri4|AX*S))!)z4o3 z_+@=ygba+N2cF6Fj;1RQlity#J?CoXedm3;w~ct)7DH+8R^8h{yd5{a_iMeQvktBD z@Ry#NHF|F$v}?%r4MG+BY@q$C_HDB~1sUh!0A^MKow-ty;QJ_ggNL3o6+hheAn&uB zLZj>#Te}8ZczHLF@U983V2My%f>*FIL5L^CRE*LtL3lA0ie+2Ow>i=xgKg2a2DvXEEuIRTBZs0pb9nT_phOEZ4SqV+istu?e<>I;dtP- zn|Jj8+g0JHGhL9$KO87GJO?HZst int: + return len(self.files) + + def supportedDragActions(self) -> Qt.DropAction: + """ Указываем, что модель поддерживает копирование при Drag-and-Drop """ + return Qt.DropAction.CopyAction + + def mimeTypes(self) -> List[str]: + return ["text/uri-list"] + + def mimeData(self, indexes: List[QModelIndex]) -> QMimeData: + """ Преобразует выделенные файлы в формат UriList для ОС и браузеров """ + mime_data = QMimeData() + urls = [] + for index in indexes: + if index.isValid(): + filepath = self.data(index, FilePathRole) + if filepath and os.path.exists(filepath): + urls.append(QUrl.fromLocalFile(filepath)) + mime_data.setUrls(urls) + return mime_data + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if not index.isValid() or index.row() >= len(self.files): + return None + file_data = self.files[index.row()] + filepath = file_data["filepath"] + + if role == Qt.ItemDataRole.DisplayRole: + return file_data["filename"] + elif role == FileIdRole: + return file_data["id"] + elif role == FilePathRole: + return filepath + elif role == HasWorkflowRole: + return bool(file_data["has_workflow"]) + elif role == RatingRole: + return file_data["rating"] + elif role == PixmapRole: + if filepath in self.pixmap_cache: + return self.pixmap_cache[filepath] + if filepath not in self.loading_paths: + self.loading_paths.add(filepath) + runnable = ThumbnailRunnable(filepath, self.thumbnail_size, self.signals) + self.thread_pool.start(runnable) + return None + return None + + def _on_thumbnail_loaded(self, filepath: str, qimage: QImage): + if filepath in self.loading_paths: + self.loading_paths.remove(filepath) + pixmap = QPixmap.fromImage(qimage) + self.pixmap_cache[filepath] = pixmap + for row, f in enumerate(self.files): + if f["filepath"] == filepath: + idx = self.index(row) + self.dataChanged.emit(idx, idx, [PixmapRole]) + break + + +class ImageDelegate(QStyledItemDelegate): + def __init__(self, model: ImageModel, parent=None): + super().__init__(parent) + self.model = model + + def paint(self, painter: QPainter, option: "QStyleOptionViewItem", index: QModelIndex): + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = option.rect + selected = option.state & QStyle.StateFlag.State_Selected + hovered = option.state & QStyle.StateFlag.State_MouseOver + + bg_color = QColor("#1c1c1c") if hovered else QColor("#141414") + if selected: + bg_color = QColor("#0d47a1") + painter.setBrush(QBrush(bg_color)) + painter.setPen(QPen(QColor("#1976d2") if selected else QColor("#2d2d2d"), 1.5)) + painter.drawRoundedRect(rect.adjusted(2, 2, -2, -2), 6, 6) + + cell_size = self.model.thumbnail_size + img_rect = QRect(rect.left() + 8, rect.top() + 8, cell_size - 16, cell_size - 16) + + pixmap = index.data(PixmapRole) + if pixmap and not pixmap.isNull(): + w, h = pixmap.width(), pixmap.height() + target_w, target_h = img_rect.width(), img_rect.height() + if w > 0 and h > 0: + ratio = min(target_w / w, target_h / h) + final_w, final_h = int(w * ratio), int(h * ratio) + x_offset = img_rect.left() + (target_w - final_w) // 2 + y_offset = img_rect.top() + (target_h - final_h) // 2 + painter.drawPixmap(x_offset, y_offset, final_w, final_h, pixmap) + else: + painter.setBrush(QBrush(QColor("#0a0a0a"))) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawRoundedRect(img_rect, 4, 4) + + text = index.data(Qt.ItemDataRole.DisplayRole) + if text: + font = painter.font() + font.setPointSize(8) + painter.setFont(font) + fm = QFontMetrics(font) + text_rect = QRect(rect.left() + 4, rect.bottom() - 20, rect.width() - 8, 16) + elided_text = fm.elidedText(text, Qt.TextElideMode.ElideMiddle, text_rect.width()) + painter.setPen(QPen(QColor("#ffffff") if selected else QColor("#8e8e8e"))) + painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, elided_text) + + has_workflow = index.data(HasWorkflowRole) + if has_workflow: + badge_rect = QRect(rect.right() - 22, rect.top() + 8, 14, 14) + painter.setBrush(QBrush(QColor("#2ea44f"))) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawEllipse(badge_rect) + painter.setPen(QPen(QColor("#ffffff"))) + font = painter.font() + font.setBold(True) + font.setPointSize(7) + painter.setFont(font) + painter.drawText(badge_rect.adjusted(0, -1, 0, 0), Qt.AlignmentFlag.AlignCenter, "W") + + painter.restore() + + def sizeHint(self, option, index) -> QSize: + size = self.model.thumbnail_size + return QSize(size, size + 22) + + +class GalleryListView(QListView): + delete_pressed = pyqtSignal(list) + copy_pressed = pyqtSignal(str) + + def keyPressEvent(self, event): + if event.modifiers() == Qt.KeyboardModifier.ControlModifier and event.key() == Qt.Key.Key_C: + indexes = self.selectedIndexes() + if indexes: + filepath = self.model().data(indexes[0], FilePathRole) + if filepath: + self.copy_pressed.emit(filepath) + event.accept() + return + if event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace): + indexes = self.selectedIndexes() + if indexes: + paths = [self.model().data(idx, FilePathRole) for idx in indexes if idx.isValid()] + self.delete_pressed.emit(paths) + event.accept() + return + super().keyPressEvent(event) + + def startDrag(self, supportedActions): + """ + Переопределяем метод начала перетаскивания. + Принудительно инициализирует системное событие Drag-and-Drop для внешних окон. + """ + indexes = self.selectedIndexes() + if not indexes: + return + + logger.debug(f"Начало перетаскивания файлов: {len(indexes)} шт.") + drag = QDrag(self) + mime_data = self.model().mimeData(indexes) + drag.setMimeData(mime_data) + + # Установка миниатюры под курсор при перетаскивании + pixmap = self.model().data(indexes[0], PixmapRole) + if pixmap and not pixmap.isNull(): + drag.setPixmap(pixmap.scaled(70, 70, Qt.AspectRatioMode.KeepAspectRatio)) + + drag.exec(Qt.DropAction.CopyAction) + + +class CenterGrid(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.init_ui() + + def init_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + + control_layout = QHBoxLayout() + self.path_label = QLabel("Выберите папку...") + control_layout.addWidget(self.path_label) + control_layout.addStretch() + + control_layout.addWidget(QLabel("Масштаб:")) + self.zoom_slider = QSlider(Qt.Orientation.Horizontal) + self.zoom_slider.setRange(80, 250) + self.zoom_slider.setValue(130) + self.zoom_slider.setFixedWidth(150) + control_layout.addWidget(self.zoom_slider) + layout.addLayout(control_layout) + + self.list_view = GalleryListView() + self.list_view.setViewMode(QListView.ViewMode.IconMode) + self.list_view.setResizeMode(QListView.ResizeMode.Adjust) + self.list_view.setSelectionMode(QListView.SelectionMode.ExtendedSelection) + self.list_view.setDragEnabled(True) + self.list_view.setSpacing(10) + self.list_view.setUniformItemSizes(True) + self.list_view.setStyleSheet("QListView { border: 1px solid #2d2d2d; background-color: #121212; }") + + self.list_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + + self.model = ImageModel() + self.delegate = ImageDelegate(self.model) + self.list_view.setModel(self.model) + self.list_view.setItemDelegate(self.delegate) + layout.addWidget(self.list_view) + + self.zoom_slider.valueChanged.connect(self._on_zoom_changed) + + def _on_zoom_changed(self, value: int): + self.model.set_thumbnail_size(value) \ No newline at end of file diff --git a/ui/widgets/left_panel.py b/ui/widgets/left_panel.py new file mode 100644 index 0000000..eb44338 --- /dev/null +++ b/ui/widgets/left_panel.py @@ -0,0 +1,57 @@ +# ui/widgets/left_panel.py +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QLineEdit, QTreeView, QComboBox +from PyQt6.QtGui import QFileSystemModel +from PyQt6.QtCore import QDir, pyqtSignal + +class LeftPanel(QWidget): + search_changed = pyqtSignal(str) + root_folder_changed = pyqtSignal(str) # Сигнал переключения активной корневой папки + + def __init__(self, parent=None): + super().__init__(parent) + self.init_ui() + + def init_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Поиск по тегам или промтам...") + self.search_input.textChanged.connect(self.search_changed.emit) + layout.addWidget(self.search_input) + + # Выпадающий список выбора активной корневой директории + layout.addWidget(QLabel("Активная папка:")) + self.folder_selector = QComboBox() + self.folder_selector.currentTextChanged.connect(self._on_folder_changed) + layout.addWidget(self.folder_selector) + + layout.addWidget(QLabel("Дерево подпапок:")) + + self.folder_model = QFileSystemModel() + self.folder_model.setFilter(QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot) + + self.tree_view = QTreeView() + self.tree_view.setModel(self.folder_model) + self.tree_view.setHeaderHidden(True) + + for i in range(1, self.folder_model.columnCount()): + self.tree_view.setColumnHidden(i, True) + + layout.addWidget(self.tree_view) + + def set_tracked_folders(self, folders: list): + """ Обновляет выпадающий список папок """ + self.folder_selector.blockSignals(True) + self.folder_selector.clear() + self.folder_selector.addItems(folders) + self.folder_selector.blockSignals(False) + + def set_root_path(self, path: str): + self.folder_model.setRootPath(path) + self.tree_view.setRootIndex(self.folder_model.index(path)) + + def _on_folder_changed(self, path: str): + if path: + self.set_root_path(path) + self.root_folder_changed.emit(path) \ No newline at end of file diff --git a/ui/widgets/right_panel.py b/ui/widgets/right_panel.py new file mode 100644 index 0000000..d213ed2 --- /dev/null +++ b/ui/widgets/right_panel.py @@ -0,0 +1,186 @@ +# ui/widgets/right_panel.py +import json +from typing import Optional # <-- Добавлен пропущенный импорт +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTabWidget, + QTextEdit, QLineEdit, QPushButton, QFormLayout, QComboBox, QMessageBox, QApplication +) +from PyQt6.QtGui import QPixmap +from PyQt6.QtCore import Qt, pyqtSignal + +class RightPanel(QWidget): + save_clicked = pyqtSignal(int, dict) + send_to_comfy_clicked = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.current_file_id = None + self.current_filepath = None + self.current_raw_prompt = None + self.init_ui() + + def init_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + + self.preview_label = QLabel("Нет выделенного изображения") + self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.preview_label.setMinimumHeight(250) + self.preview_label.setStyleSheet( + "QLabel { background-color: #0f0f0f; border: 1px solid #2d2d2d; border-radius: 4px; }" + ) + layout.addWidget(self.preview_label) + + self.tabs = QTabWidget() + + self.gen_tab = QWidget() + gen_layout = QVBoxLayout(self.gen_tab) + gen_layout.setContentsMargins(5, 5, 5, 5) + + form_layout = QFormLayout() + self.model_input = QLineEdit() + self.model_input.setReadOnly(True) + form_layout.addRow("Модель:", self.model_input) + + self.sampler_input = QLineEdit() + self.sampler_input.setReadOnly(True) + form_layout.addRow("Сэмплер:", self.sampler_input) + + params_row = QHBoxLayout() + self.seed_input = QLineEdit() + self.seed_input.setReadOnly(True) + self.steps_input = QLineEdit() + self.steps_input.setReadOnly(True) + self.cfg_input = QLineEdit() + self.cfg_input.setReadOnly(True) + + params_row.addWidget(QLabel("Seed:")) + params_row.addWidget(self.seed_input) + params_row.addWidget(QLabel("Steps:")) + params_row.addWidget(self.steps_input) + params_row.addWidget(QLabel("CFG:")) + params_row.addWidget(self.cfg_input) + form_layout.addRow(params_row) + + self.rating_combo = QComboBox() + self.rating_combo.addItems(["Без рейтинга", "★", "★★", "★★★", "★★★★", "★★★★★"]) + form_layout.addRow("Рейтинг:", self.rating_combo) + gen_layout.addLayout(form_layout) + + gen_layout.addWidget(QLabel("Позитивный промт:")) + self.positive_text = QTextEdit() + gen_layout.addWidget(self.positive_text) + + gen_layout.addWidget(QLabel("Негативный промт:")) + self.negative_text = QTextEdit() + gen_layout.addWidget(self.negative_text) + + self.save_btn = QPushButton("Сохранить изменения") + self.save_btn.clicked.connect(self._on_save_clicked) + gen_layout.addWidget(self.save_btn) + + self.workflow_tab = QWidget() + wf_layout = QVBoxLayout(self.workflow_tab) + wf_layout.setContentsMargins(5, 5, 5, 5) + self.workflow_text = QTextEdit() + self.workflow_text.setReadOnly(True) + wf_layout.addWidget(self.workflow_text) + + self.tabs.addTab(self.gen_tab, "Generation") + self.tabs.addTab(self.workflow_tab, "Workflow") + layout.addWidget(self.tabs) + + export_layout = QHBoxLayout() + self.copy_pos_btn = QPushButton("Коп. Pos") + self.copy_pos_btn.clicked.connect(self._copy_positive) + self.copy_neg_btn = QPushButton("Коп. Neg") + self.copy_neg_btn.clicked.connect(self._copy_negative) + self.copy_wf_btn = QPushButton("Коп. Work") + self.copy_wf_btn.clicked.connect(self._copy_workflow) + export_layout.addWidget(self.copy_pos_btn) + export_layout.addWidget(self.copy_neg_btn) + export_layout.addWidget(self.copy_wf_btn) + layout.addLayout(export_layout) + + self.send_btn = QPushButton("Отправить в ComfyUI") + self.send_btn.clicked.connect(self._on_send_clicked) + layout.addWidget(self.send_btn) + + def display_metadata(self, file_details: Optional[dict]): + if not file_details: + self.clear_fields() + return + self.current_file_id = file_details["id"] + self.current_filepath = file_details["filepath"] + self.current_raw_prompt = file_details.get("prompt_json") + + pixmap = QPixmap(self.current_filepath) + if not pixmap.isNull(): + scaled = pixmap.scaled( + self.preview_label.width(), self.preview_label.height(), + Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation + ) + self.preview_label.setPixmap(scaled) + else: + self.preview_label.setText("Ошибка рендеринга превью") + + self.model_input.setText(file_details.get("model_name") or "Не указано") + self.sampler_input.setText(file_details.get("sampler") or "Не указано") + self.seed_input.setText(str(file_details.get("seed") or "")) + self.steps_input.setText(str(file_details.get("steps") or "")) + self.cfg_input.setText(str(file_details.get("cfg") or "")) + + rating = file_details.get("rating", 0) + self.rating_combo.setCurrentIndex(rating if 0 <= rating <= 5 else 0) + + self.positive_text.setPlainText(file_details.get("positive_prompt") or "") + self.negative_text.setPlainText(file_details.get("negative_prompt") or "") + + wf_raw = file_details.get("workflow_json") + if wf_raw: + try: + self.workflow_text.setPlainText(json.dumps(json.loads(wf_raw), indent=2, ensure_ascii=False)) + except Exception: + self.workflow_text.setPlainText(wf_raw) + else: + self.workflow_text.setPlainText("Workflow не найден") + + def clear_fields(self): + self.current_file_id = None + self.current_filepath = None + self.current_raw_prompt = None + self.preview_label.setText("Нет выделенного изображения") + self.preview_label.setPixmap(QPixmap()) + self.model_input.clear() + self.sampler_input.clear() + self.seed_input.clear() + self.steps_input.clear() + self.cfg_input.clear() + self.rating_combo.setCurrentIndex(0) + self.positive_text.clear() + self.negative_text.clear() + self.workflow_text.clear() + + def _on_save_clicked(self): + if self.current_file_id is None: return + payload = { + "positive_prompt": self.positive_text.toPlainText(), + "negative_prompt": self.negative_text.toPlainText(), + "rating": self.rating_combo.currentIndex() + } + self.save_clicked.emit(self.current_file_id, payload) + + def _on_send_clicked(self): + if not self.current_raw_prompt: + QMessageBox.warning(self, "Ошибка", "У изображения отсутствует prompt-граф.") + return + self.send_to_comfy_clicked.emit(self.current_raw_prompt) + + def _copy_positive(self): + QApplication.clipboard().setText(self.positive_text.toPlainText()) + + def _copy_negative(self): + QApplication.clipboard().setText(self.negative_text.toPlainText()) + + def _copy_workflow(self): + QApplication.clipboard().setText(self.workflow_text.toPlainText()) \ No newline at end of file diff --git a/ui/widgets/settings_dialog.py b/ui/widgets/settings_dialog.py new file mode 100644 index 0000000..cf6b18f --- /dev/null +++ b/ui/widgets/settings_dialog.py @@ -0,0 +1,74 @@ +# ui/widgets/settings_dialog.py +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QListWidget, QFileDialog, QMessageBox +) + +class SettingsDialog(QDialog): + def __init__(self, current_settings, parent=None): + super().__init__(parent) + self.settings = current_settings + self.setWindowTitle("Настройки подключения и папок") + self.resize(500, 380) + self.init_ui() + + def init_ui(self): + layout = QVBoxLayout(self) + + layout.addWidget(QLabel("Адрес API ComfyUI:")) + self.url_input = QLineEdit() + self.url_input.setText(self.settings.get("comfyui_url", "http://127.0.0.1:8000")) + layout.addWidget(self.url_input) + + layout.addWidget(QLabel("Отслеживаемые папки генерации:")) + self.paths_list = QListWidget() + for p in self.settings.get("tracked_paths", []): + self.paths_list.addItem(p) + layout.addWidget(self.paths_list) + + paths_btn_layout = QHBoxLayout() + self.add_path_btn = QPushButton("Добавить дополнительную папку") + self.add_path_btn.clicked.connect(self._add_path) + self.remove_path_btn = QPushButton("Удалить выбранную") + self.remove_path_btn.clicked.connect(self._remove_path) + paths_btn_layout.addWidget(self.add_path_btn) + paths_btn_layout.addWidget(self.remove_path_btn) + layout.addLayout(paths_btn_layout) + + btn_layout = QHBoxLayout() + self.save_btn = QPushButton("Сохранить") + self.save_btn.clicked.connect(self._save) + self.cancel_btn = QPushButton("Отмена") + self.cancel_btn.clicked.connect(self.reject) + btn_layout.addStretch() + btn_layout.addWidget(self.save_btn) + btn_layout.addWidget(self.cancel_btn) + layout.addLayout(btn_layout) + + def _add_path(self): + dir_path = QFileDialog.getExistingDirectory(self, "Выбрать отслеживаемую папку") + if dir_path: + items = [self.paths_list.item(i).text() for i in range(self.paths_list.count())] + if dir_path not in items: + self.paths_list.addItem(dir_path) + + def _remove_path(self): + selected = self.paths_list.selectedItems() + if not selected: return + for s in selected: + self.paths_list.takeItem(self.paths_list.row(s)) + + def _save(self): + url = self.url_input.text().strip() + if not url: + QMessageBox.warning(self, "Ошибка", "Адрес API не может быть пустым.") + return + + paths = [self.paths_list.item(i).text() for i in range(self.paths_list.count())] + if not paths: + QMessageBox.warning(self, "Ошибка", "Должна быть добавлена хотя бы одна папка.") + return + + self.settings["comfyui_url"] = url + self.settings["tracked_paths"] = paths + self.accept() \ No newline at end of file diff --git a/utils/__pycache__/api_client.cpython-312.pyc b/utils/__pycache__/api_client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d9bef8505d4f15dbce732ae4dc525aedb416f710 GIT binary patch literal 1800 zcmah}U2IcT96#rNwcR@IvIV+z3RlsD4)=i{0U9-7ivmi9aqvJLxs=}1+1mA%b57Y> zTQg;bnxM$jLI|=)3_f52$ubNoPb9vaDK`6>*(eBEI>fy zPDob3f=EtFmNcU#6iY_3r64t}Sn7{mtj2K zIUFB5Ob{kxR%RlD#|qd+DN4rH_dZsnnH(8Q7BXhaE4jZ=a)YLs$!Dl(mLpI0)9)KX zJOkxG)~dD{x#1+LDMO%$k^C3fEAb@FNvE#mwWa|m!xZw}ME zzgbX$1eRb3(A93U>!&8-@>N-#OO@Y5MasacTmf1C&Kgc0y}%K0QK~9bJq z;@>p{CAbw(FfYBVZE7!Xt0G zTI1Tvfq{nbK#Wy@OWdOypqU9|7wLoBeD_c>$|?KGsj+$XEn8q$4UT)m1I*$5aic67 zeMa#u;d7B)H=I-K8oSDFu$#`u&KZM!&u;RK1$M*v!a2?6oll%I+;G-l3w{XC$KYqK zIn(SqH=Z)eQrw8OU@;w6&Dlwdcz}0<7@a60LW!#sY(m{uLX)&e%#>{*SIgPs;|RMd z!q~>HLeUg;LvKu_P=RLbd@O(knsJ*5+S-Jzm83~GP)L?U5?AIASmYoDJ5P`c5fO+o z8Vd`o@Qf&Lna$}%|M6N6Md!jc0ICX zu7_>vtL?3C?4CV(?&y~X=XSD=?X}^h$d=jax$4~L<;fr5-N;aFUtNzZ>RTOs>m2!6 z@2y9Aerw+}8$K6iTXxTHTa1r5@e#KB;BCT2-&-6#=8PU=?XE1ZT++K9 z0g2}icbq-&*#XvrSUS6yCQh0#L>WEFCa2iR66-GC4S%@Q+4a}5%Do?x&Afq6_vk|^ z_;KR^r?=G5&};Io4bKnll5g!&xjYhs<&E!CT8Iw}?09i!{~-VEi0>L49E=6T_a&d4 zK&I)onC66S6>~xko92mPGS{@o{G)+e#b}k|ww=Sza`9_`#r(X9V}VSMh&N51;baCp zl%U!-*SP{XTh?^7`|?Z6fU~PF|1MbLv+&24=lLbkB<4H zUzgSmzS=1Gb-bM$#FvT)VOfR{-Us?U(0UJS_%#%*MHWL*ClqBpeRo3r)7l?OC+vO% IIPt3Z8wB&nkN^Mx literal 0 HcmV?d00001 diff --git a/utils/__pycache__/logger.cpython-312.pyc b/utils/__pycache__/logger.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3f76d0a2a6d79931f6114b45e0df009bbc49eaaa GIT binary patch literal 1890 zcmbVNO>7fK6rT0&+T&mclRqb=2^(5|oB)@C8leicO-NeQB$WWQu`D9jyD@gwUUzog z#3(@ypcVvDTD20XDx@5Xh|~b3m?BcC^wu6`6-$n`AXS1(Z=vB((H{E7_HIntQ%BmF z_w&u0_r4iF|LphIAs9WMHza=%5PHNEdx%wGZ489VNJcWJqHty9!W`%>)s^DIJjZOj zDx}1)2(m{O6j9;ZV3#Z^DCx5UuCHT)>^>)kJuakpu2s!poZ~|Hccu-6I7@gpYMg`> z4`+;omX4|xR<-!(SVC1SH&Ki%NyoVLh=^s&!?*Uji)Cv#z9i@yhs<58`lq=mE@@X< z12Y^s>2^d%uy=E{=U~U}2(rsYPP=L{Ds%3D%7Wprl&pW=!!#!fKgB1v{M$d}oWmig zr|~}!yd47&D8I>QwlT6`d!~^We90Euv%V(TWo{U2(k!{l=)W6NPtgo_nO8g34so`k^-t4q^8t_M}(gZhw zCixLG!G-FwXJM~Iuh2zv#yn%@=^`!A8!+E8&q(wxU7|N>0nAHf<2`WRlFF#-bP4bm zCHe!Xg);IxdW!MgMI7Bx~MEyVT3gW%U!9}5>w-Hf>@jS zp^eyayoV=aO1a5cqC$v9LTsRW5+h^E90JyLt4k*twW^+@HUeVJUjkZfkOVj!Im%>$qQH%xi- z*xyw0cbNW;xzo%3efjMm2ja?c>t}rzd^9*t)f631(`K!N zGZbfj+q5M=_}8k3fu8BC1O3?kMoaGjbpL?2ubsbtsHv}&|EX03Im8Wxyo9w-(f|{C z+QvyW@j>F;Bg5@e&gBcMCCJVoo(&VhF93s0wW&aWS0hm^K zj^iGo`d?AqL)7{ZZG9rNat(7m^L<|)T16l(zIXe?^%HAMe6k%q%GEHtC;*PlzXjKnFdBvXi%=HfyLmOvcd_(h@ILqV*!(CewwveCl#;UjAnqq40RIT2M#YfGL~ ztO{4eS$;2&ooE!%sv<@xUTvXrpD%eI#bxhtPkDufX4gOneFz@=A;wW*RLl!`d=%^W zoGUNpabDO1tIpL(D37CpD)BbE_8J)VqRpro>3BrIs1IRiIq8_#heq&bMC+_`wjkpC zZ=_M74|a~A;>v)W?Sz$nW6GE_elW+(PmCV{T{Tbnjhn`tdD1*(jv2R%>&6XZ-fzqs z*Zk&bSa99=$(Zv$M~p^4!(>&?(!qvZmoal_sc2dS+VlimOi1fY;-8+`v|~UoohiK? zhYof3boCB&b|2_qiXJ(t4OsqIb*Q7W_3(j11J)`gr88QJNt}U9N~9wylVem%>de`3 zEUIN_ES(BS3=c9%jnD|gM;Jb;^ThLmtnO@Y=|8M%MDJH)$r$O+khB)1dVf2oXjdeW z&`7R7OJfPW-?~F%CdU+hQ0Z1=Be>%n(Z@lJp+_!dJa{%ZzW40jiw$>O+m;;lch|jf z*YW03mCtbamIbHmUs7r=G|YG|joed$^UX`1SEhDP?w&sQ*`70@N8WAsy^W@~@po_2 zq%?sinjd)TrbFl7d$7?r5t^-cZoKsXb9IsU(-|Gq@|mkB>Y_zj2~)MFk|Clo$gETw8G%G_E?&k}7QqDNz~p6m{oHoi8` zKoWfi*)-=jPnM52u0p!ao8!-C-Bsg8i$7o^Jediv`lKEdP&}K=5HE*vhQv}dAQB&+ z<&~acP3PYYXir|_##&49k^TXwF|;g6@_zgRIo;Lc-DkTehG+1l*XOt1bHD#oEV9l7 z);)67an8XehhkKRZXdkeZdC5O@8~ui-Oo4$_v2N)37ns*Z57dlSA*iW4glBWR%HKK z1hjw(T6DH<5f|K*tv+$VCj$;Bg`f!kqhmIj%RtE>JOIMqlW;l}L+5pu*&`q8yp9lF zQJJVyQpn0ekeAU47*Qc==Gc==tJ#FMmo$KlQ&;~86x z=h#~h)_JFfCWofdlj)iCZ|mMQ>VtpFVx3^1>g9?`?4O|1?K7U~eUm3v5Db_7HoPBC Vhh{u8(HZX-Jx>rHlegiJe*wyc4OIXD literal 0 HcmV?d00001 diff --git a/utils/api_client.py b/utils/api_client.py new file mode 100644 index 0000000..64584ef --- /dev/null +++ b/utils/api_client.py @@ -0,0 +1,27 @@ +# utils/api_client.py +import requests +import json +import logging + +logger = logging.getLogger("ComfyGallery.API") + +class ComfyAPIClient: + def __init__(self, base_url: str = "http://127.0.0.1:8000"): + self.base_url = base_url.rstrip("/") + + def send_prompt(self, prompt_json_str: str) -> bool: + if not prompt_json_str: + return False + try: + prompt_data = json.loads(prompt_json_str) + payload = {"prompt": prompt_data} + url = f"{self.base_url}/prompt" + response = requests.post(url, json=payload, timeout=4) + if response.status_code == 200: + return True + else: + logger.error(f"Ошибка API ComfyUI: {response.status_code} - {response.text}") + return False + except Exception as e: + logger.error(f"Не удалось подключиться к ComfyUI по адресу {self.base_url}: {e}") + return False \ No newline at end of file diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000..ea78291 --- /dev/null +++ b/utils/logger.py @@ -0,0 +1,33 @@ +# utils/logger.py +import logging +import sys +from pathlib import Path +from typing import Optional + +def setup_logger(log_file: Optional[Path] = None) -> logging.Logger: + """ Настраивает логирование работы приложения в консоль и в файл comfygallery.log """ + logger = logging.getLogger("ComfyGallery") + logger.setLevel(logging.DEBUG) + + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + + # Вывод в консоль + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + # Вывод в файл + if log_file is None: + log_file = Path("comfygallery.log") + + try: + log_file.parent.mkdir(parents=True, exist_ok=True) + file_handler = logging.FileHandler(log_file, encoding='utf-8') + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + except Exception as e: + logger.error(f"Не удалось инициализировать файловый логгер: {e}") + + return logger + +logger = setup_logger() \ No newline at end of file diff --git a/utils/settings.py b/utils/settings.py new file mode 100644 index 0000000..62d9c0c --- /dev/null +++ b/utils/settings.py @@ -0,0 +1,35 @@ +# utils/settings.py +import json +import os +from typing import List, Dict, Any + +SETTINGS_FILE = "settings.json" + +DEFAULT_SETTINGS = { + "comfyui_url": "http://127.0.0.1:8000", # Новый порт по умолчанию + "tracked_paths": [os.path.abspath("./test_output")] +} + +def load_settings() -> Dict[str, Any]: + """ Загружает настройки из файла settings.json. """ + if not os.path.exists(SETTINGS_FILE): + save_settings(DEFAULT_SETTINGS) + return DEFAULT_SETTINGS + try: + with open(SETTINGS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + # Заполняем пропущенные ключи значениями по умолчанию + for k, v in DEFAULT_SETTINGS.items(): + if k not in data: + data[k] = v + return data + except Exception: + return DEFAULT_SETTINGS + +def save_settings(settings: Dict[str, Any]): + """ Сохраняет настройки в файл settings.json. """ + try: + with open(SETTINGS_FILE, "w", encoding="utf-8") as f: + json.dump(settings, f, indent=4, ensure_ascii=False) + except Exception as e: + print(f"Ошибка сохранения настроек: {e}") \ No newline at end of file diff --git a/watcher/__pycache__/fs_watcher.cpython-312.pyc b/watcher/__pycache__/fs_watcher.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fa74470d95382367d88523829d0c2a3f5a51994d GIT binary patch literal 9412 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 literal 0 HcmV?d00001 diff --git a/watcher/fs_watcher.py b/watcher/fs_watcher.py new file mode 100644 index 0000000..2a579b3 --- /dev/null +++ b/watcher/fs_watcher.py @@ -0,0 +1,142 @@ +# watcher/fs_watcher.py +import os +import time +import logging +from pathlib import Path +from typing import Optional, List +from PyQt6.QtCore import QObject, pyqtSignal +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler + +from database.db_manager import DBManager +from metadata.parser import MetadataParser + +logger = logging.getLogger("ComfyGallery.Watcher") + +class WatcherSignals(QObject): + """ Сигналы моста между сторонними потоками Watchdog и основным GUI-потоком PyQt6 """ + file_added = pyqtSignal(str) # (filepath) + file_removed = pyqtSignal(str) # (filepath) + + +class GalleryEventHandler(FileSystemEventHandler): + def __init__(self, db_manager: DBManager, root_path: str, signals: WatcherSignals): + super().__init__() + self.db_manager = db_manager + self.root_path = Path(root_path).resolve() + self.signals = signals + self._supported_extensions = ('.png', '.jpg', '.jpeg', '.webp') + + def on_created(self, event): + if event.is_directory: + self._register_folder_recursive(Path(event.src_path)) + else: + self._handle_file_creation(Path(event.src_path)) + + def on_deleted(self, event): + if event.is_directory: + self.db_manager.remove_folder_by_path(event.src_path) + else: + self.db_manager.remove_file_by_path(event.src_path) + self.signals.file_removed.emit(event.src_path) + + def on_moved(self, event): + src_path = Path(event.src_path) + dest_path = Path(event.dest_path) + if event.is_directory: + self.db_manager.remove_folder_by_path(str(src_path)) + self._register_folder_recursive(dest_path) + else: + self.db_manager.remove_file_by_path(str(src_path)) + self._handle_file_creation(dest_path) + + def _handle_file_creation(self, path: Path): + if path.suffix.lower() not in self._supported_extensions: + return + + # Пауза, гарантирующая полное высвобождение файлового дескриптора при записи из ComfyUI + time.sleep(0.3) + try: + stat = path.stat() + size = stat.st_size + mtime = stat.st_mtime + parent_path = path.parent.resolve() + folder_id = self._get_or_create_folder_id(parent_path) + if folder_id is not None: + file_id = self.db_manager.register_file( + folder_id=folder_id, filename=path.name, + filepath=str(path.as_posix()), size=size, mtime=mtime + ) + if file_id: + prompt_raw, workflow_raw = MetadataParser.extract_raw_metadata(str(path)) + parsed_params = MetadataParser.parse_comfy_parameters(prompt_raw) + meta_payload = { + "prompt_json": prompt_raw, "workflow_json": workflow_raw, + **parsed_params + } + self.db_manager.save_metadata(file_id, meta_payload) + # Мгновенная реактивная отправка сигнала о добавлении файла в GUI + self.signals.file_added.emit(str(path.as_posix())) + except FileNotFoundError: + pass + except Exception as e: + logger.error(f"Не удалось обработать файл {path}: {e}") + + def _get_or_create_folder_id(self, folder_path: Path) -> Optional[int]: + normalized_path = str(folder_path.resolve().as_posix()) + if not normalized_path.startswith(str(self.root_path.as_posix())): + return None + parent_path = folder_path.parent + parent_id = None + if parent_path != folder_path and normalized_path != str(self.root_path.as_posix()): + parent_id = self._get_or_create_folder_id(parent_path) + return self.db_manager.add_folder(normalized_path, parent_id) + + def _register_folder_recursive(self, folder_path: Path): + self._get_or_create_folder_id(folder_path) + try: + for entry in os.scandir(folder_path): + entry_path = Path(entry.path) + if entry.is_dir(): + self._register_folder_recursive(entry_path) + elif entry.is_file(): + self._handle_file_creation(entry_path) + except Exception as e: + logger.error(f"Ошибка сканирования папки {folder_path}: {e}") + + +class FolderWatcher: + """ Управляет асинхронным мониторингом списка директорий (Multi-Folder Tracking). """ + def __init__(self, db_manager: DBManager): + self.db_manager = db_manager + self.signals = WatcherSignals() + self.observer: Optional[Observer] = None + self.handlers: List[GalleryEventHandler] = [] + + def start_monitoring(self, root_paths: List[str]): + if self.observer and self.observer.is_alive(): + self.stop_monitoring() + + self.observer = Observer() + self.handlers = [] + + for p in root_paths: + path = Path(p).resolve() + path.mkdir(parents=True, exist_ok=True) + + handler = GalleryEventHandler(self.db_manager, str(path), self.signals) + logger.info(f"Запуск первоначальной индексации: {path}") + handler._register_folder_recursive(path) + + self.observer.schedule(handler, str(path), recursive=True) + self.handlers.append(handler) + logger.info(f"Начато отслеживание изменений: {path}") + + self.observer.start() + + def stop_monitoring(self): + if self.observer: + self.observer.stop() + self.observer.join() + self.observer = None + self.handlers = [] \ No newline at end of file