What you pay for, what Discord drops, and pixiv switched off #251
@@ -60,7 +60,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import Select, func, select, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
@@ -244,7 +244,7 @@ async def _synthesize(
|
||||
# without this "why did it group these" is unanswerable later.
|
||||
"max_distance": max_distance,
|
||||
"window_minutes": window_minutes,
|
||||
"grouped_at": datetime.now(timezone.utc).isoformat(),
|
||||
"grouped_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
)
|
||||
session.add(post)
|
||||
@@ -291,7 +291,7 @@ async def group_source(
|
||||
) -> int:
|
||||
"""Group one Discord source's ungrouped messages. Returns posts created."""
|
||||
window = timedelta(minutes=window_minutes)
|
||||
now = now or datetime.now(timezone.utc)
|
||||
now = now or datetime.now(UTC)
|
||||
# Leave the most recent window alone: a drop that is still arriving would
|
||||
# otherwise be cut in half by whichever sweep happened to land mid-drop,
|
||||
# and the second half would become a separate post claiming to be its own
|
||||
|
||||
@@ -6,6 +6,19 @@
|
||||
<v-chip size="x-small" variant="tonal">
|
||||
{{ post.source?.platform ?? 'filesystem import' }}
|
||||
</v-chip>
|
||||
<!-- The honesty marker (#388 E2). Discord doesn't publish posts, so FC
|
||||
groups a creator's drop and writes the post itself. This chip is the
|
||||
only thing standing between "FC assembled this" and the card reading
|
||||
as something the artist authored — it must never be conditional on
|
||||
anything but the flag, and it says what it was built FROM so the
|
||||
claim is checkable rather than just disclosed. -->
|
||||
<v-chip
|
||||
v-if="synthesized" size="x-small" variant="outlined"
|
||||
class="fc-post-card__synthetic" :title="synthesisTitle"
|
||||
>
|
||||
<v-icon icon="mdi-auto-fix" size="x-small" start />
|
||||
grouped by FabledCurator
|
||||
</v-chip>
|
||||
<RouterLink
|
||||
:to="{ name: 'artist', params: { slug: post.artist.slug } }"
|
||||
class="fc-post-card__artist"
|
||||
@@ -59,6 +72,13 @@
|
||||
|
||||
<div class="fc-post-card__text">
|
||||
<h3 v-if="displayTitle" class="fc-post-card__title">{{ displayTitle }}</h3>
|
||||
<!-- A synthetic post has no title on purpose (inventing one is the one
|
||||
place this feature could put words in a creator's mouth), so name
|
||||
it for what it is rather than leaking `fc-drop:<message id>`. -->
|
||||
<h3
|
||||
v-else-if="synthesized"
|
||||
class="fc-post-card__title fc-post-card__title--missing"
|
||||
>{{ synthesisTitle }}</h3>
|
||||
<h3 v-else class="fc-post-card__title fc-post-card__title--missing">
|
||||
Post {{ post.external_post_id }}
|
||||
</h3>
|
||||
@@ -163,6 +183,18 @@ const images = computed(() => props.post.thumbnails || [])
|
||||
const totalImages = computed(() => images.value.length + (props.post.thumbnails_more || 0))
|
||||
const plainTitle = computed(() => toPlainText(props.post.post_title))
|
||||
|
||||
// #388 E2. Non-null `synthesized_by` means FC authored this row by grouping a
|
||||
// creator's drop; `synthesis` carries what it was built from. Read defensively
|
||||
// — a post fetched before the field existed, or any surface that composes a
|
||||
// post dict by hand, must degrade to "not synthetic" rather than throw.
|
||||
const synthesized = computed(() => Boolean(props.post.synthesized_by))
|
||||
const messageCount = computed(() => props.post.synthesis?.message_count ?? 0)
|
||||
const synthesisTitle = computed(() => {
|
||||
const n = messageCount.value
|
||||
if (!n) return 'Grouped from Discord'
|
||||
return `Grouped from ${n} Discord message${n === 1 ? '' : 's'}`
|
||||
})
|
||||
|
||||
const hero = computed(() => images.value[0])
|
||||
// The thumbnail strip spans the hero's full width (CSS grid, equal columns),
|
||||
// rather than a fixed 3-cell cap. Show up to RAIL_MAX cells; when there are
|
||||
@@ -337,6 +369,12 @@ function formatBytes (n) {
|
||||
font-weight: 600;
|
||||
}
|
||||
.fc-post-card__artist:hover { color: rgb(var(--v-theme-accent)); }
|
||||
|
||||
/* Quiet, not decorative: the marker has to be legible on every card without
|
||||
turning a synthetic post into the loudest thing in the feed. */
|
||||
.fc-post-card__synthetic {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-post-card__date,
|
||||
.fc-post-card__meta { white-space: nowrap; }
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<MaintenanceTile
|
||||
icon="mdi-image-multiple"
|
||||
title="Discord drop grouping"
|
||||
blurb="Group a creator's variant drop into one post FC writes itself."
|
||||
>
|
||||
<div v-if="store.settings">
|
||||
<div class="text-caption fc-muted mb-3">
|
||||
Discord is a delivery channel, not a publisher — one message isn't one
|
||||
post. When this is on, FC groups a creator's variant drop (same piece,
|
||||
different hair colour or outfit) into a single post it authors, with the
|
||||
messages' text as the body. Grouped posts are always marked as FC's own,
|
||||
and deleting one returns its messages to the feed unchanged.
|
||||
</div>
|
||||
|
||||
<v-switch
|
||||
v-model="local.discord_grouping_enabled" color="accent" hide-details
|
||||
density="compact" label="Group Discord drops into posts"
|
||||
@update:model-value="onSave"
|
||||
/>
|
||||
|
||||
<v-row class="mt-2">
|
||||
<v-col cols="12" sm="6">
|
||||
<SettingNumberField
|
||||
v-model="local.discord_group_max_distance"
|
||||
label="Max visual distance" :min="0" :max="1" :step="0.01"
|
||||
density="comfortable" max-width="none"
|
||||
:disabled="!local.discord_grouping_enabled" @change="onSave"
|
||||
/>
|
||||
<div class="text-caption fc-muted mt-1">
|
||||
0 is identical, 1 is unrelated. Lower groups less. Raising this is
|
||||
the fix when a drop comes out scattered across several posts —
|
||||
but raise it slowly: too high merges pieces that only look alike.
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<SettingNumberField
|
||||
v-model="local.discord_group_window_minutes"
|
||||
label="Drop window (minutes)" :min="1" :step="5"
|
||||
density="comfortable" max-width="none"
|
||||
:disabled="!local.discord_grouping_enabled" @change="onSave"
|
||||
/>
|
||||
<div class="text-caption fc-muted mt-1">
|
||||
The quiet gap that ends a drop, measured between consecutive
|
||||
messages — so variants trickling out over an evening stay one post.
|
||||
This is the setting doing most of the work: without it, everything
|
||||
an artist ever drew of one character would collapse into one post.
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
<div v-else><v-skeleton-loader type="paragraph" /></div>
|
||||
</MaintenanceTile>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// #388 E2. Every value here is operator-tunable because the quality bar is a
|
||||
// judgement no test can settle: grouping too greedy merges distinct pieces,
|
||||
// too shy leaves a drop scattered. The two failure modes are not symmetric —
|
||||
// scattered is visible and fixable, a wrong merge is a post asserting that
|
||||
// unrelated art belongs together — so the shipped default sits on the tight
|
||||
// side and this card is how it gets loosened.
|
||||
import { reactive, watch } from 'vue'
|
||||
|
||||
import { useSettingSave } from '../../composables/useSettingSave.js'
|
||||
import { useMLStore } from '../../stores/ml.js'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
import SettingNumberField from '../common/SettingNumberField.vue'
|
||||
|
||||
const store = useMLStore()
|
||||
const { save } = useSettingSave(store.patchSettings)
|
||||
const local = reactive({})
|
||||
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
||||
|
||||
function onSave() {
|
||||
save({
|
||||
discord_grouping_enabled: Boolean(local.discord_grouping_enabled),
|
||||
discord_group_max_distance: Number(local.discord_group_max_distance),
|
||||
discord_group_window_minutes: Number(local.discord_group_window_minutes),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -14,6 +14,7 @@
|
||||
<div class="fc-tile-stack">
|
||||
<ImportFiltersForm />
|
||||
<TranslationCard />
|
||||
<DiscordGroupingCard />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -80,6 +81,7 @@ import DbMaintenanceCard from './DbMaintenanceCard.vue'
|
||||
import VideoEmbeddingCard from './VideoEmbeddingCard.vue'
|
||||
import CropProposersCard from './CropProposersCard.vue'
|
||||
import HeadsCard from './HeadsCard.vue'
|
||||
import DiscordGroupingCard from './DiscordGroupingCard.vue'
|
||||
import GpuAgentCard from './GpuAgentCard.vue'
|
||||
import AliasTable from './AliasTable.vue'
|
||||
import BackupCard from './BackupCard.vue'
|
||||
|
||||
@@ -40,6 +40,60 @@ describe('PostCard', () => {
|
||||
expect(full.text()).not.toContain('Show more')
|
||||
})
|
||||
|
||||
// #388 E2. The honesty marker is the only thing standing between "FC
|
||||
// assembled this" and the card reading as something the artist authored, so
|
||||
// these pin BOTH directions: it appears when the flag is set, and — the one
|
||||
// that actually matters — it is absent on every ordinary post.
|
||||
describe('synthetic posts', () => {
|
||||
const SYNTH = {
|
||||
...BASE,
|
||||
post_title: null,
|
||||
synthesized_by: 'discord_drop',
|
||||
synthesis: { message_count: 4, member_post_ids: [1, 2, 3, 4] },
|
||||
}
|
||||
|
||||
it('says FC grouped it, and what from', () => {
|
||||
const w = mountComponent(PostCard, { props: { post: SYNTH }, pinia: freshPinia() })
|
||||
expect(w.find('.fc-post-card__synthetic').exists()).toBe(true)
|
||||
expect(w.text()).toContain('grouped by FabledCurator')
|
||||
expect(w.text()).toContain('Grouped from 4 Discord messages')
|
||||
})
|
||||
|
||||
it('never marks an ordinary post', () => {
|
||||
const w = mountComponent(PostCard, { props: { post: BASE }, pinia: freshPinia() })
|
||||
expect(w.find('.fc-post-card__synthetic').exists()).toBe(false)
|
||||
expect(w.text()).not.toContain('grouped by FabledCurator')
|
||||
})
|
||||
|
||||
it('does not leak the internal drop key as a title', () => {
|
||||
// post_title is NULL on purpose (inventing one would put words in a
|
||||
// creator's mouth), so the untitled fallback must not print
|
||||
// `fc-drop:<message id>` the way it does for a real untitled post.
|
||||
const w = mountComponent(PostCard, {
|
||||
props: { post: { ...SYNTH, external_post_id: 'fc-drop:99887766' } },
|
||||
pinia: freshPinia(),
|
||||
})
|
||||
expect(w.text()).not.toContain('fc-drop:')
|
||||
})
|
||||
|
||||
it('degrades to unmarked when the field is absent entirely', () => {
|
||||
// A post dict composed before the field existed must read as "not
|
||||
// synthetic" rather than throw on `synthesis.message_count`.
|
||||
const { synthesized_by: _drop, synthesis: _also, ...legacy } = SYNTH
|
||||
const w = mountComponent(PostCard, { props: { post: legacy }, pinia: freshPinia() })
|
||||
expect(w.find('.fc-post-card__synthetic').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('singularises a one-message drop', () => {
|
||||
const w = mountComponent(PostCard, {
|
||||
props: { post: { ...SYNTH, synthesis: { message_count: 1 } } },
|
||||
pinia: freshPinia(),
|
||||
})
|
||||
expect(w.text()).toContain('Grouped from 1 Discord message')
|
||||
expect(w.text()).not.toContain('1 Discord messages')
|
||||
})
|
||||
})
|
||||
|
||||
const thumbs = (n) =>
|
||||
Array.from({ length: n }, (_, i) => ({ image_id: 100 + i, thumbnail_url: `/t${i}` }))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user