mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Merge branch 'advplyr:master' into audible-confidence-score
This commit is contained in:
		
						commit
						9c44fc0d01
					
				| @ -57,7 +57,7 @@ WORKDIR /app | |||||||
| # Copy compiled frontend and server from build stages | # Copy compiled frontend and server from build stages | ||||||
| COPY --from=build-client /client/dist /app/client/dist | COPY --from=build-client /client/dist /app/client/dist | ||||||
| COPY --from=build-server /server /app | COPY --from=build-server /server /app | ||||||
| COPY --from=build-server /usr/local/lib/nusqlite3 /usr/local/lib/nusqlite3 | COPY --from=build-server ${NUSQLITE3_PATH} ${NUSQLITE3_PATH} | ||||||
| 
 | 
 | ||||||
| EXPOSE 80 | EXPOSE 80 | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -94,6 +94,9 @@ export default { | |||||||
|     userIsAdminOrUp() { |     userIsAdminOrUp() { | ||||||
|       return this.$store.getters['user/getIsAdminOrUp'] |       return this.$store.getters['user/getIsAdminOrUp'] | ||||||
|     }, |     }, | ||||||
|  |     userCanAccessExplicitContent() { | ||||||
|  |       return this.$store.getters['user/getUserCanAccessExplicitContent'] | ||||||
|  |     }, | ||||||
|     libraryMediaType() { |     libraryMediaType() { | ||||||
|       return this.$store.getters['libraries/getCurrentLibraryMediaType'] |       return this.$store.getters['libraries/getCurrentLibraryMediaType'] | ||||||
|     }, |     }, | ||||||
| @ -239,6 +242,15 @@ export default { | |||||||
|           sublist: false |           sublist: false | ||||||
|         } |         } | ||||||
|       ] |       ] | ||||||
|  | 
 | ||||||
|  |       if (this.userCanAccessExplicitContent) { | ||||||
|  |         items.push({ | ||||||
|  |           text: this.$strings.LabelExplicit, | ||||||
|  |           value: 'explicit', | ||||||
|  |           sublist: false | ||||||
|  |         }) | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|       if (this.userIsAdminOrUp) { |       if (this.userIsAdminOrUp) { | ||||||
|         items.push({ |         items.push({ | ||||||
|           text: this.$strings.LabelShareOpen, |           text: this.$strings.LabelShareOpen, | ||||||
| @ -249,7 +261,7 @@ export default { | |||||||
|       return items |       return items | ||||||
|     }, |     }, | ||||||
|     podcastItems() { |     podcastItems() { | ||||||
|       return [ |       const items = [ | ||||||
|         { |         { | ||||||
|           text: this.$strings.LabelAll, |           text: this.$strings.LabelAll, | ||||||
|           value: 'all' |           value: 'all' | ||||||
| @ -283,6 +295,16 @@ export default { | |||||||
|           sublist: false |           sublist: false | ||||||
|         } |         } | ||||||
|       ] |       ] | ||||||
|  | 
 | ||||||
|  |       if (this.userCanAccessExplicitContent) { | ||||||
|  |         items.push({ | ||||||
|  |           text: this.$strings.LabelExplicit, | ||||||
|  |           value: 'explicit', | ||||||
|  |           sublist: false | ||||||
|  |         }) | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       return items | ||||||
|     }, |     }, | ||||||
|     selectItems() { |     selectItems() { | ||||||
|       if (this.isSeries) return this.seriesItems |       if (this.isSeries) return this.seriesItems | ||||||
|  | |||||||
| @ -35,7 +35,14 @@ | |||||||
|               <widgets-podcast-type-indicator :type="episode.episodeType" /> |               <widgets-podcast-type-indicator :type="episode.episodeType" /> | ||||||
|             </div> |             </div> | ||||||
|             <p v-if="episode.subtitle" class="mb-1 text-sm text-gray-300 line-clamp-2">{{ episode.subtitle }}</p> |             <p v-if="episode.subtitle" class="mb-1 text-sm text-gray-300 line-clamp-2">{{ episode.subtitle }}</p> | ||||||
|             <p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p> |             <div class="flex items-center space-x-2"> | ||||||
|  |               <!-- published --> | ||||||
|  |               <p class="text-xs text-gray-300 w-40">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p> | ||||||
|  |               <!-- duration --> | ||||||
|  |               <p v-if="episode.durationSeconds && !isNaN(episode.durationSeconds)" class="text-xs text-gray-300 min-w-28">{{ $strings.LabelDuration }}: {{ $elapsedPretty(episode.durationSeconds) }}</p> | ||||||
|  |               <!-- size --> | ||||||
|  |               <p v-if="episode.enclosure?.length && !isNaN(episode.enclosure.length) && Number(episode.enclosure.length) > 0" class="text-xs text-gray-300">{{ $strings.LabelSize }}: {{ $bytesPretty(Number(episode.enclosure.length)) }}</p> | ||||||
|  |             </div> | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|  | |||||||
| @ -16,7 +16,7 @@ | |||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|       <p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p> |       <p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p> | ||||||
|       <div v-if="description" dir="auto" class="default-style less-spacing" v-html="description" /> |       <div v-if="description" dir="auto" class="default-style less-spacing" @click="handleDescriptionClick" v-html="description" /> | ||||||
|       <p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p> |       <p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p> | ||||||
| 
 | 
 | ||||||
|       <div class="w-full h-px bg-white/5 my-4" /> |       <div class="w-full h-px bg-white/5 my-4" /> | ||||||
| @ -34,6 +34,12 @@ | |||||||
|             {{ audioFileSize }} |             {{ audioFileSize }} | ||||||
|           </p> |           </p> | ||||||
|         </div> |         </div> | ||||||
|  |         <div class="grow"> | ||||||
|  |           <p class="font-semibold text-xs mb-1">{{ $strings.LabelDuration }}</p> | ||||||
|  |           <p class="mb-2 text-xs"> | ||||||
|  |             {{ audioFileDuration }} | ||||||
|  |           </p> | ||||||
|  |         </div> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </modals-modal> |   </modals-modal> | ||||||
| @ -68,7 +74,7 @@ export default { | |||||||
|       return this.episode.title || 'No Episode Title' |       return this.episode.title || 'No Episode Title' | ||||||
|     }, |     }, | ||||||
|     description() { |     description() { | ||||||
|       return this.episode.description || '' |       return this.parseDescription(this.episode.description || '') | ||||||
|     }, |     }, | ||||||
|     media() { |     media() { | ||||||
|       return this.libraryItem?.media || {} |       return this.libraryItem?.media || {} | ||||||
| @ -90,11 +96,49 @@ export default { | |||||||
| 
 | 
 | ||||||
|       return this.$bytesPretty(size) |       return this.$bytesPretty(size) | ||||||
|     }, |     }, | ||||||
|  |     audioFileDuration() { | ||||||
|  |       const duration = this.episode.duration || 0 | ||||||
|  |       return this.$elapsedPretty(duration) | ||||||
|  |     }, | ||||||
|     bookCoverAspectRatio() { |     bookCoverAspectRatio() { | ||||||
|       return this.$store.getters['libraries/getBookCoverAspectRatio'] |       return this.$store.getters['libraries/getBookCoverAspectRatio'] | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: {}, |   methods: { | ||||||
|  |     handleDescriptionClick(e) { | ||||||
|  |       if (e.target.matches('span.time-marker')) { | ||||||
|  |         const time = parseInt(e.target.dataset.time) | ||||||
|  |         if (!isNaN(time)) { | ||||||
|  |           this.$eventBus.$emit('play-item', { | ||||||
|  |             episodeId: this.episodeId, | ||||||
|  |             libraryItemId: this.libraryItem.id, | ||||||
|  |             startTime: time | ||||||
|  |           }) | ||||||
|  |         } | ||||||
|  |         e.preventDefault() | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     parseDescription(description) { | ||||||
|  |       const timeMarkerLinkRegex = /<a href="#([^"]*?\b\d{1,2}:\d{1,2}(?::\d{1,2})?)">(.*?)<\/a>/g | ||||||
|  |       const timeMarkerRegex = /\b\d{1,2}:\d{1,2}(?::\d{1,2})?\b/g | ||||||
|  | 
 | ||||||
|  |       function convertToSeconds(time) { | ||||||
|  |         const timeParts = time.split(':').map(Number) | ||||||
|  |         return timeParts.reduce((acc, part, index) => acc * 60 + part, 0) | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       return description | ||||||
|  |         .replace(timeMarkerLinkRegex, (match, href, displayTime) => { | ||||||
|  |           const time = displayTime.match(timeMarkerRegex)[0] | ||||||
|  |           const seekTimeInSeconds = convertToSeconds(time) | ||||||
|  |           return `<span class="time-marker cursor-pointer text-blue-400 hover:text-blue-300" data-time="${seekTimeInSeconds}">${displayTime}</span>` | ||||||
|  |         }) | ||||||
|  |         .replace(timeMarkerRegex, (match) => { | ||||||
|  |           const seekTimeInSeconds = convertToSeconds(match) | ||||||
|  |           return `<span class="time-marker cursor-pointer text-blue-400 hover:text-blue-300" data-time="${seekTimeInSeconds}">${match}</span>` | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|   mounted() {} |   mounted() {} | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
|  | |||||||
| @ -58,6 +58,9 @@ export const getters = { | |||||||
|   getUserCanAccessAllLibraries: (state) => { |   getUserCanAccessAllLibraries: (state) => { | ||||||
|     return !!state.user?.permissions?.accessAllLibraries |     return !!state.user?.permissions?.accessAllLibraries | ||||||
|   }, |   }, | ||||||
|  |   getUserCanAccessExplicitContent: (state) => { | ||||||
|  |     return !!state.user?.permissions?.accessExplicitContent | ||||||
|  |   }, | ||||||
|   getLibrariesAccessible: (state, getters) => { |   getLibrariesAccessible: (state, getters) => { | ||||||
|     if (!state.user) return [] |     if (!state.user) return [] | ||||||
|     if (getters.getUserCanAccessAllLibraries) return [] |     if (getters.getUserCanAccessAllLibraries) return [] | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								index.js
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								index.js
									
									
									
									
									
								
							| @ -28,6 +28,7 @@ if (isDev) { | |||||||
|   if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1' |   if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1' | ||||||
|   if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1' |   if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1' | ||||||
|   if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath |   if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath | ||||||
|  |   if (devEnv.ReactClientPath) process.env.REACT_CLIENT_PATH = devEnv.ReactClientPath | ||||||
|   process.env.SOURCE = 'local' |   process.env.SOURCE = 'local' | ||||||
|   process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf' |   process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf' | ||||||
| } | } | ||||||
|  | |||||||
| @ -442,7 +442,17 @@ class Auth { | |||||||
|     // Local strategy login route (takes username and password)
 |     // Local strategy login route (takes username and password)
 | ||||||
|     router.post('/login', passport.authenticate('local'), async (req, res) => { |     router.post('/login', passport.authenticate('local'), async (req, res) => { | ||||||
|       // return the user login response json if the login was successfull
 |       // return the user login response json if the login was successfull
 | ||||||
|       res.json(await this.getUserLoginResponsePayload(req.user)) |       const userResponse = await this.getUserLoginResponsePayload(req.user) | ||||||
|  | 
 | ||||||
|  |       // Experimental Next.js client uses bearer token in cookies
 | ||||||
|  |       res.cookie('auth_token', userResponse.user.token, { | ||||||
|  |         httpOnly: true, | ||||||
|  |         secure: req.secure || req.get('x-forwarded-proto') === 'https', | ||||||
|  |         sameSite: 'strict', | ||||||
|  |         maxAge: 1000 * 60 * 60 * 24 * 7 // 7 days
 | ||||||
|  |       }) | ||||||
|  | 
 | ||||||
|  |       res.json(userResponse) | ||||||
|     }) |     }) | ||||||
| 
 | 
 | ||||||
|     // openid strategy login route (this redirects to the configured openid login provider)
 |     // openid strategy login route (this redirects to the configured openid login provider)
 | ||||||
| @ -718,6 +728,7 @@ class Auth { | |||||||
|           const authMethod = req.cookies.auth_method |           const authMethod = req.cookies.auth_method | ||||||
| 
 | 
 | ||||||
|           res.clearCookie('auth_method') |           res.clearCookie('auth_method') | ||||||
|  |           res.clearCookie('auth_token') | ||||||
| 
 | 
 | ||||||
|           let logoutUrl = null |           let logoutUrl = null | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -766,14 +766,25 @@ class Database { | |||||||
|       Logger.warn(`Removed ${badSessionsRemoved} sessions that were 3 seconds or less`) |       Logger.warn(`Removed ${badSessionsRemoved} sessions that were 3 seconds or less`) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // Remove mediaProgresses with duplicate mediaItemId (remove the oldest updatedAt)
 |     // Remove mediaProgresses with duplicate mediaItemId (remove the oldest updatedAt or if updatedAt is the same, remove arbitrary one)
 | ||||||
|     // const [duplicateMediaProgresses] = await this.sequelize.query(`SELECT id, mediaItemId FROM mediaProgresses WHERE (mediaItemId, userId, updatedAt) IN (SELECT mediaItemId, userId, MIN(updatedAt) FROM mediaProgresses GROUP BY mediaItemId, userId HAVING COUNT(*) > 1)`)
 |     const [duplicateMediaProgresses] = await this.sequelize.query(`SELECT mp1.id, mp1.mediaItemId
 | ||||||
|     // for (const duplicateMediaProgress of duplicateMediaProgresses) {
 | FROM mediaProgresses mp1 | ||||||
|     //   Logger.warn(`Found duplicate mediaProgress for mediaItem "${duplicateMediaProgress.mediaItemId}" - removing it`)
 | WHERE EXISTS ( | ||||||
|     //   await this.mediaProgressModel.destroy({
 |     SELECT 1 | ||||||
|     //     where: { id: duplicateMediaProgress.id }
 |     FROM mediaProgresses mp2 | ||||||
|     //   })
 |     WHERE mp2.mediaItemId = mp1.mediaItemId | ||||||
|     // }
 |     AND mp2.userId = mp1.userId | ||||||
|  |     AND ( | ||||||
|  |         mp2.updatedAt > mp1.updatedAt | ||||||
|  |         OR (mp2.updatedAt = mp1.updatedAt AND mp2.id < mp1.id) | ||||||
|  |     ) | ||||||
|  | )`)
 | ||||||
|  |     for (const duplicateMediaProgress of duplicateMediaProgresses) { | ||||||
|  |       Logger.warn(`Found duplicate mediaProgress for mediaItem "${duplicateMediaProgress.mediaItemId}" - removing it`) | ||||||
|  |       await this.mediaProgressModel.destroy({ | ||||||
|  |         where: { id: duplicateMediaProgress.id } | ||||||
|  |       }) | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async createTextSearchQuery(query) { |   async createTextSearchQuery(query) { | ||||||
|  | |||||||
| @ -220,6 +220,7 @@ class Server { | |||||||
| 
 | 
 | ||||||
|   async start() { |   async start() { | ||||||
|     Logger.info('=== Starting Server ===') |     Logger.info('=== Starting Server ===') | ||||||
|  | 
 | ||||||
|     this.initProcessEventListeners() |     this.initProcessEventListeners() | ||||||
|     await this.init() |     await this.init() | ||||||
| 
 | 
 | ||||||
| @ -281,6 +282,7 @@ class Server { | |||||||
|     await this.auth.initPassportJs() |     await this.auth.initPassportJs() | ||||||
| 
 | 
 | ||||||
|     const router = express.Router() |     const router = express.Router() | ||||||
|  | 
 | ||||||
|     // if RouterBasePath is set, modify all requests to include the base path
 |     // if RouterBasePath is set, modify all requests to include the base path
 | ||||||
|     app.use((req, res, next) => { |     app.use((req, res, next) => { | ||||||
|       const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath) |       const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath) | ||||||
| @ -313,10 +315,6 @@ class Server { | |||||||
|     router.use('/hls', this.hlsRouter.router) |     router.use('/hls', this.hlsRouter.router) | ||||||
|     router.use('/public', this.publicRouter.router) |     router.use('/public', this.publicRouter.router) | ||||||
| 
 | 
 | ||||||
|     // Static path to generated nuxt
 |  | ||||||
|     const distPath = Path.join(global.appRoot, '/client/dist') |  | ||||||
|     router.use(express.static(distPath)) |  | ||||||
| 
 |  | ||||||
|     // Static folder
 |     // Static folder
 | ||||||
|     router.use(express.static(Path.join(global.appRoot, 'static'))) |     router.use(express.static(Path.join(global.appRoot, 'static'))) | ||||||
| 
 | 
 | ||||||
| @ -336,32 +334,6 @@ class Server { | |||||||
|     // Auth routes
 |     // Auth routes
 | ||||||
|     await this.auth.initAuthRoutes(router) |     await this.auth.initAuthRoutes(router) | ||||||
| 
 | 
 | ||||||
|     // Client dynamic routes
 |  | ||||||
|     const dynamicRoutes = [ |  | ||||||
|       '/item/:id', |  | ||||||
|       '/author/:id', |  | ||||||
|       '/audiobook/:id/chapters', |  | ||||||
|       '/audiobook/:id/edit', |  | ||||||
|       '/audiobook/:id/manage', |  | ||||||
|       '/library/:library', |  | ||||||
|       '/library/:library/search', |  | ||||||
|       '/library/:library/bookshelf/:id?', |  | ||||||
|       '/library/:library/authors', |  | ||||||
|       '/library/:library/narrators', |  | ||||||
|       '/library/:library/stats', |  | ||||||
|       '/library/:library/series/:id?', |  | ||||||
|       '/library/:library/podcast/search', |  | ||||||
|       '/library/:library/podcast/latest', |  | ||||||
|       '/library/:library/podcast/download-queue', |  | ||||||
|       '/config/users/:id', |  | ||||||
|       '/config/users/:id/sessions', |  | ||||||
|       '/config/item-metadata-utils/:id', |  | ||||||
|       '/collection/:id', |  | ||||||
|       '/playlist/:id', |  | ||||||
|       '/share/:slug' |  | ||||||
|     ] |  | ||||||
|     dynamicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html')))) |  | ||||||
| 
 |  | ||||||
|     router.post('/init', (req, res) => { |     router.post('/init', (req, res) => { | ||||||
|       if (Database.hasRootUser) { |       if (Database.hasRootUser) { | ||||||
|         Logger.error(`[Server] attempt to init server when server already has a root user`) |         Logger.error(`[Server] attempt to init server when server already has a root user`) | ||||||
| @ -392,6 +364,48 @@ class Server { | |||||||
|     }) |     }) | ||||||
|     router.get('/healthcheck', (req, res) => res.sendStatus(200)) |     router.get('/healthcheck', (req, res) => res.sendStatus(200)) | ||||||
| 
 | 
 | ||||||
|  |     const ReactClientPath = process.env.REACT_CLIENT_PATH | ||||||
|  |     if (!ReactClientPath) { | ||||||
|  |       // Static path to generated nuxt
 | ||||||
|  |       const distPath = Path.join(global.appRoot, '/client/dist') | ||||||
|  |       router.use(express.static(distPath)) | ||||||
|  | 
 | ||||||
|  |       // Client dynamic routes
 | ||||||
|  |       const dynamicRoutes = [ | ||||||
|  |         '/item/:id', | ||||||
|  |         '/author/:id', | ||||||
|  |         '/audiobook/:id/chapters', | ||||||
|  |         '/audiobook/:id/edit', | ||||||
|  |         '/audiobook/:id/manage', | ||||||
|  |         '/library/:library', | ||||||
|  |         '/library/:library/search', | ||||||
|  |         '/library/:library/bookshelf/:id?', | ||||||
|  |         '/library/:library/authors', | ||||||
|  |         '/library/:library/narrators', | ||||||
|  |         '/library/:library/stats', | ||||||
|  |         '/library/:library/series/:id?', | ||||||
|  |         '/library/:library/podcast/search', | ||||||
|  |         '/library/:library/podcast/latest', | ||||||
|  |         '/library/:library/podcast/download-queue', | ||||||
|  |         '/config/users/:id', | ||||||
|  |         '/config/users/:id/sessions', | ||||||
|  |         '/config/item-metadata-utils/:id', | ||||||
|  |         '/collection/:id', | ||||||
|  |         '/playlist/:id', | ||||||
|  |         '/share/:slug' | ||||||
|  |       ] | ||||||
|  |       dynamicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html')))) | ||||||
|  |     } else { | ||||||
|  |       // This is for using the experimental Next.js client
 | ||||||
|  |       Logger.info(`Using React client at ${ReactClientPath}`) | ||||||
|  |       const nextPath = Path.join(ReactClientPath, 'node_modules/next') | ||||||
|  |       const next = require(nextPath) | ||||||
|  |       const nextApp = next({ dev: Logger.isDev, dir: ReactClientPath }) | ||||||
|  |       const handle = nextApp.getRequestHandler() | ||||||
|  |       await nextApp.prepare() | ||||||
|  |       router.get('*', (req, res) => handle(req, res)) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     const unixSocketPrefix = 'unix/' |     const unixSocketPrefix = 'unix/' | ||||||
|     if (this.Host?.startsWith(unixSocketPrefix)) { |     if (this.Host?.startsWith(unixSocketPrefix)) { | ||||||
|       const sockPath = this.Host.slice(unixSocketPrefix.length) |       const sockPath = this.Host.slice(unixSocketPrefix.length) | ||||||
|  | |||||||
| @ -52,9 +52,7 @@ class FantLab { | |||||||
|         return [] |         return [] | ||||||
|       }) |       }) | ||||||
| 
 | 
 | ||||||
|     return Promise.all(items.map(async (item) => await this.getWork(item, timeout))).then((resArray) => { |     return Promise.all(items.map(async (item) => await this.getWork(item, timeout))).then((resArray) => resArray.filter(Boolean)) | ||||||
|       return resArray.filter((res) => res) |  | ||||||
|     }) |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
| @ -83,6 +81,10 @@ class FantLab { | |||||||
|         return null |         return null | ||||||
|       }) |       }) | ||||||
| 
 | 
 | ||||||
|  |     if (!bookData) { | ||||||
|  |       return null | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     return this.cleanBookData(bookData, timeout) |     return this.cleanBookData(bookData, timeout) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -25,6 +25,7 @@ const Fuse = require('../libs/fusejs') | |||||||
|  * @property {string} episode |  * @property {string} episode | ||||||
|  * @property {string} author |  * @property {string} author | ||||||
|  * @property {string} duration |  * @property {string} duration | ||||||
|  |  * @property {number|null} durationSeconds - Parsed from duration string if duration is valid | ||||||
|  * @property {string} explicit |  * @property {string} explicit | ||||||
|  * @property {number} publishedAt - Unix timestamp |  * @property {number} publishedAt - Unix timestamp | ||||||
|  * @property {{ url: string, type?: string, length?: string }} enclosure |  * @property {{ url: string, type?: string, length?: string }} enclosure | ||||||
| @ -217,8 +218,9 @@ function extractEpisodeData(item) { | |||||||
|   }) |   }) | ||||||
| 
 | 
 | ||||||
|   // Extract psc:chapters if duration is set
 |   // Extract psc:chapters if duration is set
 | ||||||
|   let episodeDuration = !isNaN(episode.duration) ? timestampToSeconds(episode.duration) : null |   episode.durationSeconds = episode.duration ? timestampToSeconds(episode.duration) : null | ||||||
|   if (item['psc:chapters']?.[0]?.['psc:chapter']?.length && episodeDuration) { | 
 | ||||||
|  |   if (item['psc:chapters']?.[0]?.['psc:chapter']?.length && episode.durationSeconds) { | ||||||
|     // Example chapter:
 |     // Example chapter:
 | ||||||
|     // {"id":0,"start":0,"end":43.004286,"title":"chapter 1"}
 |     // {"id":0,"start":0,"end":43.004286,"title":"chapter 1"}
 | ||||||
| 
 | 
 | ||||||
| @ -244,7 +246,7 @@ function extractEpisodeData(item) { | |||||||
|     } else { |     } else { | ||||||
|       episode.chapters = cleanedChapters.map((chapter, index) => { |       episode.chapters = cleanedChapters.map((chapter, index) => { | ||||||
|         const nextChapter = cleanedChapters[index + 1] |         const nextChapter = cleanedChapters[index + 1] | ||||||
|         const end = nextChapter ? nextChapter.start : episodeDuration |         const end = nextChapter ? nextChapter.start : episode.durationSeconds | ||||||
|         return { |         return { | ||||||
|           id: chapter.id, |           id: chapter.id, | ||||||
|           title: chapter.title, |           title: chapter.title, | ||||||
| @ -273,6 +275,7 @@ function cleanEpisodeData(data) { | |||||||
|     episode: data.episode || '', |     episode: data.episode || '', | ||||||
|     author: data.author || '', |     author: data.author || '', | ||||||
|     duration: data.duration || '', |     duration: data.duration || '', | ||||||
|  |     durationSeconds: data.durationSeconds || null, | ||||||
|     explicit: data.explicit || '', |     explicit: data.explicit || '', | ||||||
|     publishedAt, |     publishedAt, | ||||||
|     enclosure: data.enclosure, |     enclosure: data.enclosure, | ||||||
|  | |||||||
| @ -186,6 +186,8 @@ module.exports = { | |||||||
|       mediaWhere['$series.id$'] = null |       mediaWhere['$series.id$'] = null | ||||||
|     } else if (group === 'abridged') { |     } else if (group === 'abridged') { | ||||||
|       mediaWhere['abridged'] = true |       mediaWhere['abridged'] = true | ||||||
|  |     } else if (group === 'explicit') { | ||||||
|  |       mediaWhere['explicit'] = true | ||||||
|     } else if (['genres', 'tags', 'narrators'].includes(group)) { |     } else if (['genres', 'tags', 'narrators'].includes(group)) { | ||||||
|       mediaWhere[group] = Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(${group}) WHERE json_valid(${group}) AND json_each.value = :filterValue)`), { |       mediaWhere[group] = Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(${group}) WHERE json_valid(${group}) AND json_each.value = :filterValue)`), { | ||||||
|         [Sequelize.Op.gte]: 1 |         [Sequelize.Op.gte]: 1 | ||||||
| @ -251,6 +253,15 @@ module.exports = { | |||||||
|    */ |    */ | ||||||
|   getOrder(sortBy, sortDesc, collapseseries) { |   getOrder(sortBy, sortDesc, collapseseries) { | ||||||
|     const dir = sortDesc ? 'DESC' : 'ASC' |     const dir = sortDesc ? 'DESC' : 'ASC' | ||||||
|  | 
 | ||||||
|  |     const getTitleOrder = () => { | ||||||
|  |       if (global.ServerSettings.sortingIgnorePrefix) { | ||||||
|  |         return [Sequelize.literal('`libraryItem`.`titleIgnorePrefix` COLLATE NOCASE'), dir] | ||||||
|  |       } else { | ||||||
|  |         return [Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir] | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     if (sortBy === 'addedAt') { |     if (sortBy === 'addedAt') { | ||||||
|       return [[Sequelize.literal('libraryItem.createdAt'), dir]] |       return [[Sequelize.literal('libraryItem.createdAt'), dir]] | ||||||
|     } else if (sortBy === 'size') { |     } else if (sortBy === 'size') { | ||||||
| @ -264,25 +275,16 @@ module.exports = { | |||||||
|     } else if (sortBy === 'media.metadata.publishedYear') { |     } else if (sortBy === 'media.metadata.publishedYear') { | ||||||
|       return [[Sequelize.literal(`CAST(\`book\`.\`publishedYear\` AS INTEGER)`), dir]] |       return [[Sequelize.literal(`CAST(\`book\`.\`publishedYear\` AS INTEGER)`), dir]] | ||||||
|     } else if (sortBy === 'media.metadata.authorNameLF') { |     } else if (sortBy === 'media.metadata.authorNameLF') { | ||||||
|       return [ |       // Sort by author name last first, secondary sort by title
 | ||||||
|         [Sequelize.literal('`libraryItem`.`authorNamesLastFirst` COLLATE NOCASE'), dir], |       return [[Sequelize.literal('`libraryItem`.`authorNamesLastFirst` COLLATE NOCASE'), dir], getTitleOrder()] | ||||||
|         [Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir] |  | ||||||
|       ] |  | ||||||
|     } else if (sortBy === 'media.metadata.authorName') { |     } else if (sortBy === 'media.metadata.authorName') { | ||||||
|       return [ |       // Sort by author name first last, secondary sort by title
 | ||||||
|         [Sequelize.literal('`libraryItem`.`authorNamesFirstLast` COLLATE NOCASE'), dir], |       return [[Sequelize.literal('`libraryItem`.`authorNamesFirstLast` COLLATE NOCASE'), dir], getTitleOrder()] | ||||||
|         [Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir] |  | ||||||
|       ] |  | ||||||
|     } else if (sortBy === 'media.metadata.title') { |     } else if (sortBy === 'media.metadata.title') { | ||||||
|       if (collapseseries) { |       if (collapseseries) { | ||||||
|         return [[Sequelize.literal('display_title COLLATE NOCASE'), dir]] |         return [[Sequelize.literal('display_title COLLATE NOCASE'), dir]] | ||||||
|       } |       } | ||||||
| 
 |       return [getTitleOrder()] | ||||||
|       if (global.ServerSettings.sortingIgnorePrefix) { |  | ||||||
|         return [[Sequelize.literal('`libraryItem`.`titleIgnorePrefix` COLLATE NOCASE'), dir]] |  | ||||||
|       } else { |  | ||||||
|         return [[Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]] |  | ||||||
|       } |  | ||||||
|     } else if (sortBy === 'sequence') { |     } else if (sortBy === 'sequence') { | ||||||
|       const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST' |       const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST' | ||||||
|       return [[Sequelize.literal(`CAST(\`series.bookSeries.sequence\` AS FLOAT) ${nullDir}`)]] |       return [[Sequelize.literal(`CAST(\`series.bookSeries.sequence\` AS FLOAT) ${nullDir}`)]] | ||||||
|  | |||||||
| @ -59,6 +59,8 @@ module.exports = { | |||||||
|       replacements.filterValue = value |       replacements.filterValue = value | ||||||
|     } else if (group === 'languages') { |     } else if (group === 'languages') { | ||||||
|       mediaWhere['language'] = value |       mediaWhere['language'] = value | ||||||
|  |     } else if (group === 'explicit') { | ||||||
|  |       mediaWhere['explicit'] = true | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return { |     return { | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user