pixelcue 0.8.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pixelcue/__init__.py +1 -0
- pixelcue/analysis_store.py +232 -0
- pixelcue/archives.py +319 -0
- pixelcue/cli.py +40 -0
- pixelcue/cluster_process.py +96 -0
- pixelcue/face.py +660 -0
- pixelcue/gui.py +1074 -0
- pixelcue/logging_utils.py +113 -0
- pixelcue/main.py +37 -0
- pixelcue/media.py +183 -0
- pixelcue/metadata.py +220 -0
- pixelcue/model.py +1117 -0
- pixelcue/os_thumbnail.py +32 -0
- pixelcue/scanner.py +1651 -0
- pixelcue/server.py +166 -0
- pixelcue/service.py +183 -0
- pixelcue-0.8.7.data/data/share/pixelcue/branding/README.md +11 -0
- pixelcue-0.8.7.data/data/share/pixelcue/branding/logo-monochrome.svg +6 -0
- pixelcue-0.8.7.data/data/share/pixelcue/branding/logo.png +0 -0
- pixelcue-0.8.7.data/data/share/pixelcue/branding/logo.svg +6 -0
- pixelcue-0.8.7.dist-info/METADATA +1007 -0
- pixelcue-0.8.7.dist-info/RECORD +26 -0
- pixelcue-0.8.7.dist-info/WHEEL +5 -0
- pixelcue-0.8.7.dist-info/entry_points.txt +3 -0
- pixelcue-0.8.7.dist-info/licenses/LICENSE +21 -0
- pixelcue-0.8.7.dist-info/top_level.txt +1 -0
pixelcue/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.8.7"
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sqlite3
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from platformdirs import user_data_dir
|
|
12
|
+
|
|
13
|
+
from .logging_utils import log_event, log_exception
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SCHEMA_VERSION = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def default_analysis_db_path() -> Path:
|
|
20
|
+
override = os.environ.get("PIXELCUE_ANALYSIS_DB", "").strip()
|
|
21
|
+
if override:
|
|
22
|
+
return Path(override).expanduser()
|
|
23
|
+
return Path(user_data_dir("PixelCue", "DanceFlow")) / "analysis-cache.sqlite3"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _jsonable(value: Any) -> Any:
|
|
27
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
28
|
+
return value
|
|
29
|
+
if isinstance(value, Path):
|
|
30
|
+
return str(value)
|
|
31
|
+
if isinstance(value, dict):
|
|
32
|
+
return {str(k): _jsonable(v) for k, v in value.items()}
|
|
33
|
+
if isinstance(value, (list, tuple, set)):
|
|
34
|
+
return [_jsonable(v) for v in value]
|
|
35
|
+
item = getattr(value, "item", None)
|
|
36
|
+
if callable(item):
|
|
37
|
+
try:
|
|
38
|
+
return _jsonable(item())
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
return str(value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def normalized_path(path: str | Path) -> str:
|
|
45
|
+
return os.path.normcase(os.path.abspath(os.fspath(path)))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def file_signature(path: str | Path) -> tuple[str, int, int]:
|
|
49
|
+
p = Path(path)
|
|
50
|
+
st = p.stat()
|
|
51
|
+
return normalized_path(p), int(st.st_size), int(st.st_mtime_ns)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AnalysisStore:
|
|
55
|
+
"""Permanent stage-oriented analysis cache backed by SQLite.
|
|
56
|
+
|
|
57
|
+
Results are immutable by file signature: if a path changes size or mtime, a
|
|
58
|
+
new row is written while the old result remains as historical analysis.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, db_path: str | Path | None = None) -> None:
|
|
62
|
+
self.db_path = Path(db_path or default_analysis_db_path()).expanduser()
|
|
63
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
self._lock = threading.RLock()
|
|
65
|
+
self._conn = sqlite3.connect(
|
|
66
|
+
self.db_path,
|
|
67
|
+
timeout=30.0,
|
|
68
|
+
check_same_thread=False,
|
|
69
|
+
)
|
|
70
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
71
|
+
self._conn.execute("PRAGMA synchronous=NORMAL")
|
|
72
|
+
self._conn.execute("PRAGMA busy_timeout=30000")
|
|
73
|
+
self._create_schema()
|
|
74
|
+
log_event(
|
|
75
|
+
"analysis-store",
|
|
76
|
+
"ready",
|
|
77
|
+
"persistent analysis cache opened",
|
|
78
|
+
db_path=str(self.db_path),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def _create_schema(self) -> None:
|
|
82
|
+
with self._lock, self._conn:
|
|
83
|
+
self._conn.executescript(
|
|
84
|
+
"""
|
|
85
|
+
CREATE TABLE IF NOT EXISTS analysis_results (
|
|
86
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
87
|
+
path TEXT NOT NULL,
|
|
88
|
+
size_bytes INTEGER NOT NULL,
|
|
89
|
+
mtime_ns INTEGER NOT NULL,
|
|
90
|
+
kind TEXT NOT NULL,
|
|
91
|
+
engine TEXT NOT NULL,
|
|
92
|
+
payload_json TEXT NOT NULL,
|
|
93
|
+
created_at REAL NOT NULL,
|
|
94
|
+
updated_at REAL NOT NULL,
|
|
95
|
+
UNIQUE(path, size_bytes, mtime_ns, kind, engine)
|
|
96
|
+
);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS idx_analysis_lookup
|
|
98
|
+
ON analysis_results(path, kind, engine, size_bytes, mtime_ns);
|
|
99
|
+
CREATE TABLE IF NOT EXISTS metadata (
|
|
100
|
+
key TEXT PRIMARY KEY,
|
|
101
|
+
value TEXT NOT NULL
|
|
102
|
+
);
|
|
103
|
+
"""
|
|
104
|
+
)
|
|
105
|
+
self._conn.execute(
|
|
106
|
+
"INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', ?)",
|
|
107
|
+
(str(SCHEMA_VERSION),),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def load(
|
|
111
|
+
self,
|
|
112
|
+
path: str | Path,
|
|
113
|
+
*,
|
|
114
|
+
kind: str,
|
|
115
|
+
engine: str,
|
|
116
|
+
) -> dict[str, Any] | None:
|
|
117
|
+
try:
|
|
118
|
+
normalized, size_bytes, mtime_ns = file_signature(path)
|
|
119
|
+
with self._lock:
|
|
120
|
+
row = self._conn.execute(
|
|
121
|
+
"""
|
|
122
|
+
SELECT payload_json
|
|
123
|
+
FROM analysis_results
|
|
124
|
+
WHERE path=? AND size_bytes=? AND mtime_ns=?
|
|
125
|
+
AND kind=? AND engine=?
|
|
126
|
+
ORDER BY updated_at DESC
|
|
127
|
+
LIMIT 1
|
|
128
|
+
""",
|
|
129
|
+
(normalized, size_bytes, mtime_ns, kind, engine),
|
|
130
|
+
).fetchone()
|
|
131
|
+
if row is None:
|
|
132
|
+
return None
|
|
133
|
+
payload = json.loads(row[0])
|
|
134
|
+
log_event(
|
|
135
|
+
"analysis-store",
|
|
136
|
+
"cache-hit",
|
|
137
|
+
"restored persisted analysis",
|
|
138
|
+
path=normalized,
|
|
139
|
+
kind=kind,
|
|
140
|
+
engine=engine,
|
|
141
|
+
)
|
|
142
|
+
return payload
|
|
143
|
+
except FileNotFoundError:
|
|
144
|
+
return None
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
log_exception(
|
|
147
|
+
"analysis-store",
|
|
148
|
+
"load-failure",
|
|
149
|
+
exc,
|
|
150
|
+
path=str(path),
|
|
151
|
+
kind=kind,
|
|
152
|
+
engine=engine,
|
|
153
|
+
)
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
def save(
|
|
157
|
+
self,
|
|
158
|
+
path: str | Path,
|
|
159
|
+
*,
|
|
160
|
+
kind: str,
|
|
161
|
+
engine: str,
|
|
162
|
+
payload: dict[str, Any],
|
|
163
|
+
) -> bool:
|
|
164
|
+
try:
|
|
165
|
+
normalized, size_bytes, mtime_ns = file_signature(path)
|
|
166
|
+
encoded = json.dumps(
|
|
167
|
+
_jsonable(payload),
|
|
168
|
+
ensure_ascii=False,
|
|
169
|
+
separators=(",", ":"),
|
|
170
|
+
sort_keys=True,
|
|
171
|
+
)
|
|
172
|
+
now = time.time()
|
|
173
|
+
with self._lock, self._conn:
|
|
174
|
+
self._conn.execute(
|
|
175
|
+
"""
|
|
176
|
+
INSERT INTO analysis_results(
|
|
177
|
+
path, size_bytes, mtime_ns, kind, engine,
|
|
178
|
+
payload_json, created_at, updated_at
|
|
179
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
180
|
+
ON CONFLICT(path, size_bytes, mtime_ns, kind, engine)
|
|
181
|
+
DO UPDATE SET payload_json=excluded.payload_json,
|
|
182
|
+
updated_at=excluded.updated_at
|
|
183
|
+
""",
|
|
184
|
+
(
|
|
185
|
+
normalized,
|
|
186
|
+
size_bytes,
|
|
187
|
+
mtime_ns,
|
|
188
|
+
kind,
|
|
189
|
+
engine,
|
|
190
|
+
encoded,
|
|
191
|
+
now,
|
|
192
|
+
now,
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
log_event(
|
|
196
|
+
"analysis-store",
|
|
197
|
+
"cache-save",
|
|
198
|
+
"persisted analysis result",
|
|
199
|
+
path=normalized,
|
|
200
|
+
kind=kind,
|
|
201
|
+
engine=engine,
|
|
202
|
+
)
|
|
203
|
+
return True
|
|
204
|
+
except FileNotFoundError:
|
|
205
|
+
return False
|
|
206
|
+
except Exception as exc:
|
|
207
|
+
log_exception(
|
|
208
|
+
"analysis-store",
|
|
209
|
+
"save-failure",
|
|
210
|
+
exc,
|
|
211
|
+
path=str(path),
|
|
212
|
+
kind=kind,
|
|
213
|
+
engine=engine,
|
|
214
|
+
)
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
def count(self) -> int:
|
|
218
|
+
with self._lock:
|
|
219
|
+
row = self._conn.execute("SELECT COUNT(*) FROM analysis_results").fetchone()
|
|
220
|
+
return int(row[0] if row else 0)
|
|
221
|
+
|
|
222
|
+
def close(self) -> None:
|
|
223
|
+
with self._lock:
|
|
224
|
+
try:
|
|
225
|
+
self._conn.close()
|
|
226
|
+
finally:
|
|
227
|
+
log_event(
|
|
228
|
+
"analysis-store",
|
|
229
|
+
"finish",
|
|
230
|
+
"persistent analysis cache closed",
|
|
231
|
+
db_path=str(self.db_path),
|
|
232
|
+
)
|
pixelcue/archives.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import shutil
|
|
5
|
+
import tarfile
|
|
6
|
+
import tempfile
|
|
7
|
+
import zipfile
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
from typing import BinaryIO, Callable
|
|
11
|
+
|
|
12
|
+
from .media import is_image, is_video
|
|
13
|
+
from .metadata import MagicDetector
|
|
14
|
+
|
|
15
|
+
ARCHIVE_SUFFIXES = (
|
|
16
|
+
".zip", ".7z", ".rar", ".tar", ".tgz", ".tbz2", ".txz",
|
|
17
|
+
".tar.gz", ".tar.bz2", ".tar.xz",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
ARCHIVE_MIMES = {
|
|
21
|
+
"application/zip",
|
|
22
|
+
"application/x-7z-compressed",
|
|
23
|
+
"application/vnd.rar",
|
|
24
|
+
"application/x-rar",
|
|
25
|
+
"application/x-rar-compressed",
|
|
26
|
+
"application/x-tar",
|
|
27
|
+
"application/gzip",
|
|
28
|
+
"application/x-gzip",
|
|
29
|
+
"application/x-bzip2",
|
|
30
|
+
"application/x-xz",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
PROBE_BYTES = 128 * 1024
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_archive(path: Path, mime: str = "") -> bool:
|
|
37
|
+
"""Content MIME wins completely whenever it is available."""
|
|
38
|
+
mime = (mime or "").strip().lower()
|
|
39
|
+
if mime:
|
|
40
|
+
return mime in ARCHIVE_MIMES
|
|
41
|
+
|
|
42
|
+
# Suffix fallback exists only for callers that genuinely have no MIME result.
|
|
43
|
+
name = path.name.lower()
|
|
44
|
+
return any(name.endswith(s) for s in ARCHIVE_SUFFIXES)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _archive_kind(path: Path, mime: str = "") -> str:
|
|
48
|
+
mime = (mime or "").strip().lower()
|
|
49
|
+
if mime == "application/zip":
|
|
50
|
+
return "zip"
|
|
51
|
+
if mime == "application/x-7z-compressed":
|
|
52
|
+
return "7z"
|
|
53
|
+
if mime in {
|
|
54
|
+
"application/vnd.rar",
|
|
55
|
+
"application/x-rar",
|
|
56
|
+
"application/x-rar-compressed",
|
|
57
|
+
}:
|
|
58
|
+
return "rar"
|
|
59
|
+
if mime in {
|
|
60
|
+
"application/x-tar",
|
|
61
|
+
"application/gzip",
|
|
62
|
+
"application/x-gzip",
|
|
63
|
+
"application/x-bzip2",
|
|
64
|
+
"application/x-xz",
|
|
65
|
+
}:
|
|
66
|
+
return "tar"
|
|
67
|
+
|
|
68
|
+
if mime:
|
|
69
|
+
return "unknown"
|
|
70
|
+
|
|
71
|
+
# Only callers with no content MIME may fall back to the suffix.
|
|
72
|
+
lower = path.name.lower()
|
|
73
|
+
if lower.endswith(".zip"):
|
|
74
|
+
return "zip"
|
|
75
|
+
if lower.endswith(".7z"):
|
|
76
|
+
return "7z"
|
|
77
|
+
if lower.endswith(".rar"):
|
|
78
|
+
return "rar"
|
|
79
|
+
if lower.endswith((
|
|
80
|
+
".tar", ".tgz", ".tbz2", ".txz",
|
|
81
|
+
".tar.gz", ".tar.bz2", ".tar.xz",
|
|
82
|
+
)):
|
|
83
|
+
return "tar"
|
|
84
|
+
return "unknown"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _safe_member_name(name: str) -> bool:
|
|
88
|
+
pp = PurePosixPath(name.replace("\\", "/"))
|
|
89
|
+
return not pp.is_absolute() and ".." not in pp.parts
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_media_bytes(detector: MagicDetector, data: bytes) -> tuple[bool, str]:
|
|
93
|
+
"""Return whether member bytes are image/video according to libmagic."""
|
|
94
|
+
mime, _desc = detector.identify_buffer(data)
|
|
95
|
+
# No filename is passed here: extensions have zero influence.
|
|
96
|
+
return is_image(Path("member"), mime) or is_video(Path("member"), mime), mime
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class SampledArchive:
|
|
101
|
+
tempdir: tempfile.TemporaryDirectory
|
|
102
|
+
paths: list[Path]
|
|
103
|
+
member_names: list[str]
|
|
104
|
+
total_media_members: int
|
|
105
|
+
total_members: int
|
|
106
|
+
|
|
107
|
+
def cleanup(self) -> None:
|
|
108
|
+
self.tempdir.cleanup()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _safe_output(td: Path, idx: int, member_name: str) -> Path:
|
|
112
|
+
# Keep the original suffix only so downstream tools have a convenient temp
|
|
113
|
+
# filename. It has already passed content-based MIME classification.
|
|
114
|
+
suffix = Path(member_name).suffix.lower()
|
|
115
|
+
return td / f"member-{idx:02d}{suffix}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _choose(items: list, sample_size: int) -> list:
|
|
119
|
+
if len(items) <= sample_size:
|
|
120
|
+
return list(items)
|
|
121
|
+
return random.SystemRandom().sample(items, sample_size)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def sample_archive(
|
|
125
|
+
path: Path,
|
|
126
|
+
sample_size: int = 5,
|
|
127
|
+
mime: str = "",
|
|
128
|
+
detector: MagicDetector | None = None,
|
|
129
|
+
) -> SampledArchive:
|
|
130
|
+
"""Sample actual media members from an archive using content MIME.
|
|
131
|
+
|
|
132
|
+
Archive type is selected from libmagic MIME when supplied. Member filenames
|
|
133
|
+
are never used to decide whether an entry is image/video.
|
|
134
|
+
"""
|
|
135
|
+
detector = detector or MagicDetector()
|
|
136
|
+
archive_kind = _archive_kind(path, mime)
|
|
137
|
+
|
|
138
|
+
if archive_kind == "unknown":
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"Unsupported archive content type: {mime or 'unknown'} ({path.name})"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
td_obj = tempfile.TemporaryDirectory(prefix="pixelcue-archive-")
|
|
144
|
+
td = Path(td_obj.name)
|
|
145
|
+
|
|
146
|
+
extracted: list[Path] = []
|
|
147
|
+
sampled_names: list[str] = []
|
|
148
|
+
total_members = 0
|
|
149
|
+
total_media = 0
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
if archive_kind == "zip":
|
|
153
|
+
with zipfile.ZipFile(path) as zf:
|
|
154
|
+
infos = [
|
|
155
|
+
i for i in zf.infolist()
|
|
156
|
+
if not i.is_dir() and _safe_member_name(i.filename)
|
|
157
|
+
]
|
|
158
|
+
total_members = len(infos)
|
|
159
|
+
|
|
160
|
+
media_infos = []
|
|
161
|
+
for info in infos:
|
|
162
|
+
try:
|
|
163
|
+
with zf.open(info) as src:
|
|
164
|
+
header = src.read(PROBE_BYTES)
|
|
165
|
+
ok, _mime = _is_media_bytes(detector, header)
|
|
166
|
+
if ok:
|
|
167
|
+
media_infos.append(info)
|
|
168
|
+
except Exception:
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
total_media = len(media_infos)
|
|
172
|
+
for idx, info in enumerate(_choose(media_infos, sample_size)):
|
|
173
|
+
out = _safe_output(td, idx, info.filename)
|
|
174
|
+
with zf.open(info) as src, out.open("wb") as dst:
|
|
175
|
+
shutil.copyfileobj(src, dst)
|
|
176
|
+
extracted.append(out)
|
|
177
|
+
sampled_names.append(info.filename)
|
|
178
|
+
|
|
179
|
+
elif archive_kind == "tar":
|
|
180
|
+
with tarfile.open(path, mode="r:*") as tf:
|
|
181
|
+
infos = [
|
|
182
|
+
i for i in tf.getmembers()
|
|
183
|
+
if i.isfile() and _safe_member_name(i.name)
|
|
184
|
+
]
|
|
185
|
+
total_members = len(infos)
|
|
186
|
+
|
|
187
|
+
media_infos = []
|
|
188
|
+
for info in infos:
|
|
189
|
+
try:
|
|
190
|
+
src = tf.extractfile(info)
|
|
191
|
+
if src is None:
|
|
192
|
+
continue
|
|
193
|
+
with src:
|
|
194
|
+
header = src.read(PROBE_BYTES)
|
|
195
|
+
ok, _mime = _is_media_bytes(detector, header)
|
|
196
|
+
if ok:
|
|
197
|
+
media_infos.append(info)
|
|
198
|
+
except Exception:
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
total_media = len(media_infos)
|
|
202
|
+
for idx, info in enumerate(_choose(media_infos, sample_size)):
|
|
203
|
+
src = tf.extractfile(info)
|
|
204
|
+
if src is None:
|
|
205
|
+
continue
|
|
206
|
+
out = _safe_output(td, idx, info.name)
|
|
207
|
+
with src, out.open("wb") as dst:
|
|
208
|
+
shutil.copyfileobj(src, dst)
|
|
209
|
+
extracted.append(out)
|
|
210
|
+
sampled_names.append(info.name)
|
|
211
|
+
|
|
212
|
+
elif archive_kind == "rar":
|
|
213
|
+
import rarfile
|
|
214
|
+
|
|
215
|
+
with rarfile.RarFile(path) as rf:
|
|
216
|
+
infos = [
|
|
217
|
+
i for i in rf.infolist()
|
|
218
|
+
if not i.isdir() and _safe_member_name(i.filename)
|
|
219
|
+
]
|
|
220
|
+
total_members = len(infos)
|
|
221
|
+
|
|
222
|
+
media_infos = []
|
|
223
|
+
for info in infos:
|
|
224
|
+
try:
|
|
225
|
+
with rf.open(info) as src:
|
|
226
|
+
header = src.read(PROBE_BYTES)
|
|
227
|
+
ok, _mime = _is_media_bytes(detector, header)
|
|
228
|
+
if ok:
|
|
229
|
+
media_infos.append(info)
|
|
230
|
+
except Exception:
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
total_media = len(media_infos)
|
|
234
|
+
for idx, info in enumerate(_choose(media_infos, sample_size)):
|
|
235
|
+
out = _safe_output(td, idx, info.filename)
|
|
236
|
+
with rf.open(info) as src, out.open("wb") as dst:
|
|
237
|
+
shutil.copyfileobj(src, dst)
|
|
238
|
+
extracted.append(out)
|
|
239
|
+
sampled_names.append(info.filename)
|
|
240
|
+
|
|
241
|
+
elif archive_kind == "7z":
|
|
242
|
+
# py7zr does not expose a stable streaming member API across the
|
|
243
|
+
# supported versions, so probe members one at a time into a private
|
|
244
|
+
# temp directory. Crucially, the decision is still based on libmagic
|
|
245
|
+
# bytes, never the member suffix.
|
|
246
|
+
import py7zr
|
|
247
|
+
|
|
248
|
+
with py7zr.SevenZipFile(path, mode="r") as zf:
|
|
249
|
+
names = [
|
|
250
|
+
n for n in zf.getnames()
|
|
251
|
+
if _safe_member_name(n) and not n.endswith("/")
|
|
252
|
+
]
|
|
253
|
+
total_members = len(names)
|
|
254
|
+
|
|
255
|
+
media_names: list[str] = []
|
|
256
|
+
probe_root = td / "_probe"
|
|
257
|
+
probe_root.mkdir()
|
|
258
|
+
|
|
259
|
+
for probe_idx, name in enumerate(names):
|
|
260
|
+
one = probe_root / f"p{probe_idx:08d}"
|
|
261
|
+
one.mkdir()
|
|
262
|
+
try:
|
|
263
|
+
# Reopen for each target: some py7zr versions cannot perform
|
|
264
|
+
# repeated extract() calls on one exhausted archive object.
|
|
265
|
+
with py7zr.SevenZipFile(path, mode="r") as zf:
|
|
266
|
+
zf.extract(path=one, targets=[name])
|
|
267
|
+
|
|
268
|
+
candidate = (one / name).resolve()
|
|
269
|
+
if (
|
|
270
|
+
one.resolve() not in candidate.parents
|
|
271
|
+
or not candidate.is_file()
|
|
272
|
+
):
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
with candidate.open("rb") as src:
|
|
276
|
+
header = src.read(PROBE_BYTES)
|
|
277
|
+
ok, _mime = _is_media_bytes(detector, header)
|
|
278
|
+
if ok:
|
|
279
|
+
media_names.append(name)
|
|
280
|
+
except Exception:
|
|
281
|
+
continue
|
|
282
|
+
finally:
|
|
283
|
+
shutil.rmtree(one, ignore_errors=True)
|
|
284
|
+
|
|
285
|
+
total_media = len(media_names)
|
|
286
|
+
chosen = _choose(media_names, sample_size)
|
|
287
|
+
for idx, name in enumerate(chosen):
|
|
288
|
+
one = probe_root / f"chosen-{idx:04d}"
|
|
289
|
+
one.mkdir()
|
|
290
|
+
try:
|
|
291
|
+
with py7zr.SevenZipFile(path, mode="r") as zf:
|
|
292
|
+
zf.extract(path=one, targets=[name])
|
|
293
|
+
candidate = (one / name).resolve()
|
|
294
|
+
if (
|
|
295
|
+
one.resolve() not in candidate.parents
|
|
296
|
+
or not candidate.is_file()
|
|
297
|
+
):
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
out = _safe_output(td, idx, name)
|
|
301
|
+
shutil.copy2(candidate, out)
|
|
302
|
+
extracted.append(out)
|
|
303
|
+
sampled_names.append(name)
|
|
304
|
+
finally:
|
|
305
|
+
shutil.rmtree(one, ignore_errors=True)
|
|
306
|
+
|
|
307
|
+
shutil.rmtree(probe_root, ignore_errors=True)
|
|
308
|
+
|
|
309
|
+
return SampledArchive(
|
|
310
|
+
td_obj,
|
|
311
|
+
extracted,
|
|
312
|
+
sampled_names,
|
|
313
|
+
total_media,
|
|
314
|
+
total_members,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
except Exception:
|
|
318
|
+
td_obj.cleanup()
|
|
319
|
+
raise
|
pixelcue/cli.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .model import TAGGER_PROFILES, DEFAULT_TAGGER_PROFILE
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def model_profile_from_argv(argv: list[str]) -> str:
|
|
10
|
+
"""Read --model ID / --model=ID, falling back to configured default."""
|
|
11
|
+
for index, arg in enumerate(argv[1:], start=1):
|
|
12
|
+
if arg.startswith("--model="):
|
|
13
|
+
candidate = arg.split("=", 1)[1].strip()
|
|
14
|
+
return candidate if candidate in TAGGER_PROFILES else DEFAULT_TAGGER_PROFILE
|
|
15
|
+
if arg == "--model" and index + 1 < len(argv):
|
|
16
|
+
candidate = argv[index + 1].strip()
|
|
17
|
+
return candidate if candidate in TAGGER_PROFILES else DEFAULT_TAGGER_PROFILE
|
|
18
|
+
return DEFAULT_TAGGER_PROFILE
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def startup_path_from_argv(argv: list[str]) -> str | None:
|
|
22
|
+
"""Return the first non-option command-line argument that is a valid path."""
|
|
23
|
+
skip_next = False
|
|
24
|
+
for index, arg in enumerate(argv[1:], start=1):
|
|
25
|
+
if skip_next:
|
|
26
|
+
skip_next = False
|
|
27
|
+
continue
|
|
28
|
+
if arg == "--model":
|
|
29
|
+
skip_next = True
|
|
30
|
+
continue
|
|
31
|
+
if arg.startswith("--model=") or arg.startswith("-"):
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
candidate = Path(os.path.expandvars(os.path.expanduser(arg)))
|
|
35
|
+
if os.path.lexists(candidate):
|
|
36
|
+
try:
|
|
37
|
+
return str(candidate.absolute())
|
|
38
|
+
except OSError:
|
|
39
|
+
return str(candidate)
|
|
40
|
+
return None
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .face import (
|
|
6
|
+
FaceResult,
|
|
7
|
+
cluster_embeddings,
|
|
8
|
+
CLUSTER_COSINE_DISTANCE,
|
|
9
|
+
CLUSTER_MERGE_MARGIN,
|
|
10
|
+
)
|
|
11
|
+
from .logging_utils import log_event, log_exception
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def face_cluster_process_main(request_queue, result_queue) -> None:
|
|
15
|
+
"""Persistent clustering subprocess; state is incrementally updated over IPC."""
|
|
16
|
+
worker = "face-cluster-process"
|
|
17
|
+
embeddings: dict[str, list[float]] = {}
|
|
18
|
+
log_event(worker, "start", "clustering subprocess started")
|
|
19
|
+
finish_reason = "normal stop"
|
|
20
|
+
success = True
|
|
21
|
+
try:
|
|
22
|
+
while True:
|
|
23
|
+
message = request_queue.get()
|
|
24
|
+
command = message.get("command")
|
|
25
|
+
if command == "stop":
|
|
26
|
+
finish_reason = str(message.get("reason") or "stop requested")
|
|
27
|
+
break
|
|
28
|
+
if command != "cluster":
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
job_id = int(message.get("job_id", 0))
|
|
32
|
+
items = list(message.get("items") or [])
|
|
33
|
+
threshold = float(message.get("threshold", CLUSTER_COSINE_DISTANCE))
|
|
34
|
+
log_event(
|
|
35
|
+
worker,
|
|
36
|
+
"job-start",
|
|
37
|
+
"clustering embeddings",
|
|
38
|
+
job_id=job_id,
|
|
39
|
+
new_embeddings=len(items),
|
|
40
|
+
known_embeddings=len(embeddings),
|
|
41
|
+
assignment_threshold=threshold,
|
|
42
|
+
merge_threshold=min(
|
|
43
|
+
1.0,
|
|
44
|
+
threshold + max(0.0, CLUSTER_MERGE_MARGIN),
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
try:
|
|
48
|
+
for item in items:
|
|
49
|
+
path = str(item["path"])
|
|
50
|
+
embeddings[path] = [float(x) for x in item["embedding"]]
|
|
51
|
+
|
|
52
|
+
results = [
|
|
53
|
+
FaceResult(path=path, embedding=embedding, attributes={})
|
|
54
|
+
for path, embedding in embeddings.items()
|
|
55
|
+
]
|
|
56
|
+
clusters = cluster_embeddings(results, threshold=threshold)
|
|
57
|
+
payload = [
|
|
58
|
+
{
|
|
59
|
+
"cluster_id": int(cluster["cluster_id"]),
|
|
60
|
+
"centroid": [float(x) for x in cluster["centroid"]],
|
|
61
|
+
"representative_path": str(cluster["representative_path"]),
|
|
62
|
+
"paths": list(cluster["paths"]),
|
|
63
|
+
"size": len(cluster["paths"]),
|
|
64
|
+
}
|
|
65
|
+
for cluster in clusters
|
|
66
|
+
]
|
|
67
|
+
result_queue.put({"job_id": job_id, "ok": True, "clusters": payload})
|
|
68
|
+
log_event(
|
|
69
|
+
worker,
|
|
70
|
+
"job-finish",
|
|
71
|
+
"clustering succeeded",
|
|
72
|
+
job_id=job_id,
|
|
73
|
+
clusters=len(payload),
|
|
74
|
+
embeddings=len(embeddings),
|
|
75
|
+
)
|
|
76
|
+
except BaseException as exc:
|
|
77
|
+
log_exception(worker, "job-failure", exc, job_id=job_id)
|
|
78
|
+
result_queue.put(
|
|
79
|
+
{
|
|
80
|
+
"job_id": job_id,
|
|
81
|
+
"ok": False,
|
|
82
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
except BaseException as exc:
|
|
86
|
+
success = False
|
|
87
|
+
finish_reason = f"{type(exc).__name__}: {exc}"
|
|
88
|
+
log_exception(worker, "failure", exc)
|
|
89
|
+
finally:
|
|
90
|
+
log_event(
|
|
91
|
+
worker,
|
|
92
|
+
"finish",
|
|
93
|
+
finish_reason,
|
|
94
|
+
level="INFO" if success else "ERROR",
|
|
95
|
+
success=success,
|
|
96
|
+
)
|