diff --git a/.github/workflows/action.yml b/.github/workflows/action.yml index 8779e4d..0a3c917 100644 --- a/.github/workflows/action.yml +++ b/.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 diff --git a/CHANGELOG.md b/CHANGELOG.md index e8d5a37..83118d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 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 +++ b/README.md @@ -8,7 +8,7 @@ Generate CUE sheet from timestamps in youtube video description. 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,10 +37,19 @@ You need to pass 2 parameters, a Youtube URL and a output CUE filename. YouTube --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" @@ -66,7 +75,7 @@ function ytdl.album() { ## 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 +++ b/index.js @@ -4,6 +4,7 @@ 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 @@ if (argv._.length <1 || argv.help ){ 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 @@ -35,13 +45,22 @@ if (argv._.length <1 || argv.help ){ 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", defaultTitle: info.videoDetails.title }); 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 +++ b/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 +++ b/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 = /^((?\d{1,3})\.)? *(?.*?) *[\(\[]?(?((?\d{1,2}):)?(?\d{1,2}):(?\d{1,2})) *-? *[\)\]]?(?(?\d{1,2}:)?(?\d{1,2}):(?\d{1,2}))? *((?\d{1,3})\.)? *(?.*?)$/; -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; +}; -export function parse (text, options = { artist: 'Unknown' }) { +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", 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 +++ b/test/parser_test.js @@ -5,10 +5,10 @@ import { parse } from "../src/parser.js"; 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 @@ describe("Parser", function() { 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() {