Merge branch 'development' into related-similar-songs

This commit is contained in:
Jeff 2024-03-04 05:04:54 -08:00 committed by GitHub
commit 132b0e173f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 891 additions and 565 deletions

View file

@ -54,8 +54,8 @@ import type {
StructuredLyric,
SimilarSongsArgs,
Song,
ServerType,
} from '/@/renderer/api/types';
import { ServerType } from '/@/renderer/types';
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
import { ssController } from '/@/renderer/api/subsonic/subsonic-controller';
@ -177,7 +177,7 @@ const endpoints: ApiController = {
getPlaylistList: ndController.getPlaylistList,
getPlaylistSongList: ndController.getPlaylistSongList,
getRandomSongList: ssController.getRandomSongList,
getServerInfo: ssController.getServerInfo,
getServerInfo: ndController.getServerInfo,
getSimilarSongs: ssController.getSimilarSongs,
getSongDetail: ndController.getSongDetail,
getSongList: ndController.getSongList,

View file

@ -0,0 +1,6 @@
export enum ServerFeature {
SMART_PLAYLISTS = 'smartPlaylists',
SONG_LYRICS = 'songLyrics',
}
export type ServerFeatures = Record<Partial<ServerFeature>, boolean>;

View file

@ -3,7 +3,7 @@ import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import { initClient, initContract } from '@ts-rest/core';
import axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios';
import qs from 'qs';
import { ServerListItem } from '/@/renderer/types';
import { ServerListItem } from '/@/renderer/api/types';
import omitBy from 'lodash/omitBy';
import { z } from 'zod';
import { authenticationFailure } from '/@/renderer/api/utils';

View file

@ -61,6 +61,7 @@ import packageJson from '../../../../package.json';
import { z } from 'zod';
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
import isElectron from 'is-electron';
import { ServerFeatures } from '/@/renderer/api/features.types';
const formatCommaDelimitedString = (value: string[]) => {
return value.join(',');
@ -959,7 +960,16 @@ const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
throw new Error('Failed to get server info');
}
return { id: apiClientProps.server?.id, version: res.body.Version };
const features: ServerFeatures = {
smartPlaylists: false,
songLyrics: true,
};
return {
features,
id: apiClientProps.server?.id,
version: res.body.Version,
};
};
const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {

View file

@ -10,8 +10,9 @@ import {
Playlist,
MusicFolder,
Genre,
ServerListItem,
ServerType,
} from '/@/renderer/api/types';
import { ServerListItem, ServerType } from '/@/renderer/types';
const getStreamUrl = (args: {
container?: string;

View file

@ -675,6 +675,10 @@ const similarSongs = pagination.extend({
Items: z.array(song),
});
export enum JellyfinExtensions {
SONG_LYRICS = 'songLyrics',
}
export const jfType = {
_enum: {
albumArtistList: albumArtistListSort,

View file

@ -7,7 +7,7 @@ import qs from 'qs';
import { ndType } from './navidrome-types';
import { authenticationFailure, resultWithHeaders } from '/@/renderer/api/utils';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/types';
import { ServerListItem } from '/@/renderer/api/types';
import { toast } from '/@/renderer/components';
import i18n from '/@/i18n/i18n';

View file

@ -1,3 +1,9 @@
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
import { NavidromeExtensions, ndType } from '/@/renderer/api/navidrome/navidrome-types';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import semverCoerce from 'semver/functions/coerce';
import semverGte from 'semver/functions/gte';
import {
AlbumArtistDetailArgs,
AlbumArtistDetailResponse,
@ -39,11 +45,11 @@ import {
RemoveFromPlaylistResponse,
RemoveFromPlaylistArgs,
genreListSortMap,
ServerInfo,
ServerInfoArgs,
} from '../types';
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
import { ndType } from '/@/renderer/api/navidrome/navidrome-types';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { hasFeature } from '/@/renderer/api/utils';
import { ServerFeature, ServerFeatures } from '/@/renderer/api/features.types';
const authenticate = async (
url: string,
@ -355,6 +361,16 @@ const deletePlaylist = async (args: DeletePlaylistArgs): Promise<DeletePlaylistR
const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResponse> => {
const { query, apiClientProps } = args;
const customQuery = query._custom?.navidrome;
// Smart playlists only became available in 0.48.0. Do not filter for previous versions
if (
customQuery &&
customQuery.smart !== undefined &&
!hasFeature(apiClientProps.server, ServerFeature.SMART_PLAYLISTS)
) {
customQuery.smart = undefined;
}
const res = await ndApiClient(apiClientProps).getPlaylistList({
query: {
@ -363,7 +379,7 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
_start: query.startIndex,
q: query.searchTerm,
...query._custom?.navidrome,
...customQuery,
},
});
@ -465,6 +481,70 @@ const removeFromPlaylist = async (
return null;
};
const VERSION_INFO: Array<[string, Record<string, number[]>]> = [
['0.48.0', { [ServerFeature.SMART_PLAYLISTS]: [1] }],
];
const getFeatures = (version: string): Record<string, number[]> => {
const cleanVersion = semverCoerce(version);
const features: Record<string, number[]> = {};
let matched = cleanVersion === null;
for (const [version, supportedFeatures] of VERSION_INFO) {
if (!matched) {
matched = semverGte(cleanVersion!, version);
}
if (matched) {
for (const [feature, feat] of Object.entries(supportedFeatures)) {
if (feature in features) {
features[feature].push(...feat);
} else {
features[feature] = feat;
}
}
}
}
return features;
};
const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
const { apiClientProps } = args;
// Navidrome will always populate serverVersion
const ping = await ssApiClient(apiClientProps).ping();
if (ping.status !== 200) {
throw new Error('Failed to ping server');
}
const navidromeFeatures: Record<string, number[]> = getFeatures(ping.body.serverVersion!);
if (ping.body.openSubsonic) {
const res = await ssApiClient(apiClientProps).getServerInfo();
if (res.status !== 200) {
throw new Error('Failed to get server extensions');
}
for (const extension of res.body.openSubsonicExtensions) {
navidromeFeatures[extension.name] = extension.versions;
}
}
const features: ServerFeatures = {
smartPlaylists: false,
songLyrics: true,
};
if (navidromeFeatures[NavidromeExtensions.SMART_PLAYLISTS]) {
features[ServerFeature.SMART_PLAYLISTS] = true;
}
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion! };
};
export const ndController = {
addToPlaylist,
authenticate,
@ -478,6 +558,7 @@ export const ndController = {
getPlaylistDetail,
getPlaylistList,
getPlaylistSongList,
getServerInfo,
getSongDetail,
getSongList,
getUserList,

View file

@ -7,8 +7,9 @@ import {
User,
AlbumArtist,
Genre,
ServerListItem,
ServerType,
} from '/@/renderer/api/types';
import { ServerListItem, ServerType } from '/@/renderer/types';
import z from 'zod';
import { ndType } from './navidrome-types';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';

View file

@ -343,6 +343,10 @@ const removeFromPlaylistParameters = z.object({
id: z.array(z.string()),
});
export enum NavidromeExtensions {
SMART_PLAYLISTS = 'smartPlaylists',
}
export const ndType = {
_enum: {
albumArtistList: ndAlbumArtistListSort,

View file

@ -2,7 +2,7 @@ import md5 from 'md5';
import { z } from 'zod';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
import { SubsonicExtensions, ssType } from '/@/renderer/api/subsonic/subsonic-types';
import {
ArtistInfoArgs,
AuthenticationResponse,
@ -29,6 +29,7 @@ import {
Song,
} from '/@/renderer/api/types';
import { randomString } from '/@/renderer/utils';
import { ServerFeatures } from '/@/renderer/api/features.types';
const authenticate = async (
url: string,
@ -383,8 +384,13 @@ const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
throw new Error('Failed to ping server');
}
const features: ServerFeatures = {
smartPlaylists: false,
songLyrics: false,
};
if (!ping.body.openSubsonic || !ping.body.serverVersion) {
return { version: ping.body.version };
return { features, version: ping.body.version };
}
const res = await ssApiClient(apiClientProps).getServerInfo();
@ -393,9 +399,13 @@ const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
throw new Error('Failed to get server extensions');
}
const features: Record<string, number[]> = {};
const subsonicFeatures: Record<string, number[]> = {};
for (const extension of res.body.openSubsonicExtensions) {
features[extension.name] = extension.versions;
subsonicFeatures[extension.name] = extension.versions;
}
if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) {
features.songLyrics = true;
}
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };

View file

@ -1,8 +1,14 @@
import { nanoid } from 'nanoid';
import { z } from 'zod';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
import { QueueSong, LibraryItem, AlbumArtist, Album } from '/@/renderer/api/types';
import { ServerListItem, ServerType } from '/@/renderer/types';
import {
QueueSong,
LibraryItem,
AlbumArtist,
Album,
ServerListItem,
ServerType,
} from '/@/renderer/api/types';
const getCoverArtUrl = (args: {
baseUrl: string | undefined;

View file

@ -260,6 +260,13 @@ const similarSongs = z.object({
.optional(),
});
export enum SubsonicExtensions {
FORM_POST = 'formPost',
SONG_LYRICS = 'songLyrics',
TRANSCODE_OFFSET = 'transcodeOffset',
}
export const ssType = {
_parameters: {
albumList: albumListParameters,

View file

@ -20,6 +20,7 @@ import {
NDUserListSort,
NDGenreListSort,
} from './navidrome.types';
import { ServerFeatures } from '/@/renderer/api/features.types';
export enum LibraryItem {
ALBUM = 'album',
@ -57,13 +58,16 @@ export type User = {
export type ServerListItem = {
credential: string;
features?: ServerFeatures;
id: string;
name: string;
ndCredential?: string;
savePassword?: boolean;
type: ServerType;
url: string;
userId: string | null;
username: string;
version?: string;
};
export enum ServerType {
@ -1141,14 +1145,8 @@ export type FontData = {
export type ServerInfoArgs = BaseEndpointArgs;
export enum SubsonicExtensions {
FORM_POST = 'formPost',
SONG_LYRICS = 'songLyrics',
TRANSCODE_OFFSET = 'transcodeOffset',
}
export type ServerInfo = {
features?: Record<string, number[]>;
features: ServerFeatures;
id?: string;
version: string;
};

View file

@ -2,7 +2,8 @@ import { AxiosHeaders } from 'axios';
import { z } from 'zod';
import { toast } from '/@/renderer/components';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/types';
import { ServerListItem } from '/@/renderer/api/types';
import { ServerFeature } from '/@/renderer/api/features.types';
// Since ts-rest client returns a strict response type, we need to add the headers to the body object
export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => {
@ -38,3 +39,11 @@ export const authenticationFailure = (currentServer: ServerListItem | null) => {
useAuthStore.getState().actions.setCurrentServer(null);
}
};
export const hasFeature = (server: ServerListItem | null, feature: ServerFeature): boolean => {
if (!server || !server.features) {
return false;
}
return server.features[feature];
};