archive-organize-sync¶
Behaviors
A timer-driven NixOS behavior that keeps a growing local archive tidy and backed up, then reclaims local disk — without ever risking a file that hasn't been safely copied first.
Each run does three things, in this order:
- Organize — sort loose files in the archive's subdirectories into
YYYY.MMmonth folders, keyed on each file's modification time. - Sync — additively
rsyncthe archive to a remote/backup directory. The remote is the durable side; nothing is deleted there. - Prune — delete local files older than N days to free space.
The trap this recipe encodes¶
The ordering is the whole point, and step 3 has a safety gate that is easy to get wrong.
A local file is deleted only after its remote copy is proven byte-identical by sha256. Not "rsync exited 0", not "the path exists on the remote" — an actual content hash comparison per file:
If a sync is interrupted, the remote is on a different filesystem that dropped the write, or a file changed between sync and prune, the hashes won't match and the local copy stays. Pruning can therefore never outrun a successful backup. This is why the sequence must be organize → sync → verify → prune, and why prune must re-hash rather than trust the sync's exit status.
The second trap: yt-dlp sidecars¶
Tools like yt-dlp write a media file plus sidecars that share a filename stem
but have different mtimes — video.mp4, video.info.json,
video.en.srt, video.webp. Sorting each file independently by its own mtime
scatters a video's metadata across different month folders.
The ytdlp mode fixes this: it anchors each group on the .info.json, finds
the group's media file (the largest same-stem member), and moves the whole
group into the media file's month. The .info.json lands next to its
video. If the media is missing (download failed, json only), it falls back to
the json's own date.
Usage¶
{
imports = [ ./archive-organize-sync ];
services.archiveOrganizeSync = {
enable = true;
user = "alice"; # owner of localPath
localPath = "/home/alice/archive";
remotePath = "/mnt/backup/archive"; # a locally-mounted path
retentionDays = 14;
folders = [
{ name = "downloads"; mode = "organize"; } # sort + sync + prune
{ name = "screenshots"; mode = "organize"; }
{ name = "photos"; mode = "sync-only"; } # back up, never delete
{ name = "videos"; mode = "ytdlp"; } # sort keeping sidecars grouped
];
defaultMode = "sync-prune"; # any subdir not listed above
interval = "daily";
};
}
The module is self-contained — it ships both helper scripts (archive-sync-start
and organize-yyyy-mm) inline, so importing this one directory is enough.
Options¶
| Option | Default | Meaning |
|---|---|---|
enable |
false |
Turn the timer + oneshot on. |
user |
"archive" |
User the oneshot runs as; set to the owner of localPath. |
localPath |
/var/lib/archive |
Archive root; its immediate subdirectories are processed. |
remotePath |
/mnt/backup/archive |
rsync destination (see caveat below). |
retentionDays |
14 |
Prune local files older than this — only if the remote copy hashes equal. |
folders |
[ ] |
Per-subdirectory rules (name + mode). |
defaultMode |
"sync-prune" |
Mode for any subdirectory not named in folders. |
interval |
"daily" |
systemd OnCalendar expression. |
Modes¶
| Mode | Organize into YYYY.MM |
Sync to remote | Verified prune |
|---|---|---|---|
organize |
yes | yes | yes |
ytdlp |
yes (sidecars grouped) | yes | yes |
ytdlp-only |
yes (sidecars grouped) | yes | no |
sync-only |
no | yes | no |
sync-prune |
no | yes | yes |
Use sync-only (or ytdlp-only, which still sorts) for anything you never
want auto-deleted locally (photo libraries, VM images, a media library the
remote is a backup of rather than an offload target), and
organize/ytdlp/sync-prune for churn you're happy to offload to backup
after a couple of weeks.
Keep-list: pinning files that must not be sorted¶
organize-yyyy-mm reads an optional .organize-yyyy-mm.config in the directory
it's organizing — one filename per line, # comments allowed. Listed names (and
the config file itself) are never moved into a month folder. Handy for a README,
an index, or a working file that lives at the directory root.
Caveats¶
remotePathis a plain filesystem path. The verified-prune gate re-reads and hashes the remote file, so the remote must be a locally reachable path. For an off-host backup, mount it first (NFS, sshfs, an rclone mount, a bind-mounted block device) and pointremotePathat the mountpoint. If the mount is absent at run time, rsync recreates the directory locally and files will not hash-match a real backup — verify your mount is up (e.g. order the serviceafterthe mount unit, or have the script bail if a sentinel file on the mount is missing).- mtime is the sort key. Files whose mtime doesn't reflect their real date
(restored-from-backup,
cpwithout-p, unzipped archives) sort into the wrong month. Preserve timestamps when moving things into the archive. - Month folders already matching
YYYY.MMare left alone, so re-runs are idempotent and safe to run on a tight timer. - The prune walk is per-file
sha256sumon both sides; on very large archives that's I/O the run pays every cycle. TuneretentionDaysandintervalaccordingly.
Source¶
behaviors/archive-organize-sync/default.nix
# archive-organize-sync
#
# A timer-driven NixOS behavior that:
# 1. Organizes a local archive directory into YYYY.MM month folders (by mtime),
# 2. Additively rsyncs it to a remote/backup directory, then
# 3. Prunes local files older than N days — but ONLY after sha256-verifying
# that an identical copy already exists on the remote.
#
# The important part is the ordering and the safety gate: organize -> sync ->
# verify -> prune. A local file is never deleted until its remote twin is
# proven byte-identical (sha256), so an interrupted or failed sync can never
# cause data loss. yt-dlp sidecar files (.info.json, thumbnails, subtitles) are
# kept grouped with their media by anchoring the whole group on the *media*
# file's date rather than each sidecar's own mtime.
#
# This module is self-contained: it ships both helper scripts inline, so you
# can drop it into any NixOS configuration and `import` it. Nothing outside
# this file is required.
#
# Usage:
# imports = [ ./archive-organize-sync ];
# services.archiveOrganizeSync = {
# enable = true;
# user = "alice";
# localPath = "/home/alice/archive";
# remotePath = "/mnt/backup/archive";
# retentionDays = 14;
# folders = [
# { name = "downloads"; mode = "organize"; } # organize + sync + prune
# { name = "screenshots"; mode = "organize"; }
# { name = "photos"; mode = "sync-only"; } # never prune, never reorganize
# { name = "videos"; mode = "ytdlp"; } # organize keeping sidecars grouped
# ];
# defaultMode = "sync-prune"; # any subdir not listed above
# };
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.archiveOrganizeSync;
# --- organize-yyyy-mm ----------------------------------------------------
# Sorts loose entries in the current directory into YYYY.MM folders keyed on
# each entry's mtime. A `.organize-yyyy-mm.config` keep-list (one name per
# line, #-comments allowed) pins files that must never be sorted. With
# `--ytdlp`, media files and their same-stem sidecars are moved together into
# the media file's month, so an .info.json lands next to its video instead of
# being sorted independently by its own (different) mtime.
organize-yyyy-mm = pkgs.writeShellApplication {
name = "organize-yyyy-mm";
runtimeInputs = with pkgs; [ coreutils ];
text = ''
set -euo pipefail
clobber=false
dry_run=false
ytdlp=false
dir=""
for arg in "$@"; do
case "$arg" in
--clobber) clobber=true ;;
--dry-run|-n) dry_run=true ;;
--ytdlp) ytdlp=true ;;
*) dir="$arg" ;;
esac
done
if [[ -n "$dir" ]]; then
if [[ ! -d "$dir" ]]; then
echo "Error: Directory '$dir' does not exist" >&2
exit 1
fi
cd "$dir"
fi
get_date_folder() {
local timestamp
timestamp=$(stat -c %Y "$1")
date -d "@$timestamp" +"%Y.%m"
}
move_entry() {
local entry="$1" folder="$2"
if [[ "$clobber" == false ]] && [[ -e "$folder/$entry" ]]; then
echo "Skipping '$entry' (already exists in $folder/)" >&2
return
fi
if [[ "$dry_run" == true ]]; then
echo "Would move '$entry' to $folder/"
else
echo "Moving '$entry' to $folder/"
mv "$entry" "$folder/"
fi
}
shopt -s nullglob
# Files that live in this directory permanently and must never be sorted
# into a month folder. Read one filename per line from
# .organize-yyyy-mm.config (in the directory being organized); blank lines
# and #-comments are ignored.
declare -A keep=()
if [[ -f .organize-yyyy-mm.config ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
line="''${line%%#*}"
line="''${line#"''${line%%[![:space:]]*}"}"
line="''${line%"''${line##*[![:space:]]}"}"
[[ -z "$line" ]] && continue
keep["$line"]=1
done < .organize-yyyy-mm.config
fi
# Never sort the config file itself.
keep[.organize-yyyy-mm.config]=1
# Entries already placed by the --ytdlp pass, so the generic per-entry
# pass below skips them (matters in --dry-run, where they're not actually
# moved).
declare -A grouped=()
if [[ "$ytdlp" == true ]]; then
# yt-dlp writes a media file plus sidecars (info.json, thumbnail,
# subtitles) that share a stem but have different mtimes. Anchor each
# group on its .info.json, then move the whole group into the *media*
# file's month so the json info lands next to its video instead of
# being sorted independently.
for info in *.info.json; do
stem="''${info%.info.json}"
members=()
for f in "$stem".*; do
[[ -e "$f" ]] || continue
members+=("$f")
done
# The media file is the largest member that isn't the info.json; it
# sets the group's folder. If there's no media (download failed, json
# only), fall back to the info.json's own date.
media=""
media_size=-1
for m in "''${members[@]}"; do
[[ "$m" == "$info" ]] && continue
size=$(stat -c %s "$m")
if (( size > media_size )); then
media_size=$size
media="$m"
fi
done
folder=$(get_date_folder "''${media:-$info}")
mkdir -p "$folder"
for m in "''${members[@]}"; do
move_entry "$m" "$folder"
grouped["$m"]=1
done
done
fi
for entry in *; do
if [[ "$entry" =~ ^20[0-9][0-9]\.[0-9][0-9]$ ]]; then
continue
fi
[[ -n "''${keep[$entry]:-}" ]] && continue
[[ -n "''${grouped[$entry]:-}" ]] && continue
folder=$(get_date_folder "$entry")
mkdir -p "$folder"
move_entry "$entry" "$folder"
done
'';
};
# --- routing table -------------------------------------------------------
# Build the bash associative array that maps a subdirectory name to its mode.
folderModeLines = lib.concatMapStringsSep "\n" (
f: "MODE[${lib.escapeShellArg f.name}]=${lib.escapeShellArg f.mode}"
) cfg.folders;
# --- archive-sync-start --------------------------------------------------
# Walks each immediate subdirectory of LOCAL_PATH and applies its mode:
#
# organize organize into YYYY.MM, sync to remote, prune verified-synced
# files older than retentionDays
# ytdlp like organize but keeps yt-dlp sidecars grouped with media
# ytdlp-only like ytdlp but never prunes -- for a library the remote is a
# backup of rather than an offload target
# sync-only sync to remote, never reorganize, never prune
# sync-prune sync to remote, prune verified-synced old files (no reorganize)
#
# Prune is gated on a sha256 match against the remote copy: a local file is
# removed only once its remote twin is proven byte-identical.
archive-sync-start = pkgs.writeShellApplication {
name = "archive-sync-start";
runtimeInputs = with pkgs; [
coreutils
findutils
rsync
organize-yyyy-mm
];
text = ''
set -euo pipefail
if [ "$#" -lt 2 ]; then
echo "Usage: $0 LOCAL_PATH REMOTE_PATH [RETENTION_DAYS] [DEFAULT_MODE]" >&2
exit 1
fi
LOCAL_PATH=$(realpath "$1")
REMOTE_PATH=$(realpath -m "$2")
RETENTION_DAYS="''${3:-14}"
DEFAULT_MODE="''${4:-sync-prune}"
# Subdirectory -> mode routing table (populated from module options).
declare -A MODE=()
${folderModeLines}
ORIGINAL_DIR=$(pwd)
trap 'cd "$ORIGINAL_DIR"' EXIT
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
mkdir -p "$REMOTE_PATH"
# Additive rsync: copy new/changed files to the remote, never delete
# there. The remote is the durable side.
sync_files() {
local source="$1" target="$2"
mkdir -p "$target"
log "Syncing files from $source to $target"
if ! rsync -av "$source/" "$target/"; then
log "Error: rsync failed for $source to $target"
return 1
fi
}
# Prune local files older than RETENTION_DAYS, but only after proving the
# remote copy is byte-identical (sha256). This is the safety gate: an
# interrupted or partial sync can never cause local data loss.
delete_old_files() {
local source="$1" target="$2"
log "Checking for old files to delete in $source"
find "$source" -type f -mtime "+$RETENTION_DAYS" -print0 |
while read -d "" -r source_file; do
rel_path="''${source_file#"$source"/}"
target_file="$target/$rel_path"
if [ -f "$target_file" ] &&
[ "$(sha256sum "$source_file" | cut -d' ' -f1)" = \
"$(sha256sum "$target_file" | cut -d' ' -f1)" ]; then
log "Deleting verified old file: $source_file"
rm "$source_file"
fi
done
}
# Organize a yt-dlp-style download dir: move media into YYYY.MM, then move
# each .info.json next to whichever media file it belongs to.
organize_videos() {
local dir="$1"
log "Organizing videos in $dir"
cd "$dir"
if ! organize-yyyy-mm --ytdlp; then
log "Error: organize-yyyy-mm failed for $dir"
return 1
fi
}
log "Starting directory processing from $LOCAL_PATH"
find "$LOCAL_PATH" -mindepth 1 -maxdepth 1 -type d -print0 |
while read -d "" -r dir; do
dir_name=$(basename "$dir")
mode="''${MODE[$dir_name]:-$DEFAULT_MODE}"
log "Processing directory: $dir_name (mode: $mode)"
case "$mode" in
organize)
cd "$dir" && organize-yyyy-mm
sync_files "$dir" "$REMOTE_PATH/$dir_name"
delete_old_files "$dir" "$REMOTE_PATH/$dir_name"
;;
ytdlp)
organize_videos "$dir"
sync_files "$dir" "$REMOTE_PATH/$dir_name"
delete_old_files "$dir" "$REMOTE_PATH/$dir_name"
;;
ytdlp-only)
organize_videos "$dir"
sync_files "$dir" "$REMOTE_PATH/$dir_name"
;;
sync-only)
sync_files "$dir" "$REMOTE_PATH/$dir_name"
;;
sync-prune|*)
sync_files "$dir" "$REMOTE_PATH/$dir_name"
delete_old_files "$dir" "$REMOTE_PATH/$dir_name"
;;
esac
done
log "Archive sync completed successfully"
'';
};
folderModule = lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "Immediate subdirectory of localPath this rule applies to.";
};
mode = lib.mkOption {
type = lib.types.enum [
"organize"
"ytdlp"
"ytdlp-only"
"sync-only"
"sync-prune"
];
default = "sync-prune";
description = ''
How to process this subdirectory:
- organize: sort into YYYY.MM, sync, prune verified-synced old files
- ytdlp: like organize but keep yt-dlp sidecars grouped with media
- ytdlp-only: like ytdlp but never prune
- sync-only: sync to remote, never reorganize or prune
- sync-prune: sync to remote, prune verified-synced old files (no sort)
'';
};
};
};
in
{
options.services.archiveOrganizeSync = {
enable = lib.mkEnableOption "archive organize-then-sync-then-prune timer";
user = lib.mkOption {
type = lib.types.str;
default = "archive";
description = "User the oneshot runs as. Set this to the owner of localPath.";
};
localPath = lib.mkOption {
type = lib.types.str;
default = "/var/lib/archive";
example = "/home/alice/archive";
description = "Local archive directory whose immediate subdirectories are processed.";
};
remotePath = lib.mkOption {
type = lib.types.str;
default = "/mnt/backup/archive";
example = "/srv/archive";
description = ''
Destination the archive is additively rsynced into. For an off-host
target, point this at a locally-mounted path (NFS, sshfs, rclone mount,
etc.) — the module uses a plain filesystem rsync so the durable copy can
be verified with sha256 before local pruning.
'';
};
retentionDays = lib.mkOption {
type = lib.types.int;
default = 14;
description = ''
Prune local files older than this many days — but only once an
identical (sha256) copy is confirmed present on the remote.
'';
};
folders = lib.mkOption {
type = lib.types.listOf folderModule;
default = [ ];
example = lib.literalExpression ''
[
{ name = "downloads"; mode = "organize"; }
{ name = "screenshots"; mode = "organize"; }
{ name = "photos"; mode = "sync-only"; }
{ name = "videos"; mode = "ytdlp"; }
]
'';
description = "Per-subdirectory processing rules. Unlisted subdirectories use defaultMode.";
};
defaultMode = lib.mkOption {
type = lib.types.enum [
"organize"
"ytdlp"
"ytdlp-only"
"sync-only"
"sync-prune"
];
default = "sync-prune";
description = "Mode applied to any subdirectory of localPath not named in `folders`.";
};
interval = lib.mkOption {
type = lib.types.str;
default = "daily";
description = "systemd OnCalendar expression for how often to run.";
};
};
config = lib.mkIf cfg.enable {
systemd.services.archive-organize-sync = {
description = "Archive organize, sync, and verified-prune";
serviceConfig = {
Type = "oneshot";
User = cfg.user;
ExecStart = lib.escapeShellArgs [
"${archive-sync-start}/bin/archive-sync-start"
cfg.localPath
cfg.remotePath
(toString cfg.retentionDays)
cfg.defaultMode
];
};
};
systemd.timers.archive-organize-sync = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = cfg.interval;
Persistent = true;
};
};
environment.systemPackages = [
archive-sync-start
organize-yyyy-mm
];
};
}