mirror of
https://github.com/informaticker/discord-jellyfin-bot.git
synced 2024-11-23 18:21:55 +01:00
✨ Add playlists playback
This commit is contained in:
parent
3f2b1a7b5f
commit
c218c1273d
@ -2,9 +2,11 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { JellyfinService } from './jellyfin.service';
|
import { JellyfinService } from './jellyfin.service';
|
||||||
|
|
||||||
import { SearchHint } from '@jellyfin/sdk/lib/generated-client/models';
|
import { SearchHint } from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
import { getSearchApi } from '@jellyfin/sdk/lib/utils/api/search-api';
|
|
||||||
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
|
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
|
||||||
|
import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api/playlists-api';
|
||||||
|
import { getSearchApi } from '@jellyfin/sdk/lib/utils/api/search-api';
|
||||||
import { Logger } from '@nestjs/common/services';
|
import { Logger } from '@nestjs/common/services';
|
||||||
|
import { JellyfinAudioPlaylist } from '../../models/jellyfinAudioItems';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JellyfinSearchService {
|
export class JellyfinSearchService {
|
||||||
@ -20,16 +22,39 @@ export class JellyfinSearchService {
|
|||||||
const searchApi = getSearchApi(api);
|
const searchApi = getSearchApi(api);
|
||||||
const {
|
const {
|
||||||
data: { SearchHints, TotalRecordCount },
|
data: { SearchHints, TotalRecordCount },
|
||||||
|
status,
|
||||||
} = await searchApi.get({
|
} = await searchApi.get({
|
||||||
searchTerm: searchTerm,
|
searchTerm: searchTerm,
|
||||||
mediaTypes: ['Audio', 'Album'],
|
mediaTypes: ['Audio', 'MusicAlbum', 'Playlist'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
this.logger.error(`Jellyfin Search failed with status code ${status}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.debug(`Found ${TotalRecordCount} results for '${searchTerm}'`);
|
this.logger.debug(`Found ${TotalRecordCount} results for '${searchTerm}'`);
|
||||||
|
|
||||||
return SearchHints;
|
return SearchHints;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPlaylistById(id: string): Promise<JellyfinAudioPlaylist> {
|
||||||
|
const api = this.jellyfinService.getApi();
|
||||||
|
const searchApi = getPlaylistsApi(api);
|
||||||
|
|
||||||
|
const axiosResponse = await searchApi.getPlaylistItems({
|
||||||
|
userId: this.jellyfinService.getUserId(),
|
||||||
|
playlistId: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (axiosResponse.status !== 200) {
|
||||||
|
this.logger.error(`Jellyfin Search failed with status code ${status}`);
|
||||||
|
return new JellyfinAudioPlaylist();
|
||||||
|
}
|
||||||
|
|
||||||
|
return axiosResponse.data as JellyfinAudioPlaylist;
|
||||||
|
}
|
||||||
|
|
||||||
async getById(id: string): Promise<SearchHint> {
|
async getById(id: string): Promise<SearchHint> {
|
||||||
const api = this.jellyfinService.getApi();
|
const api = this.jellyfinService.getApi();
|
||||||
|
|
||||||
|
@ -1,15 +1,13 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { JellyfinService } from './jellyfin.service';
|
import { JellyfinService } from './jellyfin.service';
|
||||||
|
|
||||||
import { getUniversalAudioApi } from '@jellyfin/sdk/lib/utils/api/universal-audio-api';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JellyfinStreamBuilderService {
|
export class JellyfinStreamBuilderService {
|
||||||
private readonly logger = new Logger(JellyfinStreamBuilderService.name);
|
private readonly logger = new Logger(JellyfinStreamBuilderService.name);
|
||||||
|
|
||||||
constructor(private readonly jellyfinService: JellyfinService) {}
|
constructor(private readonly jellyfinService: JellyfinService) {}
|
||||||
|
|
||||||
async buildStreamUrl(jellyfinItemId: string, bitrate: number) {
|
buildStreamUrl(jellyfinItemId: string, bitrate: number) {
|
||||||
const api = this.jellyfinService.getApi();
|
const api = this.jellyfinService.getApi();
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
|
@ -21,12 +21,14 @@ import { TrackRequestDto } from '../models/track-request.dto';
|
|||||||
|
|
||||||
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
||||||
|
|
||||||
import { formatDuration, intervalToDuration } from 'date-fns';
|
|
||||||
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
|
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
|
||||||
import { JellyfinStreamBuilderService } from '../clients/jellyfin/jellyfin.stream.builder.service';
|
import { JellyfinStreamBuilderService } from '../clients/jellyfin/jellyfin.stream.builder.service';
|
||||||
|
import {
|
||||||
|
BaseJellyfinAudioPlayable,
|
||||||
|
searchResultAsJellyfinAudio,
|
||||||
|
} from '../models/jellyfinAudioItems';
|
||||||
import { PlaybackService } from '../playback/playback.service';
|
import { PlaybackService } from '../playback/playback.service';
|
||||||
import { Constants } from '../utils/constants';
|
import { Constants } from '../utils/constants';
|
||||||
import { trimStringToFixedLength } from '../utils/stringUtils';
|
|
||||||
|
|
||||||
@Command({
|
@Command({
|
||||||
name: 'play',
|
name: 'play',
|
||||||
@ -51,8 +53,18 @@ export class PlayItemCommand
|
|||||||
executionContext: TransformedCommandExecutionContext<any>,
|
executionContext: TransformedCommandExecutionContext<any>,
|
||||||
): Promise<InteractionReplyOptions | string> {
|
): Promise<InteractionReplyOptions | string> {
|
||||||
const items = await this.jellyfinSearchService.search(dto.search);
|
const items = await this.jellyfinSearchService.search(dto.search);
|
||||||
|
const parsedItems = await Promise.all(
|
||||||
|
items.map(
|
||||||
|
async (item) =>
|
||||||
|
await searchResultAsJellyfinAudio(
|
||||||
|
this.logger,
|
||||||
|
this.jellyfinSearchService,
|
||||||
|
item,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if (items.length < 1) {
|
if (parsedItems.length < 1) {
|
||||||
return {
|
return {
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildErrorMessage({
|
this.discordMessageService.buildErrorMessage({
|
||||||
@ -63,15 +75,13 @@ export class PlayItemCommand
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstItems = items.slice(0, 10);
|
const firstItems = parsedItems.slice(0, 10);
|
||||||
|
|
||||||
const lines: string[] = firstItems.map(
|
const lines: string[] = firstItems.map((item, index) => {
|
||||||
(item) =>
|
let line = `${index + 1}. `;
|
||||||
`:white_small_square: ${trimStringToFixedLength(
|
line += item.prettyPrint(dto.search);
|
||||||
this.markSearchTermOverlap(item.Name, dto.search),
|
return line;
|
||||||
30,
|
});
|
||||||
)} [${item.Artists.join(', ')}] *(${item.Type})*`,
|
|
||||||
);
|
|
||||||
|
|
||||||
let description =
|
let description =
|
||||||
'I have found **' +
|
'I have found **' +
|
||||||
@ -87,22 +97,11 @@ export class PlayItemCommand
|
|||||||
|
|
||||||
description += '\n\n' + lines.join('\n');
|
description += '\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 }[] =
|
const selectOptions: { label: string; value: string; emoji?: string }[] =
|
||||||
firstItems.map((item) => ({
|
firstItems.map((item) => ({
|
||||||
label: `${item.Name} [${item.Artists.join(', ')}]`,
|
label: item.prettyPrint(dto.search),
|
||||||
value: item.Id,
|
value: item.getValueId(),
|
||||||
emoji: emojiForType(item.Type),
|
emoji: item.getEmoji(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -148,10 +147,6 @@ export class PlayItemCommand
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = await this.jellyfinSearchService.getById(
|
|
||||||
interaction.values[0],
|
|
||||||
);
|
|
||||||
|
|
||||||
const guildMember = interaction.member as GuildMember;
|
const guildMember = interaction.member as GuildMember;
|
||||||
|
|
||||||
const tryResult =
|
const tryResult =
|
||||||
@ -174,51 +169,73 @@ export class PlayItemCommand
|
|||||||
|
|
||||||
const bitrate = guildMember.voice.channel.bitrate;
|
const bitrate = guildMember.voice.channel.bitrate;
|
||||||
|
|
||||||
const stream = await this.jellyfinStreamBuilder.buildStreamUrl(
|
const valueParts = interaction.values[0].split('_');
|
||||||
item.Id,
|
const type = valueParts[0];
|
||||||
|
const id = valueParts[1];
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'track':
|
||||||
|
const item = await this.jellyfinSearchService.getById(id);
|
||||||
|
const addedIndex = this.enqueueSingleTrack(
|
||||||
|
item as BaseJellyfinAudioPlayable,
|
||||||
|
bitrate,
|
||||||
|
);
|
||||||
|
interaction.update({
|
||||||
|
embeds: [
|
||||||
|
this.discordMessageService.buildMessage({
|
||||||
|
title: item.Name,
|
||||||
|
description: `Your track was added to the position ${addedIndex} in the playlist`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
components: [],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'playlist':
|
||||||
|
const playlist = await this.jellyfinSearchService.getPlaylistById(id);
|
||||||
|
playlist.Items.forEach((item) => {
|
||||||
|
this.enqueueSingleTrack(item as BaseJellyfinAudioPlayable, bitrate);
|
||||||
|
});
|
||||||
|
interaction.update({
|
||||||
|
embeds: [
|
||||||
|
this.discordMessageService.buildMessage({
|
||||||
|
title: `Added ${playlist.TotalRecordCount} items from your playlist`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
components: [],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
interaction.update({
|
||||||
|
embeds: [
|
||||||
|
this.discordMessageService.buildErrorMessage({
|
||||||
|
title: 'Unable to process your selection',
|
||||||
|
description: `Sorry. I don't know the type you selected: \`\`${type}\`\`. Please report this bug to the developers.\n\nDebug Information:\`\`${interaction.values.join(
|
||||||
|
', ',
|
||||||
|
)}\`\``,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
components: [],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueueSingleTrack(
|
||||||
|
jellyfinPlayable: BaseJellyfinAudioPlayable,
|
||||||
|
bitrate: number,
|
||||||
|
) {
|
||||||
|
const stream = this.jellyfinStreamBuilder.buildStreamUrl(
|
||||||
|
jellyfinPlayable.Id,
|
||||||
bitrate,
|
bitrate,
|
||||||
);
|
);
|
||||||
|
|
||||||
const milliseconds = item.RunTimeTicks / 10000;
|
const milliseconds = jellyfinPlayable.RunTimeTicks / 10000;
|
||||||
|
|
||||||
const duration = formatDuration(
|
return this.playbackService.eneuqueTrack({
|
||||||
intervalToDuration({
|
jellyfinId: jellyfinPlayable.Id,
|
||||||
start: milliseconds,
|
name: jellyfinPlayable.Name,
|
||||||
end: 0,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const addedIndex = this.playbackService.eneuqueTrack({
|
|
||||||
jellyfinId: item.Id,
|
|
||||||
name: item.Name,
|
|
||||||
durationInMilliseconds: milliseconds,
|
durationInMilliseconds: milliseconds,
|
||||||
streamUrl: stream,
|
streamUrl: stream,
|
||||||
});
|
});
|
||||||
|
|
||||||
const artists = item.Artists.join(', ');
|
|
||||||
|
|
||||||
await interaction.update({
|
|
||||||
embeds: [
|
|
||||||
this.discordMessageService.buildMessage({
|
|
||||||
title: 'Jellyfin Search',
|
|
||||||
description: `**Duration**: ${duration}\n**Artists**: ${artists}\n\nTrack was added to the queue at position ${addedIndex}`,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
components: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
)}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
197
src/models/jellyfinAudioItems.ts
Normal file
197
src/models/jellyfinAudioItems.ts
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import { SearchHint } from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
|
import { JellyfinStreamBuilderService } from '../clients/jellyfin/jellyfin.stream.builder.service';
|
||||||
|
import { Track } from '../types/track';
|
||||||
|
import { trimStringToFixedLength } from '../utils/stringUtils';
|
||||||
|
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { JellyfinSearchService } from '../clients/jellyfin/jellyfin.search.service';
|
||||||
|
|
||||||
|
export interface BaseJellyfinAudioPlayable {
|
||||||
|
/**
|
||||||
|
* The primary identifier of the item
|
||||||
|
*/
|
||||||
|
Id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the item
|
||||||
|
*/
|
||||||
|
Name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The runtime in ticks. 10'000 ticks equal one second
|
||||||
|
*/
|
||||||
|
RunTimeTicks: number;
|
||||||
|
|
||||||
|
fromSearchHint(
|
||||||
|
jellyfinSearchService: JellyfinSearchService,
|
||||||
|
searchHint: SearchHint,
|
||||||
|
): Promise<BaseJellyfinAudioPlayable>;
|
||||||
|
|
||||||
|
fetchTracks(
|
||||||
|
jellyfinStreamBuilder: JellyfinStreamBuilderService,
|
||||||
|
bitrate: number,
|
||||||
|
): Track[];
|
||||||
|
|
||||||
|
prettyPrint(search: string): string;
|
||||||
|
|
||||||
|
getId(): string;
|
||||||
|
|
||||||
|
getValueId(): string;
|
||||||
|
|
||||||
|
getEmoji(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JellyfinAudioItem implements BaseJellyfinAudioPlayable {
|
||||||
|
Id: string;
|
||||||
|
Name: string;
|
||||||
|
RunTimeTicks: number;
|
||||||
|
ItemId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The year, when this was produced. Usually something like 2021
|
||||||
|
*/
|
||||||
|
ProductionYear?: number;
|
||||||
|
|
||||||
|
Album?: string;
|
||||||
|
|
||||||
|
AlbumId?: string;
|
||||||
|
|
||||||
|
AlbumArtist?: string;
|
||||||
|
|
||||||
|
Artists?: string[];
|
||||||
|
|
||||||
|
getValueId(): string {
|
||||||
|
return `track_${this.getId()}`;
|
||||||
|
}
|
||||||
|
async fromSearchHint(
|
||||||
|
jellyfinSearchService: JellyfinSearchService,
|
||||||
|
searchHint: SearchHint,
|
||||||
|
): Promise<BaseJellyfinAudioPlayable> {
|
||||||
|
this.Id = searchHint.Id;
|
||||||
|
this.ItemId = searchHint.ItemId;
|
||||||
|
this.Name = searchHint.Name;
|
||||||
|
this.RunTimeTicks = searchHint.RunTimeTicks;
|
||||||
|
this.Album = searchHint.Album;
|
||||||
|
this.AlbumArtist = searchHint.AlbumArtist;
|
||||||
|
this.AlbumId = searchHint.AlbumId;
|
||||||
|
this.Artists = searchHint.Artists;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
getEmoji(): string {
|
||||||
|
return '🎵';
|
||||||
|
}
|
||||||
|
|
||||||
|
getId(): string {
|
||||||
|
return this.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
prettyPrint(search: string): string {
|
||||||
|
let line = trimStringToFixedLength(
|
||||||
|
markSearchTermOverlap(this.Name, search),
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
if (this.Artists !== undefined && this.Artists.length > 0) {
|
||||||
|
line += ` [${this.Artists.join(', ')}]`;
|
||||||
|
}
|
||||||
|
line += ` *(Audio)*`;
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchTracks(
|
||||||
|
jellyfinStreamBuilder: JellyfinStreamBuilderService,
|
||||||
|
bitrate: number,
|
||||||
|
): Track[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: this.Name,
|
||||||
|
durationInMilliseconds: this.RunTimeTicks / 1000,
|
||||||
|
jellyfinId: this.Id,
|
||||||
|
streamUrl: jellyfinStreamBuilder.buildStreamUrl(this.Id, bitrate),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JellyfinAudioPlaylist implements BaseJellyfinAudioPlayable {
|
||||||
|
getValueId(): string {
|
||||||
|
return `playlist_${this.getId()}`;
|
||||||
|
}
|
||||||
|
async fromSearchHint(
|
||||||
|
jellyfinSearchService: JellyfinSearchService,
|
||||||
|
searchHint: SearchHint,
|
||||||
|
): Promise<BaseJellyfinAudioPlayable> {
|
||||||
|
this.Id = searchHint.Id;
|
||||||
|
this.Name = searchHint.Name;
|
||||||
|
this.RunTimeTicks = searchHint.RunTimeTicks;
|
||||||
|
const playlist = await jellyfinSearchService.getPlaylistById(searchHint.Id);
|
||||||
|
this.Items = playlist.Items;
|
||||||
|
this.TotalRecordCount = playlist.TotalRecordCount;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
getEmoji(): string {
|
||||||
|
return '📚';
|
||||||
|
}
|
||||||
|
|
||||||
|
getId(): string {
|
||||||
|
return this.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
prettyPrint(search: string): string {
|
||||||
|
return `${markSearchTermOverlap(this.Name, search)} (${
|
||||||
|
this.TotalRecordCount
|
||||||
|
} items) (Playlist)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchTracks(
|
||||||
|
jellyfinStreamBuilder: JellyfinStreamBuilderService,
|
||||||
|
bitrate: number,
|
||||||
|
): Track[] {
|
||||||
|
return this.Items.flatMap((item) =>
|
||||||
|
item.fetchTracks(jellyfinStreamBuilder, bitrate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Id: string;
|
||||||
|
Name: string;
|
||||||
|
RunTimeTicks: number;
|
||||||
|
Items: JellyfinAudioItem[];
|
||||||
|
TotalRecordCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchResultAsJellyfinAudio = async (
|
||||||
|
logger: Logger,
|
||||||
|
jellyfinSearchService: JellyfinSearchService,
|
||||||
|
searchHint: SearchHint,
|
||||||
|
) => {
|
||||||
|
switch (searchHint.Type) {
|
||||||
|
case 'Audio':
|
||||||
|
return await new JellyfinAudioItem().fromSearchHint(
|
||||||
|
jellyfinSearchService,
|
||||||
|
searchHint,
|
||||||
|
);
|
||||||
|
case 'Playlist':
|
||||||
|
return await new JellyfinAudioPlaylist().fromSearchHint(
|
||||||
|
jellyfinSearchService,
|
||||||
|
searchHint,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
logger.error(
|
||||||
|
`Failed to parse Jellyfin response for item type ${searchHint.Type}`,
|
||||||
|
);
|
||||||
|
null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const 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,
|
||||||
|
)}`;
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user