選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

flac2mp3.pl 17KB

3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
3年前
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  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