Compare commits

...
13 Commits
Author SHA1 Message Date
bvandeusen 0533807669 Merge pull request 'feat(ext): verify cookies in-browser before uploading (1.0.7)' (#57) from dev into main
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-ml (push) Successful in 3m28s
Build images / build-web (push) Successful in 3m42s
CI / intapi (push) Successful in 9m32s
CI / frontend-build (push) Successful in 20s
extension / lint (push) Successful in 14s
Build images / sign-extension (push) Successful in 3m17s
CI / intimp (push) Successful in 4m26s
CI / lint (push) Successful in 3s
CI / intcore (push) Successful in 10m18s
2026-06-03 14:17:42 -04:00
bvandeusen d3245f0c22 feat(ext): verify cookies in-browser before uploading (1.0.7)
CI / intcore (push) Successful in 8m45s
extension / lint (pull_request) Successful in 14s
CI / backend-lint-and-test (push) Successful in 37s
CI / frontend-build (push) Successful in 40s
extension / lint (push) Successful in 36s
CI / lint (push) Successful in 4s
CI / intimp (push) Successful in 4m4s
CI / intapi (push) Successful in 8m2s
Pre-upload verify request: after capturing the live browser cookies,
hit a known authenticated endpoint with credentials:'include' from the
extension's background context. If the platform reports we're not
logged in, abort the upload so we don't overwrite FC-side credentials
with stale data.

- platforms.js: add `verify` config per cookie-auth platform
  - hentaifoundry: HEAD /?enterAgree=1 (mirrors gallery-dl's HF
    _init_site_filters; same 401 path the operator hit 2026-06-03)
  - patreon: GET /api/current_user (clean 401 when logged out)
  - subscribestar, deviantart: no stable auth endpoint, skip verify
- cookies.js: verifyCookiesForPlatform() returns {ok, status, reason}.
  ok=true/false/null tri-state — null = verify not configured, caller
  treats as "proceed".
- background.js EXPORT_COOKIES + EXPORT_ALL_COOKIES: verify gates the
  upload; failures bubble up with the platform's name + reason.
- popup.js: success message now appends "(verified ✓)" when applicable.
- manifest + package.json: 1.0.6 → 1.0.7.
2026-06-03 14:04:45 -04:00
bvandeusen 279dff3fb6 Merge pull request 'feat(ml): normalize Camie suggestion names to human-readable' (#56) from dev into main
Build images / build-ml (push) Successful in 2m55s
CI / intimp (push) Successful in 3m36s
CI / intapi (push) Successful in 7m38s
CI / intcore (push) Successful in 8m24s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 17s
Build images / build-web (push) Successful in 2m17s
2026-06-03 13:18:44 -04:00
bvandeusen e450145304 fix(ml): preserve digit-only tag names in normalize (year tags)
CI / frontend-build (push) Successful in 21s
CI / intapi (push) Successful in 7m42s
CI / intcore (push) Successful in 8m12s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 14s
CI / intimp (push) Successful in 3m45s
Rule 8 'no letters -> drop' was over-eager: bare digit tags like '2005'
returned None even though they're legitimate (booru year-tag shape).
Widen the keep-condition to any alphanumeric. Emoticons (':/', '^_^',
'+_+') still drop since they contain neither letters nor digits.
2026-06-03 13:09:35 -04:00
bvandeusen a6e8d4b52e feat(ml): normalize Camie suggestion names to human-readable
CI / lint (push) Successful in 2s
CI / intimp (push) Successful in 3m57s
CI / intapi (push) Successful in 7m40s
CI / intcore (push) Successful in 8m22s
CI / backend-lint-and-test (push) Failing after 24s
CI / frontend-build (push) Successful in 28s
Camie's booru-style vocab strings (`uchiha_sasuke_(naruto)`,
`#unicus_(idolmaster)`, `1000-nen_ikiteru_(vocaloid)`, `:/`) were
surfacing raw in SuggestionsPanel — and worse, the SAME raw string was
written to tag.name on Accept, polluting the DB with `underscored_lowercase`
names that don't match the operator's "Title Case" tag convention.

Add backend/app/services/ml/tag_name.py with a single normalize()
applying nine rules (strip leading junk #/./+/;/~/_/ws, drop trailing
_(disambiguator) blocks iteratively, strip wrapping quotes, underscores
to spaces, space after colon, title-case each word's first char,
preserve hyphens/apostrophes/digits, drop entries with no letters).

Wire into SuggestionService.for_image:
- raw Camie key kept for alias_map lookup (alias rows are hand-curated
  against raw keys; don't disturb)
- display_name = normalize(raw); None means drop the candidate
- existing-tag lookup widened to case-insensitive match against BOTH
  raw and normalized forms so legacy underscore-named Tag rows accepted
  before this change still surface as "existing" not "+ new"
2026-06-03 13:00:08 -04:00
bvandeusen 37e66cddc4 Merge pull request 'chore(modal): drop ?image=N soft-compat — pure overlay' (#55) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-ml (push) Successful in 2m46s
Build images / build-web (push) Successful in 2m36s
CI / intimp (push) Successful in 3m42s
CI / intapi (push) Successful in 7m25s
CI / intcore (push) Successful in 8m12s
2026-06-02 19:35:04 -04:00
bvandeusen f1860866de chore(modal): drop the ?image=N soft-compat from G5.4
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 14s
CI / frontend-build (push) Successful in 20s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Successful in 8m38s
CI / intcore (push) Successful in 9m31s
Operator confirmed they have no existing ?image=N bookmarks to
preserve, so the soft-compat read on initial mount + router.replace
strip is dead weight. The modal is now purely a Pinia overlay — no
URL involvement on open OR initial mount.

Drops 33 lines plus the now-unused vue-router imports in both
ArtistGalleryTab (entire onMounted gone) and GalleryView (just the
?image=N block; the post_id/tag_id filter handling stays).
2026-06-02 19:24:09 -04:00
bvandeusen 9cf6b2d363 Merge pull request 'audit-g5 final + ML threshold default + kebab menu fix' (#54) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / backend-lint-and-test (push) Successful in 23s
CI / frontend-build (push) Successful in 27s
Build images / build-web (push) Successful in 3m0s
Build images / build-ml (push) Successful in 3m45s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 8m8s
CI / intcore (push) Successful in 8m34s
2026-06-02 19:09:49 -04:00
bvandeusen b181d779fe fix(test): default suggestion_threshold_general now 0.70 (alembic 0033)
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 17s
CI / frontend-build (push) Successful in 26s
CI / intimp (push) Successful in 3m49s
CI / intapi (push) Successful in 8m13s
CI / intcore (push) Successful in 7m6s
2026-06-02 18:49:24 -04:00
bvandeusen 0fbb19dc24 fix(modal): TagPanel kebab — apply the wrapping-span fix that the prior commit missed
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 37s
CI / frontend-build (push) Successful in 26s
CI / intimp (push) Successful in 3m59s
CI / intapi (push) Failing after 8m56s
CI / intcore (push) Successful in 10m0s
The previous commit (8326e54) updated the CSS but the structural
edit to the v-menu wrapper didn't take. Re-applying so the chip
kebab actually opens.
2026-06-02 18:48:53 -04:00
bvandeusen 8326e5447a fix(modal): kebab menus weren't opening (chip + suggestion rows)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 25s
CI / intimp (push) Successful in 3m40s
CI / intapi (push) Failing after 8m41s
CI / intcore (push) Successful in 10m0s
Operator-flagged 2026-06-02. Both kebab activators were broken:

- TagPanel chip kebab had `@click.stop` directly on the v-icon
  activator. In Vue 3, an explicit @click on the same element as
  `v-bind="props"` overrides the spread onClick — so Vuetify's
  activator handler never fired. Menu never opened.

- SuggestionItem kebab didn't have @click.stop, but for consistency
  and to make both kebabs follow the same shape, wrap it too.

The fix: each kebab is now wrapped in a `<span @click.stop>`. The
v-icon / v-btn receives Vuetify's onClick cleanly and opens the menu;
the bubbling click then reaches the span where stopPropagation
absorbs it before it can affect a parent (the v-chip's close button
in TagPanel's case).
2026-06-02 18:48:32 -04:00
bvandeusen 1fd594baaf chore(ml): suggestion_threshold default 0.50 → 0.70
CI / lint (push) Successful in 4s
CI / backend-lint-and-test (push) Successful in 28s
CI / frontend-build (push) Successful in 29s
CI / intimp (push) Successful in 3m44s
CI / intapi (push) Failing after 8m16s
CI / intcore (push) Successful in 8m43s
Operator-flagged 2026-06-02 — the 0.50 default (set on 2026-06-01)
surfaces too many low-confidence picks in the modal's Suggestions
rail. 0.70 keeps the rail signal-rich while still showing more than
the original 0.95 (which hid almost everything).

Alembic 0033 updates the singleton row conditionally — only rows
still at the old 0.50 default flip to 0.70. Operators who tuned to
some other value via Settings → ML keep their pick.

Settings UI already exposes both sliders (MLThresholdSliders.vue),
so further tuning continues to work without a deploy.
2026-06-02 18:38:12 -04:00
bvandeusen ecac6c4bda fix(audit-g5): centroid version DB-as-truth + modal as overlay
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 24s
CI / frontend-build (push) Successful in 27s
CI / intimp (push) Successful in 3m49s
CI / intapi (push) Successful in 7m57s
CI / intcore (push) Successful in 8m46s
Closes the last two findings from the 2026-06-02 audit (G5.1 + G5.4).

G5.1 — Centroid version no longer drifts:

CentroidService now reads MLSettings.embedder_model_version (the DB
row tag_and_embed already writes from) for both the centroid model-
version stamp and the drift-detection comparison. Previously the
centroid sites imported MODEL_VERSION from env, so the version stamped
on centroids could disagree with the version stamped on the embeddings
they were built from. By construction those now match, so list_drifted
won't silently miss the env-vs-DB drift case.

embedder.py keeps MODEL_VERSION as an env-driven constant for the
actual model loader — that's a different concern (which weights are
loaded) from the version-stamp that gets persisted alongside data.

G5.4 — Modal is a Pinia-only overlay:

The previous URL↔modal sync in GalleryView and ArtistGalleryTab
leaked the modal across route changes (RouterLink to /artist/<slug>
left the modal mounted on top of the new route) and re-opened it
on history back/forward with stale ?image=N entries.

Now: openImage() just calls modal.open(id) — no URL push.
GalleryView's dead closeImage helper is deleted. A route.name
watcher in App.vue closes the modal whenever the route changes,
which auto-fixes RouterLink-in-modal and back/forward.

Backward-compat: ?image=N is still honored on initial mount as a
one-shot deep-link opener, then router.replace strips the query so
the URL doesn't re-trigger and no extra history entry is added.
Existing bookmarks / shared URLs keep working; new opens stay
Pinia-only.
2026-06-02 18:28:57 -04:00
19 changed files with 376 additions and 96 deletions
@@ -0,0 +1,48 @@
"""suggestion_threshold default 0.50 → 0.70
Revision ID: 0033
Revises: 0032
Create Date: 2026-06-02
Operator-flagged 2026-06-02 — the 0.50 default (set on 2026-06-01) is
too noisy in practice; raise to 0.70 for both suggestion categories.
Only conditionally updates singletons whose current value is still the
2026-06-01 default (0.50). Operators who deliberately tuned their row
to some other value (0.55, 0.65, 0.80, etc. via the Settings UI) keep
their pick — the migration only catches the unchanged-default case.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0033"
down_revision: Union[str, None] = "0032"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"UPDATE ml_settings "
"SET suggestion_threshold_character = 0.70 "
"WHERE id = 1 AND suggestion_threshold_character = 0.50"
)
op.execute(
"UPDATE ml_settings "
"SET suggestion_threshold_general = 0.70 "
"WHERE id = 1 AND suggestion_threshold_general = 0.50"
)
def downgrade() -> None:
op.execute(
"UPDATE ml_settings "
"SET suggestion_threshold_character = 0.50 "
"WHERE id = 1 AND suggestion_threshold_character = 0.70"
)
op.execute(
"UPDATE ml_settings "
"SET suggestion_threshold_general = 0.50 "
"WHERE id = 1 AND suggestion_threshold_general = 0.70"
)
+6 -5
View File
@@ -16,13 +16,14 @@ class MLSettings(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
suggestion_threshold_character: Mapped[float] = mapped_column( suggestion_threshold_character: Mapped[float] = mapped_column(
Float, nullable=False, default=0.50 Float, nullable=False, default=0.70
) )
# Default lowered 0.95 → 0.50 on 2026-06-01 — operator-flagged that # Default raised 0.50 → 0.70 on 2026-06-02 — operator-flagged 0.50
# 0.95 hid most general suggestions. Operator-tunable via Settings → # surfaced too many low-confidence picks; 0.70 keeps the rail
# ML if too noisy. # signal-rich while still surfacing more than the original 0.95
# which hid almost everything. Operator-tunable via Settings → ML.
suggestion_threshold_general: Mapped[float] = mapped_column( suggestion_threshold_general: Mapped[float] = mapped_column(
Float, nullable=False, default=0.50 Float, nullable=False, default=0.70
) )
centroid_similarity_threshold: Mapped[float] = mapped_column( centroid_similarity_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.55 Float, nullable=False, default=0.55
+20 -4
View File
@@ -19,7 +19,6 @@ from ...models import (
TagReferenceEmbedding, TagReferenceEmbedding,
) )
from ...models.tag import image_tag from ...models.tag import image_tag
from .embedder import MODEL_VERSION as SIGLIP_VERSION
ELIGIBLE_KINDS = { ELIGIBLE_KINDS = {
TagKind.character, TagKind.character,
@@ -46,6 +45,21 @@ class CentroidService:
) )
).scalar_one() ).scalar_one()
async def _model_version(self) -> str:
"""Audit 2026-06-02: SigLIP model-version stamp comes from the
DB row, not the env constant. tag_and_embed (tasks/ml.py:110)
already reads from MLSettings.embedder_model_version, so by
sourcing centroid stamps + drift checks from the same row, we
eliminate the silent-drift case the audit flagged. env
SIGLIP_MODEL_VERSION still drives which model embedder.py
loads at runtime; the version stamp is purely the operator-
controlled identifier."""
return (
await self.session.execute(
select(MLSettings.embedder_model_version).where(MLSettings.id == 1)
)
).scalar_one()
async def recompute_for_tag(self, tag_id: int) -> bool: async def recompute_for_tag(self, tag_id: int) -> bool:
"""Recompute one tag's centroid. Returns True if a centroid was """Recompute one tag's centroid. Returns True if a centroid was
written, False if skipped (ineligible kind or too few members).""" written, False if skipped (ineligible kind or too few members)."""
@@ -69,19 +83,20 @@ class CentroidService:
return False return False
centroid = np.mean(np.stack(embeddings), axis=0).astype(np.float32) centroid = np.mean(np.stack(embeddings), axis=0).astype(np.float32)
model_version = await self._model_version()
stmt = insert(TagReferenceEmbedding).values( stmt = insert(TagReferenceEmbedding).values(
tag_id=tag_id, tag_id=tag_id,
embedding=centroid.tolist(), embedding=centroid.tolist(),
reference_count=len(embeddings), reference_count=len(embeddings),
model_version=SIGLIP_VERSION, model_version=model_version,
) )
stmt = stmt.on_conflict_do_update( stmt = stmt.on_conflict_do_update(
index_elements=["tag_id"], index_elements=["tag_id"],
set_={ set_={
"embedding": centroid.tolist(), "embedding": centroid.tolist(),
"reference_count": len(embeddings), "reference_count": len(embeddings),
"model_version": SIGLIP_VERSION, "model_version": model_version,
"updated_at": func.now(), "updated_at": func.now(),
}, },
) )
@@ -92,6 +107,7 @@ class CentroidService:
"""Tag ids whose centroid is stale: member count != reference_count, """Tag ids whose centroid is stale: member count != reference_count,
OR no centroid row, OR centroid built on a different SigLIP version. OR no centroid row, OR centroid built on a different SigLIP version.
Only considers eligible-kind tags with embeddings present.""" Only considers eligible-kind tags with embeddings present."""
current_model_version = await self._model_version()
member_counts = ( member_counts = (
select( select(
image_tag.c.tag_id.label("tag_id"), image_tag.c.tag_id.label("tag_id"),
@@ -116,7 +132,7 @@ class CentroidService:
TagReferenceEmbedding.reference_count TagReferenceEmbedding.reference_count
!= member_counts.c.members != member_counts.c.members
) )
| (TagReferenceEmbedding.model_version != SIGLIP_VERSION) | (TagReferenceEmbedding.model_version != current_model_version)
) )
) )
return list((await self.session.execute(stmt)).scalars().all()) return list((await self.session.execute(stmt)).scalars().all())
+27 -9
View File
@@ -4,7 +4,7 @@ threshold-filtered, category-grouped, ranked suggestions for one image.
from dataclasses import dataclass, field from dataclasses import dataclass, field
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ...models import ( from ...models import (
@@ -16,6 +16,7 @@ from ...models import (
from ...models.tag import image_tag from ...models.tag import image_tag
from .aliases import AliasService from .aliases import AliasService
from .centroids import CentroidService from .centroids import CentroidService
from .tag_name import normalize as normalize_tag_name
from .tagger import SURFACED_CATEGORIES from .tagger import SURFACED_CATEGORIES
@@ -84,7 +85,12 @@ class SuggestionService:
) )
# --- Camie predictions --- # --- Camie predictions ---
candidates: list[tuple[str, str, float]] = [] # candidates carry (raw_name, display_name, category, confidence).
# raw_name = the booru-formatted vocab key, kept for alias_map
# lookup since alias rows are hand-curated against raw keys.
# display_name = normalize_tag_name(raw_name) — what the operator
# sees AND what gets written to tag.name on Accept.
candidates: list[tuple[str, str, str, float]] = []
for name, p in predictions.items(): for name, p in predictions.items():
category = p.get("category", "general") category = p.get("category", "general")
if category not in SURFACED_CATEGORIES: if category not in SURFACED_CATEGORIES:
@@ -92,10 +98,14 @@ class SuggestionService:
conf = float(p.get("confidence", 0.0)) conf = float(p.get("confidence", 0.0))
if conf < self._threshold_for(settings, category): if conf < self._threshold_for(settings, category):
continue continue
candidates.append((name, category, conf)) display = normalize_tag_name(name)
if display is None:
# emoticon / pure-punctuation vocab entry — drop entirely
continue
candidates.append((name, display, category, conf))
alias_map = await self.aliases.resolve_many( alias_map = await self.aliases.resolve_many(
[(n, c) for n, c, _ in candidates] [(raw, c) for raw, _disp, c, _conf in candidates]
) )
merged: dict[object, Suggestion] = {} merged: dict[object, Suggestion] = {}
@@ -116,8 +126,8 @@ class SuggestionService:
creates_new_tag=existing.creates_new_tag, creates_new_tag=existing.creates_new_tag,
) )
for name, category, conf in candidates: for raw, display, category, conf in candidates:
canonical = alias_map.get((name, category)) canonical = alias_map.get((raw, category))
if canonical is not None: if canonical is not None:
if canonical.id in applied or canonical.id in rejected: if canonical.id in applied or canonical.id in rejected:
continue continue
@@ -133,9 +143,17 @@ class SuggestionService:
), ),
) )
else: else:
# Case-insensitive match on BOTH the raw camie key AND
# the normalized form — covers legacy underscore-named
# Tag rows accepted before normalization shipped, AND
# any tag the operator created with the human form.
existing_tag = ( existing_tag = (
await self.session.execute( await self.session.execute(
select(Tag).where(Tag.name == name) select(Tag).where(
func.lower(Tag.name).in_(
[raw.lower(), display.lower()]
)
)
) )
).scalars().first() ).scalars().first()
if existing_tag is not None: if existing_tag is not None:
@@ -157,10 +175,10 @@ class SuggestionService:
) )
else: else:
_merge( _merge(
f"raw:{name}:{category}", f"raw:{display}:{category}",
Suggestion( Suggestion(
canonical_tag_id=None, canonical_tag_id=None,
display_name=name, display_name=display,
category=category, category=category,
score=conf, score=conf,
source="tagger", source="tagger",
+62
View File
@@ -0,0 +1,62 @@
"""Camie vocabulary -> human-readable tag-name normalization.
Camie v2's ~57k tag vocabulary is booru-derived and arrives as raw
strings like `uchiha_sasuke_(naruto)`, `#unicus_(idolmaster)`,
`1000-nen_ikiteru_(vocaloid)`, or `:/`. We want the operator to see
"Uchiha Sasuke", "Unicus", "1000-Nen Ikiteru", or to never see the
emoticon at all — and we want the same clean string to be what lands
in `tag.name` when the suggestion is accepted, so Accept matches the
existing-tag convention (`tag_service.find_or_create`).
Rules (operator-approved 2026-06-03):
1. Strip leading junk chars (#, ., +, ;, ~, _, whitespace)
2. Drop trailing `_(disambiguator)` block(s), iteratively
3. Strip wrapping single/double quotes (after disambig removal so
`"foo_em_up"_(series)` -> `"foo_em_up"` -> `foo_em_up`)
4. Replace remaining `_` with space; collapse runs of whitespace
5. Add a space after any `:` (namespace:tag -> namespace: tag)
6. Preserve hyphens (booru hyphens often carry meaning)
7. Title-case each space-separated word (first character only —
apostrophes, digits, hyphens stay)
8. If no letters AND no digits remain, return None (drops emoticons
like `:/` or `^_^`; preserves bare digit tags like `2005`)
9. No surname/givenname swap — no reliable signal in the vocab
"""
import re
_LEADING_JUNK = re.compile(r"^[#.+;~_\s]+")
_TRAILING_DISAMBIG = re.compile(r"_\([^)]*\)\s*$")
_MULTISPACE = re.compile(r"\s+")
_COLON_NOSPACE = re.compile(r":(?=\S)")
_HAS_ALPHANUMERIC = re.compile(r"[A-Za-z0-9]")
def _strip_wrapping_quotes(s: str) -> str:
if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"):
return s[1:-1]
return s
def _title_word(w: str) -> str:
return w[:1].upper() + w[1:] if w else w
def normalize(raw: str) -> str | None:
"""Return the human-readable form of a raw Camie tag, or None if the
string is junk (emoticon, empty after stripping)."""
if not raw:
return None
s = _LEADING_JUNK.sub("", raw)
while True:
new = _TRAILING_DISAMBIG.sub("", s)
if new == s:
break
s = new
s = _strip_wrapping_quotes(s)
s = s.replace("_", " ")
s = _COLON_NOSPACE.sub(": ", s)
s = _MULTISPACE.sub(" ", s).strip()
if not s or not _HAS_ALPHANUMERIC.search(s):
return None
return " ".join(_title_word(w) for w in s.split(" "))
+18 -2
View File
@@ -194,9 +194,20 @@ browser.runtime.onMessage.addListener(async (msg) => {
if (platform.authType === 'cookies') { if (platform.authType === 'cookies') {
const cookies = await extractCookiesForPlatform(key); const cookies = await extractCookiesForPlatform(key);
if (cookies.length === 0) return { error: 'No cookies found — log in first.' }; if (cookies.length === 0) return { error: 'No cookies found — log in first.' };
// Verify the captured cookies are actually live BEFORE
// uploading. Skips upload on confirmed-stale sessions so we
// don't overwrite FC-side credentials with garbage. Platforms
// without a verify config (verify.ok === null) fall through
// to upload as before.
const v = await verifyCookiesForPlatform(key);
if (v.ok === false) {
return {
error: `Captured ${cookies.length} ${platform.name} cookies but they don't appear authenticated (${v.reason}). Log in again in this browser, then retry.`,
};
}
const data = toNetscapeFormat(cookies); const data = toNetscapeFormat(cookies);
await api.uploadCredentials(key, 'cookies', data); await api.uploadCredentials(key, 'cookies', data);
return { success: true, cookieCount: cookies.length }; return { success: true, cookieCount: cookies.length, verified: v.ok === true };
} }
if (key === 'discord') { if (key === 'discord') {
if (!discordToken) return { error: 'Open discord.com to capture a token first.' }; if (!discordToken) return { error: 'Open discord.com to capture a token first.' };
@@ -229,8 +240,13 @@ browser.runtime.onMessage.addListener(async (msg) => {
results[key] = { skipped: true, reason: 'no cookies' }; results[key] = { skipped: true, reason: 'no cookies' };
continue; continue;
} }
const v = await verifyCookiesForPlatform(key);
if (v.ok === false) {
results[key] = { error: `verify failed: ${v.reason}` };
continue;
}
await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies)); await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies));
results[key] = { success: true, cookieCount: cookies.length }; results[key] = { success: true, cookieCount: cookies.length, verified: v.ok === true };
} catch (e) { } catch (e) {
results[key] = { error: e.message }; results[key] = { error: e.message };
} }
+35
View File
@@ -76,3 +76,38 @@ async function getCookieCount(platformKey) {
return 0; return 0;
} }
} }
/**
* Verify cookies are live by hitting an authenticated endpoint with the
* browser's current cookie jar. Returns:
* { ok: true, status } — verified
* { ok: false, status, reason } — endpoint said we're not logged in
* { ok: null, reason } — no verify config for this platform; caller
* should treat as "verify not available,
* proceed with upload"
*
* Implementation note: extensions with `host_permissions` for the target
* domain get the user's cookies auto-attached to fetch() — same set
* gallery-dl will later use on the backend.
*/
async function verifyCookiesForPlatform(platformKey) {
const platform = PLATFORMS[platformKey];
if (!platform) return { ok: false, reason: `Unknown platform: ${platformKey}` };
if (!platform.verify) return { ok: null, reason: 'verify-not-configured' };
const { url, method, okStatuses } = platform.verify;
let resp;
try {
resp = await fetch(url, { method, credentials: 'include', cache: 'no-store' });
} catch (e) {
return { ok: false, reason: `Verify request failed: ${e.message}` };
}
if (okStatuses.includes(resp.status)) {
return { ok: true, status: resp.status };
}
return {
ok: false,
status: resp.status,
reason: `${url} returned HTTP ${resp.status} — session looks stale or logged out`,
};
}
+21
View File
@@ -13,6 +13,13 @@ const PLATFORMS = {
authType: 'cookies', authType: 'cookies',
color: '#FF424D', color: '#FF424D',
urlPattern: /^https?:\/\/(www\.)?patreon\.com/, urlPattern: /^https?:\/\/(www\.)?patreon\.com/,
// Patreon's `/api/current_user` returns 200 + the logged-in user
// when authenticated, 401 otherwise. Cheapest definitive check.
verify: {
url: 'https://www.patreon.com/api/current_user',
method: 'GET',
okStatuses: [200],
},
}, },
subscribestar: { subscribestar: {
name: 'SubscribeStar', name: 'SubscribeStar',
@@ -26,6 +33,9 @@ const PLATFORMS = {
authType: 'cookies', authType: 'cookies',
color: '#FFD700', color: '#FFD700',
urlPattern: /^https?:\/\/(www\.)?subscribestar\.(com|adult)/, urlPattern: /^https?:\/\/(www\.)?subscribestar\.(com|adult)/,
// No known stable auth-required endpoint that returns a definitive
// status code; skipping verify so we don't false-positive-fail
// good cookies. Operator can add later if a clean endpoint surfaces.
}, },
hentaifoundry: { hentaifoundry: {
name: 'Hentai Foundry', name: 'Hentai Foundry',
@@ -33,6 +43,14 @@ const PLATFORMS = {
authType: 'cookies', authType: 'cookies',
color: '#9C27B0', color: '#9C27B0',
urlPattern: /^https?:\/\/(www\.)?hentai-foundry\.com/, urlPattern: /^https?:\/\/(www\.)?hentai-foundry\.com/,
// Mirror gallery-dl's _init_site_filters: HEAD on `?enterAgree=1`.
// Logged in → 200, logged out → 401. Catches the exact failure mode
// the backend extractor would hit later.
verify: {
url: 'https://www.hentai-foundry.com/?enterAgree=1',
method: 'HEAD',
okStatuses: [200],
},
}, },
discord: { discord: {
name: 'Discord', name: 'Discord',
@@ -56,6 +74,9 @@ const PLATFORMS = {
authType: 'cookies', authType: 'cookies',
color: '#05CC47', color: '#05CC47',
urlPattern: /^https?:\/\/(www\.)?deviantart\.com/, urlPattern: /^https?:\/\/(www\.)?deviantart\.com/,
// DA's logged-in-only endpoints sit behind their internal _napi
// namespace which shifts; skipping verify until a stable check
// surfaces. Same posture as SubscribeStar.
}, },
}; };
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "FabledCurator", "name": "FabledCurator",
"version": "1.0.6", "version": "1.0.7",
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.", "description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
"browser_specific_settings": { "browser_specific_settings": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "fabledcurator-extension", "name": "fabledcurator-extension",
"version": "1.0.6", "version": "1.0.7",
"private": true, "private": true,
"description": "Firefox extension for FabledCurator", "description": "Firefox extension for FabledCurator",
"scripts": { "scripts": {
+2 -1
View File
@@ -132,8 +132,9 @@ async function exportPlatformCookies(key, card) {
if (r.error) showError(r.error); if (r.error) showError(r.error);
else { else {
const n = r.cookieCount ?? null; const n = r.cookieCount ?? null;
const verifiedSuffix = r.verified ? ' (verified ✓)' : '';
const msg = n !== null const msg = n !== null
? `${PLATFORMS[key].name}: ${n} cookies exported` ? `${PLATFORMS[key].name}: ${n} cookies exported${verifiedSuffix}`
: `${PLATFORMS[key].name}: token exported`; : `${PLATFORMS[key].name}: token exported`;
showSuccess(msg); showSuccess(msg);
await loadPlatformStatus(); await loadPlatformStatus();
+14 -1
View File
@@ -9,7 +9,9 @@
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import AppShell from './components/AppShell.vue' import AppShell from './components/AppShell.vue'
import AppSnackbar from './components/AppSnackbar.vue' import AppSnackbar from './components/AppSnackbar.vue'
import ImageViewer from './components/modal/ImageViewer.vue' import ImageViewer from './components/modal/ImageViewer.vue'
@@ -17,9 +19,20 @@ import { useModalStore } from './stores/modal.js'
const modal = useModalStore() const modal = useModalStore()
const snackbar = ref(null) const snackbar = ref(null)
const route = useRoute()
onMounted(() => { onMounted(() => {
// Expose snackbar via a simple global so stores can call it without props. // Expose snackbar via a simple global so stores can call it without props.
window.__fcToast = (opts) => snackbar.value?.open(opts) window.__fcToast = (opts) => snackbar.value?.open(opts)
}) })
// Audit 2026-06-02: the modal is an overlay, not a page. When the
// route changes (RouterLink inside the modal, history back/forward,
// programmatic push from any view), close the modal so it doesn't
// hover over a different route. Watching route.name (not the path)
// keeps within-route nav like /artist/foo → /artist/bar from
// dismissing the modal mid-browse.
watch(() => route.name, () => {
if (modal.isOpen) modal.close()
})
</script> </script>
@@ -11,9 +11,6 @@
</template> </template>
<script setup> <script setup>
import { onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useArtistStore } from '../../stores/artist.js' import { useArtistStore } from '../../stores/artist.js'
import { useModalStore } from '../../stores/modal.js' import { useModalStore } from '../../stores/modal.js'
import MasonryGrid from '../discovery/MasonryGrid.vue' import MasonryGrid from '../discovery/MasonryGrid.vue'
@@ -24,22 +21,9 @@ const props = defineProps({
const store = useArtistStore() const store = useArtistStore()
const modal = useModalStore() const modal = useModalStore()
const route = useRoute()
const router = useRouter()
onMounted(() => {
const initial = parseInt(route.query.image, 10)
if (!isNaN(initial)) modal.open(initial)
})
watch(() => route.query.image, (q) => {
const id = parseInt(q, 10)
if (!isNaN(id) && id !== modal.currentImageId) modal.open(id)
else if (isNaN(id) && modal.currentImageId !== null) modal.close()
})
function openImage (id) { function openImage (id) {
router.push({ query: { ...route.query, image: id } }) modal.open(id)
} }
</script> </script>
@@ -19,25 +19,34 @@
> >
Accept Accept
</v-btn> </v-btn>
<v-menu> <!-- Operator-flagged 2026-06-02: the kebab menu wasn't opening.
<template #activator="{ props }"> Wrapping in a <span @click.stop> matches the TagPanel chip
<v-btn fix — even though there's no parent click capture here today,
class="fc-suggestion__menu" the wrap is harmless and keeps both kebabs on the same
icon="mdi-dots-vertical" size="small" pattern. Click bubbles from the v-btn opens menu via
variant="outlined" density="compact" activator props bubble continues to span stopPropagation
:aria-label="`More actions for ${suggestion.display_name}`" halts it. -->
v-bind="props" <span class="fc-suggestion__menu-wrap" @click.stop>
/> <v-menu>
</template> <template #activator="{ props }">
<v-list density="compact"> <v-btn
<v-list-item @click="$emit('alias', suggestion)"> class="fc-suggestion__menu"
<v-list-item-title>Treat as alias for</v-list-item-title> icon="mdi-dots-vertical" size="small"
</v-list-item> variant="outlined" density="compact"
<v-list-item @click="$emit('dismiss', suggestion)"> :aria-label="`More actions for ${suggestion.display_name}`"
<v-list-item-title>Dismiss for this image</v-list-item-title> v-bind="props"
</v-list-item> />
</v-list> </template>
</v-menu> <v-list density="compact">
<v-list-item @click="$emit('alias', suggestion)">
<v-list-item-title>Treat as alias for</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('dismiss', suggestion)">
<v-list-item-title>Dismiss for this image</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</span>
</div> </div>
</template> </template>
@@ -90,6 +99,11 @@ const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
.fc-suggestion__accept :deep(.v-btn__content) { .fc-suggestion__accept :deep(.v-btn__content) {
font-size: 12px; letter-spacing: 0.02em; font-size: 12px; letter-spacing: 0.02em;
} }
.fc-suggestion__menu-wrap {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
}
.fc-suggestion__menu { .fc-suggestion__menu {
flex: 0 0 auto; flex: 0 0 auto;
} }
+22 -13
View File
@@ -10,19 +10,27 @@
> >
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon> <v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
{{ tag.name }}<span v-if="tag.fandom_id"></span> {{ tag.name }}<span v-if="tag.fandom_id"></span>
<v-menu> <!-- Operator-flagged 2026-06-02: the previous activator had
<template #activator="{ props: mp }"> `@click.stop` directly on the v-icon, which silently
<v-icon overrode Vuetify's onClick from `v-bind="mp"` — the menu
v-bind="mp" size="x-small" class="ml-1" never opened. Now the v-icon receives the activator
icon="mdi-dots-vertical" @click.stop onClick cleanly, and the wrapping span absorbs the
/> bubbled click so the chip's close button isn't tripped. -->
</template> <span class="kebab-wrap" @click.stop>
<v-list density="compact"> <v-menu>
<v-list-item @click="openRename(tag)"> <template #activator="{ props: mp }">
<v-list-item-title>Rename</v-list-item-title> <v-icon
</v-list-item> v-bind="mp" size="x-small" class="ml-1"
</v-list> icon="mdi-dots-vertical"
</v-menu> />
</template>
<v-list density="compact">
<v-list-item @click="openRename(tag)">
<v-list-item-title>Rename…</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</span>
</v-chip> </v-chip>
<span v-if="!modal.current?.tags?.length" class="text-caption">No tags yet.</span> <span v-if="!modal.current?.tags?.length" class="text-caption">No tags yet.</span>
</div> </div>
@@ -113,4 +121,5 @@ async function onRenamed() {
margin-bottom: 12px; margin-bottom: 12px;
} }
.fc-tag-panel__chips { display: flex; flex-wrap: wrap; gap: 6px; } .fc-tag-panel__chips { display: flex; flex-wrap: wrap; gap: 6px; }
.kebab-wrap { display: inline-flex; align-items: center; }
</style> </style>
+2 -17
View File
@@ -24,7 +24,7 @@
<script setup> <script setup>
import { onMounted, watch } from 'vue' import { onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute } from 'vue-router'
import { useGalleryStore } from '../stores/gallery.js' import { useGalleryStore } from '../stores/gallery.js'
import { useModalStore } from '../stores/modal.js' import { useModalStore } from '../stores/modal.js'
import GalleryGrid from '../components/gallery/GalleryGrid.vue' import GalleryGrid from '../components/gallery/GalleryGrid.vue'
@@ -37,7 +37,6 @@ import { useGallerySelectionStore } from '../stores/gallerySelection.js'
const store = useGalleryStore() const store = useGalleryStore()
const modal = useModalStore() const modal = useModalStore()
const sel = useGallerySelectionStore() const sel = useGallerySelectionStore()
const router = useRouter()
const route = useRoute() const route = useRoute()
onMounted(async () => { onMounted(async () => {
@@ -47,9 +46,6 @@ onMounted(async () => {
else if (!isNaN(tagId)) store.setTagFilter(tagId) else if (!isNaN(tagId)) store.setTagFilter(tagId)
await store.loadInitial() await store.loadInitial()
await store.loadTimeline() await store.loadTimeline()
// Open modal if URL has ?image=N
const initial = parseInt(route.query.image, 10)
if (!isNaN(initial)) modal.open(initial)
}) })
watch(() => route.query.tag_id, (q) => { watch(() => route.query.tag_id, (q) => {
@@ -64,19 +60,8 @@ watch(() => route.query.post_id, (q) => {
store.setPostFilter(isNaN(postId) ? null : postId) store.setPostFilter(isNaN(postId) ? null : postId)
}) })
watch(() => route.query.image, (q) => {
const id = parseInt(q, 10)
if (!isNaN(id) && id !== modal.currentImageId) modal.open(id)
else if (isNaN(id) && modal.currentImageId !== null) modal.close()
})
function openImage(id) { function openImage(id) {
router.push({ query: { ...route.query, image: id } }) modal.open(id)
}
function closeImage() {
const q = { ...route.query }
delete q.image
router.push({ query: q })
} }
</script> </script>
+4 -3
View File
@@ -19,9 +19,10 @@ async def test_get_and_patch_settings(client):
resp = await client.get("/api/ml/settings") resp = await client.get("/api/ml/settings")
assert resp.status_code == 200 assert resp.status_code == 200
body = await resp.get_json() body = await resp.get_json()
# Default lowered 0.95 → 0.50 on 2026-06-01 (alembic 0029) — 0.95 # Default raised 0.50 → 0.70 on 2026-06-02 (alembic 0033) — 0.50
# hid most general suggestions in the view modal. # was too noisy in practice. The 0.70 default keeps the rail
assert body["suggestion_threshold_general"] == pytest.approx(0.50) # signal-rich without hiding everything like the original 0.95.
assert body["suggestion_threshold_general"] == pytest.approx(0.70)
# Retired threshold columns must not appear in the payload. # Retired threshold columns must not appear in the payload.
assert "suggestion_threshold_artist" not in body assert "suggestion_threshold_artist" not in body
assert "suggestion_threshold_copyright" not in body assert "suggestion_threshold_copyright" not in body
+6 -3
View File
@@ -39,8 +39,9 @@ async def test_threshold_filters_low_confidence_general(db):
await db.flush() await db.flush()
sl = await SuggestionService(db).for_image(img.id) sl = await SuggestionService(db).for_image(img.id)
names = [s.display_name for s in sl.by_category.get("general", [])] names = [s.display_name for s in sl.by_category.get("general", [])]
assert "sword" in names # display_name is normalized (tag_name.normalize) before surfacing.
assert "lowconf" not in names assert "Sword" in names
assert "Lowconf" not in names
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -84,7 +85,9 @@ async def test_raw_tag_creates_new(db):
await db.flush() await db.flush()
sl = await SuggestionService(db).for_image(img.id) sl = await SuggestionService(db).for_image(img.id)
chars = sl.by_category["character"] chars = sl.by_category["character"]
assert chars[0].display_name == "brand_new_tag" # display_name is the normalized Camie name (underscores -> spaces,
# title-cased), not the raw vocab key.
assert chars[0].display_name == "Brand New Tag"
assert chars[0].creates_new_tag is True assert chars[0].creates_new_tag is True
assert chars[0].canonical_tag_id is None assert chars[0].canonical_tag_id is None
+53
View File
@@ -0,0 +1,53 @@
import pytest
from backend.app.services.ml.tag_name import normalize
@pytest.mark.parametrize(
"raw, expected",
[
# Rule 4: underscores -> spaces; rule 7: title case
("light_purple_hair", "Light Purple Hair"),
("no_pants", "No Pants"),
("year_2005", "Year 2005"),
# Single-word still title-cased
("sword", "Sword"),
# Rule 3: drop trailing _(disambiguator)
("uchiha_sasuke_(naruto)", "Uchiha Sasuke"),
("apple_(fruit)", "Apple"),
("kirby_(series)", "Kirby"),
# Repeated trailing disambig blocks
("foo_(bar)_(baz)", "Foo"),
# Rule 1: leading junk chars
("#unicus_(idolmaster)", "Unicus"),
(".52_gal_(splatoon)", "52 Gal"),
("+_+_smile_(emote)", "Smile"),
# Rule 5: space after colon
("nier:automata", "Nier: Automata"),
# Already-spaced colon left alone
("nier: automata", "Nier: Automata"),
# Rule 6: hyphens preserved
("1000-nen_ikiteru_(vocaloid)", "1000-nen Ikiteru"),
("well-known_face", "Well-known Face"),
# Rule 2: wrapping quotes
('"pile_em_up"_(genshin_impact)', "Pile Em Up"),
("'foo_bar'", "Foo Bar"),
# Rule 8: emoticons -> None
(":/", None),
(";)", None),
("+_+", None),
("^_^", None),
# Empty / whitespace-only
("", None),
(" ", None),
("___", None),
# Apostrophe inside word — preserved, not title-cased
("it's_okay", "It's Okay"),
# Digit-only still surfaces (year tags)
("2005", "2005"),
# Multi-space collapse
("foo___bar", "Foo Bar"),
],
)
def test_normalize(raw, expected):
assert normalize(raw) == expected