diff --git a/src/Formats/AppleHLS.js b/src/Formats/AppleHLS.js new file mode 100644 index 0000000..ec61ff4 --- /dev/null +++ b/src/Formats/AppleHLS.js @@ -0,0 +1,96 @@ +import { secondsToTimestamp, timestampToSeconds } from '../util.js'; +import { FormatBase } from './FormatBase.js'; + + +export class AppleHLS extends FormatBase { + + filename = 'apple-hls.json'; + mimeType = 'application/json'; + + titleLanguage = 'en'; + imageDims = [1280, 720]; + + detect(inputString) { + try { + const data = JSON.parse(inputString); + const { errors } = this.test(data); + if (errors.length > 0) { + throw new Error('data test failed'); + } + } catch (e) { + return false; + } + return true; + } + + test(data) { + if (!Array.isArray(data)) { + return { errors: ['JSON Structure: must be an array'] }; + } + + if (data.length === 0) { + return { errors: ['JSON Structure: must not be empty'] }; + } + + if (!data.every(chapter => 'chapter' in chapter && 'start-time' in chapter)) { + return { errors: ['JSON Structure: every chapter must have a chapter and a start-time property'] }; + } + + return { errors: [] }; + } + + + parse(string) { + const data = JSON.parse(string); + const { errors } = this.test(data); + if (errors.length > 0) { + throw new Error(errors.join('')); + } + + this.chapters = data.map(raw => { + const chapter = { + startTime: parseFloat(raw['start-time']) + } + + if ('titles' in raw && raw.titles.length > 0) { + chapter.title = raw.titles[0].title; + } + + if ('images' in raw && raw.images.length > 0) { + chapter.img = raw.images[0].url; + } + + return chapter; + }); + } + + toString(pretty = false) { + return JSON.stringify(this.chapters.map((c, i) => { + + const chapter = { + 'start-time': c.startTime, + chapter: i + 1, + titles: [ + { + title: c.title || `Chapter ${i + 1}`, + language: this.titleLanguage + } + ] + } + + if (c.img) { + chapter.images = [ + { + 'image-category': "chapter", + url: c.img, + 'pixel-width': this.imageDims[0], + 'pixel-height': this.imageDims[1] + } + ] + } + + return chapter; + }), null, pretty ? 2 : 0); + } + +} \ No newline at end of file diff --git a/src/Formats/AutoFormat.js b/src/Formats/AutoFormat.js index 3e5adad..9573192 100644 --- a/src/Formats/AutoFormat.js +++ b/src/Formats/AutoFormat.js @@ -13,6 +13,7 @@ import { ShutterEDL } from "./ShutterEDL.js"; import { PodloveSimpleChapters } from "./PodloveSimpleChapters.js"; import { MP4Chaps } from "./MP4Chaps.js"; import { PodloveJson } from "./PodloveJson.js"; +import { AppleHLS } from "./AppleHLS.js"; export const AutoFormat = { classMap: { @@ -30,7 +31,8 @@ export const AutoFormat = { shutteredl: ShutterEDL, psc: PodloveSimpleChapters, mp4chaps: MP4Chaps, - podlovejson: PodloveJson + podlovejson: PodloveJson, + applehls: AppleHLS }, detect(inputString, returnWhat = 'instance') { diff --git a/tests/conversions.test.js b/tests/conversions.test.js index b8ab96a..25db216 100644 --- a/tests/conversions.test.js +++ b/tests/conversions.test.js @@ -12,6 +12,7 @@ import { VorbisComment } from "../src/Formats/VorbisComment.js"; import { PodloveSimpleChapters } from "../src/Formats/PodloveSimpleChapters.js"; import { MP4Chaps } from "../src/Formats/MP4Chaps.js"; import { PodloveJson } from "../src/Formats/PodloveJson.js"; +import { AppleHLS } from "../src/Formats/AppleHLS.js"; import { readFileSync } from "fs"; import { sep } from "path"; @@ -21,7 +22,7 @@ describe('conversions from one format to any other', () => { MatroskaXML, MKVMergeXML, MKVMergeSimple, PySceneDetect, AppleChapters, ShutterEDL, VorbisComment, PodloveSimpleChapters, MP4Chaps, - PodloveJson + PodloveJson, AppleHLS ]; const content = readFileSync(module.path + sep + 'samples' + sep + 'chapters.json', 'utf-8'); diff --git a/tests/format_applehls.test.js b/tests/format_applehls.test.js new file mode 100644 index 0000000..f55633a --- /dev/null +++ b/tests/format_applehls.test.js @@ -0,0 +1,60 @@ + +import { readFileSync } from "fs"; +import { sep } from "path"; +import { ChaptersJson } from "../src/Formats/ChaptersJson.js"; +import { AppleHLS } from "../src/Formats/AppleHLS.js"; + + +describe('AppleHLS Format Handler', () => { + it('accepts no arguments', () => { + expect(() => { + new AppleHLS(); + }).not.toThrowError(TypeError); + }); + + + it('fails on malformed input', () => { + expect(() => { + new AppleHLS('asdf'); + }).toThrowError(Error); + }); + + const content = readFileSync(module.path + sep + 'samples' + sep + 'applehls.json', 'utf-8'); + + it('parses well-formed input', () => { + expect(() => { + new AppleHLS(content); + }).not.toThrow(Error); + }); + + const instance = new AppleHLS(content); + + it('has the correct number of chapters from content', () => { + expect(instance.chapters.length).toEqual(3); + }); + + it('has parsed the timestamps correctly', () => { + expect(instance.chapters[1].startTime).toBe(500.1) + }); + + it('has parsed the chapter titles correctly', () => { + expect(instance.chapters[0].title).toBe('birth') + }); + + it('exports to correct format', () => { + expect(instance.toString()).toContain('start-time":'); + }); + + it('export includes correct timestamp', () => { + expect(instance.toString()).toContain('1200.2'); + }); + + it('can import previously generated export', () => { + expect(new AppleHLS(instance.toString()).chapters[2].startTime).toEqual(1200.2); + }); + + it('can convert into other format', () => { + expect(instance.to(ChaptersJson)).toBeInstanceOf(ChaptersJson) + }); + +}); diff --git a/tests/format_autodetection.test.js b/tests/format_autodetection.test.js index 9423e59..1b8845f 100644 --- a/tests/format_autodetection.test.js +++ b/tests/format_autodetection.test.js @@ -16,11 +16,13 @@ import { PySceneDetect } from "../src/Formats/PySceneDetect.js"; import { ShutterEDL } from "../src/Formats/ShutterEDL.js"; import { VorbisComment } from "../src/Formats/VorbisComment.js"; import { PodloveJson } from "../src/Formats/PodloveJson.js"; +import { AppleHLS } from "../src/Formats/AppleHLS.js"; describe('autodetection of sample files', () => { const filesAndKeysAndHandlers = [ ['applechapters.xml', 'applechapters', AppleChapters], + ['applehls.json', 'applehls', AppleHLS], ['chapters.json', 'chaptersjson', ChaptersJson], ['FFMetadata.txt', 'ffmetadata', FFMetadata], ['ffmpeginfo.txt', 'ffmpeginfo', FFMpegInfo], @@ -34,7 +36,7 @@ describe('autodetection of sample files', () => { ['shutter.edl', 'shutteredl', ShutterEDL], ['vorbiscomment.txt', 'vorbiscomment', VorbisComment], ['webvtt.txt', 'webvtt', WebVTT], - ['youtube-chapters.txt', 'youtube', Youtube] + ['youtube-chapters.txt', 'youtube', Youtube], ]; filesAndKeysAndHandlers.forEach(item => { diff --git a/tests/format_detection.test.js b/tests/format_detection.test.js index 1b1287a..32b5a3a 100644 --- a/tests/format_detection.test.js +++ b/tests/format_detection.test.js @@ -12,6 +12,7 @@ import { VorbisComment } from "../src/Formats/VorbisComment.js"; import { PodloveSimpleChapters } from "../src/Formats/PodloveSimpleChapters.js"; import { MP4Chaps } from "../src/Formats/MP4Chaps.js"; import { PodloveJson } from "../src/Formats/PodloveJson.js"; +import { AppleHLS } from "../src/Formats/AppleHLS.js"; import { readFileSync } from "fs"; import { sep } from "path"; @@ -21,7 +22,7 @@ describe('detection of input strings', () => { MatroskaXML, MKVMergeXML, MKVMergeSimple, PySceneDetect, AppleChapters, ShutterEDL, VorbisComment, PodloveSimpleChapters, MP4Chaps, - PodloveJson + PodloveJson, AppleHLS ]; const content = readFileSync(module.path + sep + 'samples' + sep + 'chapters.json', 'utf-8'); diff --git a/tests/samples/applehls.json b/tests/samples/applehls.json new file mode 100644 index 0000000..9541005 --- /dev/null +++ b/tests/samples/applehls.json @@ -0,0 +1,44 @@ +[ + { + "chapter": 1, + "start-time": 0, + "titles": [ + { + "language": "en", + "title": "birth" + }, + { + "language": "es", + "title": "nacimiento" + } + ] + }, + { + "chapter": 2, + "start-time": 500.1, + "titles": [ + { + "language": "en", + "title": "life" + }, + { + "language": "es", + "title": "vida" + } + ] + }, + { + "chapter": 3, + "start-time": 1200.2, + "titles": [ + { + "language": "en", + "title": "death" + }, + { + "language": "es", + "title": "muerte" + } + ] + } +] \ No newline at end of file diff --git a/tests/schema/applehls.json-schema.json b/tests/schema/applehls.json-schema.json new file mode 100644 index 0000000..4133ed4 --- /dev/null +++ b/tests/schema/applehls.json-schema.json @@ -0,0 +1,102 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "HLS Chapter Data", + "description": "HLS chapter data format", + "type": "array", + "items": { + "description": "chapter entry", + "type": "object", + "properties": { + "chapter": { + "description": "Chapter number (optional)", + "type": "number", + "minimum": 1 + }, + "start-time": { + "description": "Chapter start time", + "type": "number", + "minimum": 0 + }, + "duration": { + "description": "Chapter duration (optional)", + "type": "number", + "minimum": 0, + "exclusiveMinimum": true + }, + "titles": { + "description": "List of titles by language for chapter (optional)", + "type": "array", + "items": { + "description": "Title object", + "type": "object", + "properties": { + "language": { + "description": "BCP 47 language code; und for undefined", + "type": "string" + }, + "title": { + "description": "Chapter title string", + "type": "string" + } + }, + "required": ["language", "title"] + } + }, + "images": { + "description": "List of images for chapter (optional)", + "type": "array", + "items": { + "description": "Image object", + "type": "object", + "properties": { + "image-category": { + "description": "Image category", + "type": "string" + }, + "pixel-width": { + "description": "Pixel width", + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "pixel-height": { + "description": "Pixel height", + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "url": { + "description": "URL to image (relative or absolute)", + "type": "string" + } + }, + "required": ["image-category", "pixel-width", "pixel-height", "url"] + } + }, + "metadata": { + "description": "List of metadata entries for chapter (optional)", + "type": "array", + "items": { + "description": "Metadata object", + "type": "object", + "properties": { + "key": { + "description": "Key value name", + "type": "string" + }, + "value": { + "description": "Metadata value", + "type": ["string", "number", "boolean", "array", "object"] + }, + "language": { + "description": "BCP 47 language code (optional)", + "type": "string" + } + }, + "required": ["key", "value"] + } + } + }, + "required": ["start-time"] + } +} \ No newline at end of file