0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Windowsでネットワークカメラを利用したホームセキュリティシステムを作る(2)

Last updated at Posted at 2025-11-02

前回の記事の構成で今回は以下の機能を実現するアプリケーションをDotnet Core V9で作成します。

  • バックグラウンドでMediaMTXの起動・停止(Windowsサービスプログラムの実装)
  • ブラウザからライブ及び、録画映像の視聴(Webサーバの実装)
  • 録画ファイルを1日分保持(期間外の録画ファイルの自動削除機能の実装)

録画ファイルの保持期間はffmpegのパラメータで制御します。
今回作成するプログラムでファイルの管理は行いません。

1.事前準備

今回は.NET SDK + VSCodeで開発を行う想定です。事前に以下のモジュールをインストールしてください。(Visual Studio 2022を利用する場合はインストール不要です)

  • .NET SDK 9
  • Visual Studio Code(VSCode)
  • C# DevKit(VsCode拡張機能)

2.プロジェクトの作成

今回作成するアプリのファイル構成です。

.\RtspRec
|   .gitignore
|   RtspRec.sln
|
\---RtspRecService
    |   appsettings.Development.json
    |   appsettings.json
    |   mediamtx.yml
    |   Program.cs
    |   RtspRecService.csproj
    |   RtspRecService.csproj.user
    |   RtspRecSettings.cs
    |   Worker.cs
    |
    +---Proc
    |       MediaMTXManager.cs
    |
    \---wwwroot
            index.html

プロジェクトのディレクトリを作成します

New-Item -Path .\RtspRec -Type Directory

プロジェクトのカレントディレクトリへ移動します

cd .\RtspRec 

ソリューションファイルを作成します

dotnet new sln -n RtspRec

必要に応じてdotnet用.gitignoreファイルを作成します(任意)

dotnet new gitignore

3.Windowsサービスプロジェクト作成

Windowsサービスのプロジェクトを作成します

dotnet new worker -n RtspRecService

ソリューションにWindowsサービスのプロジェクトを追加します

dotnet sln add RtspRecService/RtspRecService.csproj

Windowsサービスプロジェクトのカレントディレクトリへ移動します

cd .\RtspRecService

利用パッケージを追加します

dotnet add package Microsoft.Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.Hosting.WindowsServices
dotnet add package NLog
dotnet add package NLog.Extensions.Logging

バックグラウンドで起動するアプリの為、調査用にNLogを利用してログ出力します。

4.プログラム作成

以降、VSCodeからプロジェクトのカレントディレクトリを開きファイルの作成、編集を行います。

appsettings.jsonを編集します

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "NLog": {
    "throwConfigExceptions": true,
    "targets": {
      "async": true,
      "console": {
        "type": "ColoredConsole",
        "layout": "${longdate} ${level:uppercase=true} [${logger}] - ${message}${exception:format=ToString,StackTrace}"
      },
      "file": {
        "type": "File",
        "archiveAboveSize": 5000000,
        "maxArchiveFiles": 5,
        "fileName": "./logs/app-${shortdate}.log",
        "layout": "${longdate} ${level:uppercase=true} [${logger}] - ${message}${exception:format=ToString,StackTrace}"
      }
    },
    "rules": [
      {
        "logger": "*",
        "minLevel": "Info",
        "writeTo": "console"
      },
      {
        "logger": "*",
        "minLevel": "Info",
        "writeTo": "file"
      }
    ]
  },
  "RtspRec": {
    "MediaMTXConfFile": "mediamtx.yml"
  }
}

NLogセクションのログフォーマット及び、ログレベルの設定は適宜変更してください。

appsettings.Development.jsonはデバッグ用の設定ファイルを想定しています。
デバッグ実行する場合は適宜編集し利用ください。

.\RtspRecSettings.csを作成します

namespace RtspRec.RtspRecService;

public class RtspRecSettings
{
    public required string MediaMTXConfFile { get; set; }
}

.\Proc\MediaMTXManager.csを作成します

using Microsoft.Extensions.Options;
using System.Diagnostics;

namespace RtspRec.RtspRecService.Proc;

public class MediaMTXManager(ILogger<MediaMTXManager> logger, IOptions<RtspRecSettings> settings)
{
    private readonly ILogger<MediaMTXManager> _logger = logger;
    private readonly RtspRecSettings _settings = settings.Value;
    private Process? _mediaMTX;
    public bool IsAlived => _mediaMTX?.HasExited == false;

    public async Task StartAsync(CancellationToken token)
    {
        if (IsAlived)
        {
            _logger.LogInformation("MediaMTXは既に起動しています");
            return;
        }
        _logger.LogInformation("MediaMTXを起動します");
        await StartMediaMTXProcess(token);
    }

    public async Task StopAsync()
    {
        if (!IsAlived)
        {
            _logger.LogInformation("MediaMTXは既に停止しています");
            return;
        }

        try
        {
            _mediaMTX!.Kill(true);
            await _mediaMTX.WaitForExitAsync();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "MediaMTXの停止中に異常が発生しました");
        }
        finally
        {
            _mediaMTX?.Dispose();
            _mediaMTX = null;
        }

        _logger.LogInformation("MediaMTXを停止しました");
    }

    private async Task StartMediaMTXProcess(CancellationToken token)
    {
        // コンフィグファイルの絶対パスを取得
        var currentDir = Environment.CurrentDirectory;
        var confPath = Path.Combine(currentDir, _settings.MediaMTXConfFile);

        if (!File.Exists(confPath))
        {
            throw new FileNotFoundException($"MediaMTXのコンフィグファイルが見つかりません。: {confPath}");
        }

        // MediaMTXプロセスの起動情報を設定
        _mediaMTX = new()
        {
            StartInfo = new ProcessStartInfo
            {
                FileName = "mediamtx",
                Arguments = confPath,
                WorkingDirectory = currentDir,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                RedirectStandardInput = true,
                UseShellExecute = false,
                CreateNoWindow = true
            }
        };

        // MediaMTXプロセスを起動
        _mediaMTX.Start();

        // エラーログの監視を開始
        await MonitorErrorOutputAsync(token);
        try
        {
            await _mediaMTX.WaitForExitAsync(token);
        }
        catch (OperationCanceledException)
        {
            _logger.LogInformation("MediaMTXのプロセスをKILLします。");
            _mediaMTX.Kill();
        }
        _logger.LogInformation($"ExitCode: {_mediaMTX.ExitCode}");
    }

    private async Task MonitorErrorOutputAsync(CancellationToken token)
    {
        if (_mediaMTX?.StandardError == null || _mediaMTX?.StandardOutput == null)
        {
            return;
        }

        // 標準出力の監視タスク
        var stdOutTask = Task.Run(async () =>
        {
            while (!_mediaMTX.StandardOutput.EndOfStream)
            {
                var line = await _mediaMTX.StandardOutput.ReadLineAsync(token);
                if (line != null)
                {
                    _logger.LogInformation("[MediaMTX:stdout] {Line}", line);
                }
            }
        }, token);

        // 標準エラー出力の監視タスク
        var stdErrTask = Task.Run(async () =>
        {
            while (!_mediaMTX.StandardError.EndOfStream)
            {
                var line = await _mediaMTX.StandardError.ReadLineAsync();
                if (line != null)
                {
                    _logger.LogDebug("[MediaMTX:stderr] {Line}", line);
                }
            }
        }, token);

        // 両方の読み取りが完了するまで待機
        await Task.WhenAll(stdOutTask, stdErrTask);
    }
}

.\Worker.csを編集します

using RtspRec.RtspRecService.Proc;

namespace RtspRec.RtspRecService;

public class Worker(ILogger<Worker> logger, MediaMTXManager mediaMtx) : BackgroundService
{
    private readonly ILogger<Worker> _logger = logger;
    private readonly MediaMTXManager _mediaMtx = mediaMtx;

    public override async Task StartAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("Worker is starting: {time}", DateTimeOffset.UtcNow.ToLocalTime().ToString("yyyy/MM/dd HH:mm:ss zzz"));
        await base.StartAsync(cancellationToken);
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            _logger.LogInformation("Worker running at: {time}", DateTimeOffset.UtcNow.ToLocalTime().ToString("yyyy/MM/dd HH:mm:ss zzz"));
            await _mediaMtx.StartAsync(stoppingToken);

            while (!stoppingToken.IsCancellationRequested)
            {
                await Task.Delay(1000, stoppingToken);
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Worker encountered an error");
        }
    }

    public override async Task StopAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("Worker is stopping: {time}", DateTimeOffset.UtcNow.ToLocalTime().ToString("yyyy/MM/dd HH:mm:ss zzz"));
        await _mediaMtx.StopAsync();

        // ExecuteAsync のキャンセルと完了を待つ
        await base.StopAsync(cancellationToken);
    }
}

.\Program.csを編集します

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.StaticFiles;
using System.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using NLog.Extensions.Logging;
using RtspRec.RtspRecService;
using RtspRec.RtspRecService.Proc;

var basePath = AppContext.BaseDirectory;

// カレントディレクトリを実行ファイルのディレクトリに設定する
Directory.SetCurrentDirectory(basePath);

// 環境変数から環境名を取得し、設定ファイルを読み込む
var environmentName = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production";
var config = new ConfigurationBuilder()
                .SetBasePath(basePath)
                .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
                .AddJsonFile($"appsettings.{environmentName}.json", optional: true, reloadOnChange: true) // 環境ごとの設定ファイルを追加
                .AddEnvironmentVariables().Build();

var builder = WebApplication.CreateBuilder(args);

// NLogの設定する
builder.Logging.ClearProviders();
builder.Logging.AddNLog();

// サービス名を設定する
builder.Services.AddWindowsService(options =>
{
    options.ServiceName = "RTSP Recorder Service";
});

// appsettings.jsonのMediaMTXセクションをRtspRecSettingsにバインドする
builder.Services.Configure<RtspRecSettings>(
    builder.Configuration.GetSection("RtspRec")
);

builder.Services.AddOptions<RtspRecSettings>()
    .ValidateOnStart();     // 起動時に検証する

// DIコンテナにバックグラウンドサービスを登録する
builder.Services.AddHostedService<Worker>();

// MediaTMXProcessManagerをシングルトンとして登録する
builder.Services.AddSingleton<MediaMTXManager>();

// CORSを設定する場合必要
builder.Services.AddControllers();

var host = builder.Build();

// 静的ファイルを有効化
host.UseDefaultFiles(); // index.html を自動的に返す
host.UseStaticFiles();  // wwwroot 配下のファイルを配信

// --- 仮想ディレクトリ "Contents" を /Contents パスで追加 ---
// basePath/Contents を物理ディレクトリとしてマッピングし、/Contents でアクセス可能にする
var contentsDir = Path.Combine(basePath, "Contents");
if (!Directory.Exists(contentsDir))
{
    Directory.CreateDirectory(contentsDir);
}

// CORS設定(hls.js、ライブ配信用)
host.UseCors(policy =>
    policy.WithOrigins(
        "https://cdn.jsdelivr.net/npm/hls.js@latest",
        "http://localhost:8888"
    ).AllowAnyHeader(
    ).AllowAnyMethod()
);

// MIME設定
var contentTypeProvider = new FileExtensionContentTypeProvider();
contentTypeProvider.Mappings[".ts"] = "video/mp2t";
contentTypeProvider.Mappings[".m3u8"] = "application/vnd.apple.mpegurl";

// /Contents にアクセスしたときに Contents/index.html を返すようにする
host.UseDefaultFiles(new DefaultFilesOptions
{
    FileProvider = new PhysicalFileProvider(contentsDir),
    RequestPath = "/Contents"
});

// /Contents 配下の静的ファイルを配信
host.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(contentsDir),
    RequestPath = "/Contents",
    ContentTypeProvider = contentTypeProvider,
    ServeUnknownFileTypes = false // 不明な拡張子は明示的にマッピングで対応する
});

host.UseRouting();

//host.Run();
await host.RunAsync();

WebサーバーとしてKestrelサーバーを利用します。

.\RtspRecService.csprojを編集します

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UserSecretsId>dotnet-RtspRecService-ed43f9c7-62d3-49ef-9f2a-30014db4106f</UserSecretsId>
    <RootNamespace>RtspRec.RtspRecService</RootNamespace>
    <OutputType>exe</OutputType>
    <PlatformTarget>x64</PlatformTarget>
    <RuntimeIdentifier>win-x64</RuntimeIdentifier>
    <Configuration>Release</Configuration>
    <SelfContained>true</SelfContained>
    <PublishSingleFile Condition="'$(Configuration)' == 'Release'">true</PublishSingleFile>
    <PublishTrimmed>false</PublishTrimmed>
    <PublishDir>C:\RtspRec\</PublishDir>
    <AssemblyName>RtspRecService</AssemblyName>
  </PropertyGroup>

  <ItemGroup>
    <None Remove="mediamtx.yml" />
  </ItemGroup>

  <ItemGroup>
    <Content Include="mediamtx.yml">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
      <CopyToPublishDirectory>Always</CopyToPublishDirectory>
    </Content>
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.10" />
    <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.10" />
    <PackageReference Include="NLog" Version="6.0.5" />
    <PackageReference Include="NLog.Extensions.Logging" Version="6.0.5" />
  </ItemGroup>
</Project>

.\mediamtx.ymlを作成します

rtsp: yes
rtspTransports: [tcp]
hls: yes
hlsAddress: :8888
rtmp: no
webrtc: no
srt: no
paths:
  Camera1:
    source: rtsp://<ユーザ>:<パスワード>@<IPアドレス>:<ポート>/<識別子>
    sourceOnDemand: no
    # ffmpeg実行
    runOnReady: |
            ffmpeg -rtsp_transport tcp \
                -i rtsp://localhost:8554/Camera1 \
                -c:v libx264 -preset veryfast -vf scale=640:360 \
                -r 15 -crf 23 -an \
                -f hls -hls_time 300 -hls_list_size 288 \
                -hls_flags delete_segments \
                -hls_segment_filename .\\Contents\\Camera1\\%Y%m%d_%H%M%S.ts \
                -strftime 1 -reset_timestamps 1 \
                -avoid_negative_ts make_zero \
                .\\Contents\\Camera1\\stream.m3u8
    runOnReadyRestart: yes
  • MediaMTXでカメラデバイスからRTSPのストリームを受信し、ライブ配信用としてRTSP(ffmpeg入力ソース用)、HLS(ライブ視聴用)ストリームを配信します
  • MediaMTXから配信されたRTSPストリームをffmpegの入力ソースとして読み込み録画用HLSファイルを作成します
  • 録画ファイルは今回実装したWebサーバから配信します

.\wwwroot\index.htmlを作成します

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>カメラ映像</title>
    <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
    <style>
        body {
            background: #111;
            color: #eee;
            font-family: sans-serif;
            text-align: center;
        }

        .player {
            display: flex;
            flex-direction: column;
            align-items: stretch;
            width: 100%;
            max-width: 640px;
            margin: 20px auto 0;
            gap: 8px;
        }

        /* select のスタイル */
        #channelSelect {
            align-self: flex-end;
        }

        /* video のスタイル */
        #video {
            border: 1px solid #ccc;
            display: block;
            width: 100%;
            height: auto;
        }

        /* 小さい画面でも同様に機能する */
        @media (max-width: 360px) {
            .player {
                padding: 0 8px;
            }
        }
    </style>
</head>
<body>
    <div class="player">
        <select id="channelSelect">
            <option value="http://localhost:8888/Camera1/index.m3u8">ライブ</option>
            <option value="/Contents/Camera1/stream.m3u8">録画</option>
        </select>
        <video id="video" controls width="640" height="360">
            Your browser does not support HTML5 video.
        </video>
    </div>

    <script>
        const video = document.getElementById('video');
        const select = document.getElementById('channelSelect');
        let hls;

        function loadStream(url) {
            if (hls) {
                hls.destroy(); // 前のインスタンスを破棄
            }

            video.muted = true; // ミュート設定

            if (Hls.isSupported()) {
                hls = new Hls();
                hls.loadSource(url);
                hls.attachMedia(video);
                hls.on(Hls.Events.MANIFEST_PARSED, () => {
                    video.play().catch(error => {
                        console.warn('Autoplay failed:', error);
                    });
                });
            } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
                video.src = url;
                video.addEventListener('loadedmetadata', () => {
                    video.play().catch(error => {
                        console.warn('Autoplay failed:', error);
                    });
                });
            } else {
                document.body.innerHTML += '<p>HLS not supported in this browser.</p>';
            }
        }

        // 初期ロード
        loadStream(select.value);

        // 選択変更時に切り替え
        select.addEventListener('change', () => {
            loadStream(select.value);
        });
    </script>
</body>
</html>
  • hls.jsを利用した視聴画面です
  • ライブ、録画映像の切り替えが可能です

5.起動・視聴確認

RtspRecServiceプロジェクトのカレントディレクトリで実行します。

ビルドします

dotnet build -c Release

パブリッシュします

dotnet publish -r win-x64 -c Release

録画ファイルの保存ディレクトリを作成します

cd C:\RtspRec
New-Item -Path .\Contents\Camera1 -Type Directory

RtspRecServiceを起動します

.\RtspRecService

視聴ページにアクセスし、カメラ映像を視聴します
http://localhost:5000

今回は録画用ファイル作成の設定(ffmpegのコマンドパラメータの設定)は、300セグメント(5分)単位でファイルを作成しています。初回のファイルが作成されるまで録画映像は視聴できません。

6.サービス登録

Windowsサービスとして登録します

New-Service -Name "RtspRec Service" -BinaryPathName "C:\RtspRec\RtspRecService.exe" -DisplayName "RTSP Recorder" -StartupType Manual

Windowsサービスの実行ユーザはデフォルトLocal Userとなります。MediaMTX、ffmpegをWindowsの全ユーザで利用できるようにインストールしてください。

以下、インストール手順です

Windowsターミナルを管理者権限で起動します

1. 「Windows」+「R」キーをファイル名を指定して実行のダイアログを表示します
2. ダイアログの名前のテキストボックスに'wt'を入力後、「Ctrl」+「Shift」+「Enter」キーを実行します

WingetMediaMTX,ffmpegをインストールします

Winget install -e --id bluenviron.mediamtx --scope machine
Winget install -e --id Gyan.FFmpeg --scope machine

コマンドを検索します

Get-Command mediamtx, ffmpeg

CommandType    Name            Version    Source
-----------    ----            -------    ------
Application    mediamtx.exe    0.0.0.0    C:\Program Files\WinGet\Links\mediamtx.exe
Application    ffmpeg.exe      0.0.0.0    C:\Program Files\WinGet\Links\ffmpeg.exe

次回は、WebRTCでのライブ視聴画面の作成を行います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?