Parse Duration

This commit is contained in:
Nemo 2021-07-29 11:11:58 +05:30 committed by GitHub
commit 610c13fcef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 316 additions and 67 deletions

View File

@ -1,13 +1,16 @@
on: push on: push
name: Main Workflow name: Main Workflow
jobs: jobs:
runNpmStuff: tests:
strategy:
matrix:
node: ['16', '14', '12']
name: Run NPM Stuff name: Run NPM Stuff
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- uses: actions/setup-node@v2 - uses: actions/setup-node@v2
with: with:
node-version: '16' node-version: ${{matrix.node}}
- run: npm install - run: npm install
- run: npm test - run: npm test

View File

@ -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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased ## Unreleased
### Added
- Tracklists using duration instead of timestamps are now supported.
## 1.0.5 - 2021-07-21 ## 1.0.5 - 2021-07-21
### Changed ### Changed

View File

@ -8,7 +8,7 @@ Generate CUE sheet from timestamps in youtube video description.
2. The video has timestamps in the video description. 2. The video has timestamps in the video description.
3. The video is publicly available on Youtube. 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 ## Anti-features
@ -37,10 +37,19 @@ You need to pass 2 parameters, a Youtube URL and a output CUE filename. YouTube
--help, Show help --help, Show help
--audio-file, Input Audio File (optional) that is written to the CUE sheet --audio-file, Input Audio File (optional) that is written to the CUE sheet
The default audio file is set to %VIDEOTITLE.m4a The default audio file is set to %VIDEOTITLE.m4a
The default output file is set to %VIDEOTITLE.cue 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 Examples
$ youtube-cue --audio-file audio.m4a "https://www.youtube.com/watch?v=THzUassmQwE" $ youtube-cue --audio-file audio.m4a "https://www.youtube.com/watch?v=THzUassmQwE"
@ -66,7 +75,7 @@ function ytdl.album() {
## HACKING ## 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 - Pull Requests are welcome that add support for a better parser without breaking the existing tests
- Please add tests for any new functionality - Please add tests for any new functionality

View File

@ -4,6 +4,7 @@ import getArtistTitle from 'get-artist-title'
import {parse} from './src/parser.js' import {parse} from './src/parser.js'
import {generate} from './src/cue.js' import {generate} from './src/cue.js'
import minimist from 'minimist' import minimist from 'minimist'
import exit from 'process'
let argv = minimist(process.argv.slice(2), { let argv = minimist(process.argv.slice(2), {
string: 'audio-file' string: 'audio-file'
@ -22,6 +23,15 @@ if (argv._.length <1 || argv.help ){
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 Examples
$ youtube-cue --audio-file audio.m4a "https://www.youtube.com/watch?v=THzUassmQwE" $ 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 "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 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,{ let res = getArtistTitle(info.videoDetails.title,{
defaultArtist: "Unknown Artist", defaultArtist: "Unknown Artist",
defaultTitle: info.videoDetails.title defaultTitle: info.videoDetails.title
}); });
let [artist, album] = res let [artist, album] = res
artist = (info.videoDetails.media ? info.videoDetails.media.artist : artist) 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) generate({tracks, artist, audioFile, album}, output_file)
console.log(`"${output_file}" saved`) console.log(`"${output_file}" saved`)
}) })

View File

@ -1,6 +1,6 @@
{ {
"name": "youtube-cue", "name": "youtube-cue",
"version": "1.0.5", "version": "1.0.6-beta.0",
"description": "Generates Cue sheet from Youtube URL", "description": "Generates Cue sheet from Youtube URL",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -21,93 +21,171 @@
* It is suggested to check their lengths and pick one to parse as the Track Title * 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>.*?)$/; 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 = {}; var _options = {};
function convertTime(h,m,s) { // Returns number of total seconds
return (+h) * 60 * 60 + (+m) * 60 + (+s) 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) { 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) { var firstPass = function(line) {
let matches = line.match(TS_REGEX); 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 { return {
track: track, track: track,
start: { start: {
ts: matches.groups['start_ts'].length<6 ? `00:${matches.groups['start_ts']}` : matches.groups['start_ts'], ts:
hh: matches.groups['start_hh'] ? +matches.groups['start_hh'] : 0, 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 // These 2 are always set
mm: +matches.groups['start_mm'], mm: +matches.groups["start_mm"],
ss: +matches.groups['start_ss'], ss: +matches.groups["start_ss"],
}, },
end: (matches.groups['end_ts']!==undefined ? { end:
ts: matches.groups['end_ts']? matches.groups['end_ts'] : null, matches.groups["end_ts"] !== undefined
hh: matches.groups['end_hh']? +matches.groups['end_hh'] : null, ? {
mm: matches.groups['end_mm']? +matches.groups['end_mm'] : null, ts: matches.groups["end_ts"] ? matches.groups["end_ts"] : null,
ss: matches.groups['end_ss']? +matches.groups['end_ss'] : null, hh: matches.groups["end_hh"] ? +matches.groups["end_hh"] : null,
} : 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'], left_text: matches.groups["text_1"],
right_text: matches.groups['text_2'] right_text: matches.groups["text_2"],
} },
} };
}; };
// Add a calc attribute with total seconds
var calcTimestamp = function(obj) { var calcTimestamp = function(obj) {
if(obj.end) { if (obj.end) {
obj.end.calc = convertTime(obj.end.hh,obj.end.mm,obj.end.ss) 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) obj.start.calc = convertTime(obj.start.hh, obj.start.mm, obj.start.ss);
return obj return obj;
} };
// Pick the longer "text" from left or right side.
var parseTitle = function(obj) { var parseTitle = function(obj) {
obj.title = obj._.left_text.length > obj._.right_text.length obj.title =
? obj._.left_text : obj._.right_text; obj._.left_text.length > obj._.right_text.length
return obj ? obj._.left_text
} : obj._.right_text;
return obj;
};
// Parse the text as the title/artist
var parseArtist = function(obj) { var parseArtist = function(obj) {
let [artist, title] = getArtistTitle(obj.title, { let [artist, title] = getArtistTitle(obj.title, {
defaultArtist: _options.artist, defaultArtist: _options.artist,
defaultTitle: obj.title defaultTitle: obj.title,
}); });
obj.artist = artist obj.artist = artist;
obj.title = title obj.title = title;
return obj return obj;
}; };
// If track numbers are not present, add them accordingly
var addTrack = function(obj, index) { var addTrack = function(obj, index) {
if (obj.track==null) { if (obj.track == null) {
obj.track = index+1 obj.track = index + 1;
} }
return obj return obj;
} };
// Add "end" timestamps as next start timestamps
var addEnd = function(obj, index, arr) { var addEnd = function(obj, index, arr) {
if (!obj.end) { if (!obj.end) {
if(arr.length!=index+1) { if (arr.length != index + 1) {
let next = arr[index+1] let next = arr[index + 1];
obj.end = next.start obj.end = next.start;
return obj 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; _options = options;
return text let durations = false;
.split('\n') let result = text
.split("\n")
.filter(filterTimestamp) .filter(filterTimestamp)
.map(firstPass) .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(parseTitle)
.map(parseArtist) .map(parseArtist)
.map(addTrack) .map(addTrack)
.map(addEnd) .map(addEnd);
} }

View File

@ -5,10 +5,10 @@ import { parse } from "../src/parser.js";
const TEXT = ` const TEXT = `
00:40 The Coders - Hello World 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 Something else in the middle
1:23:11 Not the last song 1:23 Not the last song
01. Screens 0:00 - 5:40 01. Screens 1:40 - 5:40
02. Inharmonious Slog 5:40 - 10:11 02. Inharmonious Slog 5:40 - 10:11
03. The Everyday Push 10:11 - 15:46 03. The Everyday Push 10:11 - 15:46
04. Storm 15:46 - 19:07 04. Storm 15:46 - 19:07
@ -65,15 +65,153 @@ describe("Parser", function() {
it("should parse timestamps with square brackets", function() { it("should parse timestamps with square brackets", function() {
let result = parse(`[00:00:00] 1. Steve Kroeger x Skye Holland - Through The Dark 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], { assert.deepEqual(result[0], {
artist: "Steve Kroeger x Skye Holland", artist: "Steve Kroeger x Skye Holland",
title: "Through The Dark", title: "Through The Dark",
track: 1, track: 1,
start: { ts: "00:00:00", hh: 0, mm: 0, ss: 0, calc: 0 }, 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 }, 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" }, _: {
}) 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() { it("should parse taylor swift", function() {