diff --git a/get_iplayer b/get_iplayer index e1d5d46..e54ebad 100755 --- a/get_iplayer +++ b/get_iplayer @@ -241,6 +241,14 @@ my $opt_format = { id3v2 => [ 1, "id3tag|id3v2=s", 'External Program', '--id3v2 ', "Location of id3v2 or id3tag binary"], mplayer => [ 1, "mplayer=s", 'External Program', '--mplayer ', "Location of mplayer binary"], + # Tagging + tag_fulltitle => [ 1, "tagfulltitle|tag-fulltitle!", 'Tagging', '--tag-fulltitle', "Use complete title (including series) instead of shorter episode title"], + tag_hdvideo => [ 1, "taghdvideo|tag-hdvideo!", 'Tagging', '--tag-hdvideo', "AtomicParsley supports --hdvideo argument for HD video flag"], + tag_longdesc => [ 1, "taglongdesc|tag-longdesc!", 'Tagging', '--tag-longdesc', "AtomicParsley supports --longdesc argument for long description text"], + tag_longdescription => [ 1, "taglongdescription|tag-longdescription!", 'Tagging', '--tag-longdescription', "AtomicParsley supports --longDescription argument for long description text"], + tag_podcast => [ 1, "tagpodcast|tag-podcast!", 'Tagging', '--tag-podcast', "Tag downloaded radio/tv programmes as iTunes podcasts (requires MP3::Tag module for AAC/MP3 files)"], + tag_utf8 => [ 1, "tagutf8|tag-utf8!", 'Tagging', '--tag-utf8', "AtomicParsley expects UTF-8 input"], + # Deprecated }; @@ -486,7 +494,6 @@ my $binopts; my @search_args = @ARGV; my $memcache = {}; - ########### Main processing ########### # Use --webrequest to specify options in urlencoded format @@ -3112,7 +3119,7 @@ sub usage { # Build the help usage text # Each section - for my $section ( 'Search', 'Display', 'Recording', 'Download', 'Output', 'PVR', 'Config', 'External Program', 'Misc' ) { + for my $section ( 'Search', 'Display', 'Recording', 'Download', 'Output', 'PVR', 'Config', 'External Program', 'Tagging', 'Misc' ) { next if not defined $section_name{$section}; my @lines; my @manlines; @@ -3749,6 +3756,7 @@ sub check { package Programme; +use Encode; use Env qw[@PATH]; use Fcntl; use File::Basename; @@ -4204,10 +4212,6 @@ sub mode_ver_download_retry_loop { } else { # Add to history, tag file, and run post-record command if a stream was written main::logger "\n"; - if ( ! $opt->{nowrite} ) { - $hist->add( $prog ); - $prog->tag_file; - } if ( $opt->{thumb} ) { $prog->create_dir(); $prog->download_thumbnail(); @@ -4216,6 +4220,10 @@ sub mode_ver_download_retry_loop { $prog->create_dir(); $prog->create_metadata_file(); } + if ( ! $opt->{nowrite} ) { + $hist->add( $prog ); + $prog->tag_file; + } if ( $opt->{command} && ! $opt->{nowrite} ) { $prog->run_user_command( $opt->{command} ); } @@ -4244,143 +4252,38 @@ sub report { -# Add id3 tag to MP3/AAC files if required -sub tag_file { +# create metadata for tagging +sub tag_metadata { my $prog = shift; - - # Return if file does not exist - return if ! -f $prog->{filename}; - - if ( $prog->{filename} =~ /\.(aac|mp3)$/i ) { - # Create ID3 tagging options for external tagger program (escape " for shell) - my ( $id3_name, $id3_episode, $id3_desc, $id3_channel ) = ( $prog->{name}, $prog->{episode}, $prog->{desc}, $prog->{channel} ); - s|"|\\"|g for ($id3_name, $id3_episode, $id3_desc, $id3_channel); - # Only tag if the required tool exists - if ( main::exists_in_path('id3v2') ) { - main::logger "INFO: id3 tagging $prog->{ext} file\n"; - my @cmd = ( - $bin->{id3v2}, - '--artist', $id3_channel, - '--album', $id3_name, - '--song', $id3_episode, - '--comment', $id3_desc, - '--year', substr( $prog->{firstbcast}->{$prog->{version}}, 0, 4 ) || ((localtime())[5] + 1900), - $prog->{filename}, - ); - if ( main::run_cmd( 'STDERR', @cmd ) ) { - main::logger "WARNING: Failed to tag $prog->{ext} file\n"; - return 2; - } + my $meta; + while ( my ($key, $val) = each %{$prog} ) { + if ( ref($val) eq 'HASH' ) { + $meta->{$key} = $prog->{$key}->{$prog->{version}}; } else { - main::logger "WARNING: Cannot tag $prog->{ext} file\n" if $opt->{verbose}; - } - - } elsif ( $prog->{filename} =~ /\.(mp4|m4v|m4a)$/i ) { - # Create mp4 tagging options for external tagging program. - my $tags; - for my $tag ( keys %{$prog} ) { - # Used for firstbcast etc which are a version based HASH - if ( ref$prog->{$tag} eq 'HASH' ) { - $tags->{$tag} = $prog->{$tag}->{$prog->{version}}; - } else { - $tags->{$tag} = $prog->{$tag}; - } - $tags->{$tag} =~ s|"|\\"|g; - } - - # Make 'duration' == 'length' for the selected version - $tags->{duration} = $prog->{durations}->{$prog->{version}} if $prog->{durations}->{$prog->{version}}; - - # Only tag if the required tool exists - if ( main::exists_in_path( 'atomicparsley' ) ) { - # Download the thumbnail if it doesn't already exist - $prog->download_thumbnail if ! -f $prog->{thumbfile}; - - # Download Thubnail file as well for inclusion into MP4 stream. - # Apple TV/iTunes will use it. - main::logger "INFO: mp4 tagging $prog->{ext} file\n"; - - # extract year from firstbcast e.g. 2009-10-05T22:35:00+01:00 - #$year =~ s/^.*(20\d\d|19\d\d).*$/$1/g; - # If year isn't set correctly in the information, then assume today. - $tags->{firstbcast} = (localtime())[5] + 1900 if ! $tags->{firstbcast}; - - # Add guidance if set - $tags->{guidance} = 'clean'; - $tags->{guidance} = 'explicit' if $prog->{guidance}; - - # Show type - my $stik = 'TV Show'; - $stik = 'Movie' if $tags->{categories} =~ m{(film|movie)}i; - - # Strip series and episode text from name, longname, episode - for my $tag ( qw/name longname episode/ ) { - $tags->{$tag} =~ s/(:\s*)?(Series|Episode)\s*\d+(:\s*)?//gi; - } - my $title = "$tags->{longname} - $tags->{episode}"; - # strip any trailing '-' and whitespace - $title =~ s/[\s\-]*$//g; - - # Build the command - my @cmd; - if ( $prog->{filename} =~ /\.(mp4|m4v)$/i ) { - @cmd = ( - $bin->{atomicparsley}, $prog->{filename}, - '--TVNetwork', $tags->{channel}, - '--description',$tags->{descshort}, - '--comment', $tags->{descshort}, - '--title', $title, - '--TVShowName', $tags->{longname}, - '--TVEpisode', $tags->{pid}, - '--artist', $tags->{name}, - '--year', $tags->{firstbcast}, - '--advisory', $tags->{guidance}, - '--genre', $tags->{categories}, - '--stik', $stik, - '--overWrite', # Saves temp files being left around. - ); - - # Add the series and episode numbers if they are defined - push @cmd, "--TVSeasonNum", $prog->{seriesnum} if $prog->{seriesnum}; - push @cmd, "--TVEpisodeNum", $prog->{episodenum} if $prog->{episodenum}; - - } elsif ( $prog->{filename} =~ /\.(m4a)$/i ) { - @cmd = ( - $bin->{atomicparsley}, $prog->{filename}, - '--description',$tags->{descshort}, - '--comment', $tags->{descshort}, - '--title', $tags->{title}, - '--TVShowName', $tags->{longname}, - '--TVEpisode', $tags->{pid}, - '--artist', $tags->{channel}, - '--albumArtist', $tags->{channel}, - '--album', $tags->{longname}, - '--year', $tags->{firstbcast}, - '--advisory', $tags->{guidance}, - '--genre', $tags->{categories}, - '--overWrite', # Saves temp files being left around. - ); - - # Add the series and episode numbers if they are defined as disk / track numbers - push @cmd, "--disk", $prog->{seriesnum} if $prog->{seriesnum}; - push @cmd, "--tracknum", $prog->{episodenum} if $prog->{episodenum}; - } - - # Add the thumbnail if one was downloaded - push @cmd, "--artwork", $prog->{thumbfile} if -f $prog->{thumbfile}; - - # time of recording - this messes up iTunes somewhat - #push @cmd, "--purchaseDate", "$prog->{dldate}T$prog->{dltime}Z" if $prog->{dldate} && $prog->{dltime}; - - # After running, clean up thumbnail file unless it is required using the thumbnail option. - if ( main::run_cmd( 'STDERR', @cmd ) ) { - main::logger "WARNING: Failed to tag $prog->{ext} file\n"; - unlink $prog->{thumbfile} if ! $opt->{thumb}; - return 2; - } - unlink $prog->{thumbfile} if ! $opt->{thumb}; + $meta->{$key} = $val; } } + # expect input in UTF-8 + while ( my ($key, $val) = each %{$meta} ) { + $meta->{$key} = decode("utf8", $val); + } + return $meta; +} + +# add metadata tags to file +sub tag_file { + my $prog = shift; + # return if file does not exist + return if ! -f $prog->{filename}; + # download thumbnail if necessary + $prog->download_thumbnail if ( ! -f $prog->{thumbfile} ); + # create metadata + my $meta = $prog->tag_metadata; + # tag file + my $tagger = Tagger->new(); + $tagger->tag_file($meta); + # clean up thumbnail if necessary + unlink $prog->{thumbfile} if ! $opt->{thumb}; } @@ -9416,5 +9319,312 @@ sub enable { } +package Tagger; +use Encode; +use File::stat; + +# already in scope +# my ($opt, $bin); + +# constructor +sub new { + my $class = shift; + my $self = {}; + bless($self, $class); +} + +# map metadata values to tags +sub tags_from_metadata { + my ($self, $meta) = @_; + my $tags; + # iTunes media kind + $tags->{stik} = 'Normal'; + if ( $meta->{ext} =~ /(mp4|m4v)/i) { + $tags->{stik} = $meta->{categories} =~ /(film|movie)/i ? 'Movie' : 'TV Show'; + } + $tags->{advisory} = $meta->{guidance} ? 'explicit' : 'remove'; + # copyright message from download date + $tags->{copyright} = substr($meta->{dldate}, 0, 4)." British Broadcasting Corporation, all rights reserved"; + # select version of of episode title to use + if ( $opt->{tag_fulltitle} ) { + $tags->{title} = $meta->{title}; + } else { + # fix up episode if necessary + (my $title = $meta->{episode}) =~ s/\s*-\s*//g; + $tags->{title} = $title ? $title : $meta->{name}; + } + $tags->{artist} = $meta->{channel}; + # album artist from programme type + ($tags->{albumArtist} = "BBC " . ucfirst($meta->{type})) =~ s/tv/TV/i; + $tags->{album} = $meta->{name}; + $tags->{grouping} = $meta->{categories}; + # composer references iPlayer + $tags->{composer} = "BBC iPlayer"; + # extract genre as first category, use second if first too generic + my @ignore = ("Films", "Sign Zone", "Audio Described", "Northern Ireland", "Scotland", "Wales", "England"); + my ($genre, $genre2) = split(/\s*,\s*/, $meta->{categories}, 3); + if ( $genre && $genre2 && grep(/$genre/i, @ignore) ) { $genre = $genre2; } + # fallback genre + $genre ||= "get_iplayer"; + $tags->{genre} = $genre; + $tags->{comment} = $meta->{descshort}; + # fix up firstbcast if necessary + $tags->{year} = $meta->{firstbcast}; + if ( $tags->{year} !~ /\d{4}-\d{2}-\d{2}\D\d{2}:\d{2}:\d{2}/ ) { + my @utc = gmtime(); + $utc[4] += 1; + $utc[5] += 1900; + $tags->{year} = sprintf("%4d-%02d-%02dT%02d:%02d:%02dZ", reverse @utc[0..5]); + } + # extract date components for ID3v2.3 + my @date = split(//, $tags->{year}); + $tags->{tyer} = join('', @date[0..3]); + $tags->{tdat} = join('', @date[8,9,5,6]); + $tags->{time} = join('', @date[11,12,14,15]); + $tags->{tracknum} = $meta->{episodenum}; + $tags->{disk} = $meta->{seriesnum}; + # generate lyrics text with links if available + $tags->{lyrics} = $meta->{desc}; + $tags->{lyrics} .= "\n\nEPISODE\n $meta->{player}" if $meta->{player}; + $tags->{lyrics} .= "\n\nSERIES\n $meta->{web}" if $meta->{web}; + $tags->{description} = $meta->{descshort}; + $tags->{longDescription} = $meta->{desc}; + $tags->{hdvideo} = $meta->{mode} =~ /hd/i ? 'true' : 'false'; + $tags->{TVShowName} = $tags->{title}; + $tags->{TVEpisode} = $meta->{senum} ? $meta->{senum} : $meta->{pid}; + $tags->{TVSeasonNum} = $tags->{disk}; + $tags->{TVEpisodeNum} = $tags->{tracknum}; + $tags->{TVNetwork} = $meta->{channel}; + $tags->{podcastFlag} = 'true'; + $tags->{category} = $tags->{genre}; + $tags->{keyword} = $meta->{categories}; + $tags->{podcastGUID} = $meta->{player}; + $tags->{artwork} = $meta->{thumbfile}; + # video flag + $tags->{is_video} = $meta->{ext} =~ /(mp4|m4v)/i; + # tvshow flag + $tags->{is_tvshow} = $tags->{stik} eq 'TV Show'; + # podcast flag + $tags->{is_podcast} = $meta->{type} =~ /podcast/i || $opt->{tag_podcast}; + return $tags; +} + +# add metadata tag to file +sub tag_file { + my ($self, $meta) = @_; + my $tags = $self->tags_from_metadata($meta); + # dispatch to appropriate tagging function + if ( $meta->{filename} =~ /\.(aac|mp3)$/i ) { + return $self->tag_file_id3($meta, $tags); + } elsif ( $meta->{filename} =~ /\.(mp4|m4v|m4a)$/i ) { + return $self->tag_file_mp4($meta, $tags); + } else { + main::logger "WARNING: Don't know how to tag \U$meta->{ext}\E file\n" if $opt->{verbose}; + } +} + +# add full ID3 tag with MP3::Tag +sub tag_file_id3 { + my ($self, $meta, $tags) = @_; + # look for required module + eval 'use MP3::Tag'; + if ( $@ ) { + if ( $opt->{verbose} ) { + main::logger "INFO: Install the MP3::Tag module for full taggging of \U$meta->{ext}\E files\n"; + main::logger "INFO: Falling back to ID3 BASIC taggging of \U$meta->{ext}\E files\n"; + } + return $self->tag_file_id3_basic($meta, $tags); + } + eval { + main::logger "INFO: ID3 tagging \U$meta->{ext}\E file\n"; + # translate podcast flag + $tags->{podcastFlag} = "\x01"; + # remove existing tag(s) to avoid decoding errors + my $mp3 = MP3::Tag->new($meta->{filename}); + $mp3->get_tags(); + $mp3->{ID3v1}->remove_tag() if exists $mp3->{ID3v1}; + $mp3->{ID3v2}->remove_tag() if exists $mp3->{ID3v2}; + $mp3->close(); + # add metadata + $mp3 = MP3::Tag->new($meta->{filename}); + $mp3->select_id3v2_frame_by_descr('TCOP', $tags->{copyright}); + $mp3->select_id3v2_frame_by_descr('TIT2', $tags->{title}); + $mp3->select_id3v2_frame_by_descr('TPE1', $tags->{artist}); + $mp3->select_id3v2_frame_by_descr('TPE2', $tags->{albumArtist}); + $mp3->select_id3v2_frame_by_descr('TALB', $tags->{album}); + $mp3->select_id3v2_frame_by_descr('TIT1', $tags->{grouping}); + $mp3->select_id3v2_frame_by_descr('TCOM', $tags->{composer}); + $mp3->select_id3v2_frame_by_descr('TCON', $tags->{genre}); + $mp3->select_id3v2_frame_by_descr('COMM(eng,#0)[]', $tags->{comment}); + $mp3->select_id3v2_frame_by_descr('TYER', $tags->{tyer}); + $mp3->select_id3v2_frame_by_descr('TDAT', $tags->{tdat}); + $mp3->select_id3v2_frame_by_descr('TIME', $tags->{time}); + $mp3->select_id3v2_frame_by_descr('TRCK', $tags->{tracknum}); + $mp3->select_id3v2_frame_by_descr('TPOS', $tags->{disk}); + $mp3->select_id3v2_frame_by_descr('USLT', $tags->{lyrics}); + # tag iTunes podcast + if ( $tags->{is_podcast} ) { + # ID3v2.4 only, but works in iTunes + $mp3->select_id3v2_frame_by_descr('TDRL', $tags->{year}); + # ID3v2.3 and ID3v2.4 + $mp3->select_id3v2_frame_by_descr('TIT3', $tags->{description}); + # Neither ID3v2.3 nor ID3v2.4, but work in iTunes + $mp3->select_id3v2_frame_by_descr('TDES', $tags->{longDescription}); + $mp3->{ID3v2}->add_raw_frame('PCST', $tags->{podcastFlag}); + $mp3->select_id3v2_frame_by_descr('TCAT', $tags->{category}); + $mp3->select_id3v2_frame_by_descr('TKWD', $tags->{keyword}); + $mp3->select_id3v2_frame_by_descr('TGID', $tags->{podcastGUID}); + } + # add artwork if available + if ( -f $meta->{thumbfile} ) { + my $data; + open(THUMB, $meta->{thumbfile}); + binmode(THUMB); + read(THUMB, $data, stat($meta->{thumbfile})->size()); + close(THUMB); + $mp3->select_id3v2_frame_by_descr('APIC', $data); + } + # write metadata to file + $mp3->update_tags(); + $mp3->close(); + }; + if ( $@ ) { + main::logger "ERROR: Failed to tag \U$meta->{ext}\E file\n"; + main::logger "ERROR: $@" if $opt->{verbose}; + # clean up thumbnail if necessary + unlink $meta->{thumbfile} if ! $opt->{thumb}; + return 4; + } +} + +# add basic ID3 tag with id3v2 +sub tag_file_id3_basic { + my ($self, $meta, $tags) = @_; + if ( main::exists_in_path('id3v2') ) { + main::logger "INFO: ID3 BASIC tagging \U$meta->{ext}\E file\n"; + # notify about limitations of basic tagging + if ( $opt->{verbose} ) { + main::logger "INFO: ID3 BASIC tagging cannot add artwork to \U$meta->{ext}\E files\n"; + main::logger "INFO: ID3 BASIC tagging cannot add podcast metadata to \U$meta->{ext}\E files\n" if $tags->{is_podcast}; + } + # colons are parsed as frame field separators by id3v2 + # so replace them to make safe comment text + $tags->{comment} =~ s/:/_/g; + # make safe lyrics text as well + # can't use $tags->{lyrics} because of colons in links + $tags->{longDescription} =~ s/:/_/g; + # encode for id3v2 + while ( my ($key, $val) = each %{$tags} ) { + $tags->{$key} = encode("iso-8859-1", $val); + } + # build id3v2 command + my @cmd = ( + $bin->{id3v2}, + '--TCOP', $tags->{copyright}, + '--TIT2', $tags->{title}, + '--TPE1', $tags->{artist}, + '--TPE2', $tags->{albumArtist}, + '--TALB', $tags->{album}, + '--TIT1', $tags->{grouping}, + '--TCOM', $tags->{composer}, + '--TCON', $tags->{genre}, + '--COMM', $tags->{comment}, + '--TYER', $tags->{tyer}, + '--TDAT', $tags->{tdat}, + '--TIME', $tags->{time}, + '--TRCK', $tags->{tracknum}, + '--TPOS', $tags->{disk}, + '--USLT', $tags->{longDescription}, + $meta->{filename}, + ); + # run id3v2 command + if ( main::run_cmd( 'STDERR', @cmd ) ) { + main::logger "WARNING: Failed to tag \U$meta->{ext}\E file\n"; + return 2; + } + } else { + main::logger "WARNING: Cannot tag \U$meta->{ext}\E file\n" if $opt->{verbose}; + } +} + +# add MP4 tag with atomicparsley +sub tag_file_mp4 { + my ($self, $meta, $tags) = @_; + # Only tag if the required tool exists + if ( main::exists_in_path( 'atomicparsley' ) ) { + main::logger "INFO: MP4 tagging \U$meta->{ext}\E file\n"; + # pretty copyright for MP4 + $tags->{copyright} = "\xA9 $tags->{copyright}" if $tags->{copyright}; + # encode metadata for atomicparsley + my $encoding = $opt->{tag_utf8} ? "utf8" : "iso-8859-1"; + while ( my ($key, $val) = each %$tags ) { + $tags->{$key} = encode($encoding, $val); + } + # build atomicparsley command + my @cmd = ( + $bin->{atomicparsley}, + $meta->{filename}, + '--freefree', + '--overWrite', + '--stik', $tags->{stik}, + '--advisory', $tags->{advisory}, + '--copyright', $tags->{copyright}, + '--title', $tags->{title}, + '--artist', $tags->{artist}, + '--albumArtist', $tags->{albumArtist}, + '--album', $tags->{album}, + '--grouping', $tags->{grouping}, + '--composer', $tags->{composer}, + '--genre', $tags->{genre}, + '--comment', $tags->{comment}, + '--year', $tags->{year}, + '--tracknum', $tags->{tracknum}, + '--disk', $tags->{disk}, + '--lyrics', $tags->{lyrics}, + ); + # add descriptions to audio podcasts and video + if ( $tags->{is_video} || $tags->{is_podcast}) { + push @cmd, ('--description', $tags->{description} ); + if ( $opt->{tag_longdescription} ) { + push @cmd, ( '--longDescription', $tags->{longDescription} ); + } elsif ( $opt->{tag_longdesc} ) { + push @cmd, ( '--longdesc', $tags->{longDescription} ); + } + } + # video only + if ( $tags->{is_video} ) { + # all video + push @cmd, ( '--hdvideo', $tags->{hdvideo} ) if $opt->{tag_hdvideo}; + # tv only + if ( $tags->{is_tvshow} ) { + push @cmd, ( + '--TVShowName', $tags->{TVShowName}, + '--TVEpisode', $tags->{TVEpisode}, + '--TVSeasonNum', $tags->{TVSeasonNum}, + '--TVEpisodeNum', $tags->{TVEpisodeNum}, + '--TVNetwork', $tags->{TVNetwork}, + ); + } + } + # tag iTunes podcast + if ( $tags->{is_podcast} ) { + push @cmd, ( + '--podcastFlag', $tags->{podcastFlag}, + '--category', $tags->{category}, + '--keyword', $tags->{keyword}, + '--podcastGUID', $tags->{podcastGUID}, + ); + } + # add artwork if available + push @cmd, ( '--artwork', $meta->{thumbfile} ) if -f $meta->{thumbfile}; + # run atomicparsley command + if ( main::run_cmd( 'STDERR', @cmd ) ) { + main::logger "WARNING: Failed to tag \U$meta->{ext}\E file\n"; + return 2; + } + } else { + main::logger "WARNING: Cannot tag \U$meta->{ext}\E file\n" if $opt->{verbose}; + } +} ############## End OO ############## diff --git a/get_iplayer.1 b/get_iplayer.1 index 3917f36..ca550fd 100644 --- a/get_iplayer.1 +++ b/get_iplayer.1 @@ -482,6 +482,25 @@ Location of mplayer binary .TP \fB\-\-vlc Location of vlc or cvlc binary +.SS "Tagging Options:" +.TP +\fB\-\-tag\-fulltitle +Use complete title (including series) instead of shorter episode title +.TP +\fB\-\-tag\-hdvideo +AtomicParsley supports \-\-hdvideo argument for HD video flag +.TP +\fB\-\-tag\-longdesc +AtomicParsley supports \-\-longdesc argument for long description text +.TP +\fB\-\-tag\-longdescription +AtomicParsley supports \-\-longDescription argument for long description text +.TP +\fB\-\-tag\-podcast +Tag downloaded radio/tv programmes as iTunes podcasts (requires MP3::Tag module for AAC/MP3 files) +.TP +\fB\-\-tag\-utf8 +AtomicParsley expects UTF\-8 input .SH AUTHOR get_iplayer is written and maintained by Phil Lewis . .PP