/*
* Cuelib library for manipulating cue sheets.
* Copyright (C) 2007-2008 Jan-Willem van den Broek
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
package jwbroek.cuelib;
import jwbroek.io.FileSelector;
import jwbroek.util.LogUtil;
import java.io.*;
import java.util.*;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Parser for cue sheets.
*
* @author jwbroek
*/
final public class CueParser {
/**
* Logger for this class.
*/
private final static Logger logger = Logger.getLogger(CueParser.class.getCanonicalName());
// Constants for warning texts. Quick and dirty. Should really be a ResourceBundle.
private final static String WARNING_EMPTY_LINES = "Empty lines not allowed. Will ignore.";
private final static String WARNING_UNPARSEABLE_INPUT = "Unparseable line. Will ignore.";
private final static String WARNING_INVALID_CATALOG_NUMBER = "Invalid catalog number.";
private final static String WARNING_NONCOMPLIANT_FILE_TYPE = "Noncompliant file type.";
private final static String WARNING_NO_FLAGS = "No flags specified.";
private final static String WARNING_NONCOMPLIANT_FLAG = "Noncompliant flag(s) specified.";
private final static String WARNING_WRONG_NUMBER_OF_DIGITS = "Wrong number of digits in number.";
private final static String WARNING_NONCOMPLIANT_ISRC_CODE = "ISRC code has noncompliant format.";
private final static String WARNING_FIELD_LENGTH_OVER_80 =
"The field is too long to burn as CD-TEXT. The maximum length is 80.";
private final static String WARNING_NONCOMPLIANT_DATA_TYPE = "Noncompliant data type specified.";
private final static String WARNING_TOKEN_NOT_UPPERCASE = "Token has wrong case. Uppercase was expected.";
private final static String WARNING_INVALID_FRAMES_VALUE = "Position has invalid frame value. Should be 00-74.";
private final static String WARNING_INVALID_SECONDS_VALUE =
"Position has invalid seconds value. Should be 00-59.";
private final static String WARNING_DATUM_APPEARS_TOO_OFTEN = "Datum appears too often.";
private final static String WARNING_FILE_IN_WRONG_PLACE =
"A FILE datum must come before everything else except REM and CATALOG.";
private final static String WARNING_FLAGS_IN_WRONG_PLACE =
"A FLAGS datum must come after a TRACK, but before any INDEX of that TRACK.";
private final static String WARNING_NO_FILE_SPECIFIED =
"Datum must appear in FILE, but no FILE specified.";
private final static String WARNING_NO_TRACK_SPECIFIED =
"Datum must appear in TRACK, but no TRACK specified.";
private final static String WARNING_INVALID_INDEX_NUMBER =
"Invalid index number. First number must be 0 or 1; all next ones sequential.";
private final static String WARNING_INVALID_FIRST_POSITION =
"Invalid position. First index must have position 00:00:00";
private final static String WARNING_ISRC_IN_WRONG_PLACE =
"An ISRC datum must come after TRACK, but before any INDEX of TRACK.";
private final static String WARNING_PREGAP_IN_WRONG_PLACE =
"A PREGAP datum must come after TRACK, but before any INDEX of that TRACK.";
private final static String WARNING_INDEX_AFTER_POSTGAP =
"A POSTGAP datum must come after all INDEX data of a TRACK.";
private final static String WARNING_INVALID_TRACK_NUMBER =
"Invalid track number. First number must be 1; all next ones sequential.";
private final static String WARNING_INVALID_YEAR =
"Invalid year. Should be a number from 1 to 9999 (inclusive).";
// Patterns used for parsing and validation. Quick and dirty. A formal grammar would be nicer.
private final static Pattern PATTERN_POSITION = Pattern.compile("^(\\d*):(\\d*):(\\d*)$");
private final static Pattern PATTERN_CATALOG_NUMBER = Pattern.compile("^\\d{13}$");
private final static Pattern PATTERN_FILE = Pattern.compile
("^FILE\\s+((?:\"[^\"]*\")|\\S+)\\s+(\\S+)\\s*$", Pattern.CASE_INSENSITIVE);
private final static Pattern PATTERN_CDTEXTFILE = Pattern.compile
("^CDTEXTFILE\\s+((?:\"[^\"]*\")|\\S+)\\s*$", Pattern.CASE_INSENSITIVE);
private final static Pattern PATTERN_FLAGS = Pattern.compile
("^FLAGS(\\s+\\w+)*\\s*$", Pattern.CASE_INSENSITIVE);
private final static Pattern PATTERN_INDEX = Pattern.compile
("^INDEX\\s+(\\d+)\\s+(\\d*:\\d*:\\d*)\\s*$", Pattern.CASE_INSENSITIVE);
private final static Pattern PATTERN_ISRC_CODE = Pattern.compile("^\\w{5}\\d{7}$");
private final static Pattern PATTERN_PERFORMER = Pattern.compile
("^PERFORMER\\s+((?:\"[^\"]*\")|\\S+)\\s*$", Pattern.CASE_INSENSITIVE);
private final static Pattern PATTERN_POSTGAP = Pattern.compile
("^POSTGAP\\s+(\\d*:\\d*:\\d*)\\s*$", Pattern.CASE_INSENSITIVE);
private final static Pattern PATTERN_PREGAP = Pattern.compile
("^PREGAP\\s+(\\d*:\\d*:\\d*)\\s*$", Pattern.CASE_INSENSITIVE);
private final static Pattern PATTERN_REM_COMMENT = Pattern.compile
("^(REM\\s+COMMENT)\\s+((?:\"[^\"]*\")|\\S+)\\s*$", Pattern.CASE_INSENSITIVE);
private final static Pattern PATTERN_REM_DATE = Pattern.compile
("^(REM\\s+DATE)\\s+(\\d+)\\s*$", Pattern.CASE_INSENSITIVE);
private final static Pattern PATTERN_REM_DISCID = Pattern.compile
("^(REM\\s+DISCID)\\s+((?:\"[^\"]*\")|\\S+)\\s*$", Pattern.CASE_INSENSITIVE);
private final static Pattern PATTERN_REM_GENRE = Pattern.compile
("^(REM\\s+GENRE)\\s+((?:\"[^\"]*\")|\\S+)\\s*$", Pattern.CASE_INSENSITIVE);
private final static Pattern PATTERN_SONGWRITER = Pattern.compile
("^SONGWRITER\\s+((?:\"[^\"]*\")|\\S+)\\s*$", Pattern.CASE_INSENSITIVE);
private final static Pattern PATTERN_TITLE = Pattern.compile
("^TITLE\\s+((?:\"[^\"]*\")|\\S+)\\s*$", Pattern.CASE_INSENSITIVE);
private final static Pattern PATTERN_TRACK = Pattern.compile
("TRACK\\s+(\\d+)\\s+(\\S+)\\s*$", Pattern.CASE_INSENSITIVE);
/**
* A set of all file types that are allowed by the cue sheet spec.
*/
private final static Set<String> COMPLIANT_FILE_TYPES = new TreeSet<String>
(Arrays.asList(
"BINARY"
, "MOTOROLA"
, "AIFF"
, "WAVE"
, "MP3")
);
/**
* A set of all flags that are allowed by the cue sheet spec.
*/
private final static Set<String> COMPLIANT_FLAGS = new TreeSet<String>
(Arrays.asList(
"DCP"
, "4CH"
, "PRE"
, "SCMS"
, "DATA")
);
/**
* A set of all data types that are allowed by the cue sheet spec.
*/
private final static Set<String> COMPLIANT_DATA_TYPES = new TreeSet<String>
(Arrays.asList(
"AUDIO"
, "CDG"
, "MODE1/2048"
, "MODE1/2352"
, "MODE2/2336"
, "MODE2/2352"
, "CDI/2336"
, "CDI/2352")
);
/**
* Create a CueParser. Should never be used, as all properties and methods of this class are static.
*/
private CueParser() {
// Intentionally left blank (besides logging). This class doesn't need to be instantiated.
CueParser.logger.entering(FileSelector.class.getCanonicalName(), "FileSelector(File)");
CueParser.logger.warning("jwbroek.cuelib.CueParser should not be initialized");
CueParser.logger.exiting(FileSelector.class.getCanonicalName(), "FileSelector(File)");
}
/**
* Parse a cue sheet that will be read from the InputStream.
*
* @param inputStream An {@link java.io.InputStream} that produces a cue sheet. The stream will be closed
* afterward.
* @return A representation of the cue sheet.
* @throws IOException
*/
public static CueSheet parse(final InputStream inputStream) throws IOException {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parse(InputStream)", inputStream);
final CueSheet result = CueParser.parse(new LineNumberReader(new InputStreamReader(inputStream)));
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parse(InputStream)", result);
return result;
}
/**
* Parse a cue sheet file.
*
* @param file A cue sheet file.
* @return A representation of the cue sheet.
* @throws IOException
*/
public static CueSheet parse(final File file) throws IOException {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parse(File)", file);
final CueSheet result = CueParser.parse(new LineNumberReader(new FileReader(file)));
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parse(File)", result);
return result;
}
/**
* Parse a cue sheet.
*
* @param reader A reader for the cue sheet. This reader will be closed afterward.
* @return A representation of the cue sheet.
* @throws IOException
*/
public static CueSheet parse(final LineNumberReader reader) throws IOException {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parse(LineNumberReader)", reader);
CueParser.logger.fine("Parsing cue sheet.");
final CueSheet result = new CueSheet();
try {
// Go through all lines of input.
String inputLine = reader.readLine();
while (inputLine != null) {
CueParser.logger.finest("Processing input line.");
// Normalize by removing left and right whitespace.
inputLine = inputLine.trim();
final LineOfInput input = new LineOfInput(reader.getLineNumber(), inputLine, result);
// Do some validation. If there are no problems, then parse the line.
if (inputLine.length() == 0) {
// File should not contain empty lines.
addWarning(input, WARNING_EMPTY_LINES);
} else if (inputLine.length() < 2) {
// No token in the spec has length smaller than 2. Unknown token.
addWarning(input, WARNING_UNPARSEABLE_INPUT);
} else {
// Use first 1-2 characters to guide parsing. These two characters are enough to determine how to
// proceed.
switch (inputLine.charAt(0)) {
case 'c':
case 'C':
switch (inputLine.charAt(1)) {
case 'a':
case 'A':
CueParser.parseCatalog(input);
break;
case 'd':
case 'D':
CueParser.parseCdTextFile(input);
break;
default:
addWarning(input, WARNING_UNPARSEABLE_INPUT);
break;
}
break;
case 'f':
case 'F':
switch (inputLine.charAt(1)) {
case 'i':
case 'I':
CueParser.parseFile(input);
break;
case 'l':
case 'L':
CueParser.parseFlags(input);
break;
default:
addWarning(input, WARNING_UNPARSEABLE_INPUT);
break;
}
break;
case 'i':
case 'I':
switch (inputLine.charAt(1)) {
case 'n':
case 'N':
CueParser.parseIndex(input);
break;
case 's':
case 'S':
CueParser.parseIsrc(input);
break;
default:
addWarning(input, WARNING_UNPARSEABLE_INPUT);
break;
}
break;
case 'p':
case 'P':
switch (inputLine.charAt(1)) {
case 'e':
case 'E':
CueParser.parsePerformer(input);
break;
case 'o':
case 'O':
CueParser.parsePostgap(input);
break;
case 'r':
case 'R':
CueParser.parsePregap(input);
break;
default:
addWarning(input, WARNING_UNPARSEABLE_INPUT);
break;
}
break;
case 'r':
case 'R':
CueParser.parseRem(input);
break;
case 's':
case 'S':
CueParser.parseSongwriter(input);
break;
case 't':
case 'T':
switch (inputLine.charAt(1)) {
case 'i':
case 'I':
CueParser.parseTitle(input);
break;
case 'r':
case 'R':
CueParser.parseTrack(input);
break;
default:
addWarning(input, WARNING_UNPARSEABLE_INPUT);
break;
}
break;
default:
addWarning(input, WARNING_UNPARSEABLE_INPUT);
break;
}
}
// And on to the next line...
inputLine = reader.readLine();
}
}
finally {
CueParser.logger.finest("Closing input reader.");
reader.close();
}
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parse(LineNumberReader)", result);
return result;
}
/**
* Determine if the input starts with some string. Will return true if it matches, regardless of case. If there is
* a match, but the case differs, then a "TOKEN NOT UPPERCASE" warning will be added to the cue sheet associated
* with the input.
*
* @param input The input to check.
* @param start The starting string to check for. Should be uppercase, or else the warning will not make sense.
* @return True if there is a match. False otherwise.
*/
private static boolean startsWith(final LineOfInput input, final String start) {
CueParser.logger.entering
(CueParser.class.getCanonicalName(), "startsWith(LineOfInput,String)", new Object[]{input, start});
if (input.getInput().startsWith(start)) {
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "startsWith(LineOfInput,String)", true);
return true;
} else if (input.getInput().substring(0, start.length()).equalsIgnoreCase(start)) {
addWarning(input, WARNING_TOKEN_NOT_UPPERCASE);
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "startsWith(LineOfInput,String)", true);
return true;
} else {
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "startsWith(LineOfInput,String)", false);
return false;
}
}
/**
* Determine if the input contains the specified pattern. Will return true if it matches. If there is a match and
* and there is a capturing group, then the first such group will be checked for case. If it is not uppercase,
* then a "TOKEN NOT UPPERCASE" warning will be added to the cue sheet associated with the input.
*
* @param input The input to check.
* @param pattern {@link java.util.regex.Pattern} to check for. If it contains a capturing group, then on a match,
* this group will be
* checked for case as per the method description.
* @return True if there is a match. False otherwise.
*/
private static boolean contains(final LineOfInput input, final Pattern pattern) {
CueParser.logger.entering
(CueParser.class.getCanonicalName(), "contains(LineOfInput,Pattern)", new Object[]{input, pattern});
final Matcher matcher = pattern.matcher(input.getInput());
if (matcher.find()) {
if (matcher.groupCount() > 0 && !matcher.group(1).equals(matcher.group(1).toUpperCase())) {
addWarning(input, WARNING_TOKEN_NOT_UPPERCASE);
}
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "contains(LineOfInput,Pattern)", true);
return true;
} else {
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "contains(LineOfInput,Pattern)", false);
return false;
}
}
/**
* Parse the CATALOG command.
* <p/>
* CATALOG [media-catalog-number]
* CD catalog number. Code follows UPC/EAN rules.
* Usually the first command, but this is not required. Not a mandatory command.
*
* @param input
*/
private static void parseCatalog(final LineOfInput input) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parseCatalog(LineOfInput)", input);
if (startsWith(input, "CATALOG")) {
String catalogNumber = input.getInput().substring("CATALOG".length()).trim();
if (!PATTERN_CATALOG_NUMBER.matcher(catalogNumber).matches()) {
addWarning(input, WARNING_INVALID_CATALOG_NUMBER);
}
if (input.getAssociatedSheet().getCatalog() != null) {
addWarning(input, WARNING_DATUM_APPEARS_TOO_OFTEN);
}
input.getAssociatedSheet().setCatalog(catalogNumber);
} else {
addWarning(input, WARNING_UNPARSEABLE_INPUT);
}
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parseCatalog(LineOfInput)");
}
/**
* Parse the FILE command.
* <p/>
* FILE [filename] [filetype]
* File containing data.
* According to the spec it must come before every other command except CATALOG. This rule
* contradicts the official examples and is often broken in practice. Hence, we don't raise
* a warning when this rule is broken.
*
* @param input
*/
private static void parseFile(final LineOfInput input) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parseFile(LineOfInput)", input);
Matcher fileMatcher = PATTERN_FILE.matcher(input.getInput());
if (startsWith(input, "FILE") && fileMatcher.matches()) {
if (!COMPLIANT_FILE_TYPES.contains(fileMatcher.group(2))) {
if (COMPLIANT_FILE_TYPES.contains(fileMatcher.group(2).toUpperCase())) {
addWarning(input, WARNING_TOKEN_NOT_UPPERCASE);
} else {
addWarning(input, WARNING_NONCOMPLIANT_FILE_TYPE);
}
}
/*
* This is a silly rule that is very commonly broken. Hence, we don't enforce it.
*
* Check to see if FILE is the first command in the sheet, except for CATALOG. (Technically, we should
* also check for REM commands, but we don't keep track of all of those.)
*
if ( input.getAssociatedSheet().getFileData().size()==0
&& ( input.getAssociatedSheet().getCdTextFile() != null
|| input.getAssociatedSheet().getPerformer() != null
|| input.getAssociatedSheet().getSongwriter() != null
|| input.getAssociatedSheet().getTitle() != null
|| input.getAssociatedSheet().getComment() != null
|| input.getAssociatedSheet().getDiscid() != null
|| input.getAssociatedSheet().getYear() != -1
|| input.getAssociatedSheet().getGenre() != null
)
)
{
CueParser.logger.warning(WARNING_FILE_IN_WRONG_PLACE);
input.getAssociatedSheet().addWarning(input, WARNING_FILE_IN_WRONG_PLACE);
}
*/
// If the file name is enclosed in quotes, remove those.
String file = fileMatcher.group(1);
if (file.length() > 0 && file.charAt(0) == '"' && file.charAt(file.length() - 1) == '"') {
file = file.substring(1, file.length() - 1);
}
input.getAssociatedSheet().getFileData().add(new FileData(input.getAssociatedSheet()
, file
, fileMatcher.group(2).toUpperCase()
)
);
} else {
addWarning(input, WARNING_UNPARSEABLE_INPUT);
}
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parseFile(LineOfInput)");
}
/**
* Parse the CDTEXTFILE command.
* <p/>
* CDTEXTFILE [filename]
* File that contains cd text data. Not mandatory.
*
* @param input
*/
private static void parseCdTextFile(final LineOfInput input) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parseCdTextFile(LineOfInput)", input);
Matcher cdTextFileMatcher = PATTERN_CDTEXTFILE.matcher(input.getInput());
if (startsWith(input, "CDTEXTFILE") && cdTextFileMatcher.matches()) {
if (input.getAssociatedSheet().getCdTextFile() != null) {
CueParser.logger.warning(WARNING_DATUM_APPEARS_TOO_OFTEN);
input.getAssociatedSheet().addWarning(input, WARNING_DATUM_APPEARS_TOO_OFTEN);
}
// If the file name is enclosed in quotes, remove those.
String file = cdTextFileMatcher.group(1);
if (file.length() > 0 && file.charAt(0) == '"' && file.charAt(file.length() - 1) == '"') {
file = file.substring(1, file.length() - 1);
}
input.getAssociatedSheet().setCdTextFile(file);
} else {
addWarning(input, WARNING_UNPARSEABLE_INPUT);
}
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parseCdTextFile(LineOfInput)");
}
/**
* Parse the FLAGS command.
* <p/>
* FLAGS [flags]
* Track subcode flags. Rarely used according to spec.
*
* @param input
*/
private static void parseFlags(final LineOfInput input) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parseFlags(LineOfInput)", input);
Matcher flagsMatcher = PATTERN_FLAGS.matcher(input.getInput());
if (startsWith(input, "FLAGS") && flagsMatcher.matches()) {
if (null == flagsMatcher.group(1)) {
addWarning(input, WARNING_NO_FLAGS);
} else {
TrackData trackData = getLastTrackData(input);
if (trackData.getIndices().size() > 0) {
addWarning(input, WARNING_FLAGS_IN_WRONG_PLACE);
}
Set<String> flagCollection = trackData.getFlags();
if (!flagCollection.isEmpty()) {
addWarning(input, WARNING_DATUM_APPEARS_TOO_OFTEN);
}
Scanner flagScanner = new Scanner(flagsMatcher.group(1));
while (flagScanner.hasNext()) {
String flag = flagScanner.next();
if (!COMPLIANT_FLAGS.contains(flag)) {
addWarning(input, WARNING_NONCOMPLIANT_FLAG);
}
flagCollection.add(flag);
}
}
} else {
addWarning(input, WARNING_UNPARSEABLE_INPUT);
}
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parseFlags(LineOfInput)");
}
/**
* Parse the INDEX command.
* <p/>
* INDEX [number] [mm:ss:ff]
* Indexes or subindexes within a track. Relative w.r.t. beginning of file.
* <p/>
* ff = frames; 75 frames/s
* First index must be 0 or 1. All others sequential. First index must be 00:00:00
* 0 is track pregap.
* 1 is starting time of track data.
* > 1 is subindex within track.
*
* @param input
*/
private static void parseIndex(final LineOfInput input) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parseIndex(LineOfInput)", input);
Matcher indexMatcher = PATTERN_INDEX.matcher(input.getInput());
if (startsWith(input, "INDEX") && indexMatcher.matches()) {
if (indexMatcher.group(1).length() != 2) {
addWarning(input, WARNING_WRONG_NUMBER_OF_DIGITS);
}
TrackData trackData = getLastTrackData(input);
List<Index> trackIndices = trackData.getIndices();
// Postgap data must come after all index data. Only check for first index. No need to repeat this warning for
// all indices that follow.
if (trackIndices.isEmpty() && trackData.getPostgap() != null) {
addWarning(input, WARNING_INDEX_AFTER_POSTGAP);
}
int indexNumber = Integer.parseInt(indexMatcher.group(1));
// If first index of track, then number must be 0 or 1; if not first index of track, then number must be 1
// higher than last one.
if (trackIndices.isEmpty() && indexNumber > 1
|| !trackIndices.isEmpty() && trackIndices.get(trackIndices.size() - 1).getNumber() != indexNumber - 1
) {
addWarning(input, WARNING_INVALID_INDEX_NUMBER);
}
List<Index> fileIndices = getLastFileData(input).getAllIndices();
Position position = parsePosition(input, indexMatcher.group(2));
// Position of first index of file must be 00:00:00.
if (fileIndices.isEmpty()
&& !(position.getMinutes() == 0
&& position.getSeconds() == 0
&& position.getFrames() == 0
)
) {
addWarning(input, WARNING_INVALID_FIRST_POSITION);
}
trackIndices.add(new Index(indexNumber, position));
} else {
addWarning(input, WARNING_UNPARSEABLE_INPUT);
}
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parseIndex(LineOfInput)");
}
/**
* Parse the ISRC command.
* <p/>
* ISRC [code]
* International Standard Recording Code of track. Must come after TRACK, but before INDEX.
*
* @param input
*/
private static void parseIsrc(final LineOfInput input) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parseIsrc(LineOfInput)", input);
if (startsWith(input, "ISRC")) {
String isrcCode = input.getInput().substring("ISRC".length()).trim();
if (!PATTERN_ISRC_CODE.matcher(isrcCode).matches()) {
addWarning(input, WARNING_NONCOMPLIANT_ISRC_CODE);
}
TrackData trackData = getLastTrackData(input);
if (trackData.getIndices().size() > 0) {
addWarning(input, WARNING_ISRC_IN_WRONG_PLACE);
}
if (trackData.getIsrcCode() != null) {
addWarning(input, WARNING_DATUM_APPEARS_TOO_OFTEN);
}
trackData.setIsrcCode(isrcCode);
} else {
addWarning(input, WARNING_UNPARSEABLE_INPUT);
}
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parseIsrc(LineOfInput)");
}
/**
* Parse the PERFORMER command.
* <p/>
* PERFORMER [performer-string]
* Performer of album/TRACK.
* <p/>
* [performer-string] should be <= 80 character if you want to burn it to disc.
* If used before any TRACK fields, then it is the album artist. If after a TRACK field, then
* it is the performer of that track.
*
* @param input
*/
private static void parsePerformer(final LineOfInput input) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parsePerformer(LineOfInput)", input);
Matcher performerMatcher = PATTERN_PERFORMER.matcher(input.getInput());
if (startsWith(input, "PERFORMER") && performerMatcher.matches()) {
String performer = performerMatcher.group(1);
if (performer.charAt(0) == '\"') {
performer = performer.substring(1, performer.length() - 1);
}
if (performer.length() > 80) {
addWarning(input, WARNING_FIELD_LENGTH_OVER_80);
}
// First check file data, as getLastFileData will create a FileData instance if there is none
// and we don't actually want to create such an instance.
if (input.getAssociatedSheet().getFileData().size() == 0
|| getLastFileData(input).getTrackData().size() == 0
) {
// Performer of album.
if (input.getAssociatedSheet().getPerformer() != null) {
addWarning(input, WARNING_DATUM_APPEARS_TOO_OFTEN);
}
input.getAssociatedSheet().setPerformer(performer);
} else {
// Performer of track.
TrackData trackData = getLastTrackData(input);
if (trackData.getPerformer() != null) {
addWarning(input, WARNING_DATUM_APPEARS_TOO_OFTEN);
}
trackData.setPerformer(performer);
}
} else {
addWarning(input, WARNING_UNPARSEABLE_INPUT);
}
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parsePerformer(LineOfInput)");
}
/**
* Parse the POSTGAP command.
* <p/>
* POSTGAP [mm:ss:ff]
* Must come after all INDEX fields for a track. Only one per track allowed.
*
* @param input
*/
private static void parsePostgap(final LineOfInput input) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parsePostgap(LineOfInput)", input);
Matcher postgapMatcher = PATTERN_POSTGAP.matcher(input.getInput());
if (startsWith(input, "POSTGAP") && postgapMatcher.matches()) {
TrackData trackData = getLastTrackData(input);
if (trackData.getPostgap() != null) {
addWarning(input, WARNING_DATUM_APPEARS_TOO_OFTEN);
}
trackData.setPostgap(parsePosition(input, postgapMatcher.group(1)));
} else {
addWarning(input, WARNING_UNPARSEABLE_INPUT);
}
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parsePostgap(LineOfInput)");
}
/**
* Parse the PREGAP command.
* <p/>
* PREGAP [mm:ss:ff]
* Must come after TRACK, but before INDEX fields for that track.
*
* @param input
*/
private static void parsePregap(final LineOfInput input) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parsePregap(LineOfInput)", input);
Matcher pregapMatcher = PATTERN_PREGAP.matcher(input.getInput());
if (startsWith(input, "PREGAP") && pregapMatcher.matches()) {
TrackData trackData = getLastTrackData(input);
if (trackData.getPregap() != null) {
addWarning(input, WARNING_DATUM_APPEARS_TOO_OFTEN);
}
if (trackData.getIndices().size() > 0) {
addWarning(input, WARNING_PREGAP_IN_WRONG_PLACE);
}
trackData.setPregap(parsePosition(input, pregapMatcher.group(1)));
} else {
addWarning(input, WARNING_UNPARSEABLE_INPUT);
}
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parsePregap(LineOfInput)");
}
/**
* Parse the non-standard REM COMMENT command.
* <p/>
* REM COMMENT [comment]
*
* @param input
*/
private static void parseRemComment(final LineOfInput input) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parseRemComment(LineOfInput)", input);
Matcher matcher = PATTERN_REM_COMMENT.matcher(input.getInput());
if (matcher.find()) {
String comment = matcher.group(2);
if (comment.charAt(0) == '"' && comment.charAt(comment.length() - 1) == '"') {
comment = comment.substring(1, comment.length() - 1);
}
input.getAssociatedSheet().setComment(comment);
} else {
addWarning(input, WARNING_UNPARSEABLE_INPUT);
}
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parseRemComment(LineOfInput)");
}
/**
* Parse the non-standard REM DATE command.
* <p/>
* REM DATE [year]
*
* @param input
*/
private static void parseRemDate(final LineOfInput input) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parseRemDate(LineOfInput)", input);
Matcher matcher = PATTERN_REM_DATE.matcher(input.getInput());
if (matcher.find()) {
int year = Integer.parseInt(matcher.group(2));
if (year < 1 || year > 9999) {
addWarning(input, WARNING_INVALID_YEAR);
}
input.getAssociatedSheet().setYear(year);
} else {
addWarning(input, WARNING_UNPARSEABLE_INPUT);
}
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parseRemDate(LineOfInput)");
}
/**
* Parse the non-standard REM DISCID command.
* <p/>
* REM DISCID [discid]
*
* @param input
*/
private static void parseRemDiscid(final LineOfInput input) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parseRemDiscid(LineOfInput)", input);
Matcher matcher = PATTERN_REM_DISCID.matcher(input.getInput());
if (matcher.find()) {
String discid = matcher.group(2);
if (discid.charAt(0) == '"' && discid.charAt(discid.length() - 1) == '"') {
discid = discid.substring(1, discid.length() - 1);
}
input.getAssociatedSheet().setDiscid(discid);
} else {
addWarning(input, WARNING_UNPARSEABLE_INPUT);
}
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parseRemDiscid(LineOfInput)");
}
/**
* Parse the non-standard REM GENRE command.
* <p/>
* REM GENRE [genre]
*
* @param input
*/
private static void parseRemGenre(final LineOfInput input) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parseRemGenre(LineOfInput)", input);
Matcher matcher = PATTERN_REM_GENRE.matcher(input.getInput());
if (matcher.find()) {
String genre = matcher.group(2);
if (genre.charAt(0) == '"' && genre.charAt(genre.length() - 1) == '"') {
genre = genre.substring(1, genre.length() - 1);
}
input.getAssociatedSheet().setGenre(genre);
} else {
addWarning(input, WARNING_UNPARSEABLE_INPUT);
}
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parseRemGenre(LineOfInput)");
}
/**
* Parse the REM command. Will also parse a number of non-standard commands used by Exact Audio Copy.
* <p/>
* REM [comment]
* <p/>
* Or the non-standard commands:
* <p/>
* REM COMMENT [comment]
* REM DATE [year]
* REM DISCID [discid]
* REM GENRE [genre]
*
* @param input
*/
private static void parseRem(final LineOfInput input) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parseRem(LineOfInput)", input);
if (startsWith(input, "REM")) {
// This is a comment, but popular implementation like Exact Audio Copy may still
// embed information here. We'll try to parse this, but we'll silently accept anything.
// There will be no warnings or errors, except for case mismatches.
String comment = input.getInput().substring("REM".length()).trim();
switch (comment.charAt(0)) {
case 'c':
case 'C':
if (contains(input, PATTERN_REM_COMMENT)) {
parseRemComment(input);
}
break;
case 'd':
case 'D':
if (contains(input, PATTERN_REM_DATE)) {
parseRemDate(input);
} else if (contains(input, PATTERN_REM_DISCID)) {
parseRemDiscid(input);
}
break;
case 'g':
case 'G':
if (contains(input, PATTERN_REM_GENRE)) {
parseRemGenre(input);
}
break;
}
} else {
addWarning(input, WARNING_UNPARSEABLE_INPUT);
}
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parseRem(LineOfInput)");
}
/**
* Parse the SONGWRITER command.
* <p/>
* SONGWRITER [songwriter-string]
* Songwriter of CD/TRACK.
* [songwriter-string] should be <= 80 character if you want to burn it to disc.
* If used before any TRACK fields, then it is the album writer. If after a TRACK field, then
* it is the writer of that track.
*
* @param input
*/
private static void parseSongwriter(final LineOfInput input) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parseSongwriter(LineOfInput)", input);
Matcher songwriterMatcher = PATTERN_SONGWRITER.matcher(input.getInput());
if (startsWith(input, "SONGWRITER") && songwriterMatcher.matches()) {
String songwriter = songwriterMatcher.group(1);
if (songwriter.charAt(0) == '\"') {
songwriter = songwriter.substring(1, songwriter.length() - 1);
}
if (songwriter.length() > 80) {
addWarning(input, WARNING_FIELD_LENGTH_OVER_80);
}
// First check file data, as getLastFileData will create a FileData instance if there is none
// and we don't actually want to create such an instance.
if (input.getAssociatedSheet().getFileData().size() == 0
|| getLastFileData(input).getTrackData().size() == 0
) {
// Songwriter of album.
if (input.getAssociatedSheet().getSongwriter() != null) {
addWarning(input, WARNING_DATUM_APPEARS_TOO_OFTEN);
}
input.getAssociatedSheet().setSongwriter(songwriter);
} else {
// Songwriter of track.
TrackData trackData = getLastTrackData(input);
if (trackData.getSongwriter() != null) {
addWarning(input, WARNING_DATUM_APPEARS_TOO_OFTEN);
}
trackData.setSongwriter(songwriter);
}
} else {
addWarning(input, WARNING_UNPARSEABLE_INPUT);
}
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parseSongwriter(LineOfInput)");
}
/**
* Parse the TITLE command.
* <p/>
* TITLE [title-string]
* Title of CD/TRACK.
* [title-string] should be <= 80 character if you want to burn it to disc.
* If used before any TRACK fields, then it is the album title. If after a TRACK field, then
* it is the title of that track.
*
* @param input
*/
private static void parseTitle(final LineOfInput input) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parseTitle(LineOfInput)", input);
Matcher titleMatcher = PATTERN_TITLE.matcher(input.getInput());
if (startsWith(input, "TITLE") && titleMatcher.matches()) {
String title = titleMatcher.group(1);
if (title.charAt(0) == '\"') {
title = title.substring(1, title.length() - 1);
}
if (title.length() > 80) {
addWarning(input, WARNING_FIELD_LENGTH_OVER_80);
}
// First check file data, as getLastFileData will create a FileData instance if there is none
// and we don't actually want to create such an instance.
if (input.getAssociatedSheet().getFileData().size() == 0
|| getLastFileData(input).getTrackData().size() == 0
) {
// Title of album.
if (input.getAssociatedSheet().getTitle() != null) {
addWarning(input, WARNING_DATUM_APPEARS_TOO_OFTEN);
}
input.getAssociatedSheet().setTitle(title);
} else {
// Title of track.
TrackData trackData = getLastTrackData(input);
if (trackData.getTitle() != null) {
addWarning(input, WARNING_DATUM_APPEARS_TOO_OFTEN);
}
trackData.setTitle(title);
}
} else {
addWarning(input, WARNING_UNPARSEABLE_INPUT);
}
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parseTitle(LineOfInput)");
}
/**
* Parse the TRACK command.
* <p/>
* TRACK [number] [datatype]
* Beginning of track data.
* First track number may be > 1, but all others must be sequential. Allowed are 1-99 inclusive.
* <p/>
* Modes recognized by the spec. (Others will be parsed, but will also cause a warning to be
* raised.)
* AUDIO - Audio/Music (2352)
* CDG - Karaoke CD+G (2448)
* MODE1/2048 - CDROM Mode1 Data (cooked)
* MODE1/2352 - CDROM Mode1 Data (raw)
* MODE2/2336 - CDROM-XA Mode2 Data
* MODE2/2352 - CDROM-XA Mode2 Data
* CDI/2336 - CDI Mode2 Data
* CDI/2352 - CDI Mode2 Data
*
* @param input
*/
private static void parseTrack(final LineOfInput input) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parseTrack(LineOfInput)", input);
Matcher trackMatcher = PATTERN_TRACK.matcher(input.getInput());
if (startsWith(input, "TRACK") && trackMatcher.matches()) {
if (trackMatcher.group(1).length() != 2) {
addWarning(input, WARNING_WRONG_NUMBER_OF_DIGITS);
}
int trackNumber = Integer.parseInt(trackMatcher.group(1));
String dataType = trackMatcher.group(2);
if (!COMPLIANT_DATA_TYPES.contains(dataType)) {
addWarning(input, WARNING_NONCOMPLIANT_DATA_TYPE);
}
List<TrackData> trackDataList = input.getAssociatedSheet().getAllTrackData();
// First track must have number 1; all next ones sequential.
if (trackDataList.isEmpty() && trackNumber != 1
|| !trackDataList.isEmpty() && trackDataList.get(trackDataList.size() - 1).getNumber() != trackNumber - 1
) {
addWarning(input, WARNING_INVALID_TRACK_NUMBER);
}
FileData lastFileData = getLastFileData(input);
lastFileData.getTrackData().add(new TrackData(lastFileData, trackNumber, dataType));
} else {
addWarning(input, WARNING_UNPARSEABLE_INPUT);
}
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parseTrack(LineOfInput)");
}
/**
* Parse a position, as used by several commands.
* <p/>
* [mm:ss:ff]
* mm = minutes
* ss = seconds
* ff = frames (75 per second)
*
* @param input
*/
private static Position parsePosition(final LineOfInput input, final String position) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "parsePosition(LineOfInput)", input);
Matcher positionMatcher = PATTERN_POSITION.matcher(position);
if (positionMatcher.matches()) {
String minutesString = positionMatcher.group(1);
String secondsString = positionMatcher.group(2);
String framesString = positionMatcher.group(3);
int minutes = Integer.parseInt(minutesString);
int seconds = Integer.parseInt(secondsString);
int frames = Integer.parseInt(framesString);
if (!(minutesString.length() == 2
&& secondsString.length() == 2
&& framesString.length() == 2
)
) {
addWarning(input, WARNING_WRONG_NUMBER_OF_DIGITS);
}
if (seconds > 59) {
addWarning(input, WARNING_INVALID_SECONDS_VALUE);
}
if (frames > 74) {
addWarning(input, WARNING_INVALID_FRAMES_VALUE);
}
Position result = new Position(minutes, seconds, frames);
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parsePosition(LineOfInput)", result);
return result;
} else {
addWarning(input, WARNING_UNPARSEABLE_INPUT);
Position result = new Position();
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "parsePosition(LineOfInput)", result);
return result;
}
}
/**
* Get the last {@link jwbroek.cuelib.TrackData} element. If none exist, an empty one is created and a warning
* added.
*
* @param input
* @return The last {@link jwbroek.cuelib.TrackData} element. If none exist, an empty one is created and a
* warning added.
*/
private static TrackData getLastTrackData(final LineOfInput input) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "getLastTrackData(LineOfInput)", input);
FileData lastFileData = getLastFileData(input);
List<TrackData> trackDataList = lastFileData.getTrackData();
if (trackDataList.size() == 0) {
trackDataList.add(new TrackData(lastFileData));
addWarning(input, WARNING_NO_TRACK_SPECIFIED);
}
TrackData result = trackDataList.get(trackDataList.size() - 1);
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "getLastTrackData(LineOfInput)", result);
return result;
}
/**
* Get the last {@link jwbroek.cuelib.FileData} element. If none exist, an empty one is created and a warning
* added.
*
* @param input
* @return The last {@link jwbroek.cuelib.FileData} element. If none exist, an empty one is created and a warning
* added.
*/
private static FileData getLastFileData(final LineOfInput input) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "getLastFileData(LineOfInput)", input);
List<FileData> fileDataList = input.getAssociatedSheet().getFileData();
if (fileDataList.size() == 0) {
fileDataList.add(new FileData(input.getAssociatedSheet()));
addWarning(input, WARNING_NO_FILE_SPECIFIED);
}
FileData result = fileDataList.get(fileDataList.size() - 1);
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "getLastFileData(LineOfInput)", result);
return result;
}
/**
* Write a warning to the logging and the {@link jwbroek.cuelib.CueSheet} associated with the
* {@link jwbroek.cuelib.LineOfInput}.
*
* @param input The {@link jwbroek.cuelib.LineOfInput} the warning pertains to.
* @param warning The warning to write.
*/
private static void addWarning(final LineOfInput input, final String warning) {
CueParser.logger.warning(warning);
input.getAssociatedSheet().addWarning(input, warning);
}
/**
* Parse all .cue files in the user's working directory and print any warnings to standard out.
*
* @param args
*/
public static void main(final String[] args) {
CueParser.logger.entering(CueParser.class.getCanonicalName(), "main(String[])", args);
CueSheet sheet = null;
try {
CueSheetToXmlSerializer xmlSerializer = new CueSheetToXmlSerializer();
FileFilter cueFilter = new FileFilter() {
public boolean accept(final File file) {
return file.getName().length() >= 4
&& file.getName().substring(file.getName().length() - 4).equalsIgnoreCase(".cue");
}
};
List<File> files = new ArrayList<File>();
File[] filesFound = null;
File workingDir = new File(System.getProperty("user.dir"));
filesFound = workingDir.listFiles(cueFilter);
if (filesFound != null) {
files.addAll(Arrays.asList(filesFound));
}
for (File file : files) {
CueParser.logger.info("Processing file: '" + file.toString() + "'");
sheet = CueParser.parse(file);
for (Message message : sheet.getMessages()) {
System.out.println(message);
}
System.out.println((new CueSheetSerializer()).serializeCueSheet(sheet));
xmlSerializer.serializeCueSheet(sheet, System.out);
}
}
catch (Exception e) {
LogUtil.logStacktrace(logger, Level.SEVERE, e);
}
CueParser.logger.exiting(CueParser.class.getCanonicalName(), "main(String[])");
}
}