Skip to content

Commit 2418cee

Browse files
authored
Merge pull request #437 from lockdown-systems/facebook-media
[facebook 4] import media from archive
2 parents 65adad4 + a438385 commit 2418cee

File tree

4 files changed

+190
-29
lines changed

4 files changed

+190
-29
lines changed

archive-static-sites/facebook-archive/src/components/PostComponent.vue

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ const formattedText = computed(() => {
1414
text = text.replace(/(?:\r\n|\r|\n)/g, '<br>');
1515
return text.trim();
1616
});
17+
18+
const shouldShowText = computed(() => {
19+
if (!props.post.text) return false;
20+
if (!props.post.media || props.post.media.length === 0) return true;
21+
22+
// Show text if it's different from all media descriptions
23+
return !props.post.media.some(media => media.description === props.post.text);
24+
});
1725
</script>
1826

1927
<template>
@@ -28,20 +36,41 @@ const formattedText = computed(() => {
2836
<small v-else class="text-muted">unknown date</small>
2937
</div>
3038
<div class="mt-2">
31-
<!-- Text -->
32-
<p v-html="formattedText"></p>
39+
<!-- Text (only show if it's different from media description) -->
40+
<p v-if="shouldShowText" v-html="formattedText"></p>
41+
42+
<!-- Media -->
43+
<div v-if="post.media" class="media-container mt-2">
44+
<div v-for="media in post.media" :key="media.mediaId" class="media-item mb-2">
45+
<img v-if="media.type === 'photo'"
46+
:src="`./media/${media.uri}`"
47+
:alt="media.description || ''"
48+
class="img-fluid rounded">
49+
<video v-else-if="media.type === 'video'"
50+
controls
51+
class="w-100 rounded">
52+
<source :src="`./media/${media.uri}`" type="video/mp4">
53+
Your browser does not support the video tag.
54+
</video>
55+
<p v-if="media.description" class="text-muted small mt-1">{{ media.description }}</p>
56+
</div>
57+
</div>
3358
</div>
3459
<div class="meta d-flex gap-2">
3560
<span v-if="post.archivedAt" class="date text-muted ms-2">
3661
archived {{ formattedDate(post.archivedAt) }}
3762
</span>
3863
</div>
39-
4064
</div>
4165
</template>
4266

4367
<style scoped>
44-
.meta {
45-
font-size: 0.8rem;
68+
.media-container {
69+
max-width: 100%;
70+
}
71+
.media-item img,
72+
.media-item video {
73+
max-height: 400px;
74+
object-fit: contain;
4675
}
4776
</style>

archive-static-sites/facebook-archive/src/types.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1-
export type Post = {
1+
export interface Post {
22
postID: string;
33
createdAt: string;
44
text: string;
55
title: string;
6-
archivedAt: string | null;
76
isReposted: boolean;
8-
};
7+
archivedAt: string | null;
8+
media?: {
9+
mediaId: string;
10+
type: string;
11+
uri: string;
12+
description: string | null;
13+
createdAt: string | null;
14+
}[];
15+
}
916

1017
export type FacebookArchive = {
1118
posts: Post[];

src/account_facebook/facebook_account_controller.ts

Lines changed: 124 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import {
3232
FacebookJobRow,
3333
convertFacebookJobRowToFacebookJob,
3434
FacebookArchivePost,
35+
FacebookArchiveMedia,
36+
FacebookPostWithMedia,
3537
FacebookPostRow
3638
} from './types'
3739
import * as FacebookArchiveTypes from '../../archive-static-sites/facebook-archive/src/types';
@@ -180,6 +182,22 @@ export class FacebookAccountController {
180182
`ALTER TABLE post_new RENAME TO post;`
181183
]
182184
},
185+
{
186+
name: "20250302_add_media_table",
187+
sql: [
188+
`CREATE TABLE post_media (
189+
id INTEGER PRIMARY KEY AUTOINCREMENT,
190+
mediaId TEXT NOT NULL UNIQUE,
191+
postId TEXT NOT NULL,
192+
type TEXT NOT NULL,
193+
uri TEXT NOT NULL,
194+
description TEXT,
195+
createdAt DATETIME,
196+
addedToDatabaseAt DATETIME NOT NULL,
197+
FOREIGN KEY(postId) REFERENCES post(postID)
198+
);`
199+
]
200+
},
183201
])
184202
log.info("FacebookAccountController.initDB: database initialized");
185203
}
@@ -234,29 +252,60 @@ export class FacebookAccountController {
234252
}
235253

236254
log.info("FacebookAccountController.archiveBuild: building archive");
237-
238-
// Posts
239-
const posts: FacebookPostRow[] = exec(
255+
// Posts with optional media
256+
const postsFromDb = exec(
240257
this.db,
241-
"SELECT * FROM post ORDER BY createdAt DESC",
258+
`SELECT
259+
p.*,
260+
CASE
261+
WHEN pm.mediaId IS NOT NULL
262+
THEN GROUP_CONCAT(
263+
json_object(
264+
'mediaId', pm.mediaId,
265+
'postId', pm.postId,
266+
'type', pm.type,
267+
'uri', pm.uri,
268+
'description', pm.description,
269+
'createdAt', pm.createdAt,
270+
'addedToDatabaseAt', pm.addedToDatabaseAt
271+
)
272+
)
273+
ELSE NULL
274+
END as media
275+
FROM post p
276+
LEFT JOIN post_media pm ON p.postID = pm.postId
277+
GROUP BY p.postID
278+
ORDER BY p.createdAt DESC`,
242279
[],
243280
"all"
244-
) as FacebookPostRow[];
281+
);
282+
// Transform into FacebookPostWithMedia
283+
const posts: FacebookPostWithMedia[] = (postsFromDb as Array<FacebookPostRow & { media?: string }>).map((post) => ({
284+
...post,
285+
media: post.media ? JSON.parse(`[${post.media}]`) : undefined
286+
}));
245287

246288
// Get the current account's userID
247289
// const accountUser = users.find((user) => user.screenName == this.account?.username);
248290
// const accountUserID = accountUser?.userID;
249291

250-
const postRowToArchivePost = (post: FacebookPostRow): FacebookArchiveTypes.Post => {
292+
const postRowToArchivePost = (post: FacebookPostWithMedia): FacebookArchiveTypes.Post => {
251293
const archivePost: FacebookArchiveTypes.Post = {
252294
postID: post.postID,
253295
createdAt: post.createdAt,
254296
text: post.text,
255297
title: post.title,
256298
isReposted: post.isReposted,
257299
archivedAt: post.archivedAt,
300+
media: post.media?.map(m => ({
301+
mediaId: m.mediaId,
302+
type: m.type,
303+
uri: m.uri,
304+
description: m.description,
305+
createdAt: m.createdAt
306+
}))
258307
};
259-
return archivePost
308+
return archivePost;
260309
}
261310

262311
// Build the archive object
@@ -524,30 +573,46 @@ export class FacebookAccountController {
524573
const posts = JSON.parse(postsFile);
525574

526575
for (const post of posts) {
527-
// Skip if no post text
528576
const postText = post.data?.find((d: { post?: string }) => 'post' in d && typeof d.post === 'string')?.post;
529-
if (!postText) {
530-
log.info("FacebookAccountController.importFacebookArchive: skipping post with no text");
531-
continue;
532-
}
533577

534578
// Check if it's a shared post by looking for external_context in attachments
535579
const isSharedPost = post.attachments?.[0]?.data?.[0]?.external_context !== undefined;
536580
log.info("FacebookAccountController.importFacebookArchive: isSharedPost", isSharedPost);
537581

538-
// Skip if it's a group post, shares a group, etc. We will extend the import logic
539-
// to include other data types in the future.
540-
if (post.attachments && !isSharedPost) {
541-
log.info("FacebookAccountController.importFacebookArchive: skipping unknown post type");
542-
continue;
582+
// Check if it's a share of a group post
583+
const isGroupPost = post.attachments?.[0]?.data?.[0]?.name !== undefined;
584+
const groupName = isGroupPost ? post.attachments[0].data[0].name : undefined;
585+
586+
// For group posts, if there's no explicit post text, use the group name
587+
const finalText = isGroupPost
588+
? (postText || `Shared the group: ${groupName}`)
589+
: postText;
590+
591+
// Process media attachments
592+
const media: FacebookArchiveMedia[] = [];
593+
if (post.attachments) {
594+
for (const attachment of post.attachments) {
595+
for (const data of attachment.data) {
596+
if (data.media) {
597+
media.push({
598+
uri: data.media.uri,
599+
type: data.media.uri.endsWith('.mp4') ? 'video' : 'photo',
600+
description: data.media.description,
601+
creationTimestamp: data.media.creation_timestamp
602+
});
603+
}
604+
}
605+
}
543606
}
607+
log.info("FacebookAccountController.importFacebookArchive: media", media);
544608

545609
postsData.push({
546610
id_str: post.timestamp.toString(),
547611
title: post.title || '',
548-
full_text: postText,
612+
full_text: finalText,
549613
created_at: new Date(post.timestamp * 1000).toISOString(),
550-
isReposted: isSharedPost,
614+
isReposted: isSharedPost || isGroupPost, // Group shares are reposts too
615+
media: media.length > 0 ? media : undefined,
551616
});
552617
}
553618
} catch (e) {
@@ -561,15 +626,14 @@ export class FacebookAccountController {
561626

562627
// Loop through the posts and add them to the database
563628
try {
564-
postsData.forEach((post) => {
629+
postsData.forEach(async (post) => {
565630
// Is this post already there?
566631
const existingPost = exec(this.db, 'SELECT * FROM post WHERE postID = ?', [post.id_str], "get") as FacebookPostRow;
567632
if (existingPost) {
568633
// Delete the existing post to re-import
569634
exec(this.db, 'DELETE FROM post WHERE postID = ?', [post.id_str]);
570635
}
571636

572-
// TODO: implement media import for facebook
573637
// TODO: implement urls import for facebook
574638

575639
// Import it
@@ -581,9 +645,15 @@ export class FacebookAccountController {
581645
post.isReposted ? 1 : 0,
582646
new Date(),
583647
]);
648+
649+
if (post.media && post.media.length > 0) {
650+
log.info("FacebookAccountController.importFacebookArchive: importing media for post", post.id_str);
651+
await this.importFacebookArchiveMedia(post.id_str, post.media, archivePath);
652+
}
584653
importCount++;
585654
});
586655
} catch (e) {
656+
log.error("FacebookAccountController.importFacebookArchive: error importing posts", e);
587657
return {
588658
status: "error",
589659
errorMessage: "Error importing posts: " + e,
@@ -608,4 +678,37 @@ export class FacebookAccountController {
608678
skipCount: skipCount,
609679
};
610680
}
681+
682+
async importFacebookArchiveMedia(postId: string, media: FacebookArchiveMedia[], archivePath: string): Promise<void> {
683+
for (const mediaItem of media) {
684+
const sourcePath = path.join(archivePath, mediaItem.uri);
685+
const mediaId = `${postId}_${path.basename(mediaItem.uri)}`;
686+
687+
// Create destination directory if it doesn't exist
688+
const mediaDir = path.join(this.accountDataPath, 'media');
689+
if (!fs.existsSync(mediaDir)) {
690+
fs.mkdirSync(mediaDir, { recursive: true });
691+
}
692+
693+
const destPath = path.join(mediaDir, path.basename(mediaItem.uri));
694+
try {
695+
await fs.promises.copyFile(sourcePath, destPath);
696+
697+
exec(this.db,
698+
'INSERT INTO post_media (mediaId, postId, type, uri, description, createdAt, addedToDatabaseAt) VALUES (?, ?, ?, ?, ?, ?, ?)',
699+
[
700+
mediaId,
701+
postId,
702+
mediaItem.type,
703+
path.basename(mediaItem.uri),
704+
mediaItem.description || null,
705+
mediaItem.creationTimestamp ? new Date(mediaItem.creationTimestamp * 1000) : null,
706+
new Date()
707+
]
708+
);
709+
} catch (error) {
710+
log.error(`FacebookAccountController.importFacebookArchiveMedia: Error importing media: ${error}`);
711+
}
712+
}
713+
}
611714
}

src/account_facebook/types.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,17 @@ export interface FacebookArchivePost {
3838
full_text: string;
3939
title: string;
4040
isReposted: boolean;
41+
media?: FacebookArchiveMedia[]; // Media attachments
4142
// lang: string;
4243
}
4344

45+
export interface FacebookArchiveMedia {
46+
uri: string;
47+
type: 'photo' | 'video';
48+
description?: string; // Some media items have descriptions
49+
creationTimestamp?: number; // From media.creation_timestamp
50+
}
51+
4452
export interface FacebookPostRow {
4553
id: number;
4654
username: string;
@@ -55,3 +63,17 @@ export interface FacebookPostRow {
5563
hasMedia: boolean;
5664
isReposted: boolean;
5765
}
66+
67+
export interface FacebookPostMediaRow {
68+
mediaId: string;
69+
postId: string; // Foreign key to post.postID
70+
type: string;
71+
uri: string;
72+
description: string | null;
73+
createdAt: string | null;
74+
addedToDatabaseAt: string;
75+
}
76+
77+
export interface FacebookPostWithMedia extends FacebookPostRow {
78+
media?: FacebookPostMediaRow[];
79+
}

0 commit comments

Comments
 (0)