diff --git a/scwrypts/media/.config/env.yaml b/scwrypts/media/.config/env.yaml
new file mode 100644
index 0000000..024b499
--- /dev/null
+++ b/scwrypts/media/.config/env.yaml
@@ -0,0 +1,8 @@
+---
+media-sync:
+ .DESCRIPTION: >-
+ s3 bucket name and filesystem targets for media backups
+ s3-bucket:
+ .ENVIRONMENT: MEDIA_SYNC__S3_BUCKET
+ targets:
+ .ENVIRONMENT: MEDIA_SYNC__TARGETS
diff --git a/scwrypts/media/README.md b/scwrypts/media/README.md
new file mode 100644
index 0000000..1bf7b5d
--- /dev/null
+++ b/scwrypts/media/README.md
@@ -0,0 +1,5 @@
+# ZSH Scwrypts
+[](https://github.com/ytdl-org/youtube-dl)
+
+
+Quick wrappers for downloading and trimming YouTube videos.
diff --git a/scwrypts/media/audio/audio.module.zsh b/scwrypts/media/audio/audio.module.zsh
new file mode 100644
index 0000000..3b02dcd
--- /dev/null
+++ b/scwrypts/media/audio/audio.module.zsh
@@ -0,0 +1,5 @@
+#
+#
+#
+
+#
diff --git a/scwrypts/media/audio/play-sfx b/scwrypts/media/audio/play-sfx
new file mode 100755
index 0000000..090c433
--- /dev/null
+++ b/scwrypts/media/audio/play-sfx
@@ -0,0 +1,14 @@
+#!/usr/bin/env zsh
+#####################################################################
+
+use audio/play-sfx --group media
+
+#####################################################################
+
+media.audio.play-sfx.parse.usage
+
+#####################################################################
+
+MAIN() {
+ media.audio.play-sfx $@
+}
diff --git a/scwrypts/media/audio/play-sfx.module.zsh b/scwrypts/media/audio/play-sfx.module.zsh
new file mode 100644
index 0000000..2b382b5
--- /dev/null
+++ b/scwrypts/media/audio/play-sfx.module.zsh
@@ -0,0 +1,79 @@
+#####################################################################
+
+use notify
+use scwrypts/get-realpath
+
+DEPENDENCIES+=(canberra-gtk-play)
+REQUIRED_ENV+=(DESKTOP__SFX_PATH)
+
+#####################################################################
+
+${scwryptsmodule}() {
+ local SCWRYPTS_NOTIFICATION_ENGINES=(echo notify.desktop)
+
+ eval "$(utils.parse.autosetup)"
+
+ ##########################################
+
+ echo.status 'starting playback'
+ canberra-gtk-play -f "${SFX_FILE}" \
+ && echo.success "finished output of '${SFX_FILE}'" \
+ || notify.error "something went wrong playing file '${SFX_FILE}'" \
+ || return 1
+}
+
+#####################################################################
+
+${scwryptsmodule}.parse() {
+ [[ ${POSITIONAL_ARGS} -eq 0 ]] || return 0
+
+ ((POSITIONAL_ARGS+=1))
+ case $1 in
+ ( backlight ) SFX_FILE="${DESKTOP__SFX_PATH}/yaru-audio-volume-change.oga" ;;
+ ( gamedock ) SFX_FILE="${DESKTOP__SFX_PATH}/gamedock.oga" ;;
+ ( homedock ) SFX_FILE="${DESKTOP__SFX_PATH}/homedock.oga" ;;
+ ( login ) SFX_FILE="${DESKTOP__SFX_PATH}/yaru-desktop-login.oga" ;;
+ ( logout ) SFX_FILE="${DESKTOP__SFX_PATH}/smooth-desktop-login.oga" ;;
+ ( mute ) SFX_FILE="${DESKTOP__SFX_PATH}/smooth-dialog-warning.oga" ;;
+ ( notify ) SFX_FILE="${DESKTOP__SFX_PATH}/yaru-complete.oga" ;;
+ ( undock ) SFX_FILE="${DESKTOP__SFX_PATH}/yaru-desktop-login.oga" ;;
+ ( volume ) SFX_FILE="${DESKTOP__SFX_PATH}/yaru-message.oga" ;;
+
+ ( * )
+ SFX_FILE="$1"
+ ;;
+ esac
+
+ return 1
+}
+
+${scwryptsmodule}.parse.locals() {
+ local SFX_FILE
+}
+
+${scwryptsmodule}.parse.usage() {
+ USAGE__description='
+ play the indicated sound effect by mapped name or filename
+
+ mapped names :
+ backlight notify
+ login logout
+ mute volume
+ gamedock homedock undock
+ '
+
+ USAGE__args='
+ \$1 mapped name or filename
+ '
+}
+
+${scwryptsmodule}.parse.validate() {
+ [ -f "$(scwrypts.get-realpath "${SFX_FILE}")" ] \
+ && SFX_FILE="$(scwrypts.get-realpath "${SFX_FILE}")" \
+ || SFX_FILE="${DESKTOP__SFX_PATH}/${SFX_FILE}" \
+ ;
+
+ [ -f "${SFX_FILE}" ] \
+ || notify.error "unable to locate sfx file '$1'" \
+ || return 1
+}
diff --git a/scwrypts/media/audio/pulseaudio/pulseaudio.module.zsh b/scwrypts/media/audio/pulseaudio/pulseaudio.module.zsh
new file mode 100644
index 0000000..0392b64
--- /dev/null
+++ b/scwrypts/media/audio/pulseaudio/pulseaudio.module.zsh
@@ -0,0 +1,42 @@
+#####################################################################
+
+DEPENDENCIES+=(canberra-gtk-play)
+REQUIRED_ENV+=()
+
+use notify
+
+#####################################################################
+
+media.audio.play-sfx() {
+ local SCWRYPTS_NOTIFICATION_ENGINES=(echo notify.desktop)
+ local SFX_FILE
+ case $1 in
+ ( volume ) SFX_FILE=$DESKTOP__SFX_PATH/yaru-message.oga ;;
+ ( mute ) SFX_FILE=$DESKTOP__SFX_PATH/smooth-dialog-warning.oga ;;
+ ( backlight ) SFX_FILE=$DESKTOP__SFX_PATH/yaru-audio-volume-change.oga ;;
+ ( login ) SFX_FILE=$DESKTOP__SFX_PATH/yaru-desktop-login.oga ;;
+ ( logout ) SFX_FILE=$DESKTOP__SFX_PATH/smooth-desktop-login.oga ;;
+ ( notify ) SFX_FILE=$DESKTOP__SFX_PATH/yaru-complete.oga ;;
+ ( undock ) SFX_FILE=$DESKTOP__SFX_PATH/yaru-desktop-login.oga ;;
+ ( homedock ) SFX_FILE=$DESKTOP__SFX_PATH/homedock.oga ;;
+ ( gamedock ) SFX_FILE=$DESKTOP__SFX_PATH/gamedock.oga ;;
+
+ * ) SFX_FILE="$1"
+ ;;
+ esac
+
+ [ ! -f $SFX_FILE ] && SFX_FILE="$DESKTOP__SFX_PATH/$SFX_FILE"
+
+ [ -f $SFX_FILE ] \
+ && echo.status "detected file '$SFX_FILE'" \
+ || notify.error "unable to locate sfx file '$1'" \
+ || return 1 \
+ ;
+
+ echo.status 'starting playback'
+ canberra-gtk-play -f "$SFX_FILE" \
+ && echo.success "finished output of '$SFX_FILE'" \
+ || notify.error "something went wrong playing file '$SFX_FILE'" \
+ || return 1 \
+ ;
+}
diff --git a/scwrypts/media/audio/pulseaudio/volume b/scwrypts/media/audio/pulseaudio/volume
new file mode 100755
index 0000000..823037a
--- /dev/null
+++ b/scwrypts/media/audio/pulseaudio/volume
@@ -0,0 +1,12 @@
+#!/bin/zsh
+#####################################################################
+
+use audio/pulseaudio/volume --group media
+
+#####################################################################
+
+media.audio.pulseaudio.volume.parse.usage
+
+#####################################################################
+
+MAIN() { media.audio.pulseaudio.volume $@; }
diff --git a/scwrypts/media/audio/pulseaudio/volume.module.zsh b/scwrypts/media/audio/pulseaudio/volume.module.zsh
new file mode 100644
index 0000000..852f39c
--- /dev/null
+++ b/scwrypts/media/audio/pulseaudio/volume.module.zsh
@@ -0,0 +1,93 @@
+#!/bin/zsh
+#####################################################################
+
+use notify
+use audio/play-sfx --group media
+
+DEPENDENCIES+=(pactl)
+
+#####################################################################
+
+${scwryptsmodule}() {
+ local SCWRYPTS_NOTIFICATION_ENGINES=(echo notify.desktop)
+ eval "$(utils.parse.autosetup)"
+ ##########################################
+
+ case ${COMMAND} in
+ ( up )
+ pactl set-${DEVICE}-volume ${PACTL_DEVICE} +10% \
+ || notify.error "pactl error with set-${DEVICE}-volume"
+
+ media.audio.play-sfx volume
+ ;;
+
+ ( down )
+ pactl set-${DEVICE}-volume ${PACTL_DEVICE} -10% \
+ || notify.error "pactl error with set-${DEVICE}-volume"
+
+ media.audio.play-sfx volume
+ ;;
+
+ ( mute )
+ pactl set-${DEVICE}-mute ${PACTL_DEVICE} toggle \
+ && notify.success "default ${DEVICE}" "$(amixer sget ${AMIXER_DEVICE} | grep -q '\[on\]' && echo unmuted || echo muted)" \
+ || notify.error "pactl error with set-${DEVICE}-mute"
+
+ media.audio.play-sfx mute
+ ;;
+ esac
+
+ return ${ERRORS}
+}
+
+#####################################################################
+
+${scwryptsmodule}.parse() { return 0; }
+${scwryptsmodule}.parse.locals() {
+ local ARGS=()
+
+ local DEVICE
+ local AMIXER_DEVICE
+ local PACTL_DEVICE
+ local COMMAND
+}
+
+${scwryptsmodule}.parse.usage() {
+ USAGE__description='
+ simplified pactl for volume up/down/mute on default sink and source
+ '
+
+ USAGE__args='
+ \$1 target device : one of (sink source)
+ \$2 volume command : one of (up down mute)
+ '
+}
+
+${scwryptsmodule}.parse.validate() {
+ DEVICE="${ARGS[1]}"
+ [ "${DEVICE}" ] \
+ || notify.error 'missing device'
+
+ COMMAND="${ARGS[2]}"
+ [ "${COMMAND}" ] \
+ || notify.error 'missing command'
+
+ [ "${DEVICE}" ] && [ "${COMMAND}" ] || return
+
+ case ${DEVICE} in
+ ( sink ) AMIXER_DEVICE=Master ;;
+ ( source ) AMIXER_DEVICE=Capture ;;
+ ( * )
+ notify.error "unsupported device '${DEVICE}'"
+ ;;
+ esac
+
+ case ${COMMAND} in
+ ( up | down | mute ) ;;
+ ( * )
+ notify.error "unsupported command '${COMMAND}'"
+ ;;
+ esac
+
+ PACTL_DEVICE="@DEFAULT_$(echo ${DEVICE} | tr '[:lower:]' '[:upper:]')@"
+}
diff --git a/scwrypts/media/cloud/cloud.module.zsh b/scwrypts/media/cloud/cloud.module.zsh
new file mode 100644
index 0000000..752776d
--- /dev/null
+++ b/scwrypts/media/cloud/cloud.module.zsh
@@ -0,0 +1,14 @@
+#
+# cloud media synchronization tools
+#
+
+
+# synchronize cloud media with configured targets
+use cloud/synchronize --group media
+
+# synchronize cloud media with a specific target
+use cloud/synchronize-target --group media
+
+
+# common parsers
+use cloud/zshparse --group media
diff --git a/scwrypts/media/cloud/synchronize b/scwrypts/media/cloud/synchronize
new file mode 100755
index 0000000..c87a519
--- /dev/null
+++ b/scwrypts/media/cloud/synchronize
@@ -0,0 +1,58 @@
+#!/usr/bin/env zsh
+#####################################################################
+
+use cloud --group media
+
+#####################################################################
+
+media.cloud.zshparse.actions.usage
+
+USAGE__options+='
+ --target local/remote target to synchronize (optional)
+'
+
+USAGE__description='
+ synchronize local media with an S3 bucket; *-synchronize actions
+ will perform a pull/push back-to-back in the indicated order
+'
+
+#####################################################################
+
+MAIN() {
+ local \
+ TARGET \
+ ACTION \
+ PARSERS=(
+ media.cloud.zshparse.actions
+ )
+
+ eval "$ZSHPARSEARGS"
+
+ ##########################################
+
+ case ${TARGET} in
+ ( '' )
+ media.cloud.synchronize --action "${ACTION}"
+ ;;
+ ( * )
+ media.cloud.synchronize-target --action "${ACTION}" --target "${TARGET}"
+ ;;
+ esac
+}
+
+#####################################################################
+
+MAIN.parse() {
+ # local TARGET
+ local PARSED=0
+
+ case $1 in
+ ( --target )
+ PARSED=2
+ TARGET="$2"
+ ;;
+ esac
+
+ return $PARSED
+}
+
diff --git a/scwrypts/media/cloud/synchronize-target.module.zsh b/scwrypts/media/cloud/synchronize-target.module.zsh
new file mode 100644
index 0000000..9976cd8
--- /dev/null
+++ b/scwrypts/media/cloud/synchronize-target.module.zsh
@@ -0,0 +1,88 @@
+#####################################################################
+
+REQUIRED_ENV+=(MEDIA_SYNC__S3_BUCKET)
+
+use cloud/aws
+use cloud/zshparse/actions --group media
+
+#####################################################################
+
+${scwryptsmodule}() {
+ local \
+ TARGET TARGET_CLOUD TARGET_LOCAL \
+ ACTION \
+ PARSERS=(
+ media.cloud.zshparse.actions
+ )
+
+ eval "$ZSHPARSEARGS"
+
+ ##########################################
+
+ local A B
+ case $ACTION in
+ ( push ) A="$TARGET_LOCAL"; B="$TARGET_CLOUD" ;;
+ ( pull ) A="$TARGET_CLOUD"; B="$TARGET_LOCAL" ;;
+
+ ( pull-first-synchronize )
+ : \
+ && media.cloud.synchronize-target \
+ --target "${TARGET}" \
+ --action pull \
+ && media.cloud.synchronize-target \
+ --target "${TARGET}" \
+ --action push \
+ && return 0 \
+ || return 1 \
+ ;
+ ;;
+
+ ( push-first-synchronize )
+ : \
+ && media.cloud.synchronize-target \
+ --target "${TARGET}" \
+ --action push \
+ && media.cloud.synchronize-target \
+ --target "${TARGET}" \
+ --action pull \
+ && return 0 \
+ || return 1 \
+ ;
+ ;;
+ esac
+
+ echo.status "${ACTION}ing ${TARGET}"
+ cloud.aws s3 sync $A $B \
+ && echo.success "${TARGET} up-to-date" \
+ || { ERROR "unable to sync ${TARGET} (see above)"; return 1; }
+}
+
+#####################################################################
+
+${scwryptsmodule}.parse() {
+ # local TARGET TARGET_CLOUD TARGET_LOCAL
+ local PARSED=0
+
+ case $1 in
+ ( --target )
+ PARSED=2
+ TARGET="$2"
+ ;;
+ esac
+
+ return $PARSED
+}
+
+${scwryptsmodule}.parse.usage() {
+ USAGE__options+="
+ --target local/remote target to synchronize
+ "
+}
+
+${scwryptsmodule}.parse.validate() {
+ [ "${TARGET}" ] \
+ || ERROR 'must specify a target'
+
+ TARGET_LOCAL="${HOME}/${TARGET}"
+ TARGET_CLOUD="s3://${MEDIA_SYNC__S3_BUCKET}/${TARGET}"
+}
diff --git a/scwrypts/media/cloud/synchronize.module.zsh b/scwrypts/media/cloud/synchronize.module.zsh
new file mode 100644
index 0000000..d582839
--- /dev/null
+++ b/scwrypts/media/cloud/synchronize.module.zsh
@@ -0,0 +1,43 @@
+#####################################################################
+
+REQUIRED_ENV+=(MEDIA_SYNC__TARGETS)
+
+use cloud/synchronize-target --group media
+use cloud/zshparse/actions --group media
+
+#####################################################################
+
+${scwryptsmodule}() {
+ eval "$(USAGE.reset)"
+
+ local \
+ ACTION \
+ PARSERS=(
+ media.cloud.zshparse.actions
+ )
+
+ eval "$ZSHPARSEARGS"
+
+ ##########################################
+
+ local TARGET_COUNT=${#MEDIA_SYNC__TARGETS[@]}
+ local FAILED_COUNT=0
+
+ echo.status "starting media ${ACTION}"
+
+ local TARGET
+ for TARGET in ${MEDIA_SYNC__TARGETS[@]}
+ do
+ media.cloud.synchronize-target \
+ --action "${ACTION}" \
+ --target "${TARGET}" \
+ || ((FAILED_COUNT+=1))
+ done
+
+ [[ $FAILED_COUNT -eq 0 ]] \
+ && echo.success "successfully completed ${ACTION} for ${TARGET_COUNT} / ${TARGET_COUNT} target(s)" \
+ || ERROR "failed ${ACTION} for ${FAILED_COUNT} / ${TARGET_COUNT} target(s)" \
+ ;
+}
+
+#####################################################################
diff --git a/scwrypts/media/cloud/zshparse/actions.module.zsh b/scwrypts/media/cloud/zshparse/actions.module.zsh
new file mode 100644
index 0000000..7c167d6
--- /dev/null
+++ b/scwrypts/media/cloud/zshparse/actions.module.zsh
@@ -0,0 +1,37 @@
+${scwryptsmodule}() {
+ # local ACTION
+ local PARSED=0
+
+ case $1 in
+ ( --action )
+ PARSED=2
+ ACTION="$2"
+ ;;
+ esac
+
+ return $PARSED
+}
+
+#####################################################################
+
+${scwryptsmodule}.usage() {
+ USAGE__options+="
+ --action a media sync action:
+ push
+ pull
+ push-first-synchronize
+ pull-first-synchronize
+ "
+}
+
+${scwryptsmodule}.validate() {
+ case "${ACTION}" in
+ ( push | pull | pull-first-synchronize | push-first-synchronize ) ;;
+ ( '' )
+ ERROR 'must specify a media sync action'
+ ;;
+ ( * )
+ ERROR "invalid media sync action '${ACTION}'"
+ ;;
+ esac
+}
diff --git a/scwrypts/media/cloud/zshparse/zshparse.module.zsh b/scwrypts/media/cloud/zshparse/zshparse.module.zsh
new file mode 100644
index 0000000..f65d08c
--- /dev/null
+++ b/scwrypts/media/cloud/zshparse/zshparse.module.zsh
@@ -0,0 +1,5 @@
+#
+# common parsers for cloud media synchronization
+#
+
+use cloud/zshparse/actions --group media
diff --git a/scwrypts/media/ffmpeg/ffmpeg.module.zsh b/scwrypts/media/ffmpeg/ffmpeg.module.zsh
new file mode 100644
index 0000000..3d6767a
--- /dev/null
+++ b/scwrypts/media/ffmpeg/ffmpeg.module.zsh
@@ -0,0 +1,9 @@
+#
+# personal ffmpeg utility since I don't use ffmpeg much and don't
+# want to read the man every time
+#
+
+DEPENDENCIES+=(ffmpeg)
+
+use ffmpeg/get-audio-clip-from-video.module.zsh --group media
+use ffmpeg/get-video-length-seconds.module.zsh --group media
diff --git a/scwrypts/media/ffmpeg/get-audio-clip-from-video.module.zsh b/scwrypts/media/ffmpeg/get-audio-clip-from-video.module.zsh
new file mode 100644
index 0000000..958683e
--- /dev/null
+++ b/scwrypts/media/ffmpeg/get-audio-clip-from-video.module.zsh
@@ -0,0 +1,145 @@
+#####################################################################
+
+DEPENDENCIES+=(ffmpeg)
+
+use ffmpeg/get-video-length-seconds --group media
+
+#####################################################################
+
+${scwryptsmodule}() {
+ eval "$(USAGE.reset)"
+ local USAGE__description='
+ converts a video into an audio clip (mp3)
+
+ if only --start is used, the audio clip will be from
+ the specified start point to the end of the video
+
+ if only --end is used, the audio clip will be from
+ the start of the video to the specified end point
+
+ if --start, --end, and --use-whole-video are all omitted,
+ start and end times will be prompted interactively
+ '
+
+ local \
+ INPUT_FILENAME OUTPUT_FILENAME \
+ USE_WHOLE_VIDEO=false \
+ START_TIME_SECONDS END_TIME_SECONDS \
+ PARSERS=()
+
+ eval "$ZSHPARSEARGS"
+
+ ##########################################
+
+ echo.status "converting video to audio
+ video input : ${INPUT_FILENAME}
+ audio output : ${OUTPUT_FILENAME}
+ start time : ${START_TIME_SECONDS}
+ end time : ${END_TIME_SECONDS}
+ "
+
+ ffmpeg -i "${INPUT_FILENAME}" -q:a 0 -map a \
+ -ss ${START_TIME_SECONDS} -t $((${END_TIME_SECONDS} - ${START_TIME_SECONDS}))\
+ "${OUTPUT_FILENAME}" \
+ && echo.success "created clip '${OUTPUT_FILENAME}'" \
+ || ERROR "error creating clip '$(basename -- "${OUTPUT_FILENAME}")' (see above)"
+}
+
+#####################################################################
+
+${scwryptsmodule}.parse() {
+ # local INPUT_FILENAME OUTPUT_FILENAME
+ # local USE_WHOLE_VIDEO=false
+ # local START_TIME_SECONDS END_TIME_SECONDS optional
+
+ local PARSED=0
+
+ case $1 in
+ -i | --input-filename ) PARSED=2; INPUT_FILENAME="$2" ;;
+ -o | --output-filename ) PARSED=2; OUTPUT_FILENAME="$2" ;;
+
+ --start ) PARSED=2; START_TIME_SECONDS="$2" ;;
+ --end ) PARSED=2; END_TIME_SECONDS="$2" ;;
+
+ --use-whole-video ) PARSED=1; USE_WHOLE_VIDEO=true ;;
+ esac
+
+ return $PARSED
+}
+
+${scwryptsmodule}.parse.usage() {
+ USAGE__options='
+ -i, --input-filename fully-qualified path to input file
+ -o, --output-filename fully-qualified path to output file
+
+ --start start time of the clip
+ --end end time of the clip
+
+ --use-whole-video convert whole video instead of just a portion
+ '
+}
+
+${scwryptsmodule}.parse.validate() {
+ : \
+ && [ "${INPUT_FILENAME}" ] \
+ && [ -f "${INPUT_FILENAME}" ] \
+ && [ "${OUTPUT_FILENAME}" ] \
+ || ERROR "must provide a valid input and output filename\ninput : '${INPUT_FILENAME}\noutput : '${OUTPUT_FILENAME}'" \
+ || return
+
+ [[ ${OUTPUT_FILENAME} =~ .mp3$ ]] || OUTPUT_FILENAME="${OUTPUT_FILENAME}.mp3"
+
+ local VIDEO_LENGTH_SECONDS=$(media.ffmpeg.get-video-length-seconds "${INPUT_FILENAME}")
+ [ "${VIDEO_LENGTH_SECONDS}" ] && [[ "${VIDEO_LENGTH_SECONDS}" -gt 0 ]] \
+ || ERROR "unable to determine video length; is '${INPUT_FILENAME}' a video?" \
+ || return
+
+ local CLIP_METHOD=start-time-to-end-time
+ case ${USE_WHOLE_VIDEO} in
+ true )
+ [ ! "${START_TIME_SECONDS}" ] \
+ || ERROR "conflicting arguments '--start' and '--use-whole-video'"
+
+ [ ! "${END_TIME_SECONDS}" ] \
+ || ERROR "conflicting arguments '--end' and '--use-whole-video'"
+ ;;
+ false )
+ [ ! "${START_TIME_SECONDS}" ] && [ ! "${END_TIME_SECONDS}" ] \
+ && CLIP_METHOD=interactive
+ ;;
+ esac
+
+ case ${CLIP_METHOD} in
+ start-time-to-end-time )
+ [ "${START_TIME_SECONDS}" ] || START_TIME_SECONDS=0
+ [ "${END_TIME_SECONDS}" ] || END_TIME_SECONDS="${VIDEO_LENGTH_SECONDS}"
+ ;;
+
+ interactive )
+ START_TIME_SECONDS=$(echo 0 | utils.fzf.user-input "enter start time (0 ≤ t < ${VIDEO_LENGTH_SECONDS})")
+ [ "${START_TIME_SECONDS}" ] \
+ || ERROR 'interactive user abort' \
+ || return
+
+ END_TIME_SECONDS=$(echo ${VIDEO_LENGTH_SECONDS} | utils.fzf.user-input "enter end time (${START_TIME_SECONDS} > t ≥ $VIDEO_LENGTH_SECONDS)")
+ [ "${END_TIME_SECONDS}" ] \
+ || ERROR 'interactive user abort' \
+ || return
+ ;;
+ esac
+
+ [[ "${START_TIME_SECONDS}" -ge 0 ]] \
+ || ERROR "cannot use negative start time (start time = ${START_TIME_SECONDS})"
+
+ [[ "${END_TIME_SECONDS}" -gt 0 ]] \
+ || ERROR "end time must be after the video starts (end time = ${END_TIME_SECONDS})"
+
+ [[ "${START_TIME_SECONDS}" -lt "${VIDEO_LENGTH_SECONDS}" ]] \
+ || ERROR "start time must be before video ends (start time = ${START_TIME_SECONDS}; video length = ${VIDEO_LENGTH_SECONDS})"
+
+ [[ "${END_TIME_SECONDS}" -le "${VIDEO_LENGTH_SECONDS}" ]] \
+ || ERROR "end time cannot go beyond video end (end time = ${END_TIME_SECONDS}; video length = ${VIDEO_LENGTH_SECONDS})"
+
+ [[ "${START_TIME_SECONDS}" -lt "${END_TIME_SECONDS}" ]] \
+ || ERROR "start time must come before end time (start time = ${START_TIME_SECONDS}; end time = ${END_TIME_SECONDS})"
+}
diff --git a/scwrypts/media/ffmpeg/get-video-length-seconds.module.zsh b/scwrypts/media/ffmpeg/get-video-length-seconds.module.zsh
new file mode 100644
index 0000000..ab8c148
--- /dev/null
+++ b/scwrypts/media/ffmpeg/get-video-length-seconds.module.zsh
@@ -0,0 +1,20 @@
+#####################################################################
+
+DEPENDENCIES+=(ffprobe)
+
+#####################################################################
+
+${scwryptsmodule}() {
+ local FILENAME="$1"
+
+ [ "${FILENAME}" ] && [ -f "${FILENAME}" ] \
+ || ERROR "invalid or missing file '${FILENAME}'" \
+ || return 1
+
+ ffprobe \
+ -v quiet \
+ -show_entries format=duration \
+ -of default=noprint_wrappers=1:nokey=1 \
+ -i "${FILENAME}" \
+ ;
+}
diff --git a/scwrypts/media/media.scwrypts.zsh b/scwrypts/media/media.scwrypts.zsh
new file mode 100644
index 0000000..1575be5
--- /dev/null
+++ b/scwrypts/media/media.scwrypts.zsh
@@ -0,0 +1,2 @@
+readonly ${scwryptsgroup}__type=zsh
+readonly ${scwryptsgroup}__color=$(utils.colors.magenta)
diff --git a/scwrypts/media/youtube/download b/scwrypts/media/youtube/download
new file mode 100755
index 0000000..e912d42
--- /dev/null
+++ b/scwrypts/media/youtube/download
@@ -0,0 +1,39 @@
+#!/bin/zsh
+#####################################################################
+
+use youtube --group media
+
+#####################################################################
+
+USAGE__description="
+ download videos from youtube
+"
+
+USAGE__args='
+ $@ any number of URLS to download (becomes interactive if omitted)
+'
+
+#####################################################################
+
+MAIN() {
+ local URLS=($@)
+ local ARGS=()
+
+ local DOWNLOAD_ERRORS=0
+
+ [[ $# -eq 0 ]] && {
+ URLS=($(echo '' | utils.fzf.user-input 'download URL'))
+ [[ ${#URLS[@]} -gt 0 ]] || ABORT
+
+ ARGS+=(--interactive)
+ }
+
+ local URL FILENAME
+ for URL in ${URLS[@]}
+ do
+ media.youtube.download ${ARGS[@]} --url "${URL}" \
+ || ((DOWNLOAD_ERRORS+=1))
+ done
+
+ return ${DOWNLOAD_ERRORS}
+}
diff --git a/scwrypts/media/youtube/download.module.zsh b/scwrypts/media/youtube/download.module.zsh
new file mode 100644
index 0000000..01184c4
--- /dev/null
+++ b/scwrypts/media/youtube/download.module.zsh
@@ -0,0 +1,63 @@
+#####################################################################
+
+use youtube/yt-dlp --group media
+use youtube/get-filename --group media
+use youtube/get-download-path --group media
+
+#####################################################################
+
+${scwryptsmodule}() {
+ eval "$(USAGE.reset)"
+
+ local \
+ URL INTERACTIVE=false \
+ PARSERS=()
+
+ eval "$ZSHPARSEARGS"
+
+ ##########################################
+
+ local FILENAME="$(media.youtube.get-filename "${URL}")"
+
+ [ "${FILENAME}" ] \
+ || ERROR "could not find metadata; cannot proceed with download\n${URL}" \
+ || return 1
+
+ media.youtube.yt-dlp "${URL}" \
+ --format 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4' \
+ && echo.success "finished download of ${URL}\n$(media.youtube.get-download-path)/${FILENAME}" \
+ || ERROR "failed to download '${FILENAME}' (${URL})"
+ ;
+}
+
+#####################################################################
+
+${scwryptsmodule}.parse() {
+ # local URL INTERACTIVE=false
+ local PARSED=0
+
+ case $1 in
+ --url )
+ PARSED=2
+ URL="$2"
+ ;;
+
+ --interactive )
+ PARSED=1
+ INTERACTIVE=true
+ ;;
+ esac
+
+ return $PARSED
+}
+
+${scwryptsmodule}.parse.usage() {
+ USAGE__options+="
+ --url the URL of the target video
+ "
+}
+
+${scwryptsmodule}.parse.validate() {
+ [ "$URL" ] \
+ || ERROR "must provide download URL"
+}
diff --git a/scwrypts/media/youtube/get-audio-clip b/scwrypts/media/youtube/get-audio-clip
new file mode 100755
index 0000000..259e4c8
--- /dev/null
+++ b/scwrypts/media/youtube/get-audio-clip
@@ -0,0 +1,38 @@
+#!/bin/zsh
+
+use ffmpeg/get-audio-clip-from-video --group media
+use youtube/get-download-path --group media
+
+#####################################################################
+
+
+#####################################################################
+
+MAIN() {
+ local DOWNLOAD_PATH="$(media.youtube.get-download-path)"
+
+ local INPUT_FILENAME="$(
+ cd -- "${DOWNLOAD_PATH}"
+ find . -type f -name \*.mp4 \
+ | sed 's|^./||' \
+ | utils.fzf 'select a video' \
+ | sed 's/\.mp4$//' \
+ )"
+ [ "${INPUT_FILENAME}" ] || ABORT
+
+ local OUTPUT_FILENAME="$(\
+ basename -- "${INPUT_FILENAME}" \
+ | sed 's/\.[^.]*$//' \
+ | utils.fzf.user-input 'what should I call this clip? (.mp3)' \
+ | sed 's/\(\.mp3\)*$//' \
+ )"
+ [ "${OUTPUT_FILENAME}" ] || ABORT
+
+ INPUT_FILENAME="${DOWNLOAD_PATH}/${INPUT_FILENAME}.mp4"
+ OUTPUT_FILENAME="${DOWNLOAD_PATH}/${OUTPUT_FILENAME}.mp3"
+
+ media.ffmpeg.get-audio-clip-from-video \
+ --input-filename "${INPUT_FILENAME}" \
+ --output-filename "${OUTPUT_FILENAME}" \
+ ;
+}
diff --git a/scwrypts/media/youtube/get-download-path.module.zsh b/scwrypts/media/youtube/get-download-path.module.zsh
new file mode 100644
index 0000000..7e33ff2
--- /dev/null
+++ b/scwrypts/media/youtube/get-download-path.module.zsh
@@ -0,0 +1,7 @@
+${scwryptsmodule}() {
+ local DOWNLOAD_PATH="${SCWRYPTS_DATA_PATH}/youtube"
+
+ mkdir -p -- "${DOWNLOAD_PATH}" &>/dev/null
+
+ echo "${DOWNLOAD_PATH}"
+}
diff --git a/scwrypts/media/youtube/get-filename.module.zsh b/scwrypts/media/youtube/get-filename.module.zsh
new file mode 100644
index 0000000..ed89c20
--- /dev/null
+++ b/scwrypts/media/youtube/get-filename.module.zsh
@@ -0,0 +1,12 @@
+#####################################################################
+
+use youtube/yt-dlp --group media
+
+#####################################################################
+
+${scwryptsmodule}() {
+ media.youtube.yt-dlp --dump-json $@ \
+ | jq -r '._filename' \
+ | sed 's/\.[^.]*$/\.mp4/' \
+ ;
+}
diff --git a/scwrypts/media/youtube/youtube.module.zsh b/scwrypts/media/youtube/youtube.module.zsh
new file mode 100644
index 0000000..274b08c
--- /dev/null
+++ b/scwrypts/media/youtube/youtube.module.zsh
@@ -0,0 +1,15 @@
+#
+# interact with youtube
+#
+
+
+# download a youtube video by URL
+use youtube/download --group media
+
+
+# show fully-qualified path to downloads
+use youtube/get-download-path --group media
+
+
+# interact with yt-dlp directly
+use youtube/yt-dlp --group media
diff --git a/scwrypts/media/youtube/yt-dlp.module.zsh b/scwrypts/media/youtube/yt-dlp.module.zsh
new file mode 100644
index 0000000..a8dafda
--- /dev/null
+++ b/scwrypts/media/youtube/yt-dlp.module.zsh
@@ -0,0 +1,15 @@
+#####################################################################
+
+use youtube/get-download-path --group media
+
+#####################################################################
+
+DEPENDENCIES+=(yt-dlp)
+${scwryptsmodule}() {
+ (
+ cd -- "$(media.youtube.get-download-path)"
+ yt-dlp \
+ --restrict-filenames \
+ $@
+ )
+}