mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Merge branch 'master' into shawn/rss-feeds
This commit is contained in:
		
						commit
						24989e73ae
					
				@ -171,7 +171,7 @@ export default {
 | 
			
		||||
    },
 | 
			
		||||
    async fetchCategories() {
 | 
			
		||||
      const categories = await this.$axios
 | 
			
		||||
        .$get(`/api/libraries/${this.currentLibraryId}/personalized2?include=rssfeed,numEpisodesIncomplete`)
 | 
			
		||||
        .$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed,numEpisodesIncomplete`)
 | 
			
		||||
        .then((data) => {
 | 
			
		||||
          return data
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
@ -628,6 +628,11 @@ export default {
 | 
			
		||||
      return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed
 | 
			
		||||
    },
 | 
			
		||||
    async init(bookshelf) {
 | 
			
		||||
      if (this.entityName === 'series') {
 | 
			
		||||
        this.booksPerFetch = 50
 | 
			
		||||
      } else {
 | 
			
		||||
        this.booksPerFetch = 100
 | 
			
		||||
      }
 | 
			
		||||
      this.checkUpdateSearchParams()
 | 
			
		||||
      this.initSizeData(bookshelf)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -36,7 +36,7 @@ export default {
 | 
			
		||||
      return this.narrator?.name || ''
 | 
			
		||||
    },
 | 
			
		||||
    numBooks() {
 | 
			
		||||
      return this.narrator?.books?.length || 0
 | 
			
		||||
      return this.narrator?.numBooks || this.narrator?.books?.length || 0
 | 
			
		||||
    },
 | 
			
		||||
    userCanUpdate() {
 | 
			
		||||
      return this.$store.getters['user/getUserCanUpdate']
 | 
			
		||||
 | 
			
		||||
@ -103,7 +103,7 @@ export default {
 | 
			
		||||
      return this.$store.state.libraries.currentLibraryId
 | 
			
		||||
    },
 | 
			
		||||
    totalResults() {
 | 
			
		||||
      return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length
 | 
			
		||||
      return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length + this.narratorResults.length
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
 | 
			
		||||
@ -18,7 +18,7 @@
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="flex px-4">
 | 
			
		||||
    <div v-if="isBookLibrary" class="flex px-4">
 | 
			
		||||
      <svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
 | 
			
		||||
        <path fill="currentColor" d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" />
 | 
			
		||||
      </svg>
 | 
			
		||||
@ -58,26 +58,32 @@ export default {
 | 
			
		||||
    return {}
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    currentLibraryMediaType() {
 | 
			
		||||
      return this.$store.getters['libraries/getCurrentLibraryMediaType']
 | 
			
		||||
    },
 | 
			
		||||
    isBookLibrary() {
 | 
			
		||||
      return this.currentLibraryMediaType === 'book'
 | 
			
		||||
    },
 | 
			
		||||
    user() {
 | 
			
		||||
      return this.$store.state.user.user
 | 
			
		||||
    },
 | 
			
		||||
    totalItems() {
 | 
			
		||||
      return this.libraryStats ? this.libraryStats.totalItems : 0
 | 
			
		||||
      return this.libraryStats?.totalItems || 0
 | 
			
		||||
    },
 | 
			
		||||
    totalAuthors() {
 | 
			
		||||
      return this.libraryStats ? this.libraryStats.totalAuthors : 0
 | 
			
		||||
      return this.libraryStats?.totalAuthors || 0
 | 
			
		||||
    },
 | 
			
		||||
    numAudioTracks() {
 | 
			
		||||
      return this.libraryStats ? this.libraryStats.numAudioTracks : 0
 | 
			
		||||
      return this.libraryStats?.numAudioTracks || 0
 | 
			
		||||
    },
 | 
			
		||||
    totalDuration() {
 | 
			
		||||
      return this.libraryStats ? this.libraryStats.totalDuration : 0
 | 
			
		||||
      return this.libraryStats?.totalDuration || 0
 | 
			
		||||
    },
 | 
			
		||||
    totalHours() {
 | 
			
		||||
      return Math.round(this.totalDuration / (60 * 60))
 | 
			
		||||
    },
 | 
			
		||||
    totalSizePretty() {
 | 
			
		||||
      var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0
 | 
			
		||||
      var totalSize = this.libraryStats?.totalSize || 0
 | 
			
		||||
      return this.$bytesPretty(totalSize, 1)
 | 
			
		||||
    },
 | 
			
		||||
    totalSizeNum() {
 | 
			
		||||
 | 
			
		||||
@ -343,6 +343,10 @@ export default {
 | 
			
		||||
      }
 | 
			
		||||
      this.$store.commit('libraries/removeCollection', collection)
 | 
			
		||||
    },
 | 
			
		||||
    seriesRemoved({ id, libraryId }) {
 | 
			
		||||
      if (this.currentLibraryId !== libraryId) return
 | 
			
		||||
      this.$store.commit('libraries/removeSeriesFromFilterData', id)
 | 
			
		||||
    },
 | 
			
		||||
    playlistAdded(playlist) {
 | 
			
		||||
      if (playlist.userId !== this.user.id || this.currentLibraryId !== playlist.libraryId) return
 | 
			
		||||
      this.$store.commit('libraries/addUpdateUserPlaylist', playlist)
 | 
			
		||||
@ -442,6 +446,9 @@ export default {
 | 
			
		||||
      this.socket.on('collection_updated', this.collectionUpdated)
 | 
			
		||||
      this.socket.on('collection_removed', this.collectionRemoved)
 | 
			
		||||
 | 
			
		||||
      // Series Listeners
 | 
			
		||||
      this.socket.on('series_removed', this.seriesRemoved)
 | 
			
		||||
 | 
			
		||||
      // User Playlist Listeners
 | 
			
		||||
      this.socket.on('playlist_added', this.playlistAdded)
 | 
			
		||||
      this.socket.on('playlist_updated', this.playlistUpdated)
 | 
			
		||||
 | 
			
		||||
@ -22,7 +22,7 @@
 | 
			
		||||
            </div>
 | 
			
		||||
          </template>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="w-80 my-6 mx-auto">
 | 
			
		||||
        <div v-if="isBookLibrary" class="w-80 my-6 mx-auto">
 | 
			
		||||
          <h1 class="text-2xl mb-4">{{ $strings.HeaderStatsTop10Authors }}</h1>
 | 
			
		||||
          <p v-if="!top10Authors.length">{{ $strings.MessageNoAuthors }}</p>
 | 
			
		||||
          <template v-for="(author, index) in top10Authors">
 | 
			
		||||
@ -114,43 +114,49 @@ export default {
 | 
			
		||||
      return this.$store.state.user.user
 | 
			
		||||
    },
 | 
			
		||||
    totalItems() {
 | 
			
		||||
      return this.libraryStats ? this.libraryStats.totalItems : 0
 | 
			
		||||
      return this.libraryStats?.totalItems || 0
 | 
			
		||||
    },
 | 
			
		||||
    genresWithCount() {
 | 
			
		||||
      return this.libraryStats ? this.libraryStats.genresWithCount : []
 | 
			
		||||
      return this.libraryStats?.genresWithCount || []
 | 
			
		||||
    },
 | 
			
		||||
    top5Genres() {
 | 
			
		||||
      return this.genresWithCount.slice(0, 5)
 | 
			
		||||
      return this.genresWithCount?.slice(0, 5) || []
 | 
			
		||||
    },
 | 
			
		||||
    top10LongestItems() {
 | 
			
		||||
      return this.libraryStats ? this.libraryStats.longestItems || [] : []
 | 
			
		||||
      return this.libraryStats?.longestItems || []
 | 
			
		||||
    },
 | 
			
		||||
    longestItemDuration() {
 | 
			
		||||
      if (!this.top10LongestItems.length) return 0
 | 
			
		||||
      return this.top10LongestItems[0].duration
 | 
			
		||||
    },
 | 
			
		||||
    top10LargestItems() {
 | 
			
		||||
      return this.libraryStats ? this.libraryStats.largestItems || [] : []
 | 
			
		||||
      return this.libraryStats?.largestItems || []
 | 
			
		||||
    },
 | 
			
		||||
    largestItemSize() {
 | 
			
		||||
      if (!this.top10LargestItems.length) return 0
 | 
			
		||||
      return this.top10LargestItems[0].size
 | 
			
		||||
    },
 | 
			
		||||
    authorsWithCount() {
 | 
			
		||||
      return this.libraryStats ? this.libraryStats.authorsWithCount : []
 | 
			
		||||
      return this.libraryStats?.authorsWithCount || []
 | 
			
		||||
    },
 | 
			
		||||
    mostUsedAuthorCount() {
 | 
			
		||||
      if (!this.authorsWithCount.length) return 0
 | 
			
		||||
      return this.authorsWithCount[0].count
 | 
			
		||||
    },
 | 
			
		||||
    top10Authors() {
 | 
			
		||||
      return this.authorsWithCount.slice(0, 10)
 | 
			
		||||
      return this.authorsWithCount?.slice(0, 10) || []
 | 
			
		||||
    },
 | 
			
		||||
    currentLibraryId() {
 | 
			
		||||
      return this.$store.state.libraries.currentLibraryId
 | 
			
		||||
    },
 | 
			
		||||
    currentLibraryName() {
 | 
			
		||||
      return this.$store.getters['libraries/getCurrentLibraryName']
 | 
			
		||||
    },
 | 
			
		||||
    currentLibraryMediaType() {
 | 
			
		||||
      return this.$store.getters['libraries/getCurrentLibraryMediaType']
 | 
			
		||||
    },
 | 
			
		||||
    isBookLibrary() {
 | 
			
		||||
      return this.currentLibraryMediaType === 'book'
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
 | 
			
		||||
@ -234,6 +234,10 @@ export const mutations = {
 | 
			
		||||
  setNumUserPlaylists(state, numUserPlaylists) {
 | 
			
		||||
    state.numUserPlaylists = numUserPlaylists
 | 
			
		||||
  },
 | 
			
		||||
  removeSeriesFromFilterData(state, seriesId) {
 | 
			
		||||
    if (!seriesId || !state.filterData) return
 | 
			
		||||
    state.filterData.series = state.filterData.series.filter(se => se.id !== seriesId)
 | 
			
		||||
  },
 | 
			
		||||
  updateFilterDataWithItem(state, libraryItem) {
 | 
			
		||||
    if (!libraryItem || !state.filterData) return
 | 
			
		||||
    if (state.currentLibraryId !== libraryItem.libraryId) return
 | 
			
		||||
 | 
			
		||||
@ -32,7 +32,7 @@ class Auth {
 | 
			
		||||
    await Database.updateServerSettings()
 | 
			
		||||
 | 
			
		||||
    // New token secret creation added in v2.1.0 so generate new API tokens for each user
 | 
			
		||||
    const users = await Database.models.user.getOldUsers()
 | 
			
		||||
    const users = await Database.userModel.getOldUsers()
 | 
			
		||||
    if (users.length) {
 | 
			
		||||
      for (const user of users) {
 | 
			
		||||
        user.token = await this.generateAccessToken({ userId: user.id, username: user.username })
 | 
			
		||||
@ -100,7 +100,7 @@ class Auth {
 | 
			
		||||
          return resolve(null)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const user = await Database.models.user.getUserByIdOrOldId(payload.userId)
 | 
			
		||||
        const user = await Database.userModel.getUserByIdOrOldId(payload.userId)
 | 
			
		||||
        if (user && user.username === payload.username) {
 | 
			
		||||
          resolve(user)
 | 
			
		||||
        } else {
 | 
			
		||||
@ -116,7 +116,7 @@ class Auth {
 | 
			
		||||
   * @returns {object}
 | 
			
		||||
   */
 | 
			
		||||
  async getUserLoginResponsePayload(user) {
 | 
			
		||||
    const libraryIds = await Database.models.library.getAllLibraryIds()
 | 
			
		||||
    const libraryIds = await Database.libraryModel.getAllLibraryIds()
 | 
			
		||||
    return {
 | 
			
		||||
      user: user.toJSONForBrowser(),
 | 
			
		||||
      userDefaultLibraryId: user.getDefaultLibraryId(libraryIds),
 | 
			
		||||
@ -131,7 +131,7 @@ class Auth {
 | 
			
		||||
    const username = (req.body.username || '').toLowerCase()
 | 
			
		||||
    const password = req.body.password || ''
 | 
			
		||||
 | 
			
		||||
    const user = await Database.models.user.getUserByUsername(username)
 | 
			
		||||
    const user = await Database.userModel.getUserByUsername(username)
 | 
			
		||||
 | 
			
		||||
    if (!user?.isActive) {
 | 
			
		||||
      Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
 | 
			
		||||
@ -178,7 +178,7 @@ class Auth {
 | 
			
		||||
  async userChangePassword(req, res) {
 | 
			
		||||
    var { password, newPassword } = req.body
 | 
			
		||||
    newPassword = newPassword || ''
 | 
			
		||||
    const matchingUser = await Database.models.user.getUserById(req.user.id)
 | 
			
		||||
    const matchingUser = await Database.userModel.getUserById(req.user.id)
 | 
			
		||||
 | 
			
		||||
    // Only root can have an empty password
 | 
			
		||||
    if (matchingUser.type !== 'root' && !newPassword) {
 | 
			
		||||
 | 
			
		||||
@ -34,6 +34,100 @@ class Database {
 | 
			
		||||
    return this.sequelize?.models || {}
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /** @type {typeof import('./models/User')} */
 | 
			
		||||
  get userModel() {
 | 
			
		||||
    return this.models.user
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /** @type {typeof import('./models/Library')} */
 | 
			
		||||
  get libraryModel() {
 | 
			
		||||
    return this.models.library
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /** @type {typeof import('./models/Author')} */
 | 
			
		||||
  get authorModel() {
 | 
			
		||||
    return this.models.author
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /** @type {typeof import('./models/Series')} */
 | 
			
		||||
  get seriesModel() {
 | 
			
		||||
    return this.models.series
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /** @type {typeof import('./models/Book')} */
 | 
			
		||||
  get bookModel() {
 | 
			
		||||
    return this.models.book
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /** @type {typeof import('./models/BookSeries')} */
 | 
			
		||||
  get bookSeriesModel() {
 | 
			
		||||
    return this.models.bookSeries
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /** @type {typeof import('./models/BookAuthor')} */
 | 
			
		||||
  get bookAuthorModel() {
 | 
			
		||||
    return this.models.bookAuthor
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /** @type {typeof import('./models/Podcast')} */
 | 
			
		||||
  get podcastModel() {
 | 
			
		||||
    return this.models.podcast
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /** @type {typeof import('./models/PodcastEpisode')} */
 | 
			
		||||
  get podcastEpisodeModel() {
 | 
			
		||||
    return this.models.podcastEpisode
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /** @type {typeof import('./models/LibraryItem')} */
 | 
			
		||||
  get libraryItemModel() {
 | 
			
		||||
    return this.models.libraryItem
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /** @type {typeof import('./models/PodcastEpisode')} */
 | 
			
		||||
  get podcastEpisodeModel() {
 | 
			
		||||
    return this.models.podcastEpisode
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /** @type {typeof import('./models/MediaProgress')} */
 | 
			
		||||
  get mediaProgressModel() {
 | 
			
		||||
    return this.models.mediaProgress
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /** @type {typeof import('./models/Collection')} */
 | 
			
		||||
  get collectionModel() {
 | 
			
		||||
    return this.models.collection
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /** @type {typeof import('./models/CollectionBook')} */
 | 
			
		||||
  get collectionBookModel() {
 | 
			
		||||
    return this.models.collectionBook
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /** @type {typeof import('./models/Playlist')} */
 | 
			
		||||
  get playlistModel() {
 | 
			
		||||
    return this.models.playlist
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /** @type {typeof import('./models/PlaylistMediaItem')} */
 | 
			
		||||
  get playlistMediaItemModel() {
 | 
			
		||||
    return this.models.playlistMediaItem
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /** @type {typeof import('./models/Feed')} */
 | 
			
		||||
  get feedModel() {
 | 
			
		||||
    return this.models.feed
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /** @type {typeof import('./models/Feed')} */
 | 
			
		||||
  get feedEpisodeModel() {
 | 
			
		||||
    return this.models.feedEpisode
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Check if db file exists
 | 
			
		||||
   * @returns {boolean}
 | 
			
		||||
   */
 | 
			
		||||
  async checkHasDb() {
 | 
			
		||||
    if (!await fs.pathExists(this.dbPath)) {
 | 
			
		||||
      Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`)
 | 
			
		||||
@ -42,6 +136,10 @@ class Database {
 | 
			
		||||
    return true
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Connect to db, build models and run migrations
 | 
			
		||||
   * @param {boolean} [force=false] Used for testing, drops & re-creates all tables
 | 
			
		||||
   */
 | 
			
		||||
  async init(force = false) {
 | 
			
		||||
    this.dbPath = Path.join(global.ConfigPath, 'absdatabase.sqlite')
 | 
			
		||||
 | 
			
		||||
@ -58,6 +156,10 @@ class Database {
 | 
			
		||||
    await this.loadData()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Connect to db
 | 
			
		||||
   * @returns {boolean}
 | 
			
		||||
   */
 | 
			
		||||
  async connect() {
 | 
			
		||||
    Logger.info(`[Database] Initializing db at "${this.dbPath}"`)
 | 
			
		||||
    this.sequelize = new Sequelize({
 | 
			
		||||
@ -80,12 +182,18 @@ class Database {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Disconnect from db
 | 
			
		||||
   */
 | 
			
		||||
  async disconnect() {
 | 
			
		||||
    Logger.info(`[Database] Disconnecting sqlite db`)
 | 
			
		||||
    await this.sequelize.close()
 | 
			
		||||
    this.sequelize = null
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Reconnect to db and init
 | 
			
		||||
   */
 | 
			
		||||
  async reconnect() {
 | 
			
		||||
    Logger.info(`[Database] Reconnecting sqlite db`)
 | 
			
		||||
    await this.init()
 | 
			
		||||
@ -481,6 +589,88 @@ class Database {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  removeSeriesFromFilterData(libraryId, seriesId) {
 | 
			
		||||
    if (!this.libraryFilterData[libraryId]) return
 | 
			
		||||
    this.libraryFilterData[libraryId].series = this.libraryFilterData[libraryId].series.filter(se => se.id !== seriesId)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  addSeriesToFilterData(libraryId, seriesName, seriesId) {
 | 
			
		||||
    if (!this.libraryFilterData[libraryId]) return
 | 
			
		||||
    // Check if series is already added
 | 
			
		||||
    if (this.libraryFilterData[libraryId].series.some(se => se.id === seriesId)) return
 | 
			
		||||
    this.libraryFilterData[libraryId].series.push({
 | 
			
		||||
      id: seriesId,
 | 
			
		||||
      name: seriesName
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  removeAuthorFromFilterData(libraryId, authorId) {
 | 
			
		||||
    if (!this.libraryFilterData[libraryId]) return
 | 
			
		||||
    this.libraryFilterData[libraryId].authors = this.libraryFilterData[libraryId].authors.filter(au => au.id !== authorId)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  addAuthorToFilterData(libraryId, authorName, authorId) {
 | 
			
		||||
    if (!this.libraryFilterData[libraryId]) return
 | 
			
		||||
    // Check if author is already added
 | 
			
		||||
    if (this.libraryFilterData[libraryId].authors.some(au => au.id === authorId)) return
 | 
			
		||||
    this.libraryFilterData[libraryId].authors.push({
 | 
			
		||||
      id: authorId,
 | 
			
		||||
      name: authorName
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Used when updating items to make sure author id exists
 | 
			
		||||
   * If library filter data is set then use that for check
 | 
			
		||||
   * otherwise lookup in db
 | 
			
		||||
   * @param {string} libraryId 
 | 
			
		||||
   * @param {string} authorId 
 | 
			
		||||
   * @returns {Promise<boolean>}
 | 
			
		||||
   */
 | 
			
		||||
  async checkAuthorExists(libraryId, authorId) {
 | 
			
		||||
    if (!this.libraryFilterData[libraryId]) {
 | 
			
		||||
      return this.authorModel.checkExistsById(authorId)
 | 
			
		||||
    }
 | 
			
		||||
    return this.libraryFilterData[libraryId].authors.some(au => au.id === authorId)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Used when updating items to make sure series id exists
 | 
			
		||||
   * If library filter data is set then use that for check
 | 
			
		||||
   * otherwise lookup in db
 | 
			
		||||
   * @param {string} libraryId 
 | 
			
		||||
   * @param {string} seriesId 
 | 
			
		||||
   * @returns {Promise<boolean>}
 | 
			
		||||
   */
 | 
			
		||||
  async checkSeriesExists(libraryId, seriesId) {
 | 
			
		||||
    if (!this.libraryFilterData[libraryId]) {
 | 
			
		||||
      return this.seriesModel.checkExistsById(seriesId)
 | 
			
		||||
    }
 | 
			
		||||
    return this.libraryFilterData[libraryId].series.some(se => se.id === seriesId)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Reset numIssues for library
 | 
			
		||||
   * @param {string} libraryId 
 | 
			
		||||
   */
 | 
			
		||||
  async resetLibraryIssuesFilterData(libraryId) {
 | 
			
		||||
    if (!this.libraryFilterData[libraryId]) return // Do nothing if filter data is not set
 | 
			
		||||
 | 
			
		||||
    this.libraryFilterData[libraryId].numIssues = await this.libraryItemModel.count({
 | 
			
		||||
      where: {
 | 
			
		||||
        libraryId,
 | 
			
		||||
        [Sequelize.Op.or]: [
 | 
			
		||||
          {
 | 
			
		||||
            isMissing: true
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            isInvalid: true
 | 
			
		||||
          }
 | 
			
		||||
        ]
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = new Database()
 | 
			
		||||
@ -116,7 +116,7 @@ class Server {
 | 
			
		||||
    await this.logManager.init()
 | 
			
		||||
    await this.rssFeedManager.init()
 | 
			
		||||
 | 
			
		||||
    const libraries = await Database.models.library.getAllOldLibraries()
 | 
			
		||||
    const libraries = await Database.libraryModel.getAllOldLibraries()
 | 
			
		||||
    await this.cronManager.init(libraries)
 | 
			
		||||
 | 
			
		||||
    if (Database.serverSettings.scannerDisableWatcher) {
 | 
			
		||||
@ -253,7 +253,7 @@ class Server {
 | 
			
		||||
   */
 | 
			
		||||
  async cleanUserData() {
 | 
			
		||||
    // Get all media progress without an associated media item
 | 
			
		||||
    const mediaProgressToRemove = await Database.models.mediaProgress.findAll({
 | 
			
		||||
    const mediaProgressToRemove = await Database.mediaProgressModel.findAll({
 | 
			
		||||
      where: {
 | 
			
		||||
        '$podcastEpisode.id$': null,
 | 
			
		||||
        '$book.id$': null
 | 
			
		||||
@ -261,18 +261,18 @@ class Server {
 | 
			
		||||
      attributes: ['id'],
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.book,
 | 
			
		||||
          model: Database.bookModel,
 | 
			
		||||
          attributes: ['id']
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.podcastEpisode,
 | 
			
		||||
          model: Database.podcastEpisodeModel,
 | 
			
		||||
          attributes: ['id']
 | 
			
		||||
        }
 | 
			
		||||
      ]
 | 
			
		||||
    })
 | 
			
		||||
    if (mediaProgressToRemove.length) {
 | 
			
		||||
      // Remove media progress
 | 
			
		||||
      const mediaProgressRemoved = await Database.models.mediaProgress.destroy({
 | 
			
		||||
      const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
 | 
			
		||||
        where: {
 | 
			
		||||
          id: {
 | 
			
		||||
            [Sequelize.Op.in]: mediaProgressToRemove.map(mp => mp.id)
 | 
			
		||||
@ -285,7 +285,7 @@ class Server {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Remove series from hide from continue listening that no longer exist
 | 
			
		||||
    const users = await Database.models.user.getOldUsers()
 | 
			
		||||
    const users = await Database.userModel.getOldUsers()
 | 
			
		||||
    for (const _user of users) {
 | 
			
		||||
      let hasUpdated = false
 | 
			
		||||
      if (_user.seriesHideFromContinueListening.length) {
 | 
			
		||||
 | 
			
		||||
@ -21,7 +21,7 @@ class AuthorController {
 | 
			
		||||
 | 
			
		||||
    // Used on author landing page to include library items and items grouped in series
 | 
			
		||||
    if (include.includes('items')) {
 | 
			
		||||
      authorJson.libraryItems = await Database.models.libraryItem.getForAuthor(req.author, req.user)
 | 
			
		||||
      authorJson.libraryItems = await Database.libraryItemModel.getForAuthor(req.author, req.user)
 | 
			
		||||
 | 
			
		||||
      if (include.includes('series')) {
 | 
			
		||||
        const seriesMap = {}
 | 
			
		||||
@ -96,7 +96,7 @@ class AuthorController {
 | 
			
		||||
    const existingAuthor = authorNameUpdate ? Database.authors.find(au => au.id !== req.author.id && payload.name === au.name) : false
 | 
			
		||||
    if (existingAuthor) {
 | 
			
		||||
      const bookAuthorsToCreate = []
 | 
			
		||||
      const itemsWithAuthor = await Database.models.libraryItem.getForAuthor(req.author)
 | 
			
		||||
      const itemsWithAuthor = await Database.libraryItemModel.getForAuthor(req.author)
 | 
			
		||||
      itemsWithAuthor.forEach(libraryItem => { // Replace old author with merging author for each book
 | 
			
		||||
        libraryItem.media.metadata.replaceAuthor(req.author, existingAuthor)
 | 
			
		||||
        bookAuthorsToCreate.push({
 | 
			
		||||
@ -113,9 +113,11 @@ class AuthorController {
 | 
			
		||||
      // Remove old author
 | 
			
		||||
      await Database.removeAuthor(req.author.id)
 | 
			
		||||
      SocketAuthority.emitter('author_removed', req.author.toJSON())
 | 
			
		||||
      // Update filter data
 | 
			
		||||
      Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id)
 | 
			
		||||
 | 
			
		||||
      // Send updated num books for merged author
 | 
			
		||||
      const numBooks = await Database.models.libraryItem.getForAuthor(existingAuthor).length
 | 
			
		||||
      const numBooks = await Database.libraryItemModel.getForAuthor(existingAuthor).length
 | 
			
		||||
      SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
 | 
			
		||||
 | 
			
		||||
      res.json({
 | 
			
		||||
@ -130,7 +132,7 @@ class AuthorController {
 | 
			
		||||
      if (hasUpdated) {
 | 
			
		||||
        req.author.updatedAt = Date.now()
 | 
			
		||||
 | 
			
		||||
        const itemsWithAuthor = await Database.models.libraryItem.getForAuthor(req.author)
 | 
			
		||||
        const itemsWithAuthor = await Database.libraryItemModel.getForAuthor(req.author)
 | 
			
		||||
        if (authorNameUpdate) { // Update author name on all books
 | 
			
		||||
          itemsWithAuthor.forEach(libraryItem => {
 | 
			
		||||
            libraryItem.media.metadata.updateAuthor(req.author)
 | 
			
		||||
@ -202,7 +204,7 @@ class AuthorController {
 | 
			
		||||
 | 
			
		||||
      await Database.updateAuthor(req.author)
 | 
			
		||||
 | 
			
		||||
      const numBooks = await Database.models.libraryItem.getForAuthor(req.author).length
 | 
			
		||||
      const numBooks = await Database.libraryItemModel.getForAuthor(req.author).length
 | 
			
		||||
      SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -22,10 +22,10 @@ class CollectionController {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Create collection record
 | 
			
		||||
    await Database.models.collection.createFromOld(newCollection)
 | 
			
		||||
    await Database.collectionModel.createFromOld(newCollection)
 | 
			
		||||
 | 
			
		||||
    // Get library items in collection
 | 
			
		||||
    const libraryItemsInCollection = await Database.models.libraryItem.getForCollection(newCollection)
 | 
			
		||||
    const libraryItemsInCollection = await Database.libraryItemModel.getForCollection(newCollection)
 | 
			
		||||
 | 
			
		||||
    // Create collectionBook records
 | 
			
		||||
    let order = 1
 | 
			
		||||
@ -50,7 +50,7 @@ class CollectionController {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async findAll(req, res) {
 | 
			
		||||
    const collectionsExpanded = await Database.models.collection.getOldCollectionsJsonExpanded(req.user)
 | 
			
		||||
    const collectionsExpanded = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user)
 | 
			
		||||
    res.json({
 | 
			
		||||
      collections: collectionsExpanded
 | 
			
		||||
    })
 | 
			
		||||
@ -96,8 +96,8 @@ class CollectionController {
 | 
			
		||||
    if (req.body.books?.length) {
 | 
			
		||||
      const collectionBooks = await req.collection.getCollectionBooks({
 | 
			
		||||
        include: {
 | 
			
		||||
          model: Database.models.book,
 | 
			
		||||
          include: Database.models.libraryItem
 | 
			
		||||
          model: Database.bookModel,
 | 
			
		||||
          include: Database.libraryItemModel
 | 
			
		||||
        },
 | 
			
		||||
        order: [['order', 'ASC']]
 | 
			
		||||
      })
 | 
			
		||||
@ -143,7 +143,7 @@ class CollectionController {
 | 
			
		||||
   * @param {*} res 
 | 
			
		||||
   */
 | 
			
		||||
  async addBook(req, res) {
 | 
			
		||||
    const libraryItem = await Database.models.libraryItem.getOldById(req.body.id)
 | 
			
		||||
    const libraryItem = await Database.libraryItemModel.getOldById(req.body.id)
 | 
			
		||||
    if (!libraryItem) {
 | 
			
		||||
      return res.status(404).send('Book not found')
 | 
			
		||||
    }
 | 
			
		||||
@ -158,7 +158,7 @@ class CollectionController {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Create collectionBook record
 | 
			
		||||
    await Database.models.collectionBook.create({
 | 
			
		||||
    await Database.collectionBookModel.create({
 | 
			
		||||
      collectionId: req.collection.id,
 | 
			
		||||
      bookId: libraryItem.media.id,
 | 
			
		||||
      order: collectionBooks.length + 1
 | 
			
		||||
@ -176,7 +176,7 @@ class CollectionController {
 | 
			
		||||
   * @param {*} res 
 | 
			
		||||
   */
 | 
			
		||||
  async removeBook(req, res) {
 | 
			
		||||
    const libraryItem = await Database.models.libraryItem.getOldById(req.params.bookId)
 | 
			
		||||
    const libraryItem = await Database.libraryItemModel.getOldById(req.params.bookId)
 | 
			
		||||
    if (!libraryItem) {
 | 
			
		||||
      return res.sendStatus(404)
 | 
			
		||||
    }
 | 
			
		||||
@ -227,14 +227,14 @@ class CollectionController {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Get library items associated with ids
 | 
			
		||||
    const libraryItems = await Database.models.libraryItem.findAll({
 | 
			
		||||
    const libraryItems = await Database.libraryItemModel.findAll({
 | 
			
		||||
      where: {
 | 
			
		||||
        id: {
 | 
			
		||||
          [Sequelize.Op.in]: bookIdsToAdd
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      include: {
 | 
			
		||||
        model: Database.models.book
 | 
			
		||||
        model: Database.bookModel
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
@ -285,14 +285,14 @@ class CollectionController {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Get library items associated with ids
 | 
			
		||||
    const libraryItems = await Database.models.libraryItem.findAll({
 | 
			
		||||
    const libraryItems = await Database.libraryItemModel.findAll({
 | 
			
		||||
      where: {
 | 
			
		||||
        id: {
 | 
			
		||||
          [Sequelize.Op.in]: bookIdsToRemove
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      include: {
 | 
			
		||||
        model: Database.models.book
 | 
			
		||||
        model: Database.bookModel
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
@ -327,7 +327,7 @@ class CollectionController {
 | 
			
		||||
 | 
			
		||||
  async middleware(req, res, next) {
 | 
			
		||||
    if (req.params.id) {
 | 
			
		||||
      const collection = await Database.models.collection.findByPk(req.params.id)
 | 
			
		||||
      const collection = await Database.collectionModel.findByPk(req.params.id)
 | 
			
		||||
      if (!collection) {
 | 
			
		||||
        return res.status(404).send('Collection not found')
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
@ -17,7 +17,7 @@ class FileSystemController {
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    // Do not include existing mapped library paths in response
 | 
			
		||||
    const libraryFoldersPaths = await Database.models.libraryFolder.getAllLibraryFolderPaths()
 | 
			
		||||
    const libraryFoldersPaths = await Database.libraryModelFolder.getAllLibraryFolderPaths()
 | 
			
		||||
    libraryFoldersPaths.forEach((path) => {
 | 
			
		||||
      let dir = path || ''
 | 
			
		||||
      if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '')
 | 
			
		||||
 | 
			
		||||
@ -8,6 +8,7 @@ const Library = require('../objects/Library')
 | 
			
		||||
const libraryHelpers = require('../utils/libraryHelpers')
 | 
			
		||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
 | 
			
		||||
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
 | 
			
		||||
const seriesFilters = require('../utils/queries/seriesFilters')
 | 
			
		||||
const { sort, createNewSortInstance } = require('../libs/fastSort')
 | 
			
		||||
const naturalSort = createNewSortInstance({
 | 
			
		||||
  comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
 | 
			
		||||
@ -15,6 +16,8 @@ const naturalSort = createNewSortInstance({
 | 
			
		||||
 | 
			
		||||
const Database = require('../Database')
 | 
			
		||||
const libraryFilters = require('../utils/queries/libraryFilters')
 | 
			
		||||
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
 | 
			
		||||
const authorFilters = require('../utils/queries/authorFilters')
 | 
			
		||||
 | 
			
		||||
class LibraryController {
 | 
			
		||||
  constructor() { }
 | 
			
		||||
@ -48,7 +51,7 @@ class LibraryController {
 | 
			
		||||
 | 
			
		||||
    const library = new Library()
 | 
			
		||||
 | 
			
		||||
    let currentLargestDisplayOrder = await Database.models.library.getMaxDisplayOrder()
 | 
			
		||||
    let currentLargestDisplayOrder = await Database.libraryModel.getMaxDisplayOrder()
 | 
			
		||||
    if (isNaN(currentLargestDisplayOrder)) currentLargestDisplayOrder = 0
 | 
			
		||||
    newLibraryPayload.displayOrder = currentLargestDisplayOrder + 1
 | 
			
		||||
    library.setData(newLibraryPayload)
 | 
			
		||||
@ -67,7 +70,7 @@ class LibraryController {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async findAll(req, res) {
 | 
			
		||||
    const libraries = await Database.models.library.getAllOldLibraries()
 | 
			
		||||
    const libraries = await Database.libraryModel.getAllOldLibraries()
 | 
			
		||||
 | 
			
		||||
    const librariesAccessible = req.user.librariesAccessible || []
 | 
			
		||||
    if (librariesAccessible.length) {
 | 
			
		||||
@ -89,7 +92,7 @@ class LibraryController {
 | 
			
		||||
      return res.json({
 | 
			
		||||
        filterdata,
 | 
			
		||||
        issues: filterdata.numIssues,
 | 
			
		||||
        numUserPlaylists: await Database.models.playlist.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id),
 | 
			
		||||
        numUserPlaylists: await Database.playlistModel.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id),
 | 
			
		||||
        library: req.library
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
@ -141,17 +144,17 @@ class LibraryController {
 | 
			
		||||
      for (const folder of library.folders) {
 | 
			
		||||
        if (!req.body.folders.some(f => f.id === folder.id)) {
 | 
			
		||||
          // Remove library items in folder
 | 
			
		||||
          const libraryItemsInFolder = await Database.models.libraryItem.findAll({
 | 
			
		||||
          const libraryItemsInFolder = await Database.libraryItemModel.findAll({
 | 
			
		||||
            where: {
 | 
			
		||||
              libraryFolderId: folder.id
 | 
			
		||||
            },
 | 
			
		||||
            attributes: ['id', 'mediaId', 'mediaType'],
 | 
			
		||||
            include: [
 | 
			
		||||
              {
 | 
			
		||||
                model: Database.models.podcast,
 | 
			
		||||
                model: Database.podcastModel,
 | 
			
		||||
                attributes: ['id'],
 | 
			
		||||
                include: {
 | 
			
		||||
                  model: Database.models.podcastEpisode,
 | 
			
		||||
                  model: Database.podcastEpisodeModel,
 | 
			
		||||
                  attributes: ['id']
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
@ -188,6 +191,8 @@ class LibraryController {
 | 
			
		||||
        return user.checkCanAccessLibrary && user.checkCanAccessLibrary(library.id)
 | 
			
		||||
      }
 | 
			
		||||
      SocketAuthority.emitter('library_updated', library.toJSON(), userFilter)
 | 
			
		||||
 | 
			
		||||
      await Database.resetLibraryIssuesFilterData(library.id)
 | 
			
		||||
    }
 | 
			
		||||
    return res.json(library.toJSON())
 | 
			
		||||
  }
 | 
			
		||||
@ -205,23 +210,23 @@ class LibraryController {
 | 
			
		||||
    this.watcher.removeLibrary(library)
 | 
			
		||||
 | 
			
		||||
    // Remove collections for library
 | 
			
		||||
    const numCollectionsRemoved = await Database.models.collection.removeAllForLibrary(library.id)
 | 
			
		||||
    const numCollectionsRemoved = await Database.collectionModel.removeAllForLibrary(library.id)
 | 
			
		||||
    if (numCollectionsRemoved) {
 | 
			
		||||
      Logger.info(`[Server] Removed ${numCollectionsRemoved} collections for library "${library.name}"`)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Remove items in this library
 | 
			
		||||
    const libraryItemsInLibrary = await Database.models.libraryItem.findAll({
 | 
			
		||||
    const libraryItemsInLibrary = await Database.libraryItemModel.findAll({
 | 
			
		||||
      where: {
 | 
			
		||||
        libraryId: library.id
 | 
			
		||||
      },
 | 
			
		||||
      attributes: ['id', 'mediaId', 'mediaType'],
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.podcast,
 | 
			
		||||
          model: Database.podcastModel,
 | 
			
		||||
          attributes: ['id'],
 | 
			
		||||
          include: {
 | 
			
		||||
            model: Database.models.podcastEpisode,
 | 
			
		||||
            model: Database.podcastEpisodeModel,
 | 
			
		||||
            attributes: ['id']
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
@ -243,9 +248,15 @@ class LibraryController {
 | 
			
		||||
    await Database.removeLibrary(library.id)
 | 
			
		||||
 | 
			
		||||
    // Re-order libraries
 | 
			
		||||
    await Database.models.library.resetDisplayOrder()
 | 
			
		||||
    await Database.libraryModel.resetDisplayOrder()
 | 
			
		||||
 | 
			
		||||
    SocketAuthority.emitter('library_removed', libraryJson)
 | 
			
		||||
 | 
			
		||||
    // Remove library filter data
 | 
			
		||||
    if (Database.libraryFilterData[library.id]) {
 | 
			
		||||
      delete Database.libraryFilterData[library.id]
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return res.json(libraryJson)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -267,7 +278,7 @@ class LibraryController {
 | 
			
		||||
    }
 | 
			
		||||
    payload.offset = payload.page * payload.limit
 | 
			
		||||
 | 
			
		||||
    const { libraryItems, count } = await Database.models.libraryItem.getByFilterAndSort(req.library, req.user, payload)
 | 
			
		||||
    const { libraryItems, count } = await Database.libraryItemModel.getByFilterAndSort(req.library, req.user, payload)
 | 
			
		||||
    payload.results = libraryItems
 | 
			
		||||
    payload.total = count
 | 
			
		||||
 | 
			
		||||
@ -471,12 +482,13 @@ class LibraryController {
 | 
			
		||||
  /**
 | 
			
		||||
   * DELETE: /libraries/:id/issues
 | 
			
		||||
   * Remove all library items missing or invalid
 | 
			
		||||
   * @param {*} req 
 | 
			
		||||
   * @param {*} res 
 | 
			
		||||
   * @param {import('express').Request} req 
 | 
			
		||||
   * @param {import('express').Response} res 
 | 
			
		||||
   */
 | 
			
		||||
  async removeLibraryItemsWithIssues(req, res) {
 | 
			
		||||
    const libraryItemsWithIssues = await Database.models.libraryItem.findAll({
 | 
			
		||||
    const libraryItemsWithIssues = await Database.libraryItemModel.findAll({
 | 
			
		||||
      where: {
 | 
			
		||||
        libraryId: req.library.id,
 | 
			
		||||
        [Sequelize.Op.or]: [
 | 
			
		||||
          {
 | 
			
		||||
            isMissing: true
 | 
			
		||||
@ -489,10 +501,10 @@ class LibraryController {
 | 
			
		||||
      attributes: ['id', 'mediaId', 'mediaType'],
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.podcast,
 | 
			
		||||
          model: Database.podcastModel,
 | 
			
		||||
          attributes: ['id'],
 | 
			
		||||
          include: {
 | 
			
		||||
            model: Database.models.podcastEpisode,
 | 
			
		||||
            model: Database.podcastEpisodeModel,
 | 
			
		||||
            attributes: ['id']
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
@ -507,7 +519,7 @@ class LibraryController {
 | 
			
		||||
    Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`)
 | 
			
		||||
    for (const libraryItem of libraryItemsWithIssues) {
 | 
			
		||||
      let mediaItemIds = []
 | 
			
		||||
      if (library.isPodcast) {
 | 
			
		||||
      if (req.library.isPodcast) {
 | 
			
		||||
        mediaItemIds = libraryItem.media.podcastEpisodes.map(pe => pe.id)
 | 
			
		||||
      } else {
 | 
			
		||||
        mediaItemIds.push(libraryItem.mediaId)
 | 
			
		||||
@ -516,19 +528,22 @@ class LibraryController {
 | 
			
		||||
      await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Set numIssues to 0 for library filter data
 | 
			
		||||
    if (Database.libraryFilterData[req.library.id]) {
 | 
			
		||||
      Database.libraryFilterData[req.library.id].numIssues = 0
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    res.sendStatus(200)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * GET: /api/libraries/:id/series
 | 
			
		||||
   * Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open
 | 
			
		||||
   * 
 | 
			
		||||
   * @param {*} req 
 | 
			
		||||
   * @param {*} res 
 | 
			
		||||
   */
 | 
			
		||||
 * GET: /api/libraries/:id/series
 | 
			
		||||
 * Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open
 | 
			
		||||
 * 
 | 
			
		||||
 * @param {import('express').Request} req 
 | 
			
		||||
 * @param {import('express').Response} res 
 | 
			
		||||
 */
 | 
			
		||||
  async getAllSeriesForLibrary(req, res) {
 | 
			
		||||
    const libraryItems = req.libraryItems
 | 
			
		||||
 | 
			
		||||
    const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
 | 
			
		||||
 | 
			
		||||
    const payload = {
 | 
			
		||||
@ -543,45 +558,10 @@ class LibraryController {
 | 
			
		||||
      include: include.join(',')
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let series = libraryHelpers.getSeriesFromBooks(libraryItems, Database.series, null, payload.filterBy, req.user, payload.minified, req.library.settings.hideSingleBookSeries)
 | 
			
		||||
 | 
			
		||||
    const direction = payload.sortDesc ? 'desc' : 'asc'
 | 
			
		||||
    series = naturalSort(series).by([
 | 
			
		||||
      {
 | 
			
		||||
        [direction]: (se) => {
 | 
			
		||||
          if (payload.sortBy === 'numBooks') {
 | 
			
		||||
            return se.books.length
 | 
			
		||||
          } else if (payload.sortBy === 'totalDuration') {
 | 
			
		||||
            return se.totalDuration
 | 
			
		||||
          } else if (payload.sortBy === 'addedAt') {
 | 
			
		||||
            return se.addedAt
 | 
			
		||||
          } else if (payload.sortBy === 'lastBookUpdated') {
 | 
			
		||||
            return Math.max(...(se.books).map(x => x.updatedAt), 0)
 | 
			
		||||
          } else if (payload.sortBy === 'lastBookAdded') {
 | 
			
		||||
            return Math.max(...(se.books).map(x => x.addedAt), 0)
 | 
			
		||||
          } else { // sort by name
 | 
			
		||||
            return Database.serverSettings.sortingIgnorePrefix ? se.nameIgnorePrefixSort : se.name
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    ])
 | 
			
		||||
 | 
			
		||||
    payload.total = series.length
 | 
			
		||||
 | 
			
		||||
    if (payload.limit) {
 | 
			
		||||
      const startIndex = payload.page * payload.limit
 | 
			
		||||
      series = series.slice(startIndex, startIndex + payload.limit)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // add rssFeed when "include=rssfeed" is in query string
 | 
			
		||||
    if (include.includes('rssfeed')) {
 | 
			
		||||
      series = await Promise.all(series.map(async (se) => {
 | 
			
		||||
        const feedData = await this.rssFeedManager.findFeedForEntityId(se.id)
 | 
			
		||||
        se.rssFeed = feedData?.toJSONMinified() || null
 | 
			
		||||
        return se
 | 
			
		||||
      }))
 | 
			
		||||
    }
 | 
			
		||||
    const offset = payload.page * payload.limit
 | 
			
		||||
    const { series, count } = await seriesFilters.getFilteredSeries(req.library, req.user, payload.filterBy, payload.sortBy, payload.sortDesc, include, payload.limit, offset)
 | 
			
		||||
 | 
			
		||||
    payload.total = count
 | 
			
		||||
    payload.results = series
 | 
			
		||||
    res.json(payload)
 | 
			
		||||
  }
 | 
			
		||||
@ -644,7 +624,7 @@ class LibraryController {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // TODO: Create paginated queries
 | 
			
		||||
    let collections = await Database.models.collection.getOldCollectionsJsonExpanded(req.user, req.library.id, include)
 | 
			
		||||
    let collections = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user, req.library.id, include)
 | 
			
		||||
 | 
			
		||||
    payload.total = collections.length
 | 
			
		||||
 | 
			
		||||
@ -664,7 +644,7 @@ class LibraryController {
 | 
			
		||||
   * @param {*} res 
 | 
			
		||||
   */
 | 
			
		||||
  async getUserPlaylistsForLibrary(req, res) {
 | 
			
		||||
    let playlistsForUser = await Database.models.playlist.getPlaylistsForUserAndLibrary(req.user.id, req.library.id)
 | 
			
		||||
    let playlistsForUser = await Database.playlistModel.getPlaylistsForUserAndLibrary(req.user.id, req.library.id)
 | 
			
		||||
    playlistsForUser = await Promise.all(playlistsForUser.map(async p => p.getOldJsonExpanded()))
 | 
			
		||||
 | 
			
		||||
    const payload = {
 | 
			
		||||
@ -685,8 +665,8 @@ class LibraryController {
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * GET: /api/libraries/:id/filterdata
 | 
			
		||||
   * @param {*} req 
 | 
			
		||||
   * @param {*} res 
 | 
			
		||||
   * @param {import('express').Request} req 
 | 
			
		||||
   * @param {import('express').Response} res 
 | 
			
		||||
   */
 | 
			
		||||
  async getLibraryFilterData(req, res) {
 | 
			
		||||
    const filterData = await libraryFilters.getFilterData(req.library)
 | 
			
		||||
@ -694,44 +674,30 @@ class LibraryController {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * GET: /api/libraries/:id/personalized2
 | 
			
		||||
   * TODO: new endpoint
 | 
			
		||||
   * @param {*} req 
 | 
			
		||||
   * @param {*} res 
 | 
			
		||||
   * GET: /api/libraries/:id/personalized
 | 
			
		||||
   * Home page shelves
 | 
			
		||||
   * @param {import('express').Request} req 
 | 
			
		||||
   * @param {import('express').Response} res 
 | 
			
		||||
   */
 | 
			
		||||
  async getUserPersonalizedShelves(req, res) {
 | 
			
		||||
    const limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) || 10 : 10
 | 
			
		||||
    const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
 | 
			
		||||
    const shelves = await Database.models.libraryItem.getPersonalizedShelves(req.library, req.user, include, limitPerShelf)
 | 
			
		||||
    const shelves = await Database.libraryItemModel.getPersonalizedShelves(req.library, req.user, include, limitPerShelf)
 | 
			
		||||
    res.json(shelves)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * GET: /api/libraries/:id/personalized
 | 
			
		||||
   * TODO: remove after personalized2 is ready
 | 
			
		||||
   * @param {*} req 
 | 
			
		||||
   * @param {*} res 
 | 
			
		||||
   */
 | 
			
		||||
  async getLibraryUserPersonalizedOptimal(req, res) {
 | 
			
		||||
    const limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) || 10 : 10
 | 
			
		||||
    const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
 | 
			
		||||
 | 
			
		||||
    const categories = await libraryHelpers.buildPersonalizedShelves(this, req.user, req.libraryItems, req.library, limitPerShelf, include)
 | 
			
		||||
    res.json(categories)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * POST: /api/libraries/order
 | 
			
		||||
   * Change the display order of libraries
 | 
			
		||||
   * @param {*} req 
 | 
			
		||||
   * @param {*} res 
 | 
			
		||||
   * @param {import('express').Request} req 
 | 
			
		||||
   * @param {import('express').Response} res 
 | 
			
		||||
   */
 | 
			
		||||
  async reorder(req, res) {
 | 
			
		||||
    if (!req.user.isAdminOrUp) {
 | 
			
		||||
      Logger.error('[LibraryController] ReorderLibraries invalid user', req.user)
 | 
			
		||||
      return res.sendStatus(403)
 | 
			
		||||
    }
 | 
			
		||||
    const libraries = await Database.models.library.getAllOldLibraries()
 | 
			
		||||
    const libraries = await Database.libraryModel.getAllOldLibraries()
 | 
			
		||||
 | 
			
		||||
    const orderdata = req.body
 | 
			
		||||
    let hasUpdates = false
 | 
			
		||||
@ -759,99 +725,62 @@ class LibraryController {
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // GET: Global library search
 | 
			
		||||
  search(req, res) {
 | 
			
		||||
  /**
 | 
			
		||||
   * GET: /api/libraries/:id/search
 | 
			
		||||
   * Search library items with query
 | 
			
		||||
   * ?q=search
 | 
			
		||||
   * @param {import('express').Request} req 
 | 
			
		||||
   * @param {import('express').Response} res 
 | 
			
		||||
   */
 | 
			
		||||
  async search(req, res) {
 | 
			
		||||
    if (!req.query.q) {
 | 
			
		||||
      return res.status(400).send('No query string')
 | 
			
		||||
    }
 | 
			
		||||
    const libraryItems = req.libraryItems
 | 
			
		||||
    const maxResults = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
 | 
			
		||||
    const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
 | 
			
		||||
    const query = req.query.q.trim().toLowerCase()
 | 
			
		||||
 | 
			
		||||
    const itemMatches = []
 | 
			
		||||
    const authorMatches = {}
 | 
			
		||||
    const narratorMatches = {}
 | 
			
		||||
    const seriesMatches = {}
 | 
			
		||||
    const tagMatches = {}
 | 
			
		||||
 | 
			
		||||
    libraryItems.forEach((li) => {
 | 
			
		||||
      const queryResult = li.searchQuery(req.query.q)
 | 
			
		||||
      if (queryResult.matchKey) {
 | 
			
		||||
        itemMatches.push({
 | 
			
		||||
          libraryItem: li.toJSONExpanded(),
 | 
			
		||||
          matchKey: queryResult.matchKey,
 | 
			
		||||
          matchText: queryResult.matchText
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
      if (queryResult.series?.length) {
 | 
			
		||||
        queryResult.series.forEach((se) => {
 | 
			
		||||
          if (!seriesMatches[se.id]) {
 | 
			
		||||
            const _series = Database.series.find(_se => _se.id === se.id)
 | 
			
		||||
            if (_series) seriesMatches[se.id] = { series: _series.toJSON(), books: [li.toJSON()] }
 | 
			
		||||
          } else {
 | 
			
		||||
            seriesMatches[se.id].books.push(li.toJSON())
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
      if (queryResult.authors?.length) {
 | 
			
		||||
        queryResult.authors.forEach((au) => {
 | 
			
		||||
          if (!authorMatches[au.id]) {
 | 
			
		||||
            const _author = Database.authors.find(_au => _au.id === au.id)
 | 
			
		||||
            if (_author) {
 | 
			
		||||
              authorMatches[au.id] = _author.toJSON()
 | 
			
		||||
              authorMatches[au.id].numBooks = 1
 | 
			
		||||
            }
 | 
			
		||||
          } else {
 | 
			
		||||
            authorMatches[au.id].numBooks++
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
      if (queryResult.tags?.length) {
 | 
			
		||||
        queryResult.tags.forEach((tag) => {
 | 
			
		||||
          if (!tagMatches[tag]) {
 | 
			
		||||
            tagMatches[tag] = { name: tag, books: [li.toJSON()] }
 | 
			
		||||
          } else {
 | 
			
		||||
            tagMatches[tag].books.push(li.toJSON())
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
      if (queryResult.narrators?.length) {
 | 
			
		||||
        queryResult.narrators.forEach((narrator) => {
 | 
			
		||||
          if (!narratorMatches[narrator]) {
 | 
			
		||||
            narratorMatches[narrator] = { name: narrator, books: [li.toJSON()] }
 | 
			
		||||
          } else {
 | 
			
		||||
            narratorMatches[narrator].books.push(li.toJSON())
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
    const itemKey = req.library.mediaType
 | 
			
		||||
    const results = {
 | 
			
		||||
      [itemKey]: itemMatches.slice(0, maxResults),
 | 
			
		||||
      tags: Object.values(tagMatches).slice(0, maxResults),
 | 
			
		||||
      authors: Object.values(authorMatches).slice(0, maxResults),
 | 
			
		||||
      series: Object.values(seriesMatches).slice(0, maxResults),
 | 
			
		||||
      narrators: Object.values(narratorMatches).slice(0, maxResults)
 | 
			
		||||
    }
 | 
			
		||||
    res.json(results)
 | 
			
		||||
    const matches = await libraryItemFilters.search(req.user, req.library, query, limit)
 | 
			
		||||
    res.json(matches)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * GET: /api/libraries/:id/stats
 | 
			
		||||
   * Get stats for library
 | 
			
		||||
   * @param {import('express').Request} req 
 | 
			
		||||
   * @param {import('express').Response} res 
 | 
			
		||||
   */
 | 
			
		||||
  async stats(req, res) {
 | 
			
		||||
    var libraryItems = req.libraryItems
 | 
			
		||||
    var authorsWithCount = libraryHelpers.getAuthorsWithCount(libraryItems)
 | 
			
		||||
    var genresWithCount = libraryHelpers.getGenresWithCount(libraryItems)
 | 
			
		||||
    var durationStats = libraryHelpers.getItemDurationStats(libraryItems)
 | 
			
		||||
    var sizeStats = libraryHelpers.getItemSizeStats(libraryItems)
 | 
			
		||||
    var stats = {
 | 
			
		||||
      totalItems: libraryItems.length,
 | 
			
		||||
      totalAuthors: Object.keys(authorsWithCount).length,
 | 
			
		||||
      totalGenres: Object.keys(genresWithCount).length,
 | 
			
		||||
      totalDuration: durationStats.totalDuration,
 | 
			
		||||
      longestItems: durationStats.longestItems,
 | 
			
		||||
      numAudioTracks: durationStats.numAudioTracks,
 | 
			
		||||
      totalSize: libraryHelpers.getLibraryItemsTotalSize(libraryItems),
 | 
			
		||||
      largestItems: sizeStats.largestItems,
 | 
			
		||||
      authorsWithCount,
 | 
			
		||||
      genresWithCount
 | 
			
		||||
    const stats = {
 | 
			
		||||
      largestItems: await libraryItemFilters.getLargestItems(req.library.id, 10)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (req.library.isBook) {
 | 
			
		||||
      const authors = await authorFilters.getAuthorsWithCount(req.library.id)
 | 
			
		||||
      const genres = await libraryItemsBookFilters.getGenresWithCount(req.library.id)
 | 
			
		||||
      const bookStats = await libraryItemsBookFilters.getBookLibraryStats(req.library.id)
 | 
			
		||||
      const longestBooks = await libraryItemsBookFilters.getLongestBooks(req.library.id, 10)
 | 
			
		||||
 | 
			
		||||
      stats.totalAuthors = authors.length
 | 
			
		||||
      stats.authorsWithCount = authors
 | 
			
		||||
      stats.totalGenres = genres.length
 | 
			
		||||
      stats.genresWithCount = genres
 | 
			
		||||
      stats.totalItems = bookStats.totalItems
 | 
			
		||||
      stats.longestItems = longestBooks
 | 
			
		||||
      stats.totalSize = bookStats.totalSize
 | 
			
		||||
      stats.totalDuration = bookStats.totalDuration
 | 
			
		||||
      stats.numAudioTracks = bookStats.numAudioFiles
 | 
			
		||||
    } else {
 | 
			
		||||
      const genres = await libraryItemsPodcastFilters.getGenresWithCount(req.library.id)
 | 
			
		||||
      const podcastStats = await libraryItemsPodcastFilters.getPodcastLibraryStats(req.library.id)
 | 
			
		||||
      const longestPodcasts = await libraryItemsPodcastFilters.getLongestPodcasts(req.library.id, 10)
 | 
			
		||||
 | 
			
		||||
      stats.totalGenres = genres.length
 | 
			
		||||
      stats.genresWithCount = genres
 | 
			
		||||
      stats.totalItems = podcastStats.totalItems
 | 
			
		||||
      stats.longestItems = longestPodcasts
 | 
			
		||||
      stats.totalSize = podcastStats.totalSize
 | 
			
		||||
      stats.totalDuration = podcastStats.totalDuration
 | 
			
		||||
      stats.numAudioTracks = podcastStats.numAudioFiles
 | 
			
		||||
    }
 | 
			
		||||
    res.json(stats)
 | 
			
		||||
  }
 | 
			
		||||
@ -859,18 +788,18 @@ class LibraryController {
 | 
			
		||||
  /**
 | 
			
		||||
   * GET: /api/libraries/:id/authors
 | 
			
		||||
   * Get authors for library
 | 
			
		||||
   * @param {*} req 
 | 
			
		||||
   * @param {*} res 
 | 
			
		||||
   * @param {import('express').Request} req 
 | 
			
		||||
   * @param {import('express').Response} res 
 | 
			
		||||
   */
 | 
			
		||||
  async getAuthors(req, res) {
 | 
			
		||||
    const { bookWhere, replacements } = libraryItemsBookFilters.getUserPermissionBookWhereQuery(req.user)
 | 
			
		||||
    const authors = await Database.models.author.findAll({
 | 
			
		||||
    const authors = await Database.authorModel.findAll({
 | 
			
		||||
      where: {
 | 
			
		||||
        libraryId: req.library.id
 | 
			
		||||
      },
 | 
			
		||||
      replacements,
 | 
			
		||||
      include: {
 | 
			
		||||
        model: Database.models.book,
 | 
			
		||||
        model: Database.bookModel,
 | 
			
		||||
        attributes: ['id', 'tags', 'explicit'],
 | 
			
		||||
        where: bookWhere,
 | 
			
		||||
        required: true,
 | 
			
		||||
@ -903,12 +832,12 @@ class LibraryController {
 | 
			
		||||
   */
 | 
			
		||||
  async getNarrators(req, res) {
 | 
			
		||||
    // Get all books with narrators
 | 
			
		||||
    const booksWithNarrators = await Database.models.book.findAll({
 | 
			
		||||
    const booksWithNarrators = await Database.bookModel.findAll({
 | 
			
		||||
      where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('narrators')), {
 | 
			
		||||
        [Sequelize.Op.gt]: 0
 | 
			
		||||
      }),
 | 
			
		||||
      include: {
 | 
			
		||||
        model: Database.models.libraryItem,
 | 
			
		||||
        model: Database.libraryItemModel,
 | 
			
		||||
        attributes: ['id', 'libraryId'],
 | 
			
		||||
        where: {
 | 
			
		||||
          libraryId: req.library.id
 | 
			
		||||
@ -975,7 +904,7 @@ class LibraryController {
 | 
			
		||||
      await libraryItem.media.update({
 | 
			
		||||
        narrators: libraryItem.media.narrators
 | 
			
		||||
      })
 | 
			
		||||
      const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem)
 | 
			
		||||
      const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
 | 
			
		||||
      itemsUpdated.push(oldLibraryItem)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -1015,7 +944,7 @@ class LibraryController {
 | 
			
		||||
      await libraryItem.media.update({
 | 
			
		||||
        narrators: libraryItem.media.narrators
 | 
			
		||||
      })
 | 
			
		||||
      const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem)
 | 
			
		||||
      const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
 | 
			
		||||
      itemsUpdated.push(oldLibraryItem)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -1048,10 +977,16 @@ class LibraryController {
 | 
			
		||||
    }
 | 
			
		||||
    res.sendStatus(200)
 | 
			
		||||
    await this.scanner.scan(req.library, options)
 | 
			
		||||
    await Database.resetLibraryIssuesFilterData(req.library.id)
 | 
			
		||||
    Logger.info('[LibraryController] Scan complete')
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // GET: api/libraries/:id/recent-episode
 | 
			
		||||
  /**
 | 
			
		||||
   * GET: /api/libraries/:id/recent-episodes
 | 
			
		||||
   * Used for latest page
 | 
			
		||||
   * @param {import('express').Request} req 
 | 
			
		||||
   * @param {import('express').Response} res 
 | 
			
		||||
   */
 | 
			
		||||
  async getRecentEpisodes(req, res) {
 | 
			
		||||
    if (!req.library.isPodcast) {
 | 
			
		||||
      return res.sendStatus(404)
 | 
			
		||||
@ -1059,40 +994,37 @@ class LibraryController {
 | 
			
		||||
 | 
			
		||||
    const payload = {
 | 
			
		||||
      episodes: [],
 | 
			
		||||
      total: 0,
 | 
			
		||||
      limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
 | 
			
		||||
      page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0,
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var allUnfinishedEpisodes = []
 | 
			
		||||
    for (const libraryItem of req.libraryItems) {
 | 
			
		||||
      const unfinishedEpisodes = libraryItem.media.episodes.filter(ep => {
 | 
			
		||||
        const userProgress = req.user.getMediaProgress(libraryItem.id, ep.id)
 | 
			
		||||
        return !userProgress || !userProgress.isFinished
 | 
			
		||||
      }).map(_ep => {
 | 
			
		||||
        const ep = _ep.toJSONExpanded()
 | 
			
		||||
        ep.podcast = libraryItem.media.toJSONMinified()
 | 
			
		||||
        ep.libraryItemId = libraryItem.id
 | 
			
		||||
        ep.libraryId = libraryItem.libraryId
 | 
			
		||||
        return ep
 | 
			
		||||
      })
 | 
			
		||||
      allUnfinishedEpisodes.push(...unfinishedEpisodes)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    payload.total = allUnfinishedEpisodes.length
 | 
			
		||||
 | 
			
		||||
    allUnfinishedEpisodes = sort(allUnfinishedEpisodes).desc(ep => ep.publishedAt)
 | 
			
		||||
 | 
			
		||||
    if (payload.limit) {
 | 
			
		||||
      var startIndex = payload.page * payload.limit
 | 
			
		||||
      allUnfinishedEpisodes = allUnfinishedEpisodes.slice(startIndex, startIndex + payload.limit)
 | 
			
		||||
    }
 | 
			
		||||
    payload.episodes = allUnfinishedEpisodes
 | 
			
		||||
    const offset = payload.page * payload.limit
 | 
			
		||||
    payload.episodes = await libraryItemsPodcastFilters.getRecentEpisodes(req.user, req.library, payload.limit, offset)
 | 
			
		||||
    res.json(payload)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getOPMLFile(req, res) {
 | 
			
		||||
    const opmlText = this.podcastManager.generateOPMLFileText(req.libraryItems)
 | 
			
		||||
  /**
 | 
			
		||||
   * GET: /api/libraries/:id/opml
 | 
			
		||||
   * Get OPML file for a podcast library
 | 
			
		||||
   * @param {import('express').Request} req 
 | 
			
		||||
   * @param {import('express').Response} res 
 | 
			
		||||
   */
 | 
			
		||||
  async getOPMLFile(req, res) {
 | 
			
		||||
    const userPermissionPodcastWhere = libraryItemsPodcastFilters.getUserPermissionPodcastWhereQuery(req.user)
 | 
			
		||||
    const podcasts = await Database.podcastModel.findAll({
 | 
			
		||||
      attributes: ['id', 'feedURL', 'title', 'description', 'itunesPageURL', 'language'],
 | 
			
		||||
      where: userPermissionPodcastWhere.podcastWhere,
 | 
			
		||||
      replacements: userPermissionPodcastWhere.replacements,
 | 
			
		||||
      include: {
 | 
			
		||||
        model: Database.libraryItemModel,
 | 
			
		||||
        attributes: ['id', 'libraryId'],
 | 
			
		||||
        where: {
 | 
			
		||||
          libraryId: req.library.id
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    const opmlText = this.podcastManager.generateOPMLFileText(podcasts)
 | 
			
		||||
    res.type('application/xml')
 | 
			
		||||
    res.send(opmlText)
 | 
			
		||||
  }
 | 
			
		||||
@ -1109,7 +1041,7 @@ class LibraryController {
 | 
			
		||||
      return res.sendStatus(403)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const library = await Database.models.library.getOldById(req.params.id)
 | 
			
		||||
    const library = await Database.libraryModel.getOldById(req.params.id)
 | 
			
		||||
    if (!library) {
 | 
			
		||||
      return res.status(404).send('Library not found')
 | 
			
		||||
    }
 | 
			
		||||
@ -1122,9 +1054,9 @@ class LibraryController {
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Middleware that is not using libraryItems from memory
 | 
			
		||||
   * @param {*} req 
 | 
			
		||||
   * @param {*} res 
 | 
			
		||||
   * @param {*} next 
 | 
			
		||||
   * @param {import('express').Request} req 
 | 
			
		||||
   * @param {import('express').Response} res 
 | 
			
		||||
   * @param {import('express').NextFunction} next 
 | 
			
		||||
   */
 | 
			
		||||
  async middlewareNew(req, res, next) {
 | 
			
		||||
    if (!req.user.checkCanAccessLibrary(req.params.id)) {
 | 
			
		||||
@ -1132,7 +1064,7 @@ class LibraryController {
 | 
			
		||||
      return res.sendStatus(403)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const library = await Database.models.library.getOldById(req.params.id)
 | 
			
		||||
    const library = await Database.libraryModel.getOldById(req.params.id)
 | 
			
		||||
    if (!library) {
 | 
			
		||||
      return res.status(404).send('Library not found')
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -78,6 +78,7 @@ class LibraryItemController {
 | 
			
		||||
        Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
    await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
 | 
			
		||||
    res.sendStatus(200)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -124,7 +125,7 @@ class LibraryItemController {
 | 
			
		||||
    // Book specific - Get all series being removed from this item
 | 
			
		||||
    let seriesRemoved = []
 | 
			
		||||
    if (libraryItem.isBook && mediaPayload.metadata?.series) {
 | 
			
		||||
      const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map(se => se.id)
 | 
			
		||||
      const seriesIdsInUpdate = mediaPayload.metadata.series?.map(se => se.id) || []
 | 
			
		||||
      seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -313,7 +314,7 @@ class LibraryItemController {
 | 
			
		||||
      return res.status(400).send('Invalid request body')
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const itemsToDelete = await Database.models.libraryItem.getAllOldLibraryItems({
 | 
			
		||||
    const itemsToDelete = await Database.libraryItemModel.getAllOldLibraryItems({
 | 
			
		||||
      id: libraryItemIds
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
@ -332,6 +333,8 @@ class LibraryItemController {
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
 | 
			
		||||
    res.sendStatus(200)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -346,7 +349,7 @@ class LibraryItemController {
 | 
			
		||||
 | 
			
		||||
    for (const updatePayload of updatePayloads) {
 | 
			
		||||
      const mediaPayload = updatePayload.mediaPayload
 | 
			
		||||
      const libraryItem = await Database.models.libraryItem.getOldById(updatePayload.id)
 | 
			
		||||
      const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id)
 | 
			
		||||
      if (!libraryItem) return null
 | 
			
		||||
 | 
			
		||||
      await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
 | 
			
		||||
@ -384,7 +387,7 @@ class LibraryItemController {
 | 
			
		||||
    if (!libraryItemIds.length) {
 | 
			
		||||
      return res.status(403).send('Invalid payload')
 | 
			
		||||
    }
 | 
			
		||||
    const libraryItems = await Database.models.libraryItem.getAllOldLibraryItems({
 | 
			
		||||
    const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
 | 
			
		||||
      id: libraryItemIds
 | 
			
		||||
    })
 | 
			
		||||
    res.json({
 | 
			
		||||
@ -456,9 +459,11 @@ class LibraryItemController {
 | 
			
		||||
        await this.scanner.scanLibraryItemByRequest(libraryItem)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // POST: api/items/:id/scan (admin)
 | 
			
		||||
  // POST: api/items/:id/scan
 | 
			
		||||
  async scan(req, res) {
 | 
			
		||||
    if (!req.user.isAdminOrUp) {
 | 
			
		||||
      Logger.error(`[LibraryItemController] Non-admin user attempted to scan library item`, req.user)
 | 
			
		||||
@ -471,6 +476,7 @@ class LibraryItemController {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const result = await this.scanner.scanLibraryItemByRequest(req.libraryItem)
 | 
			
		||||
    await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
 | 
			
		||||
    res.json({
 | 
			
		||||
      result: Object.keys(ScanResult).find(key => ScanResult[key] == result)
 | 
			
		||||
    })
 | 
			
		||||
@ -694,7 +700,7 @@ class LibraryItemController {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async middleware(req, res, next) {
 | 
			
		||||
    req.libraryItem = await Database.models.libraryItem.getOldById(req.params.id)
 | 
			
		||||
    req.libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
 | 
			
		||||
    if (!req.libraryItem?.media) return res.sendStatus(404)
 | 
			
		||||
 | 
			
		||||
    // Check user can access this library item
 | 
			
		||||
 | 
			
		||||
@ -59,7 +59,7 @@ class MeController {
 | 
			
		||||
 | 
			
		||||
  // PATCH: api/me/progress/:id
 | 
			
		||||
  async createUpdateMediaProgress(req, res) {
 | 
			
		||||
    const libraryItem = await Database.models.libraryItem.getOldById(req.params.id)
 | 
			
		||||
    const libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
 | 
			
		||||
    if (!libraryItem) {
 | 
			
		||||
      return res.status(404).send('Item not found')
 | 
			
		||||
    }
 | 
			
		||||
@ -75,7 +75,7 @@ class MeController {
 | 
			
		||||
  // PATCH: api/me/progress/:id/:episodeId
 | 
			
		||||
  async createUpdateEpisodeMediaProgress(req, res) {
 | 
			
		||||
    const episodeId = req.params.episodeId
 | 
			
		||||
    const libraryItem = await Database.models.libraryItem.getOldById(req.params.id)
 | 
			
		||||
    const libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
 | 
			
		||||
    if (!libraryItem) {
 | 
			
		||||
      return res.status(404).send('Item not found')
 | 
			
		||||
    }
 | 
			
		||||
@ -101,7 +101,7 @@ class MeController {
 | 
			
		||||
 | 
			
		||||
    let shouldUpdate = false
 | 
			
		||||
    for (const itemProgress of itemProgressPayloads) {
 | 
			
		||||
      const libraryItem = await Database.models.libraryItem.getOldById(itemProgress.libraryItemId)
 | 
			
		||||
      const libraryItem = await Database.libraryItemModel.getOldById(itemProgress.libraryItemId)
 | 
			
		||||
      if (libraryItem) {
 | 
			
		||||
        if (req.user.createUpdateMediaProgress(libraryItem, itemProgress, itemProgress.episodeId)) {
 | 
			
		||||
          const mediaProgress = req.user.getMediaProgress(libraryItem.id, itemProgress.episodeId)
 | 
			
		||||
@ -122,7 +122,7 @@ class MeController {
 | 
			
		||||
 | 
			
		||||
  // POST: api/me/item/:id/bookmark
 | 
			
		||||
  async createBookmark(req, res) {
 | 
			
		||||
    if (!await Database.models.libraryItem.checkExistsById(req.params.id)) return res.sendStatus(404)
 | 
			
		||||
    if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
 | 
			
		||||
 | 
			
		||||
    const { time, title } = req.body
 | 
			
		||||
    const bookmark = req.user.createBookmark(req.params.id, time, title)
 | 
			
		||||
@ -133,7 +133,7 @@ class MeController {
 | 
			
		||||
 | 
			
		||||
  // PATCH: api/me/item/:id/bookmark
 | 
			
		||||
  async updateBookmark(req, res) {
 | 
			
		||||
    if (!await Database.models.libraryItem.checkExistsById(req.params.id)) return res.sendStatus(404)
 | 
			
		||||
    if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
 | 
			
		||||
 | 
			
		||||
    const { time, title } = req.body
 | 
			
		||||
    if (!req.user.findBookmark(req.params.id, time)) {
 | 
			
		||||
@ -151,7 +151,7 @@ class MeController {
 | 
			
		||||
 | 
			
		||||
  // DELETE: api/me/item/:id/bookmark/:time
 | 
			
		||||
  async removeBookmark(req, res) {
 | 
			
		||||
    if (!await Database.models.libraryItem.checkExistsById(req.params.id)) return res.sendStatus(404)
 | 
			
		||||
    if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
 | 
			
		||||
 | 
			
		||||
    const time = Number(req.params.time)
 | 
			
		||||
    if (isNaN(time)) return res.sendStatus(500)
 | 
			
		||||
 | 
			
		||||
@ -38,7 +38,7 @@ class MiscController {
 | 
			
		||||
    const libraryId = req.body.library
 | 
			
		||||
    const folderId = req.body.folder
 | 
			
		||||
 | 
			
		||||
    const library = await Database.models.library.getOldById(libraryId)
 | 
			
		||||
    const library = await Database.libraryModel.getOldById(libraryId)
 | 
			
		||||
    if (!library) {
 | 
			
		||||
      return res.status(404).send(`Library not found with id ${libraryId}`)
 | 
			
		||||
    }
 | 
			
		||||
@ -177,7 +177,7 @@ class MiscController {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const tags = []
 | 
			
		||||
    const books = await Database.models.book.findAll({
 | 
			
		||||
    const books = await Database.bookModel.findAll({
 | 
			
		||||
      attributes: ['tags'],
 | 
			
		||||
      where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('tags')), {
 | 
			
		||||
        [Sequelize.Op.gt]: 0
 | 
			
		||||
@ -189,7 +189,7 @@ class MiscController {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const podcasts = await Database.models.podcast.findAll({
 | 
			
		||||
    const podcasts = await Database.podcastModel.findAll({
 | 
			
		||||
      attributes: ['tags'],
 | 
			
		||||
      where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('tags')), {
 | 
			
		||||
        [Sequelize.Op.gt]: 0
 | 
			
		||||
@ -248,7 +248,7 @@ class MiscController {
 | 
			
		||||
        await libraryItem.media.update({
 | 
			
		||||
          tags: libraryItem.media.tags
 | 
			
		||||
        })
 | 
			
		||||
        const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem)
 | 
			
		||||
        const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
 | 
			
		||||
        SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
 | 
			
		||||
        numItemsUpdated++
 | 
			
		||||
      }
 | 
			
		||||
@ -289,7 +289,7 @@ class MiscController {
 | 
			
		||||
      await libraryItem.media.update({
 | 
			
		||||
        tags: libraryItem.media.tags
 | 
			
		||||
      })
 | 
			
		||||
      const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem)
 | 
			
		||||
      const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
 | 
			
		||||
      SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
 | 
			
		||||
      numItemsUpdated++
 | 
			
		||||
    }
 | 
			
		||||
@ -311,7 +311,7 @@ class MiscController {
 | 
			
		||||
      return res.sendStatus(404)
 | 
			
		||||
    }
 | 
			
		||||
    const genres = []
 | 
			
		||||
    const books = await Database.models.book.findAll({
 | 
			
		||||
    const books = await Database.bookModel.findAll({
 | 
			
		||||
      attributes: ['genres'],
 | 
			
		||||
      where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('genres')), {
 | 
			
		||||
        [Sequelize.Op.gt]: 0
 | 
			
		||||
@ -323,7 +323,7 @@ class MiscController {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const podcasts = await Database.models.podcast.findAll({
 | 
			
		||||
    const podcasts = await Database.podcastModel.findAll({
 | 
			
		||||
      attributes: ['genres'],
 | 
			
		||||
      where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('genres')), {
 | 
			
		||||
        [Sequelize.Op.gt]: 0
 | 
			
		||||
@ -382,7 +382,7 @@ class MiscController {
 | 
			
		||||
        await libraryItem.media.update({
 | 
			
		||||
          genres: libraryItem.media.genres
 | 
			
		||||
        })
 | 
			
		||||
        const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem)
 | 
			
		||||
        const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
 | 
			
		||||
        SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
 | 
			
		||||
        numItemsUpdated++
 | 
			
		||||
      }
 | 
			
		||||
@ -423,7 +423,7 @@ class MiscController {
 | 
			
		||||
      await libraryItem.media.update({
 | 
			
		||||
        genres: libraryItem.media.genres
 | 
			
		||||
      })
 | 
			
		||||
      const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem)
 | 
			
		||||
      const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
 | 
			
		||||
      SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
 | 
			
		||||
      numItemsUpdated++
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -22,11 +22,11 @@ class PlaylistController {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Create Playlist record
 | 
			
		||||
    const newPlaylist = await Database.models.playlist.createFromOld(oldPlaylist)
 | 
			
		||||
    const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
 | 
			
		||||
 | 
			
		||||
    // Lookup all library items in playlist
 | 
			
		||||
    const libraryItemIds = oldPlaylist.items.map(i => i.libraryItemId).filter(i => i)
 | 
			
		||||
    const libraryItemsInPlaylist = await Database.models.libraryItem.findAll({
 | 
			
		||||
    const libraryItemsInPlaylist = await Database.libraryItemModel.findAll({
 | 
			
		||||
      where: {
 | 
			
		||||
        id: libraryItemIds
 | 
			
		||||
      }
 | 
			
		||||
@ -62,7 +62,7 @@ class PlaylistController {
 | 
			
		||||
   * @param {*} res 
 | 
			
		||||
   */
 | 
			
		||||
  async findAllForUser(req, res) {
 | 
			
		||||
    const playlistsForUser = await Database.models.playlist.findAll({
 | 
			
		||||
    const playlistsForUser = await Database.playlistModel.findAll({
 | 
			
		||||
      where: {
 | 
			
		||||
        userId: req.user.id
 | 
			
		||||
      }
 | 
			
		||||
@ -106,7 +106,7 @@ class PlaylistController {
 | 
			
		||||
    // If array of items is passed in then update order of playlist media items
 | 
			
		||||
    const libraryItemIds = req.body.items?.map(i => i.libraryItemId).filter(i => i) || []
 | 
			
		||||
    if (libraryItemIds.length) {
 | 
			
		||||
      const libraryItems = await Database.models.libraryItem.findAll({
 | 
			
		||||
      const libraryItems = await Database.libraryItemModel.findAll({
 | 
			
		||||
        where: {
 | 
			
		||||
          id: libraryItemIds
 | 
			
		||||
        }
 | 
			
		||||
@ -173,14 +173,14 @@ class PlaylistController {
 | 
			
		||||
   * @param {*} res 
 | 
			
		||||
   */
 | 
			
		||||
  async addItem(req, res) {
 | 
			
		||||
    const oldPlaylist = await Database.models.playlist.getById(req.playlist.id)
 | 
			
		||||
    const oldPlaylist = await Database.playlistModel.getById(req.playlist.id)
 | 
			
		||||
    const itemToAdd = req.body
 | 
			
		||||
 | 
			
		||||
    if (!itemToAdd.libraryItemId) {
 | 
			
		||||
      return res.status(400).send('Request body has no libraryItemId')
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const libraryItem = await Database.models.libraryItem.getOldById(itemToAdd.libraryItemId)
 | 
			
		||||
    const libraryItem = await Database.libraryItemModel.getOldById(itemToAdd.libraryItemId)
 | 
			
		||||
    if (!libraryItem) {
 | 
			
		||||
      return res.status(400).send('Library item not found')
 | 
			
		||||
    }
 | 
			
		||||
@ -217,7 +217,7 @@ class PlaylistController {
 | 
			
		||||
   * @param {*} res 
 | 
			
		||||
   */
 | 
			
		||||
  async removeItem(req, res) {
 | 
			
		||||
    const oldLibraryItem = await Database.models.libraryItem.getOldById(req.params.libraryItemId)
 | 
			
		||||
    const oldLibraryItem = await Database.libraryItemModel.getOldById(req.params.libraryItemId)
 | 
			
		||||
    if (!oldLibraryItem) {
 | 
			
		||||
      return res.status(404).send('Library item not found')
 | 
			
		||||
    }
 | 
			
		||||
@ -281,7 +281,7 @@ class PlaylistController {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Find all library items
 | 
			
		||||
    const libraryItems = await Database.models.libraryItem.findAll({
 | 
			
		||||
    const libraryItems = await Database.libraryItemModel.findAll({
 | 
			
		||||
      where: {
 | 
			
		||||
        id: libraryItemIds
 | 
			
		||||
      }
 | 
			
		||||
@ -345,7 +345,7 @@ class PlaylistController {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Find all library items
 | 
			
		||||
    const libraryItems = await Database.models.libraryItem.findAll({
 | 
			
		||||
    const libraryItems = await Database.libraryItemModel.findAll({
 | 
			
		||||
      where: {
 | 
			
		||||
        id: libraryItemIds
 | 
			
		||||
      }
 | 
			
		||||
@ -391,7 +391,7 @@ class PlaylistController {
 | 
			
		||||
   * @param {*} res 
 | 
			
		||||
   */
 | 
			
		||||
  async createFromCollection(req, res) {
 | 
			
		||||
    const collection = await Database.models.collection.findByPk(req.params.collectionId)
 | 
			
		||||
    const collection = await Database.collectionModel.findByPk(req.params.collectionId)
 | 
			
		||||
    if (!collection) {
 | 
			
		||||
      return res.status(404).send('Collection not found')
 | 
			
		||||
    }
 | 
			
		||||
@ -416,7 +416,7 @@ class PlaylistController {
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    // Create Playlist record
 | 
			
		||||
    const newPlaylist = await Database.models.playlist.createFromOld(oldPlaylist)
 | 
			
		||||
    const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
 | 
			
		||||
 | 
			
		||||
    // Create PlaylistMediaItem records
 | 
			
		||||
    const mediaItemsToAdd = []
 | 
			
		||||
@ -438,7 +438,7 @@ class PlaylistController {
 | 
			
		||||
 | 
			
		||||
  async middleware(req, res, next) {
 | 
			
		||||
    if (req.params.id) {
 | 
			
		||||
      const playlist = await Database.models.playlist.findByPk(req.params.id)
 | 
			
		||||
      const playlist = await Database.playlistModel.findByPk(req.params.id)
 | 
			
		||||
      if (!playlist) {
 | 
			
		||||
        return res.status(404).send('Playlist not found')
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
@ -19,7 +19,7 @@ class PodcastController {
 | 
			
		||||
    }
 | 
			
		||||
    const payload = req.body
 | 
			
		||||
 | 
			
		||||
    const library = await Database.models.library.getOldById(payload.libraryId)
 | 
			
		||||
    const library = await Database.libraryModel.getOldById(payload.libraryId)
 | 
			
		||||
    if (!library) {
 | 
			
		||||
      Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
 | 
			
		||||
      return res.status(404).send('Library not found')
 | 
			
		||||
@ -34,7 +34,7 @@ class PodcastController {
 | 
			
		||||
    const podcastPath = filePathToPOSIX(payload.path)
 | 
			
		||||
 | 
			
		||||
    // Check if a library item with this podcast folder exists already
 | 
			
		||||
    const existingLibraryItem = (await Database.models.libraryItem.count({
 | 
			
		||||
    const existingLibraryItem = (await Database.libraryItemModel.count({
 | 
			
		||||
      where: {
 | 
			
		||||
        path: podcastPath
 | 
			
		||||
      }
 | 
			
		||||
@ -272,13 +272,13 @@ class PodcastController {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Update/remove playlists that had this podcast episode
 | 
			
		||||
    const playlistMediaItems = await Database.models.playlistMediaItem.findAll({
 | 
			
		||||
    const playlistMediaItems = await Database.playlistMediaItemModel.findAll({
 | 
			
		||||
      where: {
 | 
			
		||||
        mediaItemId: episodeId
 | 
			
		||||
      },
 | 
			
		||||
      include: {
 | 
			
		||||
        model: Database.models.playlist,
 | 
			
		||||
        include: Database.models.playlistMediaItem
 | 
			
		||||
        model: Database.playlistModel,
 | 
			
		||||
        include: Database.playlistMediaItemModel
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
    for (const pmi of playlistMediaItems) {
 | 
			
		||||
@ -297,7 +297,7 @@ class PodcastController {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Remove media progress for this episode
 | 
			
		||||
    const mediaProgressRemoved = await Database.models.mediaProgress.destroy({
 | 
			
		||||
    const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
 | 
			
		||||
      where: {
 | 
			
		||||
        mediaItemId: episode.id
 | 
			
		||||
      }
 | 
			
		||||
@ -312,7 +312,7 @@ class PodcastController {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async middleware(req, res, next) {
 | 
			
		||||
    const item = await Database.models.libraryItem.getOldById(req.params.id)
 | 
			
		||||
    const item = await Database.libraryItemModel.getOldById(req.params.id)
 | 
			
		||||
    if (!item?.media) return res.sendStatus(404)
 | 
			
		||||
 | 
			
		||||
    if (!item.isPodcast) {
 | 
			
		||||
 | 
			
		||||
@ -17,7 +17,7 @@ class RSSFeedController {
 | 
			
		||||
  async openRSSFeedForItem(req, res) {
 | 
			
		||||
    const options = req.body || {}
 | 
			
		||||
 | 
			
		||||
    const item = await Database.models.libraryItem.getOldById(req.params.itemId)
 | 
			
		||||
    const item = await Database.libraryItemModel.getOldById(req.params.itemId)
 | 
			
		||||
    if (!item) return res.sendStatus(404)
 | 
			
		||||
 | 
			
		||||
    // Check user can access this library item
 | 
			
		||||
@ -54,7 +54,7 @@ class RSSFeedController {
 | 
			
		||||
  async openRSSFeedForCollection(req, res) {
 | 
			
		||||
    const options = req.body || {}
 | 
			
		||||
 | 
			
		||||
    const collection = await Database.models.collection.findByPk(req.params.collectionId)
 | 
			
		||||
    const collection = await Database.collectionModel.findByPk(req.params.collectionId)
 | 
			
		||||
    if (!collection) return res.sendStatus(404)
 | 
			
		||||
 | 
			
		||||
    // Check request body options exist
 | 
			
		||||
 | 
			
		||||
@ -49,7 +49,7 @@ class SessionController {
 | 
			
		||||
      return res.sendStatus(404)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const minifiedUserObjects = await Database.models.user.getMinifiedUserObjects()
 | 
			
		||||
    const minifiedUserObjects = await Database.userModel.getMinifiedUserObjects()
 | 
			
		||||
    const openSessions = this.playbackSessionManager.sessions.map(se => {
 | 
			
		||||
      return {
 | 
			
		||||
        ...se.toJSON(),
 | 
			
		||||
 | 
			
		||||
@ -106,7 +106,7 @@ class ToolsController {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (req.params.id) {
 | 
			
		||||
      const item = await Database.models.libraryItem.getOldById(req.params.id)
 | 
			
		||||
      const item = await Database.libraryItemModel.getOldById(req.params.id)
 | 
			
		||||
      if (!item?.media) return res.sendStatus(404)
 | 
			
		||||
 | 
			
		||||
      // Check user can access this library item
 | 
			
		||||
 | 
			
		||||
@ -17,7 +17,7 @@ class UserController {
 | 
			
		||||
    const includes = (req.query.include || '').split(',').map(i => i.trim())
 | 
			
		||||
 | 
			
		||||
    // Minimal toJSONForBrowser does not include mediaProgress and bookmarks
 | 
			
		||||
    const allUsers = await Database.models.user.getOldUsers()
 | 
			
		||||
    const allUsers = await Database.userModel.getOldUsers()
 | 
			
		||||
    const users = allUsers.map(u => u.toJSONForBrowser(hideRootToken, true))
 | 
			
		||||
 | 
			
		||||
    if (includes.includes('latestSession')) {
 | 
			
		||||
@ -47,20 +47,20 @@ class UserController {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Get user media progress with associated mediaItem
 | 
			
		||||
    const mediaProgresses = await Database.models.mediaProgress.findAll({
 | 
			
		||||
    const mediaProgresses = await Database.mediaProgressModel.findAll({
 | 
			
		||||
      where: {
 | 
			
		||||
        userId: req.reqUser.id
 | 
			
		||||
      },
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.book,
 | 
			
		||||
          model: Database.bookModel,
 | 
			
		||||
          attributes: ['id', 'title', 'coverPath', 'updatedAt']
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.podcastEpisode,
 | 
			
		||||
          model: Database.podcastEpisodeModel,
 | 
			
		||||
          attributes: ['id', 'title'],
 | 
			
		||||
          include: {
 | 
			
		||||
            model: Database.models.podcast,
 | 
			
		||||
            model: Database.podcastModel,
 | 
			
		||||
            attributes: ['id', 'title', 'coverPath', 'updatedAt']
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
@ -92,7 +92,7 @@ class UserController {
 | 
			
		||||
    const account = req.body
 | 
			
		||||
    const username = account.username
 | 
			
		||||
 | 
			
		||||
    const usernameExists = await Database.models.user.getUserByUsername(username)
 | 
			
		||||
    const usernameExists = await Database.userModel.getUserByUsername(username)
 | 
			
		||||
    if (usernameExists) {
 | 
			
		||||
      return res.status(500).send('Username already taken')
 | 
			
		||||
    }
 | 
			
		||||
@ -127,7 +127,7 @@ class UserController {
 | 
			
		||||
    var shouldUpdateToken = false
 | 
			
		||||
 | 
			
		||||
    if (account.username !== undefined && account.username !== user.username) {
 | 
			
		||||
      const usernameExists = await Database.models.user.getUserByUsername(account.username)
 | 
			
		||||
      const usernameExists = await Database.userModel.getUserByUsername(account.username)
 | 
			
		||||
      if (usernameExists) {
 | 
			
		||||
        return res.status(500).send('Username already taken')
 | 
			
		||||
      }
 | 
			
		||||
@ -169,7 +169,7 @@ class UserController {
 | 
			
		||||
    // Todo: check if user is logged in and cancel streams
 | 
			
		||||
 | 
			
		||||
    // Remove user playlists
 | 
			
		||||
    const userPlaylists = await Database.models.playlist.findAll({
 | 
			
		||||
    const userPlaylists = await Database.playlistModel.findAll({
 | 
			
		||||
      where: {
 | 
			
		||||
        userId: user.id
 | 
			
		||||
      }
 | 
			
		||||
@ -233,7 +233,7 @@ class UserController {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (req.params.id) {
 | 
			
		||||
      req.reqUser = await Database.models.user.getUserById(req.params.id)
 | 
			
		||||
      req.reqUser = await Database.userModel.getUserById(req.params.id)
 | 
			
		||||
      if (!req.reqUser) {
 | 
			
		||||
        return res.sendStatus(404)
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
@ -5,23 +5,23 @@ const { Sequelize } = require('sequelize')
 | 
			
		||||
const Database = require('../Database')
 | 
			
		||||
 | 
			
		||||
const getLibraryItemMinified = (libraryItemId) => {
 | 
			
		||||
  return Database.models.libraryItem.findByPk(libraryItemId, {
 | 
			
		||||
  return Database.libraryItemModel.findByPk(libraryItemId, {
 | 
			
		||||
    include: [
 | 
			
		||||
      {
 | 
			
		||||
        model: Database.models.book,
 | 
			
		||||
        model: Database.bookModel,
 | 
			
		||||
        attributes: [
 | 
			
		||||
          'id', 'title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language', 'explicit', 'narrators', 'coverPath', 'genres', 'tags'
 | 
			
		||||
        ],
 | 
			
		||||
        include: [
 | 
			
		||||
          {
 | 
			
		||||
            model: Database.models.author,
 | 
			
		||||
            model: Database.authorModel,
 | 
			
		||||
            attributes: ['id', 'name'],
 | 
			
		||||
            through: {
 | 
			
		||||
              attributes: []
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            model: Database.models.series,
 | 
			
		||||
            model: Database.seriesModel,
 | 
			
		||||
            attributes: ['id', 'name'],
 | 
			
		||||
            through: {
 | 
			
		||||
              attributes: ['sequence']
 | 
			
		||||
@ -30,7 +30,7 @@ const getLibraryItemMinified = (libraryItemId) => {
 | 
			
		||||
        ]
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        model: Database.models.podcast,
 | 
			
		||||
        model: Database.podcastModel,
 | 
			
		||||
        attributes: [
 | 
			
		||||
          'id', 'title', 'author', 'releaseDate', 'feedURL', 'imageURL', 'description', 'itunesPageURL', 'itunesId', 'itunesArtistId', 'language', 'podcastType', 'explicit', 'autoDownloadEpisodes', 'genres', 'tags',
 | 
			
		||||
          [Sequelize.literal('(SELECT COUNT(*) FROM "podcastEpisodes" WHERE "podcastEpisodes"."podcastId" = podcast.id)'), 'numPodcastEpisodes']
 | 
			
		||||
@ -41,19 +41,19 @@ const getLibraryItemMinified = (libraryItemId) => {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const getLibraryItemExpanded = (libraryItemId) => {
 | 
			
		||||
  return Database.models.libraryItem.findByPk(libraryItemId, {
 | 
			
		||||
  return Database.libraryItemModel.findByPk(libraryItemId, {
 | 
			
		||||
    include: [
 | 
			
		||||
      {
 | 
			
		||||
        model: Database.models.book,
 | 
			
		||||
        model: Database.bookModel,
 | 
			
		||||
        include: [
 | 
			
		||||
          {
 | 
			
		||||
            model: Database.models.author,
 | 
			
		||||
            model: Database.authorModel,
 | 
			
		||||
            through: {
 | 
			
		||||
              attributes: []
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            model: Database.models.series,
 | 
			
		||||
            model: Database.seriesModel,
 | 
			
		||||
            through: {
 | 
			
		||||
              attributes: ['sequence']
 | 
			
		||||
            }
 | 
			
		||||
@ -61,10 +61,10 @@ const getLibraryItemExpanded = (libraryItemId) => {
 | 
			
		||||
        ]
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        model: Database.models.podcast,
 | 
			
		||||
        model: Database.podcastModel,
 | 
			
		||||
        include: [
 | 
			
		||||
          {
 | 
			
		||||
            model: Database.models.podcastEpisode
 | 
			
		||||
            model: Database.podcastEpisodeModel
 | 
			
		||||
          }
 | 
			
		||||
        ]
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
@ -77,7 +77,7 @@ class CronManager {
 | 
			
		||||
  async initPodcastCrons() {
 | 
			
		||||
    const cronExpressionMap = {}
 | 
			
		||||
 | 
			
		||||
    const podcastsWithAutoDownload = await Database.models.podcast.findAll({
 | 
			
		||||
    const podcastsWithAutoDownload = await Database.podcastModel.findAll({
 | 
			
		||||
      where: {
 | 
			
		||||
        autoDownloadEpisodes: true,
 | 
			
		||||
        autoDownloadSchedule: {
 | 
			
		||||
@ -85,7 +85,7 @@ class CronManager {
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      include: {
 | 
			
		||||
        model: Database.models.libraryItem
 | 
			
		||||
        model: Database.libraryItemModel
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
@ -139,7 +139,7 @@ class CronManager {
 | 
			
		||||
    // Get podcast library items to check
 | 
			
		||||
    const libraryItems = []
 | 
			
		||||
    for (const libraryItemId of libraryItemIds) {
 | 
			
		||||
      const libraryItem = await Database.models.libraryItem.getOldById(libraryItemId)
 | 
			
		||||
      const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
 | 
			
		||||
      if (!libraryItem) {
 | 
			
		||||
        Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
 | 
			
		||||
        podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItemId) // Filter it out
 | 
			
		||||
 | 
			
		||||
@ -18,7 +18,7 @@ class NotificationManager {
 | 
			
		||||
    if (!Database.notificationSettings.isUseable) return
 | 
			
		||||
 | 
			
		||||
    Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`)
 | 
			
		||||
    const library = await Database.models.library.getOldById(libraryItem.libraryId)
 | 
			
		||||
    const library = await Database.libraryModel.getOldById(libraryItem.libraryId)
 | 
			
		||||
    const eventData = {
 | 
			
		||||
      libraryItemId: libraryItem.id,
 | 
			
		||||
      libraryId: libraryItem.libraryId,
 | 
			
		||||
 | 
			
		||||
@ -265,7 +265,7 @@ class PlaybackSessionManager {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async syncSession(user, session, syncData) {
 | 
			
		||||
    const libraryItem = await Database.models.libraryItem.getOldById(session.libraryItemId)
 | 
			
		||||
    const libraryItem = await Database.libraryItemModel.getOldById(session.libraryItemId)
 | 
			
		||||
    if (!libraryItem) {
 | 
			
		||||
      Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
 | 
			
		||||
      return null
 | 
			
		||||
 | 
			
		||||
@ -150,7 +150,7 @@ class PodcastManager {
 | 
			
		||||
      return false
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const libraryItem = await Database.models.libraryItem.getOldById(this.currentDownload.libraryItem.id)
 | 
			
		||||
    const libraryItem = await Database.libraryItemModel.getOldById(this.currentDownload.libraryItem.id)
 | 
			
		||||
    if (!libraryItem) {
 | 
			
		||||
      Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
 | 
			
		||||
      return false
 | 
			
		||||
@ -372,8 +372,13 @@ class PodcastManager {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  generateOPMLFileText(libraryItems) {
 | 
			
		||||
    return opmlGenerator.generate(libraryItems)
 | 
			
		||||
  /**
 | 
			
		||||
   * OPML file string for podcasts in a library
 | 
			
		||||
   * @param {import('../models/Podcast')[]} podcasts 
 | 
			
		||||
   * @returns {string} XML string
 | 
			
		||||
   */
 | 
			
		||||
  generateOPMLFileText(podcasts) {
 | 
			
		||||
    return opmlGenerator.generate(podcasts)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getDownloadQueueDetails(libraryId = null) {
 | 
			
		||||
 | 
			
		||||
@ -13,13 +13,13 @@ class RssFeedManager {
 | 
			
		||||
 | 
			
		||||
  async validateFeedEntity(feedObj) {
 | 
			
		||||
    if (feedObj.entityType === 'collection') {
 | 
			
		||||
      const collection = await Database.models.collection.getOldById(feedObj.entityId)
 | 
			
		||||
      const collection = await Database.collectionModel.getOldById(feedObj.entityId)
 | 
			
		||||
      if (!collection) {
 | 
			
		||||
        Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Collection "${feedObj.entityId}" not found`)
 | 
			
		||||
        return false
 | 
			
		||||
      }
 | 
			
		||||
    } else if (feedObj.entityType === 'libraryItem') {
 | 
			
		||||
      const libraryItemExists = await Database.models.libraryItem.checkExistsById(feedObj.entityId)
 | 
			
		||||
      const libraryItemExists = await Database.libraryItemModel.checkExistsById(feedObj.entityId)
 | 
			
		||||
      if (!libraryItemExists) {
 | 
			
		||||
        Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Library item "${feedObj.entityId}" not found`)
 | 
			
		||||
        return false
 | 
			
		||||
@ -41,7 +41,7 @@ class RssFeedManager {
 | 
			
		||||
   * Validate all feeds and remove invalid
 | 
			
		||||
   */
 | 
			
		||||
  async init() {
 | 
			
		||||
    const feeds = await Database.models.feed.getOldFeeds()
 | 
			
		||||
    const feeds = await Database.feedModel.getOldFeeds()
 | 
			
		||||
    for (const feed of feeds) {
 | 
			
		||||
      // Remove invalid feeds
 | 
			
		||||
      if (!await this.validateFeedEntity(feed)) {
 | 
			
		||||
@ -56,7 +56,7 @@ class RssFeedManager {
 | 
			
		||||
   * @returns {Promise<objects.Feed>} oldFeed
 | 
			
		||||
   */
 | 
			
		||||
  findFeedForEntityId(entityId) {
 | 
			
		||||
    return Database.models.feed.findOneOld({ entityId })
 | 
			
		||||
    return Database.feedModel.findOneOld({ entityId })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
@ -65,7 +65,7 @@ class RssFeedManager {
 | 
			
		||||
   * @returns {Promise<objects.Feed>} oldFeed
 | 
			
		||||
   */
 | 
			
		||||
  findFeedBySlug(slug) {
 | 
			
		||||
    return Database.models.feed.findOneOld({ slug })
 | 
			
		||||
    return Database.feedModel.findOneOld({ slug })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
@ -74,7 +74,7 @@ class RssFeedManager {
 | 
			
		||||
   * @returns {Promise<objects.Feed>} oldFeed
 | 
			
		||||
   */
 | 
			
		||||
  findFeed(id) {
 | 
			
		||||
    return Database.models.feed.findByPkOld(id)
 | 
			
		||||
    return Database.feedModel.findByPkOld(id)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getFeed(req, res) {
 | 
			
		||||
@ -103,7 +103,7 @@ class RssFeedManager {
 | 
			
		||||
        await Database.updateFeed(feed)
 | 
			
		||||
      }
 | 
			
		||||
    } else if (feed.entityType === 'collection') {
 | 
			
		||||
      const collection = await Database.models.collection.findByPk(feed.entityId)
 | 
			
		||||
      const collection = await Database.collectionModel.findByPk(feed.entityId)
 | 
			
		||||
      if (collection) {
 | 
			
		||||
        const collectionExpanded = await collection.getOldJsonExpanded()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -83,6 +83,15 @@ class Author extends Model {
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Check if author exists
 | 
			
		||||
   * @param {string} authorId 
 | 
			
		||||
   * @returns {Promise<boolean>}
 | 
			
		||||
   */
 | 
			
		||||
  static async checkExistsById(authorId) {
 | 
			
		||||
    return (await this.count({ where: { id: authorId } })) > 0
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Initialize model
 | 
			
		||||
   * @param {import('../Database').sequelize} sequelize 
 | 
			
		||||
 | 
			
		||||
@ -54,7 +54,7 @@ class Podcast extends Model {
 | 
			
		||||
 | 
			
		||||
  static getOldPodcast(libraryItemExpanded) {
 | 
			
		||||
    const podcastExpanded = libraryItemExpanded.media
 | 
			
		||||
    const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id)).sort((a, b) => a.index - b.index)
 | 
			
		||||
    const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id).toJSON()).sort((a, b) => a.index - b.index)
 | 
			
		||||
    return {
 | 
			
		||||
      id: podcastExpanded.id,
 | 
			
		||||
      libraryItemId: libraryItemExpanded.id,
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
const { DataTypes, Model } = require('sequelize')
 | 
			
		||||
const oldPodcastEpisode = require('../objects/entities/PodcastEpisode')
 | 
			
		||||
 | 
			
		||||
class PodcastEpisode extends Model {
 | 
			
		||||
  constructor(values, options) {
 | 
			
		||||
@ -44,6 +45,10 @@ class PodcastEpisode extends Model {
 | 
			
		||||
    this.updatedAt
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * @param {string} libraryItemId 
 | 
			
		||||
   * @returns {oldPodcastEpisode}
 | 
			
		||||
   */
 | 
			
		||||
  getOldPodcastEpisode(libraryItemId = null) {
 | 
			
		||||
    let enclosure = null
 | 
			
		||||
    if (this.enclosureURL) {
 | 
			
		||||
@ -53,7 +58,7 @@ class PodcastEpisode extends Model {
 | 
			
		||||
        length: this.enclosureSize !== null ? String(this.enclosureSize) : null
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return {
 | 
			
		||||
    return new oldPodcastEpisode({
 | 
			
		||||
      libraryItemId: libraryItemId || null,
 | 
			
		||||
      podcastId: this.podcastId,
 | 
			
		||||
      id: this.id,
 | 
			
		||||
@ -72,7 +77,7 @@ class PodcastEpisode extends Model {
 | 
			
		||||
      publishedAt: this.publishedAt?.valueOf() || null,
 | 
			
		||||
      addedAt: this.createdAt.valueOf(),
 | 
			
		||||
      updatedAt: this.updatedAt.valueOf()
 | 
			
		||||
    }
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static createFromOld(oldEpisode) {
 | 
			
		||||
 | 
			
		||||
@ -75,6 +75,15 @@ class Series extends Model {
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Check if series exists
 | 
			
		||||
   * @param {string} seriesId 
 | 
			
		||||
   * @returns {Promise<boolean>}
 | 
			
		||||
   */
 | 
			
		||||
  static async checkExistsById(seriesId) {
 | 
			
		||||
    return (await this.count({ where: { id: seriesId } })) > 0
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Initialize model
 | 
			
		||||
   * @param {import('../Database').sequelize} sequelize 
 | 
			
		||||
@ -91,7 +100,24 @@ class Series extends Model {
 | 
			
		||||
      description: DataTypes.TEXT
 | 
			
		||||
    }, {
 | 
			
		||||
      sequelize,
 | 
			
		||||
      modelName: 'series'
 | 
			
		||||
      modelName: 'series',
 | 
			
		||||
      indexes: [
 | 
			
		||||
        {
 | 
			
		||||
          fields: [{
 | 
			
		||||
            name: 'name',
 | 
			
		||||
            collate: 'NOCASE'
 | 
			
		||||
          }]
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          fields: [{
 | 
			
		||||
            name: 'nameIgnorePrefix',
 | 
			
		||||
            collate: 'NOCASE'
 | 
			
		||||
          }]
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          fields: ['libraryId']
 | 
			
		||||
        }
 | 
			
		||||
      ]
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    const { library } = sequelize.models
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
const uuidv4 = require("uuid").v4
 | 
			
		||||
const { getTitleIgnorePrefix } = require('../../utils/index')
 | 
			
		||||
const { getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
 | 
			
		||||
 | 
			
		||||
class Series {
 | 
			
		||||
  constructor(series) {
 | 
			
		||||
@ -33,6 +33,7 @@ class Series {
 | 
			
		||||
    return {
 | 
			
		||||
      id: this.id,
 | 
			
		||||
      name: this.name,
 | 
			
		||||
      nameIgnorePrefix: getTitlePrefixAtEnd(this.name),
 | 
			
		||||
      description: this.description,
 | 
			
		||||
      addedAt: this.addedAt,
 | 
			
		||||
      updatedAt: this.updatedAt,
 | 
			
		||||
 | 
			
		||||
@ -78,23 +78,22 @@ class ApiRouter {
 | 
			
		||||
    this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this))
 | 
			
		||||
    this.router.delete('/libraries/:id/issues', LibraryController.middlewareNew.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/episode-downloads', LibraryController.middlewareNew.bind(this), LibraryController.getEpisodeDownloadQueue.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/series', LibraryController.middlewareNew.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/series/:seriesId', LibraryController.middlewareNew.bind(this), LibraryController.getSeriesForLibrary.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/collections', LibraryController.middlewareNew.bind(this), LibraryController.getCollectionsForLibrary.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/playlists', LibraryController.middlewareNew.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/personalized2', LibraryController.middlewareNew.bind(this), LibraryController.getUserPersonalizedShelves.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getLibraryUserPersonalizedOptimal.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/personalized', LibraryController.middlewareNew.bind(this), LibraryController.getUserPersonalizedShelves.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/filterdata', LibraryController.middlewareNew.bind(this), LibraryController.getLibraryFilterData.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/search', LibraryController.middlewareNew.bind(this), LibraryController.search.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/stats', LibraryController.middlewareNew.bind(this), LibraryController.stats.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/authors', LibraryController.middlewareNew.bind(this), LibraryController.getAuthors.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/narrators', LibraryController.middlewareNew.bind(this), LibraryController.getNarrators.bind(this))
 | 
			
		||||
    this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middlewareNew.bind(this), LibraryController.updateNarrator.bind(this))
 | 
			
		||||
    this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middlewareNew.bind(this), LibraryController.removeNarrator.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/matchall', LibraryController.middleware.bind(this), LibraryController.matchAll.bind(this))
 | 
			
		||||
    this.router.post('/libraries/:id/scan', LibraryController.middleware.bind(this), LibraryController.scan.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/recent-episodes', LibraryController.middleware.bind(this), LibraryController.getRecentEpisodes.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/opml', LibraryController.middleware.bind(this), LibraryController.getOPMLFile.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/matchall', LibraryController.middlewareNew.bind(this), LibraryController.matchAll.bind(this))
 | 
			
		||||
    this.router.post('/libraries/:id/scan', LibraryController.middlewareNew.bind(this), LibraryController.scan.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/recent-episodes', LibraryController.middlewareNew.bind(this), LibraryController.getRecentEpisodes.bind(this))
 | 
			
		||||
    this.router.get('/libraries/:id/opml', LibraryController.middlewareNew.bind(this), LibraryController.getOPMLFile.bind(this))
 | 
			
		||||
    this.router.post('/libraries/order', LibraryController.reorder.bind(this))
 | 
			
		||||
 | 
			
		||||
    //
 | 
			
		||||
@ -361,7 +360,7 @@ class ApiRouter {
 | 
			
		||||
   */
 | 
			
		||||
  async handleDeleteLibraryItem(mediaType, libraryItemId, mediaItemIds) {
 | 
			
		||||
    // Remove media progress for this library item from all users
 | 
			
		||||
    const users = await Database.models.user.getOldUsers()
 | 
			
		||||
    const users = await Database.userModel.getOldUsers()
 | 
			
		||||
    for (const user of users) {
 | 
			
		||||
      for (const mediaProgress of user.getAllMediaProgressForLibraryItem(libraryItemId)) {
 | 
			
		||||
        await Database.removeMediaProgress(mediaProgress.id)
 | 
			
		||||
@ -373,14 +372,14 @@ class ApiRouter {
 | 
			
		||||
    // Remove series if empty
 | 
			
		||||
    if (mediaType === 'book') {
 | 
			
		||||
      // TODO: update filter data
 | 
			
		||||
      const bookSeries = await Database.models.bookSeries.findAll({
 | 
			
		||||
      const bookSeries = await Database.bookSeriesModel.findAll({
 | 
			
		||||
        where: {
 | 
			
		||||
          bookId: mediaItemIds[0]
 | 
			
		||||
        },
 | 
			
		||||
        include: {
 | 
			
		||||
          model: Database.models.series,
 | 
			
		||||
          model: Database.seriesModel,
 | 
			
		||||
          include: {
 | 
			
		||||
            model: Database.models.book
 | 
			
		||||
            model: Database.bookModel
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
@ -392,7 +391,7 @@ class ApiRouter {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // remove item from playlists
 | 
			
		||||
    const playlistsWithItem = await Database.models.playlist.getPlaylistsForMediaItemIds(mediaItemIds)
 | 
			
		||||
    const playlistsWithItem = await Database.playlistModel.getPlaylistsForMediaItemIds(mediaItemIds)
 | 
			
		||||
    for (const playlist of playlistsWithItem) {
 | 
			
		||||
      let numMediaItems = playlist.playlistMediaItems.length
 | 
			
		||||
 | 
			
		||||
@ -445,23 +444,23 @@ class ApiRouter {
 | 
			
		||||
  /**
 | 
			
		||||
   * Used when a series is removed from a book
 | 
			
		||||
   * Series is removed if it only has 1 book
 | 
			
		||||
   * TODO: Update filter data
 | 
			
		||||
   * @param {UUIDV4} bookId
 | 
			
		||||
   * @param {UUIDV4[]} seriesIds
 | 
			
		||||
   * 
 | 
			
		||||
   * @param {string} bookId
 | 
			
		||||
   * @param {string[]} seriesIds 
 | 
			
		||||
   */
 | 
			
		||||
  async checkRemoveEmptySeries(bookId, seriesIds) {
 | 
			
		||||
    if (!seriesIds?.length) return
 | 
			
		||||
 | 
			
		||||
    const bookSeries = await Database.models.bookSeries.findAll({
 | 
			
		||||
    const bookSeries = await Database.bookSeriesModel.findAll({
 | 
			
		||||
      where: {
 | 
			
		||||
        bookId,
 | 
			
		||||
        seriesId: seriesIds
 | 
			
		||||
      },
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.series,
 | 
			
		||||
          model: Database.seriesModel,
 | 
			
		||||
          include: {
 | 
			
		||||
            model: Database.models.book
 | 
			
		||||
            model: Database.bookModel
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      ]
 | 
			
		||||
@ -473,10 +472,20 @@ class ApiRouter {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Remove an empty series & close an open RSS feed
 | 
			
		||||
   * @param {import('../models/Series')} series 
 | 
			
		||||
   */
 | 
			
		||||
  async removeEmptySeries(series) {
 | 
			
		||||
    await this.rssFeedManager.closeFeedForEntityId(series.id)
 | 
			
		||||
    Logger.info(`[ApiRouter] Series "${series.name}" is now empty. Removing series`)
 | 
			
		||||
    await Database.removeSeries(series.id)
 | 
			
		||||
    // Remove series from library filter data
 | 
			
		||||
    Database.removeSeriesFromFilterData(series.libraryId, series.id)
 | 
			
		||||
    SocketAuthority.emitter('series_removed', {
 | 
			
		||||
      id: series.id,
 | 
			
		||||
      libraryId: series.libraryId
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getUserListeningSessionsHelper(userId) {
 | 
			
		||||
@ -487,7 +496,7 @@ class ApiRouter {
 | 
			
		||||
  async getAllSessionsWithUserData() {
 | 
			
		||||
    const sessions = await Database.getPlaybackSessions()
 | 
			
		||||
    sessions.sort((a, b) => b.updatedAt - a.updatedAt)
 | 
			
		||||
    const minifiedUserObjects = await Database.models.user.getMinifiedUserObjects()
 | 
			
		||||
    const minifiedUserObjects = await Database.userModel.getMinifiedUserObjects()
 | 
			
		||||
    return sessions.map(se => {
 | 
			
		||||
      return {
 | 
			
		||||
        ...se,
 | 
			
		||||
@ -547,7 +556,7 @@ class ApiRouter {
 | 
			
		||||
      const mediaMetadata = mediaPayload.metadata
 | 
			
		||||
 | 
			
		||||
      // Create new authors if in payload
 | 
			
		||||
      if (mediaMetadata.authors && mediaMetadata.authors.length) {
 | 
			
		||||
      if (mediaMetadata.authors?.length) {
 | 
			
		||||
        const newAuthors = []
 | 
			
		||||
        for (let i = 0; i < mediaMetadata.authors.length; i++) {
 | 
			
		||||
          const authorName = (mediaMetadata.authors[i].name || '').trim()
 | 
			
		||||
@ -556,6 +565,12 @@ class ApiRouter {
 | 
			
		||||
            continue
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          // Ensure the ID for the author exists
 | 
			
		||||
          if (mediaMetadata.authors[i].id && !(await Database.checkAuthorExists(libraryId, mediaMetadata.authors[i].id))) {
 | 
			
		||||
            Logger.warn(`[ApiRouter] Author id "${mediaMetadata.authors[i].id}" does not exist`)
 | 
			
		||||
            mediaMetadata.authors[i].id = null
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          if (!mediaMetadata.authors[i].id || mediaMetadata.authors[i].id.startsWith('new')) {
 | 
			
		||||
            let author = Database.authors.find(au => au.libraryId === libraryId && au.checkNameEquals(authorName))
 | 
			
		||||
            if (!author) {
 | 
			
		||||
@ -563,6 +578,8 @@ class ApiRouter {
 | 
			
		||||
              author.setData(mediaMetadata.authors[i], libraryId)
 | 
			
		||||
              Logger.debug(`[ApiRouter] Created new author "${author.name}"`)
 | 
			
		||||
              newAuthors.push(author)
 | 
			
		||||
              // Update filter data
 | 
			
		||||
              Database.addAuthorToFilterData(libraryId, author.name, author.id)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Update ID in original payload
 | 
			
		||||
@ -585,6 +602,12 @@ class ApiRouter {
 | 
			
		||||
            continue
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          // Ensure the ID for the series exists
 | 
			
		||||
          if (mediaMetadata.series[i].id && !(await Database.checkSeriesExists(libraryId, mediaMetadata.series[i].id))) {
 | 
			
		||||
            Logger.warn(`[ApiRouter] Series id "${mediaMetadata.series[i].id}" does not exist`)
 | 
			
		||||
            mediaMetadata.series[i].id = null
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          if (!mediaMetadata.series[i].id || mediaMetadata.series[i].id.startsWith('new')) {
 | 
			
		||||
            let seriesItem = Database.series.find(se => se.libraryId === libraryId && se.checkNameEquals(seriesName))
 | 
			
		||||
            if (!seriesItem) {
 | 
			
		||||
@ -592,6 +615,8 @@ class ApiRouter {
 | 
			
		||||
              seriesItem.setData(mediaMetadata.series[i], libraryId)
 | 
			
		||||
              Logger.debug(`[ApiRouter] Created new series "${seriesItem.name}"`)
 | 
			
		||||
              newSeries.push(seriesItem)
 | 
			
		||||
              // Update filter data
 | 
			
		||||
              Database.addSeriesToFilterData(libraryId, seriesItem.name, seriesItem.id)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Update ID in original payload
 | 
			
		||||
 | 
			
		||||
@ -67,7 +67,7 @@ class Scanner {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async scanLibraryItemByRequest(libraryItem) {
 | 
			
		||||
    const library = await Database.models.library.getOldById(libraryItem.libraryId)
 | 
			
		||||
    const library = await Database.libraryModel.getOldById(libraryItem.libraryId)
 | 
			
		||||
    if (!library) {
 | 
			
		||||
      Logger.error(`[Scanner] Scan libraryItem by id library not found "${libraryItem.libraryId}"`)
 | 
			
		||||
      return ScanResult.NOTHING
 | 
			
		||||
@ -486,6 +486,8 @@ class Scanner {
 | 
			
		||||
          _author = new Author()
 | 
			
		||||
          _author.setData(tempMinAuthor, libraryItem.libraryId)
 | 
			
		||||
          newAuthors.push(_author)
 | 
			
		||||
          // Update filter data
 | 
			
		||||
          Database.addAuthorToFilterData(libraryItem.libraryId, _author.name, _author.id)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
@ -502,11 +504,17 @@ class Scanner {
 | 
			
		||||
      const newSeries = []
 | 
			
		||||
      libraryItem.media.metadata.series = libraryItem.media.metadata.series.map((tempMinSeries) => {
 | 
			
		||||
        let _series = Database.series.find(se => se.libraryId === libraryItem.libraryId && se.checkNameEquals(tempMinSeries.name))
 | 
			
		||||
        if (!_series) _series = newSeries.find(se => se.libraryId === libraryItem.libraryId && se.checkNameEquals(tempMinSeries.name)) // Check new unsaved series
 | 
			
		||||
        if (!_series) {
 | 
			
		||||
          // Check new unsaved series
 | 
			
		||||
          _series = newSeries.find(se => se.libraryId === libraryItem.libraryId && se.checkNameEquals(tempMinSeries.name))
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!_series) { // Must create new series
 | 
			
		||||
          _series = new Series()
 | 
			
		||||
          _series.setData(tempMinSeries, libraryItem.libraryId)
 | 
			
		||||
          newSeries.push(_series)
 | 
			
		||||
          // Update filter data
 | 
			
		||||
          Database.addSeriesToFilterData(libraryItem.libraryId, _series.name, _series.id)
 | 
			
		||||
        }
 | 
			
		||||
        return {
 | 
			
		||||
          id: _series.id,
 | 
			
		||||
@ -553,25 +561,30 @@ class Scanner {
 | 
			
		||||
 | 
			
		||||
    for (const folderId in folderGroups) {
 | 
			
		||||
      const libraryId = folderGroups[folderId].libraryId
 | 
			
		||||
      const library = await Database.models.library.getOldById(libraryId)
 | 
			
		||||
      const library = await Database.libraryModel.getOldById(libraryId)
 | 
			
		||||
      if (!library) {
 | 
			
		||||
        Logger.error(`[Scanner] Library not found in files changed ${libraryId}`)
 | 
			
		||||
        continue;
 | 
			
		||||
        continue
 | 
			
		||||
      }
 | 
			
		||||
      const folder = library.getFolderById(folderId)
 | 
			
		||||
      if (!folder) {
 | 
			
		||||
        Logger.error(`[Scanner] Folder is not in library in files changed "${folderId}", Library "${library.name}"`)
 | 
			
		||||
        continue;
 | 
			
		||||
        continue
 | 
			
		||||
      }
 | 
			
		||||
      const relFilePaths = folderGroups[folderId].fileUpdates.map(fileUpdate => fileUpdate.relPath)
 | 
			
		||||
      const fileUpdateGroup = groupFilesIntoLibraryItemPaths(library.mediaType, relFilePaths, false)
 | 
			
		||||
 | 
			
		||||
      if (!Object.keys(fileUpdateGroup).length) {
 | 
			
		||||
        Logger.info(`[Scanner] No important changes to scan for in folder "${folderId}"`)
 | 
			
		||||
        continue;
 | 
			
		||||
        continue
 | 
			
		||||
      }
 | 
			
		||||
      const folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateGroup)
 | 
			
		||||
      Logger.debug(`[Scanner] Folder scan results`, folderScanResults)
 | 
			
		||||
 | 
			
		||||
      // If something was updated then reset numIssues filter data for library
 | 
			
		||||
      if (Object.values(folderScanResults).some(scanResult => scanResult !== ScanResult.NOTHING && scanResult !== ScanResult.UPTODATE)) {
 | 
			
		||||
        await Database.resetLibraryIssuesFilterData(libraryId)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.scanningFilesChanged = false
 | 
			
		||||
@ -599,7 +612,7 @@ class Scanner {
 | 
			
		||||
      const altDir = `${itemDir}/${firstNest}`
 | 
			
		||||
 | 
			
		||||
      const fullPath = Path.posix.join(filePathToPOSIX(folder.fullPath), itemDir)
 | 
			
		||||
      const childLibraryItem = await Database.models.libraryItem.findOne({
 | 
			
		||||
      const childLibraryItem = await Database.libraryItemModel.findOne({
 | 
			
		||||
        attributes: ['id', 'path'],
 | 
			
		||||
        where: {
 | 
			
		||||
          path: {
 | 
			
		||||
@ -615,7 +628,7 @@ class Scanner {
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const altFullPath = Path.posix.join(filePathToPOSIX(folder.fullPath), altDir)
 | 
			
		||||
      const altChildLibraryItem = await Database.models.libraryItem.findOne({
 | 
			
		||||
      const altChildLibraryItem = await Database.libraryItemModel.findOne({
 | 
			
		||||
        attributes: ['id', 'path'],
 | 
			
		||||
        where: {
 | 
			
		||||
          path: {
 | 
			
		||||
@ -648,12 +661,12 @@ class Scanner {
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Check if book dir group is already an item
 | 
			
		||||
      let existingLibraryItem = await Database.models.libraryItem.findOneOld({
 | 
			
		||||
      let existingLibraryItem = await Database.libraryItemModel.findOneOld({
 | 
			
		||||
        path: potentialChildDirs
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      if (!existingLibraryItem) {
 | 
			
		||||
        existingLibraryItem = await Database.models.libraryItem.findOneOld({
 | 
			
		||||
        existingLibraryItem = await Database.libraryItemModel.findOneOld({
 | 
			
		||||
          ino: dirIno
 | 
			
		||||
        })
 | 
			
		||||
        if (existingLibraryItem) {
 | 
			
		||||
@ -688,11 +701,11 @@ class Scanner {
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Check if a library item is a subdirectory of this dir
 | 
			
		||||
      const childItem = await Database.models.libraryItem.findOne({
 | 
			
		||||
      const childItem = await Database.libraryItemModel.findOne({
 | 
			
		||||
        attributes: ['id', 'path'],
 | 
			
		||||
        where: {
 | 
			
		||||
          path: {
 | 
			
		||||
            [Sequelize.Op.startsWith]: fullPath
 | 
			
		||||
            [Sequelize.Op.startsWith]: fullPath + '/'
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
@ -924,6 +937,8 @@ class Scanner {
 | 
			
		||||
          author.setData({ name: authorName }, libraryItem.libraryId)
 | 
			
		||||
          await Database.createAuthor(author)
 | 
			
		||||
          SocketAuthority.emitter('author_added', author.toJSON())
 | 
			
		||||
          // Update filter data
 | 
			
		||||
          Database.addAuthorToFilterData(libraryItem.libraryId, author.name, author.id)
 | 
			
		||||
        }
 | 
			
		||||
        authorPayload.push(author.toJSONMinimal())
 | 
			
		||||
      }
 | 
			
		||||
@ -940,6 +955,8 @@ class Scanner {
 | 
			
		||||
          seriesItem = new Series()
 | 
			
		||||
          seriesItem.setData({ name: seriesMatchItem.series }, libraryItem.libraryId)
 | 
			
		||||
          await Database.createSeries(seriesItem)
 | 
			
		||||
          // Update filter data
 | 
			
		||||
          Database.addSeriesToFilterData(libraryItem.libraryId, seriesItem.name, seriesItem.id)
 | 
			
		||||
          SocketAuthority.emitter('series_added', seriesItem.toJSON())
 | 
			
		||||
        }
 | 
			
		||||
        seriesPayload.push(seriesItem.toJSONMinimal(seriesMatchItem.sequence))
 | 
			
		||||
 | 
			
		||||
@ -1,23 +1,29 @@
 | 
			
		||||
const xml = require('../../libs/xml')
 | 
			
		||||
 | 
			
		||||
module.exports.generate = (libraryItems, indent = true) => {
 | 
			
		||||
/**
 | 
			
		||||
 * Generate OPML file string for podcasts in a library
 | 
			
		||||
 * @param {import('../../models/Podcast')[]} podcasts 
 | 
			
		||||
 * @param {boolean} [indent=true] 
 | 
			
		||||
 * @returns {string}
 | 
			
		||||
 */
 | 
			
		||||
module.exports.generate = (podcasts, indent = true) => {
 | 
			
		||||
  const bodyItems = []
 | 
			
		||||
  libraryItems.forEach((item) => {
 | 
			
		||||
    if (!item.media.metadata.feedUrl) return
 | 
			
		||||
  podcasts.forEach((podcast) => {
 | 
			
		||||
    if (!podcast.feedURL) return
 | 
			
		||||
    const feedAttributes = {
 | 
			
		||||
      type: 'rss',
 | 
			
		||||
      text: item.media.metadata.title,
 | 
			
		||||
      title: item.media.metadata.title,
 | 
			
		||||
      xmlUrl: item.media.metadata.feedUrl
 | 
			
		||||
      text: podcast.title,
 | 
			
		||||
      title: podcast.title,
 | 
			
		||||
      xmlUrl: podcast.feedURL
 | 
			
		||||
    }
 | 
			
		||||
    if (item.media.metadata.description) {
 | 
			
		||||
      feedAttributes.description = item.media.metadata.description
 | 
			
		||||
    if (podcast.description) {
 | 
			
		||||
      feedAttributes.description = podcast.description
 | 
			
		||||
    }
 | 
			
		||||
    if (item.media.metadata.itunesPageUrl) {
 | 
			
		||||
      feedAttributes.htmlUrl = item.media.metadata.itunesPageUrl
 | 
			
		||||
    if (podcast.itunesPageUrl) {
 | 
			
		||||
      feedAttributes.htmlUrl = podcast.itunesPageUrl
 | 
			
		||||
    }
 | 
			
		||||
    if (item.media.metadata.language) {
 | 
			
		||||
      feedAttributes.language = item.media.metadata.language
 | 
			
		||||
    if (podcast.language) {
 | 
			
		||||
      feedAttributes.language = podcast.language
 | 
			
		||||
    }
 | 
			
		||||
    bodyItems.push({
 | 
			
		||||
      outline: {
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,4 @@
 | 
			
		||||
const { sort, createNewSortInstance } = require('../libs/fastSort')
 | 
			
		||||
const Logger = require('../Logger')
 | 
			
		||||
const { createNewSortInstance } = require('../libs/fastSort')
 | 
			
		||||
const Database = require('../Database')
 | 
			
		||||
const { getTitlePrefixAtEnd, isNullOrNaN, getTitleIgnorePrefix } = require('../utils/index')
 | 
			
		||||
const naturalSort = createNewSortInstance({
 | 
			
		||||
@ -72,7 +71,7 @@ module.exports = {
 | 
			
		||||
    } else if (filterBy === 'issues') {
 | 
			
		||||
      filtered = filtered.filter(li => li.hasIssues)
 | 
			
		||||
    } else if (filterBy === 'feed-open') {
 | 
			
		||||
      const libraryItemIdsWithFeed = await Database.models.feed.findAllLibraryItemIds()
 | 
			
		||||
      const libraryItemIdsWithFeed = await Database.feedModel.findAllLibraryItemIds()
 | 
			
		||||
      filtered = filtered.filter(li => libraryItemIdsWithFeed.includes(li.id))
 | 
			
		||||
    } else if (filterBy === 'abridged') {
 | 
			
		||||
      filtered = filtered.filter(li => !!li.media.metadata?.abridged)
 | 
			
		||||
@ -126,60 +125,6 @@ module.exports = {
 | 
			
		||||
    return true
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  getDistinctFilterDataNew(libraryItems) {
 | 
			
		||||
    const data = {
 | 
			
		||||
      authors: [],
 | 
			
		||||
      genres: [],
 | 
			
		||||
      tags: [],
 | 
			
		||||
      series: [],
 | 
			
		||||
      narrators: [],
 | 
			
		||||
      languages: [],
 | 
			
		||||
      publishers: []
 | 
			
		||||
    }
 | 
			
		||||
    libraryItems.forEach((li) => {
 | 
			
		||||
      const mediaMetadata = li.media.metadata
 | 
			
		||||
      if (mediaMetadata.authors?.length) {
 | 
			
		||||
        mediaMetadata.authors.forEach((author) => {
 | 
			
		||||
          if (author && !data.authors.some(au => au.id === author.id)) data.authors.push({ id: author.id, name: author.name })
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
      if (mediaMetadata.series?.length) {
 | 
			
		||||
        mediaMetadata.series.forEach((series) => {
 | 
			
		||||
          if (series && !data.series.some(se => se.id === series.id)) data.series.push({ id: series.id, name: series.name })
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
      if (mediaMetadata.genres?.length) {
 | 
			
		||||
        mediaMetadata.genres.forEach((genre) => {
 | 
			
		||||
          if (genre && !data.genres.includes(genre)) data.genres.push(genre)
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
      if (li.media.tags.length) {
 | 
			
		||||
        li.media.tags.forEach((tag) => {
 | 
			
		||||
          if (tag && !data.tags.includes(tag)) data.tags.push(tag)
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
      if (mediaMetadata.narrators?.length) {
 | 
			
		||||
        mediaMetadata.narrators.forEach((narrator) => {
 | 
			
		||||
          if (narrator && !data.narrators.includes(narrator)) data.narrators.push(narrator)
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
      if (mediaMetadata.publisher && !data.publishers.includes(mediaMetadata.publisher)) {
 | 
			
		||||
        data.publishers.push(mediaMetadata.publisher)
 | 
			
		||||
      }
 | 
			
		||||
      if (mediaMetadata.language && !data.languages.includes(mediaMetadata.language)) {
 | 
			
		||||
        data.languages.push(mediaMetadata.language)
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
    data.authors = naturalSort(data.authors).asc(au => au.name)
 | 
			
		||||
    data.genres = naturalSort(data.genres).asc()
 | 
			
		||||
    data.tags = naturalSort(data.tags).asc()
 | 
			
		||||
    data.series = naturalSort(data.series).asc(se => se.name)
 | 
			
		||||
    data.narrators = naturalSort(data.narrators).asc()
 | 
			
		||||
    data.publishers = naturalSort(data.publishers).asc()
 | 
			
		||||
    data.languages = naturalSort(data.languages).asc()
 | 
			
		||||
    return data
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  getSeriesFromBooks(books, allSeries, filterSeries, filterBy, user, minified, hideSingleBookSeries) {
 | 
			
		||||
    const _series = {}
 | 
			
		||||
    const seriesToFilterOut = {}
 | 
			
		||||
@ -246,89 +191,6 @@ module.exports = {
 | 
			
		||||
    })
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  getBooksNextInSeries(seriesWithUserAb, limit, minified = false) {
 | 
			
		||||
    var incompleteSeires = seriesWithUserAb.filter((series) => series.books.some((book) => !book.userAudiobook || (!book.userAudiobook.isRead && book.userAudiobook.progress == 0)))
 | 
			
		||||
    var booksNextInSeries = []
 | 
			
		||||
    incompleteSeires.forEach((series) => {
 | 
			
		||||
      var dateLastRead = series.books.filter((data) => data.userAudiobook && data.userAudiobook.isRead).sort((a, b) => { return b.userAudiobook.finishedAt - a.userAudiobook.finishedAt })[0].userAudiobook.finishedAt
 | 
			
		||||
      var nextUnreadBook = series.books.filter((data) => !data.userAudiobook || (!data.userAudiobook.isRead && data.userAudiobook.progress == 0))[0]
 | 
			
		||||
      nextUnreadBook.DateLastReadSeries = dateLastRead
 | 
			
		||||
      booksNextInSeries.push(nextUnreadBook)
 | 
			
		||||
    })
 | 
			
		||||
    return booksNextInSeries.sort((a, b) => { return b.DateLastReadSeries - a.DateLastReadSeries }).map(b => minified ? b.book.toJSONMinified() : b.book.toJSONExpanded()).slice(0, limit)
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  getGenresWithCount(libraryItems) {
 | 
			
		||||
    var genresMap = {}
 | 
			
		||||
    libraryItems.forEach((li) => {
 | 
			
		||||
      var genres = li.media.metadata.genres || []
 | 
			
		||||
      genres.forEach((genre) => {
 | 
			
		||||
        if (genresMap[genre]) genresMap[genre].count++
 | 
			
		||||
        else
 | 
			
		||||
          genresMap[genre] = {
 | 
			
		||||
            genre,
 | 
			
		||||
            count: 1
 | 
			
		||||
          }
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
    return Object.values(genresMap).sort((a, b) => b.count - a.count)
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  getAuthorsWithCount(libraryItems) {
 | 
			
		||||
    var authorsMap = {}
 | 
			
		||||
    libraryItems.forEach((li) => {
 | 
			
		||||
      var authors = li.media.metadata.authors || []
 | 
			
		||||
      authors.forEach((author) => {
 | 
			
		||||
        if (authorsMap[author.id]) authorsMap[author.id].count++
 | 
			
		||||
        else
 | 
			
		||||
          authorsMap[author.id] = {
 | 
			
		||||
            id: author.id,
 | 
			
		||||
            name: author.name,
 | 
			
		||||
            count: 1
 | 
			
		||||
          }
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
    return Object.values(authorsMap).sort((a, b) => b.count - a.count)
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  getItemDurationStats(libraryItems) {
 | 
			
		||||
    var sorted = sort(libraryItems).desc(li => li.media.duration)
 | 
			
		||||
    var top10 = sorted.slice(0, 10).map(li => ({ id: li.id, title: li.media.metadata.title, duration: li.media.duration })).filter(i => i.duration > 0)
 | 
			
		||||
    var totalDuration = 0
 | 
			
		||||
    var numAudioTracks = 0
 | 
			
		||||
    libraryItems.forEach((li) => {
 | 
			
		||||
      totalDuration += li.media.duration
 | 
			
		||||
      numAudioTracks += li.media.numTracks
 | 
			
		||||
    })
 | 
			
		||||
    return {
 | 
			
		||||
      totalDuration,
 | 
			
		||||
      numAudioTracks,
 | 
			
		||||
      longestItems: top10
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  getItemSizeStats(libraryItems) {
 | 
			
		||||
    var sorted = sort(libraryItems).desc(li => li.media.size)
 | 
			
		||||
    var top10 = sorted.slice(0, 10).map(li => ({ id: li.id, title: li.media.metadata.title, size: li.media.size })).filter(i => i.size > 0)
 | 
			
		||||
    var totalSize = 0
 | 
			
		||||
    libraryItems.forEach((li) => {
 | 
			
		||||
      totalSize += li.media.size
 | 
			
		||||
    })
 | 
			
		||||
    return {
 | 
			
		||||
      totalSize,
 | 
			
		||||
      largestItems: top10
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  getLibraryItemsTotalSize(libraryItems) {
 | 
			
		||||
    var totalSize = 0
 | 
			
		||||
    libraryItems.forEach((li) => {
 | 
			
		||||
      totalSize += li.media.size
 | 
			
		||||
    })
 | 
			
		||||
    return totalSize
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  collapseBookSeries(libraryItems, series, filterSeries, hideSingleBookSeries) {
 | 
			
		||||
    // Get series from the library items. If this list is being collapsed after filtering for a series,
 | 
			
		||||
    // don't collapse that series, only books that are in other series.
 | 
			
		||||
@ -356,550 +218,5 @@ module.exports = {
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    return filteredLibraryItems
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  async buildPersonalizedShelves(ctx, user, libraryItems, library, maxEntitiesPerShelf, include) {
 | 
			
		||||
    const mediaType = library.mediaType
 | 
			
		||||
    const isPodcastLibrary = mediaType === 'podcast'
 | 
			
		||||
    const includeRssFeed = include.includes('rssfeed')
 | 
			
		||||
    const includeNumEpisodesIncomplete = include.includes('numepisodesincomplete') // Podcasts only
 | 
			
		||||
    const hideSingleBookSeries = library.settings.hideSingleBookSeries
 | 
			
		||||
 | 
			
		||||
    const shelves = [
 | 
			
		||||
      {
 | 
			
		||||
        id: 'continue-listening',
 | 
			
		||||
        label: 'Continue Listening',
 | 
			
		||||
        labelStringKey: 'LabelContinueListening',
 | 
			
		||||
        type: isPodcastLibrary ? 'episode' : mediaType,
 | 
			
		||||
        entities: []
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        id: 'continue-reading',
 | 
			
		||||
        label: 'Continue Reading',
 | 
			
		||||
        labelStringKey: 'LabelContinueReading',
 | 
			
		||||
        type: 'book',
 | 
			
		||||
        entities: []
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        id: 'continue-series',
 | 
			
		||||
        label: 'Continue Series',
 | 
			
		||||
        labelStringKey: 'LabelContinueSeries',
 | 
			
		||||
        type: mediaType,
 | 
			
		||||
        entities: []
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        id: 'episodes-recently-added',
 | 
			
		||||
        label: 'Newest Episodes',
 | 
			
		||||
        labelStringKey: 'LabelNewestEpisodes',
 | 
			
		||||
        type: 'episode',
 | 
			
		||||
        entities: []
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        id: 'recently-added',
 | 
			
		||||
        label: 'Recently Added',
 | 
			
		||||
        labelStringKey: 'LabelRecentlyAdded',
 | 
			
		||||
        type: mediaType,
 | 
			
		||||
        entities: []
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        id: 'recent-series',
 | 
			
		||||
        label: 'Recent Series',
 | 
			
		||||
        labelStringKey: 'LabelRecentSeries',
 | 
			
		||||
        type: 'series',
 | 
			
		||||
        entities: []
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        id: 'recommended',
 | 
			
		||||
        label: 'Recommended',
 | 
			
		||||
        labelStringKey: 'LabelRecommended',
 | 
			
		||||
        type: mediaType,
 | 
			
		||||
        entities: []
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        id: 'listen-again',
 | 
			
		||||
        label: 'Listen Again',
 | 
			
		||||
        labelStringKey: 'LabelListenAgain',
 | 
			
		||||
        type: isPodcastLibrary ? 'episode' : mediaType,
 | 
			
		||||
        entities: []
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        id: 'read-again',
 | 
			
		||||
        label: 'Read Again',
 | 
			
		||||
        labelStringKey: 'LabelReadAgain',
 | 
			
		||||
        type: 'book',
 | 
			
		||||
        entities: []
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        id: 'newest-authors',
 | 
			
		||||
        label: 'Newest Authors',
 | 
			
		||||
        labelStringKey: 'LabelNewestAuthors',
 | 
			
		||||
        type: 'authors',
 | 
			
		||||
        entities: []
 | 
			
		||||
      }
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    const categoryMap = {}
 | 
			
		||||
    shelves.forEach((shelf) => {
 | 
			
		||||
      categoryMap[shelf.id] = {
 | 
			
		||||
        id: shelf.id,
 | 
			
		||||
        biggest: 0,
 | 
			
		||||
        smallest: 0,
 | 
			
		||||
        items: []
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    const seriesMap = {}
 | 
			
		||||
    const authorMap = {}
 | 
			
		||||
 | 
			
		||||
    // For use with recommended
 | 
			
		||||
    const topGenresListened = {}
 | 
			
		||||
    const topAuthorsListened = {}
 | 
			
		||||
    const topTagsListened = {}
 | 
			
		||||
    const notStartedBooks = []
 | 
			
		||||
 | 
			
		||||
    for (const libraryItem of libraryItems) {
 | 
			
		||||
      if (libraryItem.addedAt > categoryMap['recently-added'].smallest) {
 | 
			
		||||
        const libraryItemObj = libraryItem.toJSONMinified()
 | 
			
		||||
 | 
			
		||||
        // add numEpisodesIncomplete if "include=numEpisodesIncomplete" was put in query string (only for podcasts)
 | 
			
		||||
        if (includeNumEpisodesIncomplete && libraryItem.isPodcast) {
 | 
			
		||||
          libraryItemObj.numEpisodesIncomplete = user.getNumEpisodesIncompleteForPodcast(libraryItem)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const indexToPut = categoryMap['recently-added'].items.findIndex(i => libraryItem.addedAt > i.addedAt)
 | 
			
		||||
        if (indexToPut >= 0) {
 | 
			
		||||
          categoryMap['recently-added'].items.splice(indexToPut, 0, libraryItemObj)
 | 
			
		||||
        } else {
 | 
			
		||||
          categoryMap['recently-added'].items.push(libraryItemObj)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (categoryMap['recently-added'].items.length > maxEntitiesPerShelf) {
 | 
			
		||||
          // Remove last item
 | 
			
		||||
          categoryMap['recently-added'].items.pop()
 | 
			
		||||
          categoryMap['recently-added'].smallest = categoryMap['recently-added'].items[categoryMap['recently-added'].items.length - 1].addedAt
 | 
			
		||||
        }
 | 
			
		||||
        categoryMap['recently-added'].biggest = categoryMap['recently-added'].items[0].addedAt
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const allItemProgress = user.getAllMediaProgressForLibraryItem(libraryItem.id)
 | 
			
		||||
      if (libraryItem.isPodcast) {
 | 
			
		||||
        // Podcast categories
 | 
			
		||||
        const podcastEpisodes = libraryItem.media.episodes || []
 | 
			
		||||
        for (const episode of podcastEpisodes) {
 | 
			
		||||
          const mediaProgress = allItemProgress.find(mp => mp.episodeId === episode.id)
 | 
			
		||||
 | 
			
		||||
          // Newest episodes
 | 
			
		||||
          if (!mediaProgress?.isFinished && episode.addedAt > categoryMap['episodes-recently-added'].smallest) {
 | 
			
		||||
            const libraryItemWithEpisode = {
 | 
			
		||||
              ...libraryItem.toJSONMinified(),
 | 
			
		||||
              recentEpisode: episode.toJSON()
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const indexToPut = categoryMap['episodes-recently-added'].items.findIndex(i => episode.addedAt > i.recentEpisode.addedAt)
 | 
			
		||||
            if (indexToPut >= 0) {
 | 
			
		||||
              categoryMap['episodes-recently-added'].items.splice(indexToPut, 0, libraryItemWithEpisode)
 | 
			
		||||
            } else {
 | 
			
		||||
              categoryMap['episodes-recently-added'].items.push(libraryItemWithEpisode)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (categoryMap['episodes-recently-added'].items.length > maxEntitiesPerShelf) {
 | 
			
		||||
              // Remove last item
 | 
			
		||||
              categoryMap['episodes-recently-added'].items.pop()
 | 
			
		||||
              categoryMap['episodes-recently-added'].smallest = categoryMap['episodes-recently-added'].items[categoryMap['episodes-recently-added'].items.length - 1].recentEpisode.addedAt
 | 
			
		||||
            }
 | 
			
		||||
            categoryMap['episodes-recently-added'].biggest = categoryMap['episodes-recently-added'].items[0].recentEpisode.addedAt
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          // Episode recently listened and finished
 | 
			
		||||
          if (mediaProgress) {
 | 
			
		||||
            if (mediaProgress.isFinished) {
 | 
			
		||||
              if (mediaProgress.finishedAt > categoryMap['listen-again'].smallest) { // Item belongs on shelf
 | 
			
		||||
                const libraryItemWithEpisode = {
 | 
			
		||||
                  ...libraryItem.toJSONMinified(),
 | 
			
		||||
                  recentEpisode: episode.toJSON(),
 | 
			
		||||
                  finishedAt: mediaProgress.finishedAt
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                const indexToPut = categoryMap['listen-again'].items.findIndex(i => mediaProgress.finishedAt > i.finishedAt)
 | 
			
		||||
                if (indexToPut >= 0) {
 | 
			
		||||
                  categoryMap['listen-again'].items.splice(indexToPut, 0, libraryItemWithEpisode)
 | 
			
		||||
                } else {
 | 
			
		||||
                  categoryMap['listen-again'].items.push(libraryItemWithEpisode)
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (categoryMap['listen-again'].items.length > maxEntitiesPerShelf) {
 | 
			
		||||
                  // Remove last item
 | 
			
		||||
                  categoryMap['listen-again'].items.pop()
 | 
			
		||||
                  categoryMap['listen-again'].smallest = categoryMap['listen-again'].items[categoryMap['listen-again'].items.length - 1].finishedAt
 | 
			
		||||
                }
 | 
			
		||||
                categoryMap['listen-again'].biggest = categoryMap['listen-again'].items[0].finishedAt
 | 
			
		||||
              }
 | 
			
		||||
            } else if (mediaProgress.inProgress && !mediaProgress.hideFromContinueListening) { // Handle most recently listened
 | 
			
		||||
              if (mediaProgress.lastUpdate > categoryMap['continue-listening'].smallest) { // Item belongs on shelf
 | 
			
		||||
                const libraryItemWithEpisode = {
 | 
			
		||||
                  ...libraryItem.toJSONMinified(),
 | 
			
		||||
                  recentEpisode: episode.toJSON(),
 | 
			
		||||
                  progressLastUpdate: mediaProgress.lastUpdate
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                const indexToPut = categoryMap['continue-listening'].items.findIndex(i => mediaProgress.lastUpdate > i.progressLastUpdate)
 | 
			
		||||
                if (indexToPut >= 0) {
 | 
			
		||||
                  categoryMap['continue-listening'].items.splice(indexToPut, 0, libraryItemWithEpisode)
 | 
			
		||||
                } else {
 | 
			
		||||
                  categoryMap['continue-listening'].items.push(libraryItemWithEpisode)
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (categoryMap['continue-listening'].items.length > maxEntitiesPerShelf) {
 | 
			
		||||
                  // Remove last item
 | 
			
		||||
                  categoryMap['continue-listening'].items.pop()
 | 
			
		||||
                  categoryMap['continue-listening'].smallest = categoryMap['continue-listening'].items[categoryMap['continue-listening'].items.length - 1].progressLastUpdate
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                categoryMap['continue-listening'].biggest = categoryMap['continue-listening'].items[0].progressLastUpdate
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      } else if (libraryItem.isBook) {
 | 
			
		||||
        // Book categories
 | 
			
		||||
 | 
			
		||||
        const mediaProgress = allItemProgress.length ? allItemProgress[0] : null
 | 
			
		||||
 | 
			
		||||
        // Used for recommended. Tally up most listened to authors/genres/tags
 | 
			
		||||
        if (mediaProgress && (mediaProgress.inProgress || mediaProgress.isFinished)) {
 | 
			
		||||
          libraryItem.media.metadata.authors.forEach((author) => {
 | 
			
		||||
            topAuthorsListened[author.id] = (topAuthorsListened[author.id] || 0) + 1
 | 
			
		||||
          })
 | 
			
		||||
          libraryItem.media.metadata.genres.forEach((genre) => {
 | 
			
		||||
            topGenresListened[genre] = (topGenresListened[genre] || 0) + 1
 | 
			
		||||
          })
 | 
			
		||||
          libraryItem.media.tags.forEach((tag) => {
 | 
			
		||||
            topTagsListened[tag] = (topTagsListened[tag] || 0) + 1
 | 
			
		||||
          })
 | 
			
		||||
        } else {
 | 
			
		||||
          // Insert in random position to add randomization to equal weighted items
 | 
			
		||||
          notStartedBooks.splice(Math.floor(Math.random() * (notStartedBooks.length + 1)), 0, libraryItem)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Newest series
 | 
			
		||||
        if (libraryItem.media.metadata.series.length) {
 | 
			
		||||
          for (const librarySeries of libraryItem.media.metadata.series) {
 | 
			
		||||
 | 
			
		||||
            const bookInProgress = mediaProgress && (mediaProgress.inProgress || mediaProgress.isFinished)
 | 
			
		||||
            const bookActive = mediaProgress && mediaProgress.inProgress && !mediaProgress.isFinished
 | 
			
		||||
            const libraryItemJson = libraryItem.toJSONMinified()
 | 
			
		||||
            libraryItemJson.seriesSequence = librarySeries.sequence
 | 
			
		||||
 | 
			
		||||
            const hideFromContinueListening = user.checkShouldHideSeriesFromContinueListening(librarySeries.id)
 | 
			
		||||
 | 
			
		||||
            if (!seriesMap[librarySeries.id]) {
 | 
			
		||||
              const seriesObj = Database.series.find(se => se.id === librarySeries.id)
 | 
			
		||||
              if (seriesObj) {
 | 
			
		||||
                const series = {
 | 
			
		||||
                  ...seriesObj.toJSON(),
 | 
			
		||||
                  books: [libraryItemJson],
 | 
			
		||||
                  inProgress: bookInProgress,
 | 
			
		||||
                  hasActiveBook: bookActive,
 | 
			
		||||
                  hideFromContinueListening,
 | 
			
		||||
                  bookInProgressLastUpdate: bookInProgress ? mediaProgress.lastUpdate : null,
 | 
			
		||||
                  firstBookUnread: bookInProgress ? null : libraryItemJson
 | 
			
		||||
                }
 | 
			
		||||
                seriesMap[librarySeries.id] = series
 | 
			
		||||
 | 
			
		||||
                const indexToPut = categoryMap['recent-series'].items.findIndex(i => series.addedAt > i.addedAt)
 | 
			
		||||
                if (indexToPut >= 0) {
 | 
			
		||||
                  categoryMap['recent-series'].items.splice(indexToPut, 0, series)
 | 
			
		||||
                } else {
 | 
			
		||||
                  categoryMap['recent-series'].items.push(series)
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
            } else {
 | 
			
		||||
              // series already in map - add book
 | 
			
		||||
              seriesMap[librarySeries.id].books.push(libraryItemJson)
 | 
			
		||||
 | 
			
		||||
              if (bookInProgress) { // Update if this series is in progress
 | 
			
		||||
                seriesMap[librarySeries.id].inProgress = true
 | 
			
		||||
 | 
			
		||||
                if (seriesMap[librarySeries.id].bookInProgressLastUpdate < mediaProgress.lastUpdate) {
 | 
			
		||||
                  seriesMap[librarySeries.id].bookInProgressLastUpdate = mediaProgress.lastUpdate
 | 
			
		||||
                }
 | 
			
		||||
              } else if (!seriesMap[librarySeries.id].firstBookUnread) {
 | 
			
		||||
                seriesMap[librarySeries.id].firstBookUnread = libraryItemJson
 | 
			
		||||
              } else if (libraryItemJson.seriesSequence) {
 | 
			
		||||
                // If current firstBookUnread has a series sequence greater than this series sequence, then update firstBookUnread
 | 
			
		||||
                const firstBookUnreadSequence = seriesMap[librarySeries.id].firstBookUnread.seriesSequence
 | 
			
		||||
                if (!firstBookUnreadSequence || String(firstBookUnreadSequence).localeCompare(String(librarySeries.sequence), undefined, { sensitivity: 'base', numeric: true }) > 0) {
 | 
			
		||||
                  seriesMap[librarySeries.id].firstBookUnread = libraryItemJson
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              // Update if series has an active (progress < 100%) book
 | 
			
		||||
              if (bookActive) {
 | 
			
		||||
                seriesMap[librarySeries.id].hasActiveBook = true
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Newest authors
 | 
			
		||||
        if (libraryItem.media.metadata.authors.length) {
 | 
			
		||||
          for (const libraryAuthor of libraryItem.media.metadata.authors) {
 | 
			
		||||
            if (!authorMap[libraryAuthor.id]) {
 | 
			
		||||
              const authorObj = Database.authors.find(au => au.id === libraryAuthor.id)
 | 
			
		||||
              if (authorObj) {
 | 
			
		||||
                const author = {
 | 
			
		||||
                  ...authorObj.toJSON(),
 | 
			
		||||
                  numBooks: 1
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (author.addedAt > categoryMap['newest-authors'].smallest) {
 | 
			
		||||
 | 
			
		||||
                  const indexToPut = categoryMap['newest-authors'].items.findIndex(i => author.addedAt > i.addedAt)
 | 
			
		||||
                  if (indexToPut >= 0) {
 | 
			
		||||
                    categoryMap['newest-authors'].items.splice(indexToPut, 0, author)
 | 
			
		||||
                  } else {
 | 
			
		||||
                    categoryMap['newest-authors'].items.push(author)
 | 
			
		||||
                  }
 | 
			
		||||
 | 
			
		||||
                  // Max authors is 10
 | 
			
		||||
                  if (categoryMap['newest-authors'].items.length > 10) {
 | 
			
		||||
                    categoryMap['newest-authors'].items.pop()
 | 
			
		||||
                    categoryMap['newest-authors'].smallest = categoryMap['newest-authors'].items[categoryMap['newest-authors'].items.length - 1].addedAt
 | 
			
		||||
                  }
 | 
			
		||||
 | 
			
		||||
                  categoryMap['newest-authors'].biggest = categoryMap['newest-authors'].items[0].addedAt
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                authorMap[libraryAuthor.id] = author
 | 
			
		||||
              }
 | 
			
		||||
            } else {
 | 
			
		||||
              authorMap[libraryAuthor.id].numBooks++
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Book listening and finished
 | 
			
		||||
        if (mediaProgress) {
 | 
			
		||||
          const categoryId = libraryItem.media.isEBookOnly ? 'read-again' : 'listen-again'
 | 
			
		||||
 | 
			
		||||
          // Handle most recently finished
 | 
			
		||||
          if (mediaProgress.isFinished) {
 | 
			
		||||
            if (mediaProgress.finishedAt > categoryMap[categoryId].smallest) { // Item belongs on shelf
 | 
			
		||||
              const libraryItemObj = {
 | 
			
		||||
                ...libraryItem.toJSONMinified(),
 | 
			
		||||
                finishedAt: mediaProgress.finishedAt
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              const indexToPut = categoryMap[categoryId].items.findIndex(i => mediaProgress.finishedAt > i.finishedAt)
 | 
			
		||||
              if (indexToPut >= 0) {
 | 
			
		||||
                categoryMap[categoryId].items.splice(indexToPut, 0, libraryItemObj)
 | 
			
		||||
              } else {
 | 
			
		||||
                categoryMap[categoryId].items.push(libraryItemObj)
 | 
			
		||||
              }
 | 
			
		||||
              if (categoryMap[categoryId].items.length > maxEntitiesPerShelf) {
 | 
			
		||||
                // Remove last item
 | 
			
		||||
                categoryMap[categoryId].items.pop()
 | 
			
		||||
                categoryMap[categoryId].smallest = categoryMap[categoryId].items[categoryMap[categoryId].items.length - 1].finishedAt
 | 
			
		||||
              }
 | 
			
		||||
              categoryMap[categoryId].biggest = categoryMap[categoryId].items[0].finishedAt
 | 
			
		||||
            }
 | 
			
		||||
          } else if (mediaProgress.inProgress && !mediaProgress.hideFromContinueListening) { // Handle most recently listened
 | 
			
		||||
            const categoryId = libraryItem.media.isEBookOnly ? 'continue-reading' : 'continue-listening'
 | 
			
		||||
 | 
			
		||||
            if (mediaProgress.lastUpdate > categoryMap[categoryId].smallest) { // Item belongs on shelf
 | 
			
		||||
              const libraryItemObj = {
 | 
			
		||||
                ...libraryItem.toJSONMinified(),
 | 
			
		||||
                progressLastUpdate: mediaProgress.lastUpdate
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              const indexToPut = categoryMap[categoryId].items.findIndex(i => mediaProgress.lastUpdate > i.progressLastUpdate)
 | 
			
		||||
              if (indexToPut >= 0) {
 | 
			
		||||
                categoryMap[categoryId].items.splice(indexToPut, 0, libraryItemObj)
 | 
			
		||||
              } else { // Should only happen when array is < max
 | 
			
		||||
                categoryMap[categoryId].items.push(libraryItemObj)
 | 
			
		||||
              }
 | 
			
		||||
              if (categoryMap[categoryId].items.length > maxEntitiesPerShelf) {
 | 
			
		||||
                // Remove last item
 | 
			
		||||
                categoryMap[categoryId].items.pop()
 | 
			
		||||
                categoryMap[categoryId].smallest = categoryMap[categoryId].items[categoryMap[categoryId].items.length - 1].progressLastUpdate
 | 
			
		||||
              }
 | 
			
		||||
              categoryMap[categoryId].biggest = categoryMap[categoryId].items[0].progressLastUpdate
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // For Continue Series - Find next book in series for series that are in progress
 | 
			
		||||
    for (const seriesId in seriesMap) {
 | 
			
		||||
      seriesMap[seriesId].books = naturalSort(seriesMap[seriesId].books).asc(li => li.seriesSequence)
 | 
			
		||||
 | 
			
		||||
      if (seriesMap[seriesId].inProgress && !seriesMap[seriesId].hideFromContinueListening) {
 | 
			
		||||
        // take the first book unread with the smallest series sequence
 | 
			
		||||
        // unless the user is already listening to a book from this series
 | 
			
		||||
        const hasActiveBook = seriesMap[seriesId].hasActiveBook
 | 
			
		||||
        const nextBookInSeries = seriesMap[seriesId].firstBookUnread
 | 
			
		||||
 | 
			
		||||
        if (!hasActiveBook && nextBookInSeries) {
 | 
			
		||||
          const bookForContinueSeries = {
 | 
			
		||||
            ...nextBookInSeries,
 | 
			
		||||
            prevBookInProgressLastUpdate: seriesMap[seriesId].bookInProgressLastUpdate
 | 
			
		||||
          }
 | 
			
		||||
          bookForContinueSeries.media.metadata.series = {
 | 
			
		||||
            id: seriesId,
 | 
			
		||||
            name: seriesMap[seriesId].name,
 | 
			
		||||
            sequence: nextBookInSeries.seriesSequence
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          const indexToPut = categoryMap['continue-series'].items.findIndex(i => i.prevBookInProgressLastUpdate < bookForContinueSeries.prevBookInProgressLastUpdate)
 | 
			
		||||
          if (!categoryMap['continue-series'].items.find(book => book.id === bookForContinueSeries.id)) {
 | 
			
		||||
            if (indexToPut >= 0) {
 | 
			
		||||
              categoryMap['continue-series'].items.splice(indexToPut, 0, bookForContinueSeries)
 | 
			
		||||
            } else if (categoryMap['continue-series'].items.length < 10) { // Max 10 books
 | 
			
		||||
              categoryMap['continue-series'].items.push(bookForContinueSeries)
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // For recommended
 | 
			
		||||
    if (!isPodcastLibrary && notStartedBooks.length) {
 | 
			
		||||
      const genresCount = Object.values(topGenresListened).reduce((a, b) => a + b, 0)
 | 
			
		||||
      const authorsCount = Object.values(topAuthorsListened).reduce((a, b) => a + b, 0)
 | 
			
		||||
      const tagsCount = Object.values(topTagsListened).reduce((a, b) => a + b, 0)
 | 
			
		||||
 | 
			
		||||
      for (const libraryItem of notStartedBooks) {
 | 
			
		||||
        // dont include books in an unfinished series and books that are not first in an unstarted series
 | 
			
		||||
        let shouldContinue = !libraryItem.media.metadata.series.length
 | 
			
		||||
        libraryItem.media.metadata.series.forEach((se) => {
 | 
			
		||||
          if (seriesMap[se.id]) {
 | 
			
		||||
            if (seriesMap[se.id].inProgress) {
 | 
			
		||||
              shouldContinue = false
 | 
			
		||||
              return
 | 
			
		||||
            } else if (seriesMap[se.id].books[0].id === libraryItem.id) {
 | 
			
		||||
              shouldContinue = true
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
        if (!shouldContinue) {
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let totalWeight = 0
 | 
			
		||||
 | 
			
		||||
        if (authorsCount > 0) {
 | 
			
		||||
          libraryItem.media.metadata.authors.forEach((author) => {
 | 
			
		||||
            if (topAuthorsListened[author.id]) {
 | 
			
		||||
              totalWeight += topAuthorsListened[author.id] / authorsCount
 | 
			
		||||
            }
 | 
			
		||||
          })
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (genresCount > 0) {
 | 
			
		||||
          libraryItem.media.metadata.genres.forEach((genre) => {
 | 
			
		||||
            if (topGenresListened[genre]) {
 | 
			
		||||
              totalWeight += topGenresListened[genre] / genresCount
 | 
			
		||||
            }
 | 
			
		||||
          })
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (tagsCount > 0) {
 | 
			
		||||
          libraryItem.media.tags.forEach((tag) => {
 | 
			
		||||
            if (topTagsListened[tag]) {
 | 
			
		||||
              totalWeight += topTagsListened[tag] / tagsCount
 | 
			
		||||
            }
 | 
			
		||||
          })
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!categoryMap.recommended.smallest || totalWeight > categoryMap.recommended.smallest) {
 | 
			
		||||
          const libraryItemObj = {
 | 
			
		||||
            ...libraryItem.toJSONMinified(),
 | 
			
		||||
            weight: totalWeight
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          const indexToPut = categoryMap.recommended.items.findIndex(i => totalWeight > i.weight)
 | 
			
		||||
          if (indexToPut >= 0) {
 | 
			
		||||
            categoryMap.recommended.items.splice(indexToPut, 0, libraryItemObj)
 | 
			
		||||
          } else {
 | 
			
		||||
            categoryMap.recommended.items.push(libraryItemObj)
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          if (categoryMap.recommended.items.length > maxEntitiesPerShelf) {
 | 
			
		||||
            categoryMap.recommended.items.pop()
 | 
			
		||||
            categoryMap.recommended.smallest = categoryMap.recommended.items[categoryMap.recommended.items.length - 1].weight
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Sort series books by sequence
 | 
			
		||||
    if (categoryMap['recent-series'].items.length) {
 | 
			
		||||
      if (hideSingleBookSeries) {
 | 
			
		||||
        categoryMap['recent-series'].items = categoryMap['recent-series'].items.filter(seriesItem => seriesItem.books.length > 1)
 | 
			
		||||
      }
 | 
			
		||||
      // Limit series shown to 5
 | 
			
		||||
      categoryMap['recent-series'].items = categoryMap['recent-series'].items.slice(0, 5)
 | 
			
		||||
 | 
			
		||||
      for (const seriesItem of categoryMap['recent-series'].items) {
 | 
			
		||||
        seriesItem.books = naturalSort(seriesItem.books).asc(li => li.seriesSequence)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const categoriesWithItems = Object.values(categoryMap).filter(cat => cat.items.length)
 | 
			
		||||
 | 
			
		||||
    const finalShelves = []
 | 
			
		||||
    for (const categoryWithItems of categoriesWithItems) {
 | 
			
		||||
      const shelf = shelves.find(s => s.id === categoryWithItems.id)
 | 
			
		||||
      shelf.entities = categoryWithItems.items
 | 
			
		||||
 | 
			
		||||
      // Add rssFeed to entities if query string "include=rssfeed" was on request
 | 
			
		||||
      if (includeRssFeed) {
 | 
			
		||||
        if (shelf.type === 'book' || shelf.type === 'podcast') {
 | 
			
		||||
          shelf.entities = await Promise.all(shelf.entities.map(async (item) => {
 | 
			
		||||
            const feed = await ctx.rssFeedManager.findFeedForEntityId(item.id)
 | 
			
		||||
            item.rssFeed = feed?.toJSONMinified() || null
 | 
			
		||||
            return item
 | 
			
		||||
          }))
 | 
			
		||||
        } else if (shelf.type === 'series') {
 | 
			
		||||
          shelf.entities = await Promise.all(shelf.entities.map(async (series) => {
 | 
			
		||||
            const feed = await ctx.rssFeedManager.findFeedForEntityId(series.id)
 | 
			
		||||
            series.rssFeed = feed?.toJSONMinified() || null
 | 
			
		||||
            return series
 | 
			
		||||
          }))
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      finalShelves.push(shelf)
 | 
			
		||||
    }
 | 
			
		||||
    return finalShelves
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  groupMusicLibraryItemsIntoAlbums(libraryItems) {
 | 
			
		||||
    const albums = {}
 | 
			
		||||
 | 
			
		||||
    libraryItems.forEach((li) => {
 | 
			
		||||
      const albumTitle = li.media.metadata.album
 | 
			
		||||
      const albumArtist = li.media.metadata.albumArtist
 | 
			
		||||
 | 
			
		||||
      if (albumTitle && !albums[albumTitle]) {
 | 
			
		||||
        albums[albumTitle] = {
 | 
			
		||||
          title: albumTitle,
 | 
			
		||||
          artist: albumArtist,
 | 
			
		||||
          libraryItemId: li.media.coverPath ? li.id : null,
 | 
			
		||||
          numTracks: 1
 | 
			
		||||
        }
 | 
			
		||||
      } else if (albumTitle && albums[albumTitle].artist === albumArtist) {
 | 
			
		||||
        if (!albums[albumTitle].libraryItemId && li.media.coverPath) albums[albumTitle].libraryItemId = li.id
 | 
			
		||||
        albums[albumTitle].numTracks++
 | 
			
		||||
      } else {
 | 
			
		||||
        if (albumTitle) {
 | 
			
		||||
          Logger.warn(`Music track "${li.media.metadata.title}" with album "${albumTitle}" has a different album artist then another track in the same album.  This track album artist is "${albumArtist}" but the album artist is already set to "${albums[albumTitle].artist}"`)
 | 
			
		||||
        }
 | 
			
		||||
        if (!albums['_none_']) albums['_none_'] = { title: 'No Album', artist: 'Various Artists', libraryItemId: null, numTracks: 0 }
 | 
			
		||||
        albums['_none_'].numTracks++
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    return Object.values(albums)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										70
									
								
								server/utils/queries/authorFilters.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								server/utils/queries/authorFilters.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,70 @@
 | 
			
		||||
const Sequelize = require('sequelize')
 | 
			
		||||
const Database = require('../../Database')
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
  /**
 | 
			
		||||
   * Get authors with count of num books
 | 
			
		||||
   * @param {string} libraryId 
 | 
			
		||||
   * @returns {{id:string, name:string, count:number}}
 | 
			
		||||
   */
 | 
			
		||||
  async getAuthorsWithCount(libraryId) {
 | 
			
		||||
    const authors = await Database.authorModel.findAll({
 | 
			
		||||
      where: [
 | 
			
		||||
        {
 | 
			
		||||
          libraryId
 | 
			
		||||
        },
 | 
			
		||||
        Sequelize.where(Sequelize.literal('count'), {
 | 
			
		||||
          [Sequelize.Op.gt]: 0
 | 
			
		||||
        })
 | 
			
		||||
      ],
 | 
			
		||||
      attributes: [
 | 
			
		||||
        'id',
 | 
			
		||||
        'name',
 | 
			
		||||
        [Sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 'count']
 | 
			
		||||
      ],
 | 
			
		||||
      order: [
 | 
			
		||||
        ['count', 'DESC']
 | 
			
		||||
      ]
 | 
			
		||||
    })
 | 
			
		||||
    return authors.map(au => {
 | 
			
		||||
      return {
 | 
			
		||||
        id: au.id,
 | 
			
		||||
        name: au.name,
 | 
			
		||||
        count: au.dataValues.count
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Search authors
 | 
			
		||||
   * @param {string} libraryId 
 | 
			
		||||
   * @param {string} query 
 | 
			
		||||
   * @param {number} limit
 | 
			
		||||
   * @param {number} offset
 | 
			
		||||
   * @returns {object[]} oldAuthor with numBooks
 | 
			
		||||
   */
 | 
			
		||||
  async search(libraryId, query, limit, offset) {
 | 
			
		||||
    const authors = await Database.authorModel.findAll({
 | 
			
		||||
      where: {
 | 
			
		||||
        name: {
 | 
			
		||||
          [Sequelize.Op.substring]: query
 | 
			
		||||
        },
 | 
			
		||||
        libraryId
 | 
			
		||||
      },
 | 
			
		||||
      attributes: {
 | 
			
		||||
        include: [
 | 
			
		||||
          [Sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 'numBooks']
 | 
			
		||||
        ]
 | 
			
		||||
      },
 | 
			
		||||
      limit,
 | 
			
		||||
      offset
 | 
			
		||||
    })
 | 
			
		||||
    const authorMatches = []
 | 
			
		||||
    for (const author of authors) {
 | 
			
		||||
      const oldAuthor = author.getOldAuthor().toJSON()
 | 
			
		||||
      oldAuthor.numBooks = author.dataValues.numBooks
 | 
			
		||||
      authorMatches.push(oldAuthor)
 | 
			
		||||
    }
 | 
			
		||||
    return authorMatches
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -15,8 +15,8 @@ module.exports = {
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get library items using filter and sort
 | 
			
		||||
   * @param {oldLibrary} library 
 | 
			
		||||
   * @param {oldUser} user 
 | 
			
		||||
   * @param {import('../../objects/Library')} library 
 | 
			
		||||
   * @param {import('../../objects/user/User')} user 
 | 
			
		||||
   * @param {object} options 
 | 
			
		||||
   * @returns {object} { libraryItems:LibraryItem[], count:number }
 | 
			
		||||
   */
 | 
			
		||||
@ -41,20 +41,20 @@ module.exports = {
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get library items for continue listening & continue reading shelves
 | 
			
		||||
   * @param {oldLibrary} library 
 | 
			
		||||
   * @param {oldUser} user 
 | 
			
		||||
   * @param {import('../../objects/Library')} library 
 | 
			
		||||
   * @param {import('../../objects/user/User')} user 
 | 
			
		||||
   * @param {string[]} include 
 | 
			
		||||
   * @param {number} limit 
 | 
			
		||||
   * @returns {object} { items:LibraryItem[], count:number }
 | 
			
		||||
   * @returns {Promise<{ items:import('../../models/LibraryItem')[], count:number }>}
 | 
			
		||||
   */
 | 
			
		||||
  async getMediaItemsInProgress(library, user, include, limit) {
 | 
			
		||||
    if (library.mediaType === 'book') {
 | 
			
		||||
      const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', 'in-progress', 'progress', true, false, include, limit, 0)
 | 
			
		||||
      return {
 | 
			
		||||
        items: libraryItems.map(li => {
 | 
			
		||||
          const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified()
 | 
			
		||||
          const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
 | 
			
		||||
          if (li.rssFeed) {
 | 
			
		||||
            oldLibraryItem.rssFeed = Database.models.feed.getOldFeed(li.rssFeed).toJSONMinified()
 | 
			
		||||
            oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
 | 
			
		||||
          }
 | 
			
		||||
          return oldLibraryItem
 | 
			
		||||
        }),
 | 
			
		||||
@ -65,7 +65,7 @@ module.exports = {
 | 
			
		||||
      return {
 | 
			
		||||
        count,
 | 
			
		||||
        items: libraryItems.map(li => {
 | 
			
		||||
          const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified()
 | 
			
		||||
          const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
 | 
			
		||||
          oldLibraryItem.recentEpisode = li.recentEpisode
 | 
			
		||||
          return oldLibraryItem
 | 
			
		||||
        })
 | 
			
		||||
@ -86,9 +86,9 @@ module.exports = {
 | 
			
		||||
      const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'recent', null, 'addedAt', true, false, include, limit, 0)
 | 
			
		||||
      return {
 | 
			
		||||
        libraryItems: libraryItems.map(li => {
 | 
			
		||||
          const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified()
 | 
			
		||||
          const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
 | 
			
		||||
          if (li.rssFeed) {
 | 
			
		||||
            oldLibraryItem.rssFeed = Database.models.feed.getOldFeed(li.rssFeed).toJSONMinified()
 | 
			
		||||
            oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
 | 
			
		||||
          }
 | 
			
		||||
          if (li.size && !oldLibraryItem.media.size) {
 | 
			
		||||
            oldLibraryItem.media.size = li.size
 | 
			
		||||
@ -101,9 +101,9 @@ module.exports = {
 | 
			
		||||
      const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredLibraryItems(library.id, user, 'recent', null, 'addedAt', true, include, limit, 0)
 | 
			
		||||
      return {
 | 
			
		||||
        libraryItems: libraryItems.map(li => {
 | 
			
		||||
          const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified()
 | 
			
		||||
          const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
 | 
			
		||||
          if (li.rssFeed) {
 | 
			
		||||
            oldLibraryItem.rssFeed = Database.models.feed.getOldFeed(li.rssFeed).toJSONMinified()
 | 
			
		||||
            oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
 | 
			
		||||
          }
 | 
			
		||||
          if (li.size && !oldLibraryItem.media.size) {
 | 
			
		||||
            oldLibraryItem.media.size = li.size
 | 
			
		||||
@ -127,9 +127,9 @@ module.exports = {
 | 
			
		||||
    const { libraryItems, count } = await libraryItemsBookFilters.getContinueSeriesLibraryItems(library.id, user, include, limit, 0)
 | 
			
		||||
    return {
 | 
			
		||||
      libraryItems: libraryItems.map(li => {
 | 
			
		||||
        const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified()
 | 
			
		||||
        const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
 | 
			
		||||
        if (li.rssFeed) {
 | 
			
		||||
          oldLibraryItem.rssFeed = Database.models.feed.getOldFeed(li.rssFeed).toJSONMinified()
 | 
			
		||||
          oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
 | 
			
		||||
        }
 | 
			
		||||
        if (li.series) {
 | 
			
		||||
          oldLibraryItem.media.metadata.series = li.series
 | 
			
		||||
@ -153,9 +153,9 @@ module.exports = {
 | 
			
		||||
      const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', 'finished', 'progress', true, false, include, limit, 0)
 | 
			
		||||
      return {
 | 
			
		||||
        items: libraryItems.map(li => {
 | 
			
		||||
          const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified()
 | 
			
		||||
          const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
 | 
			
		||||
          if (li.rssFeed) {
 | 
			
		||||
            oldLibraryItem.rssFeed = Database.models.feed.getOldFeed(li.rssFeed).toJSONMinified()
 | 
			
		||||
            oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
 | 
			
		||||
          }
 | 
			
		||||
          return oldLibraryItem
 | 
			
		||||
        }),
 | 
			
		||||
@ -166,7 +166,7 @@ module.exports = {
 | 
			
		||||
      return {
 | 
			
		||||
        count,
 | 
			
		||||
        items: libraryItems.map(li => {
 | 
			
		||||
          const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified()
 | 
			
		||||
          const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
 | 
			
		||||
          oldLibraryItem.recentEpisode = li.recentEpisode
 | 
			
		||||
          return oldLibraryItem
 | 
			
		||||
        })
 | 
			
		||||
@ -176,19 +176,19 @@ module.exports = {
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get series for recent series shelf
 | 
			
		||||
   * @param {oldLibrary} library 
 | 
			
		||||
   * @param {oldUser} user
 | 
			
		||||
   * @param {import('../../objects/Library')} library 
 | 
			
		||||
   * @param {import('../../objects/user/User')} user
 | 
			
		||||
   * @param {string[]} include 
 | 
			
		||||
   * @param {number} limit 
 | 
			
		||||
   * @returns {object} { series:oldSeries[], count:number}
 | 
			
		||||
   * @returns {{ series:import('../../objects/entities/Series')[], count:number}} 
 | 
			
		||||
   */
 | 
			
		||||
  async getSeriesMostRecentlyAdded(library, user, include, limit) {
 | 
			
		||||
    if (library.mediaType !== 'book') return { series: [], count: 0 }
 | 
			
		||||
    if (!library.isBook) return { series: [], count: 0 }
 | 
			
		||||
 | 
			
		||||
    const seriesIncludes = []
 | 
			
		||||
    if (include.includes('rssfeed')) {
 | 
			
		||||
      seriesIncludes.push({
 | 
			
		||||
        model: Database.models.feed
 | 
			
		||||
        model: Database.feedModel
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -221,7 +221,7 @@ module.exports = {
 | 
			
		||||
      }))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const { rows: series, count } = await Database.models.series.findAndCountAll({
 | 
			
		||||
    const { rows: series, count } = await Database.seriesModel.findAndCountAll({
 | 
			
		||||
      where: seriesWhere,
 | 
			
		||||
      limit,
 | 
			
		||||
      offset: 0,
 | 
			
		||||
@ -230,12 +230,12 @@ module.exports = {
 | 
			
		||||
      replacements: userPermissionBookWhere.replacements,
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.bookSeries,
 | 
			
		||||
          model: Database.bookSeriesModel,
 | 
			
		||||
          include: {
 | 
			
		||||
            model: Database.models.book,
 | 
			
		||||
            model: Database.bookModel,
 | 
			
		||||
            where: userPermissionBookWhere.bookWhere,
 | 
			
		||||
            include: {
 | 
			
		||||
              model: Database.models.libraryItem
 | 
			
		||||
              model: Database.libraryItemModel
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          separate: true
 | 
			
		||||
@ -252,7 +252,7 @@ module.exports = {
 | 
			
		||||
      const oldSeries = s.getOldSeries().toJSON()
 | 
			
		||||
 | 
			
		||||
      if (s.feeds?.length) {
 | 
			
		||||
        oldSeries.rssFeed = Database.models.feed.getOldFeed(s.feeds[0]).toJSONMinified()
 | 
			
		||||
        oldSeries.rssFeed = Database.feedModel.getOldFeed(s.feeds[0]).toJSONMinified()
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // TODO: Sort books by sequence in query
 | 
			
		||||
@ -268,7 +268,7 @@ module.exports = {
 | 
			
		||||
        const libraryItem = bs.book.libraryItem.toJSON()
 | 
			
		||||
        delete bs.book.libraryItem
 | 
			
		||||
        libraryItem.media = bs.book
 | 
			
		||||
        const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem).toJSONMinified()
 | 
			
		||||
        const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONMinified()
 | 
			
		||||
        return oldLibraryItem
 | 
			
		||||
      })
 | 
			
		||||
      allOldSeries.push(oldSeries)
 | 
			
		||||
@ -291,7 +291,7 @@ module.exports = {
 | 
			
		||||
  async getNewestAuthors(library, user, limit) {
 | 
			
		||||
    if (library.mediaType !== 'book') return { authors: [], count: 0 }
 | 
			
		||||
 | 
			
		||||
    const { rows: authors, count } = await Database.models.author.findAndCountAll({
 | 
			
		||||
    const { rows: authors, count } = await Database.authorModel.findAndCountAll({
 | 
			
		||||
      where: {
 | 
			
		||||
        libraryId: library.id,
 | 
			
		||||
        createdAt: {
 | 
			
		||||
@ -299,7 +299,7 @@ module.exports = {
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      include: {
 | 
			
		||||
        model: Database.models.bookAuthor,
 | 
			
		||||
        model: Database.bookAuthorModel,
 | 
			
		||||
        required: true // Must belong to a book
 | 
			
		||||
      },
 | 
			
		||||
      limit,
 | 
			
		||||
@ -332,9 +332,9 @@ module.exports = {
 | 
			
		||||
    const { libraryItems, count } = await libraryItemsBookFilters.getDiscoverLibraryItems(library.id, user, include, limit)
 | 
			
		||||
    return {
 | 
			
		||||
      libraryItems: libraryItems.map(li => {
 | 
			
		||||
        const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified()
 | 
			
		||||
        const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
 | 
			
		||||
        if (li.rssFeed) {
 | 
			
		||||
          oldLibraryItem.rssFeed = Database.models.feed.getOldFeed(li.rssFeed).toJSONMinified()
 | 
			
		||||
          oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
 | 
			
		||||
        }
 | 
			
		||||
        return oldLibraryItem
 | 
			
		||||
      }),
 | 
			
		||||
@ -356,7 +356,7 @@ module.exports = {
 | 
			
		||||
    return {
 | 
			
		||||
      count,
 | 
			
		||||
      libraryItems: libraryItems.map(li => {
 | 
			
		||||
        const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified()
 | 
			
		||||
        const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
 | 
			
		||||
        oldLibraryItem.recentEpisode = li.recentEpisode
 | 
			
		||||
        return oldLibraryItem
 | 
			
		||||
      })
 | 
			
		||||
@ -390,7 +390,7 @@ module.exports = {
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get filter data used in filter menus
 | 
			
		||||
   * @param {oldLibrary} oldLibrary 
 | 
			
		||||
   * @param {import('../../objects/Library')} oldLibrary 
 | 
			
		||||
   * @returns {Promise<object>}
 | 
			
		||||
   */
 | 
			
		||||
  async getFilterData(oldLibrary) {
 | 
			
		||||
@ -417,9 +417,9 @@ module.exports = {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (oldLibrary.isPodcast) {
 | 
			
		||||
      const podcasts = await Database.models.podcast.findAll({
 | 
			
		||||
      const podcasts = await Database.podcastModel.findAll({
 | 
			
		||||
        include: {
 | 
			
		||||
          model: Database.models.libraryItem,
 | 
			
		||||
          model: Database.libraryItemModel,
 | 
			
		||||
          attributes: [],
 | 
			
		||||
          where: {
 | 
			
		||||
            libraryId: oldLibrary.id
 | 
			
		||||
@ -436,9 +436,9 @@ module.exports = {
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      const books = await Database.models.book.findAll({
 | 
			
		||||
      const books = await Database.bookModel.findAll({
 | 
			
		||||
        include: {
 | 
			
		||||
          model: Database.models.libraryItem,
 | 
			
		||||
          model: Database.libraryItemModel,
 | 
			
		||||
          attributes: ['isMissing', 'isInvalid'],
 | 
			
		||||
          where: {
 | 
			
		||||
            libraryId: oldLibrary.id
 | 
			
		||||
@ -461,7 +461,7 @@ module.exports = {
 | 
			
		||||
        if (book.language) data.languages.add(book.language)
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const series = await Database.models.series.findAll({
 | 
			
		||||
      const series = await Database.seriesModel.findAll({
 | 
			
		||||
        where: {
 | 
			
		||||
          libraryId: oldLibrary.id
 | 
			
		||||
        },
 | 
			
		||||
@ -469,7 +469,7 @@ module.exports = {
 | 
			
		||||
      })
 | 
			
		||||
      series.forEach((s) => data.series.push({ id: s.id, name: s.name }))
 | 
			
		||||
 | 
			
		||||
      const authors = await Database.models.author.findAll({
 | 
			
		||||
      const authors = await Database.authorModel.findAll({
 | 
			
		||||
        where: {
 | 
			
		||||
          libraryId: oldLibrary.id
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
@ -1,15 +1,17 @@
 | 
			
		||||
const Sequelize = require('sequelize')
 | 
			
		||||
const Database = require('../../Database')
 | 
			
		||||
const libraryItemsBookFilters = require('./libraryItemsBookFilters')
 | 
			
		||||
const libraryItemsPodcastFilters = require('./libraryItemsPodcastFilters')
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
  /**
 | 
			
		||||
   * Get all library items that have tags
 | 
			
		||||
   * @param {string[]} tags 
 | 
			
		||||
   * @returns {Promise<LibraryItem[]>}
 | 
			
		||||
   * @returns {Promise<import('../../models/LibraryItem')[]>}
 | 
			
		||||
   */
 | 
			
		||||
  async getAllLibraryItemsWithTags(tags) {
 | 
			
		||||
    const libraryItems = []
 | 
			
		||||
    const booksWithTag = await Database.models.book.findAll({
 | 
			
		||||
    const booksWithTag = await Database.bookModel.findAll({
 | 
			
		||||
      where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:tags))`), {
 | 
			
		||||
        [Sequelize.Op.gte]: 1
 | 
			
		||||
      }),
 | 
			
		||||
@ -18,16 +20,16 @@ module.exports = {
 | 
			
		||||
      },
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.libraryItem
 | 
			
		||||
          model: Database.libraryItemModel
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.author,
 | 
			
		||||
          model: Database.authorModel,
 | 
			
		||||
          through: {
 | 
			
		||||
            attributes: []
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.series,
 | 
			
		||||
          model: Database.seriesModel,
 | 
			
		||||
          through: {
 | 
			
		||||
            attributes: ['sequence']
 | 
			
		||||
          }
 | 
			
		||||
@ -39,7 +41,7 @@ module.exports = {
 | 
			
		||||
      libraryItem.media = book
 | 
			
		||||
      libraryItems.push(libraryItem)
 | 
			
		||||
    }
 | 
			
		||||
    const podcastsWithTag = await Database.models.podcast.findAll({
 | 
			
		||||
    const podcastsWithTag = await Database.podcastModel.findAll({
 | 
			
		||||
      where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:tags))`), {
 | 
			
		||||
        [Sequelize.Op.gte]: 1
 | 
			
		||||
      }),
 | 
			
		||||
@ -48,10 +50,10 @@ module.exports = {
 | 
			
		||||
      },
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.libraryItem
 | 
			
		||||
          model: Database.libraryItemModel
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.podcastEpisode
 | 
			
		||||
          model: Database.podcastEpisodeModel
 | 
			
		||||
        }
 | 
			
		||||
      ]
 | 
			
		||||
    })
 | 
			
		||||
@ -70,7 +72,7 @@ module.exports = {
 | 
			
		||||
   */
 | 
			
		||||
  async getAllLibraryItemsWithGenres(genres) {
 | 
			
		||||
    const libraryItems = []
 | 
			
		||||
    const booksWithGenre = await Database.models.book.findAll({
 | 
			
		||||
    const booksWithGenre = await Database.bookModel.findAll({
 | 
			
		||||
      where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(genres) WHERE json_valid(genres) AND json_each.value IN (:genres))`), {
 | 
			
		||||
        [Sequelize.Op.gte]: 1
 | 
			
		||||
      }),
 | 
			
		||||
@ -79,16 +81,16 @@ module.exports = {
 | 
			
		||||
      },
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.libraryItem
 | 
			
		||||
          model: Database.libraryItemModel
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.author,
 | 
			
		||||
          model: Database.authorModel,
 | 
			
		||||
          through: {
 | 
			
		||||
            attributes: []
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.series,
 | 
			
		||||
          model: Database.seriesModel,
 | 
			
		||||
          through: {
 | 
			
		||||
            attributes: ['sequence']
 | 
			
		||||
          }
 | 
			
		||||
@ -100,7 +102,7 @@ module.exports = {
 | 
			
		||||
      libraryItem.media = book
 | 
			
		||||
      libraryItems.push(libraryItem)
 | 
			
		||||
    }
 | 
			
		||||
    const podcastsWithGenre = await Database.models.podcast.findAll({
 | 
			
		||||
    const podcastsWithGenre = await Database.podcastModel.findAll({
 | 
			
		||||
      where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(genres) WHERE json_valid(genres) AND json_each.value IN (:genres))`), {
 | 
			
		||||
        [Sequelize.Op.gte]: 1
 | 
			
		||||
      }),
 | 
			
		||||
@ -109,10 +111,10 @@ module.exports = {
 | 
			
		||||
      },
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.libraryItem
 | 
			
		||||
          model: Database.libraryItemModel
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.podcastEpisode
 | 
			
		||||
          model: Database.podcastEpisodeModel
 | 
			
		||||
        }
 | 
			
		||||
      ]
 | 
			
		||||
    })
 | 
			
		||||
@ -127,11 +129,11 @@ module.exports = {
 | 
			
		||||
  /**
 | 
			
		||||
 * Get all library items that have narrators
 | 
			
		||||
 * @param {string[]} narrators 
 | 
			
		||||
 * @returns {Promise<LibraryItem[]>}
 | 
			
		||||
 * @returns {Promise<import('../../models/LibraryItem')[]>}
 | 
			
		||||
 */
 | 
			
		||||
  async getAllLibraryItemsWithNarrators(narrators) {
 | 
			
		||||
    const libraryItems = []
 | 
			
		||||
    const booksWithGenre = await Database.models.book.findAll({
 | 
			
		||||
    const booksWithGenre = await Database.bookModel.findAll({
 | 
			
		||||
      where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(narrators) WHERE json_valid(narrators) AND json_each.value IN (:narrators))`), {
 | 
			
		||||
        [Sequelize.Op.gte]: 1
 | 
			
		||||
      }),
 | 
			
		||||
@ -140,16 +142,16 @@ module.exports = {
 | 
			
		||||
      },
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.libraryItem
 | 
			
		||||
          model: Database.libraryItemModel
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.author,
 | 
			
		||||
          model: Database.authorModel,
 | 
			
		||||
          through: {
 | 
			
		||||
            attributes: []
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.series,
 | 
			
		||||
          model: Database.seriesModel,
 | 
			
		||||
          through: {
 | 
			
		||||
            attributes: ['sequence']
 | 
			
		||||
          }
 | 
			
		||||
@ -162,5 +164,57 @@ module.exports = {
 | 
			
		||||
      libraryItems.push(libraryItem)
 | 
			
		||||
    }
 | 
			
		||||
    return libraryItems
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Search library items
 | 
			
		||||
   * @param {import('../../objects/user/User')} oldUser 
 | 
			
		||||
   * @param {import('../../objects/Library')} oldLibrary 
 | 
			
		||||
   * @param {string} query
 | 
			
		||||
   * @param {number} limit 
 | 
			
		||||
   * @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[], podcast:object[]}}
 | 
			
		||||
   */
 | 
			
		||||
  search(oldUser, oldLibrary, query, limit) {
 | 
			
		||||
    if (oldLibrary.isBook) {
 | 
			
		||||
      return libraryItemsBookFilters.search(oldUser, oldLibrary, query, limit, 0)
 | 
			
		||||
    } else {
 | 
			
		||||
      return libraryItemsPodcastFilters.search(oldUser, oldLibrary, query, limit, 0)
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get largest items in library
 | 
			
		||||
   * @param {string} libraryId 
 | 
			
		||||
   * @param {number} limit 
 | 
			
		||||
   * @returns {Promise<{ id:string, title:string, size:number }[]>}
 | 
			
		||||
   */
 | 
			
		||||
  async getLargestItems(libraryId, limit) {
 | 
			
		||||
    const libraryItems = await Database.libraryItemModel.findAll({
 | 
			
		||||
      attributes: ['id', 'mediaId', 'mediaType', 'size'],
 | 
			
		||||
      where: {
 | 
			
		||||
        libraryId
 | 
			
		||||
      },
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.bookModel,
 | 
			
		||||
          attributes: ['id', 'title']
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.podcastModel,
 | 
			
		||||
          attributes: ['id', 'title']
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      order: [
 | 
			
		||||
        ['size', 'DESC']
 | 
			
		||||
      ],
 | 
			
		||||
      limit
 | 
			
		||||
    })
 | 
			
		||||
    return libraryItems.map(libraryItem => {
 | 
			
		||||
      return {
 | 
			
		||||
        id: libraryItem.id,
 | 
			
		||||
        title: libraryItem.media.title,
 | 
			
		||||
        size: libraryItem.size
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -1,12 +1,13 @@
 | 
			
		||||
const Sequelize = require('sequelize')
 | 
			
		||||
const Database = require('../../Database')
 | 
			
		||||
const Logger = require('../../Logger')
 | 
			
		||||
const authorFilters = require('./authorFilters')
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
  /**
 | 
			
		||||
   * User permissions to restrict books for explicit content & tags
 | 
			
		||||
   * @param {oldUser} user 
 | 
			
		||||
   * @returns {object} { bookWhere:Sequelize.WhereOptions, replacements:string[] }
 | 
			
		||||
   * @param {import('../../objects/user/User')} user 
 | 
			
		||||
   * @returns {{ bookWhere:Sequelize.WhereOptions, replacements:object }}
 | 
			
		||||
   */
 | 
			
		||||
  getUserPermissionBookWhereQuery(user) {
 | 
			
		||||
    const bookWhere = []
 | 
			
		||||
@ -278,7 +279,7 @@ module.exports = {
 | 
			
		||||
   * @returns {object} { booksToExclude, bookSeriesToInclude }
 | 
			
		||||
   */
 | 
			
		||||
  async getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere) {
 | 
			
		||||
    const allSeries = await Database.models.series.findAll({
 | 
			
		||||
    const allSeries = await Database.seriesModel.findAll({
 | 
			
		||||
      attributes: [
 | 
			
		||||
        'id',
 | 
			
		||||
        'name',
 | 
			
		||||
@ -289,7 +290,7 @@ module.exports = {
 | 
			
		||||
      where: seriesWhere,
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.book,
 | 
			
		||||
          model: Database.bookModel,
 | 
			
		||||
          attributes: ['id', 'title'],
 | 
			
		||||
          through: {
 | 
			
		||||
            attributes: ['id', 'seriesId', 'bookId', 'sequence']
 | 
			
		||||
@ -373,10 +374,10 @@ module.exports = {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let seriesInclude = {
 | 
			
		||||
      model: Database.models.bookSeries,
 | 
			
		||||
      model: Database.bookSeriesModel,
 | 
			
		||||
      attributes: ['id', 'seriesId', 'sequence', 'createdAt'],
 | 
			
		||||
      include: {
 | 
			
		||||
        model: Database.models.series,
 | 
			
		||||
        model: Database.seriesModel,
 | 
			
		||||
        attributes: ['id', 'name', 'nameIgnorePrefix']
 | 
			
		||||
      },
 | 
			
		||||
      order: [
 | 
			
		||||
@ -386,10 +387,10 @@ module.exports = {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let authorInclude = {
 | 
			
		||||
      model: Database.models.bookAuthor,
 | 
			
		||||
      model: Database.bookAuthorModel,
 | 
			
		||||
      attributes: ['authorId', 'createdAt'],
 | 
			
		||||
      include: {
 | 
			
		||||
        model: Database.models.author,
 | 
			
		||||
        model: Database.authorModel,
 | 
			
		||||
        attributes: ['id', 'name']
 | 
			
		||||
      },
 | 
			
		||||
      order: [
 | 
			
		||||
@ -404,13 +405,13 @@ module.exports = {
 | 
			
		||||
    const bookIncludes = []
 | 
			
		||||
    if (includeRSSFeed) {
 | 
			
		||||
      libraryItemIncludes.push({
 | 
			
		||||
        model: Database.models.feed,
 | 
			
		||||
        model: Database.feedModel,
 | 
			
		||||
        required: filterGroup === 'feed-open'
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
    if (filterGroup === 'feed-open' && !includeRSSFeed) {
 | 
			
		||||
      libraryItemIncludes.push({
 | 
			
		||||
        model: Database.models.feed,
 | 
			
		||||
        model: Database.feedModel,
 | 
			
		||||
        required: true
 | 
			
		||||
      })
 | 
			
		||||
    } else if (filterGroup === 'ebooks' && filterValue === 'supplementary') {
 | 
			
		||||
@ -420,7 +421,7 @@ module.exports = {
 | 
			
		||||
      }
 | 
			
		||||
    } else if (filterGroup === 'missing' && filterValue === 'authors') {
 | 
			
		||||
      authorInclude = {
 | 
			
		||||
        model: Database.models.author,
 | 
			
		||||
        model: Database.authorModel,
 | 
			
		||||
        attributes: ['id'],
 | 
			
		||||
        through: {
 | 
			
		||||
          attributes: []
 | 
			
		||||
@ -428,7 +429,7 @@ module.exports = {
 | 
			
		||||
      }
 | 
			
		||||
    } else if ((filterGroup === 'series' && filterValue === 'no-series') || (filterGroup === 'missing' && filterValue === 'series')) {
 | 
			
		||||
      seriesInclude = {
 | 
			
		||||
        model: Database.models.series,
 | 
			
		||||
        model: Database.seriesModel,
 | 
			
		||||
        attributes: ['id'],
 | 
			
		||||
        through: {
 | 
			
		||||
          attributes: []
 | 
			
		||||
@ -436,7 +437,7 @@ module.exports = {
 | 
			
		||||
      }
 | 
			
		||||
    } else if (filterGroup === 'authors') {
 | 
			
		||||
      bookIncludes.push({
 | 
			
		||||
        model: Database.models.author,
 | 
			
		||||
        model: Database.authorModel,
 | 
			
		||||
        attributes: ['id', 'name'],
 | 
			
		||||
        where: {
 | 
			
		||||
          id: filterValue
 | 
			
		||||
@ -447,7 +448,7 @@ module.exports = {
 | 
			
		||||
      })
 | 
			
		||||
    } else if (filterGroup === 'series') {
 | 
			
		||||
      bookIncludes.push({
 | 
			
		||||
        model: Database.models.series,
 | 
			
		||||
        model: Database.seriesModel,
 | 
			
		||||
        attributes: ['id', 'name'],
 | 
			
		||||
        where: {
 | 
			
		||||
          id: filterValue
 | 
			
		||||
@ -471,7 +472,7 @@ module.exports = {
 | 
			
		||||
      ]
 | 
			
		||||
    } else if (filterGroup === 'progress' && user) {
 | 
			
		||||
      bookIncludes.push({
 | 
			
		||||
        model: Database.models.mediaProgress,
 | 
			
		||||
        model: Database.mediaProgressModel,
 | 
			
		||||
        attributes: ['id', 'isFinished', 'currentTime', 'ebookProgress', 'updatedAt'],
 | 
			
		||||
        where: {
 | 
			
		||||
          userId: user.id
 | 
			
		||||
@ -512,7 +513,7 @@ module.exports = {
 | 
			
		||||
        where: seriesBookWhere,
 | 
			
		||||
        include: [
 | 
			
		||||
          {
 | 
			
		||||
            model: Database.models.libraryItem,
 | 
			
		||||
            model: Database.libraryItemModel,
 | 
			
		||||
            required: true,
 | 
			
		||||
            where: libraryItemWhere,
 | 
			
		||||
            include: libraryItemIncludes
 | 
			
		||||
@ -541,7 +542,7 @@ module.exports = {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const { rows: books, count } = await Database.models.book.findAndCountAll({
 | 
			
		||||
    const { rows: books, count } = await Database.bookModel.findAndCountAll({
 | 
			
		||||
      where: bookWhere,
 | 
			
		||||
      distinct: true,
 | 
			
		||||
      attributes: bookAttributes,
 | 
			
		||||
@ -552,7 +553,7 @@ module.exports = {
 | 
			
		||||
      },
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.libraryItem,
 | 
			
		||||
          model: Database.libraryItemModel,
 | 
			
		||||
          required: true,
 | 
			
		||||
          where: libraryItemWhere,
 | 
			
		||||
          include: libraryItemIncludes
 | 
			
		||||
@ -632,7 +633,7 @@ module.exports = {
 | 
			
		||||
    const libraryItemIncludes = []
 | 
			
		||||
    if (include.includes('rssfeed')) {
 | 
			
		||||
      libraryItemIncludes.push({
 | 
			
		||||
        model: Database.models.feed
 | 
			
		||||
        model: Database.feedModel
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -642,7 +643,7 @@ module.exports = {
 | 
			
		||||
    const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user)
 | 
			
		||||
    bookWhere.push(...userPermissionBookWhere.bookWhere)
 | 
			
		||||
 | 
			
		||||
    const { rows: series, count } = await Database.models.series.findAndCountAll({
 | 
			
		||||
    const { rows: series, count } = await Database.seriesModel.findAndCountAll({
 | 
			
		||||
      where: [
 | 
			
		||||
        {
 | 
			
		||||
          libraryId
 | 
			
		||||
@ -669,7 +670,7 @@ module.exports = {
 | 
			
		||||
        ...userPermissionBookWhere.replacements
 | 
			
		||||
      },
 | 
			
		||||
      include: {
 | 
			
		||||
        model: Database.models.bookSeries,
 | 
			
		||||
        model: Database.bookSeriesModel,
 | 
			
		||||
        attributes: ['bookId', 'sequence'],
 | 
			
		||||
        separate: true,
 | 
			
		||||
        subQuery: false,
 | 
			
		||||
@ -682,21 +683,21 @@ module.exports = {
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        include: {
 | 
			
		||||
          model: Database.models.book,
 | 
			
		||||
          model: Database.bookModel,
 | 
			
		||||
          where: bookWhere,
 | 
			
		||||
          include: [
 | 
			
		||||
            {
 | 
			
		||||
              model: Database.models.libraryItem,
 | 
			
		||||
              model: Database.libraryItemModel,
 | 
			
		||||
              include: libraryItemIncludes
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              model: Database.models.author,
 | 
			
		||||
              model: Database.authorModel,
 | 
			
		||||
              through: {
 | 
			
		||||
                attributes: []
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              model: Database.models.mediaProgress,
 | 
			
		||||
              model: Database.mediaProgressModel,
 | 
			
		||||
              where: {
 | 
			
		||||
                userId: user.id
 | 
			
		||||
              },
 | 
			
		||||
@ -751,7 +752,7 @@ module.exports = {
 | 
			
		||||
    const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user)
 | 
			
		||||
 | 
			
		||||
    // Step 1: Get the first book of every series that hasnt been started yet
 | 
			
		||||
    const seriesNotStarted = await Database.models.series.findAll({
 | 
			
		||||
    const seriesNotStarted = await Database.seriesModel.findAll({
 | 
			
		||||
      where: [
 | 
			
		||||
        {
 | 
			
		||||
          libraryId
 | 
			
		||||
@ -764,12 +765,12 @@ module.exports = {
 | 
			
		||||
      },
 | 
			
		||||
      attributes: ['id'],
 | 
			
		||||
      include: {
 | 
			
		||||
        model: Database.models.bookSeries,
 | 
			
		||||
        model: Database.bookSeriesModel,
 | 
			
		||||
        attributes: ['bookId', 'sequence'],
 | 
			
		||||
        separate: true,
 | 
			
		||||
        required: true,
 | 
			
		||||
        include: {
 | 
			
		||||
          model: Database.models.book,
 | 
			
		||||
          model: Database.bookModel,
 | 
			
		||||
          where: userPermissionBookWhere.bookWhere
 | 
			
		||||
        },
 | 
			
		||||
        order: [
 | 
			
		||||
@ -788,12 +789,12 @@ module.exports = {
 | 
			
		||||
    const libraryItemIncludes = []
 | 
			
		||||
    if (include.includes('rssfeed')) {
 | 
			
		||||
      libraryItemIncludes.push({
 | 
			
		||||
        model: Database.models.feed
 | 
			
		||||
        model: Database.feedModel
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Step 2: Get books not started and not in a series OR is the first book of a series not started (ordered randomly)
 | 
			
		||||
    const { rows: books, count } = await Database.models.book.findAndCountAll({
 | 
			
		||||
    const { rows: books, count } = await Database.bookModel.findAndCountAll({
 | 
			
		||||
      where: [
 | 
			
		||||
        {
 | 
			
		||||
          '$mediaProgresses.isFinished$': {
 | 
			
		||||
@ -816,32 +817,32 @@ module.exports = {
 | 
			
		||||
      replacements: userPermissionBookWhere.replacements,
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.libraryItem,
 | 
			
		||||
          model: Database.libraryItemModel,
 | 
			
		||||
          where: {
 | 
			
		||||
            libraryId
 | 
			
		||||
          },
 | 
			
		||||
          include: libraryItemIncludes
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.mediaProgress,
 | 
			
		||||
          model: Database.mediaProgressModel,
 | 
			
		||||
          where: {
 | 
			
		||||
            userId: user.id
 | 
			
		||||
          },
 | 
			
		||||
          required: false
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.bookAuthor,
 | 
			
		||||
          model: Database.bookAuthorModel,
 | 
			
		||||
          attributes: ['authorId'],
 | 
			
		||||
          include: {
 | 
			
		||||
            model: Database.models.author
 | 
			
		||||
            model: Database.authorModel
 | 
			
		||||
          },
 | 
			
		||||
          separate: true
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.bookSeries,
 | 
			
		||||
          model: Database.bookSeriesModel,
 | 
			
		||||
          attributes: ['seriesId', 'sequence'],
 | 
			
		||||
          include: {
 | 
			
		||||
            model: Database.models.series
 | 
			
		||||
            model: Database.seriesModel
 | 
			
		||||
          },
 | 
			
		||||
          separate: true
 | 
			
		||||
        }
 | 
			
		||||
@ -883,10 +884,10 @@ module.exports = {
 | 
			
		||||
      return []
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const books = await Database.models.book.findAll({
 | 
			
		||||
    const books = await Database.bookModel.findAll({
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.libraryItem,
 | 
			
		||||
          model: Database.libraryItemModel,
 | 
			
		||||
          where: {
 | 
			
		||||
            id: {
 | 
			
		||||
              [Sequelize.Op.in]: collection.books
 | 
			
		||||
@ -894,13 +895,13 @@ module.exports = {
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.author,
 | 
			
		||||
          model: Database.authorModel,
 | 
			
		||||
          through: {
 | 
			
		||||
            attributes: []
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.series,
 | 
			
		||||
          model: Database.seriesModel,
 | 
			
		||||
          through: {
 | 
			
		||||
            attributes: ['sequence']
 | 
			
		||||
          }
 | 
			
		||||
@ -918,12 +919,260 @@ module.exports = {
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get library items for series
 | 
			
		||||
   * @param {oldSeries} oldSeries 
 | 
			
		||||
   * @param {[oldUser]} oldUser 
 | 
			
		||||
   * @returns {Promise<oldLibraryItem[]>}
 | 
			
		||||
   * @param {import('../../objects/entities/Series')} oldSeries 
 | 
			
		||||
   * @param {import('../../objects/user/User')} [oldUser] 
 | 
			
		||||
   * @returns {Promise<import('../../objects/LibraryItem')[]>}
 | 
			
		||||
   */
 | 
			
		||||
  async getLibraryItemsForSeries(oldSeries, oldUser) {
 | 
			
		||||
    const { libraryItems } = await this.getFilteredLibraryItems(oldSeries.libraryId, oldUser, 'series', oldSeries.id, null, null, false, [], null, null)
 | 
			
		||||
    return libraryItems.map(li => Database.models.libraryItem.getOldLibraryItem(li))
 | 
			
		||||
    return libraryItems.map(li => Database.libraryItemModel.getOldLibraryItem(li))
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Search books, authors, series
 | 
			
		||||
   * @param {import('../../objects/user/User')} oldUser
 | 
			
		||||
   * @param {import('../../objects/Library')} oldLibrary 
 | 
			
		||||
   * @param {string} query 
 | 
			
		||||
   * @param {number} limit 
 | 
			
		||||
   * @param {number} offset 
 | 
			
		||||
   * @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[]}}
 | 
			
		||||
   */
 | 
			
		||||
  async search(oldUser, oldLibrary, query, limit, offset) {
 | 
			
		||||
    const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(oldUser)
 | 
			
		||||
 | 
			
		||||
    // Search title, subtitle, asin, isbn
 | 
			
		||||
    const books = await Database.bookModel.findAll({
 | 
			
		||||
      where: [
 | 
			
		||||
        {
 | 
			
		||||
          [Sequelize.Op.or]: [
 | 
			
		||||
            {
 | 
			
		||||
              title: {
 | 
			
		||||
                [Sequelize.Op.substring]: query
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              subtitle: {
 | 
			
		||||
                [Sequelize.Op.substring]: query
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              asin: {
 | 
			
		||||
                [Sequelize.Op.substring]: query
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              isbn: {
 | 
			
		||||
                [Sequelize.Op.substring]: query
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          ]
 | 
			
		||||
        },
 | 
			
		||||
        ...userPermissionBookWhere.bookWhere
 | 
			
		||||
      ],
 | 
			
		||||
      replacements: userPermissionBookWhere.replacements,
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.libraryItemModel,
 | 
			
		||||
          where: {
 | 
			
		||||
            libraryId: oldLibrary.id
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.bookSeriesModel,
 | 
			
		||||
          include: {
 | 
			
		||||
            model: Database.seriesModel
 | 
			
		||||
          },
 | 
			
		||||
          separate: true
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.bookAuthorModel,
 | 
			
		||||
          include: {
 | 
			
		||||
            model: Database.authorModel
 | 
			
		||||
          },
 | 
			
		||||
          separate: true
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      subQuery: false,
 | 
			
		||||
      distinct: true,
 | 
			
		||||
      limit,
 | 
			
		||||
      offset
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    const itemMatches = []
 | 
			
		||||
 | 
			
		||||
    for (const book of books) {
 | 
			
		||||
      const libraryItem = book.libraryItem
 | 
			
		||||
      delete book.libraryItem
 | 
			
		||||
      libraryItem.media = book
 | 
			
		||||
 | 
			
		||||
      let matchText = null
 | 
			
		||||
      let matchKey = null
 | 
			
		||||
      for (const key of ['title', 'subtitle', 'asin', 'isbn']) {
 | 
			
		||||
        if (book[key]?.toLowerCase().includes(query)) {
 | 
			
		||||
          matchText = book[key]
 | 
			
		||||
          matchKey = key
 | 
			
		||||
          break
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (matchKey) {
 | 
			
		||||
        itemMatches.push({
 | 
			
		||||
          matchText,
 | 
			
		||||
          matchKey,
 | 
			
		||||
          libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Search narrators
 | 
			
		||||
    const narratorMatches = []
 | 
			
		||||
    const [narratorResults] = await Database.sequelize.query(`SELECT value, count(*) AS numBooks FROM books b, libraryItems li, json_each(b.narrators) WHERE json_valid(b.narrators) AND json_each.value LIKE :query AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, {
 | 
			
		||||
      replacements: {
 | 
			
		||||
        query: `%${query}%`,
 | 
			
		||||
        libraryId: oldLibrary.id,
 | 
			
		||||
        limit,
 | 
			
		||||
        offset
 | 
			
		||||
      },
 | 
			
		||||
      raw: true
 | 
			
		||||
    })
 | 
			
		||||
    for (const row of narratorResults) {
 | 
			
		||||
      narratorMatches.push({
 | 
			
		||||
        name: row.value,
 | 
			
		||||
        numBooks: row.numBooks
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Search tags
 | 
			
		||||
    const tagMatches = []
 | 
			
		||||
    const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.tags) WHERE json_valid(b.tags) AND json_each.value LIKE :query AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, {
 | 
			
		||||
      replacements: {
 | 
			
		||||
        query: `%${query}%`,
 | 
			
		||||
        libraryId: oldLibrary.id,
 | 
			
		||||
        limit,
 | 
			
		||||
        offset
 | 
			
		||||
      },
 | 
			
		||||
      raw: true
 | 
			
		||||
    })
 | 
			
		||||
    for (const row of tagResults) {
 | 
			
		||||
      tagMatches.push({
 | 
			
		||||
        name: row.value,
 | 
			
		||||
        numItems: row.numItems
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Search series
 | 
			
		||||
    const allSeries = await Database.seriesModel.findAll({
 | 
			
		||||
      where: {
 | 
			
		||||
        name: {
 | 
			
		||||
          [Sequelize.Op.substring]: query
 | 
			
		||||
        },
 | 
			
		||||
        libraryId: oldLibrary.id
 | 
			
		||||
      },
 | 
			
		||||
      replacements: userPermissionBookWhere.replacements,
 | 
			
		||||
      include: {
 | 
			
		||||
        separate: true,
 | 
			
		||||
        model: Database.bookSeriesModel,
 | 
			
		||||
        include: {
 | 
			
		||||
          model: Database.bookModel,
 | 
			
		||||
          where: userPermissionBookWhere.bookWhere,
 | 
			
		||||
          include: {
 | 
			
		||||
            model: Database.libraryItemModel
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      subQuery: false,
 | 
			
		||||
      distinct: true,
 | 
			
		||||
      limit,
 | 
			
		||||
      offset
 | 
			
		||||
    })
 | 
			
		||||
    const seriesMatches = []
 | 
			
		||||
    for (const series of allSeries) {
 | 
			
		||||
      const books = series.bookSeries.map((bs) => {
 | 
			
		||||
        const libraryItem = bs.book.libraryItem
 | 
			
		||||
        libraryItem.media = bs.book
 | 
			
		||||
        return Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSON()
 | 
			
		||||
      })
 | 
			
		||||
      seriesMatches.push({
 | 
			
		||||
        series: series.getOldSeries().toJSON(),
 | 
			
		||||
        books
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Search authors
 | 
			
		||||
    const authorMatches = await authorFilters.search(oldLibrary.id, query, limit, offset)
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      book: itemMatches,
 | 
			
		||||
      narrators: narratorMatches,
 | 
			
		||||
      tags: tagMatches,
 | 
			
		||||
      series: seriesMatches,
 | 
			
		||||
      authors: authorMatches
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Genres with num books
 | 
			
		||||
   * @param {string} libraryId 
 | 
			
		||||
   * @returns {{genre:string, count:number}[]}
 | 
			
		||||
   */
 | 
			
		||||
  async getGenresWithCount(libraryId) {
 | 
			
		||||
    const genres = []
 | 
			
		||||
    const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.genres) WHERE json_valid(b.genres) AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC;`, {
 | 
			
		||||
      replacements: {
 | 
			
		||||
        libraryId
 | 
			
		||||
      },
 | 
			
		||||
      raw: true
 | 
			
		||||
    })
 | 
			
		||||
    for (const row of genreResults) {
 | 
			
		||||
      genres.push({
 | 
			
		||||
        genre: row.value,
 | 
			
		||||
        count: row.numItems
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
    return genres
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get stats for book library
 | 
			
		||||
   * @param {string} libraryId 
 | 
			
		||||
   * @returns {Promise<{ totalSize:number, totalDuration:number, numAudioFiles:number, totalItems:number}>}
 | 
			
		||||
   */
 | 
			
		||||
  async getBookLibraryStats(libraryId) {
 | 
			
		||||
    const [statResults] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, SUM(json_array_length(b.audioFiles)) AS numAudioFiles, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.libraryId = :libraryId;`, {
 | 
			
		||||
      replacements: {
 | 
			
		||||
        libraryId
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
    return statResults[0]
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get longest books in library
 | 
			
		||||
   * @param {string} libraryId 
 | 
			
		||||
   * @param {number} limit 
 | 
			
		||||
   * @returns {Promise<{ id:string, title:string, duration:number }[]>}
 | 
			
		||||
   */
 | 
			
		||||
  async getLongestBooks(libraryId, limit) {
 | 
			
		||||
    const books = await Database.bookModel.findAll({
 | 
			
		||||
      attributes: ['id', 'title', 'duration'],
 | 
			
		||||
      include: {
 | 
			
		||||
        model: Database.libraryItemModel,
 | 
			
		||||
        attributes: ['id', 'libraryId'],
 | 
			
		||||
        where: {
 | 
			
		||||
          libraryId
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      order: [
 | 
			
		||||
        ['duration', 'DESC']
 | 
			
		||||
      ],
 | 
			
		||||
      limit
 | 
			
		||||
    })
 | 
			
		||||
    return books.map(book => {
 | 
			
		||||
      return {
 | 
			
		||||
        id: book.libraryItem.id,
 | 
			
		||||
        title: book.title,
 | 
			
		||||
        duration: book.duration
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -6,8 +6,8 @@ const Logger = require('../../Logger')
 | 
			
		||||
module.exports = {
 | 
			
		||||
  /**
 | 
			
		||||
   * User permissions to restrict podcasts for explicit content & tags
 | 
			
		||||
   * @param {oldUser} user 
 | 
			
		||||
   * @returns {object} { podcastWhere:Sequelize.WhereOptions, replacements:string[] }
 | 
			
		||||
   * @param {import('../../objects/user/User')} user 
 | 
			
		||||
   * @returns {{ podcastWhere:Sequelize.WhereOptions, replacements:object }}
 | 
			
		||||
   */
 | 
			
		||||
  getUserPermissionPodcastWhereQuery(user) {
 | 
			
		||||
    const podcastWhere = []
 | 
			
		||||
@ -112,7 +112,7 @@ module.exports = {
 | 
			
		||||
    const libraryItemIncludes = []
 | 
			
		||||
    if (includeRSSFeed) {
 | 
			
		||||
      libraryItemIncludes.push({
 | 
			
		||||
        model: Database.models.feed,
 | 
			
		||||
        model: Database.feedModel,
 | 
			
		||||
        required: filterGroup === 'feed-open'
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
@ -146,7 +146,7 @@ module.exports = {
 | 
			
		||||
    replacements = { ...replacements, ...userPermissionPodcastWhere.replacements }
 | 
			
		||||
    podcastWhere.push(...userPermissionPodcastWhere.podcastWhere)
 | 
			
		||||
 | 
			
		||||
    const { rows: podcasts, count } = await Database.models.podcast.findAndCountAll({
 | 
			
		||||
    const { rows: podcasts, count } = await Database.podcastModel.findAndCountAll({
 | 
			
		||||
      where: podcastWhere,
 | 
			
		||||
      replacements,
 | 
			
		||||
      distinct: true,
 | 
			
		||||
@ -158,7 +158,7 @@ module.exports = {
 | 
			
		||||
      },
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.libraryItem,
 | 
			
		||||
          model: Database.libraryItemModel,
 | 
			
		||||
          required: true,
 | 
			
		||||
          where: libraryItemWhere,
 | 
			
		||||
          include: libraryItemIncludes
 | 
			
		||||
@ -219,7 +219,7 @@ module.exports = {
 | 
			
		||||
    }
 | 
			
		||||
    if (filterGroup === 'progress') {
 | 
			
		||||
      podcastEpisodeIncludes.push({
 | 
			
		||||
        model: Database.models.mediaProgress,
 | 
			
		||||
        model: Database.mediaProgressModel,
 | 
			
		||||
        where: {
 | 
			
		||||
          userId: user.id
 | 
			
		||||
        },
 | 
			
		||||
@ -255,16 +255,16 @@ module.exports = {
 | 
			
		||||
 | 
			
		||||
    const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user)
 | 
			
		||||
 | 
			
		||||
    const { rows: podcastEpisodes, count } = await Database.models.podcastEpisode.findAndCountAll({
 | 
			
		||||
    const { rows: podcastEpisodes, count } = await Database.podcastEpisodeModel.findAndCountAll({
 | 
			
		||||
      where: podcastEpisodeWhere,
 | 
			
		||||
      replacements: userPermissionPodcastWhere.replacements,
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.models.podcast,
 | 
			
		||||
          model: Database.podcastModel,
 | 
			
		||||
          where: userPermissionPodcastWhere.podcastWhere,
 | 
			
		||||
          include: [
 | 
			
		||||
            {
 | 
			
		||||
              model: Database.models.libraryItem,
 | 
			
		||||
              model: Database.libraryItemModel,
 | 
			
		||||
              where: libraryItemWhere
 | 
			
		||||
            }
 | 
			
		||||
          ]
 | 
			
		||||
@ -283,7 +283,7 @@ module.exports = {
 | 
			
		||||
      const podcast = ep.podcast.toJSON()
 | 
			
		||||
      delete podcast.libraryItem
 | 
			
		||||
      libraryItem.media = podcast
 | 
			
		||||
      libraryItem.recentEpisode = ep.getOldPodcastEpisode(libraryItem.id)
 | 
			
		||||
      libraryItem.recentEpisode = ep.getOldPodcastEpisode(libraryItem.id).toJSON()
 | 
			
		||||
      return libraryItem
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
@ -291,5 +291,239 @@ module.exports = {
 | 
			
		||||
      libraryItems,
 | 
			
		||||
      count
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Search podcasts
 | 
			
		||||
   * @param {import('../../objects/user/User')} oldUser
 | 
			
		||||
   * @param {import('../../objects/Library')} oldLibrary 
 | 
			
		||||
   * @param {string} query 
 | 
			
		||||
   * @param {number} limit 
 | 
			
		||||
   * @param {number} offset 
 | 
			
		||||
   * @returns {{podcast:object[], tags:object[]}}
 | 
			
		||||
   */
 | 
			
		||||
  async search(oldUser, oldLibrary, query, limit, offset) {
 | 
			
		||||
    const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user)
 | 
			
		||||
    // Search title, author, itunesId, itunesArtistId
 | 
			
		||||
    const podcasts = await Database.podcastModel.findAll({
 | 
			
		||||
      where: [
 | 
			
		||||
        {
 | 
			
		||||
          [Sequelize.Op.or]: [
 | 
			
		||||
            {
 | 
			
		||||
              title: {
 | 
			
		||||
                [Sequelize.Op.substring]: query
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              author: {
 | 
			
		||||
                [Sequelize.Op.substring]: query
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              itunesId: {
 | 
			
		||||
                [Sequelize.Op.substring]: query
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              itunesArtistId: {
 | 
			
		||||
                [Sequelize.Op.substring]: query
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          ]
 | 
			
		||||
        },
 | 
			
		||||
        ...userPermissionPodcastWhere.podcastWhere
 | 
			
		||||
      ],
 | 
			
		||||
      replacements: userPermissionPodcastWhere.replacements,
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.libraryItemModel,
 | 
			
		||||
          where: {
 | 
			
		||||
            libraryId: oldLibrary.id
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      subQuery: false,
 | 
			
		||||
      distinct: true,
 | 
			
		||||
      limit,
 | 
			
		||||
      offset
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    const itemMatches = []
 | 
			
		||||
 | 
			
		||||
    for (const podcast of podcasts) {
 | 
			
		||||
      const libraryItem = podcast.libraryItem
 | 
			
		||||
      delete podcast.libraryItem
 | 
			
		||||
      libraryItem.media = podcast
 | 
			
		||||
 | 
			
		||||
      let matchText = null
 | 
			
		||||
      let matchKey = null
 | 
			
		||||
      for (const key of ['title', 'author', 'itunesId', 'itunesArtistId']) {
 | 
			
		||||
        if (podcast[key]?.toLowerCase().includes(query)) {
 | 
			
		||||
          matchText = podcast[key]
 | 
			
		||||
          matchKey = key
 | 
			
		||||
          break
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (matchKey) {
 | 
			
		||||
        itemMatches.push({
 | 
			
		||||
          matchText,
 | 
			
		||||
          matchKey,
 | 
			
		||||
          libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Search tags
 | 
			
		||||
    const tagMatches = []
 | 
			
		||||
    const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.tags) WHERE json_valid(p.tags) AND json_each.value LIKE :query AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, {
 | 
			
		||||
      replacements: {
 | 
			
		||||
        query: `%${query}%`,
 | 
			
		||||
        libraryId: oldLibrary.id,
 | 
			
		||||
        limit,
 | 
			
		||||
        offset
 | 
			
		||||
      },
 | 
			
		||||
      raw: true
 | 
			
		||||
    })
 | 
			
		||||
    for (const row of tagResults) {
 | 
			
		||||
      tagMatches.push({
 | 
			
		||||
        name: row.value,
 | 
			
		||||
        numItems: row.numItems
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      podcast: itemMatches,
 | 
			
		||||
      tags: tagMatches
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Most recent podcast episodes not finished
 | 
			
		||||
   * @param {import('../../objects/user/User')} oldUser 
 | 
			
		||||
   * @param {import('../../objects/Library')} oldLibrary 
 | 
			
		||||
   * @param {number} limit 
 | 
			
		||||
   * @param {number} offset 
 | 
			
		||||
   * @returns {Promise<object[]>}
 | 
			
		||||
   */
 | 
			
		||||
  async getRecentEpisodes(oldUser, oldLibrary, limit, offset) {
 | 
			
		||||
    const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(oldUser)
 | 
			
		||||
 | 
			
		||||
    const episodes = await Database.podcastEpisodeModel.findAll({
 | 
			
		||||
      where: {
 | 
			
		||||
        '$mediaProgresses.isFinished$': {
 | 
			
		||||
          [Sequelize.Op.or]: [null, false]
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      replacements: userPermissionPodcastWhere.replacements,
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.podcastModel,
 | 
			
		||||
          where: userPermissionPodcastWhere.podcastWhere,
 | 
			
		||||
          required: true,
 | 
			
		||||
          include: {
 | 
			
		||||
            model: Database.libraryItemModel,
 | 
			
		||||
            where: {
 | 
			
		||||
              libraryId: oldLibrary.id
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.mediaProgressModel,
 | 
			
		||||
          where: {
 | 
			
		||||
            userId: oldUser.id
 | 
			
		||||
          },
 | 
			
		||||
          required: false
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      order: [
 | 
			
		||||
        ['publishedAt', 'DESC']
 | 
			
		||||
      ],
 | 
			
		||||
      subQuery: false,
 | 
			
		||||
      limit,
 | 
			
		||||
      offset
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    const episodeResults = episodes.map((ep) => {
 | 
			
		||||
      const libraryItem = ep.podcast.libraryItem
 | 
			
		||||
      libraryItem.media = ep.podcast
 | 
			
		||||
      const oldPodcast = Database.podcastModel.getOldPodcast(libraryItem)
 | 
			
		||||
      const oldPodcastEpisode = ep.getOldPodcastEpisode(libraryItem.id).toJSONExpanded()
 | 
			
		||||
      oldPodcastEpisode.podcast = oldPodcast
 | 
			
		||||
      oldPodcastEpisode.libraryId = libraryItem.libraryId
 | 
			
		||||
      return oldPodcastEpisode
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    return episodeResults
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get stats for podcast library
 | 
			
		||||
   * @param {string} libraryId 
 | 
			
		||||
   * @returns {Promise<{ totalSize:number, totalDuration:number, numAudioFiles:number, totalItems:number}>}
 | 
			
		||||
   */
 | 
			
		||||
  async getPodcastLibraryStats(libraryId) {
 | 
			
		||||
    const [statResults] = await Database.sequelize.query(`SELECT SUM(json_extract(pe.audioFile, '$.duration')) AS totalDuration, SUM(li.size) AS totalSize, COUNT(DISTINCT(li.id)) AS totalItems, COUNT(pe.id) AS numAudioFiles FROM libraryItems li, podcasts p LEFT OUTER JOIN podcastEpisodes pe ON pe.podcastId = p.id WHERE p.id = li.mediaId AND li.libraryId = :libraryId;`, {
 | 
			
		||||
      replacements: {
 | 
			
		||||
        libraryId
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
    return statResults[0]
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Genres with num podcasts
 | 
			
		||||
   * @param {string} libraryId 
 | 
			
		||||
   * @returns {{genre:string, count:number}[]}
 | 
			
		||||
   */
 | 
			
		||||
  async getGenresWithCount(libraryId) {
 | 
			
		||||
    const genres = []
 | 
			
		||||
    const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.genres) WHERE json_valid(p.genres) AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC;`, {
 | 
			
		||||
      replacements: {
 | 
			
		||||
        libraryId
 | 
			
		||||
      },
 | 
			
		||||
      raw: true
 | 
			
		||||
    })
 | 
			
		||||
    for (const row of genreResults) {
 | 
			
		||||
      genres.push({
 | 
			
		||||
        genre: row.value,
 | 
			
		||||
        count: row.numItems
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
    return genres
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get longest podcasts in library
 | 
			
		||||
   * @param {string} libraryId 
 | 
			
		||||
   * @param {number} limit 
 | 
			
		||||
   * @returns {Promise<{ id:string, title:string, duration:number }[]>}
 | 
			
		||||
   */
 | 
			
		||||
  async getLongestPodcasts(libraryId, limit) {
 | 
			
		||||
    const podcasts = await Database.podcastModel.findAll({
 | 
			
		||||
      attributes: [
 | 
			
		||||
        'id',
 | 
			
		||||
        'title',
 | 
			
		||||
        [Sequelize.literal(`(SELECT SUM(json_extract(pe.audioFile, '$.duration')) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'duration']
 | 
			
		||||
      ],
 | 
			
		||||
      include: {
 | 
			
		||||
        model: Database.libraryItemModel,
 | 
			
		||||
        attributes: ['id', 'libraryId'],
 | 
			
		||||
        where: {
 | 
			
		||||
          libraryId
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      order: [
 | 
			
		||||
        ['duration', 'DESC']
 | 
			
		||||
      ],
 | 
			
		||||
      limit
 | 
			
		||||
    })
 | 
			
		||||
    return podcasts.map(podcast => {
 | 
			
		||||
      return {
 | 
			
		||||
        id: podcast.libraryItem.id,
 | 
			
		||||
        title: podcast.title,
 | 
			
		||||
        duration: podcast.dataValues.duration
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										206
									
								
								server/utils/queries/seriesFilters.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										206
									
								
								server/utils/queries/seriesFilters.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,206 @@
 | 
			
		||||
const Sequelize = require('sequelize')
 | 
			
		||||
const Logger = require('../../Logger')
 | 
			
		||||
const Database = require('../../Database')
 | 
			
		||||
const libraryItemsBookFilters = require('./libraryItemsBookFilters')
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
  decode(text) {
 | 
			
		||||
    return Buffer.from(decodeURIComponent(text), 'base64').toString()
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get series filtered and sorted
 | 
			
		||||
   * 
 | 
			
		||||
   * @param {import('../../objects/Library')} library 
 | 
			
		||||
   * @param {import('../../objects/user/User')} user 
 | 
			
		||||
   * @param {string} filterBy 
 | 
			
		||||
   * @param {string} sortBy 
 | 
			
		||||
   * @param {boolean} sortDesc 
 | 
			
		||||
   * @param {string[]} include 
 | 
			
		||||
   * @param {number} limit 
 | 
			
		||||
   * @param {number} offset 
 | 
			
		||||
   * @returns {Promise<{ series:object[], count:number }>}
 | 
			
		||||
   */
 | 
			
		||||
  async getFilteredSeries(library, user, filterBy, sortBy, sortDesc, include, limit, offset) {
 | 
			
		||||
    let filterValue = null
 | 
			
		||||
    let filterGroup = null
 | 
			
		||||
    if (filterBy) {
 | 
			
		||||
      const searchGroups = ['genres', 'tags', 'authors', 'progress', 'narrators', 'publishers', 'languages']
 | 
			
		||||
      const group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
 | 
			
		||||
      filterGroup = group || filterBy
 | 
			
		||||
      filterValue = group ? this.decode(filterBy.replace(`${group}.`, '')) : null
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const seriesIncludes = []
 | 
			
		||||
    if (include.includes('rssfeed')) {
 | 
			
		||||
      seriesIncludes.push({
 | 
			
		||||
        model: Database.feedModel
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const userPermissionBookWhere = libraryItemsBookFilters.getUserPermissionBookWhereQuery(user)
 | 
			
		||||
 | 
			
		||||
    const seriesWhere = [
 | 
			
		||||
      {
 | 
			
		||||
        libraryId: library.id
 | 
			
		||||
      }
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    // Handle library setting to hide single book series
 | 
			
		||||
    // TODO: Merge with existing query
 | 
			
		||||
    if (library.settings.hideSingleBookSeries) {
 | 
			
		||||
      seriesWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id)`), {
 | 
			
		||||
        [Sequelize.Op.gt]: 1
 | 
			
		||||
      }))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Handle filters
 | 
			
		||||
    // TODO: Simplify and break-out
 | 
			
		||||
    let attrQuery = null
 | 
			
		||||
    if (['genres', 'tags', 'narrators'].includes(filterGroup)) {
 | 
			
		||||
      attrQuery = `SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (SELECT count(*) FROM json_each(b.${filterGroup}) WHERE json_valid(b.${filterGroup}) AND json_each.value = :filterValue) > 0`
 | 
			
		||||
      userPermissionBookWhere.replacements.filterValue = filterValue
 | 
			
		||||
    } else if (filterGroup === 'authors') {
 | 
			
		||||
      attrQuery = 'SELECT count(*) FROM books b, bookSeries bs, bookAuthors ba WHERE bs.seriesId = series.id AND bs.bookId = b.id AND ba.bookId = b.id AND ba.authorId = :filterValue'
 | 
			
		||||
      userPermissionBookWhere.replacements.filterValue = filterValue
 | 
			
		||||
    } else if (filterGroup === 'publishers') {
 | 
			
		||||
      attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id AND b.publisher = :filterValue'
 | 
			
		||||
      userPermissionBookWhere.replacements.filterValue = filterValue
 | 
			
		||||
    } else if (filterGroup === 'languages') {
 | 
			
		||||
      attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id AND b.language = :filterValue'
 | 
			
		||||
      userPermissionBookWhere.replacements.filterValue = filterValue
 | 
			
		||||
    } else if (filterGroup === 'progress') {
 | 
			
		||||
      if (filterValue === 'not-finished') {
 | 
			
		||||
        attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)'
 | 
			
		||||
      } else if (filterValue === 'finished') {
 | 
			
		||||
        const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)'
 | 
			
		||||
        seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0))
 | 
			
		||||
      } else if (filterValue === 'not-started') {
 | 
			
		||||
        const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished = 1 OR mp.currentTime > 0)'
 | 
			
		||||
        seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0))
 | 
			
		||||
      } else if (filterValue === 'in-progress') {
 | 
			
		||||
        attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.currentTime > 0 OR mp.ebookProgress > 0) AND mp.isFinished = 0'
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Handle user permissions to only include series with at least 1 book
 | 
			
		||||
    // TODO: Simplify to a single query
 | 
			
		||||
    if (userPermissionBookWhere.bookWhere.length) {
 | 
			
		||||
      if (!attrQuery) attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id'
 | 
			
		||||
 | 
			
		||||
      if (!user.canAccessExplicitContent) {
 | 
			
		||||
        attrQuery += ' AND b.explicit = 0'
 | 
			
		||||
      }
 | 
			
		||||
      if (!user.permissions.accessAllTags && user.itemTagsSelected.length) {
 | 
			
		||||
        if (user.permissions.selectedTagsNotAccessible) {
 | 
			
		||||
          attrQuery += ' AND (SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)) = 0'
 | 
			
		||||
        } else {
 | 
			
		||||
          attrQuery += ' AND (SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)) > 0'
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (attrQuery) {
 | 
			
		||||
      seriesWhere.push(Sequelize.where(Sequelize.literal(`(${attrQuery})`), {
 | 
			
		||||
        [Sequelize.Op.gt]: 0
 | 
			
		||||
      }))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const order = []
 | 
			
		||||
    let seriesAttributes = {
 | 
			
		||||
      include: []
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Handle sort order
 | 
			
		||||
    const dir = sortDesc ? 'DESC' : 'ASC'
 | 
			
		||||
    if (sortBy === 'numBooks') {
 | 
			
		||||
      seriesAttributes.include.push([Sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 'numBooks'])
 | 
			
		||||
      order.push(['numBooks', dir])
 | 
			
		||||
    } else if (sortBy === 'addedAt') {
 | 
			
		||||
      order.push(['createdAt', dir])
 | 
			
		||||
    } else if (sortBy === 'name') {
 | 
			
		||||
      if (global.ServerSettings.sortingIgnorePrefix) {
 | 
			
		||||
        order.push([Sequelize.literal('nameIgnorePrefix COLLATE NOCASE'), dir])
 | 
			
		||||
      } else {
 | 
			
		||||
        order.push([Sequelize.literal('`series`.`name` COLLATE NOCASE'), dir])
 | 
			
		||||
      }
 | 
			
		||||
    } else if (sortBy === 'totalDuration') {
 | 
			
		||||
      seriesAttributes.include.push([Sequelize.literal('(SELECT SUM(b.duration) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'totalDuration'])
 | 
			
		||||
      order.push(['totalDuration', dir])
 | 
			
		||||
    } else if (sortBy === 'lastBookAdded') {
 | 
			
		||||
      seriesAttributes.include.push([Sequelize.literal('(SELECT MAX(b.createdAt) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'mostRecentBookAdded'])
 | 
			
		||||
      order.push(['mostRecentBookAdded', dir])
 | 
			
		||||
    } else if (sortBy === 'lastBookUpdated') {
 | 
			
		||||
      seriesAttributes.include.push([Sequelize.literal('(SELECT MAX(b.updatedAt) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'mostRecentBookUpdated'])
 | 
			
		||||
      order.push(['mostRecentBookUpdated', dir])
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const { rows: series, count } = await Database.seriesModel.findAndCountAll({
 | 
			
		||||
      where: seriesWhere,
 | 
			
		||||
      limit,
 | 
			
		||||
      offset,
 | 
			
		||||
      distinct: true,
 | 
			
		||||
      subQuery: false,
 | 
			
		||||
      benchmark: true,
 | 
			
		||||
      logging: (sql, timeMs) => {
 | 
			
		||||
        console.log(`[Query] Series filter/sort. Elapsed ${timeMs}ms`)
 | 
			
		||||
      },
 | 
			
		||||
      attributes: seriesAttributes,
 | 
			
		||||
      replacements: userPermissionBookWhere.replacements,
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.bookSeriesModel,
 | 
			
		||||
          include: {
 | 
			
		||||
            model: Database.bookModel,
 | 
			
		||||
            where: userPermissionBookWhere.bookWhere,
 | 
			
		||||
            include: [
 | 
			
		||||
              {
 | 
			
		||||
                model: Database.libraryItemModel
 | 
			
		||||
              }
 | 
			
		||||
            ]
 | 
			
		||||
          },
 | 
			
		||||
          separate: true
 | 
			
		||||
        },
 | 
			
		||||
        ...seriesIncludes
 | 
			
		||||
      ],
 | 
			
		||||
      order
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    // Map series to old series
 | 
			
		||||
    const allOldSeries = []
 | 
			
		||||
    for (const s of series) {
 | 
			
		||||
      const oldSeries = s.getOldSeries().toJSON()
 | 
			
		||||
 | 
			
		||||
      if (s.dataValues.totalDuration) {
 | 
			
		||||
        oldSeries.totalDuration = s.dataValues.totalDuration
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (s.feeds?.length) {
 | 
			
		||||
        oldSeries.rssFeed = Database.feedModel.getOldFeed(s.feeds[0]).toJSONMinified()
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // TODO: Sort books by sequence in query
 | 
			
		||||
      s.bookSeries.sort((a, b) => {
 | 
			
		||||
        if (!a.sequence) return 1
 | 
			
		||||
        if (!b.sequence) return -1
 | 
			
		||||
        return a.sequence.localeCompare(b.sequence, undefined, {
 | 
			
		||||
          numeric: true,
 | 
			
		||||
          sensitivity: 'base'
 | 
			
		||||
        })
 | 
			
		||||
      })
 | 
			
		||||
      oldSeries.books = s.bookSeries.map(bs => {
 | 
			
		||||
        const libraryItem = bs.book.libraryItem.toJSON()
 | 
			
		||||
        delete bs.book.libraryItem
 | 
			
		||||
        libraryItem.media = bs.book
 | 
			
		||||
        const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONMinified()
 | 
			
		||||
        return oldLibraryItem
 | 
			
		||||
      })
 | 
			
		||||
      allOldSeries.push(oldSeries)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      series: allOldSeries,
 | 
			
		||||
      count
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user