From 610c13fceff3a5154a718421a3f83ff9e3e5821c Mon Sep 17 00:00:00 2001
From: Nemo <commits@captnemo.in>
Date: Thu, 29 Jul 2021 11:11:58 +0530
Subject: [PATCH] Parse Duration

---
 CHANGELOG.md                 |   2 ++
 README.md                    |  19 ++++++++++++++-----
 index.js                     |  21 +++++++++++++++++++++
 package.json                 |   2 +-
 src/parser.js                | 172 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
 test/parser_test.js          | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 .github/workflows/action.yml |   7 +++++--
 7 files changed, 316 insertions(+), 67 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e8d5a37..83118d5 100644
--- a/CHANGELOG.md
+++ a/CHANGELOG.md
@@ -5,6 +5,8 @@
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## Unreleased
+### Added
+- Tracklists using duration instead of timestamps are now supported.
 
 ## 1.0.5 - 2021-07-21
 ### Changed
diff --git a/README.md b/README.md
index bd50a81..b24b317 100644
--- a/README.md
+++ a/README.md
@@ -8,7 +8,7 @@
 2. The video has timestamps in the video description.
 3. The video is publicly available on Youtube.
 
-`youtube-cue` will read the video description, get the timestamps and generate a [CUE sheet][cue] accordingly.
+`youtube-cue` will read the video description, get the timestamps and generate a [CUE sheet][cue] accordingly. It will also work if track durations are used instead of timestamps.
 
 ## Anti-features
 
@@ -37,11 +37,20 @@
       --help, Show help
       --audio-file, Input Audio File (optional) that is written to the CUE sheet
 
-      The default audio file is set to %VIDEOTITLE.m4a
-      The default output file is set to %VIDEOTITLE.cue
+    The default audio file is set to %VIDEOTITLE.m4a
+    The default output file is set to %VIDEOTITLE.cue
 
-      where $VIDEOTITLE is the title of the YouTube video.
+    where $VIDEOTITLE is the title of the YouTube video.
 
+    Generally the parser detects whether numbers are positional timestamps or track durations.
+    To enforce a desired interpretation you can use these flags:
+
+    --timestamps Parse as positional timestamps (relative to the start of the playlist)
+    --durations Parse as track durations
+
+    The above 2 are only needed to force behaviour in
+    very specific edge cases, they should not be required for most files.
+
     Examples
       $ youtube-cue --audio-file audio.m4a "https://www.youtube.com/watch?v=THzUassmQwE"
         "T A Y L O R  S W I F T – Folklore [Full album].cue" saved
@@ -66,7 +75,7 @@
 
 ## HACKING
 
-- If this video does not work on a specific video, please attach the complete output
+- If it does not work on a specific video, please attach the complete output
 - Pull Requests are welcome that add support for a better parser without breaking the existing tests
 - Please add tests for any new functionality
 
diff --git a/index.js b/index.js
index 6f59533..441756e 100755
--- a/index.js
+++ a/index.js
@@ -1,9 +1,10 @@
 #!/usr/bin/env node
 import ytdl from 'ytdl-core';
 import getArtistTitle from 'get-artist-title'
 import {parse} from './src/parser.js'
 import {generate} from './src/cue.js'
 import minimist from 'minimist'
+import exit from 'process'
 
 let argv = minimist(process.argv.slice(2), {
   string: 'audio-file'
@@ -22,6 +23,15 @@
 
     where $VIDEOTITLE is the title of the YouTube video.
 
+    Generally the parser detects whether numbers are positional timestamps or track durations.
+    To enforce a desired interpretation you can use these flags:
+
+    --timestamps Parse as positional timestamps (relative to the start of the playlist)
+    --durations Parse as track durations
+
+    The above 2 are only needed to force behaviour in very specific edge cases, they should
+    not be required for most files.
+
   Examples
     $ youtube-cue --audio-file audio.m4a "https://www.youtube.com/watch?v=THzUassmQwE"
       "T A Y L O R  S W I F T – Folklore [Full album].cue" saved
@@ -34,6 +44,15 @@
     let audioFile = argv['audio-file']? argv['audio-file'] : `${info.videoDetails.title}.m4a`
 
     let output_file = argv._[1]? argv._[1] : `${info.videoDetails.title}.cue`
+
+    let forceTimestamps = argv['timestamps']? argv['timestamps'] : false;
+
+    let forceDurations = argv['durations']? argv['durations'] : false;
+
+    if (forceTimestamps && forceDurations) {
+      console.error("You can't pass both --timestamps and durations");
+      exit(1)
+    }
 
     let res = getArtistTitle(info.videoDetails.title,{
       defaultArtist: "Unknown Artist",
@@ -41,7 +60,7 @@
     });
     let [artist, album] = res
     artist = (info.videoDetails.media ? info.videoDetails.media.artist : artist)
-    let tracks = parse(info.videoDetails.description, {artist})
+    let tracks = parse(info.videoDetails.description, {artist, forceTimestamps, forceDurations})
     generate({tracks, artist, audioFile, album}, output_file)
     console.log(`"${output_file}" saved`)
   })
diff --git a/package.json b/package.json
index fcb6eb4..9156a36 100644
--- a/package.json
+++ a/package.json
@@ -1,6 +1,6 @@
 {
   "name": "youtube-cue",
-  "version": "1.0.5",
+  "version": "1.0.6-beta.0",
   "description": "Generates Cue sheet from Youtube URL",
   "main": "index.js",
   "scripts": {
diff --git a/src/parser.js b/src/parser.js
index 10b9c09..5e4b701 100644
--- a/src/parser.js
+++ a/src/parser.js
@@ -21,93 +21,171 @@
  * It is suggested to check their lengths and pick one to parse as the Track Title
  */
 const TS_REGEX = /^((?<trackl>\d{1,3})\.)? *(?<text_1>.*?) *[\(\[]?(?<start_ts>((?<start_hh>\d{1,2}):)?(?<start_mm>\d{1,2}):(?<start_ss>\d{1,2})) *-? *[\)\]]?(?<end_ts>(?<end_hh>\d{1,2}:)?(?<end_mm>\d{1,2}):(?<end_ss>\d{1,2}))? *((?<trackr>\d{1,3})\.)? *(?<text_2>.*?)$/;
-import getArtistTitle from 'get-artist-title'
+import getArtistTitle from "get-artist-title";
 var _options = {};
 
-function convertTime(h,m,s) {
-  return (+h) * 60 * 60 + (+m) * 60 + (+s)
+// Returns number of total seconds
+function convertTime(h, m, s) {
+  return +h * 60 * 60 + +m * 60 + +s;
 }
 
+// Only picks out lines which have a timestamp in them
 var filterTimestamp = function(line) {
-  return TS_REGEX.test(line)
+  return TS_REGEX.test(line);
 };
 
+// Parse each line as per the regex
 var firstPass = function(line) {
   let matches = line.match(TS_REGEX);
-  let track = matches.groups['trackl'] ? +matches.groups['trackl'] : (matches.groups['trackr'] ? +matches.groups['trackr'] : null)
+  let track = matches.groups["trackl"]
+    ? +matches.groups["trackl"]
+    : matches.groups["trackr"]
+    ? +matches.groups["trackr"]
+    : null;
   return {
     track: track,
     start: {
-      ts: matches.groups['start_ts'].length<6 ? `00:${matches.groups['start_ts']}` : matches.groups['start_ts'],
-      hh: matches.groups['start_hh'] ? +matches.groups['start_hh'] : 0,
+      ts:
+        matches.groups["start_ts"].length < 6
+          ? `00:${matches.groups["start_ts"]}`
+          : matches.groups["start_ts"],
+      hh: matches.groups["start_hh"] ? +matches.groups["start_hh"] : 0,
       // These 2 are always set
-      mm: +matches.groups['start_mm'],
-      ss: +matches.groups['start_ss'],
+      mm: +matches.groups["start_mm"],
+      ss: +matches.groups["start_ss"],
     },
-    end: (matches.groups['end_ts']!==undefined ? {
-          ts: matches.groups['end_ts']? matches.groups['end_ts'] : null,
-          hh: matches.groups['end_hh']? +matches.groups['end_hh'] : null,
-          mm: matches.groups['end_mm']? +matches.groups['end_mm'] : null,
-          ss: matches.groups['end_ss']? +matches.groups['end_ss'] : null,
-        } : null),
+    end:
+      matches.groups["end_ts"] !== undefined
+        ? {
+            ts: matches.groups["end_ts"] ? matches.groups["end_ts"] : null,
+            hh: matches.groups["end_hh"] ? +matches.groups["end_hh"] : null,
+            mm: matches.groups["end_mm"] ? +matches.groups["end_mm"] : null,
+            ss: matches.groups["end_ss"] ? +matches.groups["end_ss"] : null,
+          }
+        : null,
     _: {
-      left_text: matches.groups['text_1'],
-      right_text: matches.groups['text_2']
-    }
-  }
+      left_text: matches.groups["text_1"],
+      right_text: matches.groups["text_2"],
+    },
+  };
 };
 
+// Add a calc attribute with total seconds
 var calcTimestamp = function(obj) {
-  if(obj.end) {
-    obj.end.calc = convertTime(obj.end.hh,obj.end.mm,obj.end.ss)
+  if (obj.end) {
+    obj.end.calc = convertTime(obj.end.hh, obj.end.mm, obj.end.ss);
   }
-  obj.start.calc = convertTime(obj.start.hh,obj.start.mm,obj.start.ss)
-  return obj
-}
+  obj.start.calc = convertTime(obj.start.hh, obj.start.mm, obj.start.ss);
+  return obj;
+};
 
+// Pick the longer "text" from left or right side.
 var parseTitle = function(obj) {
-  obj.title = obj._.left_text.length > obj._.right_text.length
-    ? obj._.left_text : obj._.right_text;
-  return obj
-}
+  obj.title =
+    obj._.left_text.length > obj._.right_text.length
+      ? obj._.left_text
+      : obj._.right_text;
+  return obj;
+};
 
+// Parse the text as the title/artist
 var parseArtist = function(obj) {
   let [artist, title] = getArtistTitle(obj.title, {
     defaultArtist: _options.artist,
-    defaultTitle: obj.title
+    defaultTitle: obj.title,
   });
-  obj.artist = artist
-  obj.title = title
-  return obj
+  obj.artist = artist;
+  obj.title = title;
+  return obj;
 };
 
+// If track numbers are not present, add them accordingly
 var addTrack = function(obj, index) {
-  if (obj.track==null) {
-    obj.track = index+1
+  if (obj.track == null) {
+    obj.track = index + 1;
   }
-  return obj
-}
+  return obj;
+};
 
+// Add "end" timestamps as next start timestamps
 var addEnd = function(obj, index, arr) {
   if (!obj.end) {
-    if(arr.length!=index+1) {
-      let next = arr[index+1]
-      obj.end = next.start
-      return obj
+    if (arr.length != index + 1) {
+      let next = arr[index + 1];
+      obj.end = next.start;
+      return obj;
     }
   }
-  return obj
-}
+  return obj;
+};
+
+var timeToObject = function(obj) {
+  let d = new Date(obj.calc * 1000).toISOString();
+  obj.hh = +d.substr(11, 2);
+  obj.mm = +d.substr(14, 2);
+  obj.ss = +d.substr(17, 2);
+  obj.ts = d.substr(11, 8);
+  return obj;
+};
+
+// Instead of timestamps, some tracklists use durations
+// If durations are provided, use them to re-calculate
+// the starting and ending timestamps
+var fixDurations = function(list) {
+  for (let i in list) {
+    if (i == 0) {
+      // Set the first one to start of track.
+      list[i].start.hh = list[i].start.mm = list[i].start.ss = 0;
+      // And end at the right time
+      list[i].end = { calc: list[i].start.calc };
+      list[i].start.calc = 0;
+    } else {
+      // All the others tracks start at the end of the previous one
+      // And end at start time + duration
+      let previous = list[i - 1];
+      list[i].end = { calc: previous.end.calc + list[i].start.calc };
+      list[i].start.calc = previous.end.calc;
+    }
+
+    list[i].start = timeToObject(list[i].start);
+    list[i].end = timeToObject(list[i].end);
+  }
+};
 
-export function parse (text, options = { artist: 'Unknown' }) {
+export function parse(
+  text,
+  options = { artist: "Unknown", forceTimestamps: false, forceDurations: false }
+) {
   _options = options;
-  return text
-    .split('\n')
+  let durations = false;
+  let result = text
+    .split("\n")
     .filter(filterTimestamp)
     .map(firstPass)
-    .map(calcTimestamp)
+    .map(calcTimestamp);
+
+  if (!options.forceTimestamps) {
+    // If our timestamps are not in increasing order
+    // Assume that we've been given a duration list instead
+    if (result[0].start.calc!=0) {
+      result.forEach((current, index, list) => {
+        if (index > 0) {
+          let previous = list[index - 1];
+          if (current.start.calc < previous.start.calc) {
+            durations = true;
+          }
+        }
+      });
+    }
+
+    if (durations || options.forceDurations == true) {
+      fixDurations(result);
+    }
+  }
+
+  return result
     .map(parseTitle)
     .map(parseArtist)
     .map(addTrack)
-    .map(addEnd)
+    .map(addEnd);
 }
diff --git a/test/parser_test.js b/test/parser_test.js
index b4c3c12..bb3e592 100644
--- a/test/parser_test.js
+++ a/test/parser_test.js
@@ -5,10 +5,10 @@
 
 const TEXT = `
 00:40 The Coders - Hello World
-12:23 This is not the end
+1:00 This is not the end
 Something else in the middle
-1:23:11 Not the last song
-01.   Screens     0:00 - 5:40
+1:23 Not the last song
+01.   Screens     1:40 - 5:40
 02.   Inharmonious Slog     5:40 - 10:11
 03.   The Everyday Push     10:11 - 15:46
 04.   Storm     15:46 - 19:07
@@ -65,15 +65,153 @@
 
   it("should parse timestamps with square brackets", function() {
     let result = parse(`[00:00:00] 1. Steve Kroeger x Skye Holland - Through The Dark
-      [00:02:53] 2. Gabri Ponte x Jerome - Lonely `)
+      [00:02:53] 2. Gabri Ponte x Jerome - Lonely `);
     assert.deepEqual(result[0], {
-        artist: "Steve Kroeger x Skye Holland",
-        title: "Through The Dark",
-        track: 1,
-        start: { ts: "00:00:00", hh: 0, mm: 0, ss: 0, calc: 0 },
-        end: { ts: "00:02:53", hh: 0, mm: 2, ss: 53, calc: 173 },
-        _: { left_text: "", right_text: "Steve Kroeger x Skye Holland - Through The Dark" },
-      })
+      artist: "Steve Kroeger x Skye Holland",
+      title: "Through The Dark",
+      track: 1,
+      start: { ts: "00:00:00", hh: 0, mm: 0, ss: 0, calc: 0 },
+      end: { ts: "00:02:53", hh: 0, mm: 2, ss: 53, calc: 173 },
+      _: {
+        left_text: "",
+        right_text: "Steve Kroeger x Skye Holland - Through The Dark",
+      },
+    });
+  });
+
+  it("should parse durations when given", function() {
+    let result = parse(`1. Artist - Title 6:19
+2. Another Artist - Another Title 6:59
+3. Yet Another Artist - Yet another title 5:12`);
+    assert.deepEqual(result[0], {
+      artist: "Artist",
+      title: "Title",
+      track: 1,
+      start: { ts: "00:00:00", hh: 0, mm: 0, ss: 0, calc: 0 },
+      end: { ts: "00:06:19", hh: 0, mm: 6, ss: 19, calc: 379 },
+      _: { left_text: "Artist - Title", right_text: "" },
+    });
+
+    assert.deepEqual(result[1], {
+      artist: "Another Artist",
+      title: "Another Title",
+      track: 2,
+      start: { ts: "00:06:19", hh: 0, mm: 6, ss: 19, calc: 379 },
+      end: { ts: "00:13:18", hh: 0, mm: 13, ss: 18, calc: 798 },
+      _: { left_text: "Another Artist - Another Title", right_text: "" },
+    });
+    assert.deepEqual(result[2], {
+      artist: "Yet Another Artist",
+      title: "Yet another title",
+      track: 3,
+      start: { ts: "00:13:18", hh: 0, mm: 13, ss: 18, calc: 798 },
+      end: { ts: "00:18:30", hh: 0, mm: 18, ss: 30, calc: 1110 },
+      _: {
+        left_text: "Yet Another Artist - Yet another title",
+        right_text: "",
+      },
+    });
+  });
+
+  it("should parse as timestamps if first timestamp is 00:00", function() {
+    let result = parse(`1. Artist - Title 00:00
+2. Another Artist - Another Title 01:00
+3. Yet Another Artist - Yet another title 02:00`);
+    assert.deepEqual(result[0], {
+      artist: "Artist",
+      title: "Title",
+      track: 1,
+      start: { ts: "00:00:00", hh: 0, mm: 0, ss: 0, calc: 0 },
+      end: { ts: "00:01:00", hh: 0, mm: 1, ss: 0, calc: 60 },
+      _: { left_text: "Artist - Title", right_text: "" },
+    });
+
+    assert.deepEqual(result[1], {
+      artist: "Another Artist",
+      title: "Another Title",
+      track: 2,
+      end: { ts: "00:02:00", hh: 0, mm: 2, ss: 0, calc: 120 },
+      start: { ts: "00:01:00", hh: 0, mm: 1, ss: 0, calc: 60 },
+      _: { left_text: "Another Artist - Another Title", right_text: "" },
+    });
+    assert.deepEqual(result[2], {
+      artist: "Yet Another Artist",
+      title: "Yet another title",
+      track: 3,
+      end: null,
+      start: { ts: "00:02:00", hh: 0, mm: 2, ss: 0, calc: 120 },
+      _: { left_text: "Yet Another Artist - Yet another title", right_text: "" },
+    });
+  });
+
+  it("should parse durations as timestamps when forced", function() {
+    let result = parse(
+      `1. Artist - Title 5:00
+2. Another Artist - Another Title 4:20`,
+      { forceTimestamps: true }
+    );
+    assert.deepEqual(result[0].end, {
+      ts: "00:4:20",
+      hh: 0,
+      mm: 4,
+      ss: 20,
+      calc: 260,
+    });
+    assert.deepEqual(result[1].start, {
+      ts: "00:4:20",
+      hh: 0,
+      mm: 4,
+      ss: 20,
+      calc: 260,
+    });
+  });
+
+  it("should parse timestamps as durations when forced", function() {
+    let result = parse(
+      `1. Artist - Title 1:00
+2. Another Artist - Another Title 1:15`,
+      { forceDurations: true }
+    );
+    assert.deepEqual(result[0], {
+      track: 1,
+      _: { left_text: "Artist - Title", right_text: "" },
+      title: "Title",
+      artist: "Artist",
+      end: {
+        ts: "00:01:00",
+        hh: 0,
+        mm: 1,
+        ss: 0,
+        calc: 60,
+      },
+      start: {
+        ts: "00:00:00",
+        hh: 0,
+        mm: 0,
+        ss: 0,
+        calc: 0,
+      },
+    });
+    assert.deepEqual(result[1], {
+      track: 2,
+      _: { left_text: "Another Artist - Another Title", right_text: "" },
+      title: "Another Title",
+      artist: "Another Artist",
+      start: {
+        ts: "00:01:00",
+        hh: 0,
+        mm: 1,
+        ss: 0,
+        calc: 60,
+      },
+      end: {
+        ts: "00:02:15",
+        hh: 0,
+        mm: 2,
+        ss: 15,
+        calc: 135,
+      },
+    });
   });
 
   it("should parse taylor swift", function() {
diff --git a/.github/workflows/action.yml b/.github/workflows/action.yml
index 8779e4d..0a3c917 100644
--- a/.github/workflows/action.yml
+++ a/.github/workflows/action.yml
@@ -1,13 +1,16 @@
 on: push
 name: Main Workflow
 jobs:
-  runNpmStuff:
+  tests:
+    strategy:
+      matrix:
+        node: ['16', '14', '12']
     name: Run NPM Stuff
     runs-on: ubuntu-latest
     steps:
     - uses: actions/checkout@v1
     - uses: actions/setup-node@v2
       with:
-        node-version: '16'
+        node-version: ${{matrix.node}}
     - run: npm install
     - run: npm test
--
rgit 0.1.5