Repository navigation
Expand file tree
/
Copy pathmain.py
More file actions
5199 lines (4626 loc) · 206 KB
/
Copy pathmain.py
File metadata and controls
5199 lines (4626 loc) · 206 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
Coomer Viewer - FastAPI web server for browsing and streaming
OnlyFans content from NAS with metadata from TimescaleDB.
"""
import os
import io
import re
import sys
import uuid
import json
import struct
import asyncio
import hashlib
import mimetypes
import time
import ipaddress
import stat as _statmod
import orjson
from cachetools import TTLCache
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
from dataclasses import dataclass, asdict
from enum import Enum
from urllib.parse import urlparse, quote
from datetime import datetime, timezone
from concurrent.futures import ThreadPoolExecutor, as_completed
import anyio
import asyncpg
import aiofiles
from PIL import Image
from smart_thumbs import generate_smart_thumb
from pydantic import BaseModel, Field
from fastapi import FastAPI, HTTPException, Request, Query, Depends
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, FileResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from compression_middleware import SmartCompressionMiddleware
sys.path.insert(0, str(Path(__file__).parent.parent / "album_scraper"))
from coomer_nas_downloader import CoomerNASDownloader, SUPPORTED_DOMAINS
from ingest_router import detect_route, URLRoute
import ingest_persist
DB_URL = os.environ.get("DB_URL", "postgresql://coomer:coomer_dev@localhost:5433/coomer")
NAS_BASE = os.environ.get("NAS_BASE", "/mnt/nas/drive_2")
EXTERNAL_CONTENT_BASE = os.path.join(NAS_BASE, "onlyfans", "external_content")
CHUNK_SIZE = 1024 * 1024 # 1MB chunks for streaming
THUMB_CACHE_DIR = Path(__file__).parent / ".thumb_cache"
THUMB_CACHE_DIR.mkdir(exist_ok=True)
THUMB_MAX_WIDTH = 400
THUMB_QUALITY = 75
# CORS allow-origins, configurable so the (tailnet-only, single-user) deploy can
# narrow it without a code change and can never lock itself out. Set
# CORS_ORIGINS="http://localhost:8000,http://<tailnet-host>:8000" to scope it;
# left unset it stays "*" (the prior behaviour) which also keeps the browser
# extension — which posts cross-origin from a 100.x tailnet IP — working.
CORS_ORIGINS = [
o.strip() for o in os.environ.get("CORS_ORIGINS", "*").split(",") if o.strip()
] or ["*"]
# ─────────────────────────────────────────────────────────────────────────────
# Performance layer: NAS-stat caching, DB-row caching, and zero-copy/off-loop
# file delivery. Media/HLS/thumb files are write-once-read-many (immutable per
# id), so caching path existence/size and the media_files row is safe and
# eliminates the CIFS `actimeo=1` stat storm + per-request DB round-trips that
# dominate hot-path latency.
#
# When X_ACCEL_REDIRECT=1 (set by run.sh when behind nginx) file bytes are
# handed to nginx via X-Accel-Redirect (kernel sendfile, off the single Python
# event loop). Otherwise we use Starlette FileResponse, which on
# uvicorn>=0.32 uses os.sendfile via the `http.response.pathsend` ASGI
# extension — still far better than the old hand-rolled 1 MB copy loop.
# ─────────────────────────────────────────────────────────────────────────────
X_ACCEL = os.environ.get("X_ACCEL_REDIRECT", "") == "1"
_NAS_INTERNAL_PREFIX = "/_nas_internal/"
_THUMB_INTERNAL_PREFIX = "/_thumb_internal/"
_THUMB_CACHE_STR = str(THUMB_CACHE_DIR)
_PATH_STAT_TTL = 300.0 # immutable files: 5 min existence/size cache
_PATH_STAT_MAX = 200_000
_path_stat_cache: dict[str, tuple[float, object]] = {}
_MEDIA_ROW_TTL = 120.0
_MEDIA_ROW_MAX = 100_000
_media_row_cache: dict[int, tuple[float, Optional[dict]]] = {}
_M3U8_TTL = 10.0
_m3u8_cache: dict[str, tuple[float, bytes]] = {}
_VIDEO_MIME = {
".mp4": "video/mp4", ".m4v": "video/mp4",
".webm": "video/webm",
".mkv": "video/x-matroska",
".mov": "video/quicktime",
".avi": "video/x-msvideo",
".ts": "video/mp2t",
}
def _cached_stat(path: str):
"""os.stat() with a TTL cache; returns the stat_result or None if the path
is not a regular file. Files are immutable so a 5-min cache is safe and
collapses the CIFS actimeo=1 stat amplification (~514x per Agent 05)."""
now = time.monotonic()
hit = _path_stat_cache.get(path)
if hit is not None and hit[0] > now:
return hit[1]
try:
st = os.stat(path)
if not _statmod.S_ISREG(st.st_mode):
st = None
except OSError:
st = None
if len(_path_stat_cache) > _PATH_STAT_MAX:
_path_stat_cache.clear()
_path_stat_cache[path] = (now + _PATH_STAT_TTL, st)
return st
def _cached_isfile(path: str) -> bool:
return _cached_stat(path) is not None
async def _get_media_row(pool, media_id: int) -> Optional[dict]:
"""Cached media_files lookup for the hot file-serving endpoints. The path
columns never change for a given id, so a short TTL removes a pool
acquire + DB round-trip from /stream, /hls, /thumb, /preview peak load."""
now = time.monotonic()
hit = _media_row_cache.get(media_id)
if hit is not None and hit[0] > now:
return hit[1]
row = await pool.fetchrow(
"SELECT local_path, hls_path, file_type, original_name "
"FROM media_files WHERE id = $1",
media_id,
)
data = dict(row) if row else None
if len(_media_row_cache) > _MEDIA_ROW_MAX:
_media_row_cache.clear()
_media_row_cache[media_id] = (now + _MEDIA_ROW_TTL, data)
return data
def _video_content_type(file_path: str, file_type: str) -> str:
if file_type == "video":
return _VIDEO_MIME.get(os.path.splitext(file_path)[1].lower(), "video/mp4")
return mimetypes.guess_type(file_path)[0] or "application/octet-stream"
def _internal_uri(abs_path: str) -> Optional[str]:
"""Map an absolute on-disk path to an nginx internal redirect URI, or None
if the path is not under a known internal root."""
if abs_path.startswith(NAS_BASE + os.sep):
return _NAS_INTERNAL_PREFIX + quote(abs_path[len(NAS_BASE) + 1:])
if abs_path.startswith(_THUMB_CACHE_STR + os.sep):
return _THUMB_INTERNAL_PREFIX + quote(abs_path[len(_THUMB_CACHE_STR) + 1:])
return None
def _serve_file(abs_path: str, media_type: str, cache_control: str, *,
stat_result=None, accept_ranges: bool = True,
extra_headers: Optional[dict] = None):
"""Serve a file with the fastest available transport.
nginx X-Accel-Redirect (kernel sendfile, zero Python bytes) when X_ACCEL
is on and the path is under an internal root; otherwise FileResponse
(zero-copy os.sendfile via pathsend on uvicorn>=0.32). FileResponse also
handles Range/206/416/If-Range/HEAD correctly — replacing the old buggy
hand-rolled range path."""
headers = {"Cache-Control": cache_control}
if extra_headers:
headers.update(extra_headers)
if X_ACCEL:
internal = _internal_uri(abs_path)
if internal is not None:
headers["X-Accel-Redirect"] = internal
headers["Content-Type"] = media_type
if accept_ranges:
headers["Accept-Ranges"] = "bytes"
return Response(status_code=200, headers=headers)
return FileResponse(abs_path, media_type=media_type,
stat_result=stat_result, headers=headers)
_OLD_GVFS_PREFIX = "/run/user/1000/gvfs/smb-share:server=unas-pro-8.local,share=drive_2"
# --- Threadpool / thumbnail concurrency governance -------------------------
# All blocking PIL/ffmpeg work runs via `asyncio.to_thread(...)`. That hands
# off to the event loop's *default* executor — a stdlib ThreadPoolExecutor
# whose max_workers defaults to `min(32, cpu_count + 4)` = **32** on this
# 64-core box. (NOTE: it is NOT anyio's 40-token default limiter — that limiter
# only governs `anyio.to_thread.run_sync`, which this codebase does not use.
# Verified empirically: peak concurrent threads for asyncio.to_thread stayed at
# 32 no matter what we set the anyio limiter to.)
#
# Previously `_THUMB_SEMAPHORE` was `os.cpu_count()` (=64), so the *intended*
# thumb cap was 64 but the *real* wall was the hidden 32-thread stdlib pool —
# and every other `to_thread` caller (ingest_persist, hls/ffprobe, etc.)
# competed for those same 32 threads. Under a cold thumb-grid burst, requests
# 33..N queued behind the threadpool, not the semaphore.
#
# Fix: install a custom default executor sized to _THREADPOOL_TOKENS in
# lifespan (loop.set_default_executor — the only thing that actually governs
# asyncio.to_thread), and reconcile the thumb semaphore to sit below it.
# * _THREADPOOL_TOKENS = min(cpu_count, 48): 64 cores, but PIL decode/resize
# and ffmpeg are GIL/CPU-heavy, so unbounded threads thrash the GIL and
# oversubscribe cores. 48 leaves headroom over the thumb cap for the handful
# of non-thumb to_thread callers without saturating all 64 cores.
# * _THUMB_SEMAPHORE = 40 (< threadpool size): the semaphore — not a hidden
# threadpool wall — is now the real governor for thumbnail generation, and
# the remaining 8 threads stay available for other blocking work.
_THREADPOOL_TOKENS = min(os.cpu_count() or 4, 48)
_THUMB_CONCURRENCY = 40
# Semaphore to limit concurrent PIL/ffmpeg thumbnail generation. Kept strictly
# below _THREADPOOL_TOKENS so the semaphore is the governor, not the executor.
_THUMB_SEMAPHORE = asyncio.Semaphore(_THUMB_CONCURRENCY)
# Per-host semaphores to avoid hammering a single site (lazily created)
_host_semaphores: dict[str, asyncio.Semaphore] = {}
# Cached index.html content (loaded once on first request)
_INDEX_HTML = None
_INDEX_HTML_MTIME = 0
def get_index_html():
global _INDEX_HTML, _INDEX_HTML_MTIME
p = Path(__file__).parent / "static" / "index.html"
mtime = p.stat().st_mtime
if _INDEX_HTML is None or mtime > _INDEX_HTML_MTIME:
_INDEX_HTML = p.read_text()
_INDEX_HTML_MTIME = mtime
return _INDEX_HTML
# Allowed on-disk roots for media serving. A DB-supplied absolute path is only
# returned as-is if its realpath (symlinks resolved) is contained under one of
# these roots — otherwise a crafted/corrupt path column could escape the media
# tree and read arbitrary files (e.g. /etc/passwd). EXTERNAL_CONTENT_BASE sits
# under NAS_BASE today but is listed explicitly so the guard survives it moving.
_ALLOWED_MEDIA_ROOTS: tuple[str, ...] = tuple(
os.path.realpath(p)
for p in (NAS_BASE, EXTERNAL_CONTENT_BASE, _THUMB_CACHE_STR)
)
def _is_within_allowed_roots(path: str) -> bool:
"""True iff the realpath of `path` is inside one of _ALLOWED_MEDIA_ROOTS.
Uses os.path.realpath (resolves symlinks/.. ) + os.path.commonpath so a
path like NAS_BASE + "/../../etc/passwd" or a symlink out of the tree is
rejected. commonpath raises ValueError for cross-drive / relative mixes,
which we treat as "not contained".
"""
try:
real = os.path.realpath(path)
except (OSError, ValueError):
return False
for root in _ALLOWED_MEDIA_ROOTS:
try:
if os.path.commonpath([real, root]) == root:
return True
except ValueError:
continue
return False
def _resolve_media_path(local_path: str) -> str:
"""Resolve a media file path - handles both absolute and relative paths.
Existing coomer content uses absolute NAS paths (old GVFS or new CIFS).
External content (hentairead, nhentai, etc.) uses relative paths
that need to be resolved against EXTERNAL_CONTENT_BASE.
Security: an absolute path coming from the DB is only returned when it is
contained under one of the allowed media roots (realpath check). An escaping
path returns "" so every caller naturally 404s (request handlers) or skips
the row (background workers) instead of serving an arbitrary file.
"""
# Rewrite old GVFS paths to new kernel CIFS mount
if local_path.startswith(_OLD_GVFS_PREFIX):
local_path = NAS_BASE + local_path[len(_OLD_GVFS_PREFIX):]
if os.path.isabs(local_path):
if _cached_isfile(local_path):
# Containment guard: only hand back paths that resolve inside the
# media roots. Anything escaping (symlink or .. traversal) → "".
if _is_within_allowed_roots(local_path):
return local_path
return ""
# Absolute path doesn't exist — try under NAS_BASE as last resort
basename = os.path.basename(local_path)
for base in (EXTERNAL_CONTENT_BASE, NAS_BASE):
for root, _dirs, files in os.walk(base):
if basename in files:
return os.path.join(root, basename)
break # only check top-level, don't walk entire NAS
# Try resolving relative path against external content base. Containment is
# re-checked here too: a relative path containing ".." would otherwise
# os.path.join out of the base and escape the media tree.
resolved = os.path.join(EXTERNAL_CONTENT_BASE, local_path)
if _cached_isfile(resolved) and _is_within_allowed_roots(resolved):
return resolved
# Fallback: try NAS_BASE directly
resolved2 = os.path.join(NAS_BASE, local_path)
if _cached_isfile(resolved2) and _is_within_allowed_roots(resolved2):
return resolved2
return "" # No safe resolution — caller will 404 / skip
# Connection pool (initialized on startup)
_pool: asyncpg.Pool | None = None
# In-memory list/feed cache. Bounded so it can't leak; the per-endpoint
# `time.time() - cached_at < TTL` checks layered on top still set fresher
# windows for each kind (60 s feeds, 300 s creators, 3600 s atlas) — this
# 3600 s outer TTL is just the upper-bound eviction so anything older is
# definitely gone. Keys use prefixes (`recent:`, `posts:`, `tag_posts:`,
# `tag_posts_count:`, `galleries:`, `tags:N`, `creators`, `atlas`) so
# mutations can invalidate only the affected slice (`_cache_invalidate_feeds`)
# instead of the old full `.clear()` thundering herd that re-ran the 9 s /
# 1.6 s queries in parallel on every ingest/like (R2-04 #5).
_cache: TTLCache = TTLCache(maxsize=2000, ttl=3600)
CACHE_TTL = 300 # 5 minutes — per-endpoint freshness ceiling
# Cache-key prefixes affected by content mutations (ingest / delete). `atlas`
# is intentionally NOT in this list: it depends on `post_clusters` which only
# changes via the nightly UMAP refit, so we keep it across ingests — the old
# behaviour re-ran the 7 s atlas build on the very next request after any like.
_FEED_CACHE_PREFIXES = (
"recent:", "posts:", "tag_posts:", "tag_posts_count:",
"galleries:", "tags:", "creators", "search:", "explore:",
)
def _cache_invalidate_feeds() -> None:
"""Evict only feed/list cache keys (R2-04 §5). Preserves `atlas` and any
other namespaced caches that aren't invalidated by content mutations."""
victims = [k for k in list(_cache.keys())
if isinstance(k, str) and k.startswith(_FEED_CACHE_PREFIXES)]
for k in victims:
_cache.pop(k, None)
class IngestStatus(str, Enum):
QUEUED = "queued"
DOWNLOADING = "downloading"
COMPLETED = "completed"
FAILED = "failed"
@dataclass
class IngestTask:
task_id: str
creator_url: str
status: IngestStatus = IngestStatus.QUEUED
creator_name: str | None = None
total_files: int = 0
files_downloaded: int = 0
files_failed: int = 0
current_file: str | None = None
queued_at: float = 0.0
started_at: float | None = None
completed_at: float | None = None
error: str | None = None
_ingest_tasks: dict[str, IngestTask] = {}
_ingest_queue: asyncio.Queue | None = None
# === URL Ingest (universal URL handler) ===
@dataclass
class URLIngestTask:
task_id: str
url: str
handler: str
service: str
content_id: str | None = None
status: IngestStatus = IngestStatus.QUEUED
queued_at: float = 0.0
started_at: float | None = None
completed_at: float | None = None
error: str | None = None
result: dict | None = None
_url_ingest_tasks: dict[str, URLIngestTask] = {}
_url_ingest_queue: asyncio.Queue | None = None
# State holder for the embedding background worker (readable via /api/embeddings/status)
last_embedding_run: dict = {
"started_at": None,
"finished_at": None,
"pending_at_start": None,
"exit_code": None,
"batch_limit": None,
}
_EMBED_DIR = Path(__file__).parent # cwd for subprocess so batch_embed.log lands here
_EMBED_LOG = _EMBED_DIR / "embedding_pipeline.log"
_EMBED_LOCK = _EMBED_DIR / ".batch_embed.lock"
def _batch_embed_running() -> bool:
"""True if a batch_embed subprocess from any server instance is still alive.
Guards against the multi-spawn pile-up: a stale server, an overlapping
tick, or (historically) multiple uvicorn workers each launching their own
`batch_embed.py --mode all`, which starved the box and leaked the listen fd.
"""
try:
pid = int(_EMBED_LOCK.read_text().strip())
except (FileNotFoundError, ValueError):
return False
try:
os.kill(pid, 0)
except (ProcessLookupError, PermissionError):
_EMBED_LOCK.unlink(missing_ok=True) # stale lock
return False
return True
async def _hls_worker():
"""Dedicated single worker: process video-HLS backlog without blocking URL ingest workers."""
interval = int(os.environ.get("HLS_INTERVAL_SEC", "60"))
while True:
try:
await asyncio.to_thread(_run_hls_for_pending)
except Exception:
pass
await asyncio.sleep(interval)
async def _thumb_worker():
"""Dedicated single worker: pre-generate cover thumbs for new posts."""
interval = int(os.environ.get("THUMB_INTERVAL_SEC", "30"))
while True:
try:
await asyncio.to_thread(_generate_thumbs_for_pending)
except Exception:
pass
await asyncio.sleep(interval)
async def _refresh_tag_counts():
"""Refresh `tag_post_counts` every TAG_COUNT_REFRESH_SEC (default 300 s).
This replaces the per-request GROUP BY over 606k post_tags in /api/tags
(was ~1.5 s — R2-04 §4) with a ~5 ms point read from a pre-aggregated
table. UPSERT + cleanup keeps writes minimal; concurrent reads see a
self-consistent snapshot because each statement is atomic."""
interval = int(os.environ.get("TAG_COUNT_REFRESH_SEC", "300"))
# Wait one interval before the first refresh — the initial population was
# done by the migration; this loop just keeps it fresh post-ingest.
await asyncio.sleep(interval)
while True:
try:
if _pool is not None:
async with _pool.acquire() as conn:
await conn.execute("""
INSERT INTO tag_post_counts (tag_id, n)
SELECT tag_id, COUNT(*) FROM post_tags GROUP BY tag_id
ON CONFLICT (tag_id) DO UPDATE
SET n = EXCLUDED.n, updated_at = now()
""")
await conn.execute("""
DELETE FROM tag_post_counts
WHERE tag_id NOT IN (SELECT tag_id FROM post_tags GROUP BY tag_id)
""")
except Exception:
pass
await asyncio.sleep(interval)
async def _embedding_worker():
"""Periodically run batch_embed.py --mode all if uncovered posts exist."""
interval = int(os.environ.get("EMBEDDING_INTERVAL_SEC", "600"))
batch = int(os.environ.get("EMBEDDING_BATCH_LIMIT", "200"))
while True:
await asyncio.sleep(interval)
# Count posts that have media_files but no aggregated embedding row yet.
# Approximate: joins media_files to detect posts with any media, then checks
# content_embeddings for a matching aggregation='post' row on that post.
pending = 0
try:
async with _pool.acquire() as conn:
row = await conn.fetchrow(
"""SELECT COUNT(DISTINCT mf.post_id) AS n
FROM media_files mf
WHERE NOT EXISTS (
SELECT 1 FROM content_embeddings ce
WHERE ce.post_id = mf.post_id
AND ce.aggregation = 'post'
)"""
)
pending = row["n"] if row else 0
except Exception as exc:
with open(_EMBED_LOG, "a") as f:
f.write(f"[{datetime.now(timezone.utc).isoformat()}] ERROR pending-count query failed: {exc}\n")
continue # skip this tick, try again next interval
if pending == 0:
continue
if _batch_embed_running():
with open(_EMBED_LOG, "a") as f:
f.write(
f"[{datetime.now(timezone.utc).isoformat()}] SKIP "
f"batch_embed already running (pending={pending})\n"
)
continue
last_embedding_run["pending_at_start"] = pending
last_embedding_run["batch_limit"] = batch
last_embedding_run["started_at"] = datetime.now(timezone.utc).isoformat()
last_embedding_run["finished_at"] = None
last_embedding_run["exit_code"] = None
header = (
f"[{last_embedding_run['started_at']}] START batch_embed --mode all --limit {batch}"
f" | pending={pending}\n"
)
try:
with open(_EMBED_LOG, "a") as f:
f.write(header)
proc = await asyncio.create_subprocess_exec(
sys.executable, str(_EMBED_DIR / "batch_embed.py"),
"--mode", "all", "--limit", str(batch),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=str(_EMBED_DIR),
close_fds=True, # never leak the uvicorn listen socket into the child
)
_EMBED_LOCK.write_text(str(proc.pid))
stdout, _ = await proc.communicate()
rc = proc.returncode
last_embedding_run["exit_code"] = rc
last_embedding_run["finished_at"] = datetime.now(timezone.utc).isoformat()
with open(_EMBED_LOG, "a") as f:
if stdout:
f.write(stdout.decode(errors="replace"))
level = "DONE" if rc == 0 else "ERROR"
f.write(f"[{last_embedding_run['finished_at']}] {level} exit_code={rc}\n")
except Exception as exc:
last_embedding_run["finished_at"] = datetime.now(timezone.utc).isoformat()
last_embedding_run["exit_code"] = -1
with open(_EMBED_LOG, "a") as f:
f.write(f"[{last_embedding_run['finished_at']}] ERROR subprocess launch failed: {exc}\n")
finally:
_EMBED_LOCK.unlink(missing_ok=True)
@asynccontextmanager
async def lifespan(app: FastAPI):
global _pool, _ingest_queue, _url_ingest_queue
async def _setup_conn(conn):
# JIT compilation costs ~3s for our parameterized vector queries
# while execution is <100ms. asyncpg runs DISCARD ALL on release,
# which resets GUCs — so we have to re-apply on every acquire via
# `setup` (not `init`).
await conn.execute("SET jit = off")
# Single async uvicorn worker shared by request handlers AND the background
# ingest/embedding/hls/thumb workers; 10 conns was the hard concurrency
# ceiling and one slow query could starve the request path. PG
# max_connections=100 has the headroom. (Agent 06 §1)
_pool = await asyncpg.create_pool(
DB_URL,
min_size=8,
max_size=40,
max_inactive_connection_lifetime=300,
command_timeout=30,
statement_cache_size=512,
server_settings={
"application_name": "coomer_viewer",
# Auto-kill any backend left idle-in-transaction for >30s. A leaked
# transaction (unreleased conn mid-txn) was the root cause of the
# recurring lock-exhaustion outage; PG reaps it instead of holding
# its locks forever. Value is milliseconds.
"idle_in_transaction_session_timeout": "30000",
},
setup=_setup_conn, # keep: DISCARD ALL resets jit; needed until
# jit=off lands in postgresql.conf (infra step)
)
# Raise the threadpool concurrency for blocking PIL/ffmpeg work. The hot
# path uses asyncio.to_thread, which is governed by the event loop's DEFAULT
# executor (stdlib ThreadPoolExecutor, max_workers=min(32, cpu+4)=32 here),
# NOT by anyio's limiter. So the real fix is to install a custom default
# executor sized to _THREADPOOL_TOKENS. We also bump the anyio limiter for
# completeness (covers any anyio.to_thread.run_sync paths). See the
# _THREADPOOL_TOKENS / _THUMB_SEMAPHORE notes near the top of this module.
try:
_loop = asyncio.get_running_loop()
_loop.set_default_executor(
ThreadPoolExecutor(
max_workers=_THREADPOOL_TOKENS,
thread_name_prefix="to_thread",
)
)
anyio.to_thread.current_default_thread_limiter().total_tokens = _THREADPOOL_TOKENS
logging.getLogger("startup").info(
"to_thread executor max_workers=%d, anyio limiter=%d "
"(thumb semaphore=%d)",
_THREADPOOL_TOKENS, _THREADPOOL_TOKENS, _THUMB_CONCURRENCY,
)
except Exception as _e: # noqa: BLE001 — never let tuning block startup
logging.getLogger("startup").warning(
"Could not raise to_thread concurrency: %s", _e
)
_ingest_queue = asyncio.Queue()
_url_ingest_queue = asyncio.Queue()
# Durable queue recovery: re-enqueue everything still pending from before
# the last restart. Without this every uvicorn restart silently dropped
# the whole URL-ingest backlog (queue lived only in module state).
try:
ingest_persist.init_db()
_recovered = await asyncio.to_thread(ingest_persist.load_recoverable)
for _r in _recovered:
_t = URLIngestTask(
task_id=_r["task_id"],
url=_r["url"],
handler=_r["handler"],
service=_r["service"],
content_id=_r["content_id"],
status=IngestStatus.QUEUED,
queued_at=_r["queued_at"] or time.time(),
)
_url_ingest_tasks[_t.task_id] = _t
_url_ingest_queue.put_nowait(_t)
if _recovered:
_url_ingest_log.info(
"Recovered %d pending URL-ingest tasks from durable store",
len(_recovered),
)
except Exception as _e: # noqa: BLE001 — never let recovery block startup
logging.getLogger("url_ingest").error("Queue recovery failed: %s", _e)
# COOMER_DISABLE_BG=1 starts the API WITHOUT any background sweep workers
# (ingest / url-ingest / embedding / HLS / thumb / tag-refresh). This is a
# light, NAS-safe mode: a backlog sweep (hls_generator / batch_embed) can't
# saturate the CIFS mount and wedge the box. Request-path thumb generation
# still works on demand.
if os.environ.get("COOMER_DISABLE_BG") == "1":
logging.getLogger("uvicorn").warning(
"COOMER_DISABLE_BG=1 — background sweep workers DISABLED (light mode)"
)
else:
asyncio.create_task(_ingest_worker())
_n_url_workers = int(os.environ.get("URL_INGEST_WORKERS", "6"))
for _ in range(_n_url_workers):
asyncio.create_task(_url_ingest_worker())
asyncio.create_task(_url_ingest_watchdog())
asyncio.create_task(_embedding_worker()) # one GPU-bound embedding worker
asyncio.create_task(_hls_worker())
asyncio.create_task(_thumb_worker())
asyncio.create_task(_refresh_tag_counts())
yield
if _pool:
await _pool.close()
# This FastAPI serializes returned models/dicts to JSON bytes directly (fast,
# no custom class needed — ORJSONResponse is deprecated here). The big
# serialization win was the 5 MB /api/atlas payload, now pre-serialized with
# orjson and cached as raw bytes. compresslevel=6 ≈ 4x faster than gzip-9 for
# ~5% larger output, on every compressed response (R2-09 #5).
app = FastAPI(title="Coomer Viewer", lifespan=lifespan)
# Replaces starlette GZipMiddleware (perf2/transport). Fixes three measured
# issues: (1) API/JSON now negotiates Brotli (~20-30% smaller than gzip) when
# the client sends `Accept-Encoding: br`, gzip otherwise; (2) already-compressed
# content types (woff2/woff/png/jpg/webp/avif/mp4/m4s/ts/zip) are NOT
# re-compressed — woff2 fonts were being gzip-recompressed slightly LARGER;
# (3) responses with a Content-Encoding already set (the precompressed .br/.gz
# static siblings) and 206 range responses pass through untouched. Adds
# `Vary: Accept-Encoding` to every response so caches stay correct.
app.add_middleware(SmartCompressionMiddleware, minimum_size=500,
gzip_level=6, brotli_quality=5)
app.add_middleware(CORSMiddleware, allow_origins=CORS_ORIGINS, allow_methods=["*"], allow_headers=["*"])
# Tailscale assigns node addresses out of the 100.64.0.0/10 CGNAT range. The
# app is tailnet-only + loopback; there's no password auth by design, so this
# lightweight guard just rejects anything that isn't loopback or a tailnet peer.
# The browser extension posts from a 100.x tailnet IP, so it passes.
_TRUSTED_NETS = (
ipaddress.ip_network("127.0.0.0/8"), # IPv4 loopback
ipaddress.ip_network("::1/128"), # IPv6 loopback
ipaddress.ip_network("100.64.0.0/10"), # Tailscale CGNAT
)
def require_trusted_client(request: Request) -> None:
"""FastAPI dependency: allow only loopback or Tailscale-CGNAT clients.
Applied to mutating/ingest endpoints. request.client can be None for some
ASGI transports (e.g. in-process test client) — treated as untrusted unless
the host parses to a trusted network.
"""
client = request.client
host = client.host if client else None
if not host:
raise HTTPException(status_code=403, detail="Forbidden: untrusted client")
try:
ip = ipaddress.ip_address(host)
except ValueError:
raise HTTPException(status_code=403, detail="Forbidden: untrusted client")
# IPv4-mapped IPv6 (e.g. ::ffff:100.x.y.z) → compare on the mapped v4 addr.
if getattr(ip, "ipv4_mapped", None) is not None:
ip = ip.ipv4_mapped
if any(ip in net for net in _TRUSTED_NETS):
return
raise HTTPException(status_code=403, detail="Forbidden: untrusted client")
def _run_ingest(task: IngestTask):
"""Sync function that runs the download pipeline with progress tracking."""
downloader = None
try:
task.status = IngestStatus.DOWNLOADING
task.started_at = time.time()
parsed = urlparse(task.creator_url)
domain = parsed.netloc.lower()
if not any(d in domain for d in SUPPORTED_DOMAINS):
raise ValueError(f"Unsupported domain: {domain}")
base_site = f"https://{domain}"
path_parts = [p for p in parsed.path.strip("/").split("/") if p]
if len(path_parts) < 3 or path_parts[1] != "user":
raise ValueError(f"Invalid creator URL format: {task.creator_url}")
service = path_parts[0]
user_id = path_parts[2]
downloader = CoomerNASDownloader(
nas_base=NAS_BASE,
workers=3,
rate_limit=2.0,
db_url=DB_URL,
)
username = downloader.fetch_profile(base_site, service, user_id)
task.creator_name = username
creator_id = downloader.db.get_or_create_creator(
username, user_id, service, task.creator_url
)
creator_folder = os.path.join(downloader.nas_base, "onlyfans", username)
os.makedirs(creator_folder, exist_ok=True)
posts = downloader.fetch_posts(base_site, service, user_id)
if not posts:
task.status = IngestStatus.COMPLETED
task.completed_at = time.time()
return
meta_path = os.path.join(creator_folder, "metadata.json")
with open(meta_path, "w") as f:
json.dump(
{
"creator": username,
"user_id": user_id,
"service": service,
"platform_url": task.creator_url,
"scraped_at": datetime.now(timezone.utc).isoformat(),
"total_posts": len(posts),
"posts": posts,
},
f,
indent=2,
default=str,
)
downloader.db.upsert_posts(creator_id, posts)
media = downloader.extract_media(posts, base_site)
task.total_files = len(media)
if not media:
task.status = IngestStatus.COMPLETED
task.completed_at = time.time()
return
def _do_download(item):
idx, (url, meta) = item
ext = os.path.splitext(urlparse(url).path)[1] or ".bin"
post_id = meta.get("id", "unknown")
orig_name = meta.get("original_name", "")
if orig_name:
safe_name = "".join(
c if c.isalnum() or c in "._- " else "_" for c in orig_name
)
filename = f"{post_id}_{safe_name}"
else:
filename = f"{post_id}_{idx}{ext}"
file_type = downloader._classify_file(ext)
subfolder = file_type + "s"
dest = os.path.join(creator_folder, subfolder, filename)
task.current_file = filename
if os.path.exists(dest):
file_size = os.path.getsize(dest)
downloader.db.upsert_media(
creator_id, post_id, url, orig_name, dest,
file_type, file_size, meta.get("attachment_type", "file"),
)
return True
ok, file_size = downloader.download_file(url, dest)
if ok:
downloader.db.upsert_media(
creator_id, post_id, url, orig_name, dest,
file_type, file_size, meta.get("attachment_type", "file"),
)
return ok
with ThreadPoolExecutor(max_workers=downloader.workers) as pool:
futures = {
pool.submit(_do_download, (i, item)): i
for i, item in enumerate(media, 1)
}
for future in as_completed(futures):
try:
if future.result():
task.files_downloaded += 1
else:
task.files_failed += 1
except Exception:
task.files_failed += 1
task.status = IngestStatus.COMPLETED
task.completed_at = time.time()
task.current_file = None
except Exception as e:
task.status = IngestStatus.FAILED
task.error = str(e)
task.completed_at = time.time()
finally:
if downloader:
downloader.close()
def _generate_thumbs_for_pending():
"""Generate thumbnails for videos and image-gallery cover frames missing them."""
import psycopg2 as _pg
conn = _pg.connect(DB_URL)
cur = conn.cursor()
try:
# Video thumbs (smart-thumb / ffmpeg)
cur.execute("""
SELECT id, local_path FROM media_files
WHERE file_type = 'video' AND local_path IS NOT NULL
ORDER BY id DESC
LIMIT 100
""")
for media_id, local_path in cur.fetchall():
thumb_path = THUMB_CACHE_DIR / f"video_{media_id}.jpg"
if thumb_path.exists():
continue
local_path = _resolve_media_path(local_path)
if not os.path.isfile(local_path):
continue
try:
generate_smart_thumb(local_path, str(thumb_path), THUMB_MAX_WIDTH)
except Exception:
pass
# Image-gallery cover thumbs (PIL resize, one cover per post)
cur.execute("""
SELECT (array_agg(mf.id ORDER BY mf.page_order NULLS LAST, mf.id))[1] AS media_id,
(array_agg(mf.local_path ORDER BY mf.page_order NULLS LAST, mf.id))[1] AS local_path
FROM posts p
JOIN media_files mf ON mf.post_id = p.id
WHERE (mf.file_type LIKE 'image%' OR mf.file_type = 'photo')
AND mf.local_path IS NOT NULL
GROUP BY p.id
ORDER BY p.id DESC
LIMIT 200
""")
for media_id, local_path in cur.fetchall():
cache_key = hashlib.md5(f"{media_id}:{THUMB_MAX_WIDTH}:jpg".encode()).hexdigest()
thumb_path = THUMB_CACHE_DIR / f"{cache_key}.jpg"
if thumb_path.exists():
continue
local_path = _resolve_media_path(local_path)
if not os.path.isfile(local_path):
continue
try:
with Image.open(local_path) as img:
img = img.convert("RGB")
ratio = THUMB_MAX_WIDTH / img.width
new_h = int(img.height * ratio)
img = img.resize((THUMB_MAX_WIDTH, new_h), Image.LANCZOS)
img.save(thumb_path, "JPEG", quality=THUMB_QUALITY, optimize=True)
except Exception:
pass
finally:
conn.close()
def _run_hls_for_pending():
"""Generate HLS for any videos missing it. Called after ingestion."""
import subprocess as _sp
import psycopg2 as _pg
conn = _pg.connect(DB_URL)
conn.autocommit = False
cur = conn.cursor()
try:
cur.execute("""
SELECT id, local_path FROM media_files
WHERE file_type = 'video' AND hls_path IS NULL AND local_path IS NOT NULL
ORDER BY file_size ASC
LIMIT 100
""")
pending = cur.fetchall()
if not pending:
return
for media_id, local_path in pending:
# Rewrite old GVFS paths to kernel CIFS mount
local_path = _resolve_media_path(local_path)
if not os.path.isfile(local_path):
continue
from pathlib import Path as _P
_hls_base = _P(NAS_BASE) / "drive_2" / "onlyfans" / ".hls_output"
hls_dir = _hls_base / str(media_id)
hls_dir.mkdir(parents=True, exist_ok=True)
playlist = hls_dir / "stream.m3u8"
if playlist.exists() and playlist.stat().st_size > 0:
cur.execute("UPDATE media_files SET hls_path = %s WHERE id = %s", (str(hls_dir), media_id))
conn.commit()
continue
result = _sp.run([
"ffmpeg", "-y", "-i", local_path,
"-c:v", "copy", "-c:a", "copy",
"-hls_time", "2", "-hls_list_size", "0",
"-hls_segment_type", "mpegts",
"-hls_segment_filename", str(hls_dir / "seg_%04d.ts"),
"-f", "hls", str(playlist),
], capture_output=True, timeout=3600)
if result.returncode == 0:
cur.execute("UPDATE media_files SET hls_path = %s WHERE id = %s", (str(hls_dir), media_id))
conn.commit()
finally:
conn.close()
async def _ingest_worker():
"""Async coroutine that processes the ingest queue sequentially."""
while True:
task = await _ingest_queue.get()
try:
await asyncio.to_thread(_run_ingest, task)
# Invalidate cache so new creators appear in UI
_cache_invalidate_feeds()
# Generate HLS for newly downloaded videos
await asyncio.to_thread(_run_hls_for_pending)
# Generate smart thumbnails and previews for new videos
await asyncio.to_thread(_generate_thumbs_for_pending)
except Exception as e:
task.status = IngestStatus.FAILED
task.error = str(e)
task.completed_at = time.time()
finally:
_ingest_queue.task_done()
# Trim completed tasks to last 100
completed = [
tid for tid, t in _ingest_tasks.items()
if t.status in (IngestStatus.COMPLETED, IngestStatus.FAILED)
]
if len(completed) > 100:
for tid in completed[:-100]:
del _ingest_tasks[tid]
# === URL Ingest Worker ===
import logging
_url_ingest_log = logging.getLogger("url_ingest")
# Path to download scripts
_SCRIPTS_DIR = Path(__file__).parent / "downloads" / "scripts"
# NAS content base for external downloads
_EXTERNAL_NAS = Path(EXTERNAL_CONTENT_BASE)
def _run_url_ingest(task: URLIngestTask):
"""
Sync function that dispatches a URL ingest task to the correct handler.
Each handler is a subprocess call to the matching download script.
"""
import subprocess as _sp