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.

477 lines
14KB

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