#!/bin/bash # ============================================================================================== # ============================= Media Shares Permissions ======================================= # ============================================================================================== # # PURPOSE # ───────────────────────────────────────────────────────────────────────────── # Apply nobody:users ownership and correct permissions to all media shares. # Runs daily as the first job in the maintenance window — arr cleanup depends # on correct ownership to rename and delete files. # # Now a proper daily failsafe: files arrive with wrong ownership from rsync # without --chown, manual admin copies, containers with unconfigured PUID/PGID, # or unRAID environment resets after updates. # # ============================================================================================== # OPERATIONAL MODEL # ============================================================================================== # # For each share in MEDIA_PERMISSION_SHARES: # # 1. Path safety and existence # → unsafe or missing paths are refused or skipped, never scanned # # 2. Count wrong ownership (diagnostic) # → find ! -user / ! -group — the number reported as "corrected" # # 3. Ownership pass — only if the count is non-zero # → chown PERMISSIONS_OWNER on non-matching entries only # # 4. Directory mode pass # → chmod PERMISSIONS_DIR_MODE on directories not already at that mode # # 5. File mode pass # → chmod PERMISSIONS_FILE_MODE on files not already at that mode # → "No such file" errors ignored: volatile dirs (Emby transcodes) race # # Every pass is conditional by design — see Conditional Passes below. # # ============================================================================================== # DESIGN PRINCIPLES # ============================================================================================== # # Daily Failsafe, Not Enforcer # Wrong ownership is a symptom of something else — a misconfigured container, # a manual copy, an rsync without --chown. This script corrects the symptom # daily rather than hunting the root cause. A persistent high correction count # is the signal to investigate the source. # # Runs First in the Window # Arr cleanup scripts depend on correct ownership to rename and delete files. # Permissions must be correct before cleanup runs — ordering is not optional. # # Separate Passes for Directories and Files # Directories need execute permission for traversal; files do not. Applying # the same mode to both is a common mistake this script avoids by design. # # ============================================================================================== # OPERATIONAL SAFEGUARDS # ============================================================================================== # # Root Enforcement # chown to an arbitrary owner requires root. # # Lock Acquisition # acquire_lock "wait" — waits rather than skipping. Share scans are long, and # this runs first in the daily window; skipping it would let arr cleanup run # against uncorrected ownership. # # Host Detection # detect_hosts() aliases HOST*_MEDIA_PERMISSION_SHARES to this host's shares. # # Empty Array Guard # Exits cleanly if no shares are configured for this host. # # Share Path Depth Guard # Every share must be an absolute path at least three levels deep before it is # scanned. A truncated entry like /mnt/user passes an existence check and would # chown and chmod every share on the array — which, because chown/chmod restamp # ctime, would erase the age signal the arr cleanups depend on across the whole # library in a single run. # # Folder Existence # Missing shares are skipped with a warning; remaining shares still process. # # Separate Passes # Directories and files are chmod'd in separate passes — directories need the # execute bit for traversal, media files must not have it. # # Conditional Passes # Only entries whose owner or mode is actually wrong are touched. This is not # an optimisation: chown/chmod rewrite an inode's ctime even when the value is # unchanged, so a blanket pass would restamp every file nightly and destroy # ctime as an age signal. The arr cleanups gate orphan deletion on ctime, and # mtime cannot substitute — imports preserve the release's original timestamp. # Making any pass unconditional silently stops orphan collection. # # Transcode Race Tolerance # "No such file or directory" errors from the file pass are ignored. Volatile # directories such as Emby transcodes delete files mid-scan; that is expected, # not a permissions failure. # # Dry Run Support # --dry-run counts the dirs, files and ownership entries that would change and # modifies nothing. # # Silent by Default # Only failures and diagnostics produce output; a clean run is quiet. # # Diagnostic — high corrected count on every run means a container has wrong PUID/PGID: # Correct values on unRAID: PUID=99 (nobody) PGID=100 (users) # Common culprits: SABnzbd, qBittorrent, slskd — check these first # Once fixed, this script should correct 0 files per run (pure failsafe) # # ============================================================================================== # CONFIGURATION # ============================================================================================== # # host*.conf # # HOST*_MEDIA_PERMISSION_SHARES — shares this host applies permissions to # Aliased by detect_hosts() — script uses MEDIA_PERMISSION_SHARES # # master.conf # # PERMISSIONS_DIR_MODE — directory permissions (default 755) # PERMISSIONS_FILE_MODE — file permissions (default 664) # PERMISSIONS_OWNER — ownership applied to all files (default nobody:users) # # ============================================================================================== # RUNTIME MODES # ============================================================================================== # # media_shares_permissions.sh — normal run # media_shares_permissions.sh --dry-run — preview without making changes # media_shares_permissions.sh --log — verbose output # media_shares_permissions.sh --status — show config and exit # # ============================================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../load_config.sh" parse_args "$@" # ============================================================================================== # ━━━ Setup ━━━ # ============================================================================================== if [[ "$EUID" -ne 0 ]]; then error "Must be run as root" exit 1 fi acquire_lock "wait" # detect_hosts() sets MY_ID and aliases HOST*_MEDIA_PERMISSION_SHARES detect_hosts # Empty array guard if [[ ${#MEDIA_PERMISSION_SHARES[@]} -eq 0 ]]; then warn "MEDIA_PERMISSION_SHARES is empty for $MY_ID — nothing to do" warn "Check HOST*_MEDIA_PERMISSION_SHARES in host*.conf" exit 0 fi [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made" # ============================================================================================== # ━━━ Status ━━━ # ============================================================================================== if [[ "$SHOW_STATUS" == true ]]; then echo "" echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━" echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_PERMS Dir mode: ${PERMISSIONS_DIR_MODE:-755}" echo "$ICON_PERMS File mode: ${PERMISSIONS_FILE_MODE:-664}" echo "$ICON_PERMS Owner: $PERMISSIONS_OWNER" echo "$ICON_PERMS Shares: ${#MEDIA_PERMISSION_SHARES[@]}" echo "" for share in "${MEDIA_PERMISSION_SHARES[@]}"; do local_status="missing" [[ -d "$share" ]] && local_status="exists" echo " $share — $local_status" done echo "$ICON_GEAR Dry Run: $DRY_RUN" echo "━━━━━━━━━━━━━━━━━━━━━━━" exit 0 fi # ============================================================================================== # ━━━ Apply Permissions ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_PERMS Media Permissions — $MY_ID ━━━" log "Dir mode: ${PERMISSIONS_DIR_MODE:-755}" log "File mode: ${PERMISSIONS_FILE_MODE:-664}" log "Owner: $PERMISSIONS_OWNER" log "Shares: ${#MEDIA_PERMISSION_SHARES[@]}" echo "" START=$(date +%s) FAILED=() UPDATED=() SKIPPED=() TOTAL_DIRS_FIXED=0 TOTAL_FILES_FIXED=0 # Split for find's -user/-group predicates, which take them separately PERMISSIONS_USER="${PERMISSIONS_OWNER%%:*}" PERMISSIONS_GROUP="${PERMISSIONS_OWNER##*:}" for SHARE in "${MEDIA_PERMISSION_SHARES[@]}"; do SHARE_NAME=$(basename "$SHARE") # A truncated entry such as /mnt/user passes the -d check below and would chown/chmod # every share on the array. Because chown/chmod restamp ctime, that would erase the age # signal the arr cleanups gate orphan deletion on — across the whole library, in one run. _depth="${SHARE//[^\/]/}" if [[ -z "$SHARE" || "$SHARE" != /* || "${#_depth}" -lt 3 ]]; then error "Refusing to touch unsafe path: '${SHARE:-empty}' — expected an absolute path at least 3 levels deep" notify "Media permissions refused unsafe path on $(hostname): '${SHARE:-empty}'" \ "Media Permissions" "warning" FAILED+=("${SHARE_NAME:-empty}") continue fi if [[ ! -d "$SHARE" ]]; then warn "$SHARE_NAME not found — skipping" SKIPPED+=("$SHARE_NAME") continue fi if [[ "$DRY_RUN" == true ]]; then # Count what would be changed without making changes DIR_COUNT=$(find "$SHARE" -type d ! -perm "${PERMISSIONS_DIR_MODE:-755}" \ 2>/dev/null | wc -l) FILE_COUNT=$(find "$SHARE" -type f ! -perm "${PERMISSIONS_FILE_MODE:-664}" \ 2>/dev/null | wc -l) OWNER_COUNT=$(find "$SHARE" \( ! -user "$PERMISSIONS_USER" -o ! -group "$PERMISSIONS_GROUP" \) \ 2>/dev/null | wc -l) warn "DRY RUN — $SHARE_NAME: $DIR_COUNT dirs, $FILE_COUNT files, $OWNER_COUNT ownership fixes needed" continue fi log "Updating $SHARE_NAME..." CHMOD_DIR_OK=true CHMOD_FILE_OK=true CHOWN_OK=true # Every pass below is conditional — it touches only entries that are actually wrong. # This is not just an optimisation. chown/chmod rewrite an inode's ctime even when the # value is unchanged, so a blanket pass restamps every file in the share each night and # erases ctime as an age signal. The arr cleanups need that signal to tell a file that # just landed from one that has sat untracked for days — mtime can't do it, because an # import preserves the release's original timestamp (measured 2026-07-27: 400 of 400 # files imported that week had mtimes over 7 days old, one of them 9613 days). # Count files with wrong ownership before fixing (diagnostic) WRONG_OWNER=$(find "$SHARE" \( ! -user "$PERMISSIONS_USER" -o ! -group "$PERMISSIONS_GROUP" \) \ 2>/dev/null | wc -l) # Apply ownership first — affects all files and directories if [[ "$WRONG_OWNER" -gt 0 ]]; then find "$SHARE" \( ! -user "$PERMISSIONS_USER" -o ! -group "$PERMISSIONS_GROUP" \) \ -exec chown "$PERMISSIONS_OWNER" {} + 2>/dev/null || CHOWN_OK=false fi # Apply directory permissions — separate pass for correctness # Directories need execute bit — different from files find "$SHARE" -type d ! -perm "${PERMISSIONS_DIR_MODE:-755}" \ -exec chmod "${PERMISSIONS_DIR_MODE:-755}" {} + \ 2>/dev/null || CHMOD_DIR_OK=false # Apply file permissions — no execute bit on media files # Ignore "No such file" errors: race condition with volatile dirs (e.g. Emby transcodes) _chmod_errs=$(find "$SHARE" -type f ! -perm "${PERMISSIONS_FILE_MODE:-664}" \ -exec chmod "${PERMISSIONS_FILE_MODE:-664}" {} + 2>&1 | \ grep -v "No such file or directory" | grep -c "chmod:" || true) [[ "$_chmod_errs" -gt 0 ]] && CHMOD_FILE_OK=false if [[ "$CHMOD_DIR_OK" == true && \ "$CHMOD_FILE_OK" == true && \ "$CHOWN_OK" == true ]]; then log "$ICON_UNLOCKED $SHARE_NAME — permissions applied" UPDATED+=("$SHARE_NAME") # Log diagnostic if many files had wrong ownership if [[ "$WRONG_OWNER" -gt 0 ]]; then warn "$SHARE_NAME — corrected $WRONG_OWNER file(s) with wrong ownership" warn "If this is high, check container PUID/PGID settings (should be PUID=99 PGID=100)" fi TOTAL_DIRS_FIXED=$(( TOTAL_DIRS_FIXED + 1 )) TOTAL_FILES_FIXED=$(( TOTAL_FILES_FIXED + WRONG_OWNER )) else error "$SHARE_NAME — permissions failed" error " chown: $CHOWN_OK chmod dirs: $CHMOD_DIR_OK chmod files: $CHMOD_FILE_OK" FAILED+=("$SHARE_NAME") fi done END=$(date +%s) # ============================================================================================== # ━━━ Summary ━━━ # ============================================================================================== echo "" echo "━━━━━ $ICON_SUMMARY MEDIA PERMISSIONS SUMMARY ━━━━━" echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_PERMS Dir mode: ${PERMISSIONS_DIR_MODE:-755}" echo "$ICON_PERMS File mode: ${PERMISSIONS_FILE_MODE:-664}" echo "$ICON_PERMS Owner: $PERMISSIONS_OWNER" echo "$ICON_TIME Duration: $(format_duration $(( END - START )))" echo "" [[ ${#UPDATED[@]} -gt 0 ]] && log "Updated: ${#UPDATED[@]} shares" [[ ${#SKIPPED[@]} -gt 0 ]] && warn "Skipped: ${SKIPPED[*]} (not found)" [[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}" # Diagnostic — high correction count indicates container PUID/PGID issue if [[ "$TOTAL_FILES_FIXED" -gt 50 ]]; then warn "$TOTAL_FILES_FIXED files had wrong ownership this run" warn "High count suggests a container is not set to PUID=99 PGID=100" warn "Common culprits: SABnzbd, qBittorrent, slskd — check container env vars" fi if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — no changes made" elif [[ ${#FAILED[@]} -gt 0 ]]; then echo "$ICON_ERROR Status: SOME SHARES FAILED" notify "Media permissions failed on $(hostname) — ${FAILED[*]}" \ "Media Permissions" "warning" else echo "$ICON_DONE Status: done — ${#UPDATED[@]} shares updated" fi echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" [[ ${#FAILED[@]} -gt 0 ]] && exit 1 exit 0