Compare commits

...

11 Commits

Author SHA1 Message Date
Vincent Riquer
0f13154d6f comment lib/setup/* (#LLM assisted - Claude Code) 2026-03-13 17:52:26 +01:00
Vincent Riquer
3995e92323 comment lib/files/* 2026-03-13 17:38:41 +01:00
Vincent Riquer
c9a21637c6 comment lib/encode/* (#LLM-assisted - Claude Code) 2026-03-13 17:33:12 +01:00
Vincent Riquer
d0175fa03d comment lib/destinations/* (#LLM-assisted - Claude Code) 2026-03-13 17:20:38 +01:00
Vincent Riquer
6474bcab25 comment lib/decode/* (#LLM-assisted - Claude Code) 2026-03-13 16:34:02 +01:00
Vincent Riquer
22549072c3 Comment lib/database/* (#LLM-assisted - Claude Code) 2026-03-13 05:15:02 +01:00
Vincent Riquer
ee119f07a4 Comment lib/copy/* (#LLM-assisted - Claude Code) 2026-03-13 03:54:31 +01:00
Vincent Riquer
c99825912f Comment config handling (#LLM-assisted - Claude Code) 2026-03-13 02:42:40 +01:00
Vincent Riquer
d680d52425 Comment atom 2026-02-20 04:07:38 +01:00
Vincent Riquer
756ce7ec01 atom: License 2026-02-20 02:03:15 +01:00
Vincent Riquer
05b72b70d7 schema: remove unused column hash for newly created databases 2026-02-20 01:43:51 +01:00
46 changed files with 1188 additions and 81 deletions

149
atom
View File

@ -1,5 +1,21 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
# Directories for various required data. Set by the configure script and the
# Makefile.
# Also save $IFS just in case.
declare -r \ declare -r \
DOCDIR=%DOCDIR% \ DOCDIR=%DOCDIR% \
LIBDIR=%LIBDIR% \ LIBDIR=%LIBDIR% \
@ -13,7 +29,7 @@ declare -r \
## Define exit codes ## Define exit codes
source "$SHAREDIR"/errorcodes source "$SHAREDIR"/errorcodes
# config structures # Config structures.
declare -A \ declare -A \
destinationenabled \ destinationenabled \
destinationascii \ destinationascii \
@ -38,10 +54,13 @@ declare -A \
exit $EBASHVERS exit $EBASHVERS
} }
# Locales break parsingg.
LC_ALL=C LC_ALL=C
# Enable extended globbing for some filename manipulations.
shopt -s extglob shopt -s extglob
# Array of ID3v1 genres, number to name mapping.
source "$SHAREDIR"/id3genres source "$SHAREDIR"/id3genres
for function in "$LIBDIR"/*/* for function in "$LIBDIR"/*/*
@ -49,6 +68,7 @@ do
source "$function" source "$function"
done done
# Migrate old config to XDG where required
if ! [[ -f "${XDG_CONFIG_HOME:-$HOME/.config}/AtOM/atom.cfg" ]] \ if ! [[ -f "${XDG_CONFIG_HOME:-$HOME/.config}/AtOM/atom.cfg" ]] \
&& [[ -f "$HOME/.atom/atom.cfg" ]] && [[ -f "$HOME/.atom/atom.cfg" ]]
then then
@ -128,6 +148,9 @@ do
done done
askconf() { askconf() {
# Prompt user to interactively create a config if it doesn't exist and
# we're not in cron mode.
# Called recursively until a valid answer is given.
if (( cron )) if (( cron ))
then then
echo 'Non-interactive, not running setup. Please run atom -S.' >&2 echo 'Non-interactive, not running setup. Please run atom -S.' >&2
@ -152,6 +175,7 @@ askconf() {
esac esac
} }
# Read config if it exists, call askconf otherwise.
if [ ! -f "$cffile" ] if [ ! -f "$cffile" ]
then then
if [ ! -d "${cffile%/*}" ] if [ ! -d "${cffile%/*}" ]
@ -163,14 +187,17 @@ then
fi fi
getConfig getConfig
# Uf user wants to changeg config, run setup.
(( forcesetup )) && setup (( forcesetup )) && setup
# Deactivate `!` history expansion, just in case.
set +H set +H
# Apply CLI overrides # Apply CLI overrides
[ -n "$cliload" ] && maxload=$cliload [ -n "$cliload" ] && maxload=$cliload
[ -n "$cliltimer" ] && loadinterval=$cliltimer [ -n "$cliltimer" ] && loadinterval=$cliltimer
# Print config if requested or in debug mode. Exit if only dumping config.
(( debug || cfgdump )) && printConfig (( debug || cfgdump )) && printConfig
(( cfgdump )) && exit (( cfgdump )) && exit
@ -178,6 +205,11 @@ set +H
sanityCheck sanityCheck
openDatabase openDatabase
# create missing destination directories and DB entries, get destination IDs
# for later use
createDestinations
# Apply destinations "enabled" status in DB with respect to config.
for destination in "${destinations[@]}" for destination in "${destinations[@]}"
do do
if (( ${destinationenabled["$destination"]} )) if (( ${destinationenabled["$destination"]} ))
@ -188,14 +220,20 @@ do
fi fi
done done
createDestinations # get source files. Update DB.
getFiles getFiles
# Scan mime-types (for new/changed files). Update DB
updateMimes updateMimes
# Remove source files that are gone from DB. (`last_seen` column).
# FOREIGN KEY `source_file_id` on table `destination_files` gets set to NULL
# by `ON DELETE` parameter. We can use that to find destination files that need
# to be removed.
removeObsoleteFiles removeObsoleteFiles
# remove destination files for which the source file is gone. (`source_file_id`
# NUUL -- `rm` them if they exist, remove the DB entry in any case)
(( cron )) || echo -n 'Gathering files for cleaning...' (( cron )) || echo -n 'Gathering files for cleaning...'
echo ' echo '
SELECT COUNT(id) SELECT COUNT(id)
@ -203,6 +241,7 @@ echo '
WHERE source_file_id is NULL;' >&3 WHERE source_file_id is NULL;' >&3
read -u4 -r -d $'\0' removecount read -u4 -r -d $'\0' removecount
# Gather in 500 files batches to avoid pipe overflow.
until (( ${#removefile[@]} == removecount )) until (( ${#removefile[@]} == removecount ))
do do
echo ' echo '
@ -234,6 +273,10 @@ done
unset deleted unset deleted
unset removed unset removed
echo 'BEGIN TRANSACTION;' >&3 echo 'BEGIN TRANSACTION;' >&3
# Remove the files if they exist. Unconditionnally remove from DB.
# Run in transactrion to speed up the process, COMMIT every 1000th file in case
# process gets killed.
for id in ${!removefile[@]} for id in ${!removefile[@]}
do do
filename=${removefile[id]} filename=${removefile[id]}
@ -261,8 +304,14 @@ echo -n "${deleted+$deleted files deleted${removed:+, }}${removed:+$removed remo
(( deleted || removed )) && echo (( deleted || removed )) && echo
unset removecount deleted removed removefile unset removecount deleted removed removefile
# Update tags for new/changed files and updated tag parsers. Update DB.
# Uses `tags.last_changge` vs `source_files.last_change`
# + `tags.tagreader` vs tagreader versions declared in running version's
# source code.
updateTags updateTags
# Reset timestamps for files in destinations that were requested to be fully
# rebuilt.
for forcedest in "${forceall[@]}" for forcedest in "${forceall[@]}"
do do
if forcedestid=$(Select destinations id <<<"name = $forcedest") if forcedestid=$(Select destinations id <<<"name = $forcedest")
@ -277,6 +326,18 @@ do
fi fi
done done
# Create TEMPORARY (in-memory) tables for tasks. Up to 60 arguments per task
# (including command).
#
# `requires` designates the id of a tasks that must be completed before this
# one can be run.
# `required_by` is a counter of tasks that require this one. Used for temp
# files cleanup.
# `status` is 0 for pending tasks, 2 for failed tasks, 4 for completed tasks
# depended upon (temp file should be left intact).
#
# TRIGGER `fail_depends` sets status of all tasks that depend on a failed task
# to 2
echo ' echo '
CREATE TEMPORARY TABLE tasks( CREATE TEMPORARY TABLE tasks(
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
@ -370,6 +431,7 @@ echo '
END; END;
' >&3 ' >&3
# Get number of files to process. Apply `maxbatch` limit if specified.
echo ' echo '
SELECT COUNT(source_files.id) SELECT COUNT(source_files.id)
FROM source_files FROM source_files
@ -393,6 +455,8 @@ then
(( togo = filecount - maxbatch )) (( togo = filecount - maxbatch ))
filecount=$maxbatch filecount=$maxbatch
fi fi
# Get files to process. Apply `maxbatch` limit if specified.
echo ' echo '
SELECT SELECT
source_files.id, source_files.id,
@ -439,16 +503,26 @@ echo ';
read -u4 -r -d $'\0' line read -u4 -r -d $'\0' line
while ! [[ $line = AtOM:NoMoreFiles ]] while ! [[ $line = AtOM:NoMoreFiles ]]
do do
# Append `::AtOM:SQL:Sep::` at the end of the line to make sure we can
# parse empty fields.
decodefiles+=("$line::AtOM:SQL:Sep::") decodefiles+=("$line::AtOM:SQL:Sep::")
read -u4 -r -d $'\0' line read -u4 -r -d $'\0' line
done done
(( cron )) || echo -n $'Creating tasks...\033[K' (( cron )) || echo -n $'Creating tasks...\033[K'
# Spawn perl coprocess for unicode to ascii conversion if needed.
(( textunidecodeneeded )) && ascii (( textunidecodeneeded )) && ascii
# Generate tasks for each file. Tasks that depend on other tasks (e.g. encoding
# depends on decoding) get the ID of the task they depend on in `requires`
# column. This is used to make sure that encoding doesn't start before decoding
# and other transforms have completed.
echo 'BEGIN TRANSACTION;' >&3 echo 'BEGIN TRANSACTION;' >&3
for line in "${decodefiles[@]}" for line in "${decodefiles[@]}"
do do
# Parsing SQL output is fun. We use `::AtOM:SQL:Sep::` as separator
# between fields at the end of the line to make sure we can parse empty
# fields.
fileid=${line%%::AtOM:SQL:Sep::*} fileid=${line%%::AtOM:SQL:Sep::*}
rest=${line#*::AtOM:SQL:Sep::} rest=${line#*::AtOM:SQL:Sep::}
filename=${rest%%::AtOM:SQL:Sep::*} filename=${rest%%::AtOM:SQL:Sep::*}
@ -493,14 +567,24 @@ do
rest=${rest#*::AtOM:SQL:Sep::} rest=${rest#*::AtOM:SQL:Sep::}
year=${rest%%::AtOM:SQL:Sep::*} year=${rest%%::AtOM:SQL:Sep::*}
unset rest unset rest
# Skip destinations with formats for which tools are missing.
case ${destinationformat["$destination"]} in case ${destinationformat["$destination"]} in
vorbis) (( disableoggenc )) && continue ;; vorbis) (( disableoggenc )) && continue ;;
opus) (( disableopusenc )) && continue ;; opus) (( disableopusenc )) && continue ;;
mp3) (( disablelame )) && continue ;; mp3) (( disablelame )) && continue ;;
esac esac
# Create decoding task depending on mimetype.
decodeFile decodeFile
# Build target directory path from source file path OR from rename
# pattern if set.
getDestDir getDestDir
# Same for filename.
getDestFile getDestFile
# Set copied to 1 for files with extension in `copy_extension`.
for copy_ext in "${destinationcopyext[@]}" for copy_ext in "${destinationcopyext[@]}"
do do
if [[ $filename =~ '.*\.'"$copy_ext"'$' ]] if [[ $filename =~ '.*\.'"$copy_ext"'$' ]]
@ -511,10 +595,17 @@ do
done done
if (( copied )) if (( copied ))
then then
# Copy file as-is to destination.
copyFiles_matching copyFiles_matching
else else
# Call suitable function to create encoding task depending on
# destination format.
# encodeFile::mp3
# encodeFile::opus
# encodeFile::vorbis
encodeFile::${destinationformat[$destination]} encodeFile::${destinationformat[$destination]}
fi fi
# Cleanup variables. Avoids leaking data between iterations.
unset \ unset \
album \ album \
albumartist \ albumartist \
@ -557,6 +648,13 @@ echo 'COMMIT;' >&3
# remove perl unicode to ascii coprocess # remove perl unicode to ascii coprocess
(( textunidecodeneeded )) && eval exec "${toascii[1]}>&-" (( textunidecodeneeded )) && eval exec "${toascii[1]}>&-"
# Main loop. Run up to `concurrency` tasks in parallel, depending on system
# load. Spawn new tasks as old ones complete. Update progress info and commit
# DB every minute.
#
# Start with concurrency = maxload / 2, which seems like a good starting point
# on most systems. If result is 0, force to 1 to make sure we get going. If
# `-f <workers>` option was used, use that value instead and don't change it.
concurrency=$(( maxload / 2 )) concurrency=$(( maxload / 2 ))
(( concurrency )) || concurrency=1 (( concurrency )) || concurrency=1
active=0 active=0
@ -577,6 +675,13 @@ do
fi fi
read humanload garbage < /proc/loadavg read humanload garbage < /proc/loadavg
load=${humanload%.*} load=${humanload%.*}
# If `-f <workers>` option was used, keep concurrency fixed to that
# value. Otherwise, adjust concurrency according to load and `maxload`
# value. If load is above `maxload`, reduce concurrency by 1 (down to 0
# if `allow_zero_running` is set). If load is below `maxload`, increase
# concurrency by 1 (only if all slots are populated).
# Don't update concurrency more often than every `load-interval` seconds
# to reduce histeresis.
if (( fixed_workers )) if (( fixed_workers ))
then then
concurrency="$fixed_workers" concurrency="$fixed_workers"
@ -596,9 +701,30 @@ do
fi fi
fi fi
fi fi
# check if workers have finished.
# If a worker finished with non-zero exit code, mark the task as failed
# in DB. TRIGGER `fail_depends` will fail all tasks that depend on it.
checkworkers checkworkers
# If task failed, set status to 2, remove temp files if any and
# increment `failed` counter. Don't delete task from DB to keep track
# of failed tasks for final report.
# If task was successful, update destination_files.last_change to
# source_files.last_change, update old_filename to previous
# (destination file) filename, store rename_pattern, fat32compat and
# ascii settings.
# Set status to 4 for tasks that were depended upon by other tasks to
# avoid cleaning up temp files before all tasks depending on them have
# completed, delete task otherwise.
cleaner cleaner
# Look for pending tasks that can be started (required tasks's status
# is 4 or reqquires is NULL) and start them.
# Trigger a dump of the tasks table if it's inconsistent (no running
# tasks, no ready tasks, but pending tasks exist) and exit with error
# $ETASKLEFT.
master master
# "Fancy" progress info. Only calculate if at least one task has
# succeeded to avoid division by zero.
if (( ran - failed )) if (( ran - failed ))
then then
currenttime=$timestamp currenttime=$timestamp
@ -637,6 +763,7 @@ do
fmtworkers='W:%i/%i' fmtworkers='W:%i/%i'
fmtprogress="T:%${#taskcount}i/%i (F:%i) %3i%%" fmtprogress="T:%${#taskcount}i/%i (F:%i) %3i%%"
fmttime='%2id %2ih%02im%02is (A:%4.1fs/task)' fmttime='%2id %2ih%02im%02is (A:%4.1fs/task)'
# Abuse timeformatting to get ETA.
eta="ETA:$( eta="ETA:$(
printf "%(%c)T" "$(( currenttime + secsremaining ))" printf "%(%c)T" "$(( currenttime + secsremaining ))"
)" )"
@ -655,6 +782,7 @@ do
${minutes:-0} \ ${minutes:-0} \
${seconds:-0} \ ${seconds:-0} \
${avgdsec:-0}.${avgdmsec:-0} ${avgdsec:-0}.${avgdmsec:-0}
# If 0 concurrency is allowed, show paused status when concurrency is 0
if ! (( concurrency )) && ! (( cron )) if ! (( concurrency )) && ! (( cron ))
then then
if (( active )) if (( active ))
@ -668,6 +796,7 @@ done
echo 'COMMIT;' >&3 echo 'COMMIT;' >&3
unset count unset count
# Final report. Calculate elapsed time and format it in human readable way.
endtime=$EPOCHSECONDS endtime=$EPOCHSECONDS
(( elapsedseconds = endtime - starttime )) (( elapsedseconds = endtime - starttime ))
@ -702,6 +831,9 @@ endtime=$EPOCHSECONDS
(( cron )) || echo -en "\033[K" (( cron )) || echo -en "\033[K"
(( ran )) && echo (( ran )) && echo
# If some tasks failed, print them. Don't print failed tasks that did not run
# to avoid confusing tasks marked as failed because they depended on ones that
# failed.
if (( failed )) if (( failed ))
then then
echo $'\nFailed tasks:\n' echo $'\nFailed tasks:\n'
@ -788,6 +920,8 @@ then
done done
fi fi
# Check if there are files that need to be renamed because their rename pattern
# changed.
for destination in "${!destinationpath[@]}" for destination in "${!destinationpath[@]}"
do do
echo ' echo '
@ -847,6 +981,8 @@ do
'vorbis') extension=ogg ;; 'vorbis') extension=ogg ;;
esac esac
(( cron )) || echo -en "$destination: rename pattern changed, renaming files...\033[K" (( cron )) || echo -en "$destination: rename pattern changed, renaming files...\033[K"
# Spawn perl coprocess for unicode to ascii conversion if
# needed.
(( textunidecodeneeded )) && ascii (( textunidecodeneeded )) && ascii
echo 'BEGIN TRANSACTION;' >&3 echo 'BEGIN TRANSACTION;' >&3
for line in "${renamefiles[@]}" for line in "${renamefiles[@]}"
@ -922,8 +1058,12 @@ do
unset count changedcount renamefiles unset count changedcount renamefiles
done done
# Copy files of mime-types matching `copy_mime-type`
copyFiles_action copyFiles_action
# Remove files obsoleted by `renamme_pattern`, `ascii` or `fat32compat` changes.
# Based on `destination_files.old_filename` field, populated upon task
# completion.
echo ' echo '
SELECT destination_files.id, SELECT destination_files.id,
destination_files.filename, destination_files.filename,
@ -970,6 +1110,9 @@ echo 'COMMIT;' >&3
(( cron )) || echo -en "\033[K" (( cron )) || echo -en "\033[K"
(( count )) && echo (( count )) && echo
# Remove empty directories in destinations.
# We blindly duplicate the source tree in the destination, so we may end up
# with empty directories.
(( debug )) && echo "Purging empty directories..." (( debug )) && echo "Purging empty directories..."
for path in "${destinationpath[@]}" for path in "${destinationpath[@]}"
do do

7
configure vendored
View File

@ -1,25 +1,28 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# defaults # Default install prefix if --prefix= is not provided
default_prefix=/usr/local default_prefix=/usr/local
#default_bindir=$default_prefix/bin #default_bindir=$default_prefix/bin
#default_libdir=$default_prefix/lib #default_libdir=$default_prefix/lib
#default_sharedir=$default_prefix/share #default_sharedir=$default_prefix/share
# Parse command-line arguments (only --prefix=VALUE is supported)
while (( $# )) while (( $# ))
do do
case "$1" in case "$1" in
--prefix=*) prefix="${1#*=}" --prefix=*) prefix="${1#*=}" # Strip the "--prefix=" portion to get the value
;; ;;
esac esac
shift shift
done done
# Derive install directories from prefix (or default_prefix if unset)
bindir="${prefix:-$default_prefix}"/bin bindir="${prefix:-$default_prefix}"/bin
libdir="${prefix:-$default_prefix}"/lib/AtOM libdir="${prefix:-$default_prefix}"/lib/AtOM
sharedir="${prefix:-$default_prefix}"/share/AtOM sharedir="${prefix:-$default_prefix}"/share/AtOM
docdir="${prefix:-$default_prefix}"/share/doc/AtOM docdir="${prefix:-$default_prefix}"/share/doc/AtOM
# Write Makefile.in so the Makefile can substitute real paths at build time
cat > Makefile.in <<-EOMakefile.in cat > Makefile.in <<-EOMakefile.in
bindir = "$bindir" bindir = "$bindir"
libdir = "$libdir" libdir = "$libdir"

View File

@ -1,27 +1,47 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
getConfig() { getConfig() {
# Read the config file line by line; 'key' gets the first word, 'value'
# the rest
while read key value while read key value
do do
case $key in case $key in
'#'*) '#'*)
#comment #comment - skip comment lines
;; ;;
'') '')
#empty line #empty line - skip blank lines
;; ;;
'[general]') '[general]')
# Switch parsing context to the [general] section
context=General context=General
;; ;;
'[source]') '[source]')
# Switch parsing context to the [source] section
context=Source context=Source
;; ;;
\[*\]) \[*\])
# Any other [section] header is a destination name
context=Destination context=Destination
destination="${key#[}" destination="${key#[}"
destination="${destination%]}" destination="${destination%]}"
destinations+=("${destination%]}") destinations+=("${destination%]}") # Append to list of destinations
;; ;;
*) *)
# Dispatch key=value to the handler for the current section context
getConfig$context getConfig$context
;; ;;
esac esac

View File

@ -1,30 +1,50 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
getConfigDestination() { getConfigDestination() {
case "$key" in case "$key" in
'enabled') 'enabled')
# 1 = process this destination, 0 = skip it
destinationenabled["$destination"]="$value" destinationenabled["$destination"]="$value"
;; ;;
'path') 'path')
# Strip trailing slash for consistency
destinationpath["$destination"]="${value%/}" destinationpath["$destination"]="${value%/}"
;; ;;
'format') 'format')
case "$value" in case "$value" in
'mp3') 'mp3')
destinationformat["$destination"]=mp3 destinationformat["$destination"]=mp3
# Flag that lame must be present
lameneeded=1 lameneeded=1
# MP3 can't handfle more than 2 channels # MP3 can't handle more than 2 channels
[[ -z ${destinationchannels["$destination"]} ]] \ [[ -z ${destinationchannels["$destination"]} ]] \
&& destinationchannels["$destination"]=2 && destinationchannels["$destination"]=2
;; ;;
'opus') 'opus')
destinationformat["$destination"]=opus destinationformat["$destination"]=opus
# Flag that opusenc must be present
opusencneeded=1 opusencneeded=1
;; ;;
'vorbis') 'vorbis')
destinationformat["$destination"]=vorbis destinationformat["$destination"]=vorbis
# Flag that oggenc must be present
oggencneeded=1 oggencneeded=1
;; ;;
'copy') 'copy')
# Files are copied/hardlinked as-is
destinationformat["$destination"]=copy destinationformat["$destination"]=copy
;; ;;
*) *)
@ -34,6 +54,7 @@ getConfigDestination() {
esac esac
;; ;;
'quality') 'quality')
# Vorbis-only: oggenc quality scale (integer, typically 0-10)
expr='^[0-9]*$' expr='^[0-9]*$'
if ! [[ $value =~ $expr ]] if ! [[ $value =~ $expr ]]
then then
@ -46,27 +67,32 @@ getConfigDestination() {
destinationquality["$destination"]="$value" destinationquality["$destination"]="$value"
;; ;;
*) *)
echo "Invalid parameter \"$key\" for format \"${destinationformat["$destination"]}\"" >&2 echo "Invalid parameter '$key' for" \
"format" \
"'${destinationformat["$destination"]}'" >&2
exit $EFMTINVPARM exit $EFMTINVPARM
;; ;;
esac esac
;; ;;
'normalize') 'normalize')
# Whether to normalize audio volume (via sox --norm)
case $value in case $value in
'true'|'on'|'yes') 'true'|'on'|'yes'|'1')
destinationnormalize["$destination"]=1 destinationnormalize["$destination"]=1
;; ;;
'false'|'off'|'no') 'false'|'off'|'no'|'0')
destinationnormalize["$destination"]=0 destinationnormalize["$destination"]=0
;; ;;
*) *)
echo "normalize takes values:" \ echo "normalize takes values:" \
"'yes' ,'true' ,'on', 'no', 'false',"\ "'yes' ,'true' ,'on', '1', 'no'," \
"'off'" "'false','off', '0'"
;; ;;
esac esac
;; ;;
'bitrate') 'bitrate')
# Opus/MP3: target bitrate in kbps
# integer only; Bash doesn't support floats
expr='^[0-9]*$' expr='^[0-9]*$'
if ! [[ $value =~ $expr ]] if ! [[ $value =~ $expr ]]
then then
@ -88,6 +114,7 @@ getConfigDestination() {
esac esac
;; ;;
'loss') 'loss')
# Opus Forward Error Correction: expected packet loss percentage (0-100)
expr='^[0-9]*$' expr='^[0-9]*$'
if ! [[ $value =~ $expr ]] if ! [[ $value =~ $expr ]]
then then
@ -100,12 +127,15 @@ getConfigDestination() {
destinationloss["$destination"]="$value" destinationloss["$destination"]="$value"
;; ;;
*) *)
echo "$Invalid parameter \"$key\" for format \"${destinationformat["$destination"]}\"" >&2 echo "Invalid parameter '$key' for" \
"format" \
"'${destinationformat["$destination"]}'" >&2
exit $EFMTINVPARM exit $EFMTINVPARM
;; ;;
esac esac
;; ;;
'channels') 'channels')
# Up/downmix to this many channels if needed
expr='^[0-9]*$' expr='^[0-9]*$'
if ! [[ $value =~ $expr ]] if ! [[ $value =~ $expr ]]
then then
@ -116,6 +146,7 @@ getConfigDestination() {
destinationchannels["$destination"]=$value destinationchannels["$destination"]=$value
;; ;;
'frequency') 'frequency')
# Resample to this sample rate in Hz (e.g. 44100)
expr='^[0-9]*$' expr='^[0-9]*$'
if ! [[ $value =~ $expr ]] if ! [[ $value =~ $expr ]]
then then
@ -126,21 +157,24 @@ getConfigDestination() {
destinationfrequency["$destination"]=$value destinationfrequency["$destination"]=$value
;; ;;
'noresample') 'noresample')
# MP3-only: prevent lame from auto-downsampling at low
# bitrates
case $value in case $value in
'true'|'on'|'yes') 'true'|'on'|'yes'|'1')
destinationnoresample["$destination"]=1 destinationnoresample["$destination"]=1
;; ;;
'false'|'off'|'no') 'false'|'off'|'no'|'0')
destinationnoresample["$destination"]=0 destinationnoresample["$destination"]=0
;; ;;
*) *)
echo "noresample takes values:" \ echo "noresample takes values:" \
"'yes' ,'true' ,'on', 'no', 'false',"\ "'yes' ,'true' ,'on', '1', 'no',"\
"'off'" "'false','off', '0'"
;; ;;
esac esac
;; ;;
'rename') 'rename')
# File rename pattern using %{tag} tokens
case "$value" in case "$value" in
*/*) */*)
destinationrenamepath["$destination"]="${value%/*}" destinationrenamepath["$destination"]="${value%/*}"
@ -149,46 +183,62 @@ getConfigDestination() {
destinationrename["$destination"]="${value##*/}" destinationrename["$destination"]="${value##*/}"
;; ;;
'fat32compat') 'fat32compat')
# Strip FAT32-illegal characters (? \ < > : * | ")
# trim spaces/dots
case $value in case $value in
'true'|'on'|'yes') 'true'|'on'|'yes'|'1')
destinationfat32compat["$destination"]=1 destinationfat32compat["$destination"]=1
;; ;;
'false'|'off'|'no') 'false'|'off'|'no'|'0')
destinationfat32compat["$destination"]=0 destinationfat32compat["$destination"]=0
;; ;;
*) *)
echo "fat32compat takes values:" \ echo "fat32compat takes values:" \
"'yes' ,'true' ,'on', 'no', 'false',"\ "'yes' ,'true' ,'on', '1', 'no',"\
"'off'" "'false','off', '0'"
;; ;;
esac esac
;; ;;
'ascii-only') 'ascii-only')
# Transliterate Unicode filenames to ASCII using
#Requires Perl Text::Unidecode
case $value in case $value in
'true'|'on'|'yes') 'true'|'on'|'yes'|'1')
destinationascii["$destination"]=1 destinationascii["$destination"]=1
# Signal that the perl coprocess will
# be needed
textunidecodeneeded=1 textunidecodeneeded=1
;; ;;
'false'|'off'|'no') 'false'|'off'|'no'|'0')
destinationascii["$destination"]=0 destinationascii["$destination"]=0
;; ;;
*) *)
echo "ascii-only takes values:" \ echo "ascii-only takes values:" \
"'yes' ,'true' ,'on', 'no', 'false',"\ "'yes' ,'true' ,'on', '1', 'no',"\
"'off'" "'false','off', '0'"
;; ;;
esac esac
;; ;;
'skip_mime-type') 'skip_mime-type')
# Accumulate pipe-separated list of mime patterns to
# exclude entirely
destinationskipmime[$destination]="${destinationskipmime[$destination]:+${destinationskipmime[$destination]}|}$value" destinationskipmime[$destination]="${destinationskipmime[$destination]:+${destinationskipmime[$destination]}|}$value"
;; ;;
'copy_mime-type') 'copy_mime-type')
# Accumulate pipe-separated list of mime patterns to
# copy verbatim (action=2)
destinationcopymime[$destination]="${destinationcopymime[$destination]:+${destinationcopymime[$destination]}|}$value" destinationcopymime[$destination]="${destinationcopymime[$destination]:+${destinationcopymime[$destination]}|}$value"
;; ;;
'copy_extension') 'copy_extension')
# Accumulate pipe-separated list of file extensions to
# copy verbatim
destinationcopyext[$destination]="${destinationcopyext[$destination]:+${destinationcopyext[$destination]}|}$value" destinationcopyext[$destination]="${destinationcopyext[$destination]:+${destinationcopyext[$destination]}|}$value"
;; ;;
'higher-than') 'higher-than')
# Only re-encode source files with bitrate ABOVE this
# threshold (kbps)
# Files at or below this bitrate will be
# hardlinked/copied instead
expr='^[0-9]*$' expr='^[0-9]*$'
if ! [[ $value =~ $expr ]] if ! [[ $value =~ $expr ]]
then then

View File

@ -1,7 +1,23 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
getConfigGeneral() { getConfigGeneral() {
case $key in case $key in
'max-load') 'max-load')
# Target system 1-minute load average
# concurrency is adjusted to stay near this
expr='^[0-9]*$' expr='^[0-9]*$'
if [[ $value =~ $expr ]] if [[ $value =~ $expr ]]
then then
@ -13,6 +29,7 @@ getConfigGeneral() {
unset expr unset expr
;; ;;
'load-interval') 'load-interval')
# How often (seconds) to adjust worker count
expr='^[0-9]*$' expr='^[0-9]*$'
if [[ $value =~ $expr ]] if [[ $value =~ $expr ]]
then then
@ -48,6 +65,7 @@ getConfigGeneral() {
fi fi
;; ;;
2) 2)
# Best-effort class; niceness 0 (highest) to 7 (lowest)
if [ -n "$niceness" ] \ if [ -n "$niceness" ] \
&& (( niceness >= 0 && niceness <= 7 )) && (( niceness >= 0 && niceness <= 7 ))
then then
@ -59,10 +77,11 @@ getConfigGeneral() {
fi fi
;; ;;
3) 3)
# Idle class: only gets I/O when no other process needs it
ionice="ionice -c3 " ionice="ionice -c3 "
;; ;;
*) *)
echo "Invalid ionice parameters $value"\ echo "Invalid ionice class $value"\
>&2 >&2
exit $EIONICE exit $EIONICE
;; ;;
@ -70,15 +89,23 @@ getConfigGeneral() {
fi fi
;; ;;
'temporary-directory') 'temporary-directory')
# Directory for
# * SQLite FIFOs
# * intermediate WAV files
# * debug logs
tempdir="$value" tempdir="$value"
;; ;;
'database') 'database')
# Path to the SQLite database file
database="$value" database="$value"
;; ;;
'skip-timestamp-microsec') 'skip-timestamp-microsec')
# If non-zero, ignore sub-second precision in file timestamps
# Useful on filesystems that don't preserve microseconds
skip_us_timestamp="$value" skip_us_timestamp="$value"
;; ;;
debug) debug)
# Allow config file to raise debug level (but not lower it)
(( value > debug )) && debug=$value (( value > debug )) && debug=$value
;; ;;
esac esac

View File

@ -1,10 +1,27 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
getConfigSource() { getConfigSource() {
case "$key" in case "$key" in
'path') 'path')
# Root directory of music collection to transcode from
sourcepath="$value" sourcepath="$value"
;; ;;
'skip') 'skip')
# Directory pattern to exclude from scanning
# Multiple 'skip' entries accumulate into this array
skippeddirectories+=( "$value" ) skippeddirectories+=( "$value" )
;; ;;
esac esac

View File

@ -1,6 +1,22 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
printConfig() { printConfig() {
{ {
# Build a pipe-delimited table
# 'column -t -s|' will transform into columns
echo "General|Config file|$cffile" echo "General|Config file|$cffile"
[ -n "$ionice" ] && echo "|IO Nice|$ionice" [ -n "$ionice" ] && echo "|IO Nice|$ionice"
cat <<-EOF cat <<-EOF
@ -12,15 +28,19 @@ printConfig() {
Source|Path|$sourcepath Source|Path|$sourcepath
EOF EOF
# Print skipped directories
# use a continuation prefix after the first
for prune_expression in "${skippeddirectories[@]}" for prune_expression in "${skippeddirectories[@]}"
do do
(( printed )) \ (( printed )) \
&& echo -n ' | |' \ && echo -n ' | |' \
|| echo -n ' |Skipped directories|' || echo -n ' |Skipped directories|'
echo "$prune_expression" echo "$prune_expression"
printed=1 printed=1
done done
unset printed unset printed
# Loop over destinations and print their settings
# use the destination name as a key into the associative arrays
for destination in ${destinations[@]} for destination in ${destinations[@]}
do do
cat <<-EOF cat <<-EOF
@ -30,6 +50,7 @@ printConfig() {
|Format|${destinationformat["$destination"]} |Format|${destinationformat["$destination"]}
|Quality|${destinationquality["$destination"]} |Quality|${destinationquality["$destination"]}
EOF EOF
# Show format-specific fields
if [[ ${destinationformat["$destination"]} == opus ]] if [[ ${destinationformat["$destination"]} == opus ]]
then then
echo " |Expected loss|${destinationloss["$destination"]}" echo " |Expected loss|${destinationloss["$destination"]}"
@ -47,6 +68,7 @@ printConfig() {
|Path Change|${destinationrenamepath["$destination"]} |Path Change|${destinationrenamepath["$destination"]}
|File Rename|${destinationrename["$destination"]} |File Rename|${destinationrename["$destination"]}
EOF EOF
# Display pipe-separated mime lists: one entry per row
[ -n "${destinationskipmime["$destination"]}" ] \ [ -n "${destinationskipmime["$destination"]}" ] \
&& echo " |Skipped mime-types|${destinationskipmime["$destination"]//\|/ && echo " |Skipped mime-types|${destinationskipmime["$destination"]//\|/
| |}" | |}"
@ -57,5 +79,5 @@ printConfig() {
&& echo " |Copied extensions|${destinationcopyext["$destination"]//\|/ && echo " |Copied extensions|${destinationcopyext["$destination"]//\|/
| |}" | |}"
done done
}|column -t -s'|' }|column -t -s'|' # Format as aligned columns using '|' as delimiter
} }

View File

@ -1,5 +1,22 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
# writeConfig() generates a new atom.cfg from the current in-memory settings.
# Output is sent to stdout so callers can redirect it to any config file path.
# Each setting is annotated with a description.
writeConfig() { writeConfig() {
cat <<-EOCfg cat <<-EOCfg
[general] [general]
@ -47,6 +64,7 @@ path $sourcepath
# * skip <directory>: String. Files in <directory> will be ignored. Note that # * skip <directory>: String. Files in <directory> will be ignored. Note that
# <directory> can be any expression accepted by find. # <directory> can be any expression accepted by find.
EOCfg EOCfg
# Emit one skip line per skipped directory
for dir in "${skippeddirectories[@]}" for dir in "${skippeddirectories[@]}"
do do
echo $'skip\t\t\t'"$dir" echo $'skip\t\t\t'"$dir"
@ -55,6 +73,7 @@ path $sourcepath
EOCfg EOCfg
# Emit one section per configured destination
for destination in "${destinations[@]}" for destination in "${destinations[@]}"
do do
cat <<-EOCfg cat <<-EOCfg
@ -190,6 +209,8 @@ bitrate ${destinationquality["$destination"]}
# be included in that destination. For more than one mime-type, use multiple # be included in that destination. For more than one mime-type, use multiple
# times, as needed. The '*' character is a wildcard. # times, as needed. The '*' character is a wildcard.
EOCfg EOCfg
# Emit one skip_mime-type line per MIME pattern (pipe-separated
# in the array)
destinationskipmime["$destination"]="${destinationskipmime["$destination"]}|" destinationskipmime["$destination"]="${destinationskipmime["$destination"]}|"
while [[ ${destinationskipmime["$destination"]} =~ \| ]] while [[ ${destinationskipmime["$destination"]} =~ \| ]]
do do
@ -203,6 +224,7 @@ bitrate ${destinationquality["$destination"]}
# covers and other images to the destination. In fact, AtOM will try to use # covers and other images to the destination. In fact, AtOM will try to use
# hard links instead of copies. # hard links instead of copies.
EOCfg EOCfg
# Emit one copy_mime-type line per MIME pattern
destinationcopymime["$destination"]="${destinationcopymime["$destination"]}|" destinationcopymime["$destination"]="${destinationcopymime["$destination"]}|"
while [[ ${destinationcopymime["$destination"]} =~ \| ]] while [[ ${destinationcopymime["$destination"]} =~ \| ]]
do do
@ -213,6 +235,7 @@ bitrate ${destinationquality["$destination"]}
# * copy_extension <extension>: Copy files whose name and with ".<extension>" # * copy_extension <extension>: Copy files whose name and with ".<extension>"
EOCfg EOCfg
# Emit one copy_extension line per extension pattern
destinationcopyext["$destination"]="${destinationcopyext["$destination"]}|" destinationcopyext["$destination"]="${destinationcopyext["$destination"]}|"
while [[ ${destinationcopyext["$destination"]} =~ \| ]] while [[ ${destinationcopyext["$destination"]} =~ \| ]]
do do

View File

@ -1,6 +1,25 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
copyFiles_action() { copyFiles_action() {
(( cron )) || echo -n $'Copying files...\033[K' (( cron )) || echo -n $'Copying files...\033[K'
# Query all destination_files whose last_change doesn't match the
# source, restricted to mime_type_actions with action=2 (direct copy,
# not transcode).
# The join on mime_type_actions ensures we only copy files destined for
# a destination that has explicitly mapped this MIME type to action=2.
echo ' echo '
SELECT SELECT
source_files.filename, source_files.filename,
@ -22,6 +41,8 @@ copyFiles_action() {
AND mime_type_actions.action = 2; AND mime_type_actions.action = 2;
SELECT "AtOM:NoMoreFiles";' >&3 SELECT "AtOM:NoMoreFiles";' >&3
# Results are NUL-delimited rows; sentinel value signals end of result
# set.
read -u4 -r -d $'\0' line read -u4 -r -d $'\0' line
while ! [[ $line = AtOM:NoMoreFiles ]] while ! [[ $line = AtOM:NoMoreFiles ]]
do do
@ -29,9 +50,14 @@ copyFiles_action() {
read -u4 -r -d $'\0' line read -u4 -r -d $'\0' line
done done
# Wrap all DB updates in a single transaction for performance.
echo 'BEGIN TRANSACTION;' >&3 echo 'BEGIN TRANSACTION;' >&3
for copyfile in "${copyfiles[@]}" for copyfile in "${copyfiles[@]}"
do do
# Each row is a single string with columns joined by
# ::AtOM:SQL:Sep::.
# Strip prefix to extract each field in order, advancing $rest
# each time.
sourcefilename=${copyfile%%::AtOM:SQL:Sep::*} sourcefilename=${copyfile%%::AtOM:SQL:Sep::*}
sourcedir=${sourcefilename%/*} sourcedir=${sourcefilename%/*}
rest="${copyfile#*::AtOM:SQL:Sep::}::AtOM:SQL:Sep::" rest="${copyfile#*::AtOM:SQL:Sep::}::AtOM:SQL:Sep::"
@ -45,20 +71,33 @@ copyFiles_action() {
rest=${rest#*::AtOM:SQL:Sep::} rest=${rest#*::AtOM:SQL:Sep::}
(( count++ )) (( count++ ))
(( cron )) || printf '\b\b\b\b%3i%%' $(( (count * 100) / ${#copyfiles[@]} )) (( cron )) || printf '\b\b\b\b%3i%%' $(( (count * 100) / ${#copyfiles[@]} ))
# If this destination uses a rename/path pattern, delegate path
# resolution to guessPath(), which looks up the
# already-transcoded sibling to find the correct output
# directory.
if [ -n "${destinationrenamepath["$destination"]}" ] if [ -n "${destinationrenamepath["$destination"]}" ]
then then
destdir="$(guessPath)" destdir="$(guessPath)"
guessstatus=$? guessstatus=$?
case $guessstatus in case $guessstatus in
1) 1)
# guessPath found no transcoded
# sibling; skip this file entirely.
continue continue
;; ;;
2) 2)
# Transcoded siblings exist but are not
# yet up to date; defer this copy until
# the next run so the directory is
# stable.
(( postponed++ )) (( postponed++ ))
continue continue
;; ;;
esac esac
else else
# No rename pattern: mirror the source directory
# structure under the destination root, sanitizing each
# path component for the target FS.
destdir="${destinationpath["$destination"]}/" destdir="${destinationpath["$destination"]}/"
if [[ $sourcefilename =~ / ]] if [[ $sourcefilename =~ / ]]
then then
@ -79,6 +118,9 @@ copyFiles_action() {
fi fi
fi fi
fi fi
# Try the cheapest copy methods first: reflink (CoW, same
# filesystem), then hardlink (csame filesystem), falling back
# to a full data copy.
if cp -a --reflink=always \ if cp -a --reflink=always \
"$sourcepath/$sourcefilename" \ "$sourcepath/$sourcefilename" \
"$destdir" \ "$destdir" \
@ -91,6 +133,9 @@ copyFiles_action() {
"$sourcepath/$sourcefilename" \ "$sourcepath/$sourcefilename" \
"$destdir" "$destdir"
then then
# Newlines in filenames are stored as a safe inline
# placeholder so the value can be embedded in SQL
# without breaking row parsing.
destfilename=${sourcefilename//$'\n'/::AtOM:NewLine:SQL:Inline::} destfilename=${sourcefilename//$'\n'/::AtOM:NewLine:SQL:Inline::}
Update destination_files \ Update destination_files \
filename \ filename \

View File

@ -1,12 +1,39 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
checkCopy() { checkCopy() {
# Returns true only when the source file is compatible enough with the
# destination profile that it can be copied instead of re-encoded.
# All three conditions must hold simultaneously.
( (
# If the destination doesn't restrict sample rate, any rate is
# acceptable. Otherwise the source rate must match exactly.
[ -z "${destinationfrequency[$destination]}" ] \ [ -z "${destinationfrequency[$destination]}" ] \
|| (( ${rate:-0} == ${destinationfrequency[$destination]} )) || (( ${rate:-0} == ${destinationfrequency[$destination]} ))
) && ( ) && (
# If the destination doesn't restrict channel count, any number
# is acceptable. Otherwise the source channel count must match
# exactly.
[ -z "${destinationchannels[$destination]}" ] \ [ -z "${destinationchannels[$destination]}" ] \
|| (( ${channels:-0} == ${destinationchannels[$destination]} )) || (( ${channels:-0} == ${destinationchannels[$destination]} ))
) && ( ) && (
# Bitrate check: accept if source exactly matches the target
# quality setting, OR if a maximum bps ceiling is configured
# and the source bitrate is at or below it.
# Default of 1000 kbps when bitrate is unknown forces a
# re-encode
(( ${bitrate:-1000} == ${destinationquality[$destination]} )) \ (( ${bitrate:-1000} == ${destinationquality[$destination]} )) \
|| ( || (
[ -n "${destinationmaxbps[$destination]}" ] \ [ -n "${destinationmaxbps[$destination]}" ] \

View File

@ -1,6 +1,29 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
guessPath() { guessPath() {
# For copy files (action=2) with a rename pattern, we don't know the
# output directory ourselves: it was determined when the audio siblings
# were transcoded (action=1). We infer it by finding a transcoded
# sibling in the same source directory and reading back its destination
# path.
#
# First query: check whether any transcoded sibling is up to date.
# The LIKE pattern matches files directly inside $sourcedir only
# the NOT LIKE excludes deeper subdirectorie to avoid crossing
# boundaries.
echo 'SELECT IFNULL( ( echo 'SELECT IFNULL( (
SELECT destination_files.last_change SELECT destination_files.last_change
FROM destination_files FROM destination_files
@ -22,10 +45,17 @@ guessPath() {
),"0.0"); ),"0.0");
'>&3 '>&3
read -u4 -r -d $'\0' timestamp read -u4 -r -d $'\0' timestamp
# IFNULL returns "0.0" when no transcoded sibling exists yet; strip the
# decimal and treat as zero to detect the no-sibling case.
if (( ${timestamp/./} == 0 )) if (( ${timestamp/./} == 0 ))
then then
# No transcoded sibling found at all
# caller should postpone this copy.
return 2 return 2
fi fi
# Second query: retrieve the actual destination filename of the most
# recently updated transcoded sibling so we can derive its parent
# directory.
echo 'SELECT IFNULL( ( echo 'SELECT IFNULL( (
SELECT destination_files.filename SELECT destination_files.filename
FROM destination_files FROM destination_files
@ -49,8 +79,12 @@ guessPath() {
read -u4 -r -d $'\0' filename read -u4 -r -d $'\0' filename
if [[ $filename != AtOM:NotFound ]] if [[ $filename != AtOM:NotFound ]]
then then
# Strip the filename component to return only the directory
# portion.
echo "${filename%/*}" echo "${filename%/*}"
else else
# Sibling record exists but has no usable filename — skip this
# file.
return 1 return 1
fi fi
} }

View File

@ -1,6 +1,25 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
copyFiles_matching() { copyFiles_matching() {
# Preserve the original file extension so the copy keeps its type
# (e.g. .jpg, .png, .cue) regardless of any rename pattern applied to
# the base name.
local extension="${filename##*.}" local extension="${filename##*.}"
# Try hardlink first (no extra disk space); fall back to a full data
# copy if the source and destination are on different filesystems.
if \ if \
cp -al \ cp -al \
"$sourcepath/$filename" \ "$sourcepath/$filename" \
@ -10,6 +29,11 @@ copyFiles_matching() {
"$sourcepath/$filename" \ "$sourcepath/$filename" \
"${destinationpath[$destination]}/$destdir/$destfile.$extension" "${destinationpath[$destination]}/$destdir/$destfile.$extension"
then then
# Record the new destination path and copy the source
# last_change timestamp via a subquery so the DB reflects when
# the source was last modified.
# old_filename captures the previous path so stale files can be
# cleaned up later.
echo \ echo \
"UPDATE destination_files" \ "UPDATE destination_files" \
"SET filename=" \ "SET filename=" \

View File

@ -1,4 +1,18 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
Delete() { Delete() {
#Delete table < where_key where_operator where_value #Delete table < where_key where_operator where_value
# [where_key where_operator where_value # [where_key where_operator where_value
@ -10,13 +24,16 @@ Delete() {
value \ value \
where_statement \ where_statement \
results results
# Build WHERE clause from stdin: one "key op value" triple per line
while read key operator value while read key operator value
do do
(( ${#where_statement} )) && where_statement+=( "AND" ) (( ${#where_statement} )) && where_statement+=( "AND" )
if [[ $value == NULL ]] if [[ $value == NULL ]]
then then
# NULL comparisons require IS NULL, not = "NULL"
where_statement+=( "$key is NULL" ) where_statement+=( "$key is NULL" )
else else
# Double embedded quotes to safely escape string values
where_statement+=( "$key $operator "'"'"${value//\"/\"\"}"'"' ) where_statement+=( "$key $operator "'"'"${value//\"/\"\"}"'"' )
fi fi
done done

View File

@ -1,14 +1,32 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
Insert() { Insert() {
#Insert table [no_id] < key value #Insert table [no_id] < key value
# [key value # [key value
# […]] # […]]
#
# If no_id is set to a non-zero value, the function will not return the
# auto-assigned row ID of the inserted row. 
local \ local \
table="$1" \ table="$1" \
no_id="${2:-0}" \ no_id="${2:-0}" \
insert_keys \ insert_keys \
insert_values \ insert_values \
results results
# Build column list and value list from stdin key-value pairs
while read key value while read key value
do do
(( ${#insert_keys} )) && insert_keys+="," (( ${#insert_keys} )) && insert_keys+=","
@ -16,16 +34,24 @@ Insert() {
(( ${#insert_values} )) && insert_values+="," (( ${#insert_values} )) && insert_values+=","
case $value in case $value in
'::AtOM:FT::'*) '::AtOM:FT::'*)
# Force-text prefix: strip the marker and quote
# as string (prevents numeric-looking values
# from being stored as int / float)
value="${value//::AtOM:FT::/}" value="${value//::AtOM:FT::/}"
insert_values+='"'"${value//\"/\"\"}"'"' insert_values+='"'"${value//\"/\"\"}"'"'
;; ;;
'NULL') 'NULL')
# Insert SQL NULL (not the string "NULL")
insert_values+="NULL" insert_values+="NULL"
;; ;;
+([0-9])?(.+([0-9]))) +([0-9])?(.+([0-9])))
# Pure integer or decimal: insert unquoted for
# numeric storage
insert_values+=$value insert_values+=$value
;; ;;
*) *)
# General string: restore encoded newlines,
# then quote
value=${value//::AtOM:NewLine:SQL:Inline::/$'\n'} value=${value//::AtOM:NewLine:SQL:Inline::/$'\n'}
insert_values+='"'"${value//\"/\"\"}"'"' insert_values+='"'"${value//\"/\"\"}"'"'
;; ;;
@ -35,6 +61,7 @@ Insert() {
"( $insert_keys )" \ "( $insert_keys )" \
"VALUES" \ "VALUES" \
"( $insert_values );" >&3 "( $insert_values );" >&3
# Unless no_id is set, return the auto-assigned row ID
(( no_id )) || { (( no_id )) || {
echo 'SELECT LAST_INSERT_ROWID();' >&3 echo 'SELECT LAST_INSERT_ROWID();' >&3
read -u4 -r -d $'\0' results read -u4 -r -d $'\0' results

View File

@ -1,6 +1,23 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
InsertIfUnset() { InsertIfUnset() {
#InsertIfUnset table [no_id] < key value \n key value #InsertIfUnset table [no_id] < key value \n key value
#
# If no_id is set to a non-zero value, the function will not return the
# auto-assigned row ID of the inserted row. 
local \ local \
table="$1" \ table="$1" \
no_id="${2:-0}" \ no_id="${2:-0}" \
@ -10,27 +27,32 @@ InsertIfUnset() {
results \ results \
value \ value \
values values
# Read all key-value pairs from stdin into parallel arrays
while read key value while read key value
do do
keys+=( "$key" ) keys+=( "$key" )
values+=( "$value" ) values+=( "$value" )
done done
# Choose which column to return: first key column if no_id, else 'id'
if (( no_id )) if (( no_id ))
then then
column="${keys[0]}" column="${keys[0]}"
else else
column='id' column='id'
fi fi
# Check if a matching row already exists
if ! results=$( if ! results=$(
Select "$table" "$column" < <( Select "$table" "$column" < <(
for key in ${!keys[@]} for key in ${!keys[@]}
do do
# Strip ::AtOM:FT:: for WHERE comparison
echo "${keys[$key]}" = \ echo "${keys[$key]}" = \
"${values[$key]//::AtOM:FT::}" "${values[$key]//::AtOM:FT::}"
done done
) )
) )
then then
# Row not found: insert it and return the new id
results=$( results=$(
Insert "$table" < <( Insert "$table" < <(
for key in ${!keys[@]} for key in ${!keys[@]}

View File

@ -1,4 +1,18 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
InsertOrUpdate() { InsertOrUpdate() {
#InsertOrUpdate table set_key set_value [set_key set_value […]] < where_key where_value #InsertOrUpdate table set_key set_value [set_key set_value […]] < where_key where_value
# [where_key where_value # [where_key where_value
@ -15,6 +29,8 @@ InsertOrUpdate() {
what \ what \
results results
shift shift
# Parse positional args as alternating key/value pairs for the SET
# clause
what=key what=key
for argument for argument
do do
@ -29,11 +45,13 @@ InsertOrUpdate() {
;; ;;
esac esac
done done
# Read WHERE conditions from stdin
while read key value while read key value
do do
keys+=( "$key" ) keys+=( "$key" )
values+=( "$value" ) values+=( "$value" )
done done
# Check if a matching row exists using the WHERE keys
if results=$( if results=$(
Select "$table" ${keys[0]} < <( Select "$table" ${keys[0]} < <(
for key in ${!keys[@]} for key in ${!keys[@]}
@ -43,6 +61,7 @@ InsertOrUpdate() {
) )
) )
then then
# Row exists: update it with the SET values
Update "$table" "$@" < <( Update "$table" "$@" < <(
for key in ${!keys[@]} for key in ${!keys[@]}
do do
@ -50,6 +69,8 @@ InsertOrUpdate() {
done done
) )
else else
# Row not found: insert combining SET columns and WHERE-match
# columns
results=$( results=$(
Insert "$table" < <( Insert "$table" < <(
for key in ${!set_keys[@]} for key in ${!set_keys[@]}

View File

@ -1,4 +1,18 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
Select() { Select() {
#Select table [col1 [col2 [..]]] < WHERE_key WHERE_operator WHERE_value #Select table [col1 [col2 [..]]] < WHERE_key WHERE_operator WHERE_value
# [WHERE_key WHERE_operator WHERE_value # [WHERE_key WHERE_operator WHERE_value
@ -11,23 +25,28 @@ Select() {
results \ results \
where_statement where_statement
shift shift
# Build column list
for col for col
do do
(( ${#columns} )) && columns+=',' (( ${#columns} )) && columns+=','
columns+="$col" columns+="$col"
done done
# Build WHERE clause from stdin triplets
while read key operator value while read key operator value
do do
(( ${#where_statement} )) && where_statement+=( "AND" ) (( ${#where_statement} )) && where_statement+=( "AND" )
# Restore encoded newlines before embedding in SQL
value=${value//::AtOM:NewLine:SQL:Inline::/$'\n'} value=${value//::AtOM:NewLine:SQL:Inline::/$'\n'}
where_statement+=( "$key $operator "'"'"${value//\"/\"\"}"'"' ) where_statement+=( "$key $operator "'"'"${value//\"/\"\"}"'"' )
done done
# Use IFNULL so SQLite always produces output.
echo "SELECT IFNULL(" \ echo "SELECT IFNULL(" \
"(SELECT $columns FROM $table" \ "(SELECT $columns FROM $table" \
"WHERE ${where_statement[@]})" \ "WHERE ${where_statement[@]})" \
",'SQL::Select:not found'" \ ",'SQL::Select:not found'" \
");" >&3 ");" >&3
read -u 4 -r -d $'\0' results read -u 4 -r -d $'\0' results
# Return exit code 1 if the sentinel value indicates no row was found
if ! [[ $results == "SQL::Select:not found" ]] if ! [[ $results == "SQL::Select:not found" ]]
then then
echo "$results" echo "$results"

View File

@ -1,4 +1,18 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
Update() { Update() {
#Update table set_key set_value [set_key set_value […]] < where_key where_operator where_value #Update table set_key set_value [set_key set_value […]] < where_key where_operator where_value
# [where_key where_operator where_value # [where_key where_operator where_value
@ -16,17 +30,22 @@ Update() {
where_statement \ where_statement \
results results
shift shift
# Parse positional args as alternating key/value for the SET clause
what=key what=key
for argument for argument
do do
case $what in case $what in
key) key)
# Backtick-quote column name to handle reserved
# words
set_statement="${set_statement:+${set_statement},}\`$argument\`" set_statement="${set_statement:+${set_statement},}\`$argument\`"
what=value what=value
;; ;;
value) value)
case $argument in case $argument in
'::AtOM:FT::'*) '::AtOM:FT::'*)
# Force-text: strip prefix and
# quote as string
argument="${argument//::AtOM:FT::/}" argument="${argument//::AtOM:FT::/}"
set_statement+=" = "'"'"${argument//\"/\"\"}"'"' set_statement+=" = "'"'"${argument//\"/\"\"}"'"'
;; ;;
@ -34,6 +53,7 @@ Update() {
set_statement+=" = NULL" set_statement+=" = NULL"
;; ;;
+([0-9])?(.+([0-9]))) +([0-9])?(.+([0-9])))
# Numeric value: store unquoted
set_statement+=" = $argument" set_statement+=" = $argument"
;; ;;
*) *)
@ -44,6 +64,7 @@ Update() {
;; ;;
esac esac
done done
# Build WHERE clause from stdin
while read key operator value while read key operator value
do do
(( ${#where_statement} )) && where_statement+=( "AND" ) (( ${#where_statement} )) && where_statement+=( "AND" )
@ -52,6 +73,7 @@ Update() {
where_statement+=( "$key is NULL" ) where_statement+=( "$key is NULL" )
;; ;;
+([0-9.])) +([0-9.]))
# Numeric: compare without quotes
where_statement+=( "$key $operator $value" ) where_statement+=( "$key $operator $value" )
;; ;;
*) *)

View File

@ -1,26 +1,53 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
# Current schema version this AtOM binary understands
currentdbversion=8 currentdbversion=8
checkDatabaseVersion() { checkDatabaseVersion() {
local dbversion local dbversion
# Try to read the stored version from the 'atom' metadata table
if dbversion=$(Select atom version <<<"\"1\" = 1") if dbversion=$(Select atom version <<<"\"1\" = 1")
then then
if (( dbversion == currentdbversion )) if (( dbversion == currentdbversion ))
then then
return 0 return 0 # Already up to date
elif (( dbversion < currentdbversion )) elif (( dbversion < currentdbversion ))
then then
# Run sequential upgrade functions until we reach
# `$currentdbversion`
until (( dbversion == currentdbversion )) until (( dbversion == currentdbversion ))
do do
# Dynamically calls e.g. upgradedatabase_3_4
upgradedatabase_${dbversion}_$((dbversion+1)) upgradedatabase_${dbversion}_$((dbversion+1))
# After each upgrade, re-read the version from
# the database to ensure it was updated
# correctly
dbversion=$(Select atom version <<<"\"1\" = 1") dbversion=$(Select atom version <<<"\"1\" = 1")
done done
else else
# DB was created by a newer AtOM; we can't run and
# ensure consistency
echo "Database schema version $dbversion is" \ echo "Database schema version $dbversion is" \
"higher thanthat of this version of" \ "higher thanthat of this version of" \
"AtOM ($currentdbversion). Bailing out." >&2 "AtOM ($currentdbversion). Bailing out." >&2
exit $EDBVERSION exit $EDBVERSION
fi fi
else else
# No version row found: this is a database from very early
# drafts
# This is stupid but nobody is running with DB schema v0 anyway
Insert atom 1 <<<"version $currentdbversion" Insert atom 1 <<<"version $currentdbversion"
fi fi
} }

View File

@ -1,11 +1,31 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
closeDatabase() { closeDatabase() {
# Run VACUUM to reclaim space and defragment the database before closing
echo 'vacuum;' >&3 echo 'vacuum;' >&3
# Tell sqlite3 to exit cleanly
echo .quit >&3 echo .quit >&3
(( debug )) && echo -n "Waiting for SQLite to terminate... " (( debug )) && echo -n "Waiting for SQLite to terminate... "
# Close the debug tee fd if it was opened (debug level > 2)
(( debug > 2 )) && exec 5>&- (( debug > 2 )) && exec 5>&-
# Wait for the sqlite3 background process to fully exit
wait $db_pid wait $db_pid
(( debug )) && echo OK (( debug )) && echo OK
# Close the write end of the SQLite input FIFO (FD 3)
exec 3>&- exec 3>&-
# Close the read end of the SQLite output FIFO (FD 4)
exec 4<&- exec 4<&-
} }

View File

@ -1,11 +1,35 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
openDatabase() { openDatabase() {
local \ local \
populate_db populate_db
# If the DB file doesn't exist yet, mark it for schema population
[[ -f "$database" ]] || populate_db=1 [[ -f "$database" ]] || populate_db=1
# Create named FIFOs for bidirectional communication with sqlite3
rm -f "$tempdir"/sqlite.{in,out} rm -f "$tempdir"/sqlite.{in,out}
mkfifo "$tempdir"/sqlite.{in,out} mkfifo "$tempdir"/sqlite.{in,out}
# Start sqlite3 in background:
# - '-newline ::AtOM:SQL:EOL::' makes each row end with our custom
# marker. Allows storing newlines.
# - pipe through sed to convert the custom EOL to NUL bytes
# (for 'read -d $'\0'')
# - sed also removes the trailing newline that follows each NUL
stdbuf -o0 sqlite3 -bail \ stdbuf -o0 sqlite3 -bail \
-newline $'::AtOM:SQL:EOL::\n' \ -newline $'::AtOM:SQL:EOL::\n' \
"$database" \ "$database" \
@ -13,21 +37,42 @@ openDatabase() {
| stdbuf -o0 \ | stdbuf -o0 \
sed 's/::AtOM:SQL:EOL::/\x0/g;s/\(\x0\)\xA/\1/g' \ sed 's/::AtOM:SQL:EOL::/\x0/g;s/\(\x0\)\xA/\1/g' \
> "$tempdir/sqlite.out" & > "$tempdir/sqlite.out" &
# Store the PID of the background sqlite3 process so we can wait for it
# to exit
db_pid=$! db_pid=$!
# Open FD 3 as the write end (send SQL commands to sqlite3)
exec 3> "$tempdir"/sqlite.in exec 3> "$tempdir"/sqlite.in
# Open FD 4 as the read end (receive query results from sqlite3)
exec 4< "$tempdir"/sqlite.out exec 4< "$tempdir"/sqlite.out
# FIFOs can be deleted immediately after opening; the fds keep them
# alive
rm "$tempdir"/sqlite.{in,out} rm "$tempdir"/sqlite.{in,out}
# At debug level > 2, tee all SQL to a debug log file (FD 5 = original FD 3)
if (( debug > 2 )) if (( debug > 2 ))
then then
exec 5>&3 exec 5>&3
exec 3> >(tee -a "$tempdir/debug.log" >&5) exec 3> >(tee -a "$tempdir/debug.log" >&5)
fi fi
# If new database, populate schema from the SQL schema file
(( populate_db )) && cat $schema >&3 (( populate_db )) && cat $schema >&3
# Configure sqlite3 output separator to match what we parse with ::AtOM:SQL:Sep::
echo '.separator ::AtOM:SQL:Sep::' >&3 echo '.separator ::AtOM:SQL:Sep::' >&3
# Enforce referential integrity
echo 'PRAGMA foreign_keys = ON;' >&3 echo 'PRAGMA foreign_keys = ON;' >&3
# Allow trigger chains
echo 'PRAGMA recursive_triggers = ON;' >&3 echo 'PRAGMA recursive_triggers = ON;' >&3
# Keep temp tables in memory
echo 'PRAGMA temp_store = 2;' >&3 echo 'PRAGMA temp_store = 2;' >&3
# We don't handle concurrent writes, lock the database for exclusive
# access to prevent corruption
echo 'PRAGMA locking_mode = EXCLUSIVE;' >&3 echo 'PRAGMA locking_mode = EXCLUSIVE;' >&3
# Drain the initial empty result sqlite3 sends on startup
read -u4 -r -d $'\0' read -u4 -r -d $'\0'
unset REPLY unset REPLY
checkDatabaseVersion checkDatabaseVersion

View File

@ -1,5 +1,18 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
upgradedatabase_1_2() { upgradedatabase_1_2() {
local data \ local data \
datas \ datas \
@ -9,8 +22,11 @@ upgradedatabase_1_2() {
fat32 fat32
echo "Upgrading database to version 2... (backup is $database.bak_v1)" echo "Upgrading database to version 2... (backup is $database.bak_v1)"
cp "$database" "$database.bak_v1" cp "$database" "$database.bak_v1"
# Add new columns to hold the fat32compat and ascii settings separately
echo 'ALTER TABLE destination_files ADD COLUMN fat32compat INTEGER;' >&3 echo 'ALTER TABLE destination_files ADD COLUMN fat32compat INTEGER;' >&3
echo 'ALTER TABLE destination_files ADD COLUMN ascii INTEGER;' >&3 echo 'ALTER TABLE destination_files ADD COLUMN ascii INTEGER;' >&3
# Read all existing destination_files rows to migrate rename_pattern format
# Old format embedded fat32compat after a colon: "pattern:fat32value"
echo ' echo '
SELECT id, SELECT id,
rename_pattern rename_pattern
@ -24,14 +40,17 @@ SELECT "AtOM::NoMoreData";' >&3
datas+=( "$data" ) datas+=( "$data" )
read -u4 -r -d $'\0' data read -u4 -r -d $'\0' data
done done
# Ensure consistency by performing all updates in a single transaction
echo 'BEGIN TRANSACTION;' >&3 echo 'BEGIN TRANSACTION;' >&3
for data in "${datas[@]}" for data in "${datas[@]}"
do do
id="${data%%::AtOM:SQL:Sep::*}" id="${data%%::AtOM:SQL:Sep::*}"
rename_pattern="${data#*::AtOM:SQL:Sep::}" rename_pattern="${data#*::AtOM:SQL:Sep::}"
# Split "pattern:fat32" on the colon separator
IFS=':' IFS=':'
read pattern fat32 <<<"$rename_pattern" read pattern fat32 <<<"$rename_pattern"
IFS="$oldIFS" IFS="$oldIFS"
# ASCII-only didn't exist in v1; default to off
Update destination_files \ Update destination_files \
rename_pattern "$pattern" \ rename_pattern "$pattern" \
fat32compat "$fat32" \ fat32compat "$fat32" \

View File

@ -1,5 +1,18 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
upgradedatabase_2_3() { upgradedatabase_2_3() {
local data \ local data \
datas \ datas \
@ -7,7 +20,9 @@ upgradedatabase_2_3() {
destination destination
echo "Upgrading database to version 3... (backup is $database.bak_v2)" echo "Upgrading database to version 3... (backup is $database.bak_v2)"
cp "$database" "$database.bak_v2" cp "$database" "$database.bak_v2"
# Add 'enabled' flag to destinations so individual destinations can be disabled
echo 'ALTER TABLE destinations ADD COLUMN enabled INTEGER DEFAULT 1;' >&3 echo 'ALTER TABLE destinations ADD COLUMN enabled INTEGER DEFAULT 1;' >&3
# Enable all existing destinations (preserve old behaviour where all were active)
Update destinations enabled 1 <<< "1 = 1" Update destinations enabled 1 <<< "1 = 1"
Update atom version 3 <<<"1 = 1" Update atom version 3 <<<"1 = 1"

View File

@ -1,8 +1,22 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
upgradedatabase_3_4() { upgradedatabase_3_4() {
echo "Upgrading database to version 4... (backup is $database.bak_v3)" echo "Upgrading database to version 4... (backup is $database.bak_v3)"
cp "$database" "$database.bak_v3" cp "$database" "$database.bak_v3"
# Add releasecountry tag storage (MusicBrainz Album Release Country)
echo 'ALTER TABLE tags ADD COLUMN releasecountry TEXT;' >&3 echo 'ALTER TABLE tags ADD COLUMN releasecountry TEXT;' >&3
Update atom version 4 <<<"1 = 1" Update atom version 4 <<<"1 = 1"

View File

@ -1,8 +1,23 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
upgradedatabase_4_5() { upgradedatabase_4_5() {
echo "Upgrading database to version 5... (backup is $database.bak_v4)" echo "Upgrading database to version 5... (backup is $database.bak_v4)"
cp "$database" "$database.bak_v4" cp "$database" "$database.bak_v4"
# Drop and recreate the trigger so it now also watches releasecountry
# (added in v4 but not yet included in the trigger's watched columns)
echo 'DROP TRIGGER force_destination_update_on_tag_update;' >&3 echo 'DROP TRIGGER force_destination_update_on_tag_update;' >&3
echo ' echo '
CREATE TRIGGER IF NOT EXISTS force_destination_update_on_tag_update CREATE TRIGGER IF NOT EXISTS force_destination_update_on_tag_update
@ -24,6 +39,8 @@ upgradedatabase_4_5() {
depth depth
ON tags ON tags
BEGIN BEGIN
-- Reset destination timestamp so the file gets
-- re-encoded on next run
UPDATE destination_files SET last_change=0 UPDATE destination_files SET last_change=0
WHERE source_file_id=old.source_file; WHERE source_file_id=old.source_file;
END; END;

View File

@ -1,11 +1,25 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
upgradedatabase_5_6() { upgradedatabase_5_6() {
echo "Upgrading database to version 6... (backup is $database.bak_v5)" echo "Upgrading database to version 6... (backup is $database.bak_v5)"
cp "$database" "$database.bak_v5" cp "$database" "$database.bak_v5"
echo ' echo '
ALTER TABLE tags ADD COLUMN replaygain_alb TEXT; ALTER TABLE tags ADD COLUMN replaygain_alb TEXT;
ALTER TABLE tags ADD COLUMN replaygain_trk TEXT; ALTER TABLE tags ADD COLUMN replaygain_trk TEXT;
-- Recreate trigger to also watch the new ReplayGain columns
DROP TRIGGER force_destination_update_on_tag_update; DROP TRIGGER force_destination_update_on_tag_update;
CREATE TRIGGER IF NOT EXISTS force_destination_update_on_tag_update CREATE TRIGGER IF NOT EXISTS force_destination_update_on_tag_update
AFTER UPDATE OF AFTER UPDATE OF

View File

@ -1,8 +1,25 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
upgradedatabase_6_7() { upgradedatabase_6_7() {
echo "Upgrading database to version 7... (backup is $database.bak_v6)" echo "Upgrading database to version 7... (backup is $database.bak_v6)"
cp "$database" "$database.bak_v6" cp "$database" "$database.bak_v6"
# In v6 and earlier, destination_files.filename stored absolute paths.
# From v7 onwards, filenames are stored relative to the destination
# root.
# Strip the destination path prefix from all stored filenames.
for destination in "${destinations[@]}" for destination in "${destinations[@]}"
do do
echo "UPDATE destination_files SET filename = REPLACE(filename,'${destinationpath[$destination]}/','') WHERE filename LIKE '${destinationpath[$destination]}/%';" >&3 echo "UPDATE destination_files SET filename = REPLACE(filename,'${destinationpath[$destination]}/','') WHERE filename LIKE '${destinationpath[$destination]}/%';" >&3

View File

@ -1,9 +1,25 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
upgradedatabase_7_8() { upgradedatabase_7_8() {
echo "Upgrading database to version 8... (backup is $database.bak_v7)" echo "Upgrading database to version 8... (backup is $database.bak_v7)"
cp "$database" "$database.bak_v7" cp "$database" "$database.bak_v7"
echo 'Deletion of old files was failing. Users of previous versions (YOU!) are strongly advised to run cleandestinations with the "-r" flag.' # This migration only contains a user notice; no schema changes needed.
read -p "Press Enter to continue..." # The bug fixed in v8 was that old destination files were not being
Update atom version 8 <<<"1 = 1" # deleted on disk correctly; users must run 'cleandestinations -r' to clean up.
echo 'Deletion of old files was failing. Users of previous versions (YOU!) are strongly advised to run cleandestinations with the "-r" flag.'
read -p "Press Enter to continue..."
Update atom version 8 <<<"1 = 1"
} }

View File

@ -1,12 +1,34 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
# soxtaskid persists across destinations so encode tasks share a single sox task
declare soxtaskid declare soxtaskid
decodeFile() { decodeFile() {
# copy destinations bypass decoding entirely
if [[ ${destinationformat["$destination"]} == copy ]] if [[ ${destinationformat["$destination"]} == copy ]]
then then
copied=1 copied=1
else else
# Dispatch to the appropriate decoder based on the source
# MIME type.
# sox_needed is set when normalization, resampling, or channel
# up/downmixing is required. Used to determine whether to
# insert an intermediate sox processing task.
case "$mimetype" in case "$mimetype" in
'video/'*) 'video/'*)
# Extract audio if ffmpeg is available
(( disablevideo )) && continue (( disablevideo )) && continue
extractAudio extractAudio
if (( ${destinationnormalize["$destination"]}))\ if (( ${destinationnormalize["$destination"]}))\
@ -22,6 +44,8 @@ decodeFile() {
fi fi
;; ;;
'audio/mpeg') 'audio/mpeg')
# Copy MP3 as-is if format and quality match
# otherwise decode with sox
if [[ ${destinationformat[$destination]} = mp3 ]] \ if [[ ${destinationformat[$destination]} = mp3 ]] \
&& checkCopy && checkCopy
then then
@ -30,7 +54,9 @@ decodeFile() {
decodeSox decodeSox
fi fi
;; ;;
'application/ogg opus') 'application/ogg opus'|'audio/ogg opus')
# Copy Opus as-is if format and quality match
# otherwise decode with opusdec
if [[ ${destinationformat[$destination]} = opus ]] \ if [[ ${destinationformat[$destination]} = opus ]] \
&& checkCopy && checkCopy
then then
@ -51,28 +77,9 @@ decodeFile() {
fi fi
fi fi
;; ;;
'audio/ogg opus') 'application/ogg'*|'audio/ogg'*)
if [[ ${destinationformat[$destination]} = opus ]] \ # Ogg Vorbis: copy if format/quality match
&& checkCopy # otherwise decode with sox
then
copied=1
else
(( disableopusdec )) && continue
decodeOpusdec
if (( ${destinationnormalize["$destination"]}))\
|| (
[ -n "${destinationfrequency["$destination"]}" ]\
&& (( ${rate:-0} != ${destinationfrequency["$destination"]}))\
) || (
[ -n "${destinationchannels["$destination"]}" ]\
&& (( ${channels:-0} != ${destinationchannels["$destination"]} ))
)
then
sox_needed=1
fi
fi
;;
'application/ogg'*)
if [[ ${destinationformat[$destination]} = vorbis ]] \ if [[ ${destinationformat[$destination]} = vorbis ]] \
&& checkCopy && checkCopy
then then
@ -81,22 +88,14 @@ decodeFile() {
decodeSox decodeSox
fi fi
;; ;;
'audio/ogg'*) 'audio/x-flac'|'audio/flac')
if [[ ${destinationformat[$destination]} = vorbis ]] \ # FLAC: always decode via sox
&& checkCopy # copy for FLAC makes little sense
then
copied=1
else
decodeSox
fi
;;
'audio/x-flac')
decodeSox
;;
'audio/flac')
decodeSox decodeSox
;; ;;
*) *)
# Unknown MIME type: probe with file to detect
# Musepack
extendedtype=$(file -b "$sourcepath/$filename") extendedtype=$(file -b "$sourcepath/$filename")
case "$extendedtype" in case "$extendedtype" in
*'Musepack '*) *'Musepack '*)
@ -115,6 +114,9 @@ decodeFile() {
fi fi
;; ;;
*) *)
# Truly unknown format: try
# ffmpeg if available,
# otherwise fall back to sox
if (( disablevideo )) if (( disablevideo ))
then then
decodeSox decodeSox
@ -138,6 +140,10 @@ decodeFile() {
esac esac
if ! (( copied )) if ! (( copied ))
then then
# Insert a decode task if one doesn't already exist for
# this source file
# keyed by $tmpfile so multiple destinations share a
# single decode task
if ! decodetaskid=$( if ! decodetaskid=$(
Select tasks id <<<"key = $tmpfile" Select tasks id <<<"key = $tmpfile"
) )
@ -160,11 +166,18 @@ decodeFile() {
fi fi
if (( sox_needed )) if (( sox_needed ))
then then
# Insert a sox post-processing task chained
# after the decode task
decodeSox "$tempdir/$tmpfile.wav" decodeSox "$tempdir/$tmpfile.wav"
if ! soxtaskid=$( if ! soxtaskid=$(
Select tasks id <<<"key = $tmpfile" Select tasks id <<<"key = $tmpfile"
) )
then then
# Increment the decode task's
# required_by counter so cleaner()
# waits for all dependent tasks to
# finish before cleaning up the
# intermediate file
parent_required=$( parent_required=$(
Select tasks required_by \ Select tasks required_by \
<<<"id = $decodetaskid" <<<"id = $decodetaskid"

View File

@ -1,6 +1,26 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
decodeMpcdec() { decodeMpcdec() {
# Set the tmpfile base name for this specific decode task
# source file id + decoder name
tmpfile="${fileid}mpcdec" tmpfile="${fileid}mpcdec"
# Build mpcdec command: decode Musepack to WAV in tempdir
# ${ionice} prepends the ionice invocation string if configured
commandline=(${ionice}mpcdec) commandline=(${ionice}mpcdec)
# Encode any literal newlines in the filename as the SQL-safe inline
# marker
commandline+=("$sourcepath/${filename//$'\n'/::AtOM:NewLine:SQL:Inline::}" "$tempdir/$tmpfile.wav") commandline+=("$sourcepath/${filename//$'\n'/::AtOM:NewLine:SQL:Inline::}" "$tempdir/$tmpfile.wav")
} }

View File

@ -1,6 +1,26 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
decodeOpusdec() { decodeOpusdec() {
# Set the tmpfile base name for this specific decode task
# source file id + decoder name
tmpfile="${fileid}opusdec" tmpfile="${fileid}opusdec"
# Build opusdec command: decode Opus to WAV in tempdir
# ${ionice} prepends the ionice invocation string if configured
commandline=(${ionice}opusdec) commandline=(${ionice}opusdec)
# Encode any literal newlines in the filename as the SQL-safe inline
# marker
commandline+=("$sourcepath/${filename//$'\n'/::AtOM:NewLine:SQL:Inline::}" "$tempdir/$tmpfile.wav") commandline+=("$sourcepath/${filename//$'\n'/::AtOM:NewLine:SQL:Inline::}" "$tempdir/$tmpfile.wav")
} }

View File

@ -1,36 +1,67 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
decodeSox() { decodeSox() {
# Build a SoX decode command with optional processing (normalize,
# resample, up/downmix)
commandline=(${ionice}sox --single-threaded --temp "$tempdir") commandline=(${ionice}sox --single-threaded --temp "$tempdir")
soxoptions_in='' soxoptions_in=''
soxoptions_out='' soxoptions_out=''
# Add --norm if output normalization is requested
if (( ${destinationnormalize["$destination"]} )) if (( ${destinationnormalize["$destination"]} ))
then then
commandline+=(--norm) commandline+=(--norm)
soxoptions_in+=' --norm' soxoptions_in+=' --norm'
fi fi
# $1 can be set to pass an already decoded file.
# Use the the original file when unset
if [ -n "$1" ] if [ -n "$1" ]
then then
commandline+=("$1") commandline+=("$1")
else else
commandline+=("$sourcepath/${filename//$'\n'/::AtOM:NewLine:SQL:Inline::}") commandline+=("$sourcepath/${filename//$'\n'/::AtOM:NewLine:SQL:Inline::}")
fi fi
# Add resampling if requested
if [ -n "${destinationfrequency["$destination"]}" ] \ if [ -n "${destinationfrequency["$destination"]}" ] \
&& (( ${rate:-0} != ${destinationfrequency["$destination"]} )) && (( ${rate:-0} != ${destinationfrequency["$destination"]} ))
then then
commandline+=(-r ${destinationfrequency["$destination"]}) commandline+=(-r ${destinationfrequency["$destination"]})
soxoptions_out+=" -r ${destinationfrequency["$destination"]}" soxoptions_out+=" -r ${destinationfrequency["$destination"]}"
fi fi
# Add channel up/downmixing if requested
if [ -n "${destinationchannels["$destination"]}" ] \ if [ -n "${destinationchannels["$destination"]}" ] \
&& (( ${channels:-0} != ${destinationchannels["$destination"]} )) && (( ${channels:-0} != ${destinationchannels["$destination"]} ))
then then
commandline+=(-c ${destinationchannels["$destination"]}) commandline+=(-c ${destinationchannels["$destination"]})
soxoptions_out+=" -c ${destinationchannels["$destination"]}" soxoptions_out+=" -c ${destinationchannels["$destination"]}"
fi fi
# Downsample to 16-bit if source resolution exceeds 16 bits
if (( ${depth:-0} > 16 )) if (( ${depth:-0} > 16 ))
then then
commandline+=(-b 16) commandline+=(-b 16)
soxoptions_out+=" -b 16" soxoptions_out+=" -b 16"
fi fi
# Encode all sox options into the tmpfile name so different processing
# chains get unique WAV files
# Avoids conflicts with different samplerate/norm/channel counts
tmpfile="$fileid${soxoptions_in// /}${soxoptions_out// /}" tmpfile="$fileid${soxoptions_in// /}${soxoptions_out// /}"
commandline+=("$tempdir/$tmpfile.wav") commandline+=("$tempdir/$tmpfile.wav")
} }

View File

@ -1,7 +1,22 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
createDestinations() { createDestinations() {
for destination in ${destinations[@]} for destination in ${destinations[@]}
do do
# Create destination directory if it doesn't exist yet
if ! [ -d "${destinationpath["$destination"]}" ] if ! [ -d "${destinationpath["$destination"]}" ]
then then
if ! mkdir -p "${destinationpath["$destination"]}" if ! mkdir -p "${destinationpath["$destination"]}"
@ -10,6 +25,8 @@ createDestinations() {
exit $EINVDEST exit $EINVDEST
fi fi
fi fi
# Ensure the destination has a DB record; store its numeric ID
# for later use
destinationid["$destination"]=$( destinationid["$destination"]=$(
InsertIfUnset destinations <<<"name $destination ${destinationenabled[\"$destination\"]}" InsertIfUnset destinations <<<"name $destination ${destinationenabled[\"$destination\"]}"
) )

View File

@ -1,12 +1,32 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
updateMimes() { updateMimes() {
# Reset all mime_actions to action=1 (transcode) as the default
Update mime_actions action 1 <<<"action != 1" Update mime_actions action 1 <<<"action != 1"
# For each destination's skip patterns, set action=0 (exclude from
# processing)
# Multiple patterns are pipe-separated; split by setting IFS='|'
for destination in ${!destinationskipmime[@]} for destination in ${!destinationskipmime[@]}
do do
IFS='|' IFS='|'
for mime_type in ${destinationskipmime["$destination"]} for mime_type in ${destinationskipmime["$destination"]}
do do
IFS="$oldIFS" IFS="$oldIFS"
# Convert config wildcard '*' to SQL wildcard '%'
Update mime_type_actions action 0 >/dev/null < <( Update mime_type_actions action 0 >/dev/null < <(
cat <<-EOWhere cat <<-EOWhere
destination_id = ${destinationid["$destination"]} destination_id = ${destinationid["$destination"]}
@ -15,12 +35,17 @@ updateMimes() {
) )
done done
done done
# For each destination's copy-mime patterns, set action=2 (copy
# verbatim)
# Multiple patterns are pipe-separated; split by setting IFS='|'
for destination in ${!destinationcopymime[@]} for destination in ${!destinationcopymime[@]}
do do
IFS='|' IFS='|'
for mime_type in ${destinationcopymime["$destination"]} for mime_type in ${destinationcopymime["$destination"]}
do do
IFS="$oldIFS" IFS="$oldIFS"
# Convert config wildcard '*' to SQL wildcard '%'
Update mime_type_actions action 2 >/dev/null < <( Update mime_type_actions action 2 >/dev/null < <(
cat <<-EOWhere cat <<-EOWhere
destination_id = ${destinationid["$destination"]} destination_id = ${destinationid["$destination"]}

View File

@ -1,13 +1,30 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
encodeFile::mp3() { encodeFile::mp3() {
# Build lame ABR encode command with all available metadata
lameopts=(${ionice}lame --quiet --noreplaygain) lameopts=(${ionice}lame --quiet --noreplaygain)
lameopts+=(-v --abr ${destinationquality[$destination]}) lameopts+=(-v --abr ${destinationquality[$destination]})
# Embed ID3 tags for each available metadata field
[ -n "$album" ] && lameopts+=(--tl "$album" ) [ -n "$album" ] && lameopts+=(--tl "$album" )
[ -n "$artist" ] && lameopts+=(--ta "$artist") [ -n "$artist" ] && lameopts+=(--ta "$artist")
[ -n "$genre" ] && lameopts+=(--tg "$genre") [ -n "$genre" ] && lameopts+=(--tg "$genre")
[ -n "$title" ] && lameopts+=(--tt "$title") [ -n "$title" ] && lameopts+=(--tt "$title")
[ -n "$track" ] && lameopts+=(--tn "$track") [ -n "$track" ] && lameopts+=(--tn "$track")
[ -n "$year" ] && lameopts+=(--ty "$year") [ -n "$year" ] && lameopts+=(--ty "$year")
# Extended tags using ID3v2 frames (TXXX for custom/non-standard fields)
[ -n "$albumartist" ] && lameopts+=(--tv TPE2="$albumartist") [ -n "$albumartist" ] && lameopts+=(--tv TPE2="$albumartist")
[ -n "$composer" ] && lameopts+=(--tv TCOM="$composer") [ -n "$composer" ] && lameopts+=(--tv TCOM="$composer")
[ -n "$performer" ] && lameopts+=(--tv TOPE="$performer") [ -n "$performer" ] && lameopts+=(--tv TOPE="$performer")
@ -18,6 +35,9 @@ encodeFile::mp3() {
[ -n "$replaygain_trk" ] \ [ -n "$replaygain_trk" ] \
&& lameopts+=(--tv "TXXX=REPLAYGAIN_TRACK_GAIN=$replaygain_trk") && lameopts+=(--tv "TXXX=REPLAYGAIN_TRACK_GAIN=$replaygain_trk")
[ -n "$disc" ] && lameopts+=(--tv TPOS="$disc") [ -n "$disc" ] && lameopts+=(--tv TPOS="$disc")
# Handle noresample: force lame to use a specific sample rate to prevent
# lame's own automatic downsampling at low bitrates
if (( ${destinationnoresample[$destination]:-0} == 1 )) if (( ${destinationnoresample[$destination]:-0} == 1 ))
then then
# If 'rate' is not one of these value, it cannot be encoded to # If 'rate' is not one of these value, it cannot be encoded to
@ -25,6 +45,7 @@ encodeFile::mp3() {
# rate to use. # rate to use.
if [ -n "${destinationfrequency["$destination"]}" ] if [ -n "${destinationfrequency["$destination"]}" ]
then then
# Target frequency was explicitly set; use that
case ${destinationfrequency["$destination"]} in case ${destinationfrequency["$destination"]} in
48000) lameopts+=(--resample 48) ;; 48000) lameopts+=(--resample 48) ;;
44100) lameopts+=(--resample 44.1) ;; 44100) lameopts+=(--resample 44.1) ;;
@ -38,8 +59,10 @@ encodeFile::mp3() {
esac esac
elif (( rate > 48000 )) elif (( rate > 48000 ))
then then
# Source rate exceeds MP3 maximum; cap at 48kHz
lameopts+=(--resample 48) lameopts+=(--resample 48)
else else
# Use the source file's own sample rate
case $rate in case $rate in
48000) lameopts+=(--resample 48) ;; 48000) lameopts+=(--resample 48) ;;
44100) lameopts+=(--resample 44.1) ;; 44100) lameopts+=(--resample 44.1) ;;
@ -53,7 +76,11 @@ encodeFile::mp3() {
esac esac
fi fi
fi fi
# Append input WAV and output MP3 paths to complete the command
lameopts+=("$tempdir/$tmpfile.wav" "${destinationpath[$destination]}/$destdir/$destfile.mp3") lameopts+=("$tempdir/$tmpfile.wav" "${destinationpath[$destination]}/$destdir/$destfile.mp3")
# Insert the encode task into the DB, linked to the decode (or sox) task
# Depend on sox task if it exists, else decode task
encodetaskid=$( encodetaskid=$(
Insert tasks <<-EOInsert Insert tasks <<-EOInsert
key ${fileid}lame$destination key ${fileid}lame$destination
@ -63,6 +90,9 @@ encodeFile::mp3() {
$( $(
for key in ${!lameopts[@]} for key in ${!lameopts[@]}
do do
# Escape special characters that could
# interfere with bash glob/brace
# expansion
cleanedopts="${lameopts[key]//\&/\\\&}" cleanedopts="${lameopts[key]//\&/\\\&}"
cleanedopts="${cleanedopts//\[/\\[}" cleanedopts="${cleanedopts//\[/\\[}"
cleanedopts="${cleanedopts//\]/\\]}" cleanedopts="${cleanedopts//\]/\\]}"
@ -78,6 +108,8 @@ encodeFile::mp3() {
ascii ${destinationascii["$destination"]} ascii ${destinationascii["$destination"]}
EOInsert EOInsert
) )
# Increment parent task's required_by counter so it won't clean up
# until all children finish
parent_required=$( parent_required=$(
Select tasks required_by \ Select tasks required_by \
<<<"id = ${soxtaskid:-$decodetaskid}" <<<"id = ${soxtaskid:-$decodetaskid}"
@ -85,5 +117,5 @@ encodeFile::mp3() {
Update tasks required_by $((++parent_required)) \ Update tasks required_by $((++parent_required)) \
<<<"id = ${soxtaskid:-$decodetaskid}" <<<"id = ${soxtaskid:-$decodetaskid}"
progressSpin progressSpin
soxtaskid='' soxtaskid='' # Clear sox task ID so next destination starts fresh
} }

View File

@ -1,9 +1,27 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
encodeFile::opus() { encodeFile::opus() {
# Build opusenc VBR encode command
opusencopts=(${ionice}opusenc --quiet) opusencopts=(${ionice}opusenc --quiet)
opusencopts+=(--bitrate ${destinationquality[$destination]}) opusencopts+=(--bitrate ${destinationquality[$destination]})
# Add Forward Error Correction if a packet loss percentage was
# configured
[ -n "${destinationloss["$destination"]}" ] \ [ -n "${destinationloss["$destination"]}" ] \
&& opusencopts+=(--expect-loss "${destinationloss["$destination"]}") && opusencopts+=(--expect-loss "${destinationloss["$destination"]}")
# Embed Ogg comment tags
[ -n "$albumartist" ] && opusencopts+=(--comment "ALBUMARTIST=$albumartist") [ -n "$albumartist" ] && opusencopts+=(--comment "ALBUMARTIST=$albumartist")
[ -n "$album" ] && opusencopts+=(--comment "ALBUM=$album") [ -n "$album" ] && opusencopts+=(--comment "ALBUM=$album")
[ -n "$artist" ] && opusencopts+=(--artist "$artist") [ -n "$artist" ] && opusencopts+=(--artist "$artist")
@ -20,6 +38,8 @@ encodeFile::opus() {
&& opusencopts+=(--comment) \ && opusencopts+=(--comment) \
&& opusencopts+=("REPLAYGAIN_TRACK_GAIN=$replaygain_trk") && opusencopts+=("REPLAYGAIN_TRACK_GAIN=$replaygain_trk")
[ -n "$title" ] && opusencopts+=(--title "$title") [ -n "$title" ] && opusencopts+=(--title "$title")
# Split "track/total" format: opusenc uses separate TRACKNUMBER and
# TRACKTOTAL fields
[ -n "$track" ] && opusencopts+=(--comment "TRACKNUMBER=${track%/*}") [ -n "$track" ] && opusencopts+=(--comment "TRACKNUMBER=${track%/*}")
[ -n "${track#*/}" ] && opusencopts+=(--comment "TRACKTOTAL=${track#*/}") [ -n "${track#*/}" ] && opusencopts+=(--comment "TRACKTOTAL=${track#*/}")
[ -n "$year" ] && opusencopts+=(--comment "DATE=$year") [ -n "$year" ] && opusencopts+=(--comment "DATE=$year")
@ -31,6 +51,8 @@ encodeFile::opus() {
fileid $destfileid fileid $destfileid
filename $destdir/$destfile.opus filename $destdir/$destfile.opus
$( $(
# Escape special characters that could
# interfere with bash glob/brace expansion
for key in ${!opusencopts[@]} for key in ${!opusencopts[@]}
do do
cleanedopts="${opusencopts[key]//\&/\\\&}" cleanedopts="${opusencopts[key]//\&/\\\&}"
@ -48,6 +70,8 @@ encodeFile::opus() {
ascii ${destinationascii["$destination"]} ascii ${destinationascii["$destination"]}
EOInsert EOInsert
) )
# Increment parent task's required_by counter so it won't clean up
# until all children finish
parent_required=$( parent_required=$(
Select tasks required_by \ Select tasks required_by \
<<<"id = ${soxtaskid:-$decodetaskid}" <<<"id = ${soxtaskid:-$decodetaskid}"

View File

@ -1,6 +1,23 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
encodeFile::vorbis() { encodeFile::vorbis() {
# Build oggenc quality-based encode command
# (-Q = quiet, -q = quality level)
oggencopts=(${ionice}oggenc -Q -q ${destinationquality[$destination]}) oggencopts=(${ionice}oggenc -Q -q ${destinationquality[$destination]})
# Embed Ogg comment tags using oggenc's -c "FIELD=value" syntax
[ -n "$albumartist" ] && oggencopts+=(-c "ALBUMARTIST=$albumartist") [ -n "$albumartist" ] && oggencopts+=(-c "ALBUMARTIST=$albumartist")
[ -n "$album" ] && oggencopts+=(-l "$album") [ -n "$album" ] && oggencopts+=(-l "$album")
[ -n "$artist" ] && oggencopts+=(-a "$artist") [ -n "$artist" ] && oggencopts+=(-a "$artist")
@ -17,6 +34,7 @@ encodeFile::vorbis() {
[ -n "$title" ] && oggencopts+=(-t "$title") [ -n "$title" ] && oggencopts+=(-t "$title")
[ -n "$track" ] && oggencopts+=(-N "$track") [ -n "$track" ] && oggencopts+=(-N "$track")
[ -n "$year" ] && oggencopts+=(-d "$year") [ -n "$year" ] && oggencopts+=(-d "$year")
# -o output must come before input for oggenc
oggencopts+=(-o "${destinationpath[$destination]}/$destdir/$destfile.ogg" "$tempdir/$tmpfile.wav") oggencopts+=(-o "${destinationpath[$destination]}/$destdir/$destfile.ogg" "$tempdir/$tmpfile.wav")
encodetaskid=$( encodetaskid=$(
Insert tasks <<-EOInsert Insert tasks <<-EOInsert
@ -25,6 +43,8 @@ encodeFile::vorbis() {
fileid $destfileid fileid $destfileid
filename $destdir/$destfile.ogg filename $destdir/$destfile.ogg
$( $(
# Escape special characters that could
# interfere with bash glob/brace expansion
for key in ${!oggencopts[@]} for key in ${!oggencopts[@]}
do do
cleanedopts="${oggencopts[key]//\&/\\\&}" cleanedopts="${oggencopts[key]//\&/\\\&}"
@ -42,6 +62,8 @@ encodeFile::vorbis() {
ascii ${destinationascii["$destination"]} ascii ${destinationascii["$destination"]}
EOInsert EOInsert
) )
# Increment parent task's required_by counter so it won't clean up
# until all children finish
parent_required=$( parent_required=$(
Select tasks required_by \ Select tasks required_by \
<<<"id = ${soxtaskid:-$decodetaskid}" <<<"id = ${soxtaskid:-$decodetaskid}"

View File

@ -1,5 +1,22 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
removeObsoleteFiles() { removeObsoleteFiles() {
# Delete source_files records that were not seen in the latest scan.
# The DB's ON DELETE CASCADE will remove tags
# ON DELETE SET NULL will let us take care of destination_files later.
Delete source_files <<-EOWhere Delete source_files <<-EOWhere
last_seen < $scantime last_seen < $scantime
EOWhere EOWhere

View File

@ -1,4 +1,18 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
sanitizeFile() { sanitizeFile() {
shopt -s extglob shopt -s extglob
string="$1" string="$1"
@ -6,7 +20,7 @@ sanitizeFile() {
string="${string//\// }" string="${string//\// }"
if (( ${destinationfat32compat[$destination]} )) if (( ${destinationfat32compat[$destination]} ))
then then
# Filenames can't contain: # FAT32 forbids these characters in filenames
string=${string//\?/ } string=${string//\?/ }
string=${string//\\/ } string=${string//\\/ }
string=${string//</ } string=${string//</ }

View File

@ -1,5 +1,18 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
setupDestination() { setupDestination() {
cat <<-EODesc cat <<-EODesc

View File

@ -1,5 +1,18 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
setupDestinations() { setupDestinations() {
cat <<-EODesc cat <<-EODesc

View File

@ -1,5 +1,18 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
setupGeneral() { setupGeneral() {
cat <<-EODesc cat <<-EODesc
@ -14,11 +27,14 @@ setupGeneral() {
1 minute load average between <load> and <load>+1 by adjusting 1 minute load average between <load> and <load>+1 by adjusting
concurrency. Initial concurrency will be set to half of that value. concurrency. Initial concurrency will be set to half of that value.
EODesc EODesc
# Check input is an integer
# Reused across multiple prompts in this function
expr='^[0-9]*$' expr='^[0-9]*$'
comeagain() { comeagain() {
read \ read \
-e \ -e \
-p'Target load: (integer) ' \ -p'Target load: (integer) ' \
# Pre-fill with the existing value when re-running setup
${maxload+-i"$maxload"} \ ${maxload+-i"$maxload"} \
value value
if [ -n "$value" ] && [[ $value =~ $expr ]] if [ -n "$value" ] && [[ $value =~ $expr ]]
@ -26,6 +42,7 @@ setupGeneral() {
maxload="$value" maxload="$value"
else else
echo "Invalid max-load value: $value" >&2 echo "Invalid max-load value: $value" >&2
# Recurse until we get a valid value
comeagain comeagain
fi fi
} }
@ -64,6 +81,7 @@ setupGeneral() {
read \ read \
-e \ -e \
-p'Ionice: <1-3> [0-7] ' \ -p'Ionice: <1-3> [0-7] ' \
# Default to class 3 (idle) when no prior value exists
-i"${class:-3} ${niceness}" \ -i"${class:-3} ${niceness}" \
class niceness class niceness
case $class in case $class in
@ -87,6 +105,7 @@ setupGeneral() {
fi fi
;; ;;
2) 2)
# Best-effort class; niceness 0-7 is mandatory
if [ -n "$niceness" ] \ if [ -n "$niceness" ] \
&& (( niceness >= 0 && niceness <= 7 )) && (( niceness >= 0 && niceness <= 7 ))
then then
@ -98,6 +117,7 @@ setupGeneral() {
fi fi
;; ;;
3) 3)
# Idle class; no niceness level is accepted
ionice="ionice -c3 " ionice="ionice -c3 "
;; ;;
*) *)
@ -115,6 +135,7 @@ setupGeneral() {
sqlite) and temporary WAVE files will be created. Note that debug logs sqlite) and temporary WAVE files will be created. Note that debug logs
(if enabled) will go there too. (if enabled) will go there too.
EODesc EODesc
# Tab-completion (-e) works here because readline is active
read \ read \
-e \ -e \
-i"${tempdir:-$HOME/.atom/tmp}" \ -i"${tempdir:-$HOME/.atom/tmp}" \

View File

@ -1,5 +1,18 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
setupRegen() { setupRegen() {
(( regen )) && return 0 (( regen )) && return 0
echo "Parameter $1 for destination $destination changed." echo "Parameter $1 for destination $destination changed."

View File

@ -1,5 +1,18 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
setup() { setup() {
cat <<-EOStartConf cat <<-EOStartConf
You will now be asked (hopefully) simple questions to help you configure AtOM's You will now be asked (hopefully) simple questions to help you configure AtOM's
@ -7,11 +20,17 @@ behavior.
Completion is available for prompts asking for a paths or filenames. Completion is available for prompts asking for a paths or filenames.
EOStartConf EOStartConf
# Collect all configuration sections in order
setupGeneral setupGeneral
setupSource setupSource
setupDestinations setupDestinations
# Clear the regex used by validation loops inside setup sub-functions
unset expr unset expr
# Write the newly gathered config to a temp file so the original is
# preserved until the user confirms
writeConfig >"$cffile".tmp writeConfig >"$cffile".tmp
# Unset all config variables so getConfig can repopulate them cleanly
# from the temp file; avoids stale values leaking into the review
unset \ unset \
sourcepath \ sourcepath \
skippeddirectories \ skippeddirectories \
@ -37,6 +56,8 @@ Completion is available for prompts asking for a paths or filenames.
destinationnoresample \ destinationnoresample \
destinationrenamepath \ destinationrenamepath \
destinationskipmime destinationskipmime
# Re-declare per-destination variables as associative arrays so
# getConfig can populate them with [destinationname]=value entries
declare -A \ declare -A \
destinationchannels \ destinationchannels \
destinationfat32compat \ destinationfat32compat \
@ -54,6 +75,8 @@ Completion is available for prompts asking for a paths or filenames.
destinationnoresample \ destinationnoresample \
destinationrenamepath \ destinationrenamepath \
destinationskipmime destinationskipmime
# Point getConfig at the temp file so the review reflects exactly what
# would be written, not the old on-disk config
oldcffile="$cffile" oldcffile="$cffile"
cffile="$cffile".tmp cffile="$cffile".tmp
getConfig getConfig
@ -61,6 +84,7 @@ Completion is available for prompts asking for a paths or filenames.
echo $'Please review your new configuration:\n' echo $'Please review your new configuration:\n'
printConfig printConfig
}| less -F -e }| less -F -e
# Restore the original config path before deciding whether to commit
cffile="$oldcffile" cffile="$oldcffile"
read -p'Write config file? [y/N] ' do_write read -p'Write config file? [y/N] ' do_write
case $do_write in case $do_write in

View File

@ -1,5 +1,18 @@
#!/bin/bash #!/bin/bash
# Copyright © 2012-2026 ScriptFanix
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# A copy of the GNU General Public License v3 is includded in the LICENSE file
# at the root of the project.
setupSource() { setupSource() {
cat <<-EODesc cat <<-EODesc
@ -18,6 +31,7 @@ setupSource() {
-i"${sourcepath:-/var/lib/mpd/music}" \ -i"${sourcepath:-/var/lib/mpd/music}" \
-p'Music collection (<TAB> for completion): ' \ -p'Music collection (<TAB> for completion): ' \
sourcepath sourcepath
# Reject paths that don't exist; AtOM doesn't create the source
if ! [ -d "$sourcepath" ] if ! [ -d "$sourcepath" ]
then then
echo "$sourcepath does not exist or is not a" \ echo "$sourcepath does not exist or is not a" \
@ -34,12 +48,17 @@ setupSource() {
This prompt will loop until an empty string is encountered. This prompt will loop until an empty string is encountered.
EODesc EODesc
# Change into the source directory so readline tab-completion resolves
# relative paths correctly while the user types skip entries
cd "$sourcepath" cd "$sourcepath"
# Remember how many skip entries already exist so we know when to stop
# re-presenting existing entries vs. treating blanks as "done"
count=${#skippeddirectories[@]} count=${#skippeddirectories[@]}
for (( i=0 ; 1 ; i++ )) for (( i=0 ; 1 ; i++ ))
do do
read \ read \
-e \ -e \
# Pre-fill with the existing entry at this index, if any
${skippeddirectories[i]+-i"${skippeddirectories[i]}"}\ ${skippeddirectories[i]+-i"${skippeddirectories[i]}"}\
-p'Skip: ' \ -p'Skip: ' \
value value
@ -48,11 +67,14 @@ setupSource() {
skippeddirectories[i]="$value" skippeddirectories[i]="$value"
elif (( i < count )) elif (( i < count ))
then then
# Blank input on a previously set entry removes it
unset skippeddirectories[i] unset skippeddirectories[i]
else else
# Blank input beyond the old list signals end of input
break break
fi fi
done done
unset count unset count
# Return to the prior directory; output suppressed to keep the UI clean
cd - >/dev/null cd - >/dev/null
} }

View File

@ -6,7 +6,6 @@ CREATE TABLE IF NOT EXISTS source_files (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
filename TEXT UNIQUE NOT NULL, filename TEXT UNIQUE NOT NULL,
size INTEGER NOT NULL, size INTEGER NOT NULL,
hash TEXT,
mime_type INTEGER, mime_type INTEGER,
last_change FLOAT NOT NULL DEFAULT (strftime('%s','now')), last_change FLOAT NOT NULL DEFAULT (strftime('%s','now')),
last_seen INTEGER NOT NULL DEFAULT (strftime('%s','now')), last_seen INTEGER NOT NULL DEFAULT (strftime('%s','now')),