#!/usr/bin/perl -w =head1 NAME generate - A playlist generator =cut =head1 SYNOPSIS generate [options] Path Options: --music Specify where the root of the music tree. --output The directory to write the playlists to. Control Options: --dump Dump tag data, don't create playlists. --limit N Stop finding music when you've done N files. --only-mp3 Only process MP3 files. --only-ogg Only process OGG Vorbis files. =head1 ABOUT This script recursively descends a directory containing music and readas all the tag information from each .mp3 and .ogg file. The read data is then built up into a collection of playlists: ~/Playlists/all.m3u -> One line for each file. ~/Playlists/Artists/$artist.m3u -> One line for each distinct artist. ~/Playlists/Titles/$title.m3u -> One line for each distinct song title. ~/Playlists/Terms/$term.m3u -> One line for each distinct term. ~/Playlists/Years/199x.m3u -> One line for each distinct year. =cut use strict; use warnings; # # Standard modules # use File::Find; use Getopt::Long; use Pod::Usage; # # Helpers for reading tag data. # use MP3::Tag; use Ogg::Vorbis::Header; # # Configuration variables # my %CONFIG; # # Defaults # $CONFIG{ 'music' } = "/home/music/"; # The music location $CONFIG{ 'output' } = "/home/skx/Playlists/"; # Where to generate the output. # # Parse command line arguments # parseCommandLineArguments(); # # Remove old files. # cleanup(); # # Find all music files, and their data. # my $MUSIC = undef; $CONFIG{ 'verbose' } && print "Reading music meta-data..\n"; find( { wanted => \&addMusic, no_chdir => 1 }, $CONFIG{ 'music' } ); $CONFIG{ 'verbose' } && print "\tDone\n"; # # Only dumping? # if ( $CONFIG{ 'dump' } ) { use Data::Dumper; print Dumper( \$MUSIC ); exit; } # # Output a combined playlist of all music. # $CONFIG{ 'verbose' } && print "Output a global playlist\n"; outputGlobalPlaylist(); $CONFIG{ 'verbose' } && print "\tDone.\n"; # # Output per-$value playlists # $CONFIG{ 'verbose' } && print "Outputting a per-artist playlist\n"; outputArtistPlaylist(); $CONFIG{ 'verbose' } && print "\tDone\n"; $CONFIG{ 'verbose' } && print "Outputting a per-title playlist\n"; outputTitlePlaylist(); $CONFIG{ 'verbose' } && print "\tDone\n"; $CONFIG{ 'verbose' } && print "Outputting a per-album playlist\n"; outputAlbumPlaylist(); $CONFIG{ 'verbose' } && print "\tDone\n"; $CONFIG{ 'verbose' } && print "Outputting a per-year playlist\n"; outputYearPlaylist(); $CONFIG{ 'verbose' } && print "\tDone\n"; # # Output per-term playlists # $CONFIG{ 'verbose' } && print "Outputing a per-term playlist\n"; outputTermLists(); $CONFIG{ 'verbose' } && print "\tDone\n"; =begin doc Parse the command line arguments this script was given. =end doc =cut sub parseCommandLineArguments { my $HELP = 0; # # Parse options. # if ( !GetOptions( # Help options "help", \$HELP, "verbose", \$CONFIG{ 'verbose' }, # paths "music=s", \$CONFIG{ 'music' }, "output=s", \$CONFIG{ 'output' }, # limits "limit=s", \$CONFIG{ 'limit' }, "dump", \$CONFIG{ 'dump' }, # file types to handle "only-mp3", \$CONFIG{ 'mp3' }, "only-ogg", \$CONFIG{ 'ogg' }, ) ) { exit; } pod2usage(1) if $HELP; } =begin doc Cleanup generated files from prior runs =end doc =cut sub cleanup { $CONFIG{ 'verbose' } && print "Cleaning up old playlists\n"; my $count = 0; foreach my $dir (qw! Terms Titles Artists Years !) { foreach my $file ( glob("$CONFIG{'output'}$dir/*.m3u") ) { unlink($file); $count += 1; } } $CONFIG{ 'verbose' } && print "\t$count files removed\n"; } =begin doc Add each of the named files to the playlist specified. We take care to avoid duplicates, and ensure the output playlist is sorted. =end doc =cut sub outputPlaylist { my ( $file, @music ) = (@_); $CONFIG{ 'verbose' } && print "Writing playlist: $file\n"; # # Find only unique values # my %seen; foreach my $m ( sort @music ) { $seen{ $m } = 1; } # # Open the file. # open( ALL, ">", "$file" ) or die "Failed to write to $file - $!"; # # Write out each entry. # my $count = 0; foreach my $k ( sort keys %seen ) { print ALL $k . "\n"; $count += 1; } # # All done # close(ALL); $CONFIG{ 'verbose' } && print "\t$count songs added.\n"; } =begin doc Output a playlist based upon uniq values in the given data field. e.g. outputUniqPlaylist( "/tmp", "title" ); =end doc =cut sub outputUniqPlaylist { my ( $dir, $key ) = (@_); # # Make the directory # if ( !-d $dir ) { system( "mkdir", "-p", $dir ); } # # Store a hash of $value => ( "song", "song" ) from our collection # my %uniq; foreach my $file ( keys %$MUSIC ) { # # Get the keyed data, "album", "artist", "title", etc. # my $data = $MUSIC->{ $file }; next unless ($data); my $val = $data->{ $key }; next unless ($val); $val = lc($val); # # Save this file away for that value # my $cur = $uniq{ $val }; push( @$cur, $file ); $uniq{ $val } = $cur; } # # Now handle each one # foreach my $key ( keys %uniq ) { my $name = $key; my $files = $uniq{ $key }; # # Make the name moderately attractive # $name =~ s/ /_/g; $name =~ s/[^a-zA-Z0-9_]//g; outputPlaylist( "$dir/$name.m3u", @$files ); } } =begin doc Output a single playlist "$out/all.m3u" of each file. =end doc =cut sub outputGlobalPlaylist { outputPlaylist( "$CONFIG{'output'}/all.m3u", keys %$MUSIC ); } =begin doc Output one playlist for each artist =end doc =cut sub outputArtistPlaylist { outputUniqPlaylist( "$CONFIG{'output'}/Artist", "artist" ); } =begin doc output one playlist for each title. =end doc =cut sub outputTitlePlaylist { outputUniqPlaylist( "$CONFIG{'output'}/Titles", "song" ); } =begin doc output one playlist for each album. =end doc =cut sub outputAlbumPlaylist { outputUniqPlaylist( "$CONFIG{'output'}/Albums", "album" ); } =begin doc Output one playlist for each distinct year. =end doc =cut sub outputYearPlaylist { outputUniqPlaylist( "$CONFIG{'output'}/Years", "year" ); } =begin doc Find the title of each album, and split that into terms. Output one playlist for each token from those lists. =end doc =cut sub outputTermLists { if ( !-d "$CONFIG{'output'}/Terms" ) { system( "mkdir", "-p", "$CONFIG{'output'}/Terms" ); } my $tokens; foreach my $file ( keys %$MUSIC ) { # # Get the title # my $data = $MUSIC->{ $file }; my $d = $data->{ 'title' } || undef; next if ( !$d ); # # Split the title # foreach my $term ( split( /[, ]/, $d ) ) { $term = lc($term); $term =~ s/[^a-z]//g; # skip empty terms next if ( !length($term) ); push( @{ $tokens->{ $term } }, $file ); } } # # Now make each term # foreach my $term ( keys %$tokens ) { outputPlaylist( "$CONFIG{'output'}/Terms/$term.m3u", @{ $tokens->{ $term } } ); } } =begin doc For each file we find add the information to the global $MUSIC hash. This hash is keyed upon the filename, and the values are a new hash of the data. =end doc =cut sub addMusic { # # If we're debugging stop at 300 # if ( $CONFIG{ 'limit' } ) { return if ( ( scalar( keys %$MUSIC ) ) > $CONFIG{ 'limit' } ); } # The file. my $file = $File::Find::name; # We don't care about directories return if ( !-f $file ); # nor about none mp3/ogg return unless ( $file =~ /\.(mp3|ogg)$/i ); # # Get the data # if ( $file =~ /\.mp3$/i ) { return if ( $CONFIG{ 'only-ogg' } ); # # Read the data. # my $mp3 = MP3::Tag->new($file); next if ( !$mp3 ); # # Get the data, and store it # my $data = $mp3->autoinfo(); $MUSIC->{ $file } = $data; } elsif ( $file =~ /\.ogg$/i ) { return if ( $CONFIG{ 'only-mp3' } ); my $ogg = Ogg::Vorbis::Header->new($file); my $tags; foreach my $ckey ( $ogg->comment_tags() ) { $tags->{ lc($ckey) } = ( $ogg->comment($ckey) )[0]; } $MUSIC->{ $file } = $tags; } else { print "WEIRDNESS: Failed to find data from file: $file\n"; } }