カメラの SD カードを NAS に取り込み、分割された動画をつなぎ、Immich へ送るまでを 1 つの画面で行う TrueNAS のカスタムアプリです。
カードを挿すと中身を判別し、NAS のデータセットへコピーします。長い録画がカメラの 都合で分割されていれば 1 本に結合し、RAW+JPEG は Immich 上で 1 枚に束ねます。 撮影日時のずれも、機種ごとの規則に沿って直してから送ります。
SD カード ──► NAS のライブラリ ──► (分かれた動画をつなぐ) ──► Immich
挿す 画面から取り込む 画面で確認 送信は手動
| 取り込み | カードを挿すと機種を判別してコピーします。初めて見るカードは必ず確認を求めます。一度承認したカードは、次から挿すだけで取り込めます |
| 元の構造を保つ | DCIM/100CANON/IMG_0001.JPG のようなカード上の並びをそのまま残すので、NAS を直接開いても辿れます |
| 分かれた動画をつなぐ | 長い録画は、カメラが自動で複数のファイルに分けます(DJI Osmo Pocket は約 16 GiB ごと、Canon EOS は約 4 GB ごと)。これを 1 本に結合し、結果を検証します。不合格なら元のまま残して選ばせます |
| Immich へ送信 | 送信は常に手動です。ホームの「やること」から 1 手で送れます。複数の Immich へ独立に送れます |
| 重複を作らない | 送る前にチェックサムで照合し、既にある写真は上げ直しません(ゴミ箱の中も見ます) |
| 撮影日時の補正 | UTC で書かれる機種は、現地の壁時計へ直してから送ります |
| RAW+JPEG の束ね | 同じシャッターで出た 2 枚を Immich のスタックにまとめます |
| タグ付け | 機種ごとのタグを自動で付けます |
壊さないことを優先しています。 カードは常に読み取り専用で開き、書き込みません。 Immich にある写真の撮影日時を書き換えるときは、自分が上げたと確かめられた場合を 除いて承認を求めます。
| カメラの種類 | 対象 | 取り込む拡張子 | つなぐ | 日時 | 束ねる | タグ |
|---|---|---|---|---|---|---|
dji-osmo |
DJI Osmo Pocket 系 | MP4 / JPG | 約 16 GiB 分割 | ファイル名 → 更新時刻。現地時刻へ補正 | —— | DJI Osmo Pocket 4 |
canon-eos |
Canon EOS(カードリーダー経由) | JPG / CR2 / MOV | 約 4 GB 分割 | EXIF → 器の時刻 → 更新時刻。現地時刻へ補正 | JPG + CR2 | Canon EOS |
generic-dcim |
DCIM を持つ一般的なカード |
JPG / JPEG / PNG / HEIC / MP4 / MOV / AVI | —— | EXIF → 更新時刻。補正しない | —— | —— |
機種は設定 › カメラの種類から追加・編集できます。 最初から入っているものは 直接編集できませんが(アプリを更新すると上書きされるため)、複製すれば自分用に 変えられます。ファイル名の規則、対象の拡張子、日時の取り方、つなぐかどうか、 束ね方を YAML で書きます。詳しくは 使い方ガイド。
- TrueNAS SCALE(Apps → Custom App を使います)
- 取り込み先のデータセット
- Immich v3.1.0 と、そのアカウントの API キー
- USB のカードリーダー、または USB マスストレージとして見えるカメラ
配布イメージがあります(ghcr.io/akashisn/mediaferry と -mountd)。
ビルドは要りません。
対象の Immich は v3.1.0 です。 それより新しい版へ上げるなら、まず
tools/immich_probe.py
で前提が成り立つかを確かめてください(判定は終了ステータスに返ります)。API の形が
変わっていた場合に直す場所は app/src/mediaferry/adapters/immich.py の 1 ファイルに
閉じています。
導入は「データセットを作る → compose.yaml の <...> を埋める →
Custom App に貼る」の 3 段です。
LAN へ公開する前にパスワードを設定してください(既定では本体のループバックに
しか出ません)。
ナビゲーションは「ホーム / 写真 / 設定」の 3 つだけです。
| 画面 | 何をするところか |
|---|---|
| ホーム | いま挿さっているカード、やること、進行中の作業、送り先ごとの状況、さっき取り込んだもの |
| 写真 | 取り込んだメディアを日付ごとのグリッドで。絞り込んで選び、まとめて送信 |
| 設定 | 送り先、カメラの種類、作業(つなぐ・日時の確認・接続中のカード)、記録、詳しい設定、リセット |
作業のページは設定とホームの下にあり、ナビゲーションの項目を増やしません。
| ページ | 何をするところか |
|---|---|
| 接続中のカード | 挿さっているカード。判別結果と理由、スキャン・取り込み・信頼登録 |
| つなぐ | 分かれた動画を探す、つなぐ、検証結果の確認、手での組み立て |
| 日時の確認 | 先に Immich にあった写真の日時を直してよいかの承認 |
| 送る | 送り先 → 対象 → 確認の 3 段 |
進捗と結果はホームが持ちます。送信を始めた後に画面を閉じても、送信は続きます。
Immich の登録と接続の検証、スタックの見送り理由は設定 › 送り先にあります。
環境変数で渡します。MEDIAFERRY_ を頭に付けます(例: MEDIAFERRY_DATA_ROOT)。
「画面から」の欄は、設定 › 詳しい設定で変えられるかどうかです。
| 設定 | 既定 | 画面から | 意味 |
|---|---|---|---|
DATA_ROOT |
/data |
—— | 取り込み先。ここにライブラリと DB が入ります |
BROKER_SOCKET |
/run/mediaferry/broker.sock |
—— | マウント担当のコンテナとの接続口 |
SECRET_KEY |
未設定 | —— | Immich の API キーを暗号化する鍵。送り先を 1 つでも作るなら必須(32 バイトを base64) |
AUTH_PASSWORD |
未設定 | —— | 設定するとログインが要るようになります。LAN へ出すなら設定してください |
BIND_HOST |
127.0.0.1 |
再起動後 | 待ち受けるアドレス。LAN へ出すときだけ 0.0.0.0 にします |
HTTP_PORT |
8080 |
再起動後 | 待ち受けるポート |
TRUSTED_HOSTS |
空 | 再起動後 | 名前でアクセスするとき、その名前を許可します(IP と localhost は既定で通ります) |
AUTO_IMPORT |
trusted |
すぐ | trusted = 承認済みのカードは挿すだけで取り込む / off = 常に手動 |
DEFAULT_TIMEZONE |
未設定 | すぐ | 日時の補正に使う地域(例: Asia/Tokyo)。DJI と Canon のカメラの種類を使うなら必須 |
UPLOAD_TIMEOUT_SECONDS |
86400 |
すぐ | 1 回の送信を待つ上限(大きな動画のため既定は長めです) |
UPLOAD_MAX_ATTEMPTS |
3 |
すぐ | 送信の再試行の上限 |
LOG_LEVEL |
info |
すぐ | debug / info / warning / error |
「——」の 4 つは TrueNAS のアプリ設定でだけ決められます(DB 自身の置き場所と、 秘密の置き場所なので、アプリの DB には保存しません)。
Immich の URL と API キーは環境変数では設定しません。 画面の「設定 › 送り先」から 登録します(鍵は暗号化して保存され、画面にも API にも出てきません)。
SECRET_KEY は次のように作れます。
python3 -c "import base64, os; print(base64.b64encode(os.urandom(32)).decode())"- カードは読み取り専用でしか開きません。 書き込む経路がありません
- マウントする側とアプリを別のコンテナに分けています。 特権が要るのはマウント側 だけで、アプリ側は権限を落とし、ファイルシステムも読み取り専用で動きます
- アプリに渡すのは、切り離したツリー由来のディレクトリだけです。 通常のマウントを 渡すと、そこから親ディレクトリへ抜けられてしまうため(実際に抜けられることを 確認したうえでこの方式にしています)
- カードの判別は、中身を確かめてから確定します。 USB の ID だけでは確定しません (同じ ID の別機種や、他人のカードを誤って取り込む経路になるため)
- 同じデータの置き場所を 2 つのプロセスに持たせません。 後から起動した側は、 移行も片付けも走らせる前に止まります(重なると、動いている取り込みを「中断」に 倒して作業用ディレクトリを消してしまうため)
- Immich にある写真の撮影日時を書き換えるときは、自分が上げたと確かめられた場合を 除いて承認を求めます。 タグを既存の写真にも付けるかどうかは、カメラの種類ごとに 選べます
- Immich から受け取った識別子は、保存する前と使う前の両方で検めます。 侵害された サーバが API キーを識別子として返しても保存されません
- API キーは暗号化して保存し、画面・API 応答・ログ・エラーメッセージのどこにも出しません
よくあるものは使い方ガイドの「困ったとき」に まとめてあります。分かっていてまだ直していないことは 既知の制限にあります。
- カードが「対象外」と出る → 「接続中のカード」に判別できなかった理由が出ます
- 送信が始まらない → 送り先が休止中・退役済みでないか、
SECRET_KEYがあるかを確認 - 起動しない → タイムゾーンが要るカメラの種類を使っていて
DEFAULT_TIMEZONEが 未設定だと、取り込みを始める前に止めます(誤った時刻で確定させないため) - 「同じデータの置き場所を別のプロセスが使っている」と出て起動しない → 同じデータセットを見るアプリが既に動いています。 入れ替えのときは、古い方が 完全に止まってから新しい方を起動してください
Apache License 2.0(LICENSE)
開発の入口は docs/development.md です(環境の癖、開発
コマンド、テストの作法)。
uv sync --all-packages # --all-packages が必須(素の sync ではメンバーが入りません)
uv run pytest # 1,782 件
uv run ruff check . && uv run ruff format --check .
npm --prefix web ci
npm --prefix web test # 670 件
npm --prefix web run test:e2e # 21 件。実プロセス + 偽ブローカー + 偽 Immichnpm run test:e2e は既定の検査に入っていません。単独で回してください。
既定の pytest から外してあるものが 2 つあります。
uv run pytest -m needs_root # ユーザ名前空間が使える環境でのみ(1 件)
uv run pytest -m needs_immich # 実 Immich が要る。書き込むので捨ててよい実例で(4 件)main への push と v* のタグで ghcr.io へ公開されます(latest が動くのは
タグのときだけ)。手元でビルドするときはリポジトリの根を文脈にしてください。
docker build -f app/Dockerfile -t mediaferry .
docker build -f mountd/Dockerfile -t mediaferry-mountd .| パッケージ | 役割 |
|---|---|
protocol/ |
2 つのコンテナが共有するソケットプロトコル |
mountd/ |
特権側。USB を読み取り専用でマウントし、切り離したツリーを渡す |
app/ |
非特権側。取り込み・結合・アップロード・Web UI |
web/ |
画面(React + TypeScript) |
| 知りたいこと | 見る場所 |
|---|---|
| 導入したい | docs/setup.md |
| 使い方 | docs/user-guide.md |
| バックアップ | docs/backup.md |
| 分かっている制限 | docs/known-issues.md |
| いまどうなっているか(仕様) | docs/design.md |
| なぜそうなっているか(判断と理由) | docs/decisions.md |
| 開発する | docs/development.md・CLAUDE.md |
| どう作ったか(実装計画、レビュー記録、実機検証) | docs/history/ |
| 前提を実機で確かめたい | tools/ |
docs/history/ は開発の記録です。 現在の姿を知るのに読む必要はありません
(食い違ったら design.md と decisions.md が正です)。