#------------------------------------------------------------------------------
# File:         Audible.pm
#
# Description:  Read metadata from Audible audio books
#
# Revisions:    2015/04/05 - P. Harvey Created
#
# References:   1) https://github.com/jteeuwen/audible
#               2) https://code.google.com/p/pyaudibletags/
#               3) http://wiki.multimedia.cx/index.php?title=Audible_Audio
#------------------------------------------------------------------------------

package Image::ExifTool::Audible;

use strict;
use vars qw($VERSION);
use Image::ExifTool qw(:DataAccess :Utils);

$VERSION = '1.02';

sub ProcessAudible_meta($$$);
sub ProcessAudible_cvrx($$$);

%Image::ExifTool::Audible::Main = (
    GROUPS => { 2 => 'Audio' },
    NOTES => q{
        ExifTool will extract any information found in the metadata dictionary of
        Audible .AA files, even if not listed in the table below.
    },
    # tags found in the metadata dictionary (chunk 2)
    pubdate    => { Name => 'PublishDate', Groups => { 2 => 'Time' } },
    pub_date_start => { Name => 'PublishDateStart', Groups => { 2 => 'Time' } },
    author     => { Name => 'Author',      Groups => { 2 => 'Author' } },
    copyright  => { Name => 'Copyright',   Groups => { 2 => 'Author' } },
    # also seen (ref PH):
    # product_id, parent_id, title, provider, narrator, price, description,
    # long_description, short_title, is_aggregation, title_id, codec, HeaderSeed,
    # EncryptedBlocks, HeaderKey, license_list, CPUType, license_count, <12 hex digits>,
    # parent_short_title, parent_title, aggregation_id, short_description, user_alias

    # information extracted from other chunks
    _chapter_count => { Name => 'ChapterCount' },       # from chunk 6
    _cover_art => { # from chunk 11
        Name => 'CoverArt',
        Groups => { 2 => 'Preview' },
        Binary => 1,
    },
);

# 'tags' atoms observed in Audible .m4b audio books (ref PH)
%Image::ExifTool::Audible::tags = (
    GROUPS => { 0 => 'QuickTime', 2 => 'Audio' },
    NOTES => 'Information found in "tags" atom of Audible M4B audio books.',
    meta => {
        Name => 'Audible_meta',
        SubDirectory => { TagTable => 'Image::ExifTool::Audible::meta' },
    },
    cvrx => {
        Name => 'Audible_cvrx',
        SubDirectory => { TagTable => 'Image::ExifTool::Audible::cvrx' },
    },
    tseg => {
        Name => 'Audible_tseg',
        SubDirectory => { TagTable => 'Image::ExifTool::Audible::tseg' },
    },
);

# 'meta' information observed in Audible .m4b audio books (ref PH)
%Image::ExifTool::Audible::meta = (
    PROCESS_PROC => \&ProcessAudible_meta,
    GROUPS => { 0 => 'QuickTime', 2 => 'Audio' },
    NOTES => 'Information found in Audible M4B "meta" atom.',
    Album       => 'Album',
    ALBUMARTIST => { Name => 'AlbumArtist', Groups => { 2 => 'Author' } },
    Artist      => { Name => 'Artist',      Groups => { 2 => 'Author' } },
    Comment     => 'Comment',
    Genre       => 'Genre',
    itunesmediatype => { Name => 'iTunesMediaType', Description => 'iTunes Media Type' },
    SUBTITLE    => 'Subtitle',
    Title       => 'Title',
    TOOL        => 'CreatorTool',
    Year        => { Name => 'Year', Groups => { 2 => 'Time' } },
    track       => 'ChapterName', # (found in 'meta' of 'tseg' atom)
);

# 'cvrx' information observed in Audible .m4b audio books (ref PH)
%Image::ExifTool::Audible::cvrx = (
    PROCESS_PROC => \&ProcessAudible_cvrx,
    GROUPS => { 0 => 'QuickTime', 2 => 'Audio' },
    NOTES => 'Audible cover art information in M4B audio books.',
    VARS => { ID_FMT => 'none' },
    CoverArtType => 'CoverArtType',
    CoverArt     => {
        Name => 'CoverArt',
        Groups => { 2 => 'Preview' },
        Binary => 1,
    },
);

# 'tseg' information observed in Audible .m4b audio books (ref PH)
%Image::ExifTool::Audible::tseg = (
    GROUPS => { 0 => 'QuickTime', 2 => 'Audio' },
    tshd => {
        Name => 'ChapterNumber',
        Format => 'int32u',
        ValueConv => '$val + 1',    # start counting from 1
    },
    meta => {
        Name => 'Audible_meta2',
        SubDirectory => { TagTable => 'Image::ExifTool::Audible::meta' },
    },
);

#------------------------------------------------------------------------------
# Process Audible 'meta' tags from M4B files (ref PH)
# Inputs: 0) ExifTool object ref, 1) dirInfo ref, 2) tag table ref
# Returns: 1 on success
sub ProcessAudible_meta($$$)
{
    my ($et, $dirInfo, $tagTablePtr) = @_;
    my $dataPt = $$dirInfo{DataPt};
    my $dataPos = $$dirInfo{DataPos};
    my $dirLen = length $$dataPt;
    return 0 if $dirLen < 4;
    my $num = Get32u($dataPt, 0);
    $et->VerboseDir('Audible_meta', $num);
    my $pos = 4;
    my $index;
    for ($index=0; $index<$num; ++$index) {
        last if $pos + 3 > $dirLen;
        my $unk = Get8u($dataPt, $pos);             # ? (0x80 or 0x00)
        last unless $unk == 0x80 or $unk == 0x00;
        my $len = Get16u($dataPt, $pos + 1);        # tag length
        $pos += 3;
        last if $pos + $len + 6 > $dirLen or not $len;
        my $tag = substr($$dataPt, $pos, $len);     # tag ID
        my $ver = Get16u($dataPt, $pos + $len);     # version?
        last unless $ver == 0x0001;
        my $size = Get32u($dataPt, $pos + $len + 2);# data size
        $pos += $len + 6;
        last if $pos + $size > $dirLen;
        my $val = $et->Decode(substr($$dataPt, $pos, $size), 'UTF8');
        unless ($$tagTablePtr{$tag}) {
            my $name = Image::ExifTool::MakeTagName(($tag =~ /[a-z]/) ? $tag : lc($tag));
            AddTagToTable($tagTablePtr, $tag, { Name => $name });
        }
        $et->HandleTag($tagTablePtr, $tag, $val,
            DataPt  => $dataPt,
            DataPos => $dataPos,
            Start   => $pos,
            Size    => $size,
            Index   => $index,
        );
        $pos += $size;
    }
    return 1;
}

#------------------------------------------------------------------------------
# Process Audible 'cvrx' cover art atom from M4B files (ref PH)
# Inputs: 0) ExifTool object ref, 1) dirInfo ref, 2) tag table ref
# Returns: 1 on success
sub ProcessAudible_cvrx($$$)
{
    my ($et, $dirInfo, $tagTablePtr) = @_;
    my $dataPt = $$dirInfo{DataPt};
    my $dataPos = $$dirInfo{DataPos};
    my $dirLen = length $$dataPt;
    return 0 if 0x0a > $dirLen;
    my $len = Get16u($dataPt, 0x08);
    return 0 if 0x0a + $len + 6 > $dirLen;
    my $size = Get32u($dataPt, 0x0a + $len + 2);
    return 0 if 0x0a + $len + 6 + $size > $dirLen;
    $et->VerboseDir('Audible_cvrx', undef, $dirLen);
    $et->HandleTag($tagTablePtr, 'CoverArtType', undef,
        DataPt  => $dataPt,
        DataPos => $dataPos,
        Start   => 0x0a,
        Size    => $len,
    );
    $et->HandleTag($tagTablePtr, 'CoverArt', undef,
        DataPt  => $dataPt,
        DataPos => $dataPos,
        Start   => 0x0a + $len + 6,
        Size    => $size,
    );
    return 1;
}

#------------------------------------------------------------------------------
# Read information from an Audible .AA file
# Inputs: 0) ExifTool ref, 1) dirInfo ref
# Returns: 1 on success, 0 if this wasn't a valid AA file
sub ProcessAA($$)
{
    my ($et, $dirInfo) = @_;
    my $raf = $$dirInfo{RAF};
    my ($buff, $toc, $entry, $i);

    # check magic number
    return 0 unless $raf->Read($buff, 16) == 16 and $buff=~/^.{4}\x57\x90\x75\x36/s;
    # check file size
    if (defined $$et{VALUE}{FileSize}) {
        # first 4 bytes of the file should be the filesize
        unpack('N', $buff) == $$et{VALUE}{FileSize} or return 0;
    }
    $et->SetFileType();
    SetByteOrder('MM');
    my $bytes = 12 * Get32u(\$buff, 8); # table of contents size in bytes
    $bytes > 0xc00 and $et->Warn('Invalid TOC'), return 1;
    # read the table of contents
    $raf->Read($toc, $bytes) == $bytes or $et->Warn('Truncated TOC'), return 1;
    my $tagTablePtr = GetTagTable('Image::ExifTool::Audible::Main');
    # parse table of contents (in $toc)
    for ($entry=0; $entry<$bytes; $entry+=12) {
        my $type = Get32u(\$toc, $entry);
        next unless $type == 2 or $type == 6 or $type == 11;
        my $offset = Get32u(\$toc, $entry + 4);
        my $length = Get32u(\$toc, $entry + 8) or next;
        $raf->Seek($offset, 0) or $et->Warn("Chunk $type seek error"), last;
        if ($type == 6) {   # offset table
            next if $length < 4 or $raf->Read($buff, 4) != 4; # only read the chapter count
            $et->HandleTag($tagTablePtr, '_chapter_count', Get32u(\$buff, 0));
            next;
        }
        # read the chunk
        $length > 100000000 and $et->Warn("Chunk $type too big"), next;
        $raf->Read($buff, $length) == $length or $et->Warn("Chunk $type read error"), last;
        if ($type == 11) {  # cover art
            next if $length < 8;
            my $len = Get32u(\$buff, 0);
            my $off = Get32u(\$buff, 4);
            next if $off < $offset + 8 or $off - $offset + $len > $length;
            $et->HandleTag($tagTablePtr, '_cover_art', substr($buff, $off-$offset, $len));
            next;
        }
        # parse metadata dictionary (in $buff)
        $length < 4 and $et->Warn('Bad dictionary'), next;
        my $num = Get32u(\$buff, 0);
        $num > 0x200 and $et->Warn('Bad dictionary count'), next;
        my $pos = 4;    # dictionary starts immediately after count
        require Image::ExifTool::HTML;  # (for UnescapeHTML)
        $et->VerboseDir('Audible Metadata', $num);
        for ($i=0; $i<$num; ++$i) {
            my $tagPos = $pos + 9;                  # position of tag string
            $tagPos > $length and $et->Warn('Truncated dictionary'), last;
            # (1 unknown byte ignored at start of each dictionary entry)
            my $tagLen = Get32u(\$buff, $pos + 1);  # tag string length
            my $valLen = Get32u(\$buff, $pos + 5);  # value string length
            my $valPos = $tagPos + $tagLen;         # position of value string
            my $nxtPos = $valPos + $valLen;         # position of next entry
            $nxtPos > $length and $et->Warn('Bad dictionary entry'), last;
            my $tag = substr($buff, $tagPos, $tagLen);
            my $val = substr($buff, $valPos, $valLen);
            unless ($$tagTablePtr{$tag}) {
                my $name = Image::ExifTool::MakeTagName($tag);
                $name =~ s/_(.)/\U$1/g; # change from underscore-separated to mixed case
                AddTagToTable($tagTablePtr, $tag, { Name => $name });
            }
            # unescape HTML character references and convert from UTF-8
            $val = $et->Decode(Image::ExifTool::HTML::UnescapeHTML($val), 'UTF8');
            $et->HandleTag($tagTablePtr, $tag, $val,
                DataPos => $offset,
                DataPt  => \$buff,
                Start   => $valPos,
                Size    => $valLen,
                Index   => $i,
            );
            $pos = $nxtPos; # step to next dictionary entry
        }
    }
    return 1;
}

1;  # end

__END__

=head1 NAME

Image::ExifTool::Audible - Read meta information from Audible audio books

=head1 SYNOPSIS

This module is used by Image::ExifTool

=head1 DESCRIPTION

This module contains definitions required by Image::ExifTool to read meta
information from Audible audio books.

=head1 AUTHOR

Copyright 2003-2025, Phil Harvey (philharvey66 at gmail.com)

This library is free software; you can redistribute it and/or modify it
under the same terms as Perl itself.

=head1 REFERENCES

=over 4

=item L<https://github.com/jteeuwen/audible>

=item L<https://code.google.com/p/pyaudibletags/>

=item L<http://wiki.multimedia.cx/index.php?title=Audible_Audio>

=back

=head1 SEE ALSO

L<Image::ExifTool::TagNames/Audible Tags>,
L<Image::ExifTool(3pm)|Image::ExifTool>

=cut

