ar07xd commited on
Commit
2f0924c
·
verified ·
1 Parent(s): f2a2ad5

Sync from GitHub via hub-sync

Browse files
Files changed (4) hide show
  1. api/v1/analyze.py +10 -6
  2. api/v1/history.py +43 -3
  3. schemas/analyze.py +4 -0
  4. services/storage.py +21 -10
api/v1/analyze.py CHANGED
@@ -433,8 +433,9 @@ async def analyze_image(
433
  except Exception as e: # noqa: BLE001
434
  logger.warning(f"media save failed: {e}")
435
  media_path = None
436
- thumbnail_url = make_image_thumbnail(pil, media_hash)
437
  resp.thumbnail_url = thumbnail_url
 
438
  if media_path:
439
  resp.media_path = media_path
440
 
@@ -648,8 +649,9 @@ async def analyze_video_endpoint(
648
  except Exception as e: # noqa: BLE001
649
  logger.warning(f"video media save failed: {e}")
650
  media_path = None
651
- thumbnail_url = make_video_thumbnail(path, media_hash)
652
  resp.thumbnail_url = thumbnail_url
 
653
 
654
  record = AnalysisRecord(
655
  user_id=user.id if user else None,
@@ -996,8 +998,9 @@ async def analyze_screenshot_endpoint(
996
  except Exception as e: # noqa: BLE001
997
  logger.warning(f"screenshot media save failed: {e}")
998
  media_path = None
999
- thumbnail_url = make_image_thumbnail(pil, media_hash)
1000
  resp.thumbnail_url = thumbnail_url
 
1001
 
1002
  record = AnalysisRecord(
1003
  user_id=user.id if user else None,
@@ -1157,8 +1160,9 @@ async def analyze_video_async(
1157
  except Exception as e: # noqa: BLE001
1158
  logger.warning(f"async video media save failed: {e}")
1159
  media_path = None
1160
- thumb = make_video_thumbnail(path, media_hash)
1161
- resp.thumbnail_url = thumb
 
1162
 
1163
  rec = AnalysisRecord(
1164
  user_id=user_id,
@@ -1168,7 +1172,7 @@ async def analyze_video_async(
1168
  result_json=json.dumps(resp.model_dump()),
1169
  media_hash=media_hash,
1170
  media_path=media_path,
1171
- thumbnail_url=thumb,
1172
  )
1173
  local_db.add(rec)
1174
  local_db.commit()
 
433
  except Exception as e: # noqa: BLE001
434
  logger.warning(f"media save failed: {e}")
435
  media_path = None
436
+ thumbnail_url, thumbnail_b64 = make_image_thumbnail(pil, media_hash)
437
  resp.thumbnail_url = thumbnail_url
438
+ resp.thumbnail_b64 = thumbnail_b64
439
  if media_path:
440
  resp.media_path = media_path
441
 
 
649
  except Exception as e: # noqa: BLE001
650
  logger.warning(f"video media save failed: {e}")
651
  media_path = None
652
+ thumbnail_url, thumbnail_b64 = make_video_thumbnail(path, media_hash)
653
  resp.thumbnail_url = thumbnail_url
654
+ resp.thumbnail_b64 = thumbnail_b64
655
 
656
  record = AnalysisRecord(
657
  user_id=user.id if user else None,
 
998
  except Exception as e: # noqa: BLE001
999
  logger.warning(f"screenshot media save failed: {e}")
1000
  media_path = None
1001
+ thumbnail_url, thumbnail_b64 = make_image_thumbnail(pil, media_hash)
1002
  resp.thumbnail_url = thumbnail_url
1003
+ resp.thumbnail_b64 = thumbnail_b64
1004
 
1005
  record = AnalysisRecord(
1006
  user_id=user.id if user else None,
 
1160
  except Exception as e: # noqa: BLE001
1161
  logger.warning(f"async video media save failed: {e}")
1162
  media_path = None
1163
+ thumb_url, thumb_b64 = make_video_thumbnail(path, media_hash)
1164
+ resp.thumbnail_url = thumb_url
1165
+ resp.thumbnail_b64 = thumb_b64
1166
 
1167
  rec = AnalysisRecord(
1168
  user_id=user_id,
 
1172
  result_json=json.dumps(resp.model_dump()),
1173
  media_hash=media_hash,
1174
  media_path=media_path,
1175
+ thumbnail_url=thumb_url,
1176
  )
1177
  local_db.add(rec)
1178
  local_db.commit()
api/v1/history.py CHANGED
@@ -26,6 +26,7 @@ class HistoryItem(BaseModel):
26
  authenticity_score: float
27
  created_at: datetime
28
  thumbnail_url: str | None = None
 
29
  media_path: str | None = None
30
  text_preview: str | None = None
31
 
@@ -33,6 +34,7 @@ class HistoryItem(BaseModel):
33
  class HistoryListResponse(BaseModel):
34
  items: list[HistoryItem]
35
  total: int
 
36
 
37
 
38
  class HistoryDeleteAllResponse(BaseModel):
@@ -129,6 +131,42 @@ def _history_text_preview(record: AnalysisRecord, limit: int = 260) -> str | Non
129
  return text[: limit - 3].rstrip() + "..."
130
 
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  @router.get("", response_model=HistoryListResponse)
133
  def list_history(
134
  limit: int = Query(default=50, ge=1, le=200),
@@ -136,10 +174,10 @@ def list_history(
136
  user: User = Depends(get_current_user),
137
  db: Session = Depends(get_db),
138
  ) -> HistoryListResponse:
139
- from sqlalchemy.orm import defer
140
  q = db.query(AnalysisRecord).filter(AnalysisRecord.user_id == user.id)
141
  total = q.count()
142
- rows = q.options(defer(AnalysisRecord.result_json)).order_by(AnalysisRecord.created_at.desc()).offset(offset).limit(limit).all()
 
143
  items = [
144
  HistoryItem(
145
  id=r.id,
@@ -148,12 +186,14 @@ def list_history(
148
  authenticity_score=r.authenticity_score,
149
  created_at=r.created_at,
150
  thumbnail_url=_make_asset_url(r, "thumbnail") if r.thumbnail_url else None,
 
151
  media_path=_make_asset_url(r, "media") if r.media_path else None,
152
  text_preview=_history_text_preview(r),
153
  )
154
  for r in rows
155
  ]
156
- return HistoryListResponse(items=items, total=total)
 
157
 
158
 
159
  @router.get("/{record_id}")
 
26
  authenticity_score: float
27
  created_at: datetime
28
  thumbnail_url: str | None = None
29
+ thumbnail_b64: str | None = None # inline data URL; preferred over thumbnail_url
30
  media_path: str | None = None
31
  text_preview: str | None = None
32
 
 
34
  class HistoryListResponse(BaseModel):
35
  items: list[HistoryItem]
36
  total: int
37
+ cache_hits: int = 0
38
 
39
 
40
  class HistoryDeleteAllResponse(BaseModel):
 
131
  return text[: limit - 3].rstrip() + "..."
132
 
133
 
134
+ def _thumbnail_b64_from_record(r: AnalysisRecord) -> str | None:
135
+ """Extract inline thumbnail base64 from result_json if present."""
136
+ try:
137
+ payload = json.loads(r.result_json)
138
+ b64 = payload.get("thumbnail_b64")
139
+ if b64 and str(b64).startswith("data:image"):
140
+ return b64
141
+ except Exception:
142
+ pass
143
+ return None
144
+
145
+
146
+ def _count_cache_hits(db: Session, user_id: int) -> int:
147
+ """Count analyses whose media_hash matches an earlier record for this user.
148
+ Each duplicate re-submission that was served from cache is a cache hit.
149
+ """
150
+ from sqlalchemy import func, text as sa_text
151
+ try:
152
+ result = db.execute(
153
+ sa_text(
154
+ "SELECT COUNT(*) FROM analyses a1 "
155
+ "WHERE a1.user_id = :uid AND a1.media_hash IS NOT NULL "
156
+ "AND EXISTS ("
157
+ " SELECT 1 FROM analyses a2 "
158
+ " WHERE a2.user_id = :uid AND a2.media_hash = a1.media_hash "
159
+ " AND a2.id < a1.id"
160
+ ")"
161
+ ),
162
+ {"uid": user_id},
163
+ )
164
+ row = result.fetchone()
165
+ return int(row[0]) if row else 0
166
+ except Exception:
167
+ return 0
168
+
169
+
170
  @router.get("", response_model=HistoryListResponse)
171
  def list_history(
172
  limit: int = Query(default=50, ge=1, le=200),
 
174
  user: User = Depends(get_current_user),
175
  db: Session = Depends(get_db),
176
  ) -> HistoryListResponse:
 
177
  q = db.query(AnalysisRecord).filter(AnalysisRecord.user_id == user.id)
178
  total = q.count()
179
+ # Load result_json so we can extract thumbnail_b64
180
+ rows = q.order_by(AnalysisRecord.created_at.desc()).offset(offset).limit(limit).all()
181
  items = [
182
  HistoryItem(
183
  id=r.id,
 
186
  authenticity_score=r.authenticity_score,
187
  created_at=r.created_at,
188
  thumbnail_url=_make_asset_url(r, "thumbnail") if r.thumbnail_url else None,
189
+ thumbnail_b64=_thumbnail_b64_from_record(r),
190
  media_path=_make_asset_url(r, "media") if r.media_path else None,
191
  text_preview=_history_text_preview(r),
192
  )
193
  for r in rows
194
  ]
195
+ cache_hits = _count_cache_hits(db, user.id)
196
+ return HistoryListResponse(items=items, total=total, cache_hits=cache_hits)
197
 
198
 
199
  @router.get("/{record_id}")
schemas/analyze.py CHANGED
@@ -104,6 +104,7 @@ class ScreenshotAnalysisResponse(BaseModel):
104
  record_id: int = 0
105
  cached: bool = False
106
  thumbnail_url: str | None = None
 
107
  media_type: str = "screenshot"
108
  timestamp: str
109
  verdict: Verdict
@@ -179,6 +180,7 @@ class VideoAnalysisResponse(BaseModel):
179
  record_id: int = 0
180
  cached: bool = False
181
  thumbnail_url: str | None = None
 
182
  media_type: str = "video"
183
  timestamp: str
184
  verdict: Verdict
@@ -197,6 +199,7 @@ class ImageAnalysisResponse(BaseModel):
197
  record_id: int = 0
198
  cached: bool = False
199
  thumbnail_url: str | None = None
 
200
  media_path: str | None = None
201
  media_type: str = "image"
202
  timestamp: str
@@ -230,6 +233,7 @@ class AudioAnalysisResponse(BaseModel):
230
  record_id: int = 0
231
  cached: bool = False
232
  thumbnail_url: str | None = None
 
233
  media_type: str = "audio"
234
  timestamp: str
235
  verdict: Verdict
 
104
  record_id: int = 0
105
  cached: bool = False
106
  thumbnail_url: str | None = None
107
+ thumbnail_b64: str | None = None
108
  media_type: str = "screenshot"
109
  timestamp: str
110
  verdict: Verdict
 
180
  record_id: int = 0
181
  cached: bool = False
182
  thumbnail_url: str | None = None
183
+ thumbnail_b64: str | None = None
184
  media_type: str = "video"
185
  timestamp: str
186
  verdict: Verdict
 
199
  record_id: int = 0
200
  cached: bool = False
201
  thumbnail_url: str | None = None
202
+ thumbnail_b64: str | None = None # inline data URL, survives without file storage
203
  media_path: str | None = None
204
  media_type: str = "image"
205
  timestamp: str
 
233
  record_id: int = 0
234
  cached: bool = False
235
  thumbnail_url: str | None = None
236
+ thumbnail_b64: str | None = None
237
  media_type: str = "audio"
238
  timestamp: str
239
  verdict: Verdict
services/storage.py CHANGED
@@ -11,6 +11,7 @@ from __future__ import annotations
11
 
12
  import base64
13
  import hashlib
 
14
  import os
15
  from pathlib import Path
16
 
@@ -115,15 +116,11 @@ def make_image_thumbnail(pil: Image.Image, sha: str) -> tuple[str | None, str |
115
  return url_path, data_url
116
 
117
 
118
- def make_video_thumbnail(video_path: str, sha: str) -> str | None:
119
- """Grab a frame ~1s in as the video thumbnail."""
120
  try:
121
  import cv2 # lazy import — heavy
122
 
123
- _ensure_dirs()
124
- dest = THUMB_DIR / f"{sha}_400.jpg"
125
- if dest.exists():
126
- return f"/media/thumbs/{sha}_400.jpg"
127
  cap = cv2.VideoCapture(video_path)
128
  try:
129
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
@@ -133,17 +130,31 @@ def make_video_thumbnail(video_path: str, sha: str) -> str | None:
133
  cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
134
  ok, frame = cap.read()
135
  if not ok:
136
- return None
137
  finally:
138
  cap.release()
139
  rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
140
  im = Image.fromarray(rgb)
141
  im.thumbnail((THUMB_MAX, THUMB_MAX))
142
- im.save(dest, "JPEG", quality=82, optimize=True)
143
- return f"/media/thumbs/{sha}_400.jpg"
 
 
144
  except Exception as e: # noqa: BLE001
145
  logger.warning(f"video thumbnail failed for {sha}: {e}")
146
- return None
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
 
149
  def save_overlay(data_url: str, sha: str, suffix: str) -> str | None:
 
11
 
12
  import base64
13
  import hashlib
14
+ import io
15
  import os
16
  from pathlib import Path
17
 
 
116
  return url_path, data_url
117
 
118
 
119
+ def make_video_thumbnail(video_path: str, sha: str) -> tuple[str | None, str | None]:
120
+ """Grab a frame ~1s in as the video thumbnail. Returns (url_path, data_url)."""
121
  try:
122
  import cv2 # lazy import — heavy
123
 
 
 
 
 
124
  cap = cv2.VideoCapture(video_path)
125
  try:
126
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
 
130
  cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
131
  ok, frame = cap.read()
132
  if not ok:
133
+ return None, None
134
  finally:
135
  cap.release()
136
  rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
137
  im = Image.fromarray(rgb)
138
  im.thumbnail((THUMB_MAX, THUMB_MAX))
139
+ buf = io.BytesIO()
140
+ im.save(buf, "JPEG", quality=75, optimize=True)
141
+ b64 = base64.b64encode(buf.getvalue()).decode("ascii")
142
+ data_url = f"data:image/jpeg;base64,{b64}"
143
  except Exception as e: # noqa: BLE001
144
  logger.warning(f"video thumbnail failed for {sha}: {e}")
145
+ return None, None
146
+
147
+ url_path: str | None = None
148
+ try:
149
+ _ensure_dirs()
150
+ dest = THUMB_DIR / f"{sha}_400.jpg"
151
+ if not dest.exists():
152
+ dest.write_bytes(buf.getvalue())
153
+ url_path = f"/media/thumbs/{sha}_400.jpg"
154
+ except Exception as e: # noqa: BLE001
155
+ logger.warning(f"video thumbnail file save failed for {sha}: {e}")
156
+
157
+ return url_path, data_url
158
 
159
 
160
  def save_overlay(data_url: str, sha: str, suffix: str) -> str | None: