You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

407 lines
12KB

  1. #!/usr/bin/env perl
  2. use strict;
  3. use warnings;
  4. use Getopt::Long;
  5. use File::Find;
  6. use Data::Dumper;
  7. use File::Basename;
  8. use File::Temp qw/ tempfile /;
  9. my $opt_no_genre;
  10. my $opt_comment;
  11. my $opt_catid;
  12. my $opt_rg;
  13. # TODO fill this out
  14. my %genreMap = (
  15. edm => 52,
  16. soundtrack => 24,
  17. );
  18. # this is a godsent page
  19. # https://wiki.hydrogenaud.io/index.php?title=Tag_Mapping
  20. # https://picard-docs.musicbrainz.org/en/appendices/tag_mapping.html
  21. # a lot of this may not work
  22. # TODO escape potential 's
  23. #
  24. # Format is:
  25. # Vorbis tag string => Mp3 tag value
  26. # where mp3 tag value may be:
  27. # undef -> Skip this tag
  28. # A string -> Use this as the mp3 tag, and use the vorbis tag value as value
  29. # code -> Execute this function. This should return an array, where [0] is the tag, [1] is the value.
  30. # An array (str, str) -> [0] is the mp3 tag to use, [1] is the value prefix
  31. # An array (str, code) -> [0] is the mp3 tag to use, [1] is a function that is executed, and the result is the tag value
  32. # The code-s here will be called with the flac tags hashmap
  33. my %idLookup = (
  34. album => 'TALB',
  35. albumsort => 'TSOA',
  36. discsubtitle => 'TSST',
  37. grouping => 'TIT1',
  38. title => 'TIT2',
  39. titlesort => 'TSOT',
  40. subtitle => 'TIT3',
  41. subtitle => 'TIT3',
  42. albumartist => 'TPE2',
  43. albumartistsort => 'TSO2', # Maybe?
  44. artist => 'TPE1',
  45. artistsort => 'TSOP',
  46. arranger => ['TIPL', 'arranger:'],
  47. author => 'TEXT',
  48. composer => 'TCOM',
  49. conductor => 'TPE3',
  50. engineer => ['TIPL', 'engineer:'],
  51. djmixer => ['TIPL', 'DJ-mix:'],
  52. mixer => ['TIPL', 'mix:'],
  53. #performer => 'TMCL', # This produces some really weird tags
  54. producer => ['TIPL', 'producer:'],
  55. publisher => 'TPUB',
  56. organization => 'TPUB',
  57. label => 'TPUB',
  58. remixer => 'TPE4',
  59. discnumber => ['TPOS', sub {
  60. my $t = shift;
  61. my $totalkey = exists($t->{disctotal}) ? 'disctotal' : 'totaldiscs';
  62. return "$t->{discnumber}[0]" if !exists($t->{$totalkey});
  63. return "$t->{discnumber}[0]/$t->{$totalkey}[0]";
  64. }],
  65. totaldiscs => undef,
  66. disctotal => undef,
  67. tracknumber => ['TRCK', sub {
  68. my $t = shift;
  69. my $totalkey = exists($t->{tracktotal}) ? 'tracktotal' : 'totaltracks';
  70. return "$t->{tracknumber}[0]" if !exists($t->{$totalkey});
  71. return "$t->{tracknumber}[0]/$t->{$totalkey}[0]";
  72. }],
  73. totaltracks => undef,
  74. tracktotal => undef,
  75. #date => 'TDRC', # This is for id3v2.4
  76. #date => 'TYER',
  77. date => sub {
  78. my $t = shift;
  79. my $date = $t->{date}[0];
  80. if (length($date) == 4) { # Only year
  81. return ["TYER", "$date"];
  82. }
  83. if (!($date =~ m/^\d{4}[\.-]\d{2}[\.-]\d{2}$/)) {
  84. print("Date format unknown: $date\n");
  85. exit 1;
  86. }
  87. $date =~ s/[\.-]/-/g;
  88. return ["TDRL", "$date"]; # Release date
  89. },
  90. originaldate => 'TDOR', # Also for 2.4 only
  91. 'release date' => 'TDOR', # Also for 2.4 only
  92. isrc => 'TSRC',
  93. barcode => ['TXXX', 'BARCODE:'],
  94. catalog => ['TXXX', sub { return "CATALOGNUMBER:" . tagmap_catalogid(shift, 'catalog'); } ],
  95. catalognumber => ['TXXX', sub { return "CATALOGNUMBER:" . tagmap_catalogid(shift, 'catalognumber'); } ],
  96. catalogid => ['TXXX', sub { return "CATALOGNUMBER:" . tagmap_catalogid(shift, 'catalogid'); } ],
  97. labelno => ['TXXX', sub { return "CATALOGNUMBER:" . tagmap_catalogid(shift, 'labelno'); } ],
  98. #'encoded-by' => 'TENC',
  99. #encoder => 'TSSE',
  100. #encoding => 'TSSE',
  101. #'encoder settings' => 'TSSE',
  102. media => 'TMED',
  103. genre => ['TCON', sub {
  104. return undef if ($opt_no_genre);
  105. my $genreName = shift->{genre}[0];
  106. if (!exists($genreMap{lc($genreName)})) {
  107. # If no genre number exists, use the name
  108. return $genreName;
  109. }
  110. return $genreMap{$genreName};
  111. }],
  112. #mood => ['TMOO', sub {
  113. #}],
  114. bpm => 'TBPM',
  115. comment => ['COMM', sub {
  116. return undef if (defined($opt_comment) && $opt_comment eq "");
  117. return "Comment:" . shift->{comment}[0];
  118. }],
  119. copyright => 'TCOP',
  120. language => 'TLAN',
  121. #replaygain_album_peak => 'TXXX=REPLAYGAIN_ALBUM_PEAK',
  122. #replaygain_album_gain => 'TXXX=REPLAYGAIN_ALBUM_GAIN',
  123. replaygain_track_gain => sub {
  124. print("EEEEEEERRRRRRRRROOOOOOOOOORRRRRRRRRRRE FIXXXXXXXXXXXX THIIIIIIIIISSSSSSSS\n");
  125. exit(1);
  126. return undef if (!$opt_rg);
  127. shift->{replaygain_track_gain}[0] =~ /^(-?\d+\.\d+) dB$/;
  128. my $gain_db = $1;
  129. exit(1) if ($gain_db eq "");
  130. return "--replaygain-accurate --gain $gain_db";
  131. # TODO this lulw
  132. },
  133. #replaygain_album_gain => 'TXXX=REPLAYGAIN_ALBUM_GAIN',
  134. #replaygain_album_peak => 'TXXX=REPLAYGAIN_ALBUM_PEAK',
  135. #replaygain_track_gain => 'TXXX=REPLAYGAIN_TRACK_GAIN',
  136. #replaygain_track_peak => 'TXXX=REPLAYGAIN_TRACK_PEAK',
  137. script => ['TXXX', 'SCRIPT:'],
  138. lyrics => 'USLT',
  139. circle => ['TXXX', 'CIRCLE:'],
  140. event => ['TXXX', 'EVENT:'],
  141. discid => ['TXXX', 'DISCID:'],
  142. originaltitle => ['TXXX', 'ORIGINALTITLE:'],
  143. );
  144. sub tagmap_catalogid {
  145. my $t = shift;
  146. my $own_tag_name = shift;
  147. return undef if (defined($opt_catid) && $opt_catid eq "");
  148. return $t->{$own_tag_name}[0];
  149. }
  150. my $opt_genre;
  151. my $opt_help;
  152. my @opt_tagreplace;
  153. my $opt_cbr = 0;
  154. GetOptions(
  155. "genre|g=s" => \$opt_genre,
  156. "no-genre|G" => \$opt_no_genre,
  157. "replay-gain|r" => \$opt_rg,
  158. "help|h" => \$opt_help,
  159. "catid=s" => \$opt_catid,
  160. "comment=s" => \$opt_comment,
  161. "tagreplace|t=s" => \@opt_tagreplace,
  162. "320|3" => \$opt_cbr,
  163. ) or die("Error in command line option");
  164. if ($opt_help) {
  165. help();
  166. }
  167. if (scalar(@ARGV) != 2) {
  168. print("Bad arguments\n");
  169. usage();
  170. }
  171. my ($IDIR, $ODIR) = @ARGV;
  172. if (!-e $ODIR) {
  173. mkdir $ODIR;
  174. }
  175. find({ wanted => \&iterFlac, no_chdir => 1 }, $IDIR);
  176. sub iterFlac {
  177. # Return if file is not a file, or if it's not a flac
  178. return if (!-f || !/\.flac$/);
  179. my @required_tags = ("artist", "title", "album", "tracknumber");
  180. my $flacDir = substr($File::Find::name, length($IDIR));
  181. my $flac = $_;
  182. my $flac_o = $flac;
  183. shellsan(\$flac);
  184. my $dest = "$ODIR/" . $flacDir;
  185. #print("DEBUG: $dest\n");
  186. $dest =~ s/\.flac$/\.mp3/;
  187. my $tags = getFlacTags($flac);
  188. my $has_req_tags = 1;
  189. foreach (@required_tags) {
  190. if (!exists($tags->{lc($_)})) {
  191. $has_req_tags = 0;
  192. last;
  193. }
  194. }
  195. if (!$has_req_tags) {
  196. print("WARNING: File: '$flac' does not have all the required tags. Skipping\n");
  197. exit(1);
  198. return;
  199. }
  200. argsToTags($tags, $flac_o);
  201. my $tagopts = tagsToOpts($tags);
  202. #print("Debug: @$tagopts\n");
  203. shellsan(\$dest);
  204. my $cmd;
  205. if ($opt_cbr) {
  206. $cmd = "flac -cd -- '$flac' | lame -S -b 320 -q 0 --add-id3v2 - '$dest'";
  207. } else {
  208. $cmd = "flac -cd -- '$flac' | lame -S -V0 --vbr-new -q 0 --add-id3v2 - '$dest'";
  209. }
  210. #print("Debug - CMD: [$cmd]\n");
  211. qx($cmd);
  212. if ($? != 0) {
  213. exit(1);
  214. }
  215. my $mid3v2TagLine = "";
  216. #print(Dumper(\@$tagopts));
  217. # Add tags with mid3v2 instead of lame to better support multiple tag values
  218. foreach my $tagItem (@$tagopts) {
  219. $mid3v2TagLine = $mid3v2TagLine . $tagItem . " ";
  220. }
  221. #print(Dumper(\$mid3v2TagLine));
  222. my $mid3v2TagCmd = "mid3v2 $mid3v2TagLine -- '$dest'";
  223. #print("Mid3V2 Debug - CMD: [$mid3v2TagCmd]\n");
  224. qx($mid3v2TagCmd);
  225. if ($? != 0) {
  226. print("ERROR: At mid3v2 tag set\n");
  227. exit(1);
  228. }
  229. embedImageFromFlac($flac, $dest);
  230. }
  231. sub embedImageFromFlac {
  232. my $flac = shift;
  233. my $mp3 = shift;
  234. # I can't get the automatic deletion working :c
  235. my (undef, $fname) = tempfile();
  236. # Export image from flac
  237. qx(metaflac --export-picture-to='$fname' -- '$flac');
  238. if ($? != 0) {
  239. # Probably no image
  240. unlink($fname);
  241. return;
  242. }
  243. # Extract mime type too
  244. my $pinfo = qx(metaflac --list --block-type=PICTURE -- '$flac');
  245. $pinfo =~ m/MIME type: (.*)/;
  246. my $mimeType = $1;
  247. # Add image to mp3
  248. qx(mid3v2 -p '${fname}:cover:3:$mimeType' -- '$mp3');
  249. unlink($fname);
  250. }
  251. sub argsToTags {
  252. my $argTags = shift;
  253. my $fname = shift;
  254. $fname =~ s!^.*/!!;
  255. if (defined($opt_genre)) {
  256. $argTags->{genre} = [$opt_genre];
  257. }
  258. if (defined($opt_comment) && $opt_comment ne "") {
  259. $argTags->{comment} = [$opt_comment];
  260. }
  261. if (defined($opt_catid) && $opt_catid ne "") {
  262. $argTags->{catalognumber} = [$opt_catid];
  263. }
  264. if (scalar @opt_tagreplace > 0) {
  265. foreach my $trepl (@opt_tagreplace) {
  266. $trepl =~ m!(.*?)/(.*?)=(.*)!;
  267. my ($freg, $tag, $tagval) = ($1, $2, $3);
  268. if ($fname =~ m!$freg!) {
  269. $argTags->{lc($tag)} = ($tagval);
  270. }
  271. }
  272. }
  273. }
  274. sub tagsToOpts {
  275. my $tags = shift;
  276. my @tagopts;
  277. # TODO escape stuff?
  278. foreach my $currKey (keys (%$tags)) {
  279. if (!exists($idLookup{$currKey})) {
  280. print("Tag: '$currKey' doesn't have a mapping, skipping\n");
  281. next;
  282. }
  283. my $tagMapping = $idLookup{$currKey};
  284. my $type = ref($tagMapping);
  285. if ($type eq "" && defined($tagMapping)) {
  286. # If tag name is defined and tag contents exists (aka not silenced)
  287. foreach my $tagCont (@{$tags->{$currKey}}) {
  288. shellsan(\$tagCont);
  289. push(@tagopts, qq('--$tagMapping' '$tagCont'));
  290. }
  291. } elsif ($type eq "ARRAY") {
  292. my $mapKey = $tagMapping->[0];
  293. my $mapCont = $tagMapping->[1];
  294. my $mapContType = ref($mapCont);
  295. if (not defined($mapCont)) {
  296. print("WHUT???\n");
  297. exit(1);
  298. }
  299. if ($mapContType eq "") {
  300. foreach my $tagValue (@{$tags->{$currKey}}) {
  301. shellsan(\$tagValue);
  302. push(@tagopts, qq('--$mapKey' '$mapCont$tagValue'));
  303. }
  304. } elsif ($mapContType eq "CODE") {
  305. my $tagValue = $mapCont->($tags);
  306. shellsan(\$tagValue);
  307. push(@tagopts, qq('--$mapKey' '$tagValue'));
  308. }
  309. } elsif ($type eq 'CODE') {
  310. # If we have just a code reference
  311. # do not assume, that this is a tag, rather a general cmd opt
  312. #my $opt = $tagName->($tags);
  313. #if (defined($opt)) {
  314. #shellsan(\$opt);
  315. #push(@tagopts, qq($opt));
  316. #}
  317. my $codeRet = $tagMapping->($tags);
  318. my $mapKey = $codeRet->[0];
  319. my $mapCont = $codeRet->[1];
  320. shellsan(\$mapCont);
  321. push(@tagopts, qq('--$mapKey' '$mapCont'));
  322. }
  323. }
  324. return \@tagopts;
  325. }
  326. sub getFlacTags {
  327. my $flac = shift;
  328. my %tags;
  329. my @tagtxt = qx(metaflac --list --block-type=VORBIS_COMMENT -- '$flac');
  330. if ($? != 0) {
  331. exit(1);
  332. }
  333. foreach my $tagline (@tagtxt) {
  334. if ($tagline =~ /comment\[\d+\]:\s(.*?)=(.*)/) {
  335. if ($2 eq '') {
  336. print("Empty tag: $1\n");
  337. next;
  338. }
  339. if (not exists($tags{lc($1)})) {
  340. @{$tags{lc($1)}} = ($2);
  341. } else {
  342. push(@{$tags{lc($1)}}, $2);
  343. }
  344. }
  345. }
  346. return \%tags;
  347. }
  348. sub shellsan {
  349. ${$_[0]} =~ s/'/'\\''/g;
  350. }
  351. sub usage {
  352. print("Usage: flac2mp3.pl [-h | --help] [-r] [-3] [-g | --genre NUM] <input_dir> <output_dir>\n");
  353. exit 1;
  354. }
  355. sub help {
  356. my $h = <<EOF;
  357. Usage:
  358. flac2mp3.pl [options] <input_dir> <output_dir>
  359. -h, --help print this help text
  360. -g, --genre NUM force this genre as a tag (lame --genre-list)
  361. -G, --no-genre ignore genre in flac file
  362. -r, --replay-gain use replay gain values
  363. --catid STRING the catalog id to set (or "")
  364. --comment STRING the comment to set (or "")
  365. -t --tagreplace STR Replace flac tags for a specific file only
  366. Like -t '02*flac/TITLE=Some other title'
  367. -3, --320 Convert into CBR 320 instead into the default V0
  368. EOF
  369. print($h);
  370. exit 0;
  371. }
  372. # vim: ts=4 sw=4 et sta