Compare commits
13
Commits
6ef0fed41f
...
ext-1.0.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0533807669 | ||
|
|
d3245f0c22 | ||
|
|
279dff3fb6 | ||
|
|
e450145304 | ||
|
|
a6e8d4b52e | ||
|
|
37e66cddc4 | ||
|
|
f1860866de | ||
|
|
9cf6b2d363 | ||
|
|
b181d779fe | ||
|
|
0fbb19dc24 | ||
|
|
8326e5447a | ||
|
|
1fd594baaf | ||
|
|
ecac6c4bda |
@@ -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"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(" "))
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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,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": {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user