From 3ba3fccb040db2956d9472674c7ff003d6e77a86 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Wed, 29 Jul 2026 12:56:19 -0700 Subject: [PATCH] Preserve Bluesky media for offline browse (#90) --- .../tabs/browse/__tests__/shared.test.ts | 33 +++ components/PostPreview.tsx | 71 +++++- components/__tests__/PostPreview.test.tsx | 69 +++++- components/account/browse-shared.tsx | 23 +- controllers/BlueskyAccountController.ts | 6 +- .../BlueskyAccountController.test.ts | 35 ++- .../bluesky/__tests__/blob-url.test.ts | 45 ++++ .../bluesky/__tests__/post-indexer.test.ts | 38 +++- .../__tests__/post-persistence.test.ts | 108 +++++++++ controllers/bluesky/blob-url.ts | 46 ++++ controllers/bluesky/indexer.ts | 2 + controllers/bluesky/post-indexer.ts | 2 + controllers/bluesky/post-persistence.ts | 207 +++++++++++------- controllers/bluesky/types.ts | 4 + .../__tests__/bluesky-migrations.test.ts | 18 ++ database/account-db/bluesky-migrations.ts | 27 +++ testUtils/blueskyFixtures.ts | 11 + 17 files changed, 639 insertions(+), 106 deletions(-) create mode 100644 controllers/bluesky/__tests__/blob-url.test.ts create mode 100644 controllers/bluesky/__tests__/post-persistence.test.ts create mode 100644 controllers/bluesky/blob-url.ts create mode 100644 database/account-db/__tests__/bluesky-migrations.test.ts diff --git a/app/account/tabs/browse/__tests__/shared.test.ts b/app/account/tabs/browse/__tests__/shared.test.ts index f33b160..dd23001 100644 --- a/app/account/tabs/browse/__tests__/shared.test.ts +++ b/app/account/tabs/browse/__tests__/shared.test.ts @@ -396,6 +396,39 @@ describe("shared browse helpers", () => { expect(result.size).toBe(0); }); + + it("should map account-local assets for offline browse", async () => { + (mockDb.getAllAsync as jest.Mock).mockResolvedValue([ + { + postUri: "at://did/post/1", + position: 0, + mediaType: "video", + alt: "Saved video", + width: 1920, + height: 1080, + thumbUrl: "https://cdn.bsky.app/thumb.jpg", + fullsizeUrl: null, + playlistUrl: "https://video.bsky.app/playlist.m3u8", + contentCid: "bafy-video", + localPath: "file:///account/media/bafy-video", + downloadState: "complete", + lastError: null, + }, + ] satisfies MediaRow[]); + + const result = await fetchMediaForPosts(mockDb, ["at://did/post/1"]); + + expect(result.get("at://did/post/1")?.[0]).toMatchObject({ + contentCid: "bafy-video", + localUri: "file:///account/media/bafy-video", + downloadState: "complete", + }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockDb.getAllAsync).toHaveBeenCalledWith( + expect.stringContaining("JOIN media_asset"), + ["at://did/post/1"], + ); + }); }); describe("fetchExternalEmbedsForPosts", () => { diff --git a/components/PostPreview.tsx b/components/PostPreview.tsx index 0556406..5afd0be 100644 --- a/components/PostPreview.tsx +++ b/components/PostPreview.tsx @@ -446,7 +446,7 @@ export function PostPreview({ return post.media .filter((item) => item.type === "image") .map((item, index) => ({ - uri: item.fullsizeUrl ?? item.thumbUrl ?? "", + uri: item.downloadState === "complete" ? item.localUri ?? "" : "", index, })) .filter((item) => item.uri !== ""); @@ -503,11 +503,10 @@ export function PostPreview({ /> )} - {browseMode && currentVideo && currentVideo.playlistUrl && ( + {browseMode && currentVideo?.localUri && ( )} @@ -563,13 +562,51 @@ export function PostPreview({ {post.media.map((item, index) => { const key = `${item.type}-${index}`; - const thumbUri = item.thumbUrl ?? undefined; + const isLocallyAvailable = + item.downloadState === "complete" && !!item.localUri; + const thumbUri = browseMode + ? item.type === "image" && isLocallyAvailable + ? item.localUri ?? undefined + : undefined + : item.thumbUrl ?? undefined; + + if (browseMode && !isLocallyAvailable) { + return ( + + + Media unavailable — save again to retry + + {item.downloadError ? ( + + {item.downloadError} + + ) : null} + + ); + } if (item.type === "video") { - const hasPlaylistUrl = !!item.playlistUrl; + const hasPlayableVideo = browseMode + ? !!item.localUri + : !!item.playlistUrl; - // In browse mode with a playlist URL, make it tappable to play - if (browseMode && hasPlaylistUrl) { + if (browseMode && hasPlayableVideo) { return ( g.uri === (item.fullsizeUrl ?? item.thumbUrl), + (g) => g.uri === item.localUri, ); return ( @@ -833,6 +870,22 @@ const styles = StyleSheet.create({ borderRadius: 10, backgroundColor: "#0001", }, + unavailableMedia: { + width: 240, + minHeight: 100, + borderWidth: 1, + borderRadius: 10, + padding: 12, + justifyContent: "center", + gap: 6, + }, + unavailableMediaTitle: { + fontSize: 14, + fontWeight: "600", + }, + unavailableMediaError: { + fontSize: 12, + }, videoPlaceholder: { width: 120, height: 120, diff --git a/components/__tests__/PostPreview.test.tsx b/components/__tests__/PostPreview.test.tsx index d55db3b..15d3adc 100644 --- a/components/__tests__/PostPreview.test.tsx +++ b/components/__tests__/PostPreview.test.tsx @@ -9,7 +9,8 @@ import type { PostPreviewData, } from "@/controllers/bluesky/types"; import type { AccountTabPalette } from "@/types/account-tabs"; -import { render, screen } from "@testing-library/react-native"; +import { fireEvent, render, screen } from "@testing-library/react-native"; +import { useVideoPlayer } from "expo-video"; import React from "react"; import { PostPreview } from "../PostPreview"; @@ -183,6 +184,72 @@ describe("PostPreview", () => { ).toBeTruthy(); }); + it("should use saved local media while browsing", () => { + const post: PostPreviewData = { + ...basePost, + media: [ + { + type: "image", + localUri: "file:///account/media/bafy-image", + fullsizeUrl: "https://cdn.bsky.app/full.jpg", + downloadState: "complete", + }, + ], + }; + + const rendered = render( + , + ); + + expect(JSON.stringify(rendered.toJSON())).toContain( + "file:///account/media/bafy-image", + ); + expect(JSON.stringify(rendered.toJSON())).not.toContain( + "https://cdn.bsky.app/full.jpg", + ); + }); + + it("should play saved video from its local asset while offline", () => { + const post: PostPreviewData = { + ...basePost, + media: [ + { + type: "video", + localUri: "file:///account/media/bafy-video", + playlistUrl: "https://video.bsky.app/playlist.m3u8", + downloadState: "complete", + }, + ], + }; + + render(); + fireEvent.press(screen.getByText("▶")); + + expect(jest.mocked(useVideoPlayer)).toHaveBeenCalledWith( + "file:///account/media/bafy-video", + expect.any(Function), + ); + }); + + it("should explicitly show failed saved media and how to retry", () => { + const post: PostPreviewData = { + ...basePost, + media: [ + { + type: "video", + downloadState: "failed", + downloadError: "source unavailable", + playlistUrl: "https://video.bsky.app/playlist.m3u8", + }, + ], + }; + + render(); + + expect(screen.getByText("Media unavailable — save again to retry")).toBeTruthy(); + expect(screen.getByText("source unavailable")).toBeTruthy(); + }); + it("should render post with external embed (link preview)", () => { const externalEmbed: ExternalEmbed = { uri: "https://example.com/article", diff --git a/components/account/browse-shared.tsx b/components/account/browse-shared.tsx index 6eec6a4..d656b42 100644 --- a/components/account/browse-shared.tsx +++ b/components/account/browse-shared.tsx @@ -76,6 +76,10 @@ export type MediaRow = { thumbUrl: string | null; fullsizeUrl: string | null; playlistUrl: string | null; + contentCid?: string | null; + localPath?: string | null; + downloadState?: "pending" | "downloading" | "complete" | "failed" | null; + lastError?: string | null; }; export type ExternalRow = { @@ -193,6 +197,13 @@ export function mapRowToPreview( function mapMediaRowToAttachment(row: MediaRow): MediaAttachment { return { type: row.mediaType, + contentCid: row.contentCid ?? null, + localUri: row.localPath ?? null, + downloadState: + row.downloadState === "complete" || row.downloadState === "failed" + ? row.downloadState + : "pending", + downloadError: row.lastError ?? null, alt: row.alt, width: row.width, height: row.height, @@ -212,11 +223,13 @@ export async function fetchMediaForPosts( const placeholders = postUris.map(() => "?").join(","); const mediaRows = await db.getAllAsync( - `SELECT postUri, position, mediaType, alt, width, height, - thumbUrl, fullsizeUrl, playlistUrl - FROM post_media - WHERE postUri IN (${placeholders}) - ORDER BY postUri, position;`, + `SELECT pm.postUri, pm.position, pm.mediaType, pm.alt, pm.width, pm.height, + pm.thumbUrl, pm.fullsizeUrl, pm.playlistUrl, + ma.contentCid, ma.localPath, ma.downloadState, ma.lastError + FROM post_media pm + LEFT JOIN media_asset ma ON ma.contentCid = pm.assetCid + WHERE pm.postUri IN (${placeholders}) + ORDER BY pm.postUri, pm.position;`, postUris, ); diff --git a/controllers/BlueskyAccountController.ts b/controllers/BlueskyAccountController.ts index 906afca..e1117c5 100644 --- a/controllers/BlueskyAccountController.ts +++ b/controllers/BlueskyAccountController.ts @@ -10,6 +10,7 @@ import type { SQLiteDatabase } from "expo-sqlite"; import { zip } from "react-native-zip-archive"; import { getDatabase } from "@/database"; +import { resolveBlobDownloadUrl } from "./bluesky/blob-url"; import { applyAccountMigrations, blueskyAccountMigrations, @@ -136,6 +137,8 @@ export class BlueskyAccountController extends BaseAccountController this.waitForPause(), makeApiRequest: (requestFn: ApiRequestFn) => this.makeApiRequest(requestFn), + downloadMedia: (blobCid: string, did: string) => + this.downloadMedia(blobCid, did), downloadMediaFromUrl: (url: string, did: string) => this.downloadMediaFromUrl(url, did), }); @@ -1661,9 +1664,10 @@ export class BlueskyAccountController extends BaseAccountController { }); describe("media operations", () => { + beforeEach(() => { + jest.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + service: [ + { + type: "AtprotoPersonalDataServer", + serviceEndpoint: "https://pds.example.com", + }, + ], + }), + } as unknown as Response); + }); + it("should skip download when file already exists", async () => { const controller = new BlueskyAccountController(1); (controller as unknown as { agent: Agent | null }).agent = {} as Agent; @@ -688,10 +702,29 @@ describe("BlueskyAccountController", () => { const mockedDownload = jest.mocked(File.downloadFileAsync); expect(mockedDownload).toHaveBeenCalledTimes(1); const [url, dest] = mockedDownload.mock.calls[0] as [string, File]; - expect(url).toBe("https://cdn.bsky.app/blob/did%3Aplc%3A123/bafy%2Ftest"); + expect(url).toBe( + "https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3A123&cid=bafy%2Ftest", + ); expect(dest).toBeInstanceOf(File); expect(path).toBe(targetPath); }); + + it("should isolate the same content CID within each local account", async () => { + const first = new BlueskyAccountController(1, "account-one"); + const second = new BlueskyAccountController(2, "account-two"); + (first as unknown as { agent: Agent | null }).agent = {} as Agent; + (second as unknown as { agent: Agent | null }).agent = {} as Agent; + + const firstPath = await first.downloadMedia("shared-cid", "did:plc:author"); + const secondPath = await second.downloadMedia( + "shared-cid", + "did:plc:author", + ); + + expect(firstPath).toContain("bluesky-account-one/media/shared-cid"); + expect(secondPath).toContain("bluesky-account-two/media/shared-cid"); + expect(firstPath).not.toBe(secondPath); + }); }); describe("cleanup", () => { diff --git a/controllers/bluesky/__tests__/blob-url.test.ts b/controllers/bluesky/__tests__/blob-url.test.ts new file mode 100644 index 0000000..1e113a5 --- /dev/null +++ b/controllers/bluesky/__tests__/blob-url.test.ts @@ -0,0 +1,45 @@ +import { resolveBlobDownloadUrl } from "../blob-url"; + +describe("resolveBlobDownloadUrl", () => { + it("resolves the author's PDS and builds the standard blob endpoint", async () => { + const fetcher = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + service: [ + { + type: "AtprotoPersonalDataServer", + serviceEndpoint: "https://pds.example.com/", + }, + ], + }), + }); + + const url = await resolveBlobDownloadUrl( + "did:plc:author", + "bafy-content", + fetcher as unknown as typeof fetch, + ); + + expect(fetcher).toHaveBeenCalledWith( + "https://plc.directory/did%3Aplc%3Aauthor", + ); + expect(url).toBe( + "https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aauthor&cid=bafy-content", + ); + }); + + it("fails explicitly when the DID document has no PDS", async () => { + const fetcher = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ service: [] }), + }); + + await expect( + resolveBlobDownloadUrl( + "did:plc:author", + "bafy-content", + fetcher as unknown as typeof fetch, + ), + ).rejects.toThrow("No personal data server found"); + }); +}); diff --git a/controllers/bluesky/__tests__/post-indexer.test.ts b/controllers/bluesky/__tests__/post-indexer.test.ts index d113c5f..7c340de 100644 --- a/controllers/bluesky/__tests__/post-indexer.test.ts +++ b/controllers/bluesky/__tests__/post-indexer.test.ts @@ -20,6 +20,7 @@ import { createPostWithQuotedExternalEmbed, createPostWithVideo, createReplyPost, + makePostRecordRecognizable, } from "@/testUtils/blueskyFixtures"; import { createMockDatabase } from "@/testUtils/mockDatabase"; import { PostIndexer, type PostIndexerDeps } from "../post-indexer"; @@ -61,6 +62,10 @@ describe("PostIndexer", () => { makeApiRequest: jest.fn((fn: () => T) => fn() ) as PostIndexerDeps["makeApiRequest"], + downloadMedia: jest.fn(async (blobCid: string) => { + downloadedUrls.push(blobCid); + return `/local/path/${encodeURIComponent(blobCid)}`; + }), downloadMediaFromUrl: jest.fn(async (url: string) => { downloadedUrls.push(url); return `/local/path/${encodeURIComponent(url)}`; @@ -142,7 +147,7 @@ describe("PostIndexer", () => { }); it("should handle posts with images", async () => { - const posts = [createPostWithImages(3)]; + const posts = [makePostRecordRecognizable(createPostWithImages(3))]; (mockAgent.app!.bsky.feed.getAuthorFeed as jest.Mock).mockResolvedValue( createAuthorFeedResponse(posts, undefined) @@ -151,12 +156,13 @@ describe("PostIndexer", () => { const indexer = new PostIndexer(deps); await indexer.indexPosts(); - // Should complete without errors - expect(mockAgent.app!.bsky.feed.getAuthorFeed).toHaveBeenCalled(); + expect(deps.downloadMedia).toHaveBeenCalledTimes(3); }); it("should handle posts with videos", async () => { - const posts = [createPostWithVideo()]; + const video = makePostRecordRecognizable(createPostWithVideo()); + (video.post.embed as { cid?: string }).cid = "bafy-video"; + const posts = [video]; (mockAgent.app!.bsky.feed.getAuthorFeed as jest.Mock).mockResolvedValue( createAuthorFeedResponse(posts, undefined) @@ -165,8 +171,10 @@ describe("PostIndexer", () => { const indexer = new PostIndexer(deps); await indexer.indexPosts(); - // Should complete without errors - expect(mockAgent.app!.bsky.feed.getAuthorFeed).toHaveBeenCalled(); + expect(deps.downloadMedia).toHaveBeenCalledWith( + "bafy-video", + video.post.author.did, + ); }); it("should handle posts with quoted posts", async () => { @@ -323,6 +331,24 @@ describe("PostIndexer", () => { expect(mockAgent.app!.bsky.feed.getActorLikes).toHaveBeenCalled(); }); + + it("should preserve full images and video from liked posts", async () => { + const image = makePostRecordRecognizable(createPostWithImages(1)); + const video = makePostRecordRecognizable(createPostWithVideo()); + (video.post.embed as { cid?: string }).cid = "bafy-liked-video"; + (mockAgent.app!.bsky.feed.getActorLikes as jest.Mock).mockResolvedValue({ + feed: [image, video], + cursor: undefined, + }); + + await new PostIndexer(deps).indexLikes(); + + expect(deps.downloadMedia).toHaveBeenCalledTimes(2); + expect(deps.downloadMedia).toHaveBeenCalledWith( + "bafy-liked-video", + video.post.author.did, + ); + }); }); describe("indexBookmarks", () => { diff --git a/controllers/bluesky/__tests__/post-persistence.test.ts b/controllers/bluesky/__tests__/post-persistence.test.ts new file mode 100644 index 0000000..7215f7a --- /dev/null +++ b/controllers/bluesky/__tests__/post-persistence.test.ts @@ -0,0 +1,108 @@ +import type { AppBskyFeedDefs } from "@atproto/api"; +import type { SQLiteDatabase } from "expo-sqlite"; + +import { + createPostWithImages, + createPostWithVideo, + makePostRecordRecognizable, +} from "@/testUtils/blueskyFixtures"; +import { createMockDatabase } from "@/testUtils/mockDatabase"; +import { PostPersistence } from "../post-persistence"; +function withImageCid(): AppBskyFeedDefs.FeedViewPost { + const item = makePostRecordRecognizable(createPostWithImages(1)); + const embed = item.post.embed as { images: { fullsize: string }[] }; + embed.images[0].fullsize = + "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:author/bafy-image@jpeg"; + return item; +} + +function withVideoCid(): AppBskyFeedDefs.FeedViewPost { + const item = makePostRecordRecognizable(createPostWithVideo()); + (item.post.embed as { cid?: string }).cid = "bafy-video"; + return item; +} + +describe("PostPersistence media preservation", () => { + it.each([ + ["full image", withImageCid(), "bafy-image", "file:///account/media/bafy-image"], + ["full video", withVideoCid(), "bafy-video", "file:///account/media/bafy-video"], + ])("preserves a %s by content identity", async (_label, feedItem, cid, localUri) => { + const db = createMockDatabase(); + const downloadMedia = jest.fn().mockResolvedValue(localUri); + const persistence = new PostPersistence({ + downloadMedia, + downloadMediaFromUrl: jest.fn(), + getDid: () => "did:plc:owner", + }); + + const preview = await persistence.persistPostView(db, feedItem.post); + + expect(downloadMedia).toHaveBeenCalledWith(cid, feedItem.post.author.did); + expect(preview?.media?.[0]).toMatchObject({ + contentCid: cid, + localUri, + downloadState: "complete", + }); + expect(db.runAsync).toHaveBeenCalledWith( + expect.stringContaining("downloadState = 'complete'"), + expect.arrayContaining([localUri, cid]), + ); + }); + + it("keeps a failed asset explicit and retries it when the record is saved again", async () => { + const db = createMockDatabase({ + getFirstAsync: jest + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + downloadState: "failed", + localPath: null, + }), + }); + const downloadMedia = jest + .fn() + .mockRejectedValueOnce(new Error("source unavailable")) + .mockResolvedValueOnce("file:///account/media/bafy-image"); + const persistence = new PostPersistence({ + downloadMedia, + downloadMediaFromUrl: jest.fn(), + getDid: () => "did:plc:owner", + }); + const feedItem = withImageCid(); + + const failed = await persistence.persistPostView(db, feedItem.post); + const retried = await persistence.persistPostView(db, feedItem.post); + + expect(failed?.media?.[0]).toMatchObject({ + downloadState: "failed", + downloadError: "source unavailable", + }); + expect(retried?.media?.[0]).toMatchObject({ + downloadState: "complete", + localUri: "file:///account/media/bafy-image", + }); + expect(downloadMedia).toHaveBeenCalledTimes(2); + }); + + it("does not download an already complete account-local asset again", async () => { + const db = createMockDatabase({ + getFirstAsync: jest.fn().mockResolvedValue({ + downloadState: "complete", + localPath: "file:///account/media/bafy-image", + }), + }); + const downloadMedia = jest.fn(); + const persistence = new PostPersistence({ + downloadMedia, + downloadMediaFromUrl: jest.fn(), + getDid: () => "did:plc:owner", + }); + + const preview = await persistence.persistPostView(db, withImageCid().post); + + expect(downloadMedia).not.toHaveBeenCalled(); + expect(preview?.media?.[0].localUri).toBe( + "file:///account/media/bafy-image", + ); + }); +}); diff --git a/controllers/bluesky/blob-url.ts b/controllers/bluesky/blob-url.ts new file mode 100644 index 0000000..b99db96 --- /dev/null +++ b/controllers/bluesky/blob-url.ts @@ -0,0 +1,46 @@ +type DidDocument = { + service?: { + type?: string; + serviceEndpoint?: string; + }[]; +}; + +function didDocumentUrl(did: string): string { + if (did.startsWith("did:plc:")) { + return `https://plc.directory/${encodeURIComponent(did)}`; + } + + if (did.startsWith("did:web:")) { + const parts = did.slice("did:web:".length).split(":").map(decodeURIComponent); + const host = parts.shift(); + if (!host) throw new Error(`Invalid did:web identifier: ${did}`); + const path = parts.length > 0 ? `/${parts.join("/")}/did.json` : "/.well-known/did.json"; + return `https://${host}${path}`; + } + + throw new Error(`Unsupported DID method for media download: ${did}`); +} + +export async function resolveBlobDownloadUrl( + did: string, + contentCid: string, + fetcher: typeof fetch = fetch, +): Promise { + const response = await fetcher(didDocumentUrl(did)); + if (!response.ok) { + throw new Error(`Unable to resolve media source for ${did}`); + } + + const document = (await response.json()) as DidDocument; + const pds = document.service?.find( + ({ type, serviceEndpoint }) => + type === "AtprotoPersonalDataServer" && + typeof serviceEndpoint === "string", + )?.serviceEndpoint; + if (!pds) { + throw new Error(`No personal data server found for ${did}`); + } + + const params = new URLSearchParams({ did, cid: contentCid }); + return `${pds.replace(/\/$/, "")}/xrpc/com.atproto.sync.getBlob?${params}`; +} diff --git a/controllers/bluesky/indexer.ts b/controllers/bluesky/indexer.ts index d69a233..a352ac4 100644 --- a/controllers/bluesky/indexer.ts +++ b/controllers/bluesky/indexer.ts @@ -15,6 +15,7 @@ interface IndexerDeps { updateProgress: (updates: Partial) => void; waitForPause: () => Promise; makeApiRequest: RequestExecutor; + downloadMedia: (blobCid: string, did: string) => Promise; downloadMediaFromUrl: (url: string, did: string) => Promise; } @@ -30,6 +31,7 @@ export class BlueskyIndexer { updateProgress: deps.updateProgress, waitForPause: deps.waitForPause, makeApiRequest: deps.makeApiRequest, + downloadMedia: deps.downloadMedia, downloadMediaFromUrl: deps.downloadMediaFromUrl, }; diff --git a/controllers/bluesky/post-indexer.ts b/controllers/bluesky/post-indexer.ts index f1c0c39..8896c72 100644 --- a/controllers/bluesky/post-indexer.ts +++ b/controllers/bluesky/post-indexer.ts @@ -27,6 +27,7 @@ export interface PostIndexerDeps { updateProgress: (updates: Partial) => void; waitForPause: () => Promise; makeApiRequest: RequestExecutor; + downloadMedia: (blobCid: string, did: string) => Promise; downloadMediaFromUrl: (url: string, did: string) => Promise; } @@ -39,6 +40,7 @@ export class PostIndexer { constructor(private readonly deps: PostIndexerDeps) { this.postPersistence = new PostPersistence({ + downloadMedia: deps.downloadMedia, downloadMediaFromUrl: deps.downloadMediaFromUrl, getDid: deps.getDid, }); diff --git a/controllers/bluesky/post-persistence.ts b/controllers/bluesky/post-persistence.ts index 8feca87..fba1946 100644 --- a/controllers/bluesky/post-persistence.ts +++ b/controllers/bluesky/post-persistence.ts @@ -23,6 +23,7 @@ export interface PostPersistenceOptions { } export interface PostPersistenceDeps { + downloadMedia?: (blobCid: string, did: string) => Promise; downloadMediaFromUrl: (url: string, did: string) => Promise; getDid: () => string | null; } @@ -52,7 +53,7 @@ export class PostPersistence { return null; } - const did = this.requireDid(); + this.requireDid(); await this.upsertProfile(db, postView.author); const postRecord = recordInfo.kind === "post" ? recordInfo.record : null; @@ -179,7 +180,7 @@ export class PostPersistence { db, postView.uri, media, - did + postView.author.did ); // Extract and save external link embeds @@ -276,96 +277,136 @@ export class PostPersistence { db: SQLiteDatabase, postUri: string, media: ExtractedMedia[], - _did: string + sourceDid: string ): Promise { return await Promise.all( media.map(async (attachment, position) => { - if (attachment.type === "image") { - // Insert into post_media table - await db.runAsync( - `INSERT INTO post_media ( - postUri, position, mediaType, blobCid, mimeType, alt, - width, height, aspectRatioWidth, aspectRatioHeight, - thumbUrl, fullsizeUrl, playlistUrl, downloadedAt - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(postUri, position) DO UPDATE SET - mediaType = excluded.mediaType, - blobCid = excluded.blobCid, - mimeType = excluded.mimeType, - alt = excluded.alt, - width = excluded.width, - height = excluded.height, - aspectRatioWidth = excluded.aspectRatioWidth, - aspectRatioHeight = excluded.aspectRatioHeight, - thumbUrl = excluded.thumbUrl, - fullsizeUrl = excluded.fullsizeUrl, - playlistUrl = excluded.playlistUrl, - downloadedAt = COALESCE(excluded.downloadedAt, post_media.downloadedAt);`, - [ - postUri, - position, - attachment.type, - attachment.blobCid, - attachment.mimeType ?? null, - attachment.alt ?? null, - attachment.width ?? null, - attachment.height ?? null, - attachment.width ?? null, // aspectRatioWidth - attachment.height ?? null, // aspectRatioHeight - attachment.thumbUrl ?? null, - attachment.fullsizeUrl ?? null, - null, // playlistUrl - images don't have this - null, // downloadedAt - not downloading media locally - ] - ); + const sourceUrl = + attachment.type === "image" + ? attachment.fullsizeUrl + : attachment.playlistUrl; + await db.runAsync( + `INSERT INTO media_asset ( + contentCid, mediaType, mimeType, sourceUrl, sourceDid, + sourceMetadataJSON, downloadState + ) VALUES (?, ?, ?, ?, ?, ?, 'pending') + ON CONFLICT(contentCid) DO UPDATE SET + mediaType = excluded.mediaType, + mimeType = COALESCE(excluded.mimeType, media_asset.mimeType), + sourceUrl = COALESCE(excluded.sourceUrl, media_asset.sourceUrl), + sourceDid = excluded.sourceDid, + sourceMetadataJSON = excluded.sourceMetadataJSON;`, + [ + attachment.blobCid, + attachment.type, + attachment.mimeType ?? null, + sourceUrl ?? null, + sourceDid, + JSON.stringify({ + thumbUrl: attachment.thumbUrl ?? null, + width: attachment.width ?? null, + height: attachment.height ?? null, + alt: attachment.alt ?? null, + }), + ] + ); - return attachment; - } + const existing = await db.getFirstAsync<{ + downloadState: string; + localPath: string | null; + }>( + `SELECT downloadState, localPath FROM media_asset + WHERE contentCid = ?;`, + [attachment.blobCid] + ); - // Handle video attachments - if (attachment.type === "video") { - // Insert video into post_media table + let localUri = existing?.localPath ?? null; + let downloadState: "complete" | "failed" = "complete"; + let downloadError: string | null = null; + + if (existing?.downloadState !== "complete" || !localUri) { await db.runAsync( - `INSERT INTO post_media ( - postUri, position, mediaType, blobCid, mimeType, alt, - width, height, aspectRatioWidth, aspectRatioHeight, - thumbUrl, fullsizeUrl, playlistUrl, downloadedAt - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(postUri, position) DO UPDATE SET - mediaType = excluded.mediaType, - blobCid = excluded.blobCid, - mimeType = excluded.mimeType, - alt = excluded.alt, - width = excluded.width, - height = excluded.height, - aspectRatioWidth = excluded.aspectRatioWidth, - aspectRatioHeight = excluded.aspectRatioHeight, - thumbUrl = excluded.thumbUrl, - fullsizeUrl = excluded.fullsizeUrl, - playlistUrl = excluded.playlistUrl, - downloadedAt = COALESCE(excluded.downloadedAt, post_media.downloadedAt);`, - [ - postUri, - position, - attachment.type, - attachment.blobCid, - attachment.mimeType ?? null, - attachment.alt ?? null, - attachment.width ?? null, - attachment.height ?? null, - attachment.width ?? null, // aspectRatioWidth - attachment.height ?? null, // aspectRatioHeight - attachment.thumbUrl ?? null, - null, // fullsizeUrl - videos use playlistUrl instead - attachment.playlistUrl ?? null, - null, // downloadedAt - not downloading media locally - ] + `UPDATE media_asset + SET downloadState = 'downloading', lastError = NULL, + attemptCount = attemptCount + 1 + WHERE contentCid = ?;`, + [attachment.blobCid] ); - - return attachment; + try { + if (!this.deps.downloadMedia) { + throw new Error("Media download is unavailable"); + } + localUri = await this.deps.downloadMedia( + attachment.blobCid, + sourceDid + ); + await db.runAsync( + `UPDATE media_asset + SET localPath = ?, downloadState = 'complete', lastError = NULL, + downloadedAt = ? + WHERE contentCid = ?;`, + [localUri, Date.now(), attachment.blobCid] + ); + } catch (error) { + downloadState = "failed"; + downloadError = + error instanceof Error ? error.message : String(error); + await db.runAsync( + `UPDATE media_asset + SET downloadState = 'failed', lastError = ? + WHERE contentCid = ?;`, + [downloadError, attachment.blobCid] + ); + } } - return attachment; + const downloadedAt = downloadState === "complete" ? Date.now() : null; + await db.runAsync( + `INSERT INTO post_media ( + postUri, position, mediaType, blobCid, mimeType, alt, + width, height, aspectRatioWidth, aspectRatioHeight, + thumbUrl, fullsizeUrl, playlistUrl, downloadedAt, assetCid + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(postUri, position) DO UPDATE SET + mediaType = excluded.mediaType, + blobCid = excluded.blobCid, + mimeType = excluded.mimeType, + alt = excluded.alt, + width = excluded.width, + height = excluded.height, + aspectRatioWidth = excluded.aspectRatioWidth, + aspectRatioHeight = excluded.aspectRatioHeight, + thumbUrl = excluded.thumbUrl, + fullsizeUrl = excluded.fullsizeUrl, + playlistUrl = excluded.playlistUrl, + downloadedAt = COALESCE(excluded.downloadedAt, post_media.downloadedAt), + assetCid = excluded.assetCid;`, + [ + postUri, + position, + attachment.type, + attachment.blobCid, + attachment.mimeType ?? null, + attachment.alt ?? null, + attachment.width ?? null, + attachment.height ?? null, + attachment.width ?? null, + attachment.height ?? null, + attachment.thumbUrl ?? null, + attachment.type === "image" ? attachment.fullsizeUrl ?? null : null, + attachment.type === "video" ? attachment.playlistUrl ?? null : null, + downloadedAt, + attachment.blobCid, + ] + ); + + return { + ...attachment, + contentCid: attachment.blobCid, + localUri, + downloadState, + downloadError, + }; }) ); } diff --git a/controllers/bluesky/types.ts b/controllers/bluesky/types.ts index 84259cd..fc92f4c 100644 --- a/controllers/bluesky/types.ts +++ b/controllers/bluesky/types.ts @@ -91,6 +91,10 @@ export interface RateLimitInfo { export type MediaAttachment = { type: "image" | "video"; + contentCid?: string | null; + localUri?: string | null; + downloadState?: "pending" | "complete" | "failed"; + downloadError?: string | null; thumbUrl?: string | null; fullsizeUrl?: string | null; playlistUrl?: string | null; diff --git a/database/account-db/__tests__/bluesky-migrations.test.ts b/database/account-db/__tests__/bluesky-migrations.test.ts new file mode 100644 index 0000000..5a3f0b2 --- /dev/null +++ b/database/account-db/__tests__/bluesky-migrations.test.ts @@ -0,0 +1,18 @@ +import { blueskyAccountMigrations } from "../bluesky-migrations"; + +describe("Bluesky account migrations", () => { + it("adds account-local content-addressed media assets", () => { + const migration = blueskyAccountMigrations.find( + ({ name }) => name === "preserve media assets", + ); + + expect(migration).toBeDefined(); + expect(migration?.statements.join("\n")).toContain( + "CREATE TABLE IF NOT EXISTS media_asset", + ); + expect(migration?.statements.join("\n")).toContain("contentCid TEXT"); + expect(migration?.statements.join("\n")).toContain("localPath TEXT"); + expect(migration?.statements.join("\n")).toContain("downloadState TEXT"); + expect(migration?.statements.join("\n")).toContain("assetCid TEXT"); + }); +}); diff --git a/database/account-db/bluesky-migrations.ts b/database/account-db/bluesky-migrations.ts index 5756f60..16b95d4 100644 --- a/database/account-db/bluesky-migrations.ts +++ b/database/account-db/bluesky-migrations.ts @@ -237,6 +237,33 @@ export const blueskyAccountMigrations: AccountMigration[] = [ `CREATE INDEX IF NOT EXISTS idx_message_sent ON message(sentAt);`, ], }, + { + version: 2, + name: "preserve media assets", + statements: [ + `CREATE TABLE IF NOT EXISTS media_asset ( + contentCid TEXT PRIMARY KEY, + mediaType TEXT NOT NULL CHECK (mediaType IN ('image', 'video')), + mimeType TEXT, + byteLength INTEGER, + localPath TEXT, + sourceUrl TEXT, + sourceDid TEXT, + sourceMetadataJSON TEXT, + downloadState TEXT NOT NULL DEFAULT 'pending' + CHECK (downloadState IN ('pending', 'downloading', 'complete', 'failed')), + lastError TEXT, + attemptCount INTEGER NOT NULL DEFAULT 0, + downloadedAt INTEGER + );`, + `CREATE INDEX IF NOT EXISTS idx_media_asset_state + ON media_asset(downloadState);`, + `ALTER TABLE post_media ADD COLUMN assetCid TEXT + REFERENCES media_asset(contentCid);`, + `CREATE INDEX IF NOT EXISTS idx_post_media_asset + ON post_media(assetCid);`, + ], + }, ]; /** diff --git a/testUtils/blueskyFixtures.ts b/testUtils/blueskyFixtures.ts index 3b90b4c..90c7e1b 100644 --- a/testUtils/blueskyFixtures.ts +++ b/testUtils/blueskyFixtures.ts @@ -87,6 +87,17 @@ export function createFeedViewPost( }; } +export function makePostRecordRecognizable< + T extends { post: { record: unknown } }, +>(item: T): T { + item.post.record = { + ...(item.post.record as Record), + $type: "app.bsky.feed.post", + createdAt: "2026-01-04T12:00:00.000Z", + }; + return item; +} + // Create a post with facets (links and mentions) export function createPostWithFacets(): AppBskyFeedDefs.FeedViewPost { const text =