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

554 рядки
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. sourcemedia => 'TMED',
  105. genre => ['TCON', sub {
  106. return undef if ($opt_no_genre);
  107. my $genreName = shift->{genre}[0];
  108. return mp3TagEscapeOwn($genreName);
  109. }],
  110. #mood => ['TMOO', sub {
  111. #}],
  112. bpm => 'TBPM',
  113. comment => ['COMM', sub {
  114. return undef if (defined($opt_comment) && $opt_comment eq "");
  115. # Use the default empty description and default english language
  116. return mp3TagEscapeOwn(shift->{comment}[0]);
  117. }],
  118. copyright => 'TCOP',
  119. language => 'TLAN',
  120. #replaygain_album_peak => 'TXXX=REPLAYGAIN_ALBUM_PEAK',
  121. #replaygain_album_gain => 'TXXX=REPLAYGAIN_ALBUM_GAIN',
  122. replaygain_track_gain => sub {
  123. my $gain_db = getGainFromTag(shift->{replaygain_track_gain}[0]);
  124. if ($opt_rg) {
  125. print("REPLAYGAIN :::::::::::::::: MODIFYING FILE\n");
  126. # Modify file
  127. $LAME_opts .= " --replaygain-accurate --gain $gain_db";
  128. #print("Added LAME opt: $LAME_opts\n");
  129. return undef;
  130. } else {
  131. print("REPLAYGAIN :::::::::::::::: COPYING TAG\n");
  132. # Copy tags
  133. return ["TXXX", "REPLAYGAIN_TRACK_GAIN:" . mp3TagEscapeOwn("$gain_db dB")];
  134. }
  135. },
  136. replaygain_track_peak => sub {
  137. my $peak = shift->{replaygain_track_peak}[0];
  138. if ($opt_rg) {
  139. # Modify file
  140. $LAME_opts .= " --replaygain-accurate";
  141. #print("Added LAME opt: $LAME_opts\n");
  142. return undef;
  143. } else {
  144. # Copy tags
  145. return ["TXXX", "REPLAYGAIN_TRACK_PEAK:" . mp3TagEscapeOwn("$peak")];
  146. }
  147. },
  148. #replaygain_album_gain => 'TXXX=REPLAYGAIN_ALBUM_GAIN',
  149. #replaygain_album_peak => 'TXXX=REPLAYGAIN_ALBUM_PEAK',
  150. #replaygain_track_gain => 'TXXX=REPLAYGAIN_TRACK_GAIN',
  151. script => ['TXXX', 'SCRIPT:'],
  152. #lyrics => 'USLT',
  153. #unsyncedlyrics => 'USLT',
  154. unsyncedlyrics => sub {
  155. my @lyrArr = @{shift->{unsyncedlyrics}};
  156. my $tagStr = "";
  157. foreach (@lyrArr) {
  158. $tagStr .= mp3TagEscapeOwn($_) . "\\n";
  159. }
  160. # TODO figure out how to set language here
  161. return ["USLT", "$tagStr"];
  162. },
  163. lyricist => 'TEXT',
  164. circle => ['TXXX', 'CIRCLE:'],
  165. event => ['TXXX', 'EVENT:'],
  166. discid => ['TXXX', 'DISCID:'],
  167. originaltitle => ['TXXX', 'ORIGINALTITLE:'],
  168. origin => ['TXXX', 'ORIGIN:'],
  169. origintype => ['TXXX', 'ORIGINTYPE:'],
  170. );
  171. sub tagmap_catalogid {
  172. my $t = shift;
  173. my $own_tag_name = shift;
  174. return undef if (defined($opt_catid) && $opt_catid eq "");
  175. return $t->{$own_tag_name}[0];
  176. }
  177. sub getGainFromTag {
  178. my $tagVal = shift;
  179. $tagVal =~ /^([-+]?\d+\.\d+) dB$/;
  180. my $gain_db = $1;
  181. if ($gain_db eq "") {
  182. print("gain FAIL...: $tagVal\n");
  183. exit(1);
  184. }
  185. return $gain_db;
  186. }
  187. my $opt_genre;
  188. my $opt_help;
  189. my @opt_tagreplace;
  190. my $opt_cbr = 0;
  191. GetOptions(
  192. "genre|g=s" => \$opt_genre,
  193. "no-genre|G" => \$opt_no_genre,
  194. "replay-gain|r" => \$opt_rg,
  195. "help|h" => \$opt_help,
  196. "catid=s" => \$opt_catid,
  197. "comment=s" => \$opt_comment,
  198. "cover=s" => \$opt_embedcover,
  199. "pub=s" => \$opt_publisher,
  200. "tagreplace|t=s" => \@opt_tagreplace,
  201. "320|3" => \$opt_cbr,
  202. ) or die("Error in command line option");
  203. if ($opt_help) {
  204. help();
  205. }
  206. if (scalar(@ARGV) != 2) {
  207. print("Bad arguments\n");
  208. usage();
  209. }
  210. my ($IDIR, $ODIR) = @ARGV;
  211. if (!-e $ODIR) {
  212. mkdir $ODIR;
  213. }
  214. find({ wanted => \&iterFlac, no_chdir => 1 }, $IDIR);
  215. sub iterFlac {
  216. # Return if file is not a file, or if it's not a flac
  217. return if (!-f || !/\.flac$/);
  218. my @required_tags = ("artist", "title", "album", "tracknumber");
  219. my $flacDir = substr($File::Find::name, length($IDIR));
  220. my $flac = $_;
  221. my $flac_o = $flac;
  222. shellsan(\$flac);
  223. my $dest = "$ODIR/" . $flacDir;
  224. #print("DEBUG: $dest\n");
  225. $dest =~ s/\.flac$/\.mp3/;
  226. my $tags = getFlacTags($flac);
  227. #print(Dumper($tags));
  228. my $has_req_tags = 1;
  229. foreach (@required_tags) {
  230. if (!exists($tags->{lc($_)})) {
  231. $has_req_tags = 0;
  232. last;
  233. }
  234. }
  235. if (!$has_req_tags) {
  236. print("WARNING: File: '$flac' does not have all the required tags. Skipping\n");
  237. exit(1);
  238. return;
  239. }
  240. argsToTags($tags, $flac_o);
  241. #foreach (%$tags) {
  242. #print("Copying tag '$_->[0]=$_->[1]'\n");
  243. #}
  244. my $tagopts = tagsToOpts($tags);
  245. #print(Dumper($tagopts));
  246. $dest =~ m!(.*)/[^/]+!;
  247. my $basedir = $1;
  248. mkdir($basedir) if (not -f $basedir);
  249. shellsan(\$dest);
  250. my $cmd;
  251. if ($opt_cbr) {
  252. $cmd = "flac -cd -- '$flac' | lame $LAME_opts -S -b 320 -q 0 --add-id3v2 - '$dest'";
  253. } else {
  254. $cmd = "flac -cd -- '$flac' | lame $LAME_opts -S -V0 --vbr-new -q 0 --add-id3v2 - '$dest'";
  255. }
  256. #print("Debug - CMD: [$cmd]\n");
  257. qx($cmd);
  258. if ($? != 0) {
  259. exit(1);
  260. }
  261. $LAME_opts = ""; # Reset for the next track
  262. my $mid3v2TagLine = "";
  263. #print(Dumper(\@$tagopts));
  264. # Add tags with mid3v2 instead of lame to better support multiple tag values
  265. foreach my $tagItem (@$tagopts) {
  266. $mid3v2TagLine = $mid3v2TagLine . $tagItem . " ";
  267. }
  268. #print(Dumper(\$mid3v2TagLine));
  269. my $mid3v2TagCmd = "mid3v2 -e $mid3v2TagLine -- '$dest'";
  270. #print("Mid3V2 Debug - CMD: [$mid3v2TagCmd]\n");
  271. qx($mid3v2TagCmd);
  272. if ($? != 0) {
  273. print("ERROR: At mid3v2 tag set\n");
  274. exit(1);
  275. }
  276. embedImageFromFlac($flac, $dest);
  277. }
  278. sub getMimeType {
  279. my $file = shift;
  280. shellsan(\$file);
  281. my $mime = qx(file -b --mime-type '$file');
  282. chomp($mime);
  283. return $mime;
  284. }
  285. sub embedImageFromFlac {
  286. my $flac = shift;
  287. my $mp3 = shift;
  288. if (defined($opt_embedcover)) {
  289. return if ($opt_embedcover eq "");
  290. my $cmime = getMimeType($opt_embedcover);
  291. qx(mid3v2 -p '${opt_embedcover}:cover:3:$cmime' -- '$mp3');
  292. return;
  293. }
  294. # I can't get the automatic deletion working :c
  295. my (undef, $fname) = tempfile();
  296. # Export image from flac
  297. qx(metaflac --export-picture-to='$fname' -- '$flac');
  298. if ($? != 0) {
  299. # Probably no image
  300. unlink($fname);
  301. return;
  302. }
  303. # Extract mime type too
  304. my $pinfo = qx(metaflac --list --block-type=PICTURE -- '$flac');
  305. $pinfo =~ m/MIME type: (.*)/;
  306. my $mimeType = $1;
  307. # Add image to mp3
  308. qx(mid3v2 -p '${fname}:cover:3:$mimeType' -- '$mp3');
  309. unlink($fname);
  310. }
  311. sub argsToTags {
  312. my $argTags = shift;
  313. my $fname = shift;
  314. $fname =~ s!^.*/!!;
  315. if (defined($opt_genre)) {
  316. $argTags->{genre} = [$opt_genre];
  317. }
  318. if (defined($opt_comment)) {
  319. if ($opt_comment eq "") {
  320. delete($argTags->{comment});
  321. } else {
  322. $argTags->{comment} = [$opt_comment];
  323. }
  324. }
  325. if (defined($opt_publisher)) {
  326. if ($opt_publisher eq "") {
  327. delete($argTags->{organization});
  328. } else {
  329. $argTags->{organization} = [$opt_publisher];
  330. }
  331. }
  332. if (defined($opt_catid) && $opt_catid ne "") {
  333. $argTags->{catalognumber} = [$opt_catid];
  334. }
  335. if (scalar @opt_tagreplace > 0) {
  336. foreach my $trepl (@opt_tagreplace) {
  337. $trepl =~ m!(.*?)/(.*?)=(.*)!;
  338. my ($freg, $tag, $tagval) = ($1, $2, $3);
  339. if ($fname =~ m!$freg!) {
  340. $argTags->{lc($tag)} = ($tagval);
  341. }
  342. }
  343. }
  344. }
  345. sub mergeDupeTxxx {
  346. # Merge tags together, that we can't have multiples of
  347. # Like TXXX with same key
  348. my $tagsArr = shift;
  349. for (my $i = 0; $i < scalar @$tagsArr; $i++) {
  350. if (lc($tagsArr->[$i]->[0]) ne "txxx") {
  351. next;
  352. }
  353. $tagsArr->[$i]->[1] =~ m/^(.*?):(.*)$/;
  354. my $txkeyFirst = $1;
  355. my $txvalFirst = $2;
  356. for (my $j = $i + 1; $j < scalar @$tagsArr; $j++) {
  357. next if (lc($tagsArr->[$j]->[0]) ne "txxx");
  358. $tagsArr->[$j]->[1] =~ m/^(.*?):(.*)$/;
  359. my $txkeySecond = $1;
  360. my $txvalSecond = $2;
  361. next if ($txkeyFirst ne $txkeySecond);
  362. # TXXX keys are equal, append the second to the first, and delete this entry
  363. $tagsArr->[$i]->[1] .= ';' . $txvalSecond;
  364. #print("DDDDDDDDDDDDD: Deleted $j index $txkeySecond:$txvalSecond\n");
  365. splice(@$tagsArr, $j, 1);
  366. }
  367. }
  368. }
  369. sub tagsToOpts {
  370. my $tags = shift;
  371. my @tagopts;
  372. foreach my $currKey (keys (%$tags)) {
  373. if (!exists($idLookup{$currKey})) {
  374. print("Tag: '$currKey' doesn't have a mapping, skipping\n");
  375. next;
  376. }
  377. my $tagMapping = $idLookup{$currKey};
  378. my $type = ref($tagMapping);
  379. if ($type eq "" && defined($tagMapping)) {
  380. # If tag name is defined and tag contents exists (aka not silenced)
  381. foreach my $tagCont (@{$tags->{$currKey}}) {
  382. mp3TagEscape(\$tagCont);
  383. shellsan(\$tagCont);
  384. push(@tagopts, ["$tagMapping", "$tagCont"]);
  385. }
  386. } elsif ($type eq "ARRAY") {
  387. my $mapKey = $tagMapping->[0];
  388. my $mapCont = $tagMapping->[1];
  389. my $mapContType = ref($mapCont);
  390. if (not defined($mapCont)) {
  391. print("WHUT???\n");
  392. exit(1);
  393. }
  394. if ($mapContType eq "") {
  395. foreach my $tagValue (@{$tags->{$currKey}}) {
  396. mp3TagEscape(\$tagValue);
  397. shellsan(\$tagValue);
  398. push(@tagopts, ["$mapKey", "$mapCont$tagValue"]);
  399. }
  400. } elsif ($mapContType eq "CODE") {
  401. my $tagValue = $mapCont->($tags);
  402. shellsan(\$tagValue);
  403. push(@tagopts, ["$mapKey", "$tagValue"]);
  404. }
  405. } elsif ($type eq 'CODE') {
  406. # If we have just a code reference
  407. # do not assume, that this is a tag, rather a general cmd opt
  408. #my $opt = $tagName->($tags);
  409. #if (defined($opt)) {
  410. #shellsan(\$opt);
  411. #push(@tagopts, qq($opt));
  412. #}
  413. my $codeRet = $tagMapping->($tags);
  414. next if (not defined($codeRet));
  415. my $mapKey = $codeRet->[0];
  416. my $mapCont = $codeRet->[1];
  417. shellsan(\$mapCont);
  418. push(@tagopts, ["$mapKey", "$mapCont"]);
  419. }
  420. }
  421. mergeDupeTxxx(\@tagopts);
  422. # Convert the tag array into an array of string to use with mid3v2
  423. my @tagoptsStr;
  424. foreach (@tagopts) {
  425. push(@tagoptsStr, qq('--$_->[0]' '$_->[1]'));
  426. }
  427. return \@tagoptsStr;
  428. }
  429. sub getFlacTags {
  430. my $flac = shift;
  431. my %tags;
  432. my @tagtxt = qx(metaflac --list --block-type=VORBIS_COMMENT -- '$flac');
  433. if ($? != 0) {
  434. exit(1);
  435. }
  436. my $curr_tag = "";
  437. foreach my $tagline (@tagtxt) {
  438. if ($tagline =~ /^\s+comment\[\d+\]:\s(.*?)=(.*)/) {
  439. if ($2 eq '') {
  440. print("Empty tag: $1\n");
  441. next;
  442. }
  443. $curr_tag = lc($1);
  444. if (not exists($tags{$curr_tag})) {
  445. @{$tags{$curr_tag}} = ($2);
  446. } else {
  447. push(@{$tags{$curr_tag}}, $2);
  448. }
  449. } elsif ($curr_tag ne "") {
  450. # Maybe multi line? (like lyrics) store if it was multiple tag=value fields
  451. chomp($tagline);
  452. push(@{$tags{$curr_tag}}, $tagline);
  453. #if (ref(@{$tags{$curr_tag}}) eq "") {
  454. # Second line
  455. #@{$tags{$curr_tag}} = (@{$tags{$curr_tag}}, $tagline);
  456. #} else {
  457. # 3rd, or later line
  458. #push(@{$tags{$curr_tag}}, $tagline);
  459. #}
  460. }
  461. }
  462. return \%tags;
  463. }
  464. sub shellsan {
  465. ${$_[0]} =~ s/'/'\\''/g;
  466. }
  467. # Escape ':', and '\' characters in mp3 tag values
  468. sub mp3TagEscape {
  469. ${$_[0]} =~ s!([:\\])!\\$1!g;
  470. }
  471. sub mp3TagEscapeOwn {
  472. my $s = shift;
  473. mp3TagEscape(\$s);
  474. return $s;
  475. }
  476. sub usage {
  477. print("Usage: flac2mp3.pl [-h | --help] [-r] [-3] [-g | --genre NUM] <input_dir> <output_dir>\n");
  478. exit 1;
  479. }
  480. sub help {
  481. my $h = <<EOF;
  482. Usage:
  483. flac2mp3.pl [options] <input_dir> <output_dir>
  484. -h, --help print this help text
  485. -g, --genre NUM force this genre as a tag (lame --genre-list)
  486. -G, --no-genre ignore genre in flac file
  487. -r, --replay-gain modify file with the replay-gain values
  488. --catid STRING the catalog id to set (or "")
  489. --comment STRING the comment to set (or "")
  490. --cover STRING Use this image as cover (or "" to not copy from flac)
  491. --pub STRING Publisher
  492. -t --tagreplace STR Replace flac tags for a specific file only
  493. Like -t '02*flac/TITLE=Some other title'
  494. -3, --320 Convert into CBR 320 instead into the default V0
  495. EOF
  496. print($h);
  497. exit 0;
  498. }
  499. # vim: ts=4 sw=4 et sta