diff --git a/src/clients/jellyfin/jellyfin.module.ts b/src/clients/jellyfin/jellyfin.module.ts index dcefca0..02a8dcf 100644 --- a/src/clients/jellyfin/jellyfin.module.ts +++ b/src/clients/jellyfin/jellyfin.module.ts @@ -1,16 +1,20 @@ -import { Module, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; -import { JellyfinService } from "./jellyfin.service"; +import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { JellyfinSearchService } from './jellyfin.search.service'; +import { JellyfinService } from './jellyfin.service'; +import { JellyinWebsocketService } from './jellyfin.websocket.service'; @Module({ imports: [], controllers: [], - providers: [JellyfinService], - exports: [], + providers: [JellyfinService, JellyinWebsocketService, JellyfinSearchService], + exports: [JellyfinService, JellyfinSearchService], }) export class JellyfinClientModule implements OnModuleInit, OnModuleDestroy { - - constructor(private jellyfinService: JellyfinService) {} - + constructor( + private jellyfinService: JellyfinService, + private readonly jellyfinWebsocketService: JellyinWebsocketService, + ) {} + onModuleDestroy() { this.jellyfinService.destroy(); } @@ -18,5 +22,9 @@ export class JellyfinClientModule implements OnModuleInit, OnModuleDestroy { onModuleInit() { this.jellyfinService.init(); this.jellyfinService.authenticate(); + + setTimeout(() => { + this.jellyfinWebsocketService.openSocket(); + }, 5000); } -} \ No newline at end of file +} diff --git a/src/clients/jellyfin/jellyfin.search.service.ts b/src/clients/jellyfin/jellyfin.search.service.ts new file mode 100644 index 0000000..1b8d60f --- /dev/null +++ b/src/clients/jellyfin/jellyfin.search.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { JellyfinService } from './jellyfin.service'; + +import { SearchHint } from '@jellyfin/sdk/lib/generated-client/models'; +import { getSearchApi } from '@jellyfin/sdk/lib/utils/api/search-api'; +import { Logger } from '@nestjs/common/services'; + +@Injectable() +export class JellyfinSearchService { + private readonly logger = new Logger(JellyfinSearchService.name); + + constructor(private readonly jellyfinService: JellyfinService) {} + + async search(searchTerm: string): Promise { + const api = this.jellyfinService.getApi(); + + this.logger.debug(`Searching for '${searchTerm}'`); + + const searchApi = getSearchApi(api); + const { + data: { SearchHints, TotalRecordCount }, + } = await searchApi.get({ + searchTerm: searchTerm, + mediaTypes: ['Audio', 'Album'], + }); + + this.logger.debug(`Found ${TotalRecordCount} results for '${searchTerm}'`); + + return SearchHints; + } +} diff --git a/src/clients/jellyfin/jellyfin.service.ts b/src/clients/jellyfin/jellyfin.service.ts index 56433c7..5bcfa21 100644 --- a/src/clients/jellyfin/jellyfin.service.ts +++ b/src/clients/jellyfin/jellyfin.service.ts @@ -2,12 +2,16 @@ import { Injectable, Logger } from '@nestjs/common'; import { Api, Jellyfin } from '@jellyfin/sdk'; import { Constants } from '../../utils/constants'; +import { SystemApi } from '@jellyfin/sdk/lib/generated-client/api/system-api'; +import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api'; @Injectable() export class JellyfinService { private readonly logger = new Logger(JellyfinService.name); private jellyfin: Jellyfin; private api: Api; + private systemApi: SystemApi; + private userId: string; init() { this.jellyfin = new Jellyfin({ @@ -42,6 +46,9 @@ export class JellyfinService { this.logger.debug( `Connected using user '${response.data.SessionInfo.UserId}'`, ); + this.userId = response.data.SessionInfo.UserId; + + this.systemApi = getSystemApi(this.api); }) .catch((test) => { this.logger.error(test); @@ -57,4 +64,20 @@ export class JellyfinService { } this.api.logout(); } + + getApi() { + return this.api; + } + + getJellyfin() { + return this.jellyfin; + } + + getSystemApi() { + return this.systemApi; + } + + getUserId() { + return this.userId; + } } diff --git a/src/clients/jellyfin/jellyfin.websocket.service.ts b/src/clients/jellyfin/jellyfin.websocket.service.ts new file mode 100644 index 0000000..b7c447d --- /dev/null +++ b/src/clients/jellyfin/jellyfin.websocket.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { JellyfinService } from './jellyfin.service'; + +import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api'; + +@Injectable() +export class JellyinWebsocketService { + constructor(private readonly jellyfinClientManager: JellyfinService) {} + + async openSocket() { + const systemApi = getPlaystateApi(this.jellyfinClientManager.getApi()); + + // TODO: Write socket playstate api to report playback progress + } +} diff --git a/src/commands/command.module.ts b/src/commands/command.module.ts index 0d50d1b..3358d1b 100644 --- a/src/commands/command.module.ts +++ b/src/commands/command.module.ts @@ -1,21 +1,23 @@ -import { Module } from '@nestjs/common'; import { DiscordModule } from '@discord-nestjs/core'; +import { Module } from '@nestjs/common'; -import { HelpCommand } from './help.command'; -import { StatusCommand } from './status.command'; +import { DiscordMessageService } from '../clients/discord/discord.message.service'; +import { JellyfinClientModule } from '../clients/jellyfin/jellyfin.module'; +import { PlaybackService } from '../playback/playback.service'; import { CurrentTrackCommand } from './current.command'; import { DisconnectCommand } from './disconnect.command'; import { EnqueueCommand } from './enqueue.command'; +import { HelpCommand } from './help.command'; import { PausePlaybackCommand } from './pause.command'; import { PlayCommand } from './play.command'; +import { SearchItemCommand } from './search.comands'; import { SkipTrackCommand } from './skip.command'; +import { StatusCommand } from './status.command'; import { StopPlaybackCommand } from './stop.command'; import { SummonCommand } from './summon.command'; -import { DiscordMessageService } from '../clients/discord/discord.message.service'; -import { PlaybackService } from '../playback/playback.service'; @Module({ - imports: [DiscordModule.forFeature()], + imports: [DiscordModule.forFeature(), JellyfinClientModule], controllers: [], providers: [ HelpCommand, @@ -28,6 +30,7 @@ import { PlaybackService } from '../playback/playback.service'; SkipTrackCommand, StopPlaybackCommand, SummonCommand, + SearchItemCommand, DiscordMessageService, PlaybackService, ], diff --git a/src/commands/search.comands.ts b/src/commands/search.comands.ts new file mode 100644 index 0000000..bea924a --- /dev/null +++ b/src/commands/search.comands.ts @@ -0,0 +1,113 @@ +import { TransformPipe } from '@discord-nestjs/common'; + +import { + Command, + DiscordTransformedCommand, + Param, + Payload, + TransformedCommandExecutionContext, + UsePipes, +} from '@discord-nestjs/core'; +import { + APIEmbedField, + ComponentType, + EmbedBuilder, + InteractionReplyOptions, +} from 'discord.js'; +import { JellyfinSearchService } from '../clients/jellyfin/jellyfin.search.service'; +import { TrackRequestDto } from '../models/track-request.dto'; +import { DefaultJellyfinColor } from '../types/colors'; + +@Command({ + name: 'search', + description: 'Search for an item on your Jellyfin instance', +}) +@UsePipes(TransformPipe) +export class SearchItemCommand + implements DiscordTransformedCommand +{ + constructor(private readonly jellyfinSearchService: JellyfinSearchService) {} + + async handler( + @Payload() dto: TrackRequestDto, + executionContext: TransformedCommandExecutionContext, + ): Promise { + const items = await this.jellyfinSearchService.search(dto.search); + + const firstItems = items.slice(0, 10); + + const lines: string[] = firstItems.map( + (item) => + `:white_small_square: ${this.markSearchTermOverlap( + item.Name, + dto.search, + )} *(${item.Type})*`, + ); + + const description = `I have found **${ + items.length + }** results for your search \`\`${ + dto.search + }\`\`.\nFor better readability, I have limited the search results to 10\n\n ${lines.join( + '\n', + )}`; + + const emojiForType = (type: string) => { + switch (type) { + case 'Audio': + return '🎵'; + case 'Playlist': + return '📚'; + default: + return undefined; + } + }; + + const selectOptions: { label: string; value: string; emoji?: string }[] = + firstItems.map((item) => ({ + label: item.Name, + value: item.Id, + emoji: emojiForType(item.Type), + })); + + return { + embeds: [ + new EmbedBuilder() + .setAuthor({ + name: 'Jellyfin Search Results', + iconURL: + 'https://github.com/walkxcode/dashboard-icons/blob/main/png/jellyfin.png?raw=true', + }) + .setColor(DefaultJellyfinColor) + .setDescription(description) + .toJSON(), + ], + components: [ + { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.StringSelect, + customId: 'cool', + options: selectOptions, + }, + ], + }, + ], + }; + } + + private markSearchTermOverlap(value: string, searchTerm: string) { + const startIndex = value.indexOf(searchTerm); + const actualValue = value.substring( + startIndex, + startIndex + 1 + searchTerm.length, + ); + return `${value.substring( + 0, + startIndex, + )}**${actualValue}**${value.substring( + startIndex + 1 + actualValue.length, + )}`; + } +}