Skip to content

Commit c477378

Browse files
authored
Fix numeric genres (#123)
* test suite for single term in a genre frame * O(n) processing of genre fields! * (CR) and (RX) support Tests for multiple terms. * Tests for rendering. * Remove old tests that broke as part of fixes * Address copilot comments
1 parent e92ed72 commit c477378

4 files changed

Lines changed: 492 additions & 411 deletions

File tree

src/genres.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,22 @@ export default class Genres {
231231
? Genres.AUDIO_GENRES[safeIndex]
232232
: undefined;
233233
}
234+
235+
public static indexToAudioDirect(index: number|string): string {
236+
if (typeof(index) === "string") {
237+
if (!(/\d+/).test(index)) {
238+
// Non numeric string
239+
return undefined;
240+
}
241+
242+
// Numeric string
243+
const indexNumber = Number(index);
244+
return Genres.AUDIO_GENRES[indexNumber];
245+
}
246+
247+
// Number
248+
return Genres.AUDIO_GENRES[index];
249+
}
234250

235251
/**
236252
* Gets the video genre name for a specified index.
@@ -266,7 +282,13 @@ export default class Genres {
266282
const trimRegex = /^\(+|\)+$/g;
267283
text = text.replace(trimRegex, "");
268284
}
269-
const index = parseInt(text, 10);
285+
286+
const normalized = text.trim();
287+
if (!/^\d+$/.test(normalized)) {
288+
return 255;
289+
}
290+
291+
const index = parseInt(normalized, 10);
270292
return Number.isNaN(index) ? 255 : index;
271293
}
272294
}

src/id3v2/frames/textInformationFrame.ts

Lines changed: 132 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {ByteVector, StringType} from "../../byteVector";
44
import {Frame, FrameClassType} from "./frame";
55
import {Id3v2FrameHeader} from "./frameHeader";
66
import {FrameIdentifier, FrameIdentifiers} from "../frameIdentifiers";
7-
import {Guards, StringComparison, StringUtils} from "../../utils";
7+
import {Guards, StringComparison} from "../../utils";
88

99
/**
1010
* This class provides support for ID3v2 text information frames (section 4.2) covering `T000` to
@@ -419,65 +419,29 @@ export class TextInformationFrame extends Frame {
419419
// * (CR) - "Cover"
420420
// * (( - used to escape a "(" in a refinement/genre name
421421

422-
// NOTE: This encoding has an inherent flaw around how multiple genres should be
423-
// encoded. Since multiple genres are already an edge case, I'm just going to
424-
// say yolo to this whole block of code copied over from the .NET implementation
425-
while (value.length > 1 && value[0] === "(") {
426-
const closing = value.indexOf(")");
427-
if (closing < 0) {
428-
break;
422+
// Treat each term separately
423+
const terms = Id3v2Settings.useNonStandardV2V3GenreSeparators
424+
? value.split(/[;\/]/).filter(t => !!t)
425+
: [value];
426+
for (const term of terms) {
427+
// Attempt to process it according to our best understanding of the spec
428+
const numericGenres = this.parseTconAsStandardNumeric(term);
429+
if (numericGenres !== undefined) {
430+
fieldList.push(... numericGenres);
431+
continue;
429432
}
430433

431-
const number = value.substring(1, closing);
432-
433-
let text: string;
434-
if (number === TextInformationFrame.COVER_ABBREV) {
435-
text = TextInformationFrame.COVER_STRING;
436-
} else if (number === TextInformationFrame.REMIX_ABBREV) {
437-
text = TextInformationFrame.REMIX_STRING;
438-
} else {
439-
text = Genres.indexToAudio(number, true);
440-
}
441-
442-
if (!text) {
443-
// Number in parentheses was not a numeric genre but part of a larger bit
444-
// of text?
445-
break;
446-
}
447-
448-
// Number in parentheses was a numeric genre
449-
fieldList.push(text);
450-
value = StringUtils.trimStart(value.substring(closing + 1), "/ ");
451-
452-
// Ignore genre if the same genre appears after the numeric genre
453-
if (value.startsWith(text)) {
454-
value = StringUtils.trimStart(value.substring(text.length), "/ ");
455-
}
456-
}
457-
458-
// Process whatever's left
459-
if (value.length > 0) {
460-
// Split the remaining genre value by dividers if the setting is turned on
461-
let splitValue = Id3v2Settings.useNonStandardV2V3GenreSeparators
462-
? value.split(/[\/;]/).map((v) => v.trim()).filter((v) => !!v)
463-
: [value];
464-
465-
splitValue = splitValue.map((v) => {
466-
// Unescape escaped opening parenthesis
467-
let v2 = v.replace(/\(\(/, "(");
468-
469-
// If non-standard numeric genres is enabled, parse them
470-
if (Id3v2Settings.useNonStandardV2V3NumericGenres) {
471-
const text = Genres.indexToAudio(v2, false);
472-
if (text) {
473-
v2 = text;
474-
}
434+
if (Id3v2Settings.useNonStandardV2V3NumericGenres) {
435+
// Attempt to process it as a non-standard numeric genre
436+
const numericGenre = Genres.indexToAudioDirect(term);
437+
if (numericGenre !== undefined) {
438+
fieldList.push(numericGenre);
439+
continue;
475440
}
441+
}
476442

477-
return v2;
478-
});
479-
480-
fieldList.push(...splitValue);
443+
// Yeah, we can't do anything smart, just treat it as a string
444+
fieldList.push(term);
481445
}
482446
} else {
483447
fieldList.push(value);
@@ -558,24 +522,28 @@ export class TextInformationFrame extends Frame {
558522
const numericGenres = [];
559523
const textGenres = [];
560524
for (const s of text) {
561-
switch (s) {
562-
case TextInformationFrame.COVER_STRING:
563-
numericGenres.push(`(${TextInformationFrame.COVER_ABBREV})`);
564-
break;
565-
case TextInformationFrame.REMIX_STRING:
566-
numericGenres.push(`(${TextInformationFrame.REMIX_ABBREV})`);
567-
break;
568-
default:
569-
if (Id3v2Settings.useNumericGenres) {
525+
if (Id3v2Settings.useNumericGenres) {
526+
// Try to process it as a numeric genre
527+
switch (s) {
528+
case TextInformationFrame.COVER_STRING:
529+
numericGenres.push(`(${TextInformationFrame.COVER_ABBREV})`);
530+
continue;
531+
case TextInformationFrame.REMIX_STRING:
532+
numericGenres.push(`(${TextInformationFrame.REMIX_ABBREV})`);
533+
continue;
534+
default:
570535
const numericGenre = Genres.audioToIndex(s);
571536
if (numericGenre !== 255) {
572537
numericGenres.push(`(${numericGenre})`);
573-
break;
538+
continue;
574539
}
575-
}
576-
textGenres.push(s.replace(/\(/, "(("));
577-
break;
540+
break;
541+
}
578542
}
543+
544+
// Process it as a text genre
545+
const escapedGenre = s.replace(/\(/g, "((");
546+
textGenres.push(escapedGenre);
579547
}
580548

581549
// Put the entire string together
@@ -590,6 +558,101 @@ export class TextInformationFrame extends Frame {
590558
}
591559

592560
// #endregion
561+
562+
private parseTconAsStandardNumeric(field: string): string[]|undefined {
563+
// Don't even bother setting up the state machine if we aren't starting with an opening
564+
// parenthesis.
565+
if (field[0] !== "(") {
566+
return undefined;
567+
}
568+
569+
const results: string[] = [];
570+
let inParentheses = true;
571+
let refinementAdded = false;
572+
let open = 0;
573+
let close = 0;
574+
575+
const appendToLastResult = (chunk: string): void => {
576+
if (!chunk) {
577+
return;
578+
}
579+
580+
const lastResult = results[results.length - 1];
581+
results[results.length - 1] = refinementAdded
582+
? `${lastResult}${chunk}`
583+
: `${lastResult} ${chunk}`;
584+
}
585+
586+
for (let i = 1; i < field.length; i++) {
587+
if (inParentheses) {
588+
// Inside parentheses ----------------------------------
589+
if (field[i] === ")") {
590+
// Closing parenthesis found
591+
close = i;
592+
593+
// Attempt to parse the inside as a number
594+
const parenContents = field.substring(open + 1, close);
595+
const numericGenre = Genres.indexToAudioDirect(parenContents);
596+
if (numericGenre !== undefined) {
597+
results.push(numericGenre);
598+
} else if (parenContents === TextInformationFrame.COVER_ABBREV) {
599+
results.push(TextInformationFrame.COVER_STRING);
600+
} else if (parenContents === TextInformationFrame.REMIX_ABBREV) {
601+
results.push(TextInformationFrame.REMIX_STRING);
602+
} else {
603+
// What we expected to be a numeric genre was not. We will assume this
604+
// field is not using standard numeric genres, and dump the remainder.
605+
break;
606+
}
607+
608+
// Transition to refinement processing
609+
inParentheses = false;
610+
refinementAdded = false;
611+
open = i + 1;
612+
}
613+
614+
// If we didn't find the closing paren, just increment and try again.
615+
} else {
616+
// Processing refinement ------------------------------
617+
let refinementChunk: string;
618+
if (field[i] === "(") {
619+
if (field[i + 1] === "(") {
620+
// This is an escape sequence
621+
// Take the current refinement chunk + the first paren (eg: `xyz(`)
622+
refinementChunk = field.substring(open, i + 1);
623+
624+
625+
// Skip over the next character (ie, `(`)
626+
open = i + 2;
627+
i++;
628+
} else {
629+
// This is possibly the start of a numeric genre.
630+
// Take the current refinement chunk
631+
refinementChunk = field.substring(open, i);
632+
633+
// Transition back to numeric genre processing.
634+
inParentheses = true;
635+
open = i;
636+
}
637+
638+
// Add the refinement chunk to the last result
639+
appendToLastResult(refinementChunk);
640+
refinementAdded = true;
641+
}
642+
643+
// If we didn't find an opening paren, just increment and try again
644+
}
645+
}
646+
647+
// Process the remainder
648+
// If we didn't find any results, then just return undefined.
649+
if (results.length === 0) {
650+
return undefined;
651+
}
652+
653+
appendToLastResult(field.substring(open));
654+
return results;
655+
}
593656
}
594657

595658
export class UserTextInformationFrame extends TextInformationFrame {

0 commit comments

Comments
 (0)