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);