diff --git a/client/components/app/BookShelfToolbar.vue b/client/components/app/BookShelfToolbar.vue index a76987ab..3a173d93 100644 --- a/client/components/app/BookShelfToolbar.vue +++ b/client/components/app/BookShelfToolbar.vue @@ -16,17 +16,17 @@ {{ $strings.ButtonLatest }} - + {{ $strings.ButtonSeries }} - + {{ $strings.ButtonCollections }} collections_bookmark - + {{ $strings.ButtonAuthors }} - + @@ -153,9 +153,15 @@ export default { currentLibraryMediaType() { return this.$store.getters['libraries/getCurrentLibraryMediaType'] }, + isBookLibrary() { + return this.currentLibraryMediaType === 'book' + }, isPodcastLibrary() { return this.currentLibraryMediaType === 'podcast' }, + isMusicLibrary() { + return this.currentLibraryMediaType === 'music' + }, isLibraryPage() { return this.page === '' }, @@ -184,6 +190,7 @@ export default { return this.totalEntities }, entityName() { + if (this.isMusicLibrary) return 'Tracks' if (this.isPodcastLibrary) return this.$strings.LabelPodcasts if (!this.page) return this.$strings.LabelBooks if (this.isSeriesPage) return this.$strings.LabelSeries diff --git a/client/components/app/SideRail.vue b/client/components/app/SideRail.vue index 3219731f..caa57259 100644 --- a/client/components/app/SideRail.vue +++ b/client/components/app/SideRail.vue @@ -31,7 +31,7 @@ - + @@ -41,7 +41,7 @@ - + collections_bookmark {{ $strings.ButtonCollections }} @@ -49,7 +49,7 @@ - + { + const libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => { console.error('Failed to fetch full item', error) return null }) if (!libraryItem) return + this.$store.commit('setMediaPlaying', { libraryItem, episodeId, diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index 6780c338..715d47a1 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -190,6 +190,9 @@ export default { isPodcast() { return this.mediaType === 'podcast' }, + isMusic() { + return this.mediaType === 'music' + }, placeholderUrl() { const config = this.$config || this.$nuxt.$config return `${config.routerBasePath}/book_placeholder.jpg` @@ -305,6 +308,7 @@ export default { return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id) }, userProgress() { + if (this.isMusic) return null if (this.episodeProgress) return this.episodeProgress return this.store.getters['user/getUserMediaProgress'](this.libraryItemId) }, diff --git a/client/components/modals/libraries/EditLibrary.vue b/client/components/modals/libraries/EditLibrary.vue index 38697284..438926d5 100644 --- a/client/components/modals/libraries/EditLibrary.vue +++ b/client/components/modals/libraries/EditLibrary.vue @@ -67,6 +67,10 @@ export default { value: 'podcast', text: this.$strings.LabelPodcasts } + // { + // value: 'music', + // text: 'Music' + // } ] }, folderPaths() { diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index eb9c1127..15890f63 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -132,6 +132,7 @@ play_arrow {{ isStreaming ? $strings.ButtonPlaying : $strings.ButtonPlay }} + error {{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }} @@ -150,11 +151,11 @@ - + - + @@ -263,12 +264,18 @@ export default { isDeveloperMode() { return this.$store.state.developerMode }, + isBook() { + return this.libraryItem.mediaType === 'book' + }, isPodcast() { return this.libraryItem.mediaType === 'podcast' }, isVideo() { return this.libraryItem.mediaType === 'video' }, + isMusic() { + return this.libraryItem.mediaType === 'music' + }, isMissing() { return this.libraryItem.isMissing }, @@ -276,11 +283,12 @@ export default { return this.libraryItem.isInvalid }, invalidAudioFiles() { - if (this.isPodcast || this.isVideo) return [] + if (!this.isBook) return [] return this.libraryItem.media.audioFiles.filter((af) => af.invalid) }, showPlayButton() { if (this.isMissing || this.isInvalid) return false + if (this.isMusic) return !!this.audioFile if (this.isVideo) return !!this.videoFile if (this.isPodcast) return this.podcastEpisodes.length return this.tracks.length @@ -374,6 +382,10 @@ export default { videoFile() { return this.media.videoFile }, + audioFile() { + // Music track + return this.media.audioFile + }, showExperimentalReadAlert() { return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader }, @@ -381,6 +393,7 @@ export default { return this.mediaMetadata.description || '' }, userMediaProgress() { + if (this.isMusic) return null return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId) }, userIsFinished() { @@ -425,8 +438,11 @@ export default { return this.userIsAdminOrUp || this.rssFeedUrl }, showQueueBtn() { - if (this.isPodcast || this.isVideo) return false + if (!this.isBook) return false return !this.$store.getters['getIsStreamingFromDifferentLibrary'] && this.streamLibraryItem + }, + showCollectionsButton() { + return this.isBook && this.userCanUpdate } }, methods: { @@ -531,14 +547,14 @@ export default { }) }, playItem(startTime = null) { - var episodeId = null + let episodeId = null const queueItems = [] if (this.isPodcast) { const episodesInListeningOrder = this.podcastEpisodes.map((ep) => ({ ...ep })).sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' })) // Find most recent episode unplayed - var episodeIndex = episodesInListeningOrder.findLastIndex((ep) => { - var podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id) + let episodeIndex = episodesInListeningOrder.findLastIndex((ep) => { + const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id) return !podcastProgress || !podcastProgress.isFinished }) if (episodeIndex < 0) episodeIndex = 0 diff --git a/client/players/PlayerHandler.js b/client/players/PlayerHandler.js index 2569c264..146f4c35 100644 --- a/client/players/PlayerHandler.js +++ b/client/players/PlayerHandler.js @@ -17,6 +17,7 @@ export default class PlayerHandler { this.playerState = 'IDLE' this.isHlsTranscode = false this.isVideo = false + this.isMusic = false this.currentSessionId = null this.startTimeOverride = undefined // Used for starting playback at a specific time (i.e. clicking bookmark from library item page) this.startTime = 0 @@ -54,10 +55,13 @@ export default class PlayerHandler { load(libraryItem, episodeId, playWhenReady, playbackRate, startTimeOverride = undefined) { this.libraryItem = libraryItem + this.isVideo = libraryItem.mediaType === 'video' + this.isMusic = libraryItem.mediaType === 'music' + this.episodeId = episodeId this.playWhenReady = playWhenReady - this.initialPlaybackRate = playbackRate - this.isVideo = libraryItem.mediaType === 'video' + this.initialPlaybackRate = this.isMusic ? 1 : playbackRate + this.startTimeOverride = (startTimeOverride == null || isNaN(startTimeOverride)) ? undefined : Number(startTimeOverride) if (!this.player) this.switchPlayer(playWhenReady) @@ -140,12 +144,16 @@ export default class PlayerHandler { playerStateChange(state) { console.log('[PlayerHandler] Player state change', state) this.playerState = state - if (this.playerState === 'PLAYING') { - this.setPlaybackRate(this.initialPlaybackRate) - this.startPlayInterval() - } else { - this.stopPlayInterval() + + if (!this.isMusic) { + if (this.playerState === 'PLAYING') { + this.setPlaybackRate(this.initialPlaybackRate) + this.startPlayInterval() + } else { + this.stopPlayInterval() + } } + if (this.player) { if (this.playerState === 'LOADED' || this.playerState === 'PLAYING') { this.ctx.setDuration(this.getDuration()) diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js index 0463b57d..5c494173 100644 --- a/server/managers/CoverManager.js +++ b/server/managers/CoverManager.js @@ -230,27 +230,30 @@ class CoverManager { } async saveEmbeddedCoverArt(libraryItem) { - var audioFileWithCover = null - if (libraryItem.mediaType === 'book') audioFileWithCover = libraryItem.media.audioFiles.find(af => af.embeddedCoverArt) - else { - var episodeWithCover = libraryItem.media.episodes.find(ep => ep.audioFile.embeddedCoverArt) + const audioFileWithCover = null + if (libraryItem.mediaType === 'book') { + audioFileWithCover = libraryItem.media.audioFiles.find(af => af.embeddedCoverArt) + } else if (libraryItem.mediaType == 'podcast') { + const episodeWithCover = libraryItem.media.episodes.find(ep => ep.audioFile.embeddedCoverArt) if (episodeWithCover) audioFileWithCover = episodeWithCover.audioFile + } else if (libraryItem.mediaType === 'music') { + audioFileWithCover = libraryItem.media.audioFile } if (!audioFileWithCover) return false - var coverDirPath = this.getCoverDirectory(libraryItem) + const coverDirPath = this.getCoverDirectory(libraryItem) await fs.ensureDir(coverDirPath) - var coverFilename = audioFileWithCover.embeddedCoverArt === 'png' ? 'cover.png' : 'cover.jpg' - var coverFilePath = Path.join(coverDirPath, coverFilename) + const coverFilename = audioFileWithCover.embeddedCoverArt === 'png' ? 'cover.png' : 'cover.jpg' + const coverFilePath = Path.join(coverDirPath, coverFilename) - var coverAlreadyExists = await fs.pathExists(coverFilePath) + const coverAlreadyExists = await fs.pathExists(coverFilePath) if (coverAlreadyExists) { Logger.warn(`[CoverManager] Extract embedded cover art but cover already exists for "${libraryItem.media.metadata.title}" - bail`) return false } - var success = await extractCoverArt(audioFileWithCover.metadata.path, coverFilePath) + const success = await extractCoverArt(audioFileWithCover.metadata.path, coverFilePath) if (success) { await filePerms.setDefault(coverFilePath) diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index a7091aff..c6ea39e8 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -127,7 +127,7 @@ class PlaybackSessionManager { const shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options, episodeId)) const mediaPlayer = options.mediaPlayer || 'unknown' - const userProgress = user.getMediaProgress(libraryItem.id, episodeId) + const userProgress = libraryItem.isMusic ? null : user.getMediaProgress(libraryItem.id, episodeId) let userStartTime = 0 if (userProgress) { if (userProgress.isFinished) { diff --git a/server/objects/Library.js b/server/objects/Library.js index 9194d016..3432c4ea 100644 --- a/server/objects/Library.js +++ b/server/objects/Library.js @@ -57,7 +57,9 @@ class Library { else if (this.icon === 'comic') this.icon = 'file-picture' else this.icon = 'database' } - if (!this.mediaType || (this.mediaType !== 'podcast' && this.mediaType !== 'book' && this.mediaType !== 'video')) { + + const mediaTypes = ['podcast', 'book', 'video', 'music'] + if (!this.mediaType || !mediaTypes.includes(this.mediaType)) { this.mediaType = 'book' } } diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index de507d72..382f312f 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -7,6 +7,7 @@ const LibraryFile = require('./files/LibraryFile') const Book = require('./mediaTypes/Book') const Podcast = require('./mediaTypes/Podcast') const Video = require('./mediaTypes/Video') +const Music = require('./mediaTypes/Music') const { areEquivalent, copyValue, getId, cleanStringForSearch } = require('../utils/index') class LibraryItem { @@ -72,6 +73,8 @@ class LibraryItem { this.media = new Podcast(libraryItem.media) } else if (this.mediaType === 'video') { this.media = new Video(libraryItem.media) + } else if (this.mediaType === 'music') { + this.media = new Music(libraryItem.media) } this.media.libraryItemId = this.id @@ -153,13 +156,14 @@ class LibraryItem { get isPodcast() { return this.mediaType === 'podcast' } get isBook() { return this.mediaType === 'book' } + get isMusic() { return this.mediaType === 'music' } get size() { - var total = 0 + let total = 0 this.libraryFiles.forEach((lf) => total += lf.metadata.size) return total } get audioFileTotalSize() { - var total = 0 + let total = 0 this.libraryFiles.filter(lf => lf.fileType == 'audio').forEach((lf) => total += lf.metadata.size) return total } @@ -182,8 +186,10 @@ class LibraryItem { this.media = new Video() } else if (libraryMediaType === 'podcast') { this.media = new Podcast() - } else { + } else if (libraryMediaType === 'book') { this.media = new Book() + } else if (libraryMediaType === 'music') { + this.media = new Music() } this.media.libraryItemId = this.id @@ -348,11 +354,11 @@ class LibraryItem { } }) - var newLibraryFiles = [] - var existingLibraryFiles = [] + const newLibraryFiles = [] + const existingLibraryFiles = [] dataFound.libraryFiles.forEach((lf) => { - var fileFoundCheck = this.checkFileFound(lf, true) + const fileFoundCheck = this.checkFileFound(lf, true) if (fileFoundCheck === null) { newLibraryFiles.push(lf) } else if (fileFoundCheck && lf.metadata.format !== 'abs') { // Ignore abs file updates @@ -397,7 +403,7 @@ class LibraryItem { // If cover path is in item folder, make sure libraryFile exists for it if (this.media.coverPath && this.media.coverPath.startsWith(this.path)) { - var lf = this.libraryFiles.find(lf => lf.metadata.path === this.media.coverPath) + const lf = this.libraryFiles.find(lf => lf.metadata.path === this.media.coverPath) if (!lf) { Logger.warn(`[LibraryItem] Invalid cover path - library file dne "${this.media.coverPath}"`) this.media.updateCover('') @@ -419,7 +425,7 @@ class LibraryItem { // Set metadata from files async syncFiles(preferOpfMetadata) { - var hasUpdated = false + let hasUpdated = false if (this.mediaType === 'book') { // Add/update ebook file (ebooks that were removed are removed in checkScanData) @@ -436,7 +442,7 @@ class LibraryItem { } // Set cover image if not set - var imageFiles = this.libraryFiles.filter(lf => lf.fileType === 'image') + const imageFiles = this.libraryFiles.filter(lf => lf.fileType === 'image') if (imageFiles.length && !this.media.coverPath) { this.media.coverPath = imageFiles[0].metadata.path Logger.debug('[LibraryItem] Set media cover path', this.media.coverPath) @@ -444,7 +450,7 @@ class LibraryItem { } // Parse metadata files - var textMetadataFiles = this.libraryFiles.filter(lf => lf.fileType === 'metadata' || lf.fileType === 'text') + const textMetadataFiles = this.libraryFiles.filter(lf => lf.fileType === 'metadata' || lf.fileType === 'text') if (textMetadataFiles.length) { if (await this.media.syncMetadataFiles(textMetadataFiles, preferOpfMetadata)) { hasUpdated = true @@ -468,12 +474,12 @@ class LibraryItem { // Saves metadata.abs file async saveMetadata() { - if (this.mediaType === 'video') return + if (this.mediaType === 'video' || this.mediaType === 'music') return if (this.isSavingMetadata) return this.isSavingMetadata = true - var metadataPath = Path.join(global.MetadataPath, 'items', this.id) + let metadataPath = Path.join(global.MetadataPath, 'items', this.id) if (global.ServerSettings.storeMetadataWithItem && !this.isFile) { metadataPath = this.path } else { diff --git a/server/objects/entities/PodcastEpisode.js b/server/objects/entities/PodcastEpisode.js index 36926045..d86855cb 100644 --- a/server/objects/entities/PodcastEpisode.js +++ b/server/objects/entities/PodcastEpisode.js @@ -148,7 +148,7 @@ class PodcastEpisode { // Only checks container format checkCanDirectPlay(payload) { - var supportedMimeTypes = payload.supportedMimeTypes || [] + const supportedMimeTypes = payload.supportedMimeTypes || [] return supportedMimeTypes.includes(this.audioFile.mimeType) } diff --git a/server/objects/mediaTypes/Book.js b/server/objects/mediaTypes/Book.js index fb2299de..832b7c50 100644 --- a/server/objects/mediaTypes/Book.js +++ b/server/objects/mediaTypes/Book.js @@ -118,9 +118,9 @@ class Book { return this.missingParts.length || this.invalidAudioFiles.length } get tracks() { - var startOffset = 0 + let startOffset = 0 return this.includedAudioFiles.map((af) => { - var audioTrack = new AudioTrack() + const audioTrack = new AudioTrack() audioTrack.setData(this.libraryItemId, af, startOffset) startOffset += audioTrack.duration return audioTrack diff --git a/server/objects/mediaTypes/Music.js b/server/objects/mediaTypes/Music.js new file mode 100644 index 00000000..1f6520a5 --- /dev/null +++ b/server/objects/mediaTypes/Music.js @@ -0,0 +1,159 @@ +const Logger = require('../../Logger') +const AudioFile = require('../files/AudioFile') +const AudioTrack = require('../files/AudioTrack') +const MusicMetadata = require('../metadata/MusicMetadata') +const { areEquivalent, copyValue } = require('../../utils/index') + +class Music { + constructor(music) { + this.libraryItemId = null + this.metadata = null + this.coverPath = null + this.tags = [] + this.audioFile = null + + if (music) { + this.construct(music) + } + } + + construct(music) { + this.libraryItemId = music.libraryItemId + this.metadata = new MusicMetadata(music.metadata) + this.coverPath = music.coverPath + this.tags = [...music.tags] + this.audioFile = new AudioFile(music.audioFile) + } + + toJSON() { + return { + libraryItemId: this.libraryItemId, + metadata: this.metadata.toJSON(), + coverPath: this.coverPath, + tags: [...this.tags], + audioFile: this.audioFile.toJSON(), + } + } + + toJSONMinified() { + return { + metadata: this.metadata.toJSONMinified(), + coverPath: this.coverPath, + tags: [...this.tags], + audioFile: this.audioFile.toJSON(), + size: this.size + } + } + + toJSONExpanded() { + return { + libraryItemId: this.libraryItemId, + metadata: this.metadata.toJSONExpanded(), + coverPath: this.coverPath, + tags: [...this.tags], + audioFile: this.audioFile.toJSON(), + size: this.size + } + } + + get size() { + return this.audioFile.metadata.size + } + get hasMediaEntities() { + return !!this.audioFile + } + get shouldSearchForCover() { + return false + } + get hasEmbeddedCoverArt() { + return this.audioFile.embeddedCoverArt + } + get hasIssues() { + return false + } + get duration() { + return this.audioFile.duration || 0 + } + get audioTrack() { + const audioTrack = new AudioTrack() + audioTrack.setData(this.libraryItemId, this.audioFile, 0) + return audioTrack + } + get numTracks() { + return 1 + } + + update(payload) { + const json = this.toJSON() + delete json.episodes // do not update media entities here + 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('[Podcast] Key updated', key, this[key]) + hasUpdates = true + } + } + } + return hasUpdates + } + + updateCover(coverPath) { + coverPath = coverPath.replace(/\\/g, '/') + if (this.coverPath === coverPath) return false + this.coverPath = coverPath + return true + } + + removeFileWithInode(inode) { + return false + } + + findFileWithInode(inode) { + return this.audioFile && this.audioFile.ino === inode + } + + setData(mediaData) { + this.metadata = new MusicMetadata() + if (mediaData.metadata) { + this.metadata.setData(mediaData.metadata) + } + + this.coverPath = mediaData.coverPath || null + } + + setAudioFile(audioFile) { + this.audioFile = audioFile + } + + syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) { + return false + } + + searchQuery(query) { + return {} + } + + // Only checks container format + checkCanDirectPlay(payload) { + return true + } + + getDirectPlayTracklist() { + return [this.audioTrack] + } + + getPlaybackTitle() { + return this.metadata.title + } + + getPlaybackAuthor() { + return this.metadata.artist + } +} +module.exports = Music \ No newline at end of file diff --git a/server/objects/metadata/MusicMetadata.js b/server/objects/metadata/MusicMetadata.js new file mode 100644 index 00000000..c55c6494 --- /dev/null +++ b/server/objects/metadata/MusicMetadata.js @@ -0,0 +1,104 @@ +const Logger = require('../../Logger') +const { areEquivalent, copyValue, cleanStringForSearch, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index') + +class MusicMetadata { + constructor(metadata) { + this.title = null + this.artist = null + this.album = null + this.genres = [] // Array of strings + this.releaseDate = null + this.language = null + this.explicit = false + + if (metadata) { + this.construct(metadata) + } + } + + construct(metadata) { + this.title = metadata.title + this.artist = metadata.artist + this.album = metadata.album + this.genres = metadata.genres ? [...metadata.genres] : [] + this.releaseDate = metadata.releaseDate || null + this.language = metadata.language + this.explicit = !!metadata.explicit + } + + toJSON() { + return { + title: this.title, + artist: this.artist, + album: this.album, + genres: [...this.genres], + releaseDate: this.releaseDate, + language: this.language, + explicit: this.explicit + } + } + + toJSONMinified() { + return { + title: this.title, + titleIgnorePrefix: this.titlePrefixAtEnd, + artist: this.artist, + album: this.album, + genres: [...this.genres], + releaseDate: this.releaseDate, + language: this.language, + explicit: this.explicit + } + } + + toJSONExpanded() { + return this.toJSONMinified() + } + + clone() { + return new MusicMetadata(this.toJSON()) + } + + get titleIgnorePrefix() { + return getTitleIgnorePrefix(this.title) + } + + get titlePrefixAtEnd() { + return getTitlePrefixAtEnd(this.title) + } + + searchQuery(query) { // Returns key if match is found + const keysToCheck = ['title', 'artist', 'album'] + for (const key of keysToCheck) { + if (this[key] && cleanStringForSearch(String(this[key])).includes(query)) { + return { + matchKey: key, + matchText: this[key] + } + } + } + return null + } + + setData(mediaMetadata = {}) { + this.title = mediaMetadata.title || null + this.artist = mediaMetadata.artist || null + this.album = mediaMetadata.album || null + } + + 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('[MusicMetadata] Key updated', key, this[key]) + hasUpdates = true + } + } + } + return hasUpdates + } +} +module.exports = MusicMetadata \ No newline at end of file diff --git a/server/scanner/MediaFileScanner.js b/server/scanner/MediaFileScanner.js index f27555bb..7b33658c 100644 --- a/server/scanner/MediaFileScanner.js +++ b/server/scanner/MediaFileScanner.js @@ -57,9 +57,9 @@ class MediaFileScanner { } async scan(mediaType, libraryFile, mediaMetadataFromScan, verbose = false) { - var probeStart = Date.now() + const probeStart = Date.now() - var probeData = null + let probeData = null // TODO: Temp not using tone for probing until more testing can be done // if (global.ServerSettings.scannerUseTone) { // Logger.debug(`[MediaFileScanner] using tone to probe audio file "${libraryFile.metadata.path}"`) @@ -79,7 +79,7 @@ class MediaFileScanner { return null } - var videoFile = new VideoFile() + const videoFile = new VideoFile() videoFile.setDataFromProbe(libraryFile, probeData) return { @@ -92,7 +92,7 @@ class MediaFileScanner { return null } - var audioFile = new AudioFile() + const audioFile = new AudioFile() audioFile.trackNumFromMeta = probeData.trackNumber audioFile.discNumFromMeta = probeData.discNumber if (mediaType === 'book') { @@ -113,13 +113,13 @@ class MediaFileScanner { async executeMediaFileScans(libraryItem, mediaLibraryFiles, scanData) { const mediaType = libraryItem.mediaType - var scanStart = Date.now() - var mediaMetadataFromScan = scanData.media.metadata || null - var proms = [] + const scanStart = Date.now() + const mediaMetadataFromScan = scanData.media.metadata || null + const proms = [] for (let i = 0; i < mediaLibraryFiles.length; i++) { proms.push(this.scan(mediaType, mediaLibraryFiles[i], mediaMetadataFromScan)) } - var results = await Promise.all(proms).then((scanResults) => scanResults.filter(sr => sr)) + const results = await Promise.all(proms).then((scanResults) => scanResults.filter(sr => sr)) return { audioFiles: results.filter(r => r.audioFile).map(r => r.audioFile), videoFiles: results.filter(r => r.videoFile).map(r => r.videoFile), @@ -131,7 +131,7 @@ class MediaFileScanner { isSequential(nums) { if (!nums || !nums.length) return false if (nums.length === 1) return true - var prev = nums[0] + let prev = nums[0] for (let i = 1; i < nums.length; i++) { if (nums[i] - prev > 1) return false prev = nums[i] @@ -207,9 +207,9 @@ class MediaFileScanner { } async scanMediaFiles(mediaLibraryFiles, scanData, libraryItem, preferAudioMetadata, preferOverdriveMediaMarker, libraryScan = null) { - var hasUpdated = false + let hasUpdated = false - var mediaScanResult = await this.executeMediaFileScans(libraryItem, mediaLibraryFiles, scanData) + const mediaScanResult = await this.executeMediaFileScans(libraryItem, mediaLibraryFiles, scanData) if (libraryItem.mediaType === 'video') { if (mediaScanResult.videoFiles.length) { @@ -223,32 +223,32 @@ class MediaFileScanner { } Logger.debug(`Library Item "${scanData.path}" Media file scan took ${mediaScanResult.elapsed}ms with ${mediaScanResult.audioFiles.length} audio files averaging ${mediaScanResult.averageScanDuration}ms per MB`) - var newAudioFiles = mediaScanResult.audioFiles.filter(af => { + const newAudioFiles = mediaScanResult.audioFiles.filter(af => { return !libraryItem.media.findFileWithInode(af.ino) }) // Book: Adding audio files to book media if (libraryItem.mediaType === 'book') { - var mediaScanFileInodes = mediaScanResult.audioFiles.map(af => af.ino) + const mediaScanFileInodes = mediaScanResult.audioFiles.map(af => af.ino) // Filter for existing valid track audio files not included in the audio files scanned - var existingAudioFiles = libraryItem.media.audioFiles.filter(af => af.isValidTrack && !mediaScanFileInodes.includes(af.ino)) + const existingAudioFiles = libraryItem.media.audioFiles.filter(af => af.isValidTrack && !mediaScanFileInodes.includes(af.ino)) if (newAudioFiles.length) { // Single Track Audiobooks if (mediaScanFileInodes.length + existingAudioFiles.length === 1) { - var af = mediaScanResult.audioFiles[0] + const af = mediaScanResult.audioFiles[0] af.index = 1 libraryItem.media.addAudioFile(af) hasUpdated = true } else { - var allAudioFiles = existingAudioFiles.concat(mediaScanResult.audioFiles) + const allAudioFiles = existingAudioFiles.concat(mediaScanResult.audioFiles) this.runSmartTrackOrder(libraryItem, allAudioFiles) hasUpdated = true } } else { // Only update metadata not index mediaScanResult.audioFiles.forEach((af) => { - var existingAF = libraryItem.media.findFileWithInode(af.ino) + const existingAF = libraryItem.media.findFileWithInode(af.ino) if (existingAF) { af.index = existingAF.index if (existingAF.updateFromScan && existingAF.updateFromScan(af)) { @@ -266,11 +266,11 @@ class MediaFileScanner { if (hasUpdated) { libraryItem.media.rebuildTracks(preferOverdriveMediaMarker) } - } else { // Podcast Media Type - var existingAudioFiles = mediaScanResult.audioFiles.filter(af => libraryItem.media.findFileWithInode(af.ino)) + } else if (libraryItem.mediaType === 'podcast') { // Podcast Media Type + const existingAudioFiles = mediaScanResult.audioFiles.filter(af => libraryItem.media.findFileWithInode(af.ino)) if (newAudioFiles.length) { - var newIndex = libraryItem.media.episodes.length + 1 + let newIndex = libraryItem.media.episodes.length + 1 newAudioFiles.forEach((newAudioFile) => { libraryItem.media.addNewEpisodeFromAudioFile(newAudioFile, newIndex++) }) @@ -280,11 +280,19 @@ class MediaFileScanner { // Update audio file metadata for audio files already there existingAudioFiles.forEach((af) => { - var peAudioFile = libraryItem.media.findFileWithInode(af.ino) + const peAudioFile = libraryItem.media.findFileWithInode(af.ino) if (peAudioFile.updateFromScan && peAudioFile.updateFromScan(af)) { hasUpdated = true } }) + } else if (libraryItem.mediaType === 'music') { // Music + // Only one audio file in library item + if (newAudioFiles.length) { // New audio file + libraryItem.media.setAudioFile(newAudioFiles[0]) + hasUpdated = true + } else if (libraryItem.media.audioFile && libraryItem.media.audioFile.updateFromScan(mediaScanResult.audioFiles[0])) { + hasUpdated = true + } } } diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index 542449f8..604edde8 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -47,7 +47,7 @@ class Scanner { } async scanLibraryItemById(libraryItemId) { - var libraryItem = this.db.libraryItems.find(li => li.id === libraryItemId) + const libraryItem = this.db.libraryItems.find(li => li.id === libraryItemId) if (!libraryItem) { Logger.error(`[Scanner] Scan libraryItem by id not found ${libraryItemId}`) return ScanResult.NOTHING @@ -68,13 +68,13 @@ class Scanner { async scanLibraryItem(libraryMediaType, folder, libraryItem) { // TODO: Support for single media item - var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, false, this.db.serverSettings) + const libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, false, this.db.serverSettings) if (!libraryItemData) { return ScanResult.NOTHING } - var hasUpdated = false + let hasUpdated = false - var checkRes = libraryItem.checkScanData(libraryItemData) + const checkRes = libraryItem.checkScanData(libraryItemData) if (checkRes.updated) hasUpdated = true // Sync other files first so that local images are used as cover art @@ -84,14 +84,14 @@ class Scanner { // Scan all audio files if (libraryItem.hasAudioFiles) { - var libraryAudioFiles = libraryItem.libraryFiles.filter(lf => lf.fileType === 'audio') + const libraryAudioFiles = libraryItem.libraryFiles.filter(lf => lf.fileType === 'audio') if (await MediaFileScanner.scanMediaFiles(libraryAudioFiles, libraryItemData, libraryItem, this.db.serverSettings.scannerPreferAudioMetadata, this.db.serverSettings.scannerPreferOverdriveMediaMarker)) { hasUpdated = true } // Extract embedded cover art if cover is not already in directory if (libraryItem.media.hasEmbeddedCoverArt && !libraryItem.media.coverPath) { - var coverPath = await this.coverManager.saveEmbeddedCoverArt(libraryItem) + const coverPath = await this.coverManager.saveEmbeddedCoverArt(libraryItem) if (coverPath) { Logger.debug(`[Scanner] Saved embedded cover art "${coverPath}"`) hasUpdated = true @@ -172,8 +172,8 @@ class Scanner { // Scan each library for (let i = 0; i < libraryScan.folders.length; i++) { - var folder = libraryScan.folders[i] - var itemDataFoundInFolder = await scanFolder(libraryScan.libraryMediaType, folder, this.db.serverSettings) + const folder = libraryScan.folders[i] + const itemDataFoundInFolder = await scanFolder(libraryScan.libraryMediaType, folder, this.db.serverSettings) libraryScan.addLog(LogLevel.INFO, `${itemDataFoundInFolder.length} item data found in folder "${folder.fullPath}"`) libraryItemDataFound = libraryItemDataFound.concat(itemDataFoundInFolder) } @@ -196,16 +196,16 @@ class Scanner { // Check for existing & removed library items for (let i = 0; i < libraryItemsInLibrary.length; i++) { - var libraryItem = libraryItemsInLibrary[i] + const libraryItem = libraryItemsInLibrary[i] // Find library item folder with matching inode or matching path - var dataFound = libraryItemDataFound.find(lid => lid.ino === libraryItem.ino || comparePaths(lid.relPath, libraryItem.relPath)) + const dataFound = libraryItemDataFound.find(lid => lid.ino === libraryItem.ino || comparePaths(lid.relPath, libraryItem.relPath)) if (!dataFound) { libraryScan.addLog(LogLevel.WARN, `Library Item "${libraryItem.media.metadata.title}" is missing`) libraryScan.resultsMissing++ libraryItem.setMissing() itemsToUpdate.push(libraryItem) } else { - var checkRes = libraryItem.checkScanData(dataFound) + const checkRes = libraryItem.checkScanData(dataFound) if (checkRes.newLibraryFiles.length || libraryScan.scanOptions.forceRescan) { // Item has new files checkRes.libraryItem = libraryItem checkRes.scanData = dataFound @@ -244,15 +244,15 @@ class Scanner { // Potential NEW Library Items for (let i = 0; i < libraryItemDataFound.length; i++) { - var dataFound = libraryItemDataFound[i] + const dataFound = libraryItemDataFound[i] - var hasMediaFile = dataFound.libraryFiles.some(lf => lf.isMediaFile) + const hasMediaFile = dataFound.libraryFiles.some(lf => lf.isMediaFile) if (!hasMediaFile) { libraryScan.addLog(LogLevel.WARN, `Item found "${libraryItemDataFound.path}" has no media files`) } else { if (global.ServerSettings.scannerUseSingleThreadedProber) { // If this item will go over max size then push current chunk - var mediaFileSize = 0 + let mediaFileSize = 0 dataFound.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video').forEach(lf => mediaFileSize += lf.metadata.size) if (mediaFileSize + newItemDataToScanSize > MaxSizePerChunk && newItemDataToScan.length > 0) { newItemDataToScanChunks.push(newItemDataToScan) @@ -277,8 +277,8 @@ class Scanner { // Library Items not requiring a scan but require a search for cover for (let i = 0; i < itemsToFindCovers.length; i++) { - var libraryItem = itemsToFindCovers[i] - var updatedCover = await this.searchForCover(libraryItem, libraryScan) + const libraryItem = itemsToFindCovers[i] + const updatedCover = await this.searchForCover(libraryItem, libraryScan) libraryItem.media.updateLastCoverSearch(updatedCover) } @@ -397,10 +397,10 @@ class Scanner { if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Scanning new library item "${libraryItemData.path}"`) else Logger.debug(`[Scanner] Scanning new item "${libraryItemData.path}"`) - var libraryItem = new LibraryItem() + const libraryItem = new LibraryItem() libraryItem.setData(libraryMediaType, libraryItemData) - var mediaFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video') + const mediaFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video') if (mediaFiles.length) { await MediaFileScanner.scanMediaFiles(mediaFiles, libraryItemData, libraryItem, preferAudioMetadata, preferOverdriveMediaMarker, libraryScan) } @@ -414,7 +414,7 @@ class Scanner { // Extract embedded cover art if cover is not already in directory if (libraryItem.media.hasEmbeddedCoverArt && !libraryItem.media.coverPath) { - var coverPath = await this.coverManager.saveEmbeddedCoverArt(libraryItem) + const coverPath = await this.coverManager.saveEmbeddedCoverArt(libraryItem) if (coverPath) { if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Saved embedded cover art "${coverPath}"`) else Logger.debug(`[Scanner] Saved embedded cover art "${coverPath}"`) @@ -424,7 +424,7 @@ class Scanner { // Scan for cover if enabled and has no cover if (libraryMediaType === 'book') { if (libraryItem && findCovers && !libraryItem.media.coverPath && libraryItem.media.shouldSearchForCover) { - var updatedCover = await this.searchForCover(libraryItem, libraryScan) + const updatedCover = await this.searchForCover(libraryItem, libraryScan) libraryItem.media.updateLastCoverSearch(updatedCover) } } @@ -636,19 +636,19 @@ class Scanner { } async scanPotentialNewLibraryItem(libraryMediaType, folder, fullPath, isSingleMediaItem = false) { - var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, isSingleMediaItem, this.db.serverSettings) + const libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, isSingleMediaItem, this.db.serverSettings) if (!libraryItemData) return null - var serverSettings = this.db.serverSettings + const serverSettings = this.db.serverSettings return this.scanNewLibraryItem(libraryItemData, libraryMediaType, serverSettings.scannerPreferAudioMetadata, serverSettings.scannerPreferOpfMetadata, serverSettings.scannerFindCovers, serverSettings.scannerPreferOverdriveMediaMarker) } async searchForCover(libraryItem, libraryScan = null) { - var options = { + const options = { titleDistance: 2, authorDistance: 2 } - var scannerCoverProvider = this.db.serverSettings.scannerCoverProvider - var results = await this.bookFinder.findCovers(scannerCoverProvider, libraryItem.media.metadata.title, libraryItem.media.metadata.authorName, options) + const scannerCoverProvider = this.db.serverSettings.scannerCoverProvider + const results = await this.bookFinder.findCovers(scannerCoverProvider, libraryItem.media.metadata.title, libraryItem.media.metadata.authorName, options) if (results.length) { if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Found best cover for "${libraryItem.media.metadata.title}"`) else Logger.debug(`[Scanner] Found best cover for "${libraryItem.media.metadata.title}"`) @@ -657,7 +657,7 @@ class Scanner { for (let i = 0; i < results.length && i < 2; i++) { // Downloads and updates the book cover - var result = await this.coverManager.downloadCoverFromUrl(libraryItem, results[i]) + const result = await this.coverManager.downloadCoverFromUrl(libraryItem, results[i]) if (result.error) { Logger.error(`[Scanner] Failed to download cover from url "${results[i]}" | Attempt ${i + 1}`, result.error) diff --git a/server/utils/scandir.js b/server/utils/scandir.js index 6989873e..16dbbc54 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -8,7 +8,7 @@ const LibraryFile = require('../objects/files/LibraryFile') function isMediaFile(mediaType, ext) { if (!ext) return false var extclean = ext.slice(1).toLowerCase() - if (mediaType === 'podcast') return globals.SupportedAudioTypes.includes(extclean) + if (mediaType === 'podcast' || mediaType === 'music') return globals.SupportedAudioTypes.includes(extclean) else if (mediaType === 'video') return globals.SupportedVideoTypes.includes(extclean) return globals.SupportedAudioTypes.includes(extclean) || globals.SupportedEbookTypes.includes(extclean) } @@ -91,26 +91,39 @@ module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths // Input: array of relative file items (see recurseFiles) // Output: map of files grouped into potential libarary item dirs function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) { + // Handle music where every audio file is a library item + if (mediaType === 'music') { + const audioFileGroup = {} + fileItems.filter(i => isMediaFile(mediaType, i.extension)).forEach((item) => { + if (!item.reldirpath) { + audioFileGroup[item.name] = item.name + } else { + audioFileGroup[item.reldirpath] = [item.name] + } + }) + return audioFileGroup + } + // Step 1: Filter out non-book-media files in root dir (with depth of 0) - var itemsFiltered = fileItems.filter(i => { - return i.deep > 0 || ((mediaType === 'book' || mediaType === 'video') && isMediaFile(mediaType, i.extension)) + const itemsFiltered = fileItems.filter(i => { + return i.deep > 0 || ((mediaType === 'book' || mediaType === 'video' || mediaType === 'music') && isMediaFile(mediaType, i.extension)) }) // Step 2: Seperate media files and other files // - Directories without a media file will not be included - var mediaFileItems = [] - var otherFileItems = [] + const mediaFileItems = [] + const otherFileItems = [] itemsFiltered.forEach(item => { if (isMediaFile(mediaType, item.extension)) mediaFileItems.push(item) else otherFileItems.push(item) }) // Step 3: Group audio files in library items - var libraryItemGroup = {} + const libraryItemGroup = {} mediaFileItems.forEach((item) => { - var dirparts = item.reldirpath.split('/').filter(p => !!p) - var numparts = dirparts.length - var _path = '' + const dirparts = item.reldirpath.split('/').filter(p => !!p) + const numparts = dirparts.length + let _path = '' if (!dirparts.length) { // Media file in root @@ -118,11 +131,11 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) { } else { // Iterate over directories in path for (let i = 0; i < numparts; i++) { - var dirpart = dirparts.shift() + const dirpart = dirparts.shift() _path = Path.posix.join(_path, dirpart) if (libraryItemGroup[_path]) { // Directory already has files, add file - var relpath = Path.posix.join(dirparts.join('/'), item.name) + const relpath = Path.posix.join(dirparts.join('/'), item.name) libraryItemGroup[_path].push(relpath) return } else if (!dirparts.length) { // This is the last directory, create group @@ -138,16 +151,16 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) { // Step 4: Add other files into library item groups otherFileItems.forEach((item) => { - var dirparts = item.reldirpath.split('/') - var numparts = dirparts.length - var _path = '' + const dirparts = item.reldirpath.split('/') + const numparts = dirparts.length + let _path = '' // Iterate over directories in path for (let i = 0; i < numparts; i++) { - var dirpart = dirparts.shift() + const dirpart = dirparts.shift() _path = Path.posix.join(_path, dirpart) if (libraryItemGroup[_path]) { // Directory is audiobook group - var relpath = Path.posix.join(dirparts.join('/'), item.name) + const relpath = Path.posix.join(dirparts.join('/'), item.name) libraryItemGroup[_path].push(relpath) return } @@ -158,8 +171,8 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) { function cleanFileObjects(libraryItemPath, files) { return Promise.all(files.map(async (file) => { - var filePath = Path.posix.join(libraryItemPath, file) - var newLibraryFile = new LibraryFile() + const filePath = Path.posix.join(libraryItemPath, file) + const newLibraryFile = new LibraryFile() await newLibraryFile.setDataFromPath(filePath, file) return newLibraryFile })) @@ -167,27 +180,27 @@ function cleanFileObjects(libraryItemPath, files) { // Scan folder async function scanFolder(libraryMediaType, folder, serverSettings = {}) { - var folderPath = folder.fullPath.replace(/\\/g, '/') + const folderPath = folder.fullPath.replace(/\\/g, '/') - var pathExists = await fs.pathExists(folderPath) + const pathExists = await fs.pathExists(folderPath) if (!pathExists) { Logger.error(`[scandir] Invalid folder path does not exist "${folderPath}"`) return [] } - var fileItems = await recurseFiles(folderPath) - var libraryItemGrouping = groupFileItemsIntoLibraryItemDirs(libraryMediaType, fileItems) + const fileItems = await recurseFiles(folderPath) + const libraryItemGrouping = groupFileItemsIntoLibraryItemDirs(libraryMediaType, fileItems) if (!Object.keys(libraryItemGrouping).length) { Logger.error(`Root path has no media folders: ${folderPath}`) return [] } - var items = [] + const items = [] for (const libraryItemPath in libraryItemGrouping) { - var isFile = false // item is not in a folder - var libraryItemData = null - var fileObjs = [] + let isFile = false // item is not in a folder + let libraryItemData = null + let fileObjs = [] if (libraryItemPath === libraryItemGrouping[libraryItemPath]) { // Media file in root only get title libraryItemData = { @@ -200,11 +213,11 @@ async function scanFolder(libraryMediaType, folder, serverSettings = {}) { fileObjs = await cleanFileObjects(folderPath, [libraryItemPath]) isFile = true } else { - libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath, serverSettings) + libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath, serverSettings, libraryItemGrouping[libraryItemPath]) fileObjs = await cleanFileObjects(libraryItemData.path, libraryItemGrouping[libraryItemPath]) } - var libraryItemFolderStats = await getFileTimestampsWithIno(libraryItemData.path) + const libraryItemFolderStats = await getFileTimestampsWithIno(libraryItemData.path) items.push({ folderId: folder.id, libraryId: folder.libraryId, @@ -318,21 +331,36 @@ function getSubtitle(folder) { function getPodcastDataFromDir(folderPath, relPath) { relPath = relPath.replace(/\\/g, '/') - var splitDir = relPath.split('/') + const splitDir = relPath.split('/') // Audio files will always be in the directory named for the title - var title = splitDir.pop() + const title = splitDir.pop() return { mediaMetadata: { title }, - relPath: relPath, // relative audiobook path i.e. /Author Name/Book Name/.. - path: Path.posix.join(folderPath, relPath) // i.e. /audiobook/Author Name/Book Name/.. + relPath: relPath, // relative podcast path i.e. /Podcast Name/.. + path: Path.posix.join(folderPath, relPath) // i.e. /podcasts/Podcast Name/.. } } -function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettings) { - if (libraryMediaType === 'podcast') { +function getMusicDataFromDir(folderPath, relPath, fileNames) { + relPath = relPath.replace(/\\/g, '/') + + const firstFileName = fileNames.length ? fileNames[0] : '' + return { + mediaMetadata: { + title: Path.basename(firstFileName, Path.extname(firstFileName)) + }, + relPath: relPath, // relative music audio file path i.e. /Some Folder/.. + path: Path.posix.join(folderPath, relPath) // i.e. /music/Some Folder/.. + } +} + +function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettings, fileNames) { + if (libraryMediaType === 'music') { + return getMusicDataFromDir(folderPath, relPath, fileNames) + } else if (libraryMediaType === 'podcast') { return getPodcastDataFromDir(folderPath, relPath) } else if (libraryMediaType === 'book') { var parseSubtitle = !!serverSettings.scannerParseSubtitle @@ -368,7 +396,8 @@ async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, } } else { fileItems = await recurseFiles(libraryItemPath) - libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir, serverSettings) + const fileNames = fileItems.map(i => i.name) + libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir, serverSettings, fileNames) } var libraryItemDirStats = await getFileTimestampsWithIno(libraryItemData.path) @@ -389,8 +418,8 @@ async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, } for (let i = 0; i < fileItems.length; i++) { - var fileItem = fileItems[i] - var newLibraryFile = new LibraryFile() + const fileItem = fileItems[i] + const newLibraryFile = new LibraryFile() // fileItem.path is the relative path await newLibraryFile.setDataFromPath(fileItem.fullpath, fileItem.path) libraryItem.libraryFiles.push(newLibraryFile)
{{ $strings.ButtonLatest }}
{{ $strings.ButtonSeries }}
{{ $strings.ButtonCollections }}
{{ $strings.ButtonAuthors }}