From e7d0640e25c95ba8e4559edc8f947c4a37b1dc64 Mon Sep 17 00:00:00 2001 From: hotkhwan <13896960+hotkhwan@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:25:41 +0700 Subject: [PATCH] feat(storage): make MP4 faststart configurable ([storage] mp4_faststart) Every recorded MP4 segment is finalised with movflags=+faststart, which re-reads the whole segment and re-writes it with the moov atom at the front. On network storage this roughly triples the I/O: measured on a 41-camera site recording ~8.7 MB/s to a single-disk NFS share, the NAS saw ~15 MB/s of writes plus ~13.6 MB/s of reads, and playback reads slowed to 3-8 MB/s. Add [storage] mp4_faststart (default true, the current behaviour) so a site recording to a NAS or slow HDD can skip the rewrite. Players that use HTTP range requests, including the web UI, play either layout. The option is read from and written back to the ini, exposed in the settings API, and applies to segments opened after a change. Co-Authored-By: Claude Opus 5.5 --- config/lightnvr.ini | 6 +++++ docs/CONFIGURATION.md | 1 + include/core/config.h | 6 +++++ src/core/config.c | 6 +++++ src/video/mp4_segment_recorder.c | 8 +++++-- src/video/mp4_writer_utils.c | 4 +++- src/web/api_handlers_settings.c | 9 ++++++++ tests/unit/test_config.c | 39 +++++++++++++++++++++++++++++++- 8 files changed, 75 insertions(+), 4 deletions(-) diff --git a/config/lightnvr.ini b/config/lightnvr.ini index a374cb0d..b7f78b95 100755 --- a/config/lightnvr.ini +++ b/config/lightnvr.ini @@ -26,6 +26,12 @@ max_size = 0 ; 0 means unlimited, otherwise bytes retention_days = 30 auto_delete_oldest = true +; MP4 faststart: rewrite each finished segment with its index (moov) at the +; front. The rewrite re-reads and re-writes the whole segment, roughly tripling +; the I/O on NFS/SMB storage or a slow HDD. Range-request players (browsers, +; the web UI) play either layout. Set false when recording to a NAS. +mp4_faststart = true + ; Capacity-based retention: keep at least this percent of the volume free by ; auto-evicting the oldest recordings, regardless of retention_days. This bounds ; usage to the disk size so the volume never fills. Set to 0 to disable. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index db257884..b4ec5ec7 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -246,6 +246,7 @@ mp4_retention_days = 30 camera was permanently deleted; per-camera overrides still apply, and protected recordings are never expired by age. - `auto_delete_oldest`: Whether to automatically delete the oldest recordings when storage is full +- `mp4_faststart`: Whether to rewrite each finished MP4 segment with its index (moov) at the front (default `true`). The rewrite re-reads and re-writes the whole segment, which roughly triples I/O on network storage (NFS/SMB) or a slow HDD; players that use HTTP range requests play either layout, so set `false` when recording to a NAS. - `record_mp4_directly`: Enable direct MP4 recording (instead of HLS-to-MP4 conversion) - `mp4_path`: Directory for direct MP4 recordings - `mp4_directory_format`: Recording folder layout: `flat`, `year_month` (`YYYY/MM`), or `year_month_day` (`YYYY/MM/DD`, default) diff --git a/include/core/config.h b/include/core/config.h index 8790a64a..09e0ab25 100644 --- a/include/core/config.h +++ b/include/core/config.h @@ -211,6 +211,12 @@ typedef struct { uint64_t max_storage_size; // in bytes int retention_days; bool auto_delete_oldest; + // MP4 "faststart": after a segment closes, rewrite it with the moov atom + // moved to the front. That re-reads and re-writes the whole segment, which + // roughly triples the I/O on network storage (NFS/SMB) or a slow HDD. + // Players that use HTTP range requests play either layout, so a site + // recording to a NAS may turn it off. Default true (historical behaviour). + bool mp4_faststart; // Capacity-based retention (bounds usage by disk size, not just by time). // The cleanup thread keeps at least storage_min_free_pct of the volume free diff --git a/src/core/config.c b/src/core/config.c index 32857fbb..e121d06d 100644 --- a/src/core/config.c +++ b/src/core/config.c @@ -355,6 +355,7 @@ void load_default_config(config_t *config) { config->max_storage_size = 0; // 0 means unlimited config->retention_days = 30; config->auto_delete_oldest = true; + config->mp4_faststart = true; // Capacity-based retention: keep 10% of the volume free by default so the // disk is self-bounding even when retention_days exceeds what fits. @@ -814,6 +815,8 @@ static int config_ini_handler(void* user, const char* section, const char* name, config->retention_days = safe_atoi(value, 0); } else if (strcmp(name, "auto_delete_oldest") == 0) { config->auto_delete_oldest = (strcmp(value, "true") == 0 || strcmp(value, "1") == 0); + } else if (strcmp(name, "mp4_faststart") == 0) { + config->mp4_faststart = (strcmp(value, "true") == 0 || strcmp(value, "1") == 0); } else if (strcmp(name, "min_free_pct") == 0) { config->storage_min_free_pct = safe_atoi(value, 10); } else if (strcmp(name, "pressure_warning_pct") == 0) { @@ -1803,6 +1806,8 @@ int save_config(const config_t *config, const char *path) { fprintf(file, "max_size = %llu ; 0 means unlimited, otherwise bytes\n", (unsigned long long)config->max_storage_size); fprintf(file, "retention_days = %d\n", config->retention_days); fprintf(file, "auto_delete_oldest = %s\n", config->auto_delete_oldest ? "true" : "false"); + fprintf(file, "mp4_faststart = %s ; false avoids the post-segment rewrite (less I/O on NAS/HDD)\n", + config->mp4_faststart ? "true" : "false"); fprintf(file, "min_free_pct = %d ; keep at least this %% of the volume free (0 disables capacity cap)\n", config->storage_min_free_pct); fprintf(file, "pressure_warning_pct = %.1f\n", config->storage_pressure_warning_pct); @@ -2012,6 +2017,7 @@ void print_config(const config_t *config) { printf(" Max Storage Size: %llu bytes\n", (unsigned long long)config->max_storage_size); printf(" Retention Days: %d\n", config->retention_days); printf(" Auto Delete Oldest: %s\n", config->auto_delete_oldest ? "true" : "false"); + printf(" MP4 Faststart: %s\n", config->mp4_faststart ? "true" : "false"); printf(" Health Settings:\n"); printf(" Enabled: %s\n", config->health.enabled ? "true" : "false"); diff --git a/src/video/mp4_segment_recorder.c b/src/video/mp4_segment_recorder.c index 2a9118a1..4e53dffb 100644 --- a/src/video/mp4_segment_recorder.c +++ b/src/video/mp4_segment_recorder.c @@ -847,8 +847,12 @@ int record_segment(const char *rtsp_url, const char *output_file, int duration, // Use faststart to move moov atom to beginning for better compatibility // This creates standard MP4 files that play in all applications - // The + prefix adds to existing flags rather than replacing them - av_dict_set(&out_opts, "movflags", "+faststart", 0); + // The + prefix adds to existing flags rather than replacing them. + // [storage] mp4_faststart = false skips it: the rewrite re-reads and + // re-writes the whole segment, which triples the I/O on a NAS. + if (g_config.mp4_faststart) { + av_dict_set(&out_opts, "movflags", "+faststart", 0); + } // CRITICAL FIX: Validate output_file parameter before attempting to open if (!output_file || output_file[0] == '\0') { diff --git a/src/video/mp4_writer_utils.c b/src/video/mp4_writer_utils.c index 1f2723bb..acc709b9 100644 --- a/src/video/mp4_writer_utils.c +++ b/src/video/mp4_writer_utils.c @@ -1595,7 +1595,9 @@ int mp4_writer_initialize(mp4_writer_t *writer, const AVPacket *pkt, const AVStr // Set options for fast start - EXACTLY match rtsp_recorder.c AVDictionary *opts = NULL; - av_dict_set(&opts, "movflags", "+faststart", 0); // This is the ONLY option in rtsp_recorder.c + if (g_config.mp4_faststart) { // [storage] mp4_faststart (default true) + av_dict_set(&opts, "movflags", "+faststart", 0); // This is the ONLY option in rtsp_recorder.c + } // Open output file ret = avio_open(&writer->output_ctx->pb, writer->output_path, AVIO_FLAG_WRITE); diff --git a/src/web/api_handlers_settings.c b/src/web/api_handlers_settings.c index e80e3758..7e0bdb9f 100644 --- a/src/web/api_handlers_settings.c +++ b/src/web/api_handlers_settings.c @@ -741,6 +741,7 @@ void handle_get_settings(const http_request_t *req, http_response_t *res) { cJSON_AddNumberToObject(settings, "lpr_retention_days", days); } cJSON_AddBoolToObject(settings, "auto_delete_oldest", g_config.auto_delete_oldest); + cJSON_AddBoolToObject(settings, "mp4_faststart", g_config.mp4_faststart); cJSON_AddNumberToObject(settings, "storage_min_free_pct", g_config.storage_min_free_pct); cJSON_AddNumberToObject(settings, "storage_pressure_warning_pct", g_config.storage_pressure_warning_pct); cJSON_AddNumberToObject(settings, "storage_pressure_critical_pct", g_config.storage_pressure_critical_pct); @@ -1338,6 +1339,14 @@ void handle_post_settings(const http_request_t *req, http_response_t *res) { log_info("Updated auto_delete_oldest: %s", g_config.auto_delete_oldest ? "true" : "false"); } + // MP4 faststart (applies to segments opened after the change) + cJSON *mp4_faststart = cJSON_GetObjectItem(settings, "mp4_faststart"); + if (mp4_faststart && cJSON_IsBool(mp4_faststart)) { + g_config.mp4_faststart = cJSON_IsTrue(mp4_faststart); + settings_changed = true; + log_info("Updated mp4_faststart: %s", g_config.mp4_faststart ? "true" : "false"); + } + // Capacity-based retention: minimum free-space headroom to maintain (%) cJSON *min_free_pct = cJSON_GetObjectItem(settings, "storage_min_free_pct"); if (min_free_pct && cJSON_IsNumber(min_free_pct)) { diff --git a/tests/unit/test_config.c b/tests/unit/test_config.c index 6a6ba2a1..d6c117be 100644 --- a/tests/unit/test_config.c +++ b/tests/unit/test_config.c @@ -83,6 +83,39 @@ void test_default_config_retention_days(void) { TEST_ASSERT_EQUAL_INT(30, cfg.retention_days); } +void test_default_config_mp4_faststart_on(void) { + load_default_config(&cfg); + TEST_ASSERT_TRUE(cfg.mp4_faststart); +} + +/* save_config must write mp4_faststart, or a settings save from the web UI + * would silently turn faststart back on at the next start. */ +void test_save_config_round_trips_mp4_faststart(void) { + char temp_dir[] = "/tmp/lightnvr_faststart_XXXXXX"; + char *dir = mkdtemp(temp_dir); + TEST_ASSERT_NOT_NULL(dir); + char config_path[MAX_PATH_LENGTH]; + snprintf(config_path, sizeof(config_path), "%s/faststart.ini", dir); + + load_default_config(&cfg); + cfg.mp4_faststart = false; + TEST_ASSERT_EQUAL_INT(0, save_config(&cfg, config_path)); + + FILE *saved = fopen(config_path, "r"); + TEST_ASSERT_NOT_NULL(saved); + char line[512]; + bool found = false; + while (fgets(line, sizeof(line), saved)) { + if (strncmp(line, "mp4_faststart = false", 21) == 0) { + found = true; + } + } + fclose(saved); + unlink(config_path); + rmdir(dir); + TEST_ASSERT_TRUE(found); +} + void test_default_config_buffer_size(void) { load_default_config(&cfg); TEST_ASSERT_GREATER_THAN_INT(0, cfg.buffer_size); @@ -636,7 +669,8 @@ void test_env_integer_whitespace_handling(void) { "[storage]\n" "path = %s\n" "path_hls = %s\n" - "mp4_directory_format = year_month\n\n" + "mp4_directory_format = year_month\n" + "mp4_faststart = false\n\n" "[models]\n" "path = %s\n\n" "[database]\n" @@ -686,6 +720,7 @@ void test_env_integer_whitespace_handling(void) { TEST_ASSERT_EQUAL_INT(12, cfg.db_backup_retention_count); TEST_ASSERT_EQUAL_STRING("/usr/local/bin/backup-hook", cfg.db_post_backup_script); TEST_ASSERT_EQUAL_STRING("year_month", cfg.mp4_directory_format); + TEST_ASSERT_FALSE(cfg.mp4_faststart); TEST_ASSERT_TRUE(cfg.audio_disabled); TEST_ASSERT_TRUE(cfg.auto_disabled); TEST_ASSERT_EQUAL_STRING("conservative", cfg.health.profile); @@ -748,6 +783,8 @@ int main(void) { RUN_TEST(test_default_config_web_port); RUN_TEST(test_default_config_log_level); RUN_TEST(test_default_config_retention_days); + RUN_TEST(test_default_config_mp4_faststart_on); + RUN_TEST(test_save_config_round_trips_mp4_faststart); RUN_TEST(test_default_config_buffer_size); RUN_TEST(test_default_config_storage_path_nonempty); RUN_TEST(test_default_config_db_path_nonempty);