Add calendar page

This commit is contained in:
mfcar 2025-06-14 17:27:06 +01:00
parent 2dc93258f1
commit 0dc1b69305
No known key found for this signature in database
10 changed files with 534 additions and 1 deletions

View File

@ -65,7 +65,7 @@
<ui-context-menu-dropdown v-if="!isBatchSelecting && seriesContextMenuItems.length" :items="seriesContextMenuItems" class="mx-px" @action="seriesContextMenuAction" />
</template>
<!-- library & collections page -->
<template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome && !isAuthorsPage">
<template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && page !== 'calendar' && !isHome && !isAuthorsPage">
<p class="hidden md:block">{{ $formatNumber(numShowing) }} {{ entityName }}</p>
<div class="grow hidden sm:inline-block" />
@ -247,6 +247,9 @@ export default {
isPodcastLibrary() {
return this.currentLibraryMediaType === 'podcast'
},
isCalendarPage() {
return this.page === 'calendar'
},
isLibraryPage() {
return this.page === ''
},

View File

@ -22,6 +22,12 @@
<div v-show="isPodcastLatestPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/calendar`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isCalendarPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-symbols text-2xl">&#xe935;</span>
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCalendar }}</p>
<div v-show="isCalendarPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="showLibrary ? 'bg-primary/80' : 'bg-bg/60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
@ -173,6 +179,9 @@ export default {
isPodcastLatestPage() {
return this.$route.name === 'library-library-podcast-latest'
},
isCalendarPage() {
return this.$route.name === 'library-library-calendar'
},
homePage() {
return this.$route.name === 'library-library'
},

View File

@ -140,6 +140,10 @@
<p class="text-xs ml-1 text-white/60">{{ $strings.LabelExample }}: {{ timeExample }}</p>
</div>
<div class="py-2">
<ui-dropdown :label="$strings.LabelCalendarFirstDayOfWeek" v-model="newServerSettings.calendarFirstDayOfWeek" :items="firstDayOfWeekOptions" small class="max-w-52" @input="(val) => updateSettingsKey('calendarFirstDayOfWeek', val)" />
</div>
<div class="py-2">
<ui-dropdown :label="$strings.LabelLanguageDefaultServer" ref="langDropdown" v-model="newServerSettings.language" :items="$languageCodeOptions" small class="max-w-52" @input="updateServerLanguage" />
</div>
@ -271,6 +275,12 @@ export default {
timeExample() {
const date = new Date(2014, 2, 25, 17, 30, 0)
return this.$formatJsTime(date, this.newServerSettings.timeFormat)
},
firstDayOfWeekOptions() {
return this.$store.state.globals.firstDayOfWeekOptions.map((option) => ({
text: this.$strings[option.labelKey] || option.text,
value: option.value
}))
}
},
methods: {
@ -354,6 +364,10 @@ export default {
this.newServerSettings.sortingPrefixes = [...(this.newServerSettings.sortingPrefixes || [])]
this.scannerEnableWatcher = !this.newServerSettings.scannerDisableWatcher
if (this.newServerSettings.calendarFirstDayOfWeek === undefined) {
this.newServerSettings.calendarFirstDayOfWeek = 0
}
this.homepageUseBookshelfView = this.newServerSettings.homeBookshelfView != this.$constants.BookshelfView.DETAIL
this.useBookshelfView = this.newServerSettings.bookshelfView != this.$constants.BookshelfView.DETAIL
},

View File

@ -0,0 +1,378 @@
<template>
<div class="page" :class="libraryItemIdStreaming ? 'streaming' : ''">
<app-book-shelf-toolbar page="calendar" />
<div id="bookshelf" class="w-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
<div class="w-full max-w-6xl mx-auto py-4">
<!-- Mobile Layout (stacked) -->
<div class="block md:hidden mb-4 px-2 relative z-50">
<div class="text-center mb-3">
<p class="text-xl font-semibold">{{ $strings.HeaderCalendar }}</p>
</div>
<div class="flex items-center justify-center space-x-2 mb-3">
<ui-btn small @click="navigateMonth(-1)" :disabled="processing" class="h-9 px-2">
<span class="material-symbols text-lg">chevron_left</span>
</ui-btn>
<div class="flex items-center space-x-2 relative z-50">
<ui-dropdown v-model="selectedMonth" :items="monthOptions" small class="w-28" @input="onMonthYearChange" :disabled="processing" />
<ui-dropdown v-model="selectedYear" :items="yearOptions" small class="w-20" @input="onMonthYearChange" :disabled="processing" />
</div>
<ui-btn small @click="navigateMonth(1)" :disabled="processing" class="h-9 px-2">
<span class="material-symbols text-lg">chevron_right</span>
</ui-btn>
</div>
<div class="flex justify-center">
<ui-btn small @click="goToToday" :disabled="processing || isCurrentMonth" class="h-9">
{{ $strings.ButtonToday }}
</ui-btn>
</div>
</div>
<!-- Desktop Layout (single row) -->
<div class="hidden md:flex items-center justify-between mb-4 px-4 md:px-0 relative z-50">
<p class="text-xl font-semibold">{{ $strings.HeaderCalendar }}</p>
<div class="flex items-center space-x-2">
<ui-btn small @click="navigateMonth(-1)" :disabled="processing" class="h-9 px-2">
<span class="material-symbols text-lg">chevron_left</span>
</ui-btn>
<div class="flex items-center space-x-2 min-w-64 relative z-50">
<ui-dropdown v-model="selectedMonth" :items="monthOptions" small class="w-32" @input="onMonthYearChange" :disabled="processing" />
<ui-dropdown v-model="selectedYear" :items="yearOptions" small class="w-20" @input="onMonthYearChange" :disabled="processing" />
</div>
<ui-btn small @click="navigateMonth(1)" :disabled="processing" class="h-9 px-2">
<span class="material-symbols text-lg">chevron_right</span>
</ui-btn>
<ui-btn small @click="goToToday" :disabled="processing || isCurrentMonth" class="ml-2 h-9">
{{ $strings.ButtonToday }}
</ui-btn>
</div>
</div>
<div v-if="processing" class="flex justify-center py-8">
<ui-loading-indicator />
</div>
<div v-else class="bg-primary/25 rounded-lg overflow-hidden">
<div class="grid grid-cols-7 bg-primary/40">
<div v-for="day in weekDays" :key="day" class="p-2 sm:p-4 text-center font-semibold text-gray-200 border-r border-black/20 last:border-r-0">
<span class="text-sm sm:text-base">{{ day }}</span>
</div>
</div>
<div class="grid grid-cols-7">
<div
v-for="day in calendarDays"
:key="`${day.date}-${day.isCurrentMonth}`"
class="min-h-24 sm:min-h-32 border-r border-b border-black/20 last:border-r-0 relative"
:class="{
'bg-black/20': !day.isCurrentMonth,
'bg-blue-600/30': day.isCurrentMonth && day.isToday,
'bg-transparent': day.isCurrentMonth && !day.isToday
}"
>
<div v-if="day.isToday" class="absolute inset-0 border-2 border-blue-500 pointer-events-none" style="z-index: 5"></div>
<div v-if="day.isToday" class="absolute top-1 right-1 w-2 h-2 bg-blue-500 rounded-full" style="z-index: 6"></div>
<div class="p-1 sm:p-2 relative" style="z-index: 1">
<span
class="text-xs sm:text-sm font-medium"
:class="{
'text-gray-500': !day.isCurrentMonth,
'text-blue-200 font-semibold': day.isCurrentMonth && day.isToday,
'text-gray-200': day.isCurrentMonth && !day.isToday
}"
>
{{ day.dayNumber }}
</span>
</div>
<div class="px-1 sm:px-2 pb-1 sm:pb-2 space-y-1 relative" style="z-index: 1">
<div v-for="episode in day.episodes" :key="episode.id" @click="clickEpisode(episode)" class="p-1 rounded text-xs cursor-pointer hover:bg-white/10 transition-colors" :class="getEpisodeColorClass(episode)">
<div class="font-medium truncate text-xs">
{{ episode.podcastTitle }}
</div>
<div class="text-gray-300 truncate text-xs">
<span v-if="episode.season || episode.episode" class="mr-1">
<span v-if="episode.season">S{{ episode.season }}</span
><span v-if="episode.episode">E{{ episode.episode }}</span>
</span>
{{ episode.title }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
async asyncData({ params, redirect, store }) {
var libraryId = params.library
var libraryData = await store.dispatch('libraries/fetch', libraryId)
if (!libraryData) {
return redirect('/oops?message=Library not found')
}
const library = libraryData.library
if (library.mediaType === 'book') {
return redirect(`/library/${libraryId}`)
}
return {
libraryId
}
},
data() {
return {
currentDate: new Date(),
episodes: [],
processing: false,
openingItem: false,
selectedMonth: new Date().getMonth(),
selectedYear: new Date().getFullYear(),
itemColors: [
'bg-blue-600/80',
'bg-sky-600/80',
'bg-cyan-600/80',
'bg-indigo-600/80',
'bg-green-600/80',
'bg-emerald-600/80',
'bg-teal-600/80',
'bg-lime-600/80',
'bg-purple-600/80',
'bg-violet-600/80',
'bg-fuchsia-600/80',
'bg-pink-600/80',
'bg-red-600/80',
'bg-rose-600/80',
'bg-orange-600/80',
'bg-amber-600/80',
'bg-yellow-600/80',
'bg-yellow-500/80',
'bg-slate-600/80',
'bg-gray-600/80',
'bg-zinc-600/80',
'bg-stone-600/80',
'bg-blue-500/80',
'bg-green-500/80',
'bg-purple-500/80',
'bg-red-500/80',
'bg-orange-500/80',
'bg-pink-500/80',
'bg-indigo-500/80',
'bg-teal-500/80'
]
}
},
computed: {
libraryItemIdStreaming() {
return this.$store.getters['getLibraryItemIdStreaming']
},
firstDayOfWeek() {
return this.$store.state.serverSettings?.calendarFirstDayOfWeek || 0
},
monthOptions() {
const monthNames = [
this.$strings.LabelCalendarJanuary,
this.$strings.LabelCalendarFebruary,
this.$strings.LabelCalendarMarch,
this.$strings.LabelCalendarApril,
this.$strings.LabelCalendarMay,
this.$strings.LabelCalendarJune,
this.$strings.LabelCalendarJuly,
this.$strings.LabelCalendarAugust,
this.$strings.LabelCalendarSeptember,
this.$strings.LabelCalendarOctober,
this.$strings.LabelCalendarNovember,
this.$strings.LabelCalendarDecember
]
return monthNames.map((name, index) => ({
text: name,
value: index
}))
},
yearOptions() {
const currentYear = new Date().getFullYear()
const startYear = currentYear - 10
const endYear = currentYear + 2
const years = []
for (let year = endYear; year >= startYear; year--) {
years.push({
text: year.toString(),
value: year
})
}
return years
},
weekDays() {
const allDays = [this.$strings.LabelCalendarSundayShort, this.$strings.LabelCalendarMondayShort, this.$strings.LabelCalendarTuesdayShort, this.$strings.LabelCalendarWednesdayShort, this.$strings.LabelCalendarThursdayShort, this.$strings.LabelCalendarFridayShort, this.$strings.LabelCalendarSaturdayShort]
if (this.firstDayOfWeek === 1) {
return [...allDays.slice(1), allDays[0]]
}
return allDays
},
isCurrentMonth() {
const today = new Date()
return this.currentDate.getFullYear() === today.getFullYear() && this.currentDate.getMonth() === today.getMonth()
},
calendarDays() {
const year = this.currentDate.getFullYear()
const month = this.currentDate.getMonth()
const firstDay = new Date(year, month, 1)
const firstCalendarDay = new Date(firstDay)
let daysToSubtract = firstDay.getDay() - this.firstDayOfWeek
if (daysToSubtract < 0) {
daysToSubtract += 7
}
firstCalendarDay.setDate(firstCalendarDay.getDate() - daysToSubtract)
const days = []
const today = new Date()
const currentDay = new Date(firstCalendarDay)
for (let i = 0; i < 42; i++) {
const dayKey = this.formatDateKey(currentDay)
const dayEpisodes = this.episodes.filter((episode) => {
if (!episode.publishedAt) return false
const episodeDate = new Date(episode.publishedAt)
return this.formatDateKey(episodeDate) === dayKey
})
days.push({
date: new Date(currentDay),
dayNumber: currentDay.getDate(),
isCurrentMonth: currentDay.getMonth() === month,
isToday: this.isSameDay(currentDay, today),
episodes: dayEpisodes
})
currentDay.setDate(currentDay.getDate() + 1)
}
return days
}
},
watch: {
currentDate: {
handler(newDate) {
this.selectedMonth = newDate.getMonth()
this.selectedYear = newDate.getFullYear()
},
immediate: true
}
},
methods: {
async navigateMonth(direction) {
const newDate = new Date(this.currentDate)
newDate.setMonth(newDate.getMonth() + direction)
this.currentDate = newDate
await this.loadMonthEpisodes()
},
async goToToday() {
if (this.isCurrentMonth) return
this.currentDate = new Date()
await this.loadMonthEpisodes()
},
async onMonthYearChange() {
const newDate = new Date(this.selectedYear, this.selectedMonth, 1)
this.currentDate = newDate
await this.loadMonthEpisodes()
},
async loadMonthEpisodes() {
this.processing = true
const year = this.currentDate.getFullYear()
const month = this.currentDate.getMonth()
const startOfMonth = new Date(year, month, 1)
const endOfMonth = new Date(year, month + 1, 0)
const startDate = new Date(startOfMonth)
startDate.setDate(startDate.getDate() - 7)
const endDate = new Date(endOfMonth)
endDate.setDate(endDate.getDate() + 7)
try {
const episodePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/episodes-calendar`, {
params: {
start: startDate.toISOString(),
end: endDate.toISOString()
}
})
this.episodes = episodePayload.episodes || []
} catch (error) {
console.error('Failed to get calendar episodes', error)
this.$toast.error(this.$strings.ToastFailedToLoadData)
this.episodes = []
} finally {
this.processing = false
}
},
async clickEpisode(episode) {
if (this.openingItem) return
this.openingItem = true
try {
const fullLibraryItem = await this.$axios.$get(`/api/items/${episode.libraryItemId}`)
this.$store.commit('setSelectedLibraryItem', fullLibraryItem)
this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowViewPodcastEpisodeModal', true)
} catch (error) {
console.error('Failed to get library item', error)
this.$toast.error('Failed to get library item')
} finally {
this.openingItem = false
}
},
formatDateKey(date) {
return date.toISOString().split('T')[0]
},
isSameDay(date1, date2) {
return this.formatDateKey(date1) === this.formatDateKey(date2)
},
getEpisodeColorClass(episode) {
const podcastId = episode.podcastId || episode.libraryItemId
const hash = this.simpleHash(podcastId)
return this.itemColors[hash % this.itemColors.length]
},
simpleHash(str) {
let hash = 0
if (!str) return hash
const stringToHash = str.toString()
for (let i = 0; i < stringToHash.length; i++) {
const char = stringToHash.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash
}
return Math.abs(hash)
}
},
mounted() {
this.loadMonthEpisodes()
}
}
</script>
<style scoped>
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
</style>

View File

@ -71,6 +71,18 @@ export const state = () => ({
value: 'HH:mm'
}
],
firstDayOfWeekOptions: [
{
text: 'Sunday',
value: 0,
labelKey: 'LabelCalendarFirstDayOfWeekSunday'
},
{
text: 'Monday',
value: 1,
labelKey: 'LabelCalendarFirstDayOfWeekMonday'
}
],
podcastTypes: [
{ text: 'Episodic', value: 'episodic', descriptionKey: 'LabelEpisodic' },
{ text: 'Serial', value: 'serial', descriptionKey: 'LabelSerial' }

View File

@ -13,6 +13,7 @@
"ButtonBatchEditPopulateFromExisting": "Populate from existing",
"ButtonBatchEditPopulateMapDetails": "Populate map details",
"ButtonBrowseForFolder": "Browse for Folder",
"ButtonCalendar": "Calendar",
"ButtonCancel": "Cancel",
"ButtonCancelEncode": "Cancel Encode",
"ButtonChangeRootPassword": "Change Root Password",
@ -104,6 +105,7 @@
"ButtonStats": "Stats",
"ButtonSubmit": "Submit",
"ButtonTest": "Test",
"ButtonToday": "Today",
"ButtonUnlinkOpenId": "Unlink OpenID",
"ButtonUpload": "Upload",
"ButtonUploadBackup": "Upload Backup",
@ -124,6 +126,7 @@
"HeaderAudiobookTools": "Audiobook File Management Tools",
"HeaderAuthentication": "Authentication",
"HeaderBackups": "Backups",
"HeaderCalendar": "Calendar",
"HeaderChangePassword": "Change Password",
"HeaderChapters": "Chapters",
"HeaderChooseAFolder": "Choose a Folder",
@ -265,6 +268,35 @@
"LabelBooks": "Books",
"LabelButtonText": "Button Text",
"LabelByAuthor": "by {0}",
"LabelCalendarApril": "April",
"LabelCalendarAugust": "August",
"LabelCalendarDecember": "December",
"LabelCalendarFebruary": "February",
"LabelCalendarFirstDayOfWeek": "First day of week",
"LabelCalendarFirstDayOfWeekMonday": "Monday",
"LabelCalendarFirstDayOfWeekSunday": "Sunday",
"LabelCalendarFriday": "Friday",
"LabelCalendarFridayShort": "Fri",
"LabelCalendarJanuary": "January",
"LabelCalendarJuly": "July",
"LabelCalendarJune": "June",
"LabelCalendarMarch": "March",
"LabelCalendarMay": "May",
"LabelCalendarMonday": "Monday",
"LabelCalendarMondayShort": "Mon",
"LabelCalendarNovember": "November",
"LabelCalendarOctober": "October",
"LabelCalendarSaturday": "Saturday",
"LabelCalendarSaturdayShort": "Sat",
"LabelCalendarSeptember": "September",
"LabelCalendarSunday": "Sunday",
"LabelCalendarSundayShort": "Sun",
"LabelCalendarThursday": "Thursday",
"LabelCalendarThursdayShort": "Thu",
"LabelCalendarTuesday": "Tuesday",
"LabelCalendarTuesdayShort": "Tue",
"LabelCalendarWednesday": "Wednesday",
"LabelCalendarWednesdayShort": "Wed",
"LabelChangePassword": "Change Password",
"LabelChannels": "Channels",
"LabelChapterCount": "{0} Chapters",

View File

@ -352,6 +352,7 @@ class Server {
'/library/:library/series/:id?',
'/library/:library/podcast/search',
'/library/:library/podcast/latest',
'/library/:library/podcast/calendar',
'/library/:library/podcast/download-queue',
'/config/users/:id',
'/config/users/:id/sessions',

View File

@ -1460,6 +1460,86 @@ class LibraryController {
}
}
/**
* GET: /api/libraries/:id/episodes-calendar
* Get podcast episodes for calendar view within date range
*
* @param {LibraryControllerRequest} req
* @param {Response} res
*/
async getEpisodesCalendar(req, res) {
const { start, end, limit = 1000 } = req.query
if (!start || !end) {
return res.status(400).send('Start and end date parameters are required')
}
const startDate = new Date(start)
const endDate = new Date(end)
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return res.status(400).send('Invalid date format')
}
if (req.library.mediaType !== 'podcast') {
return res.status(400).send('Library is not a podcast library')
}
const libraryItems = await Database.libraryItemModel.findAll({
where: { libraryId: req.library.id },
include: [
{
model: Database.podcastModel,
include: [
{
model: Database.podcastEpisodeModel,
where: {
publishedAt: {
[Sequelize.Op.between]: [startDate, endDate]
}
},
required: true
}
],
required: true
}
]
})
const episodes = []
for (const libraryItem of libraryItems) {
for (const episode of libraryItem.media.podcastEpisodes) {
episodes.push({
id: episode.id,
title: episode.title,
subtitle: episode.subtitle,
description: episode.description,
season: episode.season,
episode: episode.episode,
episodeType: episode.episodeType,
publishedAt: episode.publishedAt,
duration: episode.audioFile?.duration || 0,
audioFile: episode.audioFile,
libraryItemId: libraryItem.id,
podcastId: libraryItem.media.id,
podcastTitle: libraryItem.media.title,
podcastAuthor: libraryItem.media.author,
podcastExplicit: libraryItem.media.explicit,
coverPath: libraryItem.media.coverPath
})
}
}
episodes.sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt))
const limitedEpisodes = episodes.slice(0, parseInt(limit))
res.json({
episodes: limitedEpisodes,
total: episodes.length
})
}
/**
*
* @param {RequestWithUser} req

View File

@ -52,6 +52,7 @@ class ServerSettings {
this.dateFormat = 'MM/dd/yyyy'
this.timeFormat = 'HH:mm'
this.language = 'en-us'
this.calendarFirstDayOfWeek = 0
this.logLevel = Logger.logLevel
@ -119,6 +120,7 @@ class ServerSettings {
this.dateFormat = settings.dateFormat || 'MM/dd/yyyy'
this.timeFormat = settings.timeFormat || 'HH:mm'
this.language = settings.language || 'en-us'
this.calendarFirstDayOfWeek = settings.calendarFirstDayOfWeek !== undefined ? Number(settings.calendarFirstDayOfWeek) : 0
this.logLevel = settings.logLevel || Logger.logLevel
this.version = settings.version || null
this.buildNumber = settings.buildNumber || 0 // Added v2.4.5
@ -230,6 +232,7 @@ class ServerSettings {
dateFormat: this.dateFormat,
timeFormat: this.timeFormat,
language: this.language,
calendarFirstDayOfWeek: this.calendarFirstDayOfWeek,
logLevel: this.logLevel,
version: this.version,
buildNumber: this.buildNumber,

View File

@ -74,6 +74,7 @@ class ApiRouter {
this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this))
this.router.delete('/libraries/:id/issues', LibraryController.middleware.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this))
this.router.get('/libraries/:id/episode-downloads', LibraryController.middleware.bind(this), LibraryController.getEpisodeDownloadQueue.bind(this))
this.router.get('/libraries/:id/episodes-calendar', LibraryController.middleware, LibraryController.getEpisodesCalendar.bind(this))
this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))
this.router.get('/libraries/:id/series/:seriesId', LibraryController.middleware.bind(this), LibraryController.getSeriesForLibrary.bind(this))
this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this))