From 4fe60465e5cef666a57bf7bedda8a83539046a46 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 16 Mar 2022 19:15:25 -0500 Subject: [PATCH] New data model change of Book media type to include array of Audiobook and Ebook objects --- docs/LibraryItemModelDemo.js | 168 -------------- docs/SampleBookLibraryItem.js | 189 +++++++++++++++ docs/SamplePodcastLibraryItem.js | 83 +++++++ server/PlaybackSessionManager.js | 14 +- server/objects/LibraryItem.js | 14 +- server/objects/entities/Audiobook.js | 218 ++++++++++++++++++ server/objects/entities/EBook.js | 70 ++++++ server/objects/entities/PodcastEpisode.js | 3 + .../objects/{entities => mediaTypes}/Book.js | 180 ++++----------- .../{entities => mediaTypes}/Podcast.js | 9 +- server/objects/metadata/FileMetadata.js | 3 + server/scanner/Scanner.js | 6 +- server/utils/dbMigration.js | 54 +++-- 13 files changed, 677 insertions(+), 334 deletions(-) delete mode 100644 docs/LibraryItemModelDemo.js create mode 100644 docs/SampleBookLibraryItem.js create mode 100644 docs/SamplePodcastLibraryItem.js create mode 100644 server/objects/entities/Audiobook.js create mode 100644 server/objects/entities/EBook.js rename server/objects/{entities => mediaTypes}/Book.js (60%) rename server/objects/{entities => mediaTypes}/Podcast.js (93%) diff --git a/docs/LibraryItemModelDemo.js b/docs/LibraryItemModelDemo.js deleted file mode 100644 index 5fc44e06..00000000 --- a/docs/LibraryItemModelDemo.js +++ /dev/null @@ -1,168 +0,0 @@ -/* - This is an example of a fully expanded book library item -*/ - -const LibraryItem = require('../server/objects/LibraryItem') - -new LibraryItem({ - id: 'li_abai123wir', - ino: "55450570412017066", - libraryId: 'lib_1239p1d8', - folderId: 'fol_192ab8901', - path: '/audiobooks/Terry Goodkind/Sword of Truth/1 - Wizards First Rule', - relPath: '/Terry Goodkind/Sword of Truth/1 - Wizards First Rule', - mtimeMs: 1646784672127, - ctimeMs: 1646784672127, - birthtimeMs: 1646784672127, - addedAt: 1646784672127, - lastUpdate: 1646784672127, - lastScan: 1646784672127, - scanVersion: 1.72, - isMissing: false, - mediaType: 'book', - media: { // Book.js - coverPath: '/metadata/books/li_abai123wir/cover.webp', - metadata: { // BookMetadata.js - title: 'Wizards First Rule', - subtitle: null, - authors: [ - { - id: 'au_42908lkajsfdk', - name: 'Terry Goodkind' - } - ], - narrators: ['Sam Tsoutsouvas'], - series: [ - { - id: 'se_902384lansf', - name: 'Sword of Truth', - sequence: 1 - } - ], - genres: ['Fantasy', 'Adventure'], - publishedYear: '1994', - publishedDate: '1994-01-01', - publisher: 'Brilliance Audio', - description: 'In the aftermath of the brutal murder of his father, a mysterious woman...', - isbn: '289374092834', - asin: '19023819203', - language: 'english' - }, - tags: ['favorites'], - audioFiles: [ - { // AudioFile.js - ino: "55450570412017066", - index: 1, - metadata: { // FileMetadata.js - filename: 'audiofile.mp3', - ext: '.mp3', - path: '/audiobooks/Terry Goodkind/Sword of Truth/1 - Wizards First Rule/CD01/audiofile.mp3', - relPath: '/CD01/audiofile.mp3', - mtimeMs: 1646784672127, - ctimeMs: 1646784672127, - birthtimeMs: 1646784672127, - size: 1197449516 - }, - trackNumFromMeta: 1, - discNumFromMeta: null, - trackNumFromFilename: null, - discNumFromFilename: 1, - manuallyVerified: false, - exclude: false, - invalid: false, - format: "MP2/3 (MPEG audio layer 2/3)", - duration: 2342342, - bitRate: 324234, - language: null, - codec: 'mp3', - timeBase: "1/14112000", - channels: 1, - channelLayout: "mono", - chapters: [], - embeddedCoverArt: 'jpeg', // Video stream codec ['mjpeg', 'jpeg', 'png'] or null - metaTags: { // AudioMetaTags.js - tagAlbum: '', - tagArtist: '', - tagGenre: '', - tagTitle: '', - tagSeries: '', - tagSeriesPart: '', - tagTrack: '', - tagDisc: '', - tagSubtitle: '', - tagAlbumArtist: '', - tagDate: '', - tagComposer: '', - tagPublisher: '', - tagComment: '', - tagDescription: '', - tagEncoder: '', - tagEncodedBy: '', - tagIsbn: '', - tagLanguage: '', - tagASIN: '' - }, - addedAt: 1646784672127, - updatedAt: 1646784672127 - } - ], - ebookFiles: [ - { // EBookFile.js - ino: "55450570412017066", - metadata: { // FileMetadata.js - filename: 'ebookfile.mobi', - ext: '.mobi', - path: '/audiobooks/Terry Goodkind/Sword of Truth/1 - Wizards First Rule/ebookfile.mobi', - relPath: '/ebookfile.mobi', - mtimeMs: 1646784672127, - ctimeMs: 1646784672127, - birthtimeMs: 1646784672127, - size: 1197449516 - }, - ebookFormat: 'mobi', - addedAt: 1646784672127, - updatedAt: 1646784672127 - } - ], - chapters: [ - { - id: 0, - title: 'Chapter 01', - start: 0, - end: 2467.753 - } - ] - }, - libraryFiles: [ - { // LibraryFile.js - ino: "55450570412017066", - metadata: { // FileMetadata.js - filename: 'cover.png', - ext: '.png', - path: '/audiobooks/Terry Goodkind/Sword of Truth/1 - Wizards First Rule/subfolder/cover.png', - relPath: '/subfolder/cover.png', - mtimeMs: 1646784672127, - ctimeMs: 1646784672127, - birthtimeMs: 1646784672127, - size: 1197449516 - }, - addedAt: 1646784672127, - updatedAt: 1646784672127 - }, - { // LibraryFile.js - ino: "55450570412017066", - metadata: { // FileMetadata.js - filename: 'cover.png', - ext: '.mobi', - path: '/audiobooks/Terry Goodkind/Sword of Truth/1 - Wizards First Rule/ebookfile.mobi', - relPath: '/ebookfile.mobi', - mtimeMs: 1646784672127, - ctimeMs: 1646784672127, - birthtimeMs: 1646784672127, - size: 1197449516 - }, - addedAt: 1646784672127, - updatedAt: 1646784672127 - } - ] -}) \ No newline at end of file diff --git a/docs/SampleBookLibraryItem.js b/docs/SampleBookLibraryItem.js new file mode 100644 index 00000000..390efa77 --- /dev/null +++ b/docs/SampleBookLibraryItem.js @@ -0,0 +1,189 @@ +/* + This is an example of a fully expanded book library item +*/ + +const LibraryItem = require('../server/objects/LibraryItem') + +new LibraryItem({ + id: 'li_abai123wir', + ino: "55450570412017066", + libraryId: 'lib_1239p1d8', + folderId: 'fol_192ab8901', + path: '/audiobooks/Terry Goodkind/Sword of Truth/1 - Wizards First Rule', + relPath: '/Terry Goodkind/Sword of Truth/1 - Wizards First Rule', + mtimeMs: 1646784672127, + ctimeMs: 1646784672127, + birthtimeMs: 1646784672127, + addedAt: 1646784672127, + updatedAt: 1646784672127, + lastScan: 1646784672127, + scanVersion: 1.72, + isMissing: false, + isInvalid: false, + mediaType: 'book', + media: { // Book.js + coverPath: '/metadata/items/li_abai123wir/cover.webp', + tags: ['favorites'], + lastCoverSearch: null, + lastCoverSearchQuery: null, + metadata: { // BookMetadata.js + title: 'Wizards First Rule', + subtitle: null, + authors: [ + { + id: 'au_42908lkajsfdk', + name: 'Terry Goodkind' + } + ], + narrators: ['Sam Tsoutsouvas'], + series: [ + { + id: 'se_902384lansf', + name: 'Sword of Truth', + sequence: 1 + } + ], + genres: ['Fantasy', 'Adventure'], + publishedYear: '1994', + publishedDate: '1994-01-01', + publisher: 'Brilliance Audio', + description: 'In the aftermath of the brutal murder of his father, a mysterious woman...', + isbn: '289374092834', + asin: '19023819203', + language: 'english', + explicit: false + }, + audiobooks: [ + { + id: 'au_289374asf0a98', + index: 1, + name: 'default', + audioFiles: [ + { // AudioFile.js + ino: "55450570412017066", + index: 1, + metadata: { // FileMetadata.js + filename: 'audiofile.mp3', + ext: '.mp3', + path: '/audiobooks/Terry Goodkind/Sword of Truth/1 - Wizards First Rule/CD01/audiofile.mp3', + relPath: '/CD01/audiofile.mp3', + mtimeMs: 1646784672127, + ctimeMs: 1646784672127, + birthtimeMs: 1646784672127, + size: 1197449516 + }, + trackNumFromMeta: 1, + discNumFromMeta: null, + trackNumFromFilename: null, + discNumFromFilename: 1, + manuallyVerified: false, + exclude: false, + invalid: false, + format: "MP2/3 (MPEG audio layer 2/3)", + duration: 2342342, + bitRate: 324234, + language: null, + codec: 'mp3', + timeBase: "1/14112000", + channels: 1, + channelLayout: "mono", + chapters: [], + embeddedCoverArt: 'jpeg', // Video stream codec ['mjpeg', 'jpeg', 'png'] or null + metaTags: { // AudioMetaTags.js + tagAlbum: '', + tagArtist: '', + tagGenre: '', + tagTitle: '', + tagSeries: '', + tagSeriesPart: '', + tagTrack: '', + tagDisc: '', + tagSubtitle: '', + tagAlbumArtist: '', + tagDate: '', + tagComposer: '', + tagPublisher: '', + tagComment: '', + tagDescription: '', + tagEncoder: '', + tagEncodedBy: '', + tagIsbn: '', + tagLanguage: '', + tagASIN: '' + }, + addedAt: 1646784672127, + updatedAt: 1646784672127 + } + ], + chapters: [ + { + id: 0, + title: 'Chapter 01', + start: 0, + end: 2467.753 + } + ], + missingParts: [4, 10], // Array of missing parts in tracklist + addedAt: 1646784672127, + updatedAt: 1646784672127 + } + ], + ebooks: [ + { + id: 'eb_289374asf0a98', + index: 1, + name: 'default', + ebookFile: { // EBookFile.js + ino: "55450570412017066", + metadata: { // FileMetadata.js + filename: 'ebookfile.mobi', + ext: '.mobi', + path: '/audiobooks/Terry Goodkind/Sword of Truth/1 - Wizards First Rule/ebookfile.mobi', + relPath: '/ebookfile.mobi', + mtimeMs: 1646784672127, + ctimeMs: 1646784672127, + birthtimeMs: 1646784672127, + size: 1197449516 + }, + ebookFormat: 'mobi', + addedAt: 1646784672127, + updatedAt: 1646784672127 + }, + addedAt: 1646784672127, + updatedAt: 1646784672127 + } + ] + }, + libraryFiles: [ + { // LibraryFile.js + ino: "55450570412017066", + metadata: { // FileMetadata.js + filename: 'cover.png', + ext: '.png', + path: '/audiobooks/Terry Goodkind/Sword of Truth/1 - Wizards First Rule/subfolder/cover.png', + relPath: '/subfolder/cover.png', + mtimeMs: 1646784672127, + ctimeMs: 1646784672127, + birthtimeMs: 1646784672127, + size: 1197449516 + }, + addedAt: 1646784672127, + updatedAt: 1646784672127 + }, + { // LibraryFile.js + ino: "55450570412017066", + metadata: { // FileMetadata.js + filename: 'cover.png', + ext: '.mobi', + path: '/audiobooks/Terry Goodkind/Sword of Truth/1 - Wizards First Rule/ebookfile.mobi', + relPath: '/ebookfile.mobi', + mtimeMs: 1646784672127, + ctimeMs: 1646784672127, + birthtimeMs: 1646784672127, + size: 1197449516 + }, + addedAt: 1646784672127, + updatedAt: 1646784672127 + } + ] +}) \ No newline at end of file diff --git a/docs/SamplePodcastLibraryItem.js b/docs/SamplePodcastLibraryItem.js new file mode 100644 index 00000000..42d95bd1 --- /dev/null +++ b/docs/SamplePodcastLibraryItem.js @@ -0,0 +1,83 @@ +/* + This is an example of a fully expanded podcast library item (under construction) +*/ + +const LibraryItem = require('../server/objects/LibraryItem') + +new LibraryItem({ + id: 'li_abai123wir', + ino: "55450570412017066", + libraryId: 'lib_1239p1d8', + folderId: 'fol_192ab8901', + path: '/podcasts/Great Podcast Name', + relPath: '/Great Podcast Name', + mtimeMs: 1646784672127, + ctimeMs: 1646784672127, + birthtimeMs: 1646784672127, + addedAt: 1646784672127, + updatedAt: 1646784672127, + lastScan: 1646784672127, + scanVersion: 1.72, + isMissing: false, + isInvalid: false, + mediaType: 'podcast', + media: { // Podcast.js + coverPath: '/metadata/items/li_abai123wir/cover.webp', + tags: ['favorites'], + lastCoverSearch: null, + lastCoverSearchQuery: null, + metadata: { // PodcastMetadata.js + title: 'Great Podcast Name', + artist: 'Some Artist Name', + genres: ['Fantasy', 'Adventure'], + publishedDate: '1994-01-01', + description: 'In the aftermath of the brutal murder of his father, a mysterious woman...', + feedUrl: '', + itunesPageUrl: '', + itunesId: '', + itunesArtistId: '', + explicit: false + }, + episodes: [ + { // PodcastEpisode.js + id: 'ep_289374asf0a98', + index: 1, + // TODO: podcast episode data and PodcastEpisodeMetadata + addedAt: 1646784672127, + updatedAt: 1646784672127 + } + ] + }, + libraryFiles: [ + { // LibraryFile.js + ino: "55450570412017066", + metadata: { // FileMetadata.js + filename: 'cover.png', + ext: '.png', + path: '/podcasts/Great Podcast Name/cover.png', + relPath: '/cover.png', + mtimeMs: 1646784672127, + ctimeMs: 1646784672127, + birthtimeMs: 1646784672127, + size: 1197449516 + }, + addedAt: 1646784672127, + updatedAt: 1646784672127 + }, + { // LibraryFile.js + ino: "55450570412017066", + metadata: { // FileMetadata.js + filename: 'episode_1.mp3', + ext: '.mp3', + path: '/podcasts/Great Podcast Name/episode_1.mp3', + relPath: '/episode_1.mp3', + mtimeMs: 1646784672127, + ctimeMs: 1646784672127, + birthtimeMs: 1646784672127, + size: 1197449516 + }, + addedAt: 1646784672127, + updatedAt: 1646784672127 + } + ] +}) \ No newline at end of file diff --git a/server/PlaybackSessionManager.js b/server/PlaybackSessionManager.js index 70473b4c..30303eef 100644 --- a/server/PlaybackSessionManager.js +++ b/server/PlaybackSessionManager.js @@ -11,17 +11,23 @@ class PlaybackSessionManager { this.sessions = [] } - startSessionRequest(req, res) { + async startSessionRequest(req, res) { var user = req.user var libraryItem = req.libraryItem - var options = req.query - const session = this.startSession(user, libraryItem, options) + var options = req.query || {} + const session = await this.startSession(user, libraryItem, options) res.json(session) } - startSession(user, libraryItem, options) { + async startSession(user, libraryItem, options) { // TODO: Determine what play method to use and setup playback session + // temporary client can pass direct=1 in query string for direct play + if (options.direct) { + var tracks = libraryItem.media.getDirectPlayTracklist(options) + } + const newPlaybackSession = new PlaybackSession() + newPlaybackSession.setData(libraryItem, user) this.sessions.push(newPlaybackSession) return newPlaybackSession } diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index 2db38b7e..3466f6eb 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -1,8 +1,8 @@ const { version } = require('../../package.json') const Logger = require('../Logger') const LibraryFile = require('./files/LibraryFile') -const Book = require('./entities/Book') -const Podcast = require('./entities/Podcast') +const Book = require('./mediaTypes/Book') +const Podcast = require('./mediaTypes/Podcast') const { areEquivalent, copyValue, getId } = require('../utils/index') class LibraryItem { @@ -143,8 +143,8 @@ class LibraryItem { get hasAudioFiles() { return this.libraryFiles.some(lf => lf.fileType === 'audio') } - get hasMediaFiles() { - return this.media.hasMediaFiles + get hasMediaEntities() { + return this.media.hasMediaEntities } // Data comes from scandir library item data @@ -357,7 +357,7 @@ class LibraryItem { } // Check if invalid - this.isInvalid = !this.media.hasMediaFiles + this.isInvalid = !this.media.hasMediaEntities // If cover path is in item folder, make sure libraryFile exists for it if (this.media.coverPath && this.media.coverPath.startsWith(this.path)) { @@ -432,5 +432,9 @@ class LibraryItem { query = query.toLowerCase() return this.media.searchQuery(query) } + + getDirectPlayTracklist(options) { + return this.media.getDirectPlayTracklist(options) + } } module.exports = LibraryItem \ No newline at end of file diff --git a/server/objects/entities/Audiobook.js b/server/objects/entities/Audiobook.js new file mode 100644 index 00000000..bf7dde28 --- /dev/null +++ b/server/objects/entities/Audiobook.js @@ -0,0 +1,218 @@ +const Path = require('path') +const AudioFile = require('../files/AudioFile') +const { areEquivalent, copyValue } = require('../../utils/index') + +class Audiobook { + constructor(audiobook) { + this.id = null + this.index = null + this.name = null + this.audioFiles = [] + this.chapters = [] + this.missingParts = [] + this.addedAt = null + this.updatedAt = null + + if (audiobook) { + this.construct(audiobook) + } + } + + construct(audiobook) { + this.id = audiobook.id + this.index = audiobook.index + this.name = audiobook.name || null + this.audioFiles = audiobook.audioFiles.map(f => new AudioFile(f)) + this.chapters = audiobook.chapters.map(c => ({ ...c })) + this.missingParts = audiobook.missingParts ? [...book.missingParts] : [] + this.addedAt = audiobook.addedAt + this.updatedAt = audiobook.updatedAt + } + + toJSON() { + return { + id: this.id, + index: this.index, + name: this.name, + audioFiles: this.audioFiles.map(f => f.toJSON()), + chapters: this.chapters.map(c => ({ ...c })), + missingParts: [...this.missingParts], + addedAt: this.addedAt, + updatedAt: this.updatedAt + } + } + + toJSONMinified() { + return { + id: this.id, + index: this.index, + name: this.name, + numTracks: this.tracks.length, + numAudioFiles: this.audioFiles.length, + numChapters: this.chapters.length, + numMissingParts: this.missingParts.length, + duration: this.duration, + size: this.size, + addedAt: this.addedAt, + updatedAt: this.updatedAt + } + } + + toJSONExpanded() { + return { + id: this.id, + index: this.index, + name: this.name, + audioFiles: this.audioFiles.map(f => f.toJSON()), + chapters: this.chapters.map(c => ({ ...c })), + duration: this.duration, + size: this.size, + tracks: this.tracks.map(t => t.toJSON()), + missingParts: [...this.missingParts], + addedAt: this.addedAt, + updatedAt: this.updatedAt + } + } + + get tracks() { + return this.audioFiles.filter(af => !af.exclude && !af.invalid) + } + get duration() { + var total = 0 + this.tracks.forEach((track) => total += track.duration) + return total + } + get size() { + var total = 0 + this.audioFiles.forEach((af) => total += af.metadata.size) + return total + } + get hasEmbeddedCoverArt() { + return this.audioFiles.some(af => af.embeddedCoverArt) + } + + update(payload) { + var json = this.toJSON() + var hasUpdates = false + for (const key in json) { + if (payload[key] !== undefined) { + if (!areEquivalent(payload[key], json[key])) { + this[key] = copyValue(payload[key]) + Logger.debug('[Audiobook] Key updated', key, this[key]) + hasUpdates = true + } + } + } + return hasUpdates + } + + updateAudioTracks(orderedFileData) { + var index = 1 + this.audioFiles = orderedFileData.map((fileData) => { + var audioFile = this.audioFiles.find(af => af.ino === fileData.ino) + audioFile.manuallyVerified = true + audioFile.invalid = false + audioFile.error = null + if (fileData.exclude !== undefined) { + audioFile.exclude = !!fileData.exclude + } + if (audioFile.exclude) { + audioFile.index = -1 + } else { + audioFile.index = index++ + } + return audioFile + }) + + this.rebuildTracks() + } + + rebuildTracks() { + this.audioFiles.sort((a, b) => a.index - b.index) + this.missingParts = [] + this.setChapters() + this.checkUpdateMissingTracks() + } + + checkUpdateMissingTracks() { + var currMissingParts = (this.missingParts || []).join(',') || '' + + var current_index = 1 + var missingParts = [] + + for (let i = 0; i < this.tracks.length; i++) { + var _track = this.tracks[i] + if (_track.index > current_index) { + var num_parts_missing = _track.index - current_index + for (let x = 0; x < num_parts_missing && x < 9999; x++) { + missingParts.push(current_index + x) + } + } + current_index = _track.index + 1 + } + + this.missingParts = missingParts + + var newMissingParts = (this.missingParts || []).join(',') || '' + var wasUpdated = newMissingParts !== currMissingParts + if (wasUpdated && this.missingParts.length) { + Logger.info(`[Audiobook] "${this.name}" has ${missingParts.length} missing parts`) + } + + return wasUpdated + } + + setChapters() { + // If 1 audio file without chapters, then no chapters will be set + var includedAudioFiles = this.audioFiles.filter(af => !af.exclude) + if (includedAudioFiles.length === 1) { + // 1 audio file with chapters + if (includedAudioFiles[0].chapters) { + this.chapters = includedAudioFiles[0].chapters.map(c => ({ ...c })) + } + } else { + this.chapters = [] + var currChapterId = 0 + var currStartTime = 0 + includedAudioFiles.forEach((file) => { + // If audio file has chapters use chapters + if (file.chapters && file.chapters.length) { + file.chapters.forEach((chapter) => { + var chapterDuration = chapter.end - chapter.start + if (chapterDuration > 0) { + var title = `Chapter ${currChapterId}` + if (chapter.title) { + title += ` (${chapter.title})` + } + this.chapters.push({ + id: currChapterId++, + start: currStartTime, + end: currStartTime + chapterDuration, + title + }) + currStartTime += chapterDuration + } + }) + } else if (file.duration) { + // Otherwise just use track has chapter + this.chapters.push({ + id: currChapterId++, + start: currStartTime, + end: currStartTime + file.duration, + title: file.metadata.filename ? Path.basename(file.metadata.filename, Path.extname(file.metadata.filename)) : `Chapter ${currChapterId}` + }) + currStartTime += file.duration + } + }) + } + } + + findFileWithInode(inode) { + return this.audioFiles.find(af => af.ino === inode) + } + + removeFileWithInode(inode) { + this.audioFiles = this.audioFiles.filter(af => af.ino !== inode) + } +} +module.exports = Audiobook \ No newline at end of file diff --git a/server/objects/entities/EBook.js b/server/objects/entities/EBook.js new file mode 100644 index 00000000..f91c2511 --- /dev/null +++ b/server/objects/entities/EBook.js @@ -0,0 +1,70 @@ +const EBookFile = require('../files/EBookFile') +const { areEquivalent, copyValue } = require('../../utils/index') + +class EBook { + constructor(ebook) { + this.id = null + this.index = null + this.name = null + this.ebookFile = null + this.addedAt = null + this.updatedAt = null + + if (ebook) { + this.construct(ebook) + } + } + + construct(ebook) { + this.id = ebook.id + this.index = ebook.index + this.name = ebook.name + this.ebookFile = new EBookFile(ebook.ebookFile) + this.addedAt = ebook.addedAt + this.updatedAt = ebook.updatedAt + } + + toJSON() { + return { + id: this.id, + index: this.index, + name: this.name, + ebookFile: this.ebookFile.toJSON(), + addedAt: this.addedAt, + updatedAt: this.updatedAt + } + } + + toJSONMinified() { + return { + id: this.id, + index: this.index, + name: this.name, + ebookFormat: this.ebookFile.ebookFormat, + addedAt: this.addedAt, + updatedAt: this.updatedAt, + size: this.size + } + } + + toJSONMinified() { + return { + id: this.id, + index: this.index, + name: this.name, + ebookFile: this.ebookFile.toJSON(), + addedAt: this.addedAt, + updatedAt: this.updatedAt, + size: this.size + } + } + + get size() { + return this.ebookFile.metadata.size + } + + findFileWithInode(inode) { + return this.ebookFile.ino === inode + } +} +module.exports = EBook \ No newline at end of file diff --git a/server/objects/entities/PodcastEpisode.js b/server/objects/entities/PodcastEpisode.js index 08070e1c..505a6b4a 100644 --- a/server/objects/entities/PodcastEpisode.js +++ b/server/objects/entities/PodcastEpisode.js @@ -3,6 +3,7 @@ const AudioFile = require('../files/AudioFile') class PodcastEpisode { constructor(episode) { this.id = null + this.index = null this.podcastId = null this.episodeNumber = null @@ -17,6 +18,7 @@ class PodcastEpisode { construct(episode) { this.id = episode.id + this.index = episode.index this.podcastId = episode.podcastId this.episodeNumber = episode.episodeNumber this.audioFile = new AudioFile(episode.audioFile) @@ -27,6 +29,7 @@ class PodcastEpisode { toJSON() { return { id: this.id, + index: this.index, podcastId: this.podcastId, episodeNumber: this.episodeNumber, audioFile: this.audioFile.toJSON(), diff --git a/server/objects/entities/Book.js b/server/objects/mediaTypes/Book.js similarity index 60% rename from server/objects/entities/Book.js rename to server/objects/mediaTypes/Book.js index 1eb547c7..3eefc3d9 100644 --- a/server/objects/entities/Book.js +++ b/server/objects/mediaTypes/Book.js @@ -1,23 +1,23 @@ const Path = require('path') const Logger = require('../../Logger') const BookMetadata = require('../metadata/BookMetadata') -const AudioFile = require('../files/AudioFile') -const EBookFile = require('../files/EBookFile') const abmetadataGenerator = require('../../utils/abmetadataGenerator') const { areEquivalent, copyValue } = require('../../utils/index') const { parseOpfMetadataXML } = require('../../utils/parseOpfMetadata') const { readTextFile } = require('../../utils/fileUtils') +const Audiobook = require('../entities/Audiobook') +const EBook = require('../entities/EBook') + class Book { constructor(book) { this.metadata = null this.coverPath = null this.tags = [] - this.audioFiles = [] - this.ebookFiles = [] - this.chapters = [] - this.missingParts = [] + + this.audiobooks = [] + this.ebooks = [] this.lastCoverSearch = null this.lastCoverSearchQuery = null @@ -31,10 +31,8 @@ class Book { this.metadata = new BookMetadata(book.metadata) this.coverPath = book.coverPath this.tags = [...book.tags] - this.audioFiles = book.audioFiles.map(f => new AudioFile(f)) - this.ebookFiles = book.ebookFiles.map(f => new EBookFile(f)) - this.chapters = book.chapters.map(c => ({ ...c })) - this.missingParts = book.missingParts ? [...book.missingParts] : [] + this.audiobooks = book.audiobooks.map(ab => new Audiobook(ab)) + this.ebooks = book.ebooks.map(eb => new EBook(eb)) this.lastCoverSearch = book.lastCoverSearch || null this.lastCoverSearchQuery = book.lastCoverSearchQuery || null } @@ -44,10 +42,8 @@ class Book { metadata: this.metadata.toJSON(), coverPath: this.coverPath, tags: [...this.tags], - audioFiles: this.audioFiles.map(f => f.toJSON()), - ebookFiles: this.ebookFiles.map(f => f.toJSON()), - chapters: this.chapters.map(c => ({ ...c })), - missingParts: [...this.missingParts] + audiobooks: this.audiobooks.map(ab => ab.toJSON()), + ebooks: this.ebooks.map(eb => eb.toJSON()) } } @@ -56,11 +52,8 @@ class Book { metadata: this.metadata.toJSON(), coverPath: this.coverPath, tags: [...this.tags], - numTracks: this.tracks.length, - numAudioFiles: this.audioFiles.length, - numEbooks: this.ebookFiles.length, - numChapters: this.chapters.length, - numMissingParts: this.missingParts.length, + audiobooks: this.audiobooks.map(ab => ab.toJSONMinified()), + ebooks: this.ebooks.map(eb => eb.toJSONMinified()), duration: this.duration, size: this.size } @@ -71,13 +64,10 @@ class Book { metadata: this.metadata.toJSONExpanded(), coverPath: this.coverPath, tags: [...this.tags], - audioFiles: this.audioFiles.map(f => f.toJSON()), - ebookFiles: this.ebookFiles.map(f => f.toJSON()), - chapters: this.chapters.map(c => ({ ...c })), + audiobooks: this.audiobooks.map(ab => ab.toJSONExpanded()), + ebooks: this.ebooks.map(eb => eb.toJSONExpanded()), duration: this.duration, size: this.size, - tracks: this.tracks.map(t => t.toJSON()), - missingParts: [...this.missingParts] } } @@ -94,8 +84,8 @@ class Book { this.audioFiles.forEach((af) => total += af.metadata.size) return total } - get hasMediaFiles() { - return !!(this.tracks.length + this.ebookFiles.length) + get hasMediaEntities() { + return !!(this.audiobooks.length + this.ebooks.length) } get shouldSearchForCover() { if (this.coverPath) return false @@ -103,11 +93,14 @@ class Book { return (Date.now() - this.lastCoverSearch) > 1000 * 60 * 60 * 24 * 7 // 7 day } get hasEmbeddedCoverArt() { - return this.audioFiles.some(af => af.embeddedCoverArt) + return this.audiobooks.some(ab => ab.hasEmbeddedCoverArt) } update(payload) { var json = this.toJSON() + delete json.audiobooks // do not update media entities here + delete json.ebooks + var hasUpdates = false for (const key in json) { if (payload[key] !== undefined) { @@ -125,27 +118,6 @@ class Book { return hasUpdates } - updateAudioTracks(orderedFileData) { - var index = 1 - this.audioFiles = orderedFileData.map((fileData) => { - var audioFile = this.audioFiles.find(af => af.ino === fileData.ino) - audioFile.manuallyVerified = true - audioFile.invalid = false - audioFile.error = null - if (fileData.exclude !== undefined) { - audioFile.exclude = !!fileData.exclude - } - if (audioFile.exclude) { - audioFile.index = -1 - } else { - audioFile.index = index++ - } - return audioFile - }) - - this.rebuildTracks() - } - updateCover(coverPath) { coverPath = coverPath.replace(/\\/g, '/') if (this.coverPath === coverPath) return false @@ -153,50 +125,27 @@ class Book { return true } - checkUpdateMissingTracks() { - var currMissingParts = (this.missingParts || []).join(',') || '' - - var current_index = 1 - var missingParts = [] - - for (let i = 0; i < this.tracks.length; i++) { - var _track = this.tracks[i] - if (_track.index > current_index) { - var num_parts_missing = _track.index - current_index - for (let x = 0; x < num_parts_missing && x < 9999; x++) { - missingParts.push(current_index + x) - } - } - current_index = _track.index + 1 - } - - this.missingParts = missingParts - - var newMissingParts = (this.missingParts || []).join(',') || '' - var wasUpdated = newMissingParts !== currMissingParts - if (wasUpdated && this.missingParts.length) { - Logger.info(`[Book] "${this.metadata.title}" has ${missingParts.length} missing parts`) - } - - return wasUpdated - } - removeFileWithInode(inode) { - if (this.audioFiles.some(af => af.ino === inode)) { - this.audioFiles = this.audioFiles.filter(af => af.ino !== inode) + var audiobookWithIno = this.audiobooks.find(ab => ab.findFileWithInode(inode)) + if (audiobookWithIno) { + audiobookWithIno.removeFileWithInode(inode) + if (!audiobookWithIno.audioFiles.length) { // All audio files removed = remove audiobook + this.audiobooks = this.audiobooks.filter(ab => ab.id !== audiobookWithIno.id) + } return true } - if (this.ebookFiles.some(ef => ef.ino === inode)) { - this.ebookFiles = this.ebookFiles.filter(ef => ef.ino !== inode) + var ebookWithIno = this.ebooks.find(eb => eb.findFileWithInode(inode)) + if (ebookWithIno) { + this.ebooks = this.ebooks.filter(eb => eb.id !== ebookWithIno.id) // Remove ebook return true } return false } findFileWithInode(inode) { - var audioFile = this.audioFiles.find(af => af.ino == inode) + var audioFile = this.audiobooks.find(ab => ab.findFileWithInode(inode)) if (audioFile) return audioFile - var ebookFile = this.ebookFiles.find(ef => ef.inode == inode) + var ebookFile = this.ebooks.find(eb => eb.findFileWithInode(inode)) if (ebookFile) return ebookFile return null } @@ -208,64 +157,13 @@ class Book { // Audio file metadata tags map to book details (will not overwrite) setMetadataFromAudioFile(overrideExistingDetails = false) { - if (!this.audioFiles.length) return false - var audioFile = this.audioFiles[0] + if (!this.audiobooks.length) return false + var audiobook = this.audiobooks[0] + var audioFile = audiobook.audioFiles[0] if (!audioFile.metaTags) return false return this.metadata.setDataFromAudioMetaTags(audioFile.metaTags, overrideExistingDetails) } - rebuildTracks() { - this.audioFiles.sort((a, b) => a.index - b.index) - this.missingParts = [] - this.setChapters() - this.checkUpdateMissingTracks() - } - - setChapters() { - // If 1 audio file without chapters, then no chapters will be set - var includedAudioFiles = this.audioFiles.filter(af => !af.exclude) - if (includedAudioFiles.length === 1) { - // 1 audio file with chapters - if (includedAudioFiles[0].chapters) { - this.chapters = includedAudioFiles[0].chapters.map(c => ({ ...c })) - } - } else { - this.chapters = [] - var currChapterId = 0 - var currStartTime = 0 - includedAudioFiles.forEach((file) => { - // If audio file has chapters use chapters - if (file.chapters && file.chapters.length) { - file.chapters.forEach((chapter) => { - var chapterDuration = chapter.end - chapter.start - if (chapterDuration > 0) { - var title = `Chapter ${currChapterId}` - if (chapter.title) { - title += ` (${chapter.title})` - } - this.chapters.push({ - id: currChapterId++, - start: currStartTime, - end: currStartTime + chapterDuration, - title - }) - currStartTime += chapterDuration - } - }) - } else if (file.duration) { - // Otherwise just use track has chapter - this.chapters.push({ - id: currChapterId++, - start: currStartTime, - end: currStartTime + file.duration, - title: file.metadata.filename ? Path.basename(file.metadata.filename, Path.extname(file.metadata.filename)) : `Chapter ${currChapterId}` - }) - currStartTime += file.duration - } - }) - } - } - setData(scanMediaMetadata) { this.metadata = new BookMetadata() this.metadata.setData(scanMediaMetadata) @@ -365,9 +263,13 @@ class Book { } addEbookFile(libraryFile) { - var newEbook = new EBookFile() - newEbook.setData(libraryFile) - this.ebookFiles.push(newEbook) + // var newEbook = new EBookFile() + // newEbook.setData(libraryFile) + // this.ebookFiles.push(newEbook) + } + + getDirectPlayTracklist(options) { + } } module.exports = Book \ No newline at end of file diff --git a/server/objects/entities/Podcast.js b/server/objects/mediaTypes/Podcast.js similarity index 93% rename from server/objects/entities/Podcast.js rename to server/objects/mediaTypes/Podcast.js index aa4aca48..9d4d7a6e 100644 --- a/server/objects/entities/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -1,4 +1,4 @@ -const PodcastEpisode = require('./PodcastEpisode') +const PodcastEpisode = require('../entities/PodcastEpisode') const PodcastMetadata = require('../metadata/PodcastMetadata') const { areEquivalent, copyValue } = require('../../utils/index') @@ -68,7 +68,7 @@ class Podcast { get size() { return 0 } - get hasMediaFiles() { + get hasMediaEntities() { return !!this.episodes.length } get shouldSearchForCover() { @@ -80,6 +80,7 @@ class Podcast { update(payload) { var json = this.toJSON() + delete json.episodes // do not update media entities here var hasUpdates = false for (const key in json) { if (payload[key] !== undefined) { @@ -129,5 +130,9 @@ class Podcast { var payload = this.metadata.searchQuery(query) return payload || {} } + + getDirectPlayTracklist(options) { + + } } module.exports = Podcast \ No newline at end of file diff --git a/server/objects/metadata/FileMetadata.js b/server/objects/metadata/FileMetadata.js index cdc271d5..d2226cdd 100644 --- a/server/objects/metadata/FileMetadata.js +++ b/server/objects/metadata/FileMetadata.js @@ -49,6 +49,9 @@ class FileMetadata { if (!this.ext) return '' return this.ext.slice(1) } + get filenameNoExt() { + return this.filename.replace(this.ext, '') + } update(payload) { var hasUpdates = false diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index 45287da6..37e431f1 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -109,7 +109,7 @@ class Scanner { } } - if (!libraryItem.hasMediaFiles) { // Library Item is invalid + if (!libraryItem.hasMediaEntities) { // Library Item is invalid libraryItem.setInvalid() hasUpdated = true } else if (libraryItem.isInvalid) { @@ -357,7 +357,7 @@ class Scanner { // Temp authors & series are inserted - create them if found await this.createNewAuthorsAndSeries(libraryItem) - if (!libraryItem.media.hasMediaFiles) { // Library item is invalid + if (!libraryItem.hasMediaEntities) { // Library item is invalid libraryItem.setInvalid() hasUpdated = true } else if (libraryItem.isInvalid) { @@ -387,7 +387,7 @@ class Scanner { await AudioFileScanner.scanAudioFiles(audioFiles, libraryItemData, libraryItem, preferAudioMetadata, libraryScan) } - if (!libraryItem.media.hasMediaFiles) { + if (!libraryItem.hasMediaEntities) { Logger.warn(`[Scanner] Library item has no media files "${libraryItemData.path}"`) return null } diff --git a/server/utils/dbMigration.js b/server/utils/dbMigration.js index df9e65f1..f0cd84ce 100644 --- a/server/utils/dbMigration.js +++ b/server/utils/dbMigration.js @@ -3,21 +3,27 @@ const fs = require('fs-extra') const njodb = require("njodb") const { SupportedEbookTypes } = require('./globals') -const Audiobook = require('../objects/legacy/Audiobook') +const LegacyAudiobook = require('../objects/legacy/Audiobook') const UserAudiobookData = require('../objects/legacy/UserAudiobookData') const LibraryItem = require('../objects/LibraryItem') const Logger = require('../Logger') -const Book = require('../objects/entities/Book') +const Book = require('../objects/mediaTypes/Book') + const BookMetadata = require('../objects/metadata/BookMetadata') -const Author = require('../objects/entities/Author') -const Series = require('../objects/entities/Series') +const FileMetadata = require('../objects/metadata/FileMetadata') + const AudioFile = require('../objects/files/AudioFile') const EBookFile = require('../objects/files/EBookFile') const LibraryFile = require('../objects/files/LibraryFile') -const FileMetadata = require('../objects/metadata/FileMetadata') const AudioMetaTags = require('../objects/metadata/AudioMetaTags') + +const Author = require('../objects/entities/Author') +const Series = require('../objects/entities/Series') +const Audiobook = require('../objects/entities/Audiobook') +const EBook = require('../objects/entities/EBook') + const LibraryItemProgress = require('../objects/user/LibraryItemProgress') const PlaybackSession = require('../objects/PlaybackSession') @@ -40,7 +46,7 @@ async function loadAudiobooks() { var audiobooksDb = new njodb.Database(audiobookPath) return audiobooksDb.select(() => true).then((results) => { - return results.data.map(a => new Audiobook(a)) + return results.data.map(a => new LegacyAudiobook(a)) }) } @@ -144,7 +150,7 @@ function makeFilesFromOldAb(audiobook) { function makeLibraryItemFromOldAb(audiobook) { var libraryItem = new LibraryItem() - libraryItem.id = audiobook.id + libraryItem.id = getId('li') libraryItem.ino = audiobook.ino libraryItem.libraryId = audiobook.libraryId libraryItem.folderId = audiobook.folderId @@ -154,7 +160,7 @@ function makeLibraryItemFromOldAb(audiobook) { libraryItem.ctimeMs = audiobook.ctimeMs || 0 libraryItem.birthtimeMs = audiobook.birthtimeMs || 0 libraryItem.addedAt = audiobook.addedAt - libraryItem.lastUpdate = audiobook.lastUpdate + libraryItem.updatedAt = audiobook.lastUpdate libraryItem.lastScan = audiobook.lastScan libraryItem.scanVersion = audiobook.scanVersion libraryItem.isMissing = audiobook.isMissing @@ -179,13 +185,35 @@ function makeLibraryItemFromOldAb(audiobook) { bookEntity.tags = [...audiobook.tags] var payload = makeFilesFromOldAb(audiobook) - bookEntity.audioFiles = payload.audioFiles - bookEntity.ebookFiles = payload.ebookFiles + if (payload.audioFiles.length) { + var newAudiobook = new Audiobook() + newAudiobook.id = audiobook.id + newAudiobook.index = 1 + newAudiobook.name = 'default' + newAudiobook.audioFiles = payload.audioFiles + if (audiobook.chapters && audiobook.chapters.length) { + newAudiobook.chapters = audiobook.chapters.map(c => ({ ...c })) + } + newAudiobook.missingParts = audiobook.missingParts || [] + newAudiobook.addedAt = audiobook.addedAt + newAudiobook.updatedAt = audiobook.lastUpdate - if (audiobook.chapters && audiobook.chapters.length) { - bookEntity.chapters = audiobook.chapters.map(c => ({ ...c })) + bookEntity.audiobooks.push(newAudiobook) } + var ebookIndex = 1 + payload.ebookFiles.forEach(ebookFile => { + var newEBook = new EBook() + newEBook.id = getId('eb') + newEBook.index = ebookIndex++ + newEBook.name = ebookFile.metadata.filenameNoExt + newEBook.ebookFile = ebookFile + newEBook.addedAt = audiobook.addedAt + newEBook.updatedAt = audiobook.lastUpdate + + bookEntity.ebooks.push(newEBook) + }) + libraryItem.media = bookEntity libraryItem.libraryFiles = payload.libraryFiles return libraryItem @@ -234,8 +262,8 @@ async function migrateLibraryItems(db) { } module.exports.migrateLibraryItems = migrateLibraryItems -function cleanUserObject(db, userObj) { +function cleanUserObject(db, userObj) { var cleanedUserPayload = { ...userObj, libraryItemProgress: [],