feat: add EagleStorageProvider (#122)

Co-authored-by: Innei <tukon479@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Whitewater
2025-10-20 17:19:38 +08:00
committed by GitHub
parent b918d65fb9
commit c4448cef1e
9 changed files with 582 additions and 13 deletions

View File

@@ -202,7 +202,7 @@ export async function executePhotoProcessingPipeline(
const photoInfo = extractPhotoInfo(photoKey, exifData)
// 7. 处理 Live Photo
const livePhotoResult = processLivePhoto(photoKey, livePhotoMap)
const livePhotoResult = await processLivePhoto(photoKey, livePhotoMap)
// 8. 构建照片清单项
const aspectRatio = metadata.width / metadata.height
@@ -213,7 +213,7 @@ export async function executePhotoProcessingPipeline(
description: photoInfo.description,
dateTaken: photoInfo.dateTaken,
tags: photoInfo.tags,
originalUrl: defaultBuilder
originalUrl: await defaultBuilder
.getStorageManager()
.generatePublicUrl(photoKey),
thumbnailUrl: thumbnailResult.thumbnailUrl,

View File

@@ -16,10 +16,10 @@ export interface LivePhotoResult {
* @param livePhotoMap Live Photo 映射表
* @returns Live Photo 处理结果
*/
export function processLivePhoto(
export async function processLivePhoto(
photoKey: string,
livePhotoMap: Map<string, _Object | StorageObject>,
): LivePhotoResult {
): Promise<LivePhotoResult> {
const loggers = getGlobalLoggers()
const livePhotoVideo = livePhotoMap.get(photoKey)
const isLivePhoto = !!livePhotoVideo
@@ -40,7 +40,7 @@ export function processLivePhoto(
return { isLivePhoto: false }
}
const livePhotoVideoUrl = defaultBuilder
const livePhotoVideoUrl = await defaultBuilder
.getStorageManager()
.generatePublicUrl(videoKey)

View File

@@ -1,4 +1,5 @@
import type { StorageConfig, StorageProvider } from './interfaces'
import { EagleStorageProvider } from './providers/eagle-provider.js'
import { GitHubStorageProvider } from './providers/github-provider.js'
import { LocalStorageProvider } from './providers/local-provider.js'
import { S3StorageProvider } from './providers/s3-provider.js'
@@ -17,6 +18,9 @@ export class StorageFactory {
case 'github': {
return new GitHubStorageProvider(config)
}
case 'eagle': {
return new EagleStorageProvider(config)
}
case 'local': {
return new LocalStorageProvider(config)
}

View File

@@ -14,6 +14,7 @@ export { StorageFactory } from './factory.js'
export { StorageManager } from './manager.js'
// 导出具体提供商(如果需要直接使用)
export { EagleStorageProvider } from './providers/eagle-provider.js'
export { GitHubStorageProvider } from './providers/github-provider.js'
export { LocalStorageProvider } from './providers/local-provider.js'
export { S3StorageProvider } from './providers/s3-provider.js'

View File

@@ -48,7 +48,7 @@ export interface StorageProvider {
* @param key 文件的键值/路径
* @returns 公共访问 URL
*/
generatePublicUrl: (key: string) => string
generatePublicUrl: (key: string) => string | Promise<string>
/**
* 检测 Live Photos 配对
@@ -101,4 +101,50 @@ export type LocalConfig = {
maxFileLimit?: number
}
export type StorageConfig = S3Config | GitHubConfig | LocalConfig
export type EagleRule =
| {
type: 'tag'
name: string
}
| {
type: 'folder'
/**
* Only a folder name, not the full path.
*/
name: string
/**
* Defaults to `false`.
*/
includeSubfolder?: boolean
}
| {
/**
* Smart folders are not yet supported.
*/
type: 'smartFolder'
}
export type EagleConfig = {
provider: 'eagle'
/**
* The path to the Eagle library.
*/
libraryPath: string
/**
* The path where original files need to be stored.
* The original files will be copied to this path during the build process.
*
* Defaults to `web/public/originals/`
*/
distPath?: string
/**
* The base URL to access the original files.
*
* Defaults to `/originals/`
*/
baseUrl?: string
include?: EagleRule[]
exclude?: EagleRule[]
}
export type StorageConfig = S3Config | GitHubConfig | EagleConfig | LocalConfig

View File

@@ -44,7 +44,7 @@ export class StorageManager {
* @param key 文件的键值/路径
* @returns 公共访问 URL
*/
generatePublicUrl(key: string): string {
async generatePublicUrl(key: string): Promise<string> {
return this.provider.generatePublicUrl(key)
}

View File

@@ -270,4 +270,68 @@ photos/
| 适用场景 | 生产环境 | 小型项目、演示 |
| 设置复杂度 | 中等 | 简单 |
选择存储提供商时,请根据你的具体需求和预算进行选择。
选择存储提供商时,请根据你的具体需求和预算进行选择。
## Eagle 存储提供商
直接读取 Eagle 桌面应用生成的素材库,并在构建时将所需图片复制到指定目录。
### 特点
- ✅ 方便管理:直接在已有 Eagle 素材库中管理图片
- ✅ 多条件筛选:支持按文件夹(含子文件夹)或标签过滤
- ✅ 自动复制:首次访问时自动复制原图到发布目录
- ⚠️ 需自行托管筛选出的图片文件
### 配置示例
```typescript
import type { EagleConfig } from '@afilmory/builder'
const eagleConfig: EagleConfig = {
provider: 'eagle',
libraryPath: '/Users/alice/Pictures/Eagle.library',
distPath: '/Users/alice/workspaces/afilmory/apps/web/public/originals',
baseUrl: '/originals/',
include: [
{ type: 'folder', name: '精选', includeSubfolder: true },
{ type: 'tag', name: 'Published' },
],
exclude: [{ type: 'tag', name: 'Private' }],
}
```
### 设置步骤
1. **定位 Eagle 素材库**:找到 Eagle 「库位置」,复制完整路径。
2. **准备 distPath**:指定一个绝对路径,默认为 `apps/web/public/originals` 或自定义静态资源目录。
3. **调整访问 URL**:设置 `baseUrl` 以匹配前端访问路径(默认 `/originals/`)。
4. **定义筛选规则(可选)**:通过 `include` 与 `exclude` 精确控制导出范围。
### 规则说明
- `include` 为空时表示包含库内所有支持格式的图片。
- `exclude` 优先级高于 `include`,命中后即排除。
- `folder` 规则的 `name` 仅填写文件夹名称;勾选 `includeSubfolder` 可递归包含子目录。
- `tag` 规则将与 Eagle 标签匹配,大小写敏感。
### 使用示例
```typescript
import { EagleStorageProvider } from '@/core/storage'
const provider = new EagleStorageProvider(eagleConfig)
const files = await provider.listImages()
const buffer = await provider.getFile(files[0].key)
const publicUrl = await provider.generatePublicUrl(files[0].key)
```
首次生成公共 URL 时,提供商会将原图复制到 `distPath`,后续则复用已存在文件。
### 常见问题
- **路径错误**`libraryPath` 或 `distPath` 不是绝对路径会导致实例化失败。
- **版本兼容**:若检测到非 4.x 版本,会输出告警,建议升级 Eagle。
- **不支持的格式**:仅处理 `SUPPORTED_FORMATS` 中列出的扩展名(与其它提供商一致)。
- **权限限制**:确保进程具备对素材库与目标目录的读写权限。

View File

@@ -0,0 +1,410 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { SUPPORTED_FORMATS } from '@afilmory/builder/constants/index.js'
import { workdir } from '@afilmory/builder/path.js'
import type { Logger } from '../../logger/index.js'
import { logger } from '../../logger/index.js'
import type {
EagleConfig,
EagleRule,
StorageObject,
StorageProvider,
} from '../interfaces.js'
const EAGLE_VERSION = '4.0.0'
interface EagleFolderNode {
id: string
name: string
description: string
children?: EagleFolderNode[]
modificationTime: number
tags: string[]
password: string
passwordTips: string
/**
* For smart folders
*/
conditions?: unknown[]
}
interface EagleLibraryMetadata {
folders?: EagleFolderNode[]
smartFolders?: unknown[]
quickAccess: unknown[]
tagsGroups: unknown[]
modificationTime: number
applicationVersion: '4.0.0'
}
interface EagleImageMetadata {
id: string
name: string
size: number
btime: number
mtime: number
ext: string
tags: string[]
folders: string[]
isDeleted: boolean
url: string
annotation: string
modificationTime: number
star?: number
height: number
width: number
noThumbnail: boolean
palettes: unknown[]
lastModified: number
}
const defaultEagleConfig = {
provider: 'eagle',
libraryPath: '',
distPath: path.join(workdir, 'public', 'originals'),
baseUrl: '/originals/',
include: [],
exclude: [],
} satisfies Required<EagleConfig>
export class EagleStorageProvider implements StorageProvider {
private readonly config: Required<EagleConfig>
/**
* Folder index map: folder ID -> folder path array
*/
private folderIndex = new Map<string, string[]>()
constructor(userConfig: EagleConfig) {
if (!userConfig.libraryPath || userConfig.libraryPath.trim() === '') {
throw new Error('EagleStorageProvider: libraryPath 不能为空')
}
if (!path.isAbsolute(userConfig.libraryPath)) {
throw new Error(
`EagleStorageProvider: libraryPath 必须是绝对路径. libraryPath: ${userConfig.libraryPath}`,
)
}
if (userConfig.distPath && !path.isAbsolute(userConfig.distPath)) {
throw new Error(
`EagleStorageProvider: distPath 必须是绝对路径. distPath: ${userConfig.distPath}`,
)
}
this.config = {
...defaultEagleConfig,
...userConfig,
libraryPath: path.resolve(userConfig.libraryPath),
distPath: path.resolve(
userConfig.distPath ?? defaultEagleConfig.distPath,
),
}
}
initialized = false
async initialize(): Promise<void> {
if (this.initialized) return
this.initialized = true
try {
await validateEagleLibrary(this.config.libraryPath)
} catch (error) {
if (error instanceof Error) {
logger.main.error(
`EagleStorageProvider: libraryPath 不是有效的 Eagle 库:${error.message}`,
)
}
throw error
}
if (
!(await fs
.stat(this.config.distPath)
.then((res) => res.isDirectory())
.catch(() => false))
) {
await fs.mkdir(this.config.distPath, { recursive: true })
logger.main.info(
`EagleStorageProvider: 已创建 distPath 目录:${this.config.distPath}`,
)
}
const libraryMetadata = await readEagleLibraryMetadata(
this.config.libraryPath,
)
logger.main.info(
`EagleStorageProvider: 检测到 Eagle 版本:${libraryMetadata.applicationVersion}`,
)
if (
Number(libraryMetadata.applicationVersion.at(0)) !==
Number(EAGLE_VERSION.at(0))
) {
logger.main.warn(
`EagleStorageProvider: 当前支持 Eagle ${EAGLE_VERSION} 版本的库,检测到的版本为:${libraryMetadata.applicationVersion},可能会导致兼容性问题。`,
)
}
this.folderIndex = buildFolderIndexes(libraryMetadata.folders ?? [])
}
async getFile(key: string, logger?: Logger['s3']): Promise<Buffer | null> {
await this.initialize()
const imageInfoPath = path.resolve(
this.config.libraryPath,
'images',
`${key}.info`,
)
const infoStats = await fs.stat(imageInfoPath)
if (!infoStats.isDirectory()) {
logger?.error?.(
`EagleStorageProvider: 请求的文件路径不安全。key: ${key}, 路径: ${imageInfoPath}`,
)
return null
}
const imageMetadata: EagleImageMetadata = await readImageMetadata(
this.config.libraryPath,
key,
)
if (!SUPPORTED_FORMATS.has(`.${imageMetadata.ext.toLowerCase()}`)) {
logger?.error?.(
`EagleStorageProvider: 不支持的图片格式。key: ${key}, 格式: .${imageMetadata.ext}`,
)
return null
}
const imageFileName = `${imageMetadata.name}.${imageMetadata.ext}`
const imageFilePath = path.join(imageInfoPath, imageFileName)
try {
const buffer = await fs.readFile(imageFilePath)
return buffer
} catch (error) {
logger?.error?.(
`EagleStorageProvider: 读取图片文件失败。key: ${key}, 路径: ${imageFilePath}, 错误: ${error}`,
)
return null
}
}
async listImages() {
return this.listAllFiles()
}
async listAllFiles(): Promise<StorageObject[]> {
await this.initialize()
const imagesDir = path.join(this.config.libraryPath, 'images')
const imageEntries = await fs.readdir(imagesDir, { withFileTypes: true })
const keys = imageEntries
// Filter out .DS_Store
.filter((entry) => entry.isDirectory() && entry.name.endsWith('.info'))
.map((entry) => {
return entry.name.replace(/\.info$/, '')
})
const filtered: StorageObject[] = []
await Promise.all(
keys.map(async (key) => {
const meta = await readImageMetadata(this.config.libraryPath, key)
const include =
this.config.include.length === 0
? true
: this.runPredicate(meta, this.config.include)
const exclude =
this.config.exclude.length === 0
? false
: this.runPredicate(meta, this.config.exclude)
const supportedFormat = SUPPORTED_FORMATS.has(
`.${meta.ext.toLowerCase()}`,
)
if (include && !exclude && supportedFormat) {
filtered.push({
key,
size: meta.size,
lastModified: new Date(meta.lastModified),
})
}
}),
)
return filtered
}
async generatePublicUrl(key: string) {
const imageName = await this.copyToDist(key)
const publicPath = path.join(this.config.baseUrl, imageName)
return publicPath
}
detectLivePhotos(_allObjects: StorageObject[]): Map<string, StorageObject> {
// TODO
return new Map()
}
private async copyToDist(key: string) {
const imageMeta = await readImageMetadata(this.config.libraryPath, key)
const imageName = `${imageMeta.name}.${imageMeta.ext}`
const sourceImage = path.join(
this.config.libraryPath,
'images',
`${key}.info`,
imageName,
)
const distName = `${key}.${imageMeta.ext}`
const distFile = path.join(this.config.distPath, distName)
if (
await fs
.stat(distFile)
.then(() => true)
.catch(() => false)
) {
// 文件已存在,跳过复制
logger.main.log(
`EagleStorageProvider: 发布目录已存在文件,跳过复制: ${imageName} -> ${distFile}`,
)
return imageName
}
await fs.copyFile(sourceImage, distFile)
logger.main.log(
`EagleStorageProvider: 已复制文件到发布目录: ${imageName} -> ${distFile}`,
)
return distName
}
/**
* Returns `true` if the image matches any of the given rules.
*/
private runPredicate(
imageMeta: EagleImageMetadata,
rules: EagleRule[],
): boolean {
if (rules.length === 0) {
return true
}
return rules.some((condition) => {
switch (condition.type) {
case 'tag': {
return imageMeta.tags.includes(condition.name)
}
case 'folder': {
const includeSubfolder = !!condition.includeSubfolder
for (const folderId of imageMeta.folders) {
const folderPath = this.folderIndex.get(folderId)
if (!folderPath) {
logger.main.warn(
`EagleStorageProvider: 无法找到文件夹索引跳过该文件夹过滤条件。folderId: ${folderId}`,
)
continue
}
if (includeSubfolder) {
if (folderPath.includes(condition.name)) {
return true
}
continue
} else {
if (folderPath.at(-1) === condition.name) {
return true
}
continue
}
}
return false
}
case 'smartFolder': {
// Not supported yet
return false
}
default: {
return false
}
}
})
}
}
async function validateEagleLibrary(libraryPath: string): Promise<void> {
// Check for directory existence
try {
const stats = await fs.stat(libraryPath)
if (!stats.isDirectory()) {
throw new Error(
`EagleStorageProvider: 指定的 libraryPath 不是目录。libraryPath: ${libraryPath}`,
)
}
} catch (error) {
throw new Error(
`EagleStorageProvider: 无法访问指定的 libraryPath: ${libraryPath} - ${error}`,
)
}
// Check for metadata.json existence
try {
const metadataPath = path.join(libraryPath, 'metadata.json')
await fs.access(metadataPath)
} catch (error) {
throw new Error(
`EagleStorageProvider: library metadata.json 不存在:${libraryPath} - ${error}`,
)
}
// Check for images directory existence
try {
const imagesDir = path.join(libraryPath, 'images')
const stats = await fs.stat(imagesDir)
if (!stats.isDirectory()) {
throw new Error(
`EagleStorageProvider: images 不是目录。libraryPath: ${imagesDir}`,
)
}
} catch (error) {
throw new Error(
`EagleStorageProvider: 无法访问指定的 images 目录: ${libraryPath} - ${error}`,
)
}
}
async function readEagleLibraryMetadata(
libraryPath: string,
): Promise<EagleLibraryMetadata> {
const metadataPath = path.join(libraryPath, 'metadata.json')
try {
const content = await fs.readFile(metadataPath, 'utf-8')
return JSON.parse(content) as EagleLibraryMetadata
} catch (error) {
const errorMsg =
error instanceof Error ? `${error.name}: ${error.message}` : String(error)
throw new Error(
`EagleStorageProvider: 无法解析 library metadata${errorMsg}`,
)
}
}
async function readImageMetadata(
libraryPath: string,
key: string,
): Promise<EagleImageMetadata> {
const metadataPath = path.join(
libraryPath,
'images',
`${key}.info`,
'metadata.json',
)
const content = await fs.readFile(metadataPath, 'utf-8')
return JSON.parse(content) as EagleImageMetadata
}
function buildFolderIndexes(folders: EagleFolderNode[]) {
const map = new Map<string, string[]>()
const traverse = (node: EagleFolderNode, parent: string[]) => {
const path = [...parent, node.name]
map.set(node.id, path)
const children = node.children ?? []
for (const child of children) {
traverse(child, path)
}
}
for (const folder of folders) {
traverse(folder, [])
}
return map
}

View File

@@ -1,13 +1,13 @@
---
title: Storage providers
description: Afilmory can work with multiple storage providers, including S3, Git and local file system
description: Afilmory can work with multiple storage providers, including S3, Git, Eagle, and local file system
createdAt: 2025-08-12T15:09:08+08:00
lastModified: 2025-08-31T11:08:35+08:00
lastModified: 2025-10-20T01:42:38+08:00
---
# Storage Providers
Afilmory's flexible storage architecture allows you to store your photos across different platforms. The photo processing engine (`@afilmory/builder`) abstracts storage operations through a unified interface, making it easy to switch between providers.
Afilmory's flexible storage architecture allows you to store your photos across different platforms. The photo processing engine (`@afilmory/builder`) abstracts storage operations through a unified interface, making it easy to switch between providers such as S3, GitHub, Eagle, and the local file system.
## Supported Providers
@@ -89,6 +89,50 @@ GIT_TOKEN=ghp_your_github_personal_access_token
- `Contents: Read and write` - for uploading processed photos
- `Metadata: Read` - for repository access
### Eagle Storage
Eagle storage integrates directly with an existing Eagle 4 desktop library, making it ideal for teams that already curate assets in Eagle and want to publish selected photos without additional uploads.
**Features:**
- ✅ No upload step: reads directly from the Eagle library
- ✅ Flexible filtering: include or exclude by folder (with optional subfolders) and tags
- ✅ Automatic publishing: copies originals to a public directory on demand
- ⚠️ Requires absolute paths for `libraryPath` and `distPath`
- ⚠️ Only Eagle 4.x is validated; other versions may require testing
**Configuration in `builder.config.json`:**
```json
{
"storage": {
"provider": "eagle",
"libraryPath": "/Users/alice/Pictures/Eagle.library",
"distPath": "/Users/alice/workspaces/afilmory/apps/web/public/originals",
"baseUrl": "/originals/",
"include": [
{ "type": "folder", "name": "Published", "includeSubfolder": true },
{ "type": "tag", "name": "Featured" }
],
"exclude": [
{ "type": "tag", "name": "Private" }
]
}
}
```
**Setup Steps:**
1. **Locate the Eagle library**: copy the absolute path shown in Eagle → Preferences → Library.
2. **Choose a distribution path (optional)**: The original images will be copied to `distPath` during the build process (defaults to `<workdir>/public/originals`).
3. **Align URLs**: set `baseUrl` to match how the frontend serves `distPath` (defaults to `/originals/`).
4. **Refine filters (optional)**: use `include` and `exclude` rules to control which assets are exported.
**Rule Notes:**
- Leaving `include` empty processes every supported image in the library.
- `exclude` overrides inclusion rules when both match a single asset.
- `folder` rules use folder names (not full paths); enabling `includeSubfolder` recursively matches children.
- `tag` rules are case-sensitive and match Eagle tags.
### Local File System
Local storage is suitable for development, testing, or self-hosted deployments where photos are stored on the same server.
@@ -256,7 +300,7 @@ Store sensitive credentials securely:
S3_ACCESS_KEY_ID=prod_access_key
S3_SECRET_ACCESS_KEY=prod_secret_key
# .env.development
# .env.development
S3_ACCESS_KEY_ID=dev_access_key
S3_SECRET_ACCESS_KEY=dev_secret_key
```