Testing video media type

This commit is contained in:
advplyr 2022-05-30 19:26:53 -05:00
parent 705aac40d7
commit acf22ca4fa
27 changed files with 1124 additions and 92 deletions

View file

@ -184,7 +184,7 @@ class LibraryItemController {
// POST: api/items/:id/play
startPlaybackSession(req, res) {
if (!req.libraryItem.media.numTracks) {
if (!req.libraryItem.media.numTracks && req.libraryItem.mediaType !== 'video') {
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
return res.sendStatus(404)
}

View file

@ -119,29 +119,38 @@ class PlaybackSessionManager {
const newPlaybackSession = new PlaybackSession()
newPlaybackSession.setData(libraryItem, user, mediaPlayer, deviceInfo, userStartTime, episodeId)
var audioTracks = []
if (shouldDirectPlay) {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}"`)
audioTracks = libraryItem.getDirectPlayTracklist(episodeId)
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
if (libraryItem.mediaType === 'video') {
if (shouldDirectPlay) {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}"`)
newPlaybackSession.videoTrack = libraryItem.media.getVideoTrack()
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
} else {
// HLS not supported for video yet
}
} else {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}"`)
var stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, episodeId, userStartTime, this.clientEmitter.bind(this))
await stream.generatePlaylist()
stream.start() // Start transcode
var audioTracks = []
if (shouldDirectPlay) {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}"`)
audioTracks = libraryItem.getDirectPlayTracklist(episodeId)
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
} else {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}"`)
var stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, episodeId, userStartTime, this.clientEmitter.bind(this))
await stream.generatePlaylist()
stream.start() // Start transcode
audioTracks = [stream.getAudioTrack()]
newPlaybackSession.stream = stream
newPlaybackSession.playMethod = PlayMethod.TRANSCODE
audioTracks = [stream.getAudioTrack()]
newPlaybackSession.stream = stream
newPlaybackSession.playMethod = PlayMethod.TRANSCODE
stream.on('closed', () => {
Logger.debug(`[PlaybackSessionManager] Stream closed for session "${newPlaybackSession.id}"`)
newPlaybackSession.stream = null
})
stream.on('closed', () => {
Logger.debug(`[PlaybackSessionManager] Stream closed for session "${newPlaybackSession.id}"`)
newPlaybackSession.stream = null
})
}
newPlaybackSession.audioTracks = audioTracks
}
newPlaybackSession.audioTracks = audioTracks
// Will save on the first sync
user.currentSessionId = newPlaybackSession.id

View file

@ -143,13 +143,13 @@ class PodcastManager {
async probeAudioFile(libraryFile) {
var path = libraryFile.metadata.path
var audioProbeData = await prober.probe(path)
if (audioProbeData.error) {
Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, audioProbeData.error)
var mediaProbeData = await prober.probe(path)
if (mediaProbeData.error) {
Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error)
return false
}
var newAudioFile = new AudioFile()
newAudioFile.setDataFromProbe(libraryFile, audioProbeData)
newAudioFile.setDataFromProbe(libraryFile, mediaProbeData)
return newAudioFile
}

View file

@ -56,7 +56,7 @@ class Library {
else if (this.icon.endsWith('s') && availableIcons.includes(this.icon.slice(0, -1))) this.icon = this.icon.slice(0, -1)
else this.icon = 'database'
}
if (!this.mediaType || (this.mediaType !== 'podcast' && this.mediaType !== 'book')) {
if (!this.mediaType || (this.mediaType !== 'podcast' && this.mediaType !== 'book' && this.mediaType !== 'video')) {
this.mediaType = 'book'
}
}

View file

@ -6,6 +6,7 @@ const abmetadataGenerator = require('../utils/abmetadataGenerator')
const LibraryFile = require('./files/LibraryFile')
const Book = require('./mediaTypes/Book')
const Podcast = require('./mediaTypes/Podcast')
const Video = require('./mediatypes/Video')
const { areEquivalent, copyValue, getId } = require('../utils/index')
class LibraryItem {
@ -67,11 +68,12 @@ class LibraryItem {
this.mediaType = libraryItem.mediaType
if (this.mediaType === 'book') {
this.media = new Book(libraryItem.media)
this.media.libraryItemId = this.id
} else if (this.mediaType === 'podcast') {
this.media = new Podcast(libraryItem.media)
this.media.libraryItemId = this.id
} else if (this.mediaType === 'video') {
this.media = new Video(libraryItem.media)
}
this.media.libraryItemId = this.id
this.libraryFiles = libraryItem.libraryFiles.map(f => new LibraryFile(f))
}
@ -175,16 +177,15 @@ class LibraryItem {
// Data comes from scandir library item data
setData(libraryMediaType, payload) {
this.id = getId('li')
if (libraryMediaType === 'podcast') {
this.mediaType = 'podcast'
this.mediaType = libraryMediaType
if (libraryMediaType === 'video') {
this.media = new Video()
} else if (libraryMediaType === 'podcast') {
this.media = new Podcast()
this.media.libraryItemId = this.id
} else {
this.mediaType = 'book'
this.media = new Book()
this.media.libraryItemId = this.id
}
this.media.libraryItemId = this.id
for (const key in payload) {
if (key === 'libraryFiles') {
@ -460,6 +461,8 @@ class LibraryItem {
// Saves metadata.abs file
async saveMetadata() {
if (this.mediaType === 'video') return
if (this.isSavingMetadata) return
this.isSavingMetadata = true

View file

@ -4,6 +4,7 @@ const { PlayMethod } = require('../utils/constants')
const BookMetadata = require('./metadata/BookMetadata')
const PodcastMetadata = require('./metadata/PodcastMetadata')
const DeviceInfo = require('./DeviceInfo')
const VideoMetadata = require('./metadata/VideoMetadata')
class PlaybackSession {
constructor(session) {
@ -38,6 +39,7 @@ class PlaybackSession {
// Not saved in DB
this.lastSave = 0
this.audioTracks = []
this.videoTrack = null
this.stream = null
if (session) {
@ -97,6 +99,7 @@ class PlaybackSession {
startedAt: this.startedAt,
updatedAt: this.updatedAt,
audioTracks: this.audioTracks.map(at => at.toJSON()),
videoTrack: this.videoTrack ? this.videoTrack.toJSON() : null,
libraryItem: libraryItem.toJSONExpanded()
}
}
@ -120,6 +123,8 @@ class PlaybackSession {
this.mediaMetadata = new BookMetadata(session.mediaMetadata)
} else if (this.mediaType === 'podcast') {
this.mediaMetadata = new PodcastMetadata(session.mediaMetadata)
} else if (this.mediaType === 'video') {
this.mediaMetadata = new VideoMetadata(session.mediaMetadata)
}
}
this.displayTitle = session.displayTitle || ''

View file

@ -40,13 +40,14 @@ class LibraryFile {
if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image'
if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio'
if (globals.SupportedEbookTypes.includes(this.metadata.format)) return 'ebook'
if (globals.SupportedVideoTypes.includes(this.metadata.format)) return 'video'
if (globals.TextFileTypes.includes(this.metadata.format)) return 'text'
if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata'
return 'unknown'
}
get isMediaFile() {
return this.fileType === 'audio' || this.fileType === 'ebook'
return this.fileType === 'audio' || this.fileType === 'ebook' || this.fileType === 'video'
}
get isOPFFile() {

View file

@ -0,0 +1,109 @@
const { VideoMimeType } = require('../../utils/constants')
const FileMetadata = require('../metadata/FileMetadata')
class VideoFile {
constructor(data) {
this.index = null
this.ino = null
this.metadata = null
this.addedAt = null
this.updatedAt = null
this.format = null
this.duration = null
this.bitRate = null
this.language = null
this.codec = null
this.timeBase = null
this.frameRate = null
this.width = null
this.height = null
this.embeddedCoverArt = null
this.invalid = false
this.error = null
if (data) {
this.construct(data)
}
}
toJSON() {
return {
index: this.index,
ino: this.ino,
metadata: this.metadata.toJSON(),
addedAt: this.addedAt,
updatedAt: this.updatedAt,
invalid: !!this.invalid,
error: this.error || null,
format: this.format,
duration: this.duration,
bitRate: this.bitRate,
language: this.language,
codec: this.codec,
timeBase: this.timeBase,
frameRate: this.frameRate,
width: this.width,
height: this.height,
embeddedCoverArt: this.embeddedCoverArt,
mimeType: this.mimeType
}
}
construct(data) {
this.index = data.index
this.ino = data.ino
this.metadata = new FileMetadata(data.metadata || {})
this.addedAt = data.addedAt
this.updatedAt = data.updatedAt
this.invalid = !!data.invalid
this.error = data.error || null
this.format = data.format
this.duration = data.duration
this.bitRate = data.bitRate
this.language = data.language
this.codec = data.codec || null
this.timeBase = data.timeBase
this.frameRate = data.frameRate
this.width = data.width
this.height = data.height
this.embeddedCoverArt = data.embeddedCoverArt || null
}
get mimeType() {
var format = this.metadata.format.toUpperCase()
if (VideoMimeType[format]) {
return VideoMimeType[format]
} else {
return VideoMimeType.MP4
}
}
clone() {
return new VideoFile(this.toJSON())
}
setDataFromProbe(libraryFile, probeData) {
this.ino = libraryFile.ino || null
this.metadata = libraryFile.metadata.clone()
this.addedAt = Date.now()
this.updatedAt = Date.now()
const videoStream = probeData.videoStream
this.format = probeData.format
this.duration = probeData.duration
this.bitRate = videoStream.bit_rate || probeData.bitRate || null
this.language = probeData.language
this.codec = videoStream.codec || null
this.timeBase = videoStream.time_base
this.frameRate = videoStream.frame_rate || null
this.width = videoStream.width || null
this.height = videoStream.height || null
this.embeddedCoverArt = probeData.embeddedCoverArt
}
}
module.exports = VideoFile

View file

@ -0,0 +1,42 @@
const Path = require('path')
const { encodeUriPath } = require('../../utils/index')
class VideoTrack {
constructor() {
this.index = null
this.duration = null
this.title = null
this.contentUrl = null
this.mimeType = null
this.metadata = null
}
toJSON() {
return {
index: this.index,
duration: this.duration,
title: this.title,
contentUrl: this.contentUrl,
mimeType: this.mimeType,
metadata: this.metadata ? this.metadata.toJSON() : null
}
}
setData(itemId, videoFile) {
this.index = videoFile.index
this.duration = videoFile.duration
this.title = videoFile.metadata.filename || ''
this.contentUrl = Path.join(`/s/item/${itemId}`, encodeUriPath(videoFile.metadata.relPath))
this.mimeType = videoFile.mimeType
this.metadata = videoFile.metadata.clone()
}
setFromStream(title, duration, contentUrl) {
this.index = 1
this.duration = duration
this.title = title
this.contentUrl = contentUrl
this.mimeType = 'application/vnd.apple.mpegurl'
}
}
module.exports = VideoTrack

View file

@ -0,0 +1,145 @@
const Logger = require('../../Logger')
const VideoFile = require('../files/VideoFile')
const VideoTrack = require('../files/VideoTrack')
const VideoMetadata = require('../metadata/VideoMetadata')
const { areEquivalent, copyValue } = require('../../utils/index')
class Video {
constructor(video) {
this.libraryItemId = null
this.metadata = null
this.coverPath = null
this.tags = []
this.episodes = []
this.autoDownloadEpisodes = false
this.lastEpisodeCheck = 0
this.lastCoverSearch = null
this.lastCoverSearchQuery = null
if (video) {
this.construct(video)
}
}
construct(video) {
this.libraryItemId = video.libraryItemId
this.metadata = new VideoMetadata(video.metadata)
this.coverPath = video.coverPath
this.tags = [...video.tags]
this.videoFile = new VideoFile(video.videoFile)
}
toJSON() {
return {
libraryItemId: this.libraryItemId,
metadata: this.metadata.toJSONExpanded(),
coverPath: this.coverPath,
tags: [...this.tags],
videoFile: this.videoFile.toJSON()
}
}
toJSONMinified() {
return {
metadata: this.metadata.toJSONMinified(),
coverPath: this.coverPath,
tags: [...this.tags],
videoFile: this.videoFile.toJSON(),
size: this.size
}
}
toJSONExpanded() {
return {
libraryItemId: this.libraryItemId,
metadata: this.metadata.toJSONExpanded(),
coverPath: this.coverPath,
tags: [...this.tags],
videoFile: this.videoFile.toJSON(),
size: this.size
}
}
get size() {
return this.videoFile.metadata.size
}
get hasMediaEntities() {
return true
}
get shouldSearchForCover() {
return false
}
get hasEmbeddedCoverArt() {
return false
}
get hasIssues() {
return false
}
get duration() {
return 0
}
update(payload) {
var json = this.toJSON()
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('[Video] 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) {
}
findFileWithInode(inode) {
return null
}
setVideoFile(videoFile) {
this.videoFile = videoFile
}
setData(mediaMetadata) {
this.metadata = new VideoMetadata()
if (mediaMetadata.metadata) {
this.metadata.setData(mediaMetadata.metadata)
}
this.coverPath = mediaMetadata.coverPath || null
}
getPlaybackTitle() {
return this.metadata.title
}
getPlaybackAuthor() {
return ''
}
getVideoTrack() {
var track = new VideoTrack()
track.setData(this.libraryItemId, this.videoFile)
return track
}
}
module.exports = Video

View file

@ -0,0 +1,97 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue } = require('../../utils/index')
class VideoMetadata {
constructor(metadata) {
this.title = null
this.description = null
this.explicit = false
this.language = null
if (metadata) {
this.construct(metadata)
}
}
construct(metadata) {
this.title = metadata.title
this.description = metadata.description
this.explicit = metadata.explicit
this.language = metadata.language || null
}
toJSON() {
return {
title: this.title,
description: this.description,
explicit: this.explicit,
language: this.language
}
}
toJSONMinified() {
return {
title: this.title,
titleIgnorePrefix: this.titleIgnorePrefix,
description: this.description,
explicit: this.explicit,
language: this.language
}
}
toJSONExpanded() {
return this.toJSONMinified()
}
clone() {
return new VideoMetadata(this.toJSON())
}
get titleIgnorePrefix() {
if (!this.title) return ''
var prefixesToIgnore = global.ServerSettings.sortingPrefixes || []
for (const prefix of prefixesToIgnore) {
// e.g. for prefix "the". If title is "The Book Title" return "Book Title, The"
if (this.title.toLowerCase().startsWith(`${prefix} `)) {
return this.title.substr(prefix.length + 1) + `, ${prefix.substr(0, 1).toUpperCase() + prefix.substr(1)}`
}
}
return this.title
}
searchQuery(query) { // Returns key if match is found
var keysToCheck = ['title']
for (var key of keysToCheck) {
if (this[key] && String(this[key]).toLowerCase().includes(query)) {
return {
matchKey: key,
matchText: this[key]
}
}
}
return null
}
setData(mediaMetadata = {}) {
this.title = mediaMetadata.title || null
this.description = mediaMetadata.description || null
this.explicit = !!mediaMetadata.explicit
this.language = mediaMetadata.language || null
}
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('[VideoMetadata] Key updated', key, this[key])
hasUpdates = true
}
}
}
return hasUpdates
}
}
module.exports = VideoMetadata

View file

@ -343,6 +343,7 @@ class User {
checkCanAccessLibraryItem(libraryItem) {
if (!this.checkCanAccessLibrary(libraryItem.libraryId)) return false
if (libraryItem.media.metadata.explicit && !this.canAccessExplicitContent) return false
return this.checkCanAccessLibraryItemWithTags(libraryItem.media.tags)
}

View file

@ -0,0 +1,277 @@
const Path = require('path')
const AudioFile = require('../objects/files/AudioFile')
const VideoFile = require('../objects/files/VideoFile')
const prober = require('../utils/prober')
const Logger = require('../Logger')
const { LogLevel } = require('../utils/constants')
class MediaFileScanner {
constructor() { }
getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, audioLibraryFile) {
const { title, author, series, publishedYear } = mediaMetadataFromScan
const { filename, path } = audioLibraryFile.metadata
var partbasename = Path.basename(filename, Path.extname(filename))
// Remove title, author, series, and publishedYear from filename if there
if (title) partbasename = partbasename.replace(title, '')
if (author) partbasename = partbasename.replace(author, '')
if (series) partbasename = partbasename.replace(series, '')
if (publishedYear) partbasename = partbasename.replace(publishedYear)
// Look for disc number
var discNumber = null
var discMatch = partbasename.match(/\b(disc|cd) ?(\d\d?)\b/i)
if (discMatch && discMatch.length > 2 && discMatch[2]) {
if (!isNaN(discMatch[2])) {
discNumber = Number(discMatch[2])
}
// Remove disc number from filename
partbasename = partbasename.replace(/\b(disc|cd) ?(\d\d?)\b/i, '')
}
// Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3
var pathdir = Path.dirname(path).split('/').pop()
if (pathdir && /^cd\d{1,3}$/i.test(pathdir)) {
var discFromFolder = Number(pathdir.replace(/cd/i, ''))
if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder
}
var numbersinpath = partbasename.match(/\d{1,4}/g)
var trackNumber = numbersinpath && numbersinpath.length ? parseInt(numbersinpath[0]) : null
return {
trackNumber,
discNumber
}
}
getAverageScanDurationMs(results) {
if (!results.length) return 0
var total = 0
for (let i = 0; i < results.length; i++) total += results[i].elapsed
return Math.floor(total / results.length)
}
async scan(mediaType, libraryFile, mediaMetadataFromScan, verbose = false) {
var probeStart = Date.now()
var probeData = await prober.probe(libraryFile.metadata.path, verbose)
if (probeData.error) {
Logger.error(`[MediaFileScanner] ${probeData.error} : "${libraryFile.metadata.path}"`)
return null
}
if (mediaType === 'video') {
if (!probeData.videoStream) {
Logger.error('[MediaFileScanner] Invalid video file no video stream')
return null
}
var videoFile = new VideoFile()
videoFile.setDataFromProbe(libraryFile, probeData)
return {
videoFile,
elapsed: Date.now() - probeStart
}
} else {
if (!probeData.audioStream) {
Logger.error('[MediaFileScanner] Invalid audio file no audio stream')
return null
}
var audioFile = new AudioFile()
audioFile.trackNumFromMeta = probeData.trackNumber
audioFile.discNumFromMeta = probeData.discNumber
if (mediaType === 'book') {
const { trackNumber, discNumber } = this.getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, libraryFile)
audioFile.trackNumFromFilename = trackNumber
audioFile.discNumFromFilename = discNumber
}
audioFile.setDataFromProbe(libraryFile, probeData)
return {
audioFile,
elapsed: Date.now() - probeStart
}
}
}
// Returns array of { MediaFile, elapsed, averageScanDuration } from audio file scan objects
async executeMediaFileScans(mediaType, mediaLibraryFiles, scanData) {
var mediaMetadataFromScan = scanData.media.metadata || null
var proms = []
for (let i = 0; i < mediaLibraryFiles.length; i++) {
proms.push(this.scan(mediaType, mediaLibraryFiles[i], mediaMetadataFromScan))
}
var scanStart = Date.now()
var 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),
elapsed: Date.now() - scanStart,
averageScanDuration: this.getAverageScanDurationMs(results)
}
}
isSequential(nums) {
if (!nums || !nums.length) return false
if (nums.length === 1) return true
var prev = nums[0]
for (let i = 1; i < nums.length; i++) {
if (nums[i] - prev > 1) return false
prev = nums[i]
}
return true
}
removeDupes(nums) {
if (!nums || !nums.length) return []
if (nums.length === 1) return nums
var nodupes = [nums[0]]
nums.forEach((num) => {
if (num > nodupes[nodupes.length - 1]) nodupes.push(num)
})
return nodupes
}
runSmartTrackOrder(libraryItem, audioFiles) {
var discsFromFilename = []
var tracksFromFilename = []
var discsFromMeta = []
var tracksFromMeta = []
audioFiles.forEach((af) => {
if (af.discNumFromFilename !== null) discsFromFilename.push(af.discNumFromFilename)
if (af.discNumFromMeta !== null) discsFromMeta.push(af.discNumFromMeta)
if (af.trackNumFromFilename !== null) tracksFromFilename.push(af.trackNumFromFilename)
if (af.trackNumFromMeta !== null) tracksFromMeta.push(af.trackNumFromMeta)
af.validateTrackIndex() // Sets error if no valid track number
})
discsFromFilename.sort((a, b) => a - b)
discsFromMeta.sort((a, b) => a - b)
tracksFromFilename.sort((a, b) => a - b)
tracksFromMeta.sort((a, b) => a - b)
var discKey = null
if (discsFromMeta.length === audioFiles.length && this.isSequential(discsFromMeta)) {
discKey = 'discNumFromMeta'
} else if (discsFromFilename.length === audioFiles.length && this.isSequential(discsFromFilename)) {
discKey = 'discNumFromFilename'
}
var trackKey = null
tracksFromFilename = this.removeDupes(tracksFromFilename)
tracksFromMeta = this.removeDupes(tracksFromMeta)
if (tracksFromFilename.length > tracksFromMeta.length) {
trackKey = 'trackNumFromFilename'
} else {
trackKey = 'trackNumFromMeta'
}
if (discKey !== null) {
Logger.debug(`[AudioFileScanner] Smart track order for "${libraryItem.media.metadata.title}" using disc key ${discKey} and track key ${trackKey}`)
audioFiles.sort((a, b) => {
let Dx = a[discKey] - b[discKey]
if (Dx === 0) Dx = a[trackKey] - b[trackKey]
return Dx
})
} else {
Logger.debug(`[AudioFileScanner] Smart track order for "${libraryItem.media.metadata.title}" using track key ${trackKey}`)
audioFiles.sort((a, b) => a[trackKey] - b[trackKey])
}
for (let i = 0; i < audioFiles.length; i++) {
audioFiles[i].index = i + 1
var existingAF = libraryItem.media.findFileWithInode(audioFiles[i].ino)
if (existingAF) {
if (existingAF.updateFromScan) existingAF.updateFromScan(audioFiles[i])
} else {
libraryItem.media.addAudioFile(audioFiles[i])
}
}
}
async scanMediaFiles(mediaLibraryFiles, scanData, libraryItem, preferAudioMetadata, libraryScan = null) {
var hasUpdated = false
var mediaScanResult = await this.executeMediaFileScans(libraryItem.mediaType, mediaLibraryFiles, scanData)
if (libraryItem.mediaType === 'video') {
if (mediaScanResult.videoFiles.length) {
// TODO: Check for updates etc
hasUpdated = true
libraryItem.media.setVideoFile(mediaScanResult.videoFiles[0])
}
} else if (mediaScanResult.audioFiles.length) {
if (libraryScan) {
libraryScan.addLog(LogLevel.DEBUG, `Library Item "${scanData.path}" Audio file scan took ${mediaScanResult.elapsed}ms for ${mediaScanResult.audioFiles.length} with average time of ${mediaScanResult.averageScanDuration}ms`)
}
var totalAudioFilesToInclude = mediaScanResult.audioFiles.length
var newAudioFiles = mediaScanResult.audioFiles.filter(af => {
return !libraryItem.media.findFileWithInode(af.ino)
})
// Book: Adding audio files to book media
if (libraryItem.mediaType === 'book') {
if (newAudioFiles.length) {
// Single Track Audiobooks
if (totalAudioFilesToInclude === 1) {
var af = mediaScanResult.audioFiles[0]
af.index = 1
libraryItem.media.addAudioFile(af)
hasUpdated = true
} else {
this.runSmartTrackOrder(libraryItem, mediaScanResult.audioFiles)
hasUpdated = true
}
} else {
// Only update metadata not index
mediaScanResult.audioFiles.forEach((af) => {
var existingAF = libraryItem.media.findFileWithInode(af.ino)
if (existingAF) {
af.index = existingAF.index
if (existingAF.updateFromScan && existingAF.updateFromScan(af)) {
hasUpdated = true
}
}
})
}
// Set book details from audio file ID3 tags, optional prefer
if (libraryItem.media.setMetadataFromAudioFile(preferAudioMetadata)) {
hasUpdated = true
}
if (hasUpdated) {
libraryItem.media.rebuildTracks()
}
} else { // Podcast Media Type
var existingAudioFiles = mediaScanResult.audioFiles.filter(af => libraryItem.media.findFileWithInode(af.ino))
if (newAudioFiles.length) {
var newIndex = libraryItem.media.episodes.length + 1
newAudioFiles.forEach((newAudioFile) => {
libraryItem.media.addNewEpisodeFromAudioFile(newAudioFile, newIndex++)
})
libraryItem.media.reorderEpisodes()
hasUpdated = true
}
// Update audio file metadata for audio files already there
existingAudioFiles.forEach((af) => {
var peAudioFile = libraryItem.media.findFileWithInode(af.ino)
if (peAudioFile.updateFromScan && peAudioFile.updateFromScan(af)) {
hasUpdated = true
}
})
}
}
return hasUpdated
}
}
module.exports = new MediaFileScanner()

View file

@ -1,11 +1,15 @@
const AudioFileMetadata = require('../objects/metadata/AudioMetaTags')
class AudioProbeData {
class MediaProbeData {
constructor() {
this.embeddedCoverArt = null
this.format = null
this.duration = null
this.size = null
this.audioStream = null
this.videoStream = null
this.bitRate = null
this.codec = null
this.timeBase = null
@ -35,6 +39,10 @@ class AudioProbeData {
this.format = data.format
this.duration = data.duration
this.size = data.size
this.audioStream = audioStream
this.videoStream = this.embeddedCoverArt ? null : data.video_stream || null
this.bitRate = audioStream.bit_rate || data.bit_rate
this.codec = audioStream.codec
this.timeBase = audioStream.time_base
@ -78,4 +86,4 @@ class AudioProbeData {
}
}
}
module.exports = AudioProbeData
module.exports = MediaProbeData

View file

@ -8,7 +8,7 @@ const { comparePaths } = require('../utils/index')
const { getIno } = require('../utils/fileUtils')
const { ScanResult, LogLevel } = require('../utils/constants')
const AudioFileScanner = require('./AudioFileScanner')
const MediaFileScanner = require('./MediaFileScanner')
const BookFinder = require('../finders/BookFinder')
const LibraryItem = require('../objects/LibraryItem')
const LibraryScan = require('./LibraryScan')
@ -80,7 +80,7 @@ class Scanner {
// Scan all audio files
if (libraryItem.hasAudioFiles) {
var libraryAudioFiles = libraryItem.libraryFiles.filter(lf => lf.fileType === 'audio')
if (await AudioFileScanner.scanAudioFiles(libraryAudioFiles, libraryItemData, libraryItem, this.db.serverSettings.scannerPreferAudioMetadata)) {
if (await MediaFileScanner.scanMediaFiles(libraryAudioFiles, libraryItemData, libraryItem, this.db.serverSettings.scannerPreferAudioMetadata)) {
hasUpdated = true
}
@ -240,18 +240,18 @@ class Scanner {
if (!hasMediaFile) {
libraryScan.addLog(LogLevel.WARN, `Item found "${libraryItemDataFound.path}" has no media files`)
} else {
var audioFileSize = 0
dataFound.libraryFiles.filter(lf => lf.fileType == 'audio').forEach(lf => audioFileSize += lf.metadata.size)
var mediaFileSize = 0
dataFound.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video').forEach(lf => mediaFileSize += lf.metadata.size)
// If this item will go over max size then push current chunk
if (audioFileSize + newItemDataToScanSize > MaxSizePerChunk && newItemDataToScan.length > 0) {
if (mediaFileSize + newItemDataToScanSize > MaxSizePerChunk && newItemDataToScan.length > 0) {
newItemDataToScanChunks.push(newItemDataToScan)
newItemDataToScanSize = 0
newItemDataToScan = []
}
newItemDataToScan.push(dataFound)
newItemDataToScanSize += audioFileSize
newItemDataToScanSize += mediaFileSize
if (newItemDataToScanSize >= MaxSizePerChunk) {
newItemDataToScanChunks.push(newItemDataToScan)
newItemDataToScanSize = 0
@ -337,7 +337,7 @@ class Scanner {
// forceRescan all existing audio files - will probe and update ID3 tag metadata
var existingAudioFiles = existingLibraryFiles.filter(lf => lf.fileType === 'audio')
if (libraryScan.scanOptions.forceRescan && existingAudioFiles.length) {
if (await AudioFileScanner.scanAudioFiles(existingAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) {
if (await MediaFileScanner.scanMediaFiles(existingAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) {
hasUpdated = true
}
}
@ -345,7 +345,7 @@ class Scanner {
var newAudioFiles = newLibraryFiles.filter(lf => lf.fileType === 'audio')
var removedAudioFiles = filesRemoved.filter(lf => lf.fileType === 'audio')
if (newAudioFiles.length || removedAudioFiles.length) {
if (await AudioFileScanner.scanAudioFiles(newAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) {
if (await MediaFileScanner.scanMediaFiles(newAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) {
hasUpdated = true
}
}
@ -386,9 +386,9 @@ class Scanner {
var libraryItem = new LibraryItem()
libraryItem.setData(libraryMediaType, libraryItemData)
var audioFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio')
if (audioFiles.length) {
await AudioFileScanner.scanAudioFiles(audioFiles, libraryItemData, libraryItem, preferAudioMetadata, libraryScan)
var mediaFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video')
if (mediaFiles.length) {
await MediaFileScanner.scanMediaFiles(mediaFiles, libraryItemData, libraryItem, preferAudioMetadata, libraryScan)
}
await libraryItem.syncFiles(preferOpfMetadata)
@ -408,7 +408,7 @@ class Scanner {
}
// Scan for cover if enabled and has no cover
if (libraryMediaType !== 'podcast') {
if (libraryMediaType === 'book') {
if (libraryItem && findCovers && !libraryItem.media.coverPath && libraryItem.media.shouldSearchForCover) {
var updatedCover = await this.searchForCover(libraryItem, libraryScan)
libraryItem.media.updateLastCoverSearch(updatedCover)

View file

@ -44,4 +44,8 @@ module.exports.AudioMimeType = {
FLAC: 'audio/flac',
WMA: 'audio/x-ms-wma',
AIFF: 'audio/x-aiff'
}
module.exports.VideoMimeType = {
MP4: 'video/mp4'
}

View file

@ -2,6 +2,7 @@ const globals = {
SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'mp4', 'aac', 'wma', 'aiff'],
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
SupportedVideoTypes: ['mp4'],
TextFileTypes: ['txt', 'nfo'],
MetadataFileTypes: ['opf', 'abs', 'xml']
}

View file

@ -85,7 +85,7 @@ module.exports = {
if (series && !data.series.find(se => se.id === series.id)) data.series.push({ id: series.id, name: series.name })
})
}
if (mediaMetadata.genres.length) {
if (mediaMetadata.genres && mediaMetadata.genres.length) {
mediaMetadata.genres.forEach((genre) => {
if (genre && !data.genres.includes(genre)) data.genres.push(genre)
})
@ -399,7 +399,7 @@ module.exports = {
}
}
}
} else {
} else if (libraryItem.isBook) {
// Book categories
// Newest series

View file

@ -1,5 +1,5 @@
const ffprobe = require('node-ffprobe')
const AudioProbeData = require('../scanner/AudioProbeData')
const MediaProbeData = require('../scanner/MediaProbeData')
const Logger = require('../Logger')
@ -274,7 +274,7 @@ function parseProbeData(data, verbose = false) {
}
}
// Updated probe returns AudioProbeData object
// Updated probe returns MediaProbeData object
function probe(filepath, verbose = false) {
if (process.env.FFPROBE_PATH) {
ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH
@ -283,12 +283,12 @@ function probe(filepath, verbose = false) {
return ffprobe(filepath)
.then(raw => {
var rawProbeData = parseProbeData(raw, verbose)
if (!rawProbeData || !rawProbeData.audio_stream) {
if (!rawProbeData || (!rawProbeData.audio_stream && !rawProbeData.video_stream)) {
return {
error: rawProbeData ? 'Invalid audio file: no audio streams found' : 'Probe Failed'
error: rawProbeData ? 'Invalid media file: no audio or video streams found' : 'Probe Failed'
}
} else {
var probeData = new AudioProbeData()
var probeData = new MediaProbeData()
probeData.setData(rawProbeData)
return probeData
}

View file

@ -11,6 +11,7 @@ function isMediaFile(mediaType, ext) {
if (!ext) return false
var extclean = ext.slice(1).toLowerCase()
if (mediaType === 'podcast') return globals.SupportedAudioTypes.includes(extclean)
else if (mediaType === 'video') return globals.SupportedVideoTypes.includes(extclean)
return globals.SupportedAudioTypes.includes(extclean) || globals.SupportedEbookTypes.includes(extclean)
}
@ -72,7 +73,7 @@ module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths
function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
// 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' && isMediaFile(mediaType, i.extension))
return i.deep > 0 || ((mediaType === 'book' || mediaType === 'video') && isMediaFile(mediaType, i.extension))
})
// Step 2: Seperate media files and other files
@ -136,7 +137,7 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
}
function cleanFileObjects(libraryItemPath, files) {
return Promise.all(files.map(async(file) => {
return Promise.all(files.map(async (file) => {
var filePath = Path.posix.join(libraryItemPath, file)
var newLibraryFile = new LibraryFile()
await newLibraryFile.setDataFromPath(filePath, file)
@ -314,9 +315,11 @@ function getPodcastDataFromDir(folderPath, relPath) {
function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettings) {
if (libraryMediaType === 'podcast') {
return getPodcastDataFromDir(folderPath, relPath)
} else {
} else if (libraryMediaType === 'book') {
var parseSubtitle = !!serverSettings.scannerParseSubtitle
return getBookDataFromDir(folderPath, relPath, parseSubtitle)
} else {
return this.getPodcastDataFromDir(folderPath, relPath)
}
}
@ -333,10 +336,10 @@ async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath,
if (isSingleMediaItem) { // Single media item in root of folder
fileItems = [
{
fullpath: libraryItemPath,
path: libraryItemDir // actually the relPath (only filename here)
}
]
fullpath: libraryItemPath,
path: libraryItemDir // actually the relPath (only filename here)
}
]
libraryItemData = {
path: libraryItemPath, // full path
relPath: libraryItemDir, // only filename