Compare commits

...

16 Commits

Author SHA1 Message Date
jeffvli 9b95f47a91 update to v0.12.7 2025-05-12 18:27:37 -07:00
Hosted Weblate 2267e9bc9d Translated using Weblate (Czech)
Currently translated at 100.0% (657 of 657 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translation: feishin/Translation
2025-05-13 03:24:48 +02:00
Kendall Garner 089311c673 add migrate from v8 (#925) 2025-05-12 18:24:42 -07:00
Kendall Garner 773f349b66 don't show song count if not present for home carousel 2025-05-09 19:08:36 -07:00
Kendall Garner 3980c8ea97 save the package-logk.json changes as well 2025-05-08 08:23:58 -07:00
Kendall Garner 257a5ceef0 force xmljs to 0.5.0 2025-05-08 08:08:31 -07:00
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
21 changed files with 206 additions and 91 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "feishin",
"version": "0.12.5",
"version": "0.12.7",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "feishin",
"version": "0.12.5",
"version": "0.12.7",
"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.7",
"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",
+10 -9
View File
@@ -1,12 +1,12 @@
{
"name": "feishin",
"version": "0.12.5",
"version": "0.12.7",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "feishin",
"version": "0.12.5",
"version": "0.12.7",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
@@ -1318,9 +1318,10 @@
}
},
"node_modules/xml2js": {
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
@@ -1584,7 +1585,7 @@
"jsbi": "^2.0.5",
"long": "^4.0.0",
"safe-buffer": "^5.1.1",
"xml2js": "^0.4.17"
"xml2js": "0.5.0"
}
},
"debug": {
@@ -2317,9 +2318,9 @@
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="
},
"xml2js": {
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
"requires": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
+7 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "0.12.5",
"version": "0.12.7",
"description": "",
"main": "./dist/main/main.js",
"author": {
@@ -20,5 +20,11 @@
"devDependencies": {
"electron": "36.1.0"
},
"resolutions": {
"xml2js": "0.5.0"
},
"overrides": {
"xml2js": "0.5.0"
},
"license": "GPL-3.0"
}
+2 -2
View File
@@ -747,8 +747,8 @@
"folderWithCount_few": "{{count}} složky",
"folderWithCount_other": "{{count}} složek",
"albumArtist_one": "umělec alba",
"albumArtist_few": "umělci alba",
"albumArtist_other": "umělců alba",
"albumArtist_few": "umělci alb",
"albumArtist_other": "umělci alb",
"track_one": "skladba",
"track_few": "skladby",
"track_other": "skladby",
+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(),
@@ -207,11 +207,14 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
</Badge>
))}
<Badge size="lg">{currentItem?.releaseYear}</Badge>
<Badge size="lg">
{t('entity.trackWithCount', {
count: currentItem?.songCount || 0,
})}
</Badge>
{currentItem?.songCount !== null &&
currentItem?.songCount !== undefined && (
<Badge size="lg">
{t('entity.trackWithCount', {
count: currentItem?.songCount || 0,
})}
</Badge>
)}
</Group>
<Group position="apart">
<Button
@@ -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;
`;
+16
View File
@@ -734,6 +734,22 @@ export const useSettingsStore = create<SettingsSlice>()(
),
{
merge: mergeOverridingColumns,
migrate(persistedState, version) {
if (version === 8) {
const state = persistedState as SettingsSlice;
state.general.sidebarItems = state.general.sidebarItems.filter(
(item) => item.id !== 'Folders',
);
state.general.sidebarItems.push({
disabled: false,
id: 'Artists-all',
label: i18n.t('page.sidebar.artists'),
route: AppRoute.LIBRARY_ARTISTS,
});
}
return persistedState;
},
name: 'store_settings',
version: 9,
},