diff --git a/design/api.md b/design/api.md index d3fe7b7..2bcb2c0 100644 --- a/design/api.md +++ b/design/api.md @@ -246,7 +246,7 @@ Example response: ### `GET /api/cameras///recordings` -Returns information about recordings, in descending order. +Returns information about recordings. Valid request parameters: @@ -259,8 +259,8 @@ Valid request parameters: server should return a `continue` key which is expected to be returned on following requests.) -In the property `recordings`, returns a list of recordings in arbitrary order. -Each recording object has the following properties: +Returns a JSON object. Under the key `recordings` is an array of recordings in +arbitrary order. Each recording object has the following properties: * `startId`. The id of this recording, which can be used with `/view.mp4` to retrieve its content. @@ -289,13 +289,24 @@ Each recording object has the following properties: * `endTime90k`: the end time of the given recording. Note this may be greater than the requested `endTime90k` if this recording was ongoing at the requested time. -* `sampleFileBytes` -* `videoSampleEntrySha1` -* `videoSampleEntryWidth` -* `videoSampleEntryHeight` +* `videoSampleEntryId`: a reference to an entry in the `videoSampleEntries` + map. These ids are strings so that they can serve as JSON object keys. * `videoSamples`: the number of samples (aka frames) of video in this recording. +Under the property `videoSampleEntries`, an object mapping ids to objects with +the following properties: + +* `sha1`: a SHA-1 hash of the ISO/IEC 14496-12 section 8.5.2 + `VisualSampleEntry` bytes. The actual bytes can be retrieved, wrapped into + an initialization segment `.mp4`, at the URL `/api/init/.mp4`. +* `width`: the stored width in pixels. +* `height`: the stored height in pixels. +* `pixelHSpacing`: the relative width of a pixel, as in a ISO/IEC 14496-12 + section 12.1.4.3 `PixelAspectRatioBox`. If absent, assumed to be 1. +* `pixelVSpacing`: the relative height of a pixel, as in a ISO/IEC 14496-12 + section 12.1.4.3 `PixelAspectRatioBox`. If absent, assumed to be 1. + Example request URI (with added whitespace between parameters): ``` @@ -314,9 +325,7 @@ Example response: "startTime90k": 130985461191810, "endTime90k": 130985466591817, "sampleFileBytes": 8405564, - "videoSampleEntrySha1": "81710c9c51a02cc95439caa8dd3bc12b77ffe767", - "videoSampleEntryWidth": 1280, - "videoSampleEntryHeight": 720, + "videoSampleEntryId": "1", }, { "endTime90k": 130985461191810, @@ -324,7 +333,13 @@ Example response: }, ... ], - "continue": "", + "videoSampleEntries": { + "1": { + "sha1": "81710c9c51a02cc95439caa8dd3bc12b77ffe767", + "width": 1280, + "height": 720 + } + }, } ``` diff --git a/src/json.rs b/src/json.rs index 3b14f91..b7b3eff 100644 --- a/src/json.rs +++ b/src/json.rs @@ -372,9 +372,31 @@ impl<'a> TopLevel<'a> { } } -#[derive(Debug, Serialize)] -pub struct ListRecordings { +#[derive(Serialize)] +#[serde(rename_all="camelCase")] +pub struct ListRecordings<'a> { pub recordings: Vec, + + // There are likely very few video sample entries for a given stream in a given day, so + // representing with an unordered Vec (and having O(n) insert-if-absent) is probably better + // than dealing with a HashSet's code bloat. + #[serde(serialize_with = "ListRecordings::serialize_video_sample_entries")] + pub video_sample_entries: (&'a db::LockedDatabase, Vec), +} + +impl<'a> ListRecordings<'a> { + fn serialize_video_sample_entries(video_sample_entries: &(&db::LockedDatabase, Vec), + serializer: S) -> Result + where S: Serializer { + let (db, ref v) = *video_sample_entries; + let mut map = serializer.serialize_map(Some(v.len()))?; + for id in v { + map.serialize_entry( + id, + &VideoSampleEntry::from(&db.video_sample_entries_by_id().get(id).unwrap()))?; + } + map.end() + } } #[derive(Debug, Serialize)] @@ -384,7 +406,7 @@ pub struct Recording { pub end_time_90k: i64, pub sample_file_bytes: i64, pub video_samples: i64, - pub video_sample_entry_sha1: String, + pub video_sample_entry_id: String, pub start_id: i32, pub open_id: u32, @@ -393,9 +415,25 @@ pub struct Recording { #[serde(skip_serializing_if = "Option::is_none")] pub end_id: Option, - pub video_sample_entry_width: u16, - pub video_sample_entry_height: u16, #[serde(skip_serializing_if = "Not::not")] pub growing: bool, } + +#[derive(Debug, Serialize)] +#[serde(rename_all="camelCase")] +pub struct VideoSampleEntry { + pub sha1: String, + pub width: u16, + pub height: u16, +} + +impl VideoSampleEntry { + fn from(e: &db::VideoSampleEntry) -> Self { + Self { + sha1: base::strutil::hex(&e.sha1), + width: e.width, + height: e.height, + } + } +} diff --git a/src/web.rs b/src/web.rs index 3c695f2..aacac51 100644 --- a/src/web.rs +++ b/src/web.rs @@ -344,35 +344,36 @@ impl ServiceInner { } (time, split) }; - let mut out = json::ListRecordings{recordings: Vec::new()}; - { - let db = self.db.lock(); - let camera = db.get_camera(uuid) - .ok_or_else(|| plain_response(StatusCode::NOT_FOUND, - format!("no such camera {}", uuid)))?; - let stream_id = camera.streams[type_.index()] - .ok_or_else(|| plain_response(StatusCode::NOT_FOUND, - format!("no such stream {}/{}", uuid, type_)))?; - db.list_aggregated_recordings(stream_id, r, split, &mut |row| { - let end = row.ids.end - 1; // in api, ids are inclusive. - let vse = db.video_sample_entries_by_id().get(&row.video_sample_entry_id).unwrap(); - out.recordings.push(json::Recording { - start_id: row.ids.start, - end_id: if end == row.ids.start { None } else { Some(end) }, - start_time_90k: row.time.start.0, - end_time_90k: row.time.end.0, - sample_file_bytes: row.sample_file_bytes, - open_id: row.open_id, - first_uncommitted: row.first_uncommitted, - video_samples: row.video_samples, - video_sample_entry_width: vse.width, - video_sample_entry_height: vse.height, - video_sample_entry_sha1: strutil::hex(&vse.sha1), - growing: row.growing, - }); - Ok(()) - }).map_err(internal_server_err)?; - } + let db = self.db.lock(); + let mut out = json::ListRecordings { + recordings: Vec::new(), + video_sample_entries: (&db, Vec::new()), + }; + let camera = db.get_camera(uuid) + .ok_or_else(|| plain_response(StatusCode::NOT_FOUND, + format!("no such camera {}", uuid)))?; + let stream_id = camera.streams[type_.index()] + .ok_or_else(|| plain_response(StatusCode::NOT_FOUND, + format!("no such stream {}/{}", uuid, type_)))?; + db.list_aggregated_recordings(stream_id, r, split, &mut |row| { + let end = row.ids.end - 1; // in api, ids are inclusive. + out.recordings.push(json::Recording { + start_id: row.ids.start, + end_id: if end == row.ids.start { None } else { Some(end) }, + start_time_90k: row.time.start.0, + end_time_90k: row.time.end.0, + sample_file_bytes: row.sample_file_bytes, + open_id: row.open_id, + first_uncommitted: row.first_uncommitted, + video_samples: row.video_samples, + video_sample_entry_id: row.video_sample_entry_id.to_string(), + growing: row.growing, + }); + if !out.video_sample_entries.1.contains(&row.video_sample_entry_id) { + out.video_sample_entries.1.push(row.video_sample_entry_id); + } + Ok(()) + }).map_err(internal_server_err)?; serve_json(req, &out) } diff --git a/ui-src/NVRApplication.js b/ui-src/NVRApplication.js index ef747b7..ca92ee3 100644 --- a/ui-src/NVRApplication.js +++ b/ui-src/NVRApplication.js @@ -198,7 +198,7 @@ function fetch(selectedRange, videoLength) { 'Fetched results for "%s-%s" > updating recordings', streamView.camera.shortName, streamView.streamType ); - streamView.recordingsJSON = data.recordings; + streamView.recordingsJSON = data; }) .catch(function(data, status, err) { console.error(url, ' load failed: ', status, ': ', err); diff --git a/ui-src/lib/MoonfireAPI.js b/ui-src/lib/MoonfireAPI.js index eca935c..4569599 100644 --- a/ui-src/lib/MoonfireAPI.js +++ b/ui-src/lib/MoonfireAPI.js @@ -111,10 +111,10 @@ export default class MoonfireAPI { videoPlayUrl(cameraUUID, streamType, recording, trimmedRange, timestampTrack = true) { let sParam = recording.startId; - if (recording.endId !== undefined) { + if (recording.endId !== null) { sParam += '-' + recording.endId; } - if (recording.firstUncommitted !== undefined) { + if (recording.firstUncommitted !== null) { sParam += '@' + recording.openId; // disambiguate. } let rel = ''; @@ -124,7 +124,7 @@ export default class MoonfireAPI { rel += '-'; if (recording.endTime90k > trimmedRange.endTime90k) { rel += trimmedRange.endTime90k - recording.startTime90k; - } else if (recording.growing !== undefined) { + } else if (recording.growing) { // View just the portion described by recording. rel += recording.endTime90k - recording.startTime90k; } diff --git a/ui-src/lib/models/Recording.js b/ui-src/lib/models/Recording.js index ac75ba6..71144df 100644 --- a/ui-src/lib/models/Recording.js +++ b/ui-src/lib/models/Recording.js @@ -39,51 +39,46 @@ export default class Recording { /** * Accept JSON data to be encapsulated * - * @param {object} recordingJson JSON for a recording + * @param {object} recordingJson JSON for a recording + * @param {object} videoSampleEntryJson JSON for a video sample entry */ - constructor(recordingJson) { - this.json_ = recordingJson; - } + constructor(recordingJson, videoSampleEntryJson) { + /** @const {!number} */ + this.startId = recordingJson.startId; - /** @return {Number} */ - get startId() { - return this.json_.startId; - } + /** @const {?number} */ + this.endId = recordingJson.endId !== undefined ? recordingJson.endId : null; - /** @return {Number} */ - get endId() { - return this.json_.endId; - } + /** @const {!number} */ + this.openId = recordingJson.openId; - /** @return {Number} */ - get openId() { - return this.json_.openId; - } + /** @const {?number} */ + this.firstUncommitted = recordingJson.firstUncommitted !== undefined + ? recordingJson.firstUncommitted : null; - /** @return {Number} or undefined */ - get firstUncommitted() { - return this.json_.firstUncommitted; - } + /** @const {!boolean} */ + this.growing = recordingJson.growing || false; - /** @return {Boolean} or undefined */ - get growing() { - return this.json_.growing; - } + /** @const {!number} */ + this.startTime90k = recordingJson.startTime90k; - /** - * Return start time of recording in 90k units. - * @return {Number} Time in units of 90k parts of a second - */ - get startTime90k() { - return this.json_.startTime90k; - } + /** @const {!number} */ + this.endTime90k = recordingJson.endTime90k; - /** - * Return end time of recording in 90k units. - * @return {Number} Time in units of 90k parts of a second - */ - get endTime90k() { - return this.json_.endTime90k; + /** @const {!number} */ + this.sampleFileBytes = recordingJson.sampleFileBytes; + + /** @const {!number} */ + this.videoSamples = recordingJson.videoSamples; + + /** @const {!string} */ + this.videoSampleEntrySha1 = videoSampleEntryJson.sha1; + + /** @const {!number} */ + this.videoSampleEntryWidth = videoSampleEntryJson.width; + + /** @const {!number} */ + this.videoSampleEntryHeight = videoSampleEntryJson.height; } /** @@ -91,7 +86,7 @@ export default class Recording { * @return {Number} Time in units of 90k parts of a second */ get duration90k() { - return this.json_.endTime90k - this.json_.startTime90k; + return this.endTime90k - this.startTime90k; } /** @@ -112,49 +107,4 @@ export default class Recording { get duration() { return this.duration90k / 90000; } - - /** - * Get the number of bytes used by sample storage. - * - * @return {Number} Total bytes used - */ - get sampleFileBytes() { - return this.json_.sampleFileBytes; - } - - /** - * Get the number of video samples (frames) for the recording. - * - * @return {Number} Total bytes used - */ - get frameCount() { - return this.json_.videoSamples; - } - - /** - * Get the has for the video samples. - * - * @return {String} Hash - */ - get videoSampleEntryHash() { - return this.json_.videoSampleEntrySha1; - } - - /** - * Get the width of the frame(s) of the video samples. - * - * @return {Number} Width in pixels - */ - get videoSampleEntryWidth() { - return this.json_.videoSampleEntryWidth; - } - - /** - * Get the height of the frame(s) of the video samples. - * - * @return {Number} Height in pixels - */ - get videoSampleEntryHeight() { - return this.json_.videoSampleEntryHeight; - } } diff --git a/ui-src/lib/support/RecordingFormatter.js b/ui-src/lib/support/RecordingFormatter.js index 4ded344..1c7f9e0 100644 --- a/ui-src/lib/support/RecordingFormatter.js +++ b/ui-src/lib/support/RecordingFormatter.js @@ -91,7 +91,7 @@ export default class RecordingFormatter { recording.videoSampleEntryWidth + 'x' + recording.videoSampleEntryHeight, - frameRate: frameRateFmt.format(recording.frameCount / duration), + frameRate: frameRateFmt.format(recording.videoSamples / duration), size: sizeFmt.format(recording.sampleFileBytes / 1048576) + ' MB', rate: sizeFmt.format(recording.sampleFileBytes / duration * 0.000008) + diff --git a/ui-src/lib/views/RecordingsView.js b/ui-src/lib/views/RecordingsView.js index 10ca444..c11f3a0 100644 --- a/ui-src/lib/views/RecordingsView.js +++ b/ui-src/lib/views/RecordingsView.js @@ -256,13 +256,14 @@ export default class RecordingsView { * * The data is expected to be an array with recording objects. * - * @param {String} recordingsJSON JSON data (array) + * @param {object} recordingsJSON JSON data (object) */ set recordingsJSON(recordingsJSON) { this.showLoading = false; // Store as model objects - this._recordings = recordingsJSON.map(function(r) { - return new Recording(r); + this._recordings = recordingsJSON.recordings.map(function(r) { + const vse = recordingsJSON.videoSampleEntries[r.videoSampleEntryId]; + return new Recording(r, vse); }); const tbody = this._element;