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 =