Compare commits

...

4 Commits

Author SHA1 Message Date
advplyr
49997a1336 Fix Dockerfile to include nunicode in the final stage 2025-05-16 15:23:19 -05:00
advplyr
8d0434143c Merge pull request #4293 from advplyr/search_episodes
Add support for searching podcast episode titles #3301
2025-05-15 17:51:51 -05:00
advplyr
8e0319994e Update total results in global search component 2025-05-15 17:22:20 -05:00
advplyr
0ed6045d1e Add support for searching podcast episode titles #3301 2025-05-15 17:16:15 -05:00
6 changed files with 138 additions and 7 deletions

View File

@@ -1,5 +1,9 @@
ARG NUSQLITE3_DIR="/usr/local/lib/nusqlite3"
ARG NUSQLITE3_PATH="${NUSQLITE3_DIR}/libnusqlite3.so"
### STAGE 0: Build client ###
FROM node:20-alpine AS build-client
WORKDIR /client
COPY /client /client
RUN npm ci && npm cache clean --force
@@ -8,6 +12,9 @@ RUN npm run generate
### STAGE 1: Build server ###
FROM node:20-alpine AS build-server
ARG NUSQLITE3_DIR
ARG TARGETPLATFORM
ENV NODE_ENV=production
RUN apk add --no-cache --update \
@@ -21,11 +28,6 @@ WORKDIR /server
COPY index.js package* /server
COPY /server /server/server
ARG TARGETPLATFORM
ENV NUSQLITE3_DIR="/usr/local/lib/nusqlite3"
ENV NUSQLITE3_PATH="${NUSQLITE3_DIR}/libnusqlite3.so"
RUN case "$TARGETPLATFORM" in \
"linux/amd64") \
curl -L -o /tmp/library.zip "https://github.com/mikiher/nunicode-sqlite/releases/download/v1.2/libnusqlite3-linux-musl-x64.zip" ;; \
@@ -41,6 +43,9 @@ RUN npm ci --only=production
### STAGE 2: Create minimal runtime image ###
FROM node:20-alpine
ARG NUSQLITE3_DIR
ARG NUSQLITE3_PATH
# Install only runtime dependencies
RUN apk add --no-cache --update \
tzdata \
@@ -52,13 +57,17 @@ WORKDIR /app
# Copy compiled frontend and server from build stages
COPY --from=build-client /client/dist /app/client/dist
COPY --from=build-server /server /app
COPY --from=build-server /usr/local/lib/nusqlite3 /usr/local/lib/nusqlite3
EXPOSE 80
ENV PORT=80
ENV NODE_ENV=production
ENV CONFIG_PATH="/config"
ENV METADATA_PATH="/metadata"
ENV SOURCE="docker"
ENV NUSQLITE3_DIR=${NUSQLITE3_DIR}
ENV NUSQLITE3_PATH=${NUSQLITE3_PATH}
ENTRYPOINT ["tini", "--"]
CMD ["node", "index.js"]

View File

@@ -217,6 +217,16 @@ export default {
})
}
if (this.results.episodes?.length) {
shelves.push({
id: 'episodes',
label: 'Episodes',
labelStringKey: 'LabelEpisodes',
type: 'episode',
entities: this.results.episodes.map((res) => res.libraryItem)
})
}
if (this.results.series?.length) {
shelves.push({
id: 'series',

View File

@@ -0,0 +1,60 @@
<template>
<div class="flex items-center h-full px-1 overflow-hidden">
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="grow px-2 episodeSearchCardContent">
<p class="truncate text-sm">{{ episodeTitle }}</p>
<p class="text-xs text-gray-200 truncate">{{ podcastTitle }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
},
episode: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
coverWidth() {
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
return 50
},
media() {
return this.libraryItem?.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
episodeTitle() {
return this.episode.title || 'No Title'
},
podcastTitle() {
return this.mediaMetadata.title || 'No Title'
}
},
methods: {},
mounted() {}
}
</script>
<style>
.episodeSearchCardContent {
width: calc(100% - 80px);
height: 75px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View File

@@ -39,6 +39,15 @@
</li>
</template>
<p v-if="episodeResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelEpisodes }}</p>
<template v-for="item in episodeResults">
<li :key="item.libraryItem.recentEpisode.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/item/${item.libraryItem.id}`">
<cards-episode-search-card :episode="item.libraryItem.recentEpisode" :library-item="item.libraryItem" />
</nuxt-link>
</li>
</template>
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelAuthors }}</p>
<template v-for="item in authorResults">
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
@@ -100,6 +109,7 @@ export default {
isFetching: false,
search: null,
podcastResults: [],
episodeResults: [],
bookResults: [],
authorResults: [],
seriesResults: [],
@@ -115,7 +125,7 @@ export default {
return this.$store.state.libraries.currentLibraryId
},
totalResults() {
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.genreResults.length + this.podcastResults.length + this.narratorResults.length
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.genreResults.length + this.podcastResults.length + this.narratorResults.length + this.episodeResults.length
}
},
methods: {
@@ -132,6 +142,7 @@ export default {
this.search = null
this.lastSearch = null
this.podcastResults = []
this.episodeResults = []
this.bookResults = []
this.authorResults = []
this.seriesResults = []
@@ -175,6 +186,7 @@ export default {
if (!this.isFetching) return
this.podcastResults = searchResults.podcast || []
this.episodeResults = searchResults.episodes || []
this.bookResults = searchResults.book || []
this.authorResults = searchResults.authors || []
this.seriesResults = searchResults.series || []

View File

@@ -22,6 +22,7 @@ export default {
})
results = {
podcasts: results?.podcast || [],
episodes: results?.episodes || [],
books: results?.book || [],
authors: results?.authors || [],
series: results?.series || [],
@@ -61,6 +62,7 @@ export default {
})
this.results = {
podcasts: results?.podcast || [],
episodes: results?.episodes || [],
books: results?.book || [],
authors: results?.authors || [],
series: results?.series || [],

View File

@@ -411,6 +411,43 @@ module.exports = {
})
}
// Search podcast episode title
const podcastEpisodes = await Database.podcastEpisodeModel.findAll({
where: [
Sequelize.literal(textSearchQuery.matchExpression('podcastEpisode.title')),
{
'$podcast.libraryItem.libraryId$': library.id
}
],
replacements: userPermissionPodcastWhere.replacements,
include: [
{
model: Database.podcastModel,
where: [...userPermissionPodcastWhere.podcastWhere],
include: [
{
model: Database.libraryItemModel
}
]
}
],
distinct: true,
offset,
limit
})
const episodeMatches = []
for (const episode of podcastEpisodes) {
const libraryItem = episode.podcast.libraryItem
libraryItem.media = episode.podcast
libraryItem.media.podcastEpisodes = []
const oldPodcastEpisodeJson = episode.toOldJSONExpanded(libraryItem.id)
const libraryItemJson = libraryItem.toOldJSONExpanded()
libraryItemJson.recentEpisode = oldPodcastEpisodeJson
episodeMatches.push({
libraryItem: libraryItemJson
})
}
const matchJsonValue = textSearchQuery.matchExpression('json_each.value')
// Search tags
@@ -450,7 +487,8 @@ module.exports = {
return {
podcast: itemMatches,
tags: tagMatches,
genres: genreMatches
genres: genreMatches,
episodes: episodeMatches
}
},