feat(sns-export): split media export selection into image/live/video

This commit is contained in:
tisonhuang
2026-03-04 13:22:46 +08:00
parent c5eed25f06
commit 6314c0f1d6
5 changed files with 188 additions and 43 deletions

View File

@@ -1085,13 +1085,30 @@ class SnsService {
usernames?: string[] usernames?: string[]
keyword?: string keyword?: string
exportMedia?: boolean exportMedia?: boolean
exportImages?: boolean
exportLivePhotos?: boolean
exportVideos?: boolean
startTime?: number startTime?: number
endTime?: number endTime?: number
}, progressCallback?: (progress: { current: number; total: number; status: string }) => void, control?: { }, progressCallback?: (progress: { current: number; total: number; status: string }) => void, control?: {
shouldPause?: () => boolean shouldPause?: () => boolean
shouldStop?: () => boolean shouldStop?: () => boolean
}): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }> { }): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }> {
const { outputDir, format, usernames, keyword, exportMedia = false, startTime, endTime } = options const { outputDir, format, usernames, keyword, startTime, endTime } = options
const hasExplicitMediaSelection =
typeof options.exportImages === 'boolean' ||
typeof options.exportLivePhotos === 'boolean' ||
typeof options.exportVideos === 'boolean'
const shouldExportImages = hasExplicitMediaSelection
? options.exportImages === true
: options.exportMedia === true
const shouldExportLivePhotos = hasExplicitMediaSelection
? options.exportLivePhotos === true
: options.exportMedia === true
const shouldExportVideos = hasExplicitMediaSelection
? options.exportVideos === true
: options.exportMedia === true
const shouldExportMedia = shouldExportImages || shouldExportLivePhotos || shouldExportVideos
const getControlState = (): 'paused' | 'stopped' | null => { const getControlState = (): 'paused' | 'stopped' | null => {
if (control?.shouldStop?.()) return 'stopped' if (control?.shouldStop?.()) return 'stopped'
if (control?.shouldPause?.()) return 'paused' if (control?.shouldPause?.()) return 'paused'
@@ -1149,15 +1166,54 @@ class SnsService {
let mediaCount = 0 let mediaCount = 0
const mediaDir = join(outputDir, 'media') const mediaDir = join(outputDir, 'media')
if (exportMedia) { if (shouldExportMedia) {
if (!existsSync(mediaDir)) { if (!existsSync(mediaDir)) {
mkdirSync(mediaDir, { recursive: true }) mkdirSync(mediaDir, { recursive: true })
} }
// 收集所有媒体下载任务 // 收集所有媒体下载任务
const mediaTasks: { media: SnsMedia; postId: string; mi: number }[] = [] const mediaTasks: Array<{
kind: 'image' | 'video' | 'livephoto'
media: SnsMedia
url: string
key?: string
postId: string
mi: number
}> = []
for (const post of allPosts) { for (const post of allPosts) {
post.media.forEach((media, mi) => mediaTasks.push({ media, postId: post.id, mi })) post.media.forEach((media, mi) => {
const isVideo = isVideoUrl(media.url)
if (shouldExportImages && !isVideo && media.url) {
mediaTasks.push({
kind: 'image',
media,
url: media.url,
key: media.key,
postId: post.id,
mi
})
}
if (shouldExportVideos && isVideo && media.url) {
mediaTasks.push({
kind: 'video',
media,
url: media.url,
key: media.key,
postId: post.id,
mi
})
}
if (shouldExportLivePhotos && media.livePhoto?.url) {
mediaTasks.push({
kind: 'livephoto',
media,
url: media.livePhoto.url,
key: media.livePhoto.key || media.key,
postId: post.id,
mi
})
}
})
} }
// 并发下载5路 // 并发下载5路
@@ -1166,29 +1222,42 @@ class SnsService {
const runTask = async (task: typeof mediaTasks[0]) => { const runTask = async (task: typeof mediaTasks[0]) => {
const { media, postId, mi } = task const { media, postId, mi } = task
try { try {
const isVideo = isVideoUrl(media.url) const isVideo = task.kind === 'video' || task.kind === 'livephoto' || isVideoUrl(task.url)
const ext = isVideo ? 'mp4' : 'jpg' const ext = isVideo ? 'mp4' : 'jpg'
const fileName = `${postId}_${mi}.${ext}` const suffix = task.kind === 'livephoto' ? '_live' : ''
const fileName = `${postId}_${mi}${suffix}.${ext}`
const filePath = join(mediaDir, fileName) const filePath = join(mediaDir, fileName)
if (existsSync(filePath)) { if (existsSync(filePath)) {
;(media as any).localPath = `media/${fileName}` if (task.kind === 'livephoto') {
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
} else {
;(media as any).localPath = `media/${fileName}`
}
mediaCount++ mediaCount++
} else { } else {
const result = await this.fetchAndDecryptImage(media.url, media.key) const result = await this.fetchAndDecryptImage(task.url, task.key)
if (result.success && result.data) { if (result.success && result.data) {
await writeFile(filePath, result.data) await writeFile(filePath, result.data)
;(media as any).localPath = `media/${fileName}` if (task.kind === 'livephoto') {
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
} else {
;(media as any).localPath = `media/${fileName}`
}
mediaCount++ mediaCount++
} else if (result.success && result.cachePath) { } else if (result.success && result.cachePath) {
const cachedData = await readFile(result.cachePath) const cachedData = await readFile(result.cachePath)
await writeFile(filePath, cachedData) await writeFile(filePath, cachedData)
;(media as any).localPath = `media/${fileName}` if (task.kind === 'livephoto') {
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
} else {
;(media as any).localPath = `media/${fileName}`
}
mediaCount++ mediaCount++
} }
} }
} catch (e) { } catch (e) {
console.warn(`[SnsExport] 媒体下载失败: ${task.media.url}`, e) console.warn(`[SnsExport] 媒体下载失败: ${task.url}`, e)
} }
done++ done++
progressCallback?.({ current: done, total: mediaTasks.length, status: `正在下载媒体 (${done}/${mediaTasks.length})...` }) progressCallback?.({ current: done, total: mediaTasks.length, status: `正在下载媒体 (${done}/${mediaTasks.length})...` })
@@ -1323,7 +1392,12 @@ class SnsService {
media: post.media.map(m => ({ media: post.media.map(m => ({
url: m.url, url: m.url,
thumb: m.thumb, thumb: m.thumb,
localPath: (m as any).localPath || undefined localPath: (m as any).localPath || undefined,
livePhoto: m.livePhoto ? {
url: m.livePhoto.url,
thumb: m.livePhoto.thumb,
localPath: (m.livePhoto as any).localPath || undefined
} : undefined
})), })),
likes: post.likes, likes: post.likes,
comments: post.comments, comments: post.comments,
@@ -1343,6 +1417,11 @@ class SnsService {
exportTime: new Date().toISOString(), exportTime: new Date().toISOString(),
format: 'arkmejson', format: 'arkmejson',
schemaVersion: '1.0.0', schemaVersion: '1.0.0',
mediaSelection: {
images: shouldExportImages,
livePhotos: shouldExportLivePhotos,
videos: shouldExportVideos
},
totalPosts: allPosts.length, totalPosts: allPosts.length,
filters: { filters: {
usernames: usernames || [], usernames: usernames || [],

View File

@@ -112,7 +112,9 @@ interface ExportTaskPayload {
sessionNames: string[] sessionNames: string[]
snsOptions?: { snsOptions?: {
format: SnsTimelineExportFormat format: SnsTimelineExportFormat
exportMedia?: boolean exportImages?: boolean
exportLivePhotos?: boolean
exportVideos?: boolean
startTime?: number startTime?: number
endTime?: number endTime?: number
} }
@@ -878,6 +880,9 @@ function ExportPage() {
const [exportFolder, setExportFolder] = useState('') const [exportFolder, setExportFolder] = useState('')
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('A') const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('A')
const [snsExportFormat, setSnsExportFormat] = useState<SnsTimelineExportFormat>('html') const [snsExportFormat, setSnsExportFormat] = useState<SnsTimelineExportFormat>('html')
const [snsExportImages, setSnsExportImages] = useState(false)
const [snsExportLivePhotos, setSnsExportLivePhotos] = useState(false)
const [snsExportVideos, setSnsExportVideos] = useState(false)
const [options, setOptions] = useState<ExportOptions>({ const [options, setOptions] = useState<ExportOptions>({
format: 'arkme-json', format: 'arkme-json',
@@ -2038,7 +2043,6 @@ function ExportPage() {
const buildSnsExportOptions = () => { const buildSnsExportOptions = () => {
const format: SnsTimelineExportFormat = snsExportFormat const format: SnsTimelineExportFormat = snsExportFormat
const exportMediaEnabled = Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
const dateRange = options.useAllTime const dateRange = options.useAllTime
? null ? null
: options.dateRange : options.dateRange
@@ -2050,7 +2054,9 @@ function ExportPage() {
return { return {
format, format,
exportMedia: exportMediaEnabled, exportImages: snsExportImages,
exportLivePhotos: snsExportLivePhotos,
exportVideos: snsExportVideos,
startTime: dateRange?.startTime, startTime: dateRange?.startTime,
endTime: dateRange?.endTime endTime: dateRange?.endTime
} }
@@ -2159,11 +2165,13 @@ function ExportPage() {
try { try {
if (next.payload.scope === 'sns') { if (next.payload.scope === 'sns') {
const snsOptions = next.payload.snsOptions || { format: 'html' as SnsTimelineExportFormat, exportMedia: false } const snsOptions = next.payload.snsOptions || { format: 'html' as SnsTimelineExportFormat, exportImages: false, exportLivePhotos: false, exportVideos: false }
const result = await window.electronAPI.sns.exportTimeline({ const result = await window.electronAPI.sns.exportTimeline({
outputDir: next.payload.outputDir, outputDir: next.payload.outputDir,
format: snsOptions.format, format: snsOptions.format,
exportMedia: snsOptions.exportMedia, exportImages: snsOptions.exportImages,
exportLivePhotos: snsOptions.exportLivePhotos,
exportVideos: snsOptions.exportVideos,
startTime: snsOptions.startTime, startTime: snsOptions.startTime,
endTime: snsOptions.endTime, endTime: snsOptions.endTime,
taskId: next.id taskId: next.id
@@ -4414,15 +4422,28 @@ function ExportPage() {
{shouldShowMediaSection && ( {shouldShowMediaSection && (
<div className="dialog-section"> <div className="dialog-section">
<h4></h4> <h4>{exportDialog.scope === 'sns' ? '媒体文件(可多选)' : '媒体与头像'}</h4>
<div className="media-check-grid"> <div className="media-check-grid">
<label><input type="checkbox" checked={options.exportImages} onChange={event => setOptions(prev => ({ ...prev, exportImages: event.target.checked }))} /> </label> {exportDialog.scope === 'sns' ? (
<label><input type="checkbox" checked={options.exportVoices} onChange={event => setOptions(prev => ({ ...prev, exportVoices: event.target.checked }))} /> </label> <>
<label><input type="checkbox" checked={options.exportVideos} onChange={event => setOptions(prev => ({ ...prev, exportVideos: event.target.checked }))} /> </label> <label><input type="checkbox" checked={snsExportImages} onChange={event => setSnsExportImages(event.target.checked)} /> </label>
<label><input type="checkbox" checked={options.exportEmojis} onChange={event => setOptions(prev => ({ ...prev, exportEmojis: event.target.checked }))} /> </label> <label><input type="checkbox" checked={snsExportLivePhotos} onChange={event => setSnsExportLivePhotos(event.target.checked)} /> </label>
<label><input type="checkbox" checked={options.exportVoiceAsText} onChange={event => setOptions(prev => ({ ...prev, exportVoiceAsText: event.target.checked }))} /> </label> <label><input type="checkbox" checked={snsExportVideos} onChange={event => setSnsExportVideos(event.target.checked)} /> </label>
<label><input type="checkbox" checked={options.exportAvatars} onChange={event => setOptions(prev => ({ ...prev, exportAvatars: event.target.checked }))} /> </label> </>
) : (
<>
<label><input type="checkbox" checked={options.exportImages} onChange={event => setOptions(prev => ({ ...prev, exportImages: event.target.checked }))} /> </label>
<label><input type="checkbox" checked={options.exportVoices} onChange={event => setOptions(prev => ({ ...prev, exportVoices: event.target.checked }))} /> </label>
<label><input type="checkbox" checked={options.exportVideos} onChange={event => setOptions(prev => ({ ...prev, exportVideos: event.target.checked }))} /> </label>
<label><input type="checkbox" checked={options.exportEmojis} onChange={event => setOptions(prev => ({ ...prev, exportEmojis: event.target.checked }))} /> </label>
<label><input type="checkbox" checked={options.exportVoiceAsText} onChange={event => setOptions(prev => ({ ...prev, exportVoiceAsText: event.target.checked }))} /> </label>
<label><input type="checkbox" checked={options.exportAvatars} onChange={event => setOptions(prev => ({ ...prev, exportAvatars: event.target.checked }))} /> </label>
</>
)}
</div> </div>
{exportDialog.scope === 'sns' && (
<div className="format-note"></div>
)}
</div> </div>
)} )}

View File

@@ -1984,10 +1984,31 @@
font-size: 12px; font-size: 12px;
color: var(--text-tertiary); color: var(--text-tertiary);
margin: 0; margin: 0;
padding-left: 24px;
line-height: 1.4; line-height: 1.4;
} }
.export-media-check-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 8px;
label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-primary);
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
}
input[type='checkbox'] {
margin: 0;
}
}
.export-progress { .export-progress {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -62,7 +62,9 @@ export default function SnsPage() {
const [showExportDialog, setShowExportDialog] = useState(false) const [showExportDialog, setShowExportDialog] = useState(false)
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html') const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html')
const [exportFolder, setExportFolder] = useState('') const [exportFolder, setExportFolder] = useState('')
const [exportMedia, setExportMedia] = useState(false) const [exportImages, setExportImages] = useState(false)
const [exportLivePhotos, setExportLivePhotos] = useState(false)
const [exportVideos, setExportVideos] = useState(false)
const [exportDateRange, setExportDateRange] = useState<{ start: string; end: string }>({ start: '', end: '' }) const [exportDateRange, setExportDateRange] = useState<{ start: string; end: string }>({ start: '', end: '' })
const [isExporting, setIsExporting] = useState(false) const [isExporting, setIsExporting] = useState(false)
const [exportProgress, setExportProgress] = useState<{ current: number; total: number; status: string } | null>(null) const [exportProgress, setExportProgress] = useState<{ current: number; total: number; status: string } | null>(null)
@@ -950,22 +952,40 @@ export default function SnsPage() {
{/* 媒体导出 */} {/* 媒体导出 */}
<div className="export-section"> <div className="export-section">
<div className="export-toggle-row"> <label className="export-label">
<div className="toggle-label"> <Image size={14} />
<Image size={16} />
<span>/</span> </label>
</div> <div className="export-media-check-grid">
<button <label>
className={`toggle-switch${exportMedia ? ' active' : ''}`} <input
onClick={() => !isExporting && setExportMedia(!exportMedia)} type="checkbox"
disabled={isExporting} checked={exportImages}
> onChange={(e) => setExportImages(e.target.checked)}
<span className="toggle-knob" /> disabled={isExporting}
</button> />
</label>
<label>
<input
type="checkbox"
checked={exportLivePhotos}
onChange={(e) => setExportLivePhotos(e.target.checked)}
disabled={isExporting}
/>
</label>
<label>
<input
type="checkbox"
checked={exportVideos}
onChange={(e) => setExportVideos(e.target.checked)}
disabled={isExporting}
/>
</label>
</div> </div>
{exportMedia && ( <p className="export-media-hint"></p>
<p className="export-media-hint"> media </p>
)}
</div> </div>
{/* 同步提示 */} {/* 同步提示 */}
@@ -1015,7 +1035,9 @@ export default function SnsPage() {
format: exportFormat, format: exportFormat,
usernames: selectedUsernames.length > 0 ? selectedUsernames : undefined, usernames: selectedUsernames.length > 0 ? selectedUsernames : undefined,
keyword: searchKeyword || undefined, keyword: searchKeyword || undefined,
exportMedia, exportImages,
exportLivePhotos,
exportVideos,
startTime: exportDateRange.start ? Math.floor(new Date(exportDateRange.start).getTime() / 1000) : undefined, startTime: exportDateRange.start ? Math.floor(new Date(exportDateRange.start).getTime() / 1000) : undefined,
endTime: exportDateRange.end ? Math.floor(new Date(exportDateRange.end + 'T23:59:59').getTime() / 1000) : undefined endTime: exportDateRange.end ? Math.floor(new Date(exportDateRange.end + 'T23:59:59').getTime() / 1000) : undefined
}) })

View File

@@ -719,7 +719,9 @@ export interface ElectronAPI {
format: 'json' | 'html' | 'arkmejson' format: 'json' | 'html' | 'arkmejson'
usernames?: string[] usernames?: string[]
keyword?: string keyword?: string
exportMedia?: boolean exportImages?: boolean
exportLivePhotos?: boolean
exportVideos?: boolean
startTime?: number startTime?: number
endTime?: number endTime?: number
taskId?: string taskId?: string