Skip to content

Commit 40e4ec7

Browse files
utofclaude
andcommitted
feat(core): MediaMetadata + MetadataExtractor + MetadataRepository ports
Adds perima-core metadata module. MediaMetadata is a framework-free value type; MetadataExtractor trait dispatches by MIME (not first-non-empty, per spec); MetadataRepository uses &self with interior-mutability semantics (consistent with actual desktop usage pattern). FileRepository's legacy &mut self is acknowledged and deferred to v0.5.x as a fast-follow (#13). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3678483 commit 40e4ec7

4 files changed

Lines changed: 129 additions & 1 deletion

File tree

crates/core/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,19 @@
55
pub mod errors;
66
pub mod events;
77
pub mod ids;
8+
pub mod metadata;
89
pub mod types;
910

1011
pub use errors::CoreError;
1112
pub use events::{EventBus, FileEvent};
13+
pub use metadata::{MediaMetadata, MetadataExtractor};
1214
pub use types::{
1315
BlakeHash, DeviceId, DiscoveredFile, FileLocationRecord, FileSize, HashedFile, LocationStatus,
1416
MediaPath, UpsertOutcome, VolumeId, VolumeIdentifiers, VolumeRecord,
1517
};
1618

1719
pub mod ports;
18-
pub use ports::{FileRepository, HashService, Scanner, VolumeRepository};
20+
pub use ports::{FileRepository, HashService, MetadataRepository, Scanner, VolumeRepository};
1921

2022
/// Marker placeholder. Retained as a public symbol for phase-0
2123
/// compatibility tests; will be removed in phase 1b when the real

crates/core/src/metadata.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
//! Structured media metadata — domain type and extractor port.
2+
//!
3+
//! Framework-free value types consumed by `perima-media` extractors
4+
//! and persisted by `perima-db`'s `SqliteMetadataRepository`.
5+
6+
use std::path::Path;
7+
8+
use serde::{Deserialize, Serialize};
9+
10+
use crate::{BlakeHash, CoreError};
11+
12+
/// Structured metadata extracted from a media file.
13+
///
14+
/// WHY optional everywhere: not every file has every field (a PNG has
15+
/// no `captured_at`; an MP4 may have no camera info). Partial
16+
/// information is the norm with EXIF / container tag extraction, so
17+
/// each field is independently nullable.
18+
///
19+
/// WHY `captured_at: Option<String>` (ISO 8601) not `DateTime<Utc>`:
20+
/// consistency with the existing `first_seen` / `last_seen` String
21+
/// columns, and it keeps `chrono` out of `perima-core`.
22+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
23+
pub struct MediaMetadata {
24+
/// Content hash of the file this metadata describes. Matches the
25+
/// corresponding row in the `files` table.
26+
pub hash: BlakeHash,
27+
/// Pixel width (images / video). `None` for formats without a
28+
/// natural width (audio, future PDF extractors).
29+
pub width: Option<u32>,
30+
/// Pixel height (images / video). `None` for formats without a
31+
/// natural height.
32+
pub height: Option<u32>,
33+
/// Duration in milliseconds (video / audio). `None` for still
34+
/// images.
35+
pub duration_ms: Option<u64>,
36+
/// ISO 8601 UTC capture timestamp. Sourced from EXIF
37+
/// `DateTimeOriginal` for images, container tags for video.
38+
pub captured_at: Option<String>,
39+
/// Camera manufacturer (EXIF `Make`).
40+
pub camera_make: Option<String>,
41+
/// Camera model (EXIF `Model`).
42+
pub camera_model: Option<String>,
43+
/// Codec identifier (e.g. `"avc1"`, `"hevc"`). Video only.
44+
pub codec: Option<String>,
45+
/// Overall bitrate in bits per second. Video only.
46+
pub bitrate_bps: Option<u32>,
47+
/// MIME type as detected at extraction time.
48+
pub mime_type: Option<String>,
49+
}
50+
51+
/// MIME-dispatched extractor.
52+
///
53+
/// WHY MIME dispatch not "first-non-empty": a JPEG EXIF extractor that
54+
/// returns `{width: None, mime_type: Some(..)}` would falsely "win"
55+
/// against a video extractor that could actually extract duration.
56+
/// Dispatching by `accepts(mime)` avoids this ambiguity — each
57+
/// extractor declares the MIME families it handles, and the composite
58+
/// picks the first match.
59+
pub trait MetadataExtractor: Send + Sync {
60+
/// Whether this extractor handles the given MIME type.
61+
fn accepts(&self, mime: &str) -> bool;
62+
63+
/// Extract metadata from the file at `absolute_path`.
64+
///
65+
/// # Errors
66+
/// Returns `CoreError::Io` if the file cannot be read, or
67+
/// `CoreError::Internal` on decoder-level failures. Missing
68+
/// optional fields are not errors — they are `None` in the
69+
/// returned `MediaMetadata`.
70+
fn extract(
71+
&self,
72+
hash: BlakeHash,
73+
absolute_path: &Path,
74+
mime: &str,
75+
) -> Result<MediaMetadata, CoreError>;
76+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//! Media metadata repository port (implementation lands in `perima-db`).
2+
3+
use crate::{
4+
BlakeHash, CoreError, DeviceId, FileLocationRecord, MediaMetadata, UpsertOutcome, VolumeId,
5+
};
6+
7+
/// Persistence boundary for `file_metadata`.
8+
///
9+
/// WHY `&self` everywhere (not `&mut self` like `FileRepository`):
10+
/// the existing `FileRepository` trait's `&mut self` signature
11+
/// collides with the `Arc<SqliteFileRepository>` sharing pattern used
12+
/// in desktop state. Newer traits align with the actual usage —
13+
/// interior mutability via `Mutex<Connection>` inside the adapter.
14+
/// `FileRepository` will migrate to `&self` in v0.5.x as a
15+
/// fast-follow (tracked in GH issue).
16+
pub trait MetadataRepository: Send + Sync {
17+
/// Insert or update the metadata row keyed by `meta.hash`.
18+
///
19+
/// # Errors
20+
/// Adapter-level failures surface as `CoreError::Internal`.
21+
fn upsert_metadata(
22+
&self,
23+
meta: &MediaMetadata,
24+
device: DeviceId,
25+
) -> Result<UpsertOutcome, CoreError>;
26+
27+
/// Fetch the metadata row for `hash`, if one exists.
28+
///
29+
/// # Errors
30+
/// Adapter-level failures surface as `CoreError::Internal`.
31+
fn find_by_hash(&self, hash: &BlakeHash) -> Result<Option<MediaMetadata>, CoreError>;
32+
33+
/// List `(file_location, metadata)` pairs up to `limit`, optionally
34+
/// filtered by `volume`.
35+
///
36+
/// `None` metadata means the extractor has not yet run for that
37+
/// file (the scanner enqueued it but the worker is behind) or
38+
/// extraction failed — callers should treat it as "pending", not
39+
/// "absent".
40+
///
41+
/// # Errors
42+
/// Adapter-level failures surface as `CoreError::Internal`.
43+
fn list_with_metadata(
44+
&self,
45+
limit: usize,
46+
volume: Option<VolumeId>,
47+
) -> Result<Vec<(FileLocationRecord, Option<MediaMetadata>)>, CoreError>;
48+
}

crates/core/src/ports/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
33
pub mod file_repo;
44
pub mod hash;
5+
pub mod metadata_repo;
56
pub mod scanner;
67
pub mod volume_repo;
78

89
pub use file_repo::FileRepository;
910
pub use hash::HashService;
11+
pub use metadata_repo::MetadataRepository;
1012
pub use scanner::Scanner;
1113
pub use volume_repo::VolumeRepository;

0 commit comments

Comments
 (0)