Skip to content

Instantly share code, notes, and snippets.

@elmimmo
Last active August 4, 2022 19:11
Show Gist options
  • Save elmimmo/6f558b7483a191d056d1d7810789d8c5 to your computer and use it in GitHub Desktop.
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)
#!/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"
@elmimmo
Copy link
Author

elmimmo commented Jul 31, 2019

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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment