#!/bin/sh
# Create a Rockbox cuesheet beside each M4B/M4A file operand.
#
# Single, multiple, and multiple glob patterns:
#   ./m4b2cue.sh -- './Book One.m4b'
#   ./m4b2cue.sh -- './Book One.m4b' '../Book Two.m4a'
#   ./m4b2cue.sh -- ./converted/*.m4b ./other/*.m4a
#   find ./books -type f -name '*.[mM]4[aAbB]' \
#       -exec /absolute/path/m4b2cue.sh -- {} +
#
# -m supplies one book's mapping: first source track number, then title.
# Numbers are 1-based indexes into that recording's extracted sections.
# -m requires exactly one audio input, even if a glob supplied the input.
# Mapping filenames need not match the audio name:
#   ./m4b2cue.sh -m './Book.chapters.txt' -- './Book.aac-lc.m4b'
#
# Manual batch: originals and mappings here, converted files in ./m4b-aac-lc.
# Stop at the first error:
#   for file in ./*.m4b; do
#       ./m4b2cue.sh -m "${file%.m4b}.chapters.txt" \
#           -- "./m4b-aac-lc/${file#./}" || break
#   done
#
# Recursive mapping when each input has a matching Book.chapters.txt beside it:
#   find ./books -type f -name '*.[mM]4[aAbB]' -exec sh -c '
#       /absolute/path/m4b2cue.sh -m "${1%.*}.chapters.txt" -- "$1"
#   ' sh {} \;
# Missing/invalid mappings fail; they never fall back to ungrouped chapters.

usage() {
    printf 'Usage: %s [--] file ...\n       %s -m mapping [--] file\n' "$0" "$0" >&2
    exit 2
}
mapping=
while getopts 'm:' option; do
    case $option in
        m) mapping=$OPTARG; [ -n "$mapping" ] || usage ;;
        *) usage ;;
    esac
done
shift "$((OPTIND - 1))"
[ "$#" -gt 0 ] || usage
if [ -n "$mapping" ]; then
    [ "$#" -eq 1 ] || usage
    [ -f "$mapping" ] || {
        printf 'Not a regular mapping file: %s\n' "$mapping" >&2
        exit 1
    }
fi
for tool in ffprobe jq mktemp; do
    command -v "$tool" >/dev/null 2>&1 || {
        printf 'Missing command: %s\n' "$tool" >&2
        exit 1
    }
done

LC_ALL=C
export LC_ALL
trap 'exit 1' HUP INT TERM

convert() (
    case $1 in /*|./*|../*) file=$1 ;; *) file=./$1 ;; esac
    case $file in
        *.[mM]4[aAbB]) ;;
        *) printf 'Expected an M4B/M4A file: %s\n' "$file" >&2; exit 1 ;;
    esac
    [ -f "$file" ] || {
        printf 'Not a regular file: %s\n' "$file" >&2
        exit 1
    }
    directory=${file%/*}/
    cue=${file%.*}.cue
    # Rockbox associates both Book.m4b and Book.m4a with Book.cue.
    set -- "${file%.*}".[mM]4[aAbB]
    if [ "$#" -gt 1 ]; then
        printf 'Conflicting audio filenames: %s\n' "$file" >&2
        exit 1
    fi
    if [ -e "$cue" ] || [ -L "$cue" ]; then
        printf 'Exists, skipping: %s\n' "$cue" >&2
        exit 0
    fi
    tmp=$(mktemp -d "${directory}.m4b2cue.XXXXXX") || exit 1
    staged=$tmp/${cue##*/}
    trap 'rm -f "$tmp/metadata" "$staged"; rmdir "$tmp"' 0
    trap 'exit 1' HUP INT TERM
    # Separate commands let POSIX sh check both exits without pipefail.
    ffprobe -v error -of json \
        -show_entries 'chapter=start_time:chapter_tags:format_tags' \
        "$file" >"$tmp/metadata" &&
    jq -er --arg file "${file##*/}" --arg mapping_path "$mapping" \
        --rawfile mapping "${mapping:-/dev/null}" '
        def tags: .tags // {} | with_entries(.key |= ascii_downcase)
            | with_entries(select(.value != ""));
        def text:
            .[:240] | gsub("[\u0000-\u001f\u007f-\u009f]"; " ")
            | gsub("\""; "\u0027")
            | until(utf8bytelength <= 240; .[:-1]);
        def pad: tostring | if length < 2 then "0" + . else . end;
        def index:
            [(./4500 | floor | pad), (./75 | floor % 60 | pad),
             (. % 75 | pad)] | join(":");

        if ($file | test("[\"\u0000-\u001f\u007f-\u009f\ufffd]"))
            or ($file | utf8bytelength > 247) then
            error("filename cannot be represented safely in a Rockbox cuesheet")
        else . end
        | if $mapping_path != "" then
            (.chapters // []) as $tracks
            | [$mapping | ltrimstr("\uFEFF") | split("\n")[]
                | gsub("^[ \t]+|[ \t\r]+$"; "") | select(length > 0)
                | (capture("^(?<track>[0-9]+)[ \t]+(?<title>.+)$")
                    // error("mapping lines must contain a track number and title"))
                | .track |= tonumber] as $groups
            | if ($groups | length) == 0 or $groups[0].track != 1
                or any($groups[]; .track > ($tracks | length))
                or any(range(1; $groups | length);
                       $groups[.].track <= $groups[. - 1].track) then
                error("mapping must start at track 1 and increase within the source track count")
              else .chapters = [$groups[] |
                $tracks[.track - 1] + {tags: {title: .title}}] end
          else . end
        | (.chapters // []) as $chapters
        # This parser stops at TRACK 99, before reading its TITLE/INDEX.
        | if ($chapters | length) == 0 then error("no chapters")
          elif ($chapters | length) > 98 then
            error("this Rockbox parser supports only 98 complete chapters")
          else . end
        | [$chapters[] | .start_time | tonumber
            | if isfinite and . >= 0 and . < 1800060 then
                . * 75 | floor
              else error("chapter start is outside Rockbox limits") end] as $frames
        | if any(range(1; $frames | length);
                 $frames[.] <= $frames[. - 1]) then
            error("chapter starts must increase at 1/75-second resolution")
          else . end
        | (.format | tags) as $tags
        | "\uFEFFTITLE \"\(($tags.title // $tags.album // $file) | text)\"",
          "PERFORMER \"\(($tags.artist // $tags.album_artist // "") | text)\"",
          "FILE \"\($file)\" MP3",
          ($chapters | to_entries[] | .key as $i |
            "  TRACK \($i + 1 | pad) AUDIO",
            "    TITLE \"\((.value | tags | .title // "Chapter \($i + 1)") | text)\"",
            "    INDEX 01 \($frames[$i] | index)")
    ' "$tmp/metadata" >"$staged" &&
    # -i with EOF declines replacement if a cue appeared during probing.
    # Target the parent directory so a new directory at $cue is an error.
    mv -i "$staged" "$directory" </dev/null && [ ! -f "$staged" ] &&
    printf 'Created: %s\n' "$cue"
)

status=0
for file do
    convert "$file" || {
        printf 'Failed: %s\n' "$file" >&2
        status=1
    }
done
exit "$status"
