1#!/usr/local/bin/perl -w
2
3# Podcastamatic v1.3
4# Kenward Bradley
5# http://bradley.chicago.il.us/projects/podcastamatic/
6# 2 December 2005
7
8# This program looks at MP3 tags for a Podcast, and generates a webpage and RSS feed (XML file).
9# You must have the module MP3::Info installed. Get MP3::Info at http://www.cpan.org/modules/by-module/MP3/
10
11use strict;
12use MP3::Info;
13
14#use MP4::Info;
15
16
17my $MP4INFO = eval{require MP4::Info};
18
19if ($MP4INFO) {
20	use MP4::Info;
21	print "We have MP4::Info\n";
22	}
23	else {
24	print "We DONT have MP4::Info\n";
25	}
26
27sub MakeXML;
28sub MakeHTML;
29sub MakeFromTemplate;
30sub BuildEntry;
31sub DoTemplateReplacements;
32sub logprint;
33sub logdie;
34sub ecd;
35sub iCat;
36
37# don't modify variables here, edit the config file instead
38my $DefaultConfigfilename = "/usr/local/etc/podcastamatic.conf";
39my $NowTime = TimeString(time);
40my $Version = "Podcastamatic v1.2";
41my @AudioFiles;
42my @FileStats;
43my %Config;
44
45$Config{LogFile} = ""; # preinit this var to avoid warnings
46$Config{EscapeCharacterData} = 1; # set default to 1 ( = True) to enable the escape of character data
47$Config{iTunesSupport} = 0; # by default iTunes specific tags support if off -- it is an experimental feature
48
49@ARGV = ($DefaultConfigfilename) unless @ARGV;
50
51print "$Version\n\n";
52
53readconfig($ARGV[0]);
54
55if ($Config{LogFile} ne "") { # if we are using a logfile, open it.
56	open (LOGFILE, '>', $Config{LogFile}) or logdie "Can't open \"$Config{LogFile}\" for Log output.\n";
57	print LOGFILE "$Version\n$NowTime\n\n";
58	}
59
60my $IndexOfAudioFiles = -1;
61
62logprint "Looking for audio files...\n";
63
64# look for audio files and populate the hash with file and tag data
65foreach (glob("$Config{AudioPathServerSide}")) {
66
67	if ((/.mp3$/i) || (/.mp4$/i) || (/.m4a$/i) || (/.m4p$/i)) {
68	my $info;
69	my $tag;
70
71		$IndexOfAudioFiles++;
72
73		@FileStats = stat($_);
74		$AudioFiles[$IndexOfAudioFiles]{Filename}     = $_;
75		$AudioFiles[$IndexOfAudioFiles]{Bytes}        = $FileStats[7];
76		$AudioFiles[$IndexOfAudioFiles]{Epoc}         = $FileStats[9];   # used for sorting
77		$AudioFiles[$IndexOfAudioFiles]{FileModified} = TimeString($FileStats[9]);
78
79		logprint "   Found $AudioFiles[$IndexOfAudioFiles]{Filename}\n";
80		$AudioFiles[$IndexOfAudioFiles]{Filename} = $_;
81
82			if (/.mp3$/i) {
83				$info = get_mp3info($_);  # MP3::Info module required
84				$tag  = get_mp3tag($_);   # MP3::Info module required
85				$AudioFiles[$IndexOfAudioFiles]{FileType} = "MP3";
86				}
87
88			if ($MP4INFO && ((/.mp4$/i) || (/.m4a$/i) || (/.m4p$/i))) {
89				$info = get_mp4info($_);  # MP4::Info module required
90				$tag  = get_mp4tag($_);   # MP4::Info module required
91				$AudioFiles[$IndexOfAudioFiles]{FileType} = "MP4";
92				}
93
94			# retrieve values from audio files
95			$AudioFiles[$IndexOfAudioFiles]{Time}     = sprintf("%02d:%02d", $info->{MM}, $info->{SS});
96			$AudioFiles[$IndexOfAudioFiles]{Comment}  = $tag->{COMMENT};
97			$AudioFiles[$IndexOfAudioFiles]{Title}    = $tag->{TITLE};
98			$AudioFiles[$IndexOfAudioFiles]{Artist}   = $tag->{ARTIST};
99			$AudioFiles[$IndexOfAudioFiles]{Album}    = $tag->{ALBUM};
100			$AudioFiles[$IndexOfAudioFiles]{Year}     = $tag->{YEAR};
101			$AudioFiles[$IndexOfAudioFiles]{Genre}    = $tag->{GENRE};
102
103			# If we are configured to Escape Character Data, then fix relevant data
104			if ($Config{EscapeCharacterData} == 1) {
105				$AudioFiles[$IndexOfAudioFiles]{Comment}  = &ecd($tag->{COMMENT});
106				$AudioFiles[$IndexOfAudioFiles]{Title}    = &ecd($tag->{TITLE});
107				$AudioFiles[$IndexOfAudioFiles]{Artist}   = &ecd($tag->{ARTIST});
108				$AudioFiles[$IndexOfAudioFiles]{Album}    = &ecd($tag->{ALBUM});
109				$AudioFiles[$IndexOfAudioFiles]{Genre}    = &ecd($tag->{GENRE});
110			}
111			# if the title is empty then set it to the filename and pretty it up
112			if (not $AudioFiles[$IndexOfAudioFiles]{Title}) {
113				my $AudioFileNoPathNoExt = $AudioFiles[$IndexOfAudioFiles]{Filename};
114				#this ugly regex removes the path and extension
115				$AudioFileNoPathNoExt =~ /^.*\/(.+)\..*$/;
116				# escape char data and make it titlecase (usfirst)
117				$AudioFiles[$IndexOfAudioFiles]{Title} = &ecd(ucfirst($1));
118				}
119	}
120}
121
122if ($IndexOfAudioFiles == -1) {logdie "No audio files found at $Config{AudioPathServerSide} ! Please check your config file.\n";}
123
124#print how many audio files were found
125logprint "   ";
126logprint $IndexOfAudioFiles + 1 . " audio files were found.\n";
127
128# Sort Order
129# 1 = newest first
130# 2 = oldest first
131# 3 = alpha order
132# 4 = reverse alpha order
133if (not defined($Config{SortOrder})) {$Config{SortOrder} = 1;}
134# 1 = newest first
135if ($Config{SortOrder}==1) {@AudioFiles = sort { $b->{Epoc} <=> $a->{Epoc}  }  @AudioFiles;}
136# 2 = oldest first
137if ($Config{SortOrder}==2) {@AudioFiles = sort { $a->{Epoc} <=> $b->{Epoc}  }  @AudioFiles;}
138# 3 = alpha order
139if ($Config{SortOrder}==3) {@AudioFiles = sort {uc($a->{Title}) cmp uc($b->{Title})} @AudioFiles;}
140# 4 = reverse alpha order
141if ($Config{SortOrder}==4) {@AudioFiles = sort {uc($b->{Title}) cmp uc($a->{Title})} @AudioFiles;}
142
143
144# If we are configured to Escape Character Data, then fix relevant data
145if ($Config{EscapeCharacterData} == 1) {
146	$Config{Title}        = &ecd($Config{Title});
147	$Config{Description}  = &ecd($Config{Description});
148	$Config{Copyright}    = &ecd($Config{Copyright});
149}
150
151# Make the XML file
152if ($Config{MakeXMLFile} == 1)  {MakeXML;}
153	else {logprint "Note: No XML file to be generated per config file.\n";}
154
155# Make the HTML file
156if (($Config{MakeHTMLFile} == 1) and ($Config{UseTemplateForHTML} != 1)) {MakeHTML;}
157	else {logprint "Note: No automatic HTML file to be generated per config file.\n";}
158# Make the HTML file from template
159
160if ($Config{UseTemplateForHTML} == 1) {MakeFromTemplate;}
161	else {logprint "Note: No template driven HTML file to be generated per config file.\n";}
162
163close LOGFILE;
164
165
166# ---------- Subroutines ----------
167
168sub logprint {
169	print $_[0];
170	if ($Config{LogFile} ne "") {print LOGFILE $_[0];}
171}
172
173sub logdie {
174	if ($Config{LogFile} ne "") {print LOGFILE "DIE $_[0]";}
175	die "$_[0]";
176}
177
178sub MakeHTML {
179	logprint "Building automatic HTML file \"$Config{HTMLServerSide}\"\n";
180
181	open (HTMLFILE, '>', $Config{HTMLServerSide}) or logdie "Can't open \"$Config{HTMLServerSide}\" for HTML output.\n";
182	print HTMLFILE "<!DOCTYPE html PUBLIC \"-//W3C//DTD HTML 4.01//EN\">";
183	print HTMLFILE "<HTML>\n<HEAD>\n";
184	print HTMLFILE "<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html\">\n";
185	print HTMLFILE "<META NAME=\"generator\" CONTENT=\"$Version http://bradley.chicago.il.us/projects/podcastamatic/\">\n";
186	print HTMLFILE "<LINK rel=\"stylesheet\" HREF=\"$Config{StylesheetWebSide}\" TYPE=\"text/css\">\n";
187	print HTMLFILE "<TITLE>$Config{Title}</TITLE>\n";
188	print HTMLFILE "</HEAD>\n<BODY>\n";
189	print HTMLFILE DoTemplateReplacements($Config{HeaderHTML});
190	print HTMLFILE "\n";
191	for my $ca (0..$IndexOfAudioFiles) {
192		print HTMLFILE BuildEntry($ca);
193		print HTMLFILE "\n";
194	}
195
196	print HTMLFILE "<P class=\"copyright\">$Config{Copyright}</P>\n";
197	# please do not modify the Podcastamatic tagline below
198	print HTMLFILE "<P class=\"generatedby\">HTML and XML generated by <A href=\"http://bradley.chicago.il.us/projects/podcastamatic/\">Podcastamatic</A></P>\n";
199	print HTMLFILE "<P class=\"generatedtime\">Page built: $NowTime</P>\n";
200	# please do not modify the Podcastamatic tagline above
201
202	print HTMLFILE DoTemplateReplacements($Config{FooterHTML});
203
204	print HTMLFILE "</BODY>\n</HTML>\n";
205
206	close HTMLFILE;
207
208	logprint "   Automatic HTML file has been created.\n";
209}
210
211sub MakeXML {
212
213	logprint "Building XML file \"$Config{XMLServerSide}\"\n";
214
215	open (XMLFILE, '>', $Config{XMLServerSide}) or logdie "Can't open \"$Config{XMLServerSide}\" for XML output.\n";
216
217	print XMLFILE "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
218
219	if ($Config{iTunesSupport} == 1) {
220		print XMLFILE "<rss xmlns:itunes=\"http://www.itunes.com/DTDs/Podcast-1.0.dtd\" version=\"2.0\">\n";
221		}
222	else {
223		print XMLFILE "<rss version=\"2.0\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n";
224		}
225
226	print XMLFILE "<channel>\n";
227	print XMLFILE "     <title>$Config{Title}</title>\n";
228
229	if ($Config{iTunesSupport} == 1) {
230		print XMLFILE "<itunes:author>$Config{iTunesAuthor}</itunes:author>\n";
231		print XMLFILE "<itunes:link rel=\"image\" type=\"video/jpeg\" ";
232		print XMLFILE "href=\"$Config{iTunesLinkJpeg}\">$Config{iTunesLinkText}</itunes:link>\n";
233
234		if ($Config{iTunesExplicit} !~ m/^(yes|no|undefined)$/) {logdie("iTunesExplicit is not properly defined. If you are using iTunes support then you need to configure iTunesExplicit. Please see the documentation.\n");}	# error
235		if ($Config{iTunesExplicit} eq "yes") {print XMLFILE "<itunes:explicit>yes</itunes:explicit>\n";}
236		if ($Config{iTunesExplicit} eq "no") {print XMLFILE "<itunes:explicit>no</itunes:explicit>\n";}
237
238		print XMLFILE $Config{iTunesCategory};
239		}
240
241	print XMLFILE "     <link>$Config{Link}</link>\n";
242	print XMLFILE "     <description>$Config{Description}</description>\n";
243	print XMLFILE "     <language>$Config{Language}</language>\n";
244	print XMLFILE "     <copyright>$Config{Copyright}</copyright>\n";
245	print XMLFILE "     <lastBuildDate>$NowTime</lastBuildDate>\n";
246	print XMLFILE "     <ttl>60</ttl>\n";
247
248	for my $c (0..$IndexOfAudioFiles) {
249		my $AudioFileNoPath = $AudioFiles[$c]{Filename};
250		$AudioFileNoPath =~ /^.*\/(.+$)/;
251		my $AudioFileWebSide = $Config{AudioPathWebSide} . &urlencode($1);
252
253		logprint "   Adding \"$AudioFiles[$c]{Title}\"\n";
254
255		print XMLFILE "     <item>\n";
256		print XMLFILE "          <title>$AudioFiles[$c]{Title}</title>\n";
257		print XMLFILE "          <link>$AudioFileWebSide</link>\n";
258
259		if ($Config{iTunesSupport} == 1) {
260			print XMLFILE "<itunes:author>$Config{iTunesAuthor}</itunes:author>\n";
261			print XMLFILE $Config{iTunesCategory};
262			print XMLFILE "<itunes:duration>$AudioFiles[$c]{Time}</itunes:duration>\n";
263			if ($Config{iTunesExplicit} eq "yes") {print XMLFILE "<itunes:explicit>yes</itunes:explicit>\n";}
264			if ($Config{iTunesExplicit} eq "no") {print XMLFILE "<itunes:explicit>no</itunes:explicit>\n";}
265
266			}
267
268# XML description here
269		print XMLFILE "          <description>$AudioFiles[$c]{Comment}";
270		print XMLFILE " (Running Time $AudioFiles[$c]{Time})</description>\n";
271
272		print XMLFILE "          <pubDate>$AudioFiles[$c]{FileModified}</pubDate>\n";
273		print XMLFILE "          <enclosure url=\"$AudioFileWebSide\" length=\"$AudioFiles[$c]{Bytes}\" ";
274
275		# assign file type
276		if ($AudioFiles[$c]{FileType} eq "MP3") {print XMLFILE "type=\"audio/mpeg\"/>\n";}
277		if ($AudioFiles[$c]{FileType} eq "MP4") {print XMLFILE "type=\"audio/x-m4a\"/>\n";}
278
279		print XMLFILE "          </item>\n";
280
281		last if ($c == ($Config{XMLMaxEntries} -1)); # only do up to the Max Entries
282	}
283
284	print XMLFILE "	</channel>\n";
285	print XMLFILE "</rss>\n";
286	close XMLFILE;
287
288	logprint "   XML file is done.\n";
289}
290
291sub TimeString {
292
293my @t=gmtime($_[0]);
294my @DayOfWeek=("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat");
295my @MonthName=("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec");
296
297return "$DayOfWeek[$t[6]], $t[3] $MonthName[$t[4]] " . ($t[5]+1900) . sprintf(" %02d:", $t[2]) . sprintf("%02d:", $t[1]) . sprintf("%02d", $t[0]) . " GMT";
298
299#      0    1    2     3     4    5     6     7
300#    ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday) = gmtime(time);
301
302# example output "Fri, 31 Dec 2004 17:00:00 GMT"
303
304}
305
306sub readconfig {
307	my $configfile = $_[0];
308	my $Item;
309	my $Value;
310	$Config{MoreInfo}   = ""; #initialize to avoid warning
311	$Config{AdditionalHTML} = ""; #initialize to avoid warning
312
313	open (INPUT, $configfile)         or logdie "Can't open config file: $configfile: $!\n";
314	print "Reading configuration file \"$configfile\" ...\n";
315	while (<INPUT>) {
316		chomp;
317
318		s/^\s+//;	               # remove leading whitespace
319		s/\s+$//;                  # remove trailing whitespace
320
321		if (/^#/ || /^$/) {next}; # ignore comments and blank lines
322		/^(\w+)(\s*)(.*)$/;        # parse config items into $1 and $3 ($2 is whitespace)
323		$Item = $1;
324		$Value = $3;
325
326		# check for valid config items, to protect against typos
327		if ($Item !~ m/^(Title|SortOrder|MakeHTMLFile|MakeXMLFile|UseTemplateForHTML|TemplateFile|LogFile|Link|Description|Language|Copyright|MoreInfo|AdditionalHTML|InEachEntry|AudioPathServerSide|AudioPathWebSide|HTMLServerSide|StylesheetWebSide|XMLServerSide|XMLWebSide|XMLMaxEntries|HeaderHTML|FooterHTML|iTunesSupport|iTunesAuthor|iTunesExplicit|iTunesLinkJpeg|iTunesLinkText|iTunesCategory)$/) {logdie("Error in config file, please fix it. I don\'t understand this:\n\"$_\"\n");}	# error unknown data
328
329		if ($Item eq "HeaderHTML")            {$Config{$Item} .= $Value . "\n";}  # multiline item
330			elsif ($Item eq "FooterHTML")     {$Config{$Item} .= $Value . "\n";}  # multiline item
331			elsif ($Item eq "InEachEntry")    {$Config{$Item} .= $Value . "\n";}  # multiline item
332			elsif ($Item eq "AdditionalHTML") {$Config{$Item} .= $Value . "\n";}  # multiline, synonym for FooterHTML
333			elsif ($Item eq "MoreInfo")       {$Config{$Item} .= $Value . "\n";}	# multiline, synonym for HeaderHTML
334			elsif ($Item eq "iTunesCategory") {$Config{$Item} .= &iCat($Value);}	# iTunes Categories
335			else {$Config{$Item} = $Value;} 						   	            # default assignment
336		}
337
338	$Config{HeaderHTML} .= $Config{MoreInfo};				# MoreInfo parm to be phased out
339	$Config{FooterHTML} .= $Config{AdditionalHTML};		# AdditionalHTML parm to be phased out
340
341	close(INPUT)                or logdie "can't close $configfile: $!\n";
342
343	if ($Config{iTunesSupport} == 1) {print "\nEXPERIMENTAL iTunes specific tag support is ON.\n";}
344		else {print "iTunes specific tag support is OFF.\n";}
345
346	print " Done.\n";
347	}
348
349sub iCat {
350#experimental!
351#parse iTunes Catagories and format as XML
352	my $val = $_[0];
353
354	if ($Config{EscapeCharacterData} == 1) {$val = &ecd($val)};
355
356	if ($val =~ /,/) {
357		$val =~ /^(.+),(.+)$/;
358		$val = "<itunes:category text=\"$1\">\n<itunes:category text=\"$2\"/>\n</itunes:category>\n";
359	}
360	else {$val = "<category>$val</category>\n"}
361
362	return $val;
363}
364
365sub urlencode {
366# subroutine: urlencode a string (thanks to Brian Hefferan)
367       my $url = shift @_;
368       #next line lifted from CGI.pm
369         $url =~ s/([^a-zA-Z0-9_.%;&?\/\\:+=~-])/sprintf("%%%02X",ord($1))/eg;
370         return $url;
371}
372
373sub BuildEntry{
374# build individual entry for each MP3 in the HTML page
375	my $c = $_[0];
376	my $entry = $Config{InEachEntry};
377	my $AudioFileNoPath = $AudioFiles[$c]{Filename};
378	$AudioFileNoPath =~ /^.*\/(.+$)/;
379    my $AudioFileWebSide = $Config{AudioPathWebSide} . &urlencode($1);
380
381	logprint "   Adding \"$AudioFiles[$c]{Title}\"\n";
382
383	$entry =~ s/\[TITLE\]/$AudioFiles[$c]{Title}/g;
384	$entry =~ s/\[COMMENT\]/$AudioFiles[$c]{Comment}/g;
385	$entry =~ s/\[AUDIOFILE\]/$AudioFileWebSide/g;
386	$entry =~ s/\[AUDIOFILE_NO_PATH\]/&urlencode($1)/g;
387	$entry =~ s/\[AUDIOFILE_NO_PATH_NO_ENCODE\]/$1/g;
388	$entry =~ s/\[RUNNING_TIME\]/$AudioFiles[$c]{Time}/g;
389
390	$entry =~ s/\[BYTES\]/$AudioFiles[$c]{Bytes}/g;
391	my $kbytes = sprintf("%.0f", $AudioFiles[$c]{Bytes}/1024);
392	$entry =~ s/\[KBYTES\]/$kbytes/g;
393	my $mbytes = sprintf("%.1f", $AudioFiles[$c]{Bytes}/1024/1024);
394	$entry =~ s/\[MBYTES\]/$mbytes/g;
395
396	$entry =~ s/\[FILE_MODIFIED\]/$AudioFiles[$c]{FileModified}/g;
397	$entry =~ s/\[ARTIST\]/$AudioFiles[$c]{Artist}/g;
398	$entry =~ s/\[ALBUM\]/$AudioFiles[$c]{Album}/g;
399	$entry =~ s/\[YEAR\]/$AudioFiles[$c]{Year}/g;
400	$entry =~ s/\[GENRE\]/$AudioFiles[$c]{Genre}/g;
401	$entry =~ s/\[FILETYPE\]/$AudioFiles[$c]{FileType}/g;
402	$entry =~ s/\[SPACE\]/ /g; #insert a space
403	$entry =~ s/\[EOL\]/\n/g; # insert end of line char(s)
404	$entry =~ s/\[NULL\]//g; # does nothing
405	return $entry;
406}
407
408sub ecd {
409# Escape Character Data
410# certain special characters need to be replaced with escaped strings
411# for valid XML and HTML
412my $CharacterData = $_[0];
413if (defined($CharacterData)) {
414	$CharacterData =~ s/&/&amp;/g;  # escape ampersand
415	$CharacterData =~ s/</&lt;/g;   # escape less than
416	$CharacterData =~ s/>/&gt;/g;   # escape greater than
417	$CharacterData =~ s/'/&apos;/g; # escape apostrophe (single quote)
418	$CharacterData =~ s/"/&quot;/g; # escape double quote
419	}
420	else {$CharacterData = "";} # doing this makes the value defined
421	return $CharacterData;
422	}
423
424sub MakeFromTemplate {
425	my $entries = "";
426	logprint "Building HTML file \"$Config{HTMLServerSide}\" from template.\n";
427	# slurp template file
428    open (TEMPLATEFILE, $Config{TemplateFile}) or logdie "Can't open template file: $Config{TemplateFile}: $!\n";
429	undef $/;
430	my $template = <TEMPLATEFILE>;
431	close (TEMPLATEFILE) or logdie "Can't close config file: $Config{TemplateFile}: $!\n";
432
433	# do replacements
434	for my $ca (0..$IndexOfAudioFiles) {	# build the list of entries
435		$entries .= BuildEntry($ca);
436	}
437	$template =~ s/\[ENTRIES\]/$entries/;
438	$template = DoTemplateReplacements($template);
439
440	# save output
441	open (HTMLFILE, '>', $Config{HTMLServerSide}) or logdie "Can't open \"$Config{HTMLServerSide}\" for HTML output.\n";
442	print HTMLFILE $template;
443	close (HTMLFILE) or logdie "Can't close HTML file: $Config{HTMLServerSide}: $!\n";
444	logprint "   HTML file has been created from the template \"$Config{TemplateFile}\".\n";
445
446}
447
448sub DoTemplateReplacements {
449	my $template = $_[0];
450
451	$template =~ s/\[HEADERHTML\]/$Config{HeaderHTML}/g;
452	$template =~ s/\[FOOTERHTML\]/$Config{FooterHTML}/g;
453	$template =~ s/\[BUILDTIME\]/$NowTime/g;
454	$template =~ s/\[TITLE\]/$Config{Title}/g;
455	$template =~ s/\[DESCRIPTION\]/$Config{Description}/g;
456	$template =~ s/\[COPYRIGHT\]/$Config{Copyright}/g;
457	$template =~ s/\[MORE_INFO\]/$Config{MoreInfo}/g;
458	$template =~ s/\[ADDITIONAL_HTML\]/$Config{AdditionalHTML}/g;
459	$template =~ s/\[STYLESHEET_WEBSIDE\]/$Config{StylesheetWebSide}/g;
460	$template =~ s/\[XMLWEBSIDE\]/$Config{XMLWebSide}/g;
461	$template =~ s/\[SPACE\]/ /g; #insert a space
462	$template =~ s/\[EOL\]/\n/g; # insert end of line char(s)
463	$template =~ s/\[NULL\]//g; # does nothing
464	return $template;
465}