Compare commits

..

10 Commits

Author SHA1 Message Date
jeffvli fb022891fe update to v0.12.6 2025-05-08 00:44:13 -07:00
Kendall Garner 5d9906b8f2 include album artist song/album count for jellyfin, and disable playing/adding playinsts for artists with no albums 2025-05-07 21:16:47 -07:00
jeffvli 6f7cb468b2 fix regression on subsonic album artist play 2025-05-07 20:59:16 -07:00
Kendall Garner 076693e969 Merge branch 'development' of github.com:jeffvli/feishin into development 2025-05-07 20:01:04 -07:00
Kendall Garner 781d8055b5 minor artist count fixes 2025-05-07 19:53:23 -07:00
jeffvli 960bb5c660 fix navigation to detail page on artist list 2025-05-07 19:40:54 -07:00
jeffvli 42bb2bf66f fix regression on album artist play button 2025-05-07 19:25:25 -07:00
jeffvli f03d88cd8c batch jellyfin song list requests when fetching by albumId (#922) 2025-05-07 01:42:32 -07:00
jeffvli 58f6535ba6 revert electron to gtk 3 (#923) 2025-05-07 01:28:54 -07:00
jeffvli 9a59ce3613 fix casing on artist albums title 2025-05-07 01:15:00 -07:00
18 changed files with 166 additions and 77 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "feishin",
"version": "0.12.5",
"version": "0.12.6",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "feishin",
"version": "0.12.5",
"version": "0.12.6",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "feishin",
"productName": "Feishin",
"description": "Feishin music server",
"version": "0.12.5",
"version": "0.12.6",
"scripts": {
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\" \"npm run build:remote\"",
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "feishin",
"version": "0.12.5",
"version": "0.12.6",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "feishin",
"version": "0.12.5",
"version": "0.12.6",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "0.12.5",
"version": "0.12.6",
"description": "",
"main": "./dist/main/main.js",
"author": {
+3
View File
@@ -494,6 +494,9 @@ const createWindow = async (first = true) => {
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
// https://github.com/electron/electron/issues/46538#issuecomment-2808806722
app.commandLine.appendSwitch('gtk-version', '3');
// Must duplicate with the one in renderer process settings.store.ts
enum BindingActions {
GLOBAL_SEARCH = 'globalSearch',
@@ -695,58 +695,98 @@ export const JellyfinController: ControllerEndpoint = {
}
const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;
const albumIdsFilter = query.albumIds
? formatCommaDelimitedString(query.albumIds)
: undefined;
const artistIdsFilter = query.artistIds
? formatCommaDelimitedString(query.artistIds)
: query.albumArtistIds
? formatCommaDelimitedString(query.albumArtistIds)
: undefined;
const res = await jfApiClient(apiClientProps).getSongList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
AlbumIds: albumIdsFilter,
ArtistIds: artistIdsFilter,
Fields: 'Genres, DateCreated, MediaSources, ParentId',
GenreIds: query.genreIds?.join(','),
IncludeItemTypes: 'Audio',
IsFavorite: query.favorite,
Limit: query.limit,
ParentId: query.musicFolderId,
Recursive: true,
SearchTerm: query.searchTerm,
SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: query.startIndex,
...query._custom?.jellyfin,
Years: yearsFilter,
},
});
let items: z.infer<typeof jfType._response.song>[] = [];
let totalRecordCount = 0;
const batchSize = 50;
if (res.status !== 200) {
throw new Error('Failed to get song list');
}
// Handle albumIds fetches in batches to prevent HTTP 414 errors
if (query.albumIds && query.albumIds.length > batchSize) {
const albumIdBatches = chunk(query.albumIds, batchSize);
let items: z.infer<typeof jfType._response.song>[];
for (const batch of albumIdBatches) {
const albumIdsFilter = formatCommaDelimitedString(batch);
// Jellyfin Bodge because of code from https://github.com/jellyfin/jellyfin/blob/c566ccb63bf61f9c36743ddb2108a57c65a2519b/Emby.Server.Implementations/Data/SqliteItemRepository.cs#L3622
// If the Album ID filter is passed, Jellyfin will search for
// 1. the matching album id
// 2. An album with the name of the album.
// It is this second condition causing issues,
if (query.albumIds) {
const albumIdSet = new Set(query.albumIds);
items = res.body.Items.filter((item) => albumIdSet.has(item.AlbumId!));
const res = await jfApiClient(apiClientProps).getSongList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
AlbumIds: albumIdsFilter,
ArtistIds: artistIdsFilter,
Fields: 'Genres, DateCreated, MediaSources, ParentId',
GenreIds: query.genreIds?.join(','),
IncludeItemTypes: 'Audio',
IsFavorite: query.favorite,
Limit: query.limit,
ParentId: query.musicFolderId,
Recursive: true,
SearchTerm: query.searchTerm,
SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: query.startIndex,
...query._custom?.jellyfin,
Years: yearsFilter,
},
});
if (items.length < res.body.Items.length) {
res.body.TotalRecordCount -= res.body.Items.length - items.length;
if (res.status !== 200) {
throw new Error('Failed to get song list');
}
items = [...items, ...res.body.Items];
totalRecordCount += res.body.Items.length;
}
} else {
items = res.body.Items;
const albumIdsFilter = query.albumIds
? formatCommaDelimitedString(query.albumIds)
: undefined;
const res = await jfApiClient(apiClientProps).getSongList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
AlbumIds: albumIdsFilter,
ArtistIds: artistIdsFilter,
Fields: 'Genres, DateCreated, MediaSources, ParentId',
GenreIds: query.genreIds?.join(','),
IncludeItemTypes: 'Audio',
IsFavorite: query.favorite,
Limit: query.limit,
ParentId: query.musicFolderId,
Recursive: true,
SearchTerm: query.searchTerm,
SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: query.startIndex,
...query._custom?.jellyfin,
Years: yearsFilter,
},
});
if (res.status !== 200) {
throw new Error('Failed to get song list');
}
// Jellyfin Bodge because of code from https://github.com/jellyfin/jellyfin/blob/c566ccb63bf61f9c36743ddb2108a57c65a2519b/Emby.Server.Implementations/Data/SqliteItemRepository.cs#L3622
// If the Album ID filter is passed, Jellyfin will search for
// 1. the matching album id
// 2. An album with the name of the album.
// It is this second condition causing issues,
if (query.albumIds) {
const albumIdSet = new Set(query.albumIds);
items = res.body.Items.filter((item) => albumIdSet.has(item.AlbumId!));
totalRecordCount = items.length;
} else {
items = res.body.Items;
totalRecordCount = res.body.TotalRecordCount;
}
}
return {
@@ -754,7 +794,7 @@ export const JellyfinController: ControllerEndpoint = {
jfNormalize.song(item, apiClientProps.server, '', query.imageSize),
),
startIndex: query.startIndex,
totalRecordCount: res.body.TotalRecordCount,
totalRecordCount,
};
},
getSongListCount: async ({ apiClientProps, query }) =>
@@ -284,7 +284,7 @@ const normalizeAlbumArtist = (
) || [];
return {
albumCount: null,
albumCount: item.AlbumCount ?? null,
backgroundImageUrl: null,
biography: item.Overview || null,
duration: item.RunTimeTicks / 10000,
@@ -308,7 +308,7 @@ const normalizeAlbumArtist = (
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
similarArtists,
songCount: null,
songCount: item.SongCount ?? null,
userFavorite: item.UserData?.IsFavorite || false,
userRating: null,
};
@@ -431,6 +431,7 @@ const providerIds = z.object({
});
const albumArtist = z.object({
AlbumCount: z.number().optional(),
BackdropImageTags: z.array(z.string()),
ChannelId: z.null(),
DateCreated: z.string(),
@@ -446,6 +447,7 @@ const albumArtist = z.object({
ProviderIds: providerIds.optional(),
RunTimeTicks: z.number(),
ServerId: z.string(),
SongCount: z.number().optional(),
Type: z.string(),
UserData: userData.optional(),
});
@@ -278,11 +278,25 @@ const normalizeAlbumArtist = (
});
}
let albumCount: number;
let songCount: number;
if (item.stats) {
albumCount = Math.max(
item.stats.albumartist?.albumCount ?? 0,
item.stats.artist?.albumCount ?? 0,
);
songCount = Math.max(
item.stats.albumartist?.songCount ?? 0,
item.stats.artist?.songCount ?? 0,
);
} else {
albumCount = item.albumCount;
songCount = item.songCount;
}
return {
albumCount: Math.max(
item.stats?.albumartist?.albumCount || item.albumCount,
item.stats?.artist?.albumCount || 0,
),
albumCount,
backgroundImageUrl: null,
biography: item.biography || null,
duration: null,
@@ -307,7 +321,7 @@ const normalizeAlbumArtist = (
imageUrl: artist?.artistImageUrl || null,
name: artist.name,
})) || null,
songCount: item.stats?.albumartist?.songCount || item.songCount,
songCount,
userFavorite: item.starred,
userRating: item.rating,
};
@@ -190,7 +190,7 @@ export const SubsonicController: ControllerEndpoint = {
return {
...ssNormalize.albumArtist(artist, apiClientProps.server, 300),
albums: artist.album.map((album) => ssNormalize.album(album, apiClientProps.server)),
albums: artist.album?.map((album) => ssNormalize.album(album, apiClientProps.server)),
similarArtists:
artistInfo?.similarArtist?.map((artist) =>
ssNormalize.albumArtist(artist, apiClientProps.server, 300),
@@ -305,7 +305,7 @@ export const SubsonicController: ControllerEndpoint = {
return [];
}
return artist.body.artist.album;
return artist.body.artist.album ?? [];
});
return {
@@ -935,7 +935,9 @@ export const SubsonicController: ControllerEndpoint = {
};
}
if (query.albumIds || query.artistIds) {
const artistIds = query.albumArtistIds || query.artistIds;
if (query.albumIds || artistIds) {
if (query.albumIds) {
for (const albumId of query.albumIds) {
fromAlbumPromises.push(
@@ -948,8 +950,8 @@ export const SubsonicController: ControllerEndpoint = {
}
}
if (query.artistIds) {
for (const artistId of query.artistIds) {
if (artistIds) {
for (const artistId of artistIds) {
artistDetailPromises.push(
ssApiClient(apiClientProps).getArtist({
query: {
@@ -966,7 +968,7 @@ export const SubsonicController: ControllerEndpoint = {
return [];
}
return artist.body.artist.album;
return artist.body.artist.album ?? [];
});
const albumIds = albums.map((album) => album.id);
+1 -1
View File
@@ -156,7 +156,7 @@ const albumListParameters = z.object({
const albumList = z.array(album.omit({ song: true }));
const albumArtist = z.object({
album: z.array(album),
album: z.array(album).optional(),
albumCount: z.string(),
artistImageUrl: z.string().optional(),
coverArt: z.string().optional(),
@@ -388,8 +388,8 @@ export const useVirtualTable = <TFilter extends BaseQuery<any>>({
break;
case LibraryItem.ARTIST:
navigate(
generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: e.data.id,
generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, {
artistId: e.data.id,
}),
);
break;
@@ -129,7 +129,7 @@ const AlbumListRoute = () => {
const artist = searchParams.get('artistName');
const title = artist
? t('page.albumList.artistAlbums', { artist })
? t('page.albumList.artistAlbums', { artist, postProcess: 'sentenceCase' })
: genreId
? t('page.albumList.genreAlbums', { genre: titleCase(genreTitle) })
: undefined;
@@ -297,7 +297,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
handlePlayQueueAdd?.({
byItemType: {
id: [routeId],
type: albumArtistId ? LibraryItem.ALBUM : LibraryItem.ALBUM_ARTIST,
type: albumArtistId ? LibraryItem.ALBUM_ARTIST : LibraryItem.ARTIST,
},
playType: playType || playButtonBehavior,
});
@@ -340,9 +340,15 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
}
};
const albumCount = detailQuery?.data?.albumCount;
const artistContextItems =
(albumCount ?? 1) > 0
? ARTIST_CONTEXT_MENU_ITEMS
: ARTIST_CONTEXT_MENU_ITEMS.filter((item) => !item.id.toLowerCase().includes('play'));
const handleGeneralContextMenu = useHandleGeneralContextMenu(
LibraryItem.ALBUM_ARTIST,
ARTIST_CONTEXT_MENU_ITEMS,
artistContextItems,
);
const topSongs = topSongsQuery?.data?.items?.slice(0, 10);
@@ -369,7 +375,10 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
<LibraryBackgroundOverlay $backgroundColor={background} />
<DetailContainer>
<Group spacing="md">
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<PlayButton
disabled={albumCount === 0}
onClick={() => handlePlay(playButtonBehavior)}
/>
<Group spacing="xs">
<Button
compact
@@ -28,25 +28,29 @@ export const AlbumArtistDetailHeader = forwardRef(
serverId: server?.id,
});
const albumCount = detailQuery?.data?.albumCount;
const songCount = detailQuery?.data?.songCount;
const duration = detailQuery?.data?.duration;
const durationEnabled = duration !== null && duration !== undefined;
const metadataItems = [
{
enabled: detailQuery?.data?.albumCount,
enabled: albumCount !== null && albumCount !== undefined,
id: 'albumCount',
secondary: false,
value: t('entity.albumWithCount', { count: detailQuery?.data?.albumCount || 0 }),
value: t('entity.albumWithCount', { count: albumCount || 0 }),
},
{
enabled: detailQuery?.data?.songCount,
enabled: songCount !== null && songCount !== undefined,
id: 'songCount',
secondary: false,
value: t('entity.trackWithCount', { count: detailQuery?.data?.songCount || 0 }),
value: t('entity.trackWithCount', { count: songCount || 0 }),
},
{
enabled: detailQuery.data?.duration,
enabled: durationEnabled,
id: 'duration',
secondary: true,
value:
detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
value: durationEnabled && formatDurationString(duration),
},
];
@@ -2,6 +2,7 @@
import { useCallback, useState, Fragment, useRef } from 'react';
import { ActionIcon, Group, Kbd, ScrollArea } from '@mantine/core';
import { useDisclosure, useDebouncedValue } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { RiSearchLine, RiCloseFill } from 'react-icons/ri';
import { generatePath, useNavigate } from 'react-router';
import styled from 'styled-components';
@@ -37,6 +38,7 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
const activePage = pages[pages.length - 1];
const isHome = activePage === CommandPalettePages.HOME;
const searchInputRef = useRef<HTMLInputElement>(null);
const { t } = useTranslation();
const popPage = useCallback(() => {
setPages((pages) => {
@@ -187,13 +189,17 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
}}
>
<LibraryCommandItem
disabled={artist?.albumCount === 0}
handlePlayQueueAdd={handlePlayQueueAdd}
id={artist.id}
imageUrl={artist.imageUrl}
itemType={LibraryItem.ALBUM_ARTIST}
subtitle={
(artist?.albumCount || 0) > 0
? `${artist.albumCount} albums`
artist?.albumCount !== undefined &&
artist?.albumCount !== null
? t('entity.albumWithCount', {
count: artist.albumCount,
})
: undefined
}
title={artist.name}
@@ -53,6 +53,7 @@ const StyledImage = styled.img`
const ActionsContainer = styled(Flex)``;
interface LibraryCommandItemProps {
disabled?: boolean;
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
id: string;
imageUrl: string | null;
@@ -62,6 +63,7 @@ interface LibraryCommandItemProps {
}
export const LibraryCommandItem = ({
disabled,
id,
imageUrl,
subtitle,
@@ -154,6 +156,7 @@ export const LibraryCommandItem = ({
>
<Button
compact
disabled={disabled}
size="md"
tooltip={{
label: t('player.play', { postProcess: 'sentenceCase' }),
@@ -166,6 +169,7 @@ export const LibraryCommandItem = ({
</Button>
<Button
compact
disabled={disabled}
size="md"
tooltip={{
label: t('player.addLast', { postProcess: 'sentenceCase' }),
@@ -179,6 +183,7 @@ export const LibraryCommandItem = ({
</Button>
<Button
compact
disabled={disabled}
size="md"
tooltip={{
label: t('player.addNext', { postProcess: 'sentenceCase' }),
@@ -15,7 +15,7 @@ const MotionButton = styled(UnstyledButton)`
fill: var(--btn-filled-fg);
}
&:hover {
&:hover:not([disabled]) {
background: var(--btn-filled-bg);
transform: scale(1.1);
@@ -28,6 +28,10 @@ const MotionButton = styled(UnstyledButton)`
transform: scale(0.95);
}
&:disabled {
opacity: 0.6;
}
transition: background-color 0.2s ease-in-out;
transition: transform 0.2s ease-in-out;
`;