Last active
August 4, 2022 19:11
-
-
Save elmimmo/6f558b7483a191d056d1d7810789d8c5 to your computer and use it in GitHub Desktop.
Dump contents of optical media upon inserting, then eject and repeat. (requires macOS)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env bash | |
# Dump contents of optical media upon inserting, then eject and repeat. | |
# | |
# Requires macOS. | |
# | |
# If ran without the appropriate env variables, it will ask for: | |
# * destination | |
# * order no. to be recorded for the first media being copied. The orden no. | |
# of each copy which will be logged into a CSV file created in _destination_ | |
# and into a Finder/Spotlight comment of the copy itself. | |
# * drive to copy from, if there are more than one. | |
# | |
# As the copy progresses, it will also create a “.log” file next to the copy | |
# itself, reporting each copied file and which ones produced an error, as a | |
# sorts of progress indicator. When the copy finishes: | |
# 1. If no errors happened, it will delete the log file | |
# 2. If there where errors, it will append “ - errors” to the filename before | |
# the extension and preserve it. Note that this log file does not | |
# necessarily list all files that were not copied, but only those that | |
# failed before the copy command gave up. | |
# 3. If the copy was interrupted (and given an instant to tidy up via ^C or by | |
# closing the Terminal window), it will append “ - interrupted”. | |
# Interrupted copies are NOT recorded in the CSV file nor their order no. | |
# written as Finder/Spotlight comment. | |
# | |
# If growlify is installed, it will generate notifications for each copy, sticky | |
# ones if there were errors or if the copy process was stopped. | |
# | |
# Author: Jorge Hernández Valiñani | |
# Usage message | |
USAGE="Usage: OUT_DIR=\"PATH\" [DISC_NO=NUMBER] [DRIVE_NO=NUMBER] ${0##*/} | |
OUT_DIR Destination that will contain the copy. Will ask if undeclared. | |
DISC_NO Order number of the first disc being copied. If blank, resumes | |
from last copy in destination, or starts at 1 if none present. | |
Will ask if undeclared. | |
DRIVE_NO Drive to watch (if more than 1), as reported by \`drutil list\`. | |
Will ask if undeclared and several drives found. | |
NEW_MEDIA_TIMEOUT Time to wait for new media before giving up. 0=infinity. | |
CHECK_MEDIA_INTERVAL Time interval to test whether new media was inserted. | |
LIST_OF_DUMPED_MEDIA Filename of the CSV record of discs copied and their | |
order no." | |
: ${NEW_MEDIA_TIMEOUT:="300"} | |
: ${CHECK_MEDIA_INTERVAL:="10"} | |
: ${LIST_OF_DUMPED_MEDIA:="_list.csv"} | |
: ${VERBOSE:=""} | |
: ${DITTOABORT:="true"} | |
# Exit if anything breaks | |
set -e | |
if [ -n "$VERBOSE" ]; then | |
set -x | |
fi | |
# Send some of the output to Growl if present | |
_notify () { | |
# Argument: "(emoji) Disc (digits) (message) | |
# For example: "✅ Disc 1 copied successfully" | |
local ARGS="$@" | |
local STATUS=${ARGS#*Disc *[0-9] } | |
local TITLE="${ARGS% $STATUS}" | |
# Change case of 1st char in `$STATUS` | |
local STATUS_1ST=$(echo $STATUS | cut -c1 | tr ‘[[:lower:]]’ ‘[[:upper:]]’) | |
local STATUS=${STATUS_1ST}$(echo $STATUS | cut -c2-) | |
case "$TITLE" in | |
[⚠️‼️❗️]*) | |
echo "$@" 1>&2 \ | |
&& growlnotify --name "${0##*/}" \ | |
--title "$TITLE" \ | |
--message "$STATUS" \ | |
--sticky 2>&- \ | |
|| true | |
;; | |
*) | |
echo "$@" \ | |
&& growlnotify --name "${0##*/}" \ | |
--title "$TITLE" \ | |
--identifier "${NOTIFICATION_ID:=$RANDOM}" \ | |
--message "$STATUS" 2>&- \ | |
|| true | |
;; | |
esac | |
} | |
# Prompt on exit | |
_cleanup () { | |
# Log the interruption | |
local CURRENT_LOG="${OUT_DIR}$(basename "$COPY_PATH").log" | |
if [ -f "$CURRENT_LOG" ]; then | |
echo "[interrupted]">>"$CURRENT_LOG" | |
mv "$CURRENT_LOG" "${CURRENT_LOG%.log} - interrupted.log" | |
fi | |
echo "" | |
echo "⚠️ ${0##*/} stopped" 1>&2 \ | |
&& growlnotify --name "${0##*/}" \ | |
--title "⚠️ ${0##*/} stopped" \ | |
--message "Media dumping process stopped." \ | |
--sticky 2>&- \ | |
|| true | |
} | |
trap "_cleanup" EXIT | |
# https://gist.github.com/cdown/1163649#gistcomment-2794829 | |
_urlencode() { | |
if [[ $# != 1 ]]; then | |
echo "Usage: $0 string-to-urlencode" | |
return 1 | |
fi | |
local data="$(curl -s -o /dev/null -w %{url_effective} --get --data-urlencode "$1" ""|sed 's/%2F/\//g;')" | |
if [[ $? == 0 ]]; then | |
echo "${data##/?}" | |
fi | |
return 0 | |
} | |
# Clear the screen | |
if [ -z "$OUT_DIR" -o -z "$DISC_NO" ]; then | |
printf "\033c" | |
fi | |
# Ask for `OUT_DIR` | |
if [ -z "$OUT_DIR" ]; then | |
read -p "Drag destination folder here & press Enter: " OUT_DIR | |
fi | |
# `$OUT_DIR` should exist | |
if [ ! -d "$OUT_DIR" ]; then | |
echo "❗️ Failed. Destination folder '${OUT_DIR}' does not exist." 1>&2 | |
exit 1 | |
fi | |
# `$OUT_DIR` should have trailing slash | |
if [[ ! "$OUT_DIR" =~ /$ ]]; then | |
OUT_DIR="${OUT_DIR}"/ | |
fi | |
# Ask for `DISC_NO` | |
if [ -z "$DISC_NO" ]; then | |
read -p "Enter Disc number (leave blank for autonumbering) & press Enter: " \ | |
DISC_NO | |
echo "" | |
fi | |
# Get list of already dumped disc numbers | |
DUMPED_DISCS=( $(cat "${OUT_DIR}${LIST_OF_DUMPED_MEDIA}" 2>&- \ | |
|sed 's/^.*\;//;' \ | |
|sort -g) ) | |
if [ ${#DUMPED_DISCS[@]} -gt 0 ]; then | |
LAST_DUMPED_DISC=${DUMPED_DISCS[${#DUMPED_DISCS[@]} - 1]} | |
fi | |
# `$LAST_DUMPED_DISC` should be an integer | |
if [ -z "$DISC_NO" -a ${#DUMPED_DISCS[@]} -gt 0 ]; then | |
case "$LAST_DUMPED_DISC" in | |
''|*[!0-9]*) | |
echo "❗️ Failed. Last disc no. in $LIST_OF_DUMPED_MEDIA ($LAST_DUMPED_DISC) should be an integer." 1>&2 | |
exit 1 | |
;; | |
esac | |
fi | |
# Disc autonumbering | |
: ${DISC_NO:=$(( $LAST_DUMPED_DISC + 1 ))} | |
# `$DISC_NO` should be an integer | |
case "$DISC_NO" in | |
''|*[!0-9]*) | |
echo "❗️ Failed. The disc no. should be an integer." 1>&2 | |
exit 1 | |
;; | |
esac | |
# Check if explicit `$DISC_NO` already exists at destination | |
if [[ " ${DUMPED_DISCS[@]} " =~ " ${DISC_NO} " ]]; then | |
echo "❗️ Failed. Disc ${DISC_NO} already exists at destination." 1>&2 | |
echo "Leave disc no. blank to resume from last dumped." 1>&2 | |
exit 1 | |
fi | |
# Get list of drives | |
SAVEIFS=$IFS | |
IFS=$(echo -en "\n\b") | |
DRIVES=( $(drutil list|tail -n +2) ) | |
IFS=$SAVEIFS | |
# There should be a drive at all | |
if [ ${#DRIVES[@]} -eq 0 ]; then | |
echo "❗️ Failed. No drives found." 1>&2 | |
exit 1 | |
fi | |
# Ask for drive when more than one is present | |
if [ ${#DRIVES[@]} -gt 1 -a -z "$DRIVE_NO" ]; then | |
echo "${#DRIVES[@]} drives found:" | |
for ((i= 0; i < ${#DRIVES[@]}; i++)); do | |
echo "${DRIVES[$i]}" | |
done | |
echo "" | |
read -p "Enter id no. of optical drive to copy from & press Enter: " DRIVE_NO | |
fi | |
# Default drive to watch | |
: ${DRIVE_NO:="1"} | |
# `$DRIVE_NO` should be an integer | |
case "$DRIVE_NO" in | |
''|*[!0-9]*) echo "❗️ Failed. DRIVE_NO should be an integer." 1>&2 | |
echo "See a list of available drives by running \`drutil list\`." 1>&2 | |
exit 1;; | |
esac | |
# `$DRIVE_NO` should be present | |
if [ ${#DRIVES[@]} -lt $(( $DRIVE_NO )) ]; then | |
echo "❗️ Failed. Drive no. $DRIVE_NO is not present." 1>&2 | |
echo "See a list of available drives by running \`drutil list\`." 1>&2 | |
exit 1 | |
fi | |
# `$NEW_MEDIA_TIMEOUT` should be an integer | |
case "$NEW_MEDIA_TIMEOUT" in | |
''|*[!0-9]*) | |
echo "❗️ Failed. Stand-by time ($NEW_MEDIA_TIMEOUT) should be an integer." 1>&2 | |
exit 1 | |
;; | |
esac | |
# If the display sleeps, some drives will sleep too in the middle of a copy and | |
# produce errors, so wake up! | |
if [ -x "$(type -P caffeinate 2>&-)" ]; then | |
caffeinate -u -t 1 2>&- || true | |
# and stay up! | |
if ! pgrep -q caffeinate 2>&-; then | |
caffeinate -d -w $$ & | |
fi | |
fi | |
# Wait until disc is detected | |
echo "Waiting for new optical media…" | |
SECONDS=0 | |
until $(drutil -drive $DRIVE_NO status 2>&-|grep -q -m1 -o '/dev/disk[0-9]*'); do | |
# Stop if elapsed time is too long | |
if [ $NEW_MEDIA_TIMEOUT -ne 0 -a $SECONDS -ge $NEW_MEDIA_TIMEOUT ]; then | |
echo "❗️ No media was detected for $NEW_MEDIA_TIMEOUT seconds." 1>&2 | |
exit 1 | |
fi | |
sleep $CHECK_MEDIA_INTERVAL | |
done | |
# Get path of volumes in inserted media | |
DISK_ID=$(drutil -drive $DRIVE_NO status 2>&-|grep -m1 -o '/dev/disk[0-9]*') | |
SAVEIFS=$IFS | |
IFS=$(echo -en "\n\b") | |
unset VOLUMES | |
# Sometimes mounting takes a flash, so wait a bit just in case | |
until [ -n "$VOLUMES" ]; do | |
sleep 2 | |
VOLUMES=( $(df|grep "$DISK_ID"|grep -o /Volumes.*||true) ) | |
done | |
IFS=$SAVEIFS | |
# Loop for each volume | |
_notify "📀 Disc ${DISC_NO} copy attempt started." | |
unset IS_ERROR | |
for (( i=0; i<${#VOLUMES[@]}; i++ )) | |
do | |
COPY_PATH="$OUT_DIR""$(basename "${VOLUMES[$i]}")" | |
# Check if target folder already exists in destination | |
if [ -e "$COPY_PATH" ]; then | |
echo "“$(basename "${VOLUMES[$i]}")” already exists." 1>&2 | |
CREATION_DATE=$(stat -f %SB -t "%Y-%m-%d %H-%M-%S" "${VOLUMES[$i]}") | |
CURRENT_DATE=$(date "+%Y-%m-%d %H-%M-%S") | |
if [ ! -e "${COPY_PATH} ($CREATION_DATE)" ]; then | |
# Append creation date to target folder’s name | |
COPY_PATH="${COPY_PATH} ($CREATION_DATE)" | |
else | |
# Append current date instead, if target + creation date exists too | |
COPY_PATH="${COPY_PATH} ($CREATION_DATE) [$CURRENT_DATE]" | |
fi | |
echo "Will copy as “$(basename "$COPY_PATH")”." 1>&2 | |
fi | |
# Copy | |
echo "See progress at file://$(_urlencode "${COPY_PATH}.log")" | |
ditto -V "${VOLUMES[$i]}" "$COPY_PATH" &> "${COPY_PATH}.log"|| IS_ERROR=true | |
if grep -q -m 1 "ditto:" "${COPY_PATH}.log"; then | |
sed '/ditto:/!d;s/^.*\/Volumes\///;' "${COPY_PATH}.log" > "${COPY_PATH} - errors.log" | |
fi | |
rm "${COPY_PATH}.log" | |
# Append to list of dumped discs | |
echo "$(basename "$COPY_PATH");${DISC_NO}">>"${OUT_DIR}${LIST_OF_DUMPED_MEDIA}" | |
osascript -e 'on run {f, c}' -e 'tell app "Finder" to set comment of (POSIX file f as alias) to c' -e end "$COPY_PATH" "$DISC_NO" >/dev/null | |
done | |
# Print result | |
if [ -z $IS_ERROR ]; then | |
_notify "✅ Disc "$DISC_NO" copied successfully." | |
else | |
_notify "‼️ Disc "$DISC_NO" copy had some errors." | |
fi | |
echo "" | |
# Eject the disc, stubbornly | |
until $(drutil -drive $DRIVE_NO status 2>&-|grep -q "No Media Inserted"); do | |
drutil -drive $DRIVE_NO eject 2>&- | |
sleep $CHECK_MEDIA_INTERVAL | |
done | |
# Self-execute | |
exec env \ | |
OUT_DIR="$OUT_DIR" \ | |
NOTIFICATION_ID="$NOTIFICATION_ID" \ | |
DISC_NO="$(( $DISC_NO + 1 ))" \ | |
DRIVE_NO="$DRIVE_NO" \ | |
"$0" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I wrote this script for using the Nimbie USB Plus with macOS, along with the (no longer publicly available?) CD tray monitoring tool QQGetTray. Nimbie’s maker says the unit is not supported on macOS any more, but as of 10.14 it still works (although only with a USB 2.0 cable since macOS 10.11, and even with that it is indeed a bit flacky).
The script does not require the Nimbie, though, and works fine with a regular optical drive (in which case it is the human who will have to be feeding it with discs as copied ones are ejected, naturally).