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));
+}
diff --git a/tests/test_link_extract.py b/tests/test_link_extract.py
new file mode 100644
index 0000000..9f13c7d
--- /dev/null
+++ b/tests/test_link_extract.py
@@ -0,0 +1,76 @@
+"""Unit tests for link_extract — pure, no DB/network."""
+
+from backend.app.services.link_extract import extract_external_links, host_for
+
+
+def test_host_classification_covers_all_and_rejects_others():
+ assert host_for("https://mega.nz/file/x#k") == "mega"
+ assert host_for("https://www.dropbox.com/s/x?dl=0") == "dropbox"
+ assert host_for("https://drive.google.com/file/d/ID/view") == "gdrive"
+ assert host_for("https://pixeldrain.com/u/abc") == "pixeldrain"
+ assert host_for("https://www.mediafire.com/file/x") == "mediafire"
+ assert host_for("https://example.com/x") is None
+
+
+def test_anchor_preserves_fragment_and_label():
+ html = ('Watch: '
+ 'Mega - Streamable
')
+ links = extract_external_links(html)
+ assert len(links) == 1
+ assert links[0].host == "mega"
+ # The #fragment (mega's decryption key) MUST survive.
+ assert links[0].url == "https://mega.nz/file/Bux0FRrZ#b2Pn9841"
+ assert links[0].label == "Mega - Streamable"
+
+
+def test_extracts_all_five_hosts():
+ html = (
+ 'm'
+ '
g'
+ '
mf'
+ '
db'
+ '
px'
+ )
+ hosts = {lnk.host for lnk in extract_external_links(html)}
+ assert hosts == {"mega", "gdrive", "mediafire", "dropbox", "pixeldrain"}
+
+
+def test_bare_url_in_text():
+ html = "Full film: https://pixeldrain.com/u/ems4UomN and more prose."
+ links = extract_external_links(html)
+ assert len(links) == 1
+ assert links[0].host == "pixeldrain"
+ assert links[0].url == "https://pixeldrain.com/u/ems4UomN" # trailing '.' trimmed
+ assert links[0].label is None
+
+
+def test_unwraps_patreon_redirect():
+ html = ('
x')
+ links = extract_external_links(html)
+ assert len(links) == 1
+ assert links[0].host == "mega"
+ assert links[0].url == "https://mega.nz/file/z#key"
+
+
+def test_ignores_unsupported_and_dedups_anchor_label_wins():
+ html = (
+ '
no'
+ '
a'
+ ' bare https://mega.nz/file/dup#k'
+ )
+ links = extract_external_links(html)
+ assert len(links) == 1 # example.com dropped; mega dup collapsed
+ assert links[0].label == "a" # anchor (with label) wins over bare
+
+
+def test_amp_escaped_query_is_unescaped():
+ html = '
g'
+ links = extract_external_links(html)
+ assert links[0].url == "https://drive.google.com/uc?id=ID&export=download"
+
+
+def test_empty_inputs():
+ assert extract_external_links(None) == []
+ assert extract_external_links("") == []
+ assert extract_external_links("
no links here
") == []
diff --git a/tests/test_post_feed_service.py b/tests/test_post_feed_service.py
index 064b000..7374743 100644
--- a/tests/test_post_feed_service.py
+++ b/tests/test_post_feed_service.py
@@ -11,6 +11,7 @@ from sqlalchemy import select
from backend.app.models import (
Artist,
+ ExternalLink,
ImageProvenance,
ImageRecord,
Post,
@@ -437,6 +438,27 @@ async def test_get_post_returns_sanitized_html_body(db):
assert "