feat(posts): extract + record external file-host links (Phase 3)
CI / lint (push) Failing after 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 3m20s

Capture off-platform links (mega/gdrive/mediafire/dropbox/pixeldrain) embedded
in post bodies so they're never silently dropped, and surface them in the post
view. The download worker (Phase 4) walks these rows.

- link_extract.py: pure extractor — <a href> + bare URLs, unwraps Patreon
  redirect shims, PRESERVES the full url incl. #fragment (mega's key), dedups.
  Reusable by every platform (runs off Post.description).
- external_link model + migration 0049: post_id/artist_id/host/url/label/status
  /attempts/last_error/attachment_id/timing; CHECK whitelists (full enum incl.
  worker statuses up front) + (post_id,url) unique.
- importer._sync_external_links: insert-missing on both import paths
  (_apply_sidecar + upsert_post_record) so a re-import never resets a link's
  status; runs for all platforms.
- post_feed_service.get_post: returns external_links (detail-only).
- PostCard: renders the links (host chip + label + status) once expanded.
- tests: extractor (5 hosts, fragment, shim unwrap, dedup), importer (record +
  no-dup on reimport), serializer.

Refs FC #830.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 13:15:36 -04:00
parent c342c73a25
commit d96918d777
10 changed files with 525 additions and 0 deletions
@@ -87,6 +87,25 @@
@click="toggleDesc"
>{{ descExpanded ? 'Show less' : 'Show more' }}</button>
<!-- Off-platform file-host links (mega/gdrive/…) found in the body.
Shown once expanded; clickable now, auto-downloaded by the worker
slice. -->
<div v-if="descExpanded && externalLinks.length" class="fc-post-card__links">
<div class="fc-post-card__links-label">External files</div>
<a
v-for="lnk in externalLinks" :key="lnk.id"
:href="lnk.url" target="_blank" rel="noopener noreferrer"
class="fc-post-card__link"
>
<span class="fc-post-card__link-host">{{ lnk.host }}</span>
<span class="fc-post-card__link-text">{{ lnk.label || lnk.url }}</span>
<span
v-if="lnk.status && lnk.status !== 'pending'"
class="fc-post-card__link-status" :data-status="lnk.status"
>{{ lnk.status }}</span>
</a>
</div>
<div v-if="attachments.length" class="fc-post-card__atts">
<a
v-for="att in attachments" :key="att.id"
@@ -201,6 +220,8 @@ const fullDescription = computed(() => detail.value?.description_full || null)
// Backend-sanitized HTML body (detail-only). Drives the faithful render once
// expanded; falls back to plain text when detail isn't loaded.
const descHtml = computed(() => detail.value?.description_html || null)
// Off-platform file-host links (detail-only), surfaced once expanded.
const externalLinks = computed(() => detail.value?.external_links || [])
const descText = computed(() =>
descExpanded.value
? (fullDescription.value || props.post.description_plain)
@@ -469,4 +490,46 @@ function formatBytes (n) {
}
.fc-post-card__att-icon { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-post-card__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-post-card__links {
display: flex; flex-direction: column; gap: 6px;
margin-top: 12px;
}
.fc-post-card__links-label {
font-size: 0.75rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__link {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 12px;
border: 1px solid rgb(var(--v-theme-on-surface-variant));
border-radius: 8px;
color: rgb(var(--v-theme-on-surface));
text-decoration: none;
font-size: 0.85rem;
}
.fc-post-card__link:hover {
color: rgb(var(--v-theme-accent));
border-color: rgb(var(--v-theme-accent));
}
.fc-post-card__link-host {
flex: 0 0 auto;
font-weight: 700; text-transform: uppercase; font-size: 0.7rem;
letter-spacing: 0.04em;
padding: 2px 6px; border-radius: 4px;
background: rgba(var(--v-theme-accent), 0.16);
color: rgb(var(--v-theme-accent));
}
.fc-post-card__link-text {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.fc-post-card__link-status {
flex: 0 0 auto; margin-left: auto;
font-size: 0.7rem; text-transform: uppercase;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__link-status[data-status="downloaded"] {
color: rgb(var(--v-theme-accent));
}
</style>