Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

553 рядки
17KB

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