Import Instagram archive to a Bluesky account
9
fork

Configure Feed

Select the types of activity you want to include in your feed.

Found video upload regression, moved video post logic into the media and video modules. Updated unit tests to reflect the changes in complexity.

+234 -180
+4 -68
src/app.ts
··· 55 55 return process.env.ARCHIVE_FOLDER!; 56 56 } 57 57 58 - async function processVideoPost( 59 - filePath: string, 60 - buffer: Buffer, 61 - bluesky: BlueskyClient | null, 62 - simulate: boolean 63 - ) { 64 - try { 65 - if (!buffer) { 66 - throw new Error("Video buffer is undefined"); 67 - } 68 - 69 - logger.debug({ 70 - message: "Processing video", 71 - fileSize: buffer.length, 72 - filePath, 73 - }); 74 - 75 - // Prepare video metadata 76 - const videoData = await prepareVideoUpload(filePath, buffer); 77 - 78 - // Upload video to get CID 79 - if (!simulate && bluesky) { 80 - const blob = await bluesky.uploadVideo(buffer); 81 - if (!blob?.ref?.$link) { 82 - throw new Error("Failed to get video upload reference"); 83 - } 84 - videoData.ref = blob.ref.$link; 85 - } 86 - 87 - // Create video embed structure 88 - const videoEmbed = createVideoEmbed(videoData); 89 - 90 - return videoEmbed; 91 - } catch (error) { 92 - logger.error("Failed to process video:", error); 93 - throw error; 94 - } 95 - } 96 58 97 59 /** 98 60 * Validates test mode configuration ··· 225 187 postText, 226 188 mediaCount, 227 189 embeddedMedia: initialMedia, 228 - } = await processPost(post, archivalFolder); 190 + } = await processPost(post, archivalFolder, 191 + bluesky, 192 + SIMULATE); 229 193 let embeddedMedia: any = initialMedia; 230 194 231 195 logger.debug({ ··· 234 198 initialMediaPresent: !!initialMedia, 235 199 mediaCount, 236 200 }); 237 - 238 - // Handle video if present 239 - if (post.media[0].type === "Video") { 240 - try { 241 - const videoEmbed = await processVideoPost( 242 - post.media[0].media_url, 243 - post.media[0].buffer, 244 - bluesky, 245 - SIMULATE 246 - ); 247 - embeddedMedia = videoEmbed; 248 - logger.debug({ 249 - message: "Video processing complete", 250 - hasVideoEmbed: !!videoEmbed, 251 - }); 252 - } catch (error) { 253 - logger.error("Failed to process video:", error); 254 - continue; // Skip this post if video processing fails 255 - } 256 - } else { 257 - logger.debug({ 258 - message: "Using photo media from processPost", 259 - mediaType: "photo", 260 - embeddedMediaLength: Array.isArray(embeddedMedia) 261 - ? embeddedMedia.length 262 - : 0, 263 - }); 264 - } 265 - 201 + 266 202 if (!postDate) { 267 203 logger.warn("Skipping post - Invalid date"); 268 204 continue;
+97 -53
src/media.ts
··· 1 - import { ImageEmbed, VideoEmbed, ImageEmbedImpl, VideoEmbedImpl } from './bluesky'; 2 - import { logger } from './logger'; 3 - import { validateVideo } from './video'; 4 - import FS from 'fs'; 1 + import { 2 + ImageEmbed, 3 + VideoEmbed, 4 + ImageEmbedImpl, 5 + VideoEmbedImpl, 6 + BlueskyClient, 7 + } from "./bluesky"; 8 + import { logger } from "./logger"; 9 + import { validateVideo, processVideoPost } from "./video"; 10 + import FS from "fs"; 5 11 6 12 export interface MediaProcessResult { 7 13 mediaText: string; ··· 19 25 20 26 const MAX_IMAGES_PER_POST = 4; 21 27 const POST_TEXT_LIMIT = 300; 22 - const POST_TEXT_TRUNCATE_SUFFIX = '...'; 28 + const POST_TEXT_TRUNCATE_SUFFIX = "..."; 23 29 24 30 export function getMimeType(fileType: string): string { 25 31 switch (fileType.toLowerCase()) { 26 - case 'heic': 27 - return 'image/heic'; 28 - case 'webp': 29 - return 'image/webp'; 30 - case 'jpg': 31 - return 'image/jpeg'; 32 - case 'mp4': 33 - return 'video/mp4'; 34 - case 'mov': 35 - return 'video/quicktime'; 32 + case "heic": 33 + return "image/heic"; 34 + case "webp": 35 + return "image/webp"; 36 + case "jpg": 37 + return "image/jpeg"; 38 + case "mp4": 39 + return "video/mp4"; 40 + case "mov": 41 + return "video/quicktime"; 36 42 default: 37 - logger.warn('Unsupported file type ' + fileType); 38 - return ''; 43 + logger.warn("Unsupported file type " + fileType); 44 + return ""; 39 45 } 40 46 } 41 47 42 - export async function processMedia(media: any, archiveFolder: string): Promise<MediaProcessResult> { 48 + export async function processMedia( 49 + media: any, 50 + archiveFolder: string 51 + ): Promise<MediaProcessResult> { 43 52 const mediaDate = new Date(media.creation_timestamp * 1000); 44 - const fileType = media.uri.substring(media.uri.lastIndexOf('.') + 1); 53 + const fileType = media.uri.substring(media.uri.lastIndexOf(".") + 1); 45 54 const mimeType = getMimeType(fileType); 46 - const mediaFilename = `${archiveFolder}/${media.uri}`; 47 - 55 + const mediaFilename = `${archiveFolder}/${media.uri}`; 56 + 48 57 let mediaBuffer; 49 58 try { 50 59 mediaBuffer = FS.readFileSync(mediaFilename); ··· 53 62 message: `Failed to read media file: ${mediaFilename}`, 54 63 error, 55 64 }); 56 - return { mediaText: '', mimeType: null, mediaBuffer: null, isVideo: false }; 65 + return { mediaText: "", mimeType: null, mediaBuffer: null, isVideo: false }; 57 66 } 58 67 59 - let mediaText = media.title ?? ''; 68 + let mediaText = media.title ?? ""; 60 69 if (media.media_metadata?.photo_metadata?.exif_data?.length > 0) { 61 70 const location = media.media_metadata.photo_metadata.exif_data[0]; 62 71 if (location.latitude > 0) { ··· 65 74 } 66 75 67 76 const truncatedText = 68 - mediaText.length > 100 ? mediaText.substring(0, 100) + '...' : mediaText; 77 + mediaText.length > 100 ? mediaText.substring(0, 100) + "..." : mediaText; 69 78 70 - const isVideo = mimeType.startsWith('video/'); 79 + const isVideo = mimeType.startsWith("video/"); 71 80 72 81 logger.debug({ 73 - message: 'Instagram Source Media', 82 + message: "Instagram Source Media", 74 83 mimeType, 75 84 mediaFilename, 76 85 Created: `${mediaDate.toISOString()}`, 77 - Text: truncatedText.replace(/[\r\n]+/g, ' ') || 'No title', 78 - Type: isVideo ? 'Video' : 'Image', 86 + Text: truncatedText.replace(/[\r\n]+/g, " ") || "No title", 87 + Type: isVideo ? "Video" : "Image", 79 88 }); 80 89 81 90 return { mediaText: truncatedText, mimeType, mediaBuffer, isVideo }; 82 91 } 83 92 84 - export async function processPost(post: any, archiveFolder: string): Promise<ProcessedPost> { 93 + export async function processPost( 94 + post: any, 95 + archiveFolder: string, 96 + bluesky: BlueskyClient | null, 97 + simulate: boolean 98 + ): Promise<ProcessedPost> { 85 99 let postDate = post.creation_timestamp 86 100 ? new Date(post.creation_timestamp * 1000) 87 101 : undefined; 88 - let postText = post.title ?? ''; 102 + let postText = post.title ?? ""; 89 103 90 104 if (postText.length > POST_TEXT_LIMIT) { 91 - postText = postText.substring( 92 - 0, 93 - POST_TEXT_LIMIT - POST_TEXT_TRUNCATE_SUFFIX.length 94 - ) + POST_TEXT_TRUNCATE_SUFFIX; 105 + postText = 106 + postText.substring( 107 + 0, 108 + POST_TEXT_LIMIT - POST_TEXT_TRUNCATE_SUFFIX.length 109 + ) + POST_TEXT_TRUNCATE_SUFFIX; 95 110 } 96 111 97 112 if (!post.media?.length) { 98 - return { 99 - postDate: postDate || null, 100 - postText, 101 - embeddedMedia: [], 102 - mediaCount: 0 113 + return { 114 + postDate: postDate || null, 115 + postText, 116 + embeddedMedia: [], 117 + mediaCount: 0, 103 118 }; 104 119 } 105 120 ··· 108 123 postDate = postDate || new Date(post.media[0].creation_timestamp * 1000); 109 124 } 110 125 111 - let embeddedMedia: VideoEmbed | ImageEmbed[] = []; 126 + let embeddedMedia: ImageEmbed[] = []; 112 127 let mediaCount = 0; 113 128 114 129 // If first media is video, process only that 115 130 const firstMedia = await processMedia(post.media[0], archiveFolder); 116 131 if (firstMedia.isVideo) { 117 - if (firstMedia.mimeType && firstMedia.mediaBuffer && validateVideo(firstMedia.mediaBuffer)) { 118 - embeddedMedia = new VideoEmbedImpl( 132 + let embeddedVideo: VideoEmbed; 133 + if ( 134 + firstMedia.mimeType && 135 + firstMedia.mediaBuffer && 136 + validateVideo(firstMedia.mediaBuffer) 137 + ) { 138 + embeddedVideo = new VideoEmbedImpl( 119 139 firstMedia.mediaText, 120 140 firstMedia.mediaBuffer, 121 141 firstMedia.mimeType 122 142 ); 123 143 mediaCount = 1; 144 + // Handle video if present 145 + try { 146 + const videoEmbed = await processVideoPost( 147 + post.media[0].uri, 148 + embeddedVideo.buffer, 149 + bluesky, 150 + simulate 151 + ); 152 + 153 + // TODO fix typing errors 154 + embeddedVideo = videoEmbed as unknown as VideoEmbed; 155 + logger.debug({ 156 + message: "Video processing complete", 157 + hasVideoEmbed: !!videoEmbed, 158 + }); 159 + } catch (error) { 160 + logger.error("Failed to process video:", error); 161 + } 162 + return { 163 + postDate: postDate || null, 164 + postText, 165 + embeddedMedia: embeddedVideo, 166 + mediaCount, 167 + }; 124 168 } 125 - return { postDate: postDate || null, postText, embeddedMedia, mediaCount }; 126 169 } 127 170 128 171 // Otherwise process images 129 172 for (let j = 0; j < post.media.length; j++) { 130 173 if (j >= MAX_IMAGES_PER_POST) { 131 174 logger.warn( 132 - 'Bluesky does not support more than 4 images per post, excess images will be discarded.' 175 + "Bluesky does not support more than 4 images per post, excess images will be discarded." 133 176 ); 134 177 break; 135 178 } 136 179 137 180 const { mediaText, mimeType, mediaBuffer, isVideo } = await processMedia( 138 181 post.media[j], 139 - archiveFolder); 140 - 182 + archiveFolder 183 + ); 184 + 141 185 if (!mimeType || !mediaBuffer || isVideo) continue; 142 186 143 - (embeddedMedia as ImageEmbed[]).push( 187 + embeddedMedia.push( 144 188 new ImageEmbedImpl(mediaText, mediaBuffer, mimeType) 145 189 ); 146 190 mediaCount++; 147 191 } 148 192 149 - return { 150 - postDate: postDate || null, 151 - postText, 152 - embeddedMedia, 153 - mediaCount 193 + return { 194 + postDate: postDate || null, 195 + postText, 196 + embeddedMedia, 197 + mediaCount, 154 198 }; 155 - } 199 + }
+42 -1
src/video.ts
··· 1 1 import ffmpeg from 'fluent-ffmpeg'; 2 2 import ffprobe from '@ffprobe-installer/ffprobe'; 3 3 import { logger } from './logger' 4 + import { BlueskyClient } from './bluesky'; 4 5 5 6 // Configure ffmpeg to use ffprobe 6 7 ffmpeg.setFfprobePath(ffprobe.path); ··· 101 102 height: videoData.dimensions.height 102 103 } 103 104 }; 104 - } 105 + } 106 + 107 + 108 + export async function processVideoPost( 109 + filePath: string, 110 + buffer: Buffer, 111 + bluesky: BlueskyClient | null, 112 + simulate: boolean 113 + ) { 114 + try { 115 + if (!buffer) { 116 + throw new Error("Video buffer is undefined"); 117 + } 118 + 119 + logger.debug({ 120 + message: "Processing video", 121 + fileSize: buffer.length, 122 + filePath, 123 + }); 124 + 125 + // Prepare video metadata 126 + const videoData = await prepareVideoUpload(filePath, buffer); 127 + 128 + // Upload video to get CID 129 + if (!simulate && bluesky) { 130 + const blob = await bluesky.uploadVideo(buffer); 131 + if (!blob?.ref?.$link) { 132 + throw new Error("Failed to get video upload reference"); 133 + } 134 + videoData.ref = blob.ref.$link; 135 + } 136 + 137 + // Create video embed structure 138 + const videoEmbed = createVideoEmbed(videoData); 139 + 140 + return videoEmbed; 141 + } catch (error) { 142 + logger.error("Failed to process video:", error); 143 + throw error; 144 + } 145 + }
+21 -54
tests/app.test.ts
··· 3 3 import { processPost } from '../src/media'; 4 4 import { logger } from '../src/logger'; 5 5 import fs from 'fs'; 6 - import { createVideoEmbed } from '../src/video'; 6 + import { createVideoEmbed, processVideoPost } from '../src/video'; 7 7 8 8 // Mock all dependencies 9 9 jest.mock('fs'); ··· 32 32 }), 33 33 createVideoEmbed: jest.fn(), 34 34 validateVideo: jest.fn(), 35 - getVideoDimensions: jest.fn() 35 + getVideoDimensions: jest.fn(), 36 + processVideoPost: jest.fn() 36 37 })); 37 38 38 39 // Add this mock before the tests ··· 115 116 expect(jest.mocked(BlueskyClient)).toHaveBeenCalled(); 116 117 expect(processPost).toHaveBeenCalledWith( 117 118 expect.objectContaining(mockPost), 118 - expect.stringContaining('/test/folder') 119 + expect.stringContaining('/test/folder'), 120 + expect.any(BlueskyClient), 121 + false 119 122 ); 120 123 }); 121 124 ··· 228 231 ); 229 232 }); 230 233 231 - test('should handle video posts correctly', async () => { 232 - const mockVideoPost = { 233 - "title": "", 234 - "media": [ 235 - { 236 - "uri": "AQM8KYlOYHTF5GlP43eMroHUpmnFHJh5CnCJUdRUeqWxG4tNX7D43eM77F152vfi4znTzgkFTTzzM4nHa_v8ugmP4WPRJtjKPZX5pko_17845940218109367.mp4", 237 - "creation_timestamp": 1458732736, 238 - "media_metadata": { 239 - "video_metadata": { 240 - "exif_data": [ 241 - { 242 - "latitude": 53.141186112, 243 - "longitude": 11.038734576 244 - } 245 - ] 246 - } 247 - }, 248 - "title": "No filter needed. #waterfall #nature", 249 - "cross_post_source": { 250 - "source_app": "FB" 251 - } 252 - } 253 - ] 234 + test('should process posts successfully', async () => { 235 + const mockPost = { 236 + creation_timestamp: Date.now() / 1000, 237 + title: 'Test Post', 238 + media: [{ 239 + creation_timestamp: Date.now() / 1000, 240 + title: 'Test Media' 241 + }] 254 242 }; 255 243 256 - (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify([mockVideoPost])); 257 - 258 - // Mock BlueskyClient uploadVideo method 259 - jest.mocked(BlueskyClient).prototype.uploadVideo = jest.fn().mockResolvedValue({ 260 - ref: { $link: 'test-ref' } 261 - }); 262 - 263 - // Mock the video embed result 264 - const mockVideoEmbed = { 265 - $type: 'app.bsky.embed.video', 266 - video: { 267 - $type: 'blob', 268 - ref: { $link: 'test-ref' }, 269 - mimeType: 'video/mp4', 270 - size: 1000 271 - }, 272 - aspectRatio: { width: 640, height: 480 } 273 - }; 274 - 275 - (createVideoEmbed as jest.Mock).mockReturnValue(mockVideoEmbed); 276 - 244 + (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify([mockPost])); 277 245 await main(); 278 246 279 - expect(BlueskyClient.prototype.uploadVideo).toHaveBeenCalled(); 280 - expect(BlueskyClient.prototype.createPost).toHaveBeenCalledWith( 281 - expect.any(Date), 282 - expect.any(String), 283 - expect.objectContaining({ 284 - $type: 'app.bsky.embed.video' 285 - }) 247 + expect(jest.mocked(BlueskyClient)).toHaveBeenCalled(); 248 + expect(processPost).toHaveBeenCalledWith( 249 + expect.objectContaining(mockPost), 250 + expect.stringContaining('/test/folder'), 251 + expect.any(BlueskyClient), 252 + false 286 253 ); 287 254 }); 288 255 });
+70 -4
tests/media.test.ts
··· 1 1 import { getMimeType, processMedia, processPost } from "../src/media"; 2 2 import path from "path"; 3 3 import fs from "fs"; 4 + import { BlueskyClient } from '../src/bluesky'; 5 + import { createVideoEmbed, processVideoPost } from '../src/video'; 4 6 5 7 // Mock the file system 6 8 jest.mock("fs", () => ({ ··· 21 23 jest.mock("../src/video", () => ({ 22 24 validateVideo: jest.fn().mockReturnValue(true), 23 25 getVideoDimensions: jest.fn().mockResolvedValue({ width: 640, height: 480 }), 26 + createVideoEmbed: jest.fn(), 27 + processVideoPost: jest.fn() 24 28 })); 29 + 30 + jest.mock('../src/bluesky', () => { 31 + const actual = jest.requireActual('../src/bluesky'); 32 + return { 33 + ...actual, // Keep the real ImageEmbedImpl and VideoEmbedImpl 34 + BlueskyClient: jest.fn().mockImplementation(() => ({ 35 + uploadVideo: jest.fn().mockResolvedValue({ ref: { $link: 'test-ref' } }), 36 + createPost: jest.fn() 37 + })) 38 + }; 39 + }); 25 40 26 41 describe("Media Processing", () => { 27 42 beforeEach(() => { ··· 101 116 ], 102 117 }; 103 118 119 + const mockBluesky = new BlueskyClient('user', 'pass'); 120 + const simulate = false; 121 + 104 122 test("should process post correctly", async () => { 105 123 const result = await processPost( 106 124 testPost, 107 - path.join(__dirname, "../transfer/test_videos") 125 + path.join(__dirname, "../transfer/test_videos"), 126 + mockBluesky, 127 + simulate 108 128 ); 109 129 110 130 expect(result.postDate).toBeTruthy(); ··· 123 143 124 144 const result = await processPost( 125 145 emptyPost, 126 - path.join(__dirname, "../transfer/test_videos") 146 + path.join(__dirname, "../transfer/test_videos"), 147 + mockBluesky, 148 + simulate 127 149 ); 128 150 129 151 expect(result.postDate).toBeTruthy(); ··· 141 163 142 164 const result = await processPost( 143 165 longPost, 144 - path.join(__dirname, "../transfer/test_videos") 166 + path.join(__dirname, "../transfer/test_videos"), 167 + mockBluesky, 168 + simulate 145 169 ); 146 170 147 171 expect(result.postText.length).toBeLessThanOrEqual(300); ··· 163 187 164 188 const result = await processPost( 165 189 jpgPost, 166 - path.join(__dirname, "../transfer/test_videos") 190 + path.join(__dirname, "../transfer/test_videos"), 191 + mockBluesky, 192 + simulate 167 193 ); 168 194 169 195 expect(result.postDate).toBeTruthy(); ··· 171 197 // Image media should be an array 172 198 expect(Array.isArray(result.embeddedMedia)).toBe(true); 173 199 expect(result.embeddedMedia).toHaveLength(1); 200 + expect(result.mediaCount).toBe(1); 201 + }); 202 + 203 + test('should handle video posts correctly', async () => { 204 + const mockVideoPost = { 205 + title: "", 206 + media: [{ 207 + uri: "test.mp4", 208 + creation_timestamp: 1458732736, 209 + media_metadata: { 210 + video_metadata: { 211 + exif_data: [{ latitude: 53.141186112, longitude: 11.038734576 }] 212 + } 213 + }, 214 + title: "No filter needed. #waterfall #nature" 215 + }] 216 + }; 217 + 218 + const mockVideoEmbed = { 219 + $type: 'app.bsky.embed.video', 220 + video: { 221 + $type: 'blob', 222 + ref: { $link: 'test-ref' }, 223 + mimeType: 'video/mp4', 224 + size: 1000 225 + }, 226 + aspectRatio: { width: 640, height: 480 } 227 + }; 228 + 229 + (processVideoPost as jest.Mock).mockResolvedValue(mockVideoEmbed); 230 + 231 + const result = await processPost( 232 + mockVideoPost, 233 + path.join(__dirname, "../transfer/test_videos"), 234 + mockBluesky, 235 + simulate 236 + ); 237 + 238 + expect(processVideoPost).toHaveBeenCalled(); 239 + expect(result.embeddedMedia).toEqual(mockVideoEmbed); 174 240 expect(result.mediaCount).toBe(1); 175 241 }); 176 242 });