Migrate tools and collapse series. fix continue shelves. remove old objects

This commit is contained in:
advplyr 2025-01-05 14:09:03 -06:00
parent ac159bea72
commit 108eaba022
21 changed files with 132 additions and 1341 deletions

View file

@ -1,153 +0,0 @@
const fs = require('../libs/fsExtra')
const Path = require('path')
const Logger = require('../Logger')
const LibraryFile = require('./files/LibraryFile')
const Book = require('./mediaTypes/Book')
const Podcast = require('./mediaTypes/Podcast')
const { areEquivalent, copyValue } = require('../utils/index')
const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
class LibraryItem {
constructor(libraryItem = null) {
this.id = null
this.ino = null // Inode
this.oldLibraryItemId = null
this.libraryId = null
this.folderId = null
this.path = null
this.relPath = null
this.isFile = false
this.mtimeMs = null
this.ctimeMs = null
this.birthtimeMs = null
this.addedAt = null
this.updatedAt = null
this.lastScan = null
this.scanVersion = null
// Was scanned and no longer exists
this.isMissing = false
// Was scanned and no longer has media files
this.isInvalid = false
this.mediaType = null
this.media = null
/** @type {LibraryFile[]} */
this.libraryFiles = []
if (libraryItem) {
this.construct(libraryItem)
}
// Temporary attributes
this.isSavingMetadata = false
}
construct(libraryItem) {
this.id = libraryItem.id
this.ino = libraryItem.ino || null
this.oldLibraryItemId = libraryItem.oldLibraryItemId
this.libraryId = libraryItem.libraryId
this.folderId = libraryItem.folderId
this.path = libraryItem.path
this.relPath = libraryItem.relPath
this.isFile = !!libraryItem.isFile
this.mtimeMs = libraryItem.mtimeMs || 0
this.ctimeMs = libraryItem.ctimeMs || 0
this.birthtimeMs = libraryItem.birthtimeMs || 0
this.addedAt = libraryItem.addedAt
this.updatedAt = libraryItem.updatedAt || this.addedAt
this.lastScan = libraryItem.lastScan || null
this.scanVersion = libraryItem.scanVersion || null
this.isMissing = !!libraryItem.isMissing
this.isInvalid = !!libraryItem.isInvalid
this.mediaType = libraryItem.mediaType
if (this.mediaType === 'book') {
this.media = new Book(libraryItem.media)
} else if (this.mediaType === 'podcast') {
this.media = new Podcast(libraryItem.media)
}
this.media.libraryItemId = this.id
this.libraryFiles = libraryItem.libraryFiles.map((f) => new LibraryFile(f))
// Migration for v2.2.23 to set ebook library files as supplementary
if (this.isBook && this.media.ebookFile) {
for (const libraryFile of this.libraryFiles) {
if (libraryFile.isEBookFile && libraryFile.isSupplementary === null) {
libraryFile.isSupplementary = this.media.ebookFile.ino !== libraryFile.ino
}
}
}
}
toJSON() {
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.oldLibraryItemId,
libraryId: this.libraryId,
folderId: this.folderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
addedAt: this.addedAt,
updatedAt: this.updatedAt,
lastScan: this.lastScan,
scanVersion: this.scanVersion,
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid,
mediaType: this.mediaType,
media: this.media.toJSON(),
libraryFiles: this.libraryFiles.map((f) => f.toJSON())
}
}
toJSONMinified() {
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.oldLibraryItemId,
libraryId: this.libraryId,
folderId: this.folderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
addedAt: this.addedAt,
updatedAt: this.updatedAt,
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid,
mediaType: this.mediaType,
media: this.media.toJSONMinified(),
numFiles: this.libraryFiles.length,
size: this.size
}
}
get isPodcast() {
return this.mediaType === 'podcast'
}
get isBook() {
return this.mediaType === 'book'
}
get size() {
let total = 0
this.libraryFiles.forEach((lf) => (total += lf.metadata.size))
return total
}
get hasAudioFiles() {
return this.libraryFiles.some((lf) => lf.fileType === 'audio')
}
}
module.exports = LibraryItem

View file

@ -1,149 +0,0 @@
const { areEquivalent, copyValue } = require('../../utils/index')
const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack')
class PodcastEpisode {
constructor(episode) {
this.libraryItemId = null
this.podcastId = null
this.id = null
this.oldEpisodeId = null
this.index = null
this.season = null
this.episode = null
this.episodeType = null
this.title = null
this.subtitle = null
this.description = null
this.enclosure = null
this.guid = null
this.pubDate = null
this.chapters = []
this.audioFile = null
this.publishedAt = null
this.addedAt = null
this.updatedAt = null
if (episode) {
this.construct(episode)
}
}
construct(episode) {
this.libraryItemId = episode.libraryItemId
this.podcastId = episode.podcastId
this.id = episode.id
this.oldEpisodeId = episode.oldEpisodeId
this.index = episode.index
this.season = episode.season
this.episode = episode.episode
this.episodeType = episode.episodeType
this.title = episode.title
this.subtitle = episode.subtitle
this.description = episode.description
this.enclosure = episode.enclosure ? { ...episode.enclosure } : null
this.guid = episode.guid || null
this.pubDate = episode.pubDate
this.chapters = episode.chapters?.map((ch) => ({ ...ch })) || []
this.audioFile = episode.audioFile ? new AudioFile(episode.audioFile) : null
this.publishedAt = episode.publishedAt
this.addedAt = episode.addedAt
this.updatedAt = episode.updatedAt
if (this.audioFile) {
this.audioFile.index = 1 // Only 1 audio file per episode
}
}
toJSON() {
return {
libraryItemId: this.libraryItemId,
podcastId: this.podcastId,
id: this.id,
oldEpisodeId: this.oldEpisodeId,
index: this.index,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
title: this.title,
subtitle: this.subtitle,
description: this.description,
enclosure: this.enclosure ? { ...this.enclosure } : null,
guid: this.guid,
pubDate: this.pubDate,
chapters: this.chapters.map((ch) => ({ ...ch })),
audioFile: this.audioFile?.toJSON() || null,
publishedAt: this.publishedAt,
addedAt: this.addedAt,
updatedAt: this.updatedAt
}
}
toJSONExpanded() {
return {
libraryItemId: this.libraryItemId,
podcastId: this.podcastId,
id: this.id,
oldEpisodeId: this.oldEpisodeId,
index: this.index,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
title: this.title,
subtitle: this.subtitle,
description: this.description,
enclosure: this.enclosure ? { ...this.enclosure } : null,
guid: this.guid,
pubDate: this.pubDate,
chapters: this.chapters.map((ch) => ({ ...ch })),
audioFile: this.audioFile?.toJSON() || null,
audioTrack: this.audioTrack?.toJSON() || null,
publishedAt: this.publishedAt,
addedAt: this.addedAt,
updatedAt: this.updatedAt,
duration: this.duration,
size: this.size
}
}
get audioTrack() {
if (!this.audioFile) return null
const audioTrack = new AudioTrack()
audioTrack.setData(this.libraryItemId, this.audioFile, 0)
return audioTrack
}
get tracks() {
return [this.audioTrack]
}
get duration() {
return this.audioFile?.duration || 0
}
get size() {
return this.audioFile?.metadata.size || 0
}
get enclosureUrl() {
return this.enclosure?.url || null
}
update(payload) {
let hasUpdates = false
for (const key in this.toJSON()) {
let newValue = payload[key]
if (newValue === '') newValue = null
let existingValue = this[key]
if (existingValue === '') existingValue = null
if (newValue != undefined && !areEquivalent(newValue, existingValue)) {
this[key] = copyValue(newValue)
hasUpdates = true
}
}
if (hasUpdates) {
this.updatedAt = Date.now()
}
return hasUpdates
}
}
module.exports = PodcastEpisode

View file

@ -1,138 +0,0 @@
const Logger = require('../../Logger')
const BookMetadata = require('../metadata/BookMetadata')
const { areEquivalent, copyValue } = require('../../utils/index')
const { filePathToPOSIX } = require('../../utils/fileUtils')
const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack')
const EBookFile = require('../files/EBookFile')
class Book {
constructor(book) {
this.id = null
this.libraryItemId = null
this.metadata = null
this.coverPath = null
this.tags = []
this.audioFiles = []
this.chapters = []
this.ebookFile = null
this.lastCoverSearch = null
this.lastCoverSearchQuery = null
if (book) {
this.construct(book)
}
}
construct(book) {
this.id = book.id
this.libraryItemId = book.libraryItemId
this.metadata = new BookMetadata(book.metadata)
this.coverPath = book.coverPath
this.tags = [...book.tags]
this.audioFiles = book.audioFiles.map((f) => new AudioFile(f))
this.chapters = book.chapters.map((c) => ({ ...c }))
this.ebookFile = book.ebookFile ? new EBookFile(book.ebookFile) : null
this.lastCoverSearch = book.lastCoverSearch || null
this.lastCoverSearchQuery = book.lastCoverSearchQuery || null
}
toJSON() {
return {
id: this.id,
libraryItemId: this.libraryItemId,
metadata: this.metadata.toJSON(),
coverPath: this.coverPath,
tags: [...this.tags],
audioFiles: this.audioFiles.map((f) => f.toJSON()),
chapters: this.chapters.map((c) => ({ ...c })),
ebookFile: this.ebookFile ? this.ebookFile.toJSON() : null
}
}
toJSONMinified() {
return {
id: this.id,
metadata: this.metadata.toJSONMinified(),
coverPath: this.coverPath,
tags: [...this.tags],
numTracks: this.tracks.length,
numAudioFiles: this.audioFiles.length,
numChapters: this.chapters.length,
duration: this.duration,
size: this.size,
ebookFormat: this.ebookFile?.ebookFormat
}
}
toJSONForMetadataFile() {
return {
tags: [...this.tags],
chapters: this.chapters.map((c) => ({ ...c })),
...this.metadata.toJSONForMetadataFile()
}
}
get size() {
var total = 0
this.audioFiles.forEach((af) => (total += af.metadata.size))
if (this.ebookFile) {
total += this.ebookFile.metadata.size
}
return total
}
get includedAudioFiles() {
return this.audioFiles.filter((af) => !af.exclude)
}
get tracks() {
let startOffset = 0
return this.includedAudioFiles.map((af) => {
const audioTrack = new AudioTrack()
audioTrack.setData(this.libraryItemId, af, startOffset)
startOffset += audioTrack.duration
return audioTrack
})
}
get duration() {
let total = 0
this.tracks.forEach((track) => (total += track.duration))
return total
}
get numTracks() {
return this.tracks.length
}
get isEBookOnly() {
return this.ebookFile && !this.numTracks
}
update(payload) {
const json = this.toJSON()
let hasUpdates = false
for (const key in json) {
if (payload[key] !== undefined) {
if (key === 'metadata') {
if (this.metadata.update(payload.metadata)) {
hasUpdates = true
}
} else if (!areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
Logger.debug('[Book] Key updated', key, this[key])
hasUpdates = true
}
}
}
return hasUpdates
}
updateCover(coverPath) {
coverPath = filePathToPOSIX(coverPath)
if (this.coverPath === coverPath) return false
this.coverPath = coverPath
return true
}
}
module.exports = Book

View file

@ -1,161 +0,0 @@
const Logger = require('../../Logger')
const PodcastEpisode = require('../entities/PodcastEpisode')
const PodcastMetadata = require('../metadata/PodcastMetadata')
const { areEquivalent, copyValue } = require('../../utils/index')
const { filePathToPOSIX } = require('../../utils/fileUtils')
class Podcast {
constructor(podcast) {
this.id = null
this.libraryItemId = null
this.metadata = null
this.coverPath = null
this.tags = []
this.episodes = []
this.autoDownloadEpisodes = false
this.autoDownloadSchedule = null
this.lastEpisodeCheck = 0
this.maxEpisodesToKeep = 0
this.maxNewEpisodesToDownload = 3
this.lastCoverSearch = null
this.lastCoverSearchQuery = null
if (podcast) {
this.construct(podcast)
}
}
construct(podcast) {
this.id = podcast.id
this.libraryItemId = podcast.libraryItemId
this.metadata = new PodcastMetadata(podcast.metadata)
this.coverPath = podcast.coverPath
this.tags = [...podcast.tags]
this.episodes = podcast.episodes.map((e) => {
var podcastEpisode = new PodcastEpisode(e)
podcastEpisode.libraryItemId = this.libraryItemId
return podcastEpisode
})
this.autoDownloadEpisodes = !!podcast.autoDownloadEpisodes
this.autoDownloadSchedule = podcast.autoDownloadSchedule || '0 * * * *' // Added in 2.1.3 so default to hourly
this.lastEpisodeCheck = podcast.lastEpisodeCheck || 0
this.maxEpisodesToKeep = podcast.maxEpisodesToKeep || 0
// Default is 3 but 0 is allowed
if (typeof podcast.maxNewEpisodesToDownload !== 'number') {
this.maxNewEpisodesToDownload = 3
} else {
this.maxNewEpisodesToDownload = podcast.maxNewEpisodesToDownload
}
}
toJSON() {
return {
id: this.id,
libraryItemId: this.libraryItemId,
metadata: this.metadata.toJSON(),
coverPath: this.coverPath,
tags: [...this.tags],
episodes: this.episodes.map((e) => e.toJSON()),
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck,
maxEpisodesToKeep: this.maxEpisodesToKeep,
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload
}
}
toJSONMinified() {
return {
id: this.id,
metadata: this.metadata.toJSONMinified(),
coverPath: this.coverPath,
tags: [...this.tags],
numEpisodes: this.episodes.length,
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck,
maxEpisodesToKeep: this.maxEpisodesToKeep,
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
size: this.size
}
}
toJSONForMetadataFile() {
return {
tags: [...this.tags],
title: this.metadata.title,
author: this.metadata.author,
description: this.metadata.description,
releaseDate: this.metadata.releaseDate,
genres: [...this.metadata.genres],
feedURL: this.metadata.feedUrl,
imageURL: this.metadata.imageUrl,
itunesPageURL: this.metadata.itunesPageUrl,
itunesId: this.metadata.itunesId,
itunesArtistId: this.metadata.itunesArtistId,
explicit: this.metadata.explicit,
language: this.metadata.language,
podcastType: this.metadata.type
}
}
get size() {
var total = 0
this.episodes.forEach((ep) => (total += ep.size))
return total
}
get duration() {
let total = 0
this.episodes.forEach((ep) => (total += ep.duration))
return total
}
get numTracks() {
return this.episodes.length
}
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) {
if (key === 'metadata') {
if (this.metadata.update(payload.metadata)) {
hasUpdates = true
}
} else if (!areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
Logger.debug('[Podcast] Key updated', key, this[key])
hasUpdates = true
}
}
}
return hasUpdates
}
updateEpisode(id, payload) {
var episode = this.episodes.find((ep) => ep.id == id)
if (!episode) return false
return episode.update(payload)
}
updateCover(coverPath) {
coverPath = filePathToPOSIX(coverPath)
if (this.coverPath === coverPath) return false
this.coverPath = coverPath
return true
}
getEpisode(episodeId) {
if (!episodeId) return null
// Support old episode ids for mobile downloads
if (episodeId.startsWith('ep_')) return this.episodes.find((ep) => ep.oldEpisodeId == episodeId)
return this.episodes.find((ep) => ep.id == episodeId)
}
}
module.exports = Podcast

View file

@ -1,154 +0,0 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
const parseNameString = require('../../utils/parsers/parseNameString')
class BookMetadata {
constructor(metadata) {
this.title = null
this.subtitle = null
this.authors = []
this.narrators = [] // Array of strings
this.series = []
this.genres = [] // Array of strings
this.publishedYear = null
this.publishedDate = null
this.publisher = null
this.description = null
this.isbn = null
this.asin = null
this.language = null
this.explicit = false
this.abridged = false
if (metadata) {
this.construct(metadata)
}
}
construct(metadata) {
this.title = metadata.title
this.subtitle = metadata.subtitle
this.authors = metadata.authors?.map ? metadata.authors.map((a) => ({ ...a })) : []
this.narrators = metadata.narrators ? [...metadata.narrators].filter((n) => n) : []
this.series = metadata.series?.map
? metadata.series.map((s) => ({
...s,
name: s.name || 'No Title'
}))
: []
this.genres = metadata.genres ? [...metadata.genres] : []
this.publishedYear = metadata.publishedYear || null
this.publishedDate = metadata.publishedDate || null
this.publisher = metadata.publisher
this.description = metadata.description
this.isbn = metadata.isbn
this.asin = metadata.asin
this.language = metadata.language
this.explicit = !!metadata.explicit
this.abridged = !!metadata.abridged
}
toJSON() {
return {
title: this.title,
subtitle: this.subtitle,
authors: this.authors.map((a) => ({ ...a })), // Author JSONMinimal with name and id
narrators: [...this.narrators],
series: this.series.map((s) => ({ ...s })), // Series JSONMinimal with name, id and sequence
genres: [...this.genres],
publishedYear: this.publishedYear,
publishedDate: this.publishedDate,
publisher: this.publisher,
description: this.description,
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: this.explicit,
abridged: this.abridged
}
}
toJSONMinified() {
return {
title: this.title,
titleIgnorePrefix: this.titlePrefixAtEnd,
subtitle: this.subtitle,
authorName: this.authorName,
authorNameLF: this.authorNameLF,
narratorName: this.narratorName,
seriesName: this.seriesName,
genres: [...this.genres],
publishedYear: this.publishedYear,
publishedDate: this.publishedDate,
publisher: this.publisher,
description: this.description,
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: this.explicit,
abridged: this.abridged
}
}
toJSONForMetadataFile() {
const json = this.toJSON()
json.authors = json.authors.map((au) => au.name)
json.series = json.series.map((se) => {
if (!se.sequence) return se.name
return `${se.name} #${se.sequence}`
})
return json
}
clone() {
return new BookMetadata(this.toJSON())
}
get titleIgnorePrefix() {
return getTitleIgnorePrefix(this.title)
}
get titlePrefixAtEnd() {
return getTitlePrefixAtEnd(this.title)
}
get authorName() {
if (!this.authors.length) return ''
return this.authors.map((au) => au.name).join(', ')
}
get authorNameLF() {
// Last, First
if (!this.authors.length) return ''
return this.authors.map((au) => parseNameString.nameToLastFirst(au.name)).join(', ')
}
get seriesName() {
if (!this.series.length) return ''
return this.series
.map((se) => {
if (!se.sequence) return se.name
return `${se.name} #${se.sequence}`
})
.join(', ')
}
get narratorName() {
return this.narrators.join(', ')
}
getSeries(seriesId) {
return this.series.find((se) => se.id == seriesId)
}
update(payload) {
const json = this.toJSON()
let hasUpdates = false
for (const key in json) {
if (payload[key] !== undefined) {
if (!areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
Logger.debug('[BookMetadata] Key updated', key, this[key])
hasUpdates = true
}
}
}
return hasUpdates
}
}
module.exports = BookMetadata

View file

@ -1,105 +0,0 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
class PodcastMetadata {
constructor(metadata) {
this.title = null
this.author = null
this.description = null
this.releaseDate = null
this.genres = []
this.feedUrl = null
this.imageUrl = null
this.itunesPageUrl = null
this.itunesId = null
this.itunesArtistId = null
this.explicit = false
this.language = null
this.type = null
if (metadata) {
this.construct(metadata)
}
}
construct(metadata) {
this.title = metadata.title
this.author = metadata.author
this.description = metadata.description
this.releaseDate = metadata.releaseDate
this.genres = [...metadata.genres]
this.feedUrl = metadata.feedUrl
this.imageUrl = metadata.imageUrl
this.itunesPageUrl = metadata.itunesPageUrl
this.itunesId = metadata.itunesId
this.itunesArtistId = metadata.itunesArtistId
this.explicit = metadata.explicit
this.language = metadata.language || null
this.type = metadata.type || 'episodic'
}
toJSON() {
return {
title: this.title,
author: this.author,
description: this.description,
releaseDate: this.releaseDate,
genres: [...this.genres],
feedUrl: this.feedUrl,
imageUrl: this.imageUrl,
itunesPageUrl: this.itunesPageUrl,
itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId,
explicit: this.explicit,
language: this.language,
type: this.type
}
}
toJSONMinified() {
return {
title: this.title,
titleIgnorePrefix: this.titlePrefixAtEnd,
author: this.author,
description: this.description,
releaseDate: this.releaseDate,
genres: [...this.genres],
feedUrl: this.feedUrl,
imageUrl: this.imageUrl,
itunesPageUrl: this.itunesPageUrl,
itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId,
explicit: this.explicit,
language: this.language,
type: this.type
}
}
clone() {
return new PodcastMetadata(this.toJSON())
}
get titleIgnorePrefix() {
return getTitleIgnorePrefix(this.title)
}
get titlePrefixAtEnd() {
return getTitlePrefixAtEnd(this.title)
}
update(payload) {
const json = this.toJSON()
let hasUpdates = false
for (const key in json) {
if (payload[key] !== undefined) {
if (!areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
Logger.debug('[PodcastMetadata] Key updated', key, this[key])
hasUpdates = true
}
}
}
return hasUpdates
}
}
module.exports = PodcastMetadata