Compare commits

..

5 Commits

Author SHA1 Message Date
dependabot[bot] 652443fe11 Bump react-router in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router).


Updates `react-router` from 7.14.0 to 7.15.1
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.15.1/packages/react-router)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.15.1
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 20:16:33 +00:00
jeffvli 4c8894259c add Latvian translation locale 2026-07-20 02:15:49 -07:00
Hosted Weblate ac0a24f008 Translated using Weblate
Currently translated at 96.4% (1287 of 1334 strings) (Latvian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/lv/

Translated using Weblate

Currently translated at 100.0% (1334 of 1334 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Translated using Weblate

Currently translated at 100.0% (1334 of 1334 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

Currently translated at 100.0% (1334 of 1334 strings) (Russian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ru/

Translated using Weblate

Currently translated at 100.0% (1334 of 1334 strings) (Spanish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/

Translated using Weblate

Currently translated at 43.7% (584 of 1334 strings) (Japanese)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ja/

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: karigane <169052233+karigane-cha@users.noreply.github.com>
Co-authored-by: Александр Стельмах <aguhadug@gmail.com>
2026-07-20 04:13:57 +00:00
jeffvli 14625a9ef6 fix tag editor key deletion 2026-07-19 21:13:22 -07:00
jeffvli 173efa9bfd add scan listener and query invalidation on tag editor save 2026-07-19 21:03:13 -07:00
27 changed files with 2200 additions and 111 deletions
+1 -1
View File
@@ -133,7 +133,7 @@
"react-i18next": "^16.6.6",
"react-icons": "^5.6.0",
"react-player": "^2.16.1",
"react-router": "^7.17.0",
"react-router": "^7.15.1",
"react-split-pane": "^3.2.0",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "1.8.11",
+362 -83
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -18,6 +18,7 @@ import id from './locales/id.json';
import it from './locales/it.json';
import ja from './locales/ja.json';
import ko from './locales/ko.json';
import lv from './locales/lv.json';
import nbNO from './locales/nb-NO.json';
import nl from './locales/nl.json';
import pl from './locales/pl.json';
@@ -51,6 +52,7 @@ const resources = {
it: { translation: it },
ja: { translation: ja },
ko: { translation: ko },
lv: { translation: lv },
'nb-NO': { translation: nbNO },
nl: { translation: nl },
pl: { translation: pl },
@@ -129,6 +131,10 @@ export const languages = [
label: '한국어',
value: 'ko',
},
{
label: 'Latviešu',
value: 'lv',
},
{
label: 'Nederlands',
value: 'nl',
+33 -5
View File
@@ -550,7 +550,8 @@
"selectAll": "Vybrat vše",
"goToCurrent": "Přejít na aktuální položku",
"collapseAllFolders": "Sbalit všechny složky",
"expandAllFolders": "Rozbalit všechny složky"
"expandAllFolders": "Rozbalit všechny složky",
"createPlaylistFromQueue": "Vytvořit $t(entity.playlist, {\"count\": 1}) z fronty"
},
"common": {
"backward": "Zpátky",
@@ -681,7 +682,9 @@
"numberOfResults": "{{numberOfResults}} výsledků",
"grouping": "Seskupování",
"back": "Zpět",
"openFolder": "Otevřít složku"
"openFolder": "Otevřít složku",
"saveAndClose": "Uložit a zavřít",
"undo": "Vrátit"
},
"table": {
"config": {
@@ -947,7 +950,8 @@
"selectMusicFolder": "Vybrat složku s hudbou",
"noMusicFolder": "Není vybrána žádná složka s hudbou",
"multipleMusicFolders": "Vybráno {{count}} složek s hudbou",
"commandPalette": "Otevřít paletu příkazů"
"commandPalette": "Otevřít paletu příkazů",
"logout": "Odhlásit se"
},
"contextMenu": {
"addToPlaylist": "$t(action.addToPlaylist)",
@@ -975,7 +979,8 @@
"goToAlbum": "Přejít na $t(entity.album, {\"count\": 1})",
"goToAlbumArtist": "Přejít na $t(entity.albumArtist, {\"count\": 1})",
"moveItems": "$t(action.moveItems)",
"goTo": "Přejít na"
"goTo": "Přejít na",
"editMetadata": "Upravit metadata"
},
"home": {
"mostPlayed": "Nejpřehrávanější",
@@ -1065,7 +1070,27 @@
"itemDetail": {
"copiedPath": "Cesta úspěšně zkopírována",
"copyPath": "Kopírovat cestu do schránky",
"openFile": "Zobrazit skladbu ve správci souborů"
"openFile": "Zobrazit skladbu ve správci souborů",
"tagConfiguration": "Nastavení štítků",
"addTagConfig": "Přidat štítek…",
"multiValueToggle": "Více hodnot",
"autocompleteSource": "Automatické dokončení zdroje",
"customValues": "Vlastní hodnoty",
"addCustomValue": "Přidat vlastní hodnotu…",
"serverSuggestions": "Server",
"fileNotWritable": "Soubor není přístupný nebo zapisovatelný",
"triggerRescan": "Spustit opětovný sken knihovny po uložení",
"addField": "Přidat pole…",
"emptyFields": "Pole nemohou být prázdná",
"tagsTab": "Štítky",
"artworkTab": "Obal",
"removeArtwork": "Odstranit obal",
"noArtwork": "Žádný obal",
"multipleValues": "(Více hodnot)",
"multipleArtworks": "Více obalů",
"noLocalSongs": "Nenalezeny žádné skladby s lokálními cestami k souborům",
"readPartialFailure": "Nepodařilo se přečíst metadata {{count}} z {{total}} souborů",
"writePartialFailure": "Nepodařilo se uložit {{count}} soubor(ů)"
},
"playlist": {
"reorder": "Změna pořadí povolena pouze při řazení podle ID"
@@ -1218,6 +1243,9 @@
},
"editRadioStation": {
"success": "Stanice rádia úspěšně upravena"
},
"createPrefilledPlaylist": {
"title": "Vytvořit $t(entity.playlist, {\"count\": 1}) z vybraných skladeb"
}
},
"entity": {
+5 -1
View File
@@ -173,7 +173,11 @@
"gridRows": "Grid rows",
"tableColumns": "Table columns",
"itemsMore": "{{count}} more",
"newVersionAvailable": "A new version is available"
"lastScan": "Last scan {{date}}",
"newVersionAvailable": "A new version is available",
"scanFolderCount": "{{count}} folders",
"scanItemCount": "{{count}} items",
"scanningLibrary": "Scanning library…"
},
"entity": {
"album_one": "Album",
+2 -1
View File
@@ -803,7 +803,8 @@
"selectMusicFolder": "Seleccionar carpeta de música",
"noMusicFolder": "Ninguna carpeta de música seleccionada",
"multipleMusicFolders": "{{count}} carpetas de música seleccionadas",
"commandPalette": "Abrir paleta de comandos"
"commandPalette": "Abrir paleta de comandos",
"logout": "Cerrar sesión"
},
"contextMenu": {
"addToPlaylist": "$t(action.addToPlaylist)",
+30 -12
View File
@@ -207,7 +207,7 @@
"savePlayQueue_description": "アプリケーション終了時に再生キューを保存し、アプリケーション開始時に復元します",
"useSystemTheme": "システムテーマを使用",
"webAudio": "Web Audio を使用する",
"mediaSession_description": "Media Session の統合を有効にし、システムボリュームオーバーレイとロック画面にメディアコントロールとメタデータを表示します",
"mediaSession_description": "Media Session の統合を有効にし、システムボリュームオーバーレイとロック画面にメディアコントロールとメタデータを表示します。Web Audio Player が必要です。",
"mediaSession": "Media Session を有効にする",
"startMinimized_description": "システムトレイでアプリケーションを起動する",
"startMinimized": "最小化して起動する",
@@ -317,7 +317,7 @@
"exportImportSettings_importModalTitle": "Feishin 設定をインポート",
"exportImportSettings_importBtn": "設定をインポート",
"autoDJ": "自動 DJ",
"autoDJ_itemCount_description": "自動 DJ が有効なときにキューに追加しようとした曲数",
"autoDJ_itemCount_description": "キューに追加しようとした曲数",
"autoDJ_itemCount": "曲数",
"autoDJ_timing": "タイミング",
"autoDJ_timing_description": "自動 DJ が作動するまでのキューに残っている曲数",
@@ -434,7 +434,7 @@
"hotkey_listShowPlayingSong": "再生中の曲をリストに表示",
"autoDJ_mode": "モード",
"autoDJ_mode_albums": "アルバム",
"autoDJ_mode_description": "キューに曲を追加するか、アルバム全体を追加するかを選択してください",
"autoDJ_mode_description": "曲またはアルバム全体をキューに追加するかを選択してください",
"autoDJ_mode_songs": "曲",
"autoDJ_enabled": "Auto DJを有効にする",
"autoDJ_albumStrategy": "アルバム選択モード",
@@ -509,7 +509,8 @@
"addOrRemoveFromSelection": "選択に追加または選択から除外",
"goToCurrent": "現在の項目へ移動",
"collapseAllFolders": "すべてのフォルダーを折りたたむ",
"expandAllFolders": "すべてのフォルダーを展開する"
"expandAllFolders": "すべてのフォルダーを展開する",
"createPlaylistFromQueue": "キューから $t(entity.playlist, {\"count\": 1}) を作成"
},
"common": {
"backward": "逆行",
@@ -632,7 +633,9 @@
"numberOfResults": "{{numberOfResults}} 件の結果",
"grouping": "グループ化",
"back": "戻る",
"openFolder": "フォルダーを開く"
"openFolder": "フォルダーを開く",
"saveAndClose": "保存して閉じる",
"undo": "元に戻す"
},
"table": {
"config": {
@@ -861,13 +864,20 @@
"dynamicIsImage": "背景画像を有効にする",
"lyricOffset": "歌詞のオフセット (ms)",
"lyricOpacityNonActive": "非アクティブな歌詞の不透明度",
"lyricScaleNonActive": "非アクティブな歌詞のスケール"
"lyricScaleNonActive": "非アクティブな歌詞のスケール",
"lyricLineLeadTime": "行を読み込む時間 (ms)",
"lyricPaddingLeft": "歌詞の左側のパディング (%)",
"lyricPaddingRight": "歌詞の右側のパディング (%)"
},
"upNext": "次へ",
"lyrics": "歌詞",
"related": "関連",
"visualizer": "ビジュアライザー",
"noLyrics": "歌詞が見つかりません"
"noLyrics": "歌詞が見つかりません",
"lyricLayers": "歌詞レイヤー",
"lyricLanguage": "言語",
"showPronunciation": "発音",
"showTranslation": "翻訳"
},
"appMenu": {
"selectServer": "サーバーを選択",
@@ -885,7 +895,8 @@
"selectMusicFolder": "音楽フォルダーを選択",
"noMusicFolder": "音楽フォルダーが選択されていません",
"commandPalette": "コマンドパレットを開く",
"multipleMusicFolders": "{{count}} 個の音楽フォルダーが選択されました"
"multipleMusicFolders": "{{count}} 個の音楽フォルダーが選択されました",
"logout": "ログアウト"
},
"contextMenu": {
"addToPlaylist": "$t(action.addToPlaylist)",
@@ -913,7 +924,8 @@
"showDetails": "情報を取得する",
"playShuffled": "$t(player.shuffle)",
"moveItems": "$t(action.moveItems)",
"goTo": "移動"
"goTo": "移動",
"editMetadata": "メタデータを編集"
},
"home": {
"mostPlayed": "最も多く再生",
@@ -1011,7 +1023,9 @@
"itemDetail": {
"openFile": "ファイルマネージャーでトラックを表示する",
"copyPath": "パスをクリップボードにコピーする",
"copiedPath": "パスが正常にコピーされました"
"copiedPath": "パスが正常にコピーされました",
"tagConfiguration": "タグ設定",
"addTagConfig": "タグを追加…"
},
"playlist": {
"reorder": "ID によるソート時のみ並べ替えが可能です"
@@ -1069,14 +1083,15 @@
"input_preferInstantMix": "インスタントミックスを優先する",
"input_preferRemoteUrl": "公開 URL を優先する",
"input_remoteUrl": "公開 URL",
"input_remoteUrlPlaceholder": "オプション: 外部機能用の公開 URL"
"input_remoteUrlPlaceholder": "オプション: 外部機能用の公開 URL",
"input_passwordNoSSO": "リバースプロキシ(SSO)認証はサポートされていません。ユーザー名 / パスワード認証と Subsonic 認証のみがサポートされています"
},
"addToPlaylist": {
"success": "$t(entity.trackWithCount, {\"count\": {{message}} }) を $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} }) に追加しました",
"title": "$t(entity.playlist, {\"count\": 1}) に追加",
"input_skipDuplicates": "重複をスキップ",
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
"create": "$t(entity.playlist, {\"count\": 1}) {{playlist}} を作成する",
"create": "$t(entity.playlist, {\"count\": 1}) {{playlist}} を作成",
"searchOrCreate": "$t(entity.playlist, {\"count\": 2}) を検索するか、入力して新しいプレイリストを作成してください",
"noneAdded": "$t(entity.playlist, {\"count\": 1}) {{playlist}} にトラックが追加されませんでした"
},
@@ -1155,6 +1170,9 @@
},
"editRadioStation": {
"success": "ラジオ局の更新に成功しました"
},
"createPrefilledPlaylist": {
"title": "選択した曲から $t(entity.playlist, {\"count\": 1}) を作成"
}
},
"entity": {
+1435 -1
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -560,7 +560,8 @@
"noMusicFolder": "Не выбрана папка с музыкой",
"multipleMusicFolders": "Выбрано {{count}} папок с музыкой",
"commandPalette": "Открыть командную строку",
"settings": "$t(common.setting, {\"count\": 2})"
"settings": "$t(common.setting, {\"count\": 2})",
"logout": "Выйти"
},
"manageServers": {
"title": "Управление серверами",
+2 -1
View File
@@ -221,7 +221,8 @@
"selectMusicFolder": "選擇媒體庫",
"noMusicFolder": "未選取任何媒體庫",
"multipleMusicFolders": "已選取 {{count}} 個媒體庫",
"commandPalette": "開啟命令面板"
"commandPalette": "開啟命令面板",
"logout": "登出"
},
"fullscreenPlayer": {
"config": {
@@ -285,7 +285,7 @@ export async function writeFilesTags(
}
for (const key of propertyRemovals) {
delete properties[canonicalizePropertyKey(key)];
properties[canonicalizePropertyKey(key)] = [];
}
file.setProperties(properties);
+12
View File
@@ -612,6 +612,18 @@ export const controller: GeneralController = {
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
getScanStatus(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getScanStatus`);
}
return apiController(
'getScanStatus',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
getServerInfo(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -206,6 +206,14 @@ export const contract = c.router({
400: jfType._response.error,
},
},
getScheduledTasks: {
method: 'GET',
path: 'ScheduledTasks',
responses: {
200: jfType._response.scheduledTasks,
400: jfType._response.error,
},
},
getServerInfo: {
method: 'GET',
path: 'system/info',
@@ -1161,6 +1161,34 @@ export const JellyfinController: InternalControllerEndpoint = {
};
},
getRoles: async () => [],
getScanStatus: async (args) => {
const { apiClientProps } = args;
const res = await jfApiClient(apiClientProps).getScheduledTasks();
if (res.status !== 200) {
throw new Error('Failed to get scan status');
}
const task =
res.body.find((t) => t.Key === 'RefreshLibrary') ||
res.body.find((t) => t.Name === 'Scan Media Library');
if (!task) {
return {
count: 0,
folderCount: 0,
scanning: false,
};
}
return {
count: 0,
folderCount: 0,
lastScan: task.LastExecutionResult?.EndTimeUtc ?? undefined,
scanning: task.State === 'Running' || task.State === 'Cancelling',
};
},
getServerInfo: async (args) => {
const { apiClientProps } = args;
@@ -723,6 +723,7 @@ export const NavidromeController: InternalControllerEndpoint = {
getRandomSongList: SubsonicController.getRandomSongList,
getRoles: async ({ apiClientProps }) =>
hasFeature(apiClientProps.server, ServerFeature.BFR) ? NAVIDROME_ROLES : [],
getScanStatus: SubsonicController.getScanStatus,
getServerInfo: async (args) => {
const { apiClientProps } = args;
+1
View File
@@ -363,6 +363,7 @@ export const queryKeys: Record<
},
server: {
root: (serverId: string) => [serverId] as const,
scanStatus: (serverId: string) => [serverId, 'server', 'scanStatus'] as const,
},
songs: {
albumRadio: (serverId: string, query?: AlbumRadioQuery) => {
@@ -1355,6 +1355,22 @@ export const SubsonicController: InternalControllerEndpoint = {
final.splice(0, 0, { label: 'all artists', value: '' });
return final;
},
getScanStatus: async (args) => {
const { apiClientProps } = args;
const res = await ssApiClient(apiClientProps).getScanStatus({ query: {} });
if (res.status !== 200) {
throw new Error('Failed to get scan status');
}
return {
count: res.body.scanStatus.count,
folderCount: res.body.scanStatus.folderCount,
lastScan: res.body.scanStatus.lastScan,
scanning: res.body.scanStatus.scanning,
};
},
getServerInfo: async (args) => {
const { apiClientProps } = args;
+7
View File
@@ -20,6 +20,7 @@ export type EventMap = {
PLAYLIST_MOVE_UP: PlaylistMoveEventPayload;
PLAYLIST_REORDER: PlaylistReorderEventPayload;
QUEUE_RESTORED: QueueRestoredEventPayload;
TAG_EDITED: TagEditedEventPayload;
USER_FAVORITE: UserFavoriteEventPayload;
USER_RATING: UserRatingEventPayload;
};
@@ -79,6 +80,12 @@ export type QueueRestoredEventPayload = {
position: number;
};
export type TagEditedEventPayload = {
id: string[];
itemType: LibraryItem;
serverId: string;
};
export type UserFavoriteEventPayload = {
favorite: boolean;
id: string[];
@@ -28,6 +28,17 @@ export const sharedQueries = {
...args.options,
});
},
scanStatus: (args: QueryHookArgs<null>) => {
return queryOptions({
queryFn: ({ signal }) => {
return api.controller.getScanStatus({
apiClientProps: { serverId: args.serverId, signal },
});
},
queryKey: queryKeys.server.scanStatus(args.serverId),
...args.options,
});
},
tagList: (args: QueryHookArgs<TagListQuery>) => {
return queryOptions({
gcTime: 1000 * 60 * 24,
@@ -0,0 +1,138 @@
import type { QueryClient } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useSyncExternalStore } from 'react';
import { queryKeys } from '/@/renderer/api/query-keys';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import { useCurrentServerId } from '/@/renderer/store';
const SCAN_STATUS_POLL_INTERVAL_MS = 2000;
const SCAN_WATCH_TIMEOUT_MS = 30_000;
let isWatchingScan = false;
let hasSeenScanStart = false;
let watchTimeoutId: null | ReturnType<typeof setTimeout> = null;
const watchListeners = new Set<() => void>();
const subscribeWatchingScan = (listener: () => void) => {
watchListeners.add(listener);
return () => {
watchListeners.delete(listener);
};
};
const getIsWatchingScan = () => isWatchingScan;
const setIsWatchingScan = (value: boolean) => {
if (isWatchingScan === value) {
return;
}
isWatchingScan = value;
for (const listener of watchListeners) {
listener();
}
};
const clearWatchTimeout = () => {
if (watchTimeoutId !== null) {
clearTimeout(watchTimeoutId);
watchTimeoutId = null;
}
};
const stopWatchingScan = () => {
clearWatchTimeout();
hasSeenScanStart = false;
setIsWatchingScan(false);
};
const startScanWatch = () => {
clearWatchTimeout();
hasSeenScanStart = false;
setIsWatchingScan(true);
watchTimeoutId = setTimeout(() => {
watchTimeoutId = null;
if (isWatchingScan && !hasSeenScanStart) {
stopWatchingScan();
}
}, SCAN_WATCH_TIMEOUT_MS);
};
const invalidateLibraryQueriesAfterScan = (queryClient: QueryClient, serverId: string) => {
return Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.songs.root(serverId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.albums.root(serverId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.albumArtists.root(serverId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.artists.root(serverId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.genres.root(serverId) }),
queryClient.invalidateQueries({ queryKey: [serverId, 'folders'] }),
queryClient.invalidateQueries({ queryKey: queryKeys.musicFolders.list(serverId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.search.root(serverId) }),
queryClient.invalidateQueries({ queryKey: [serverId, 'tags'] }),
queryClient.invalidateQueries({ queryKey: queryKeys.roles.list(serverId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.playlists.root(serverId) }),
queryClient.invalidateQueries({ queryKey: [serverId, 'home'] }),
queryClient.invalidateQueries({ queryKey: ['home'] }),
]);
};
const finishScanWatch = (queryClient: QueryClient, serverId: string) => {
if (!hasSeenScanStart) {
return;
}
clearWatchTimeout();
hasSeenScanStart = false;
setIsWatchingScan(false);
invalidateLibraryQueriesAfterScan(queryClient, serverId);
};
eventEmitter.on('TAG_EDITED', () => {
startScanWatch();
});
export const useScanStatus = () => {
const serverId = useCurrentServerId();
const queryClient = useQueryClient();
const isWatching = useSyncExternalStore(subscribeWatchingScan, getIsWatchingScan);
const query = useQuery({
...sharedQueries.scanStatus({
options: {
enabled: isWatching && Boolean(serverId),
refetchInterval: SCAN_STATUS_POLL_INTERVAL_MS,
retry: false,
staleTime: 0,
throwOnError: false,
},
query: null,
serverId,
}),
});
useEffect(() => {
if (!isWatching || !query.isFetched || query.isFetching || !query.data || !serverId) {
return;
}
if (query.data.scanning) {
hasSeenScanStart = true;
clearWatchTimeout();
return;
}
// Keep polling until the scan has actually started, then stop when it finishes.
if (hasSeenScanStart && !query.data.scanning) {
finishScanWatch(queryClient, serverId);
}
}, [isWatching, query.data, query.isFetched, query.isFetching, queryClient, serverId]);
return {
...query,
isScanning: query.data?.scanning === true,
isWatching,
};
};
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router';
import styles from './action-bar.module.css';
import { useScanStatus } from '/@/renderer/features/shared/hooks/use-scan-status';
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
import { useCommandPalette } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
@@ -15,6 +16,7 @@ import { TextInput } from '/@/shared/components/text-input/text-input';
export const ActionBar = () => {
const { t } = useTranslation();
const { open } = useCommandPalette();
const { isScanning } = useScanStatus();
return (
<div className={styles.container}>
@@ -49,7 +51,11 @@ export const ActionBar = () => {
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button p="0">
<Icon icon="menu" size="lg" />
<Icon
animate={isScanning ? 'spin' : undefined}
icon={isScanning ? 'spinner' : 'menu'}
size="lg"
/>
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
@@ -6,6 +6,7 @@ import { Link, NavLink, useNavigate } from 'react-router';
import styles from './collapsed-sidebar.module.css';
import { useScanStatus } from '/@/renderer/features/shared/hooks/use-scan-status';
import { CollapsedSidebarButton } from '/@/renderer/features/sidebar/components/collapsed-sidebar-button';
import { CollapsedSidebarItem } from '/@/renderer/features/sidebar/components/collapsed-sidebar-item';
import { getCollectionTo } from '/@/renderer/features/sidebar/components/sidebar-collection-list';
@@ -35,6 +36,8 @@ export const CollapsedSidebar = () => {
const { windowBarStyle } = useWindowSettings();
const sidebarCollapsedNavigation = useSidebarCollapsedNavigation();
const sidebarItems = useSidebarItems();
const { isScanning } = useScanStatus();
const translatedSidebarItemMap = useMemo(
() => ({
Albums: t('page.sidebar.albums'),
@@ -94,7 +97,14 @@ export const CollapsedSidebar = () => {
<CollapsedSidebarItem
activeIcon={null}
component={Flex}
icon={<Icon fill="muted" icon="menu" size="3xl" />}
icon={
<Icon
animate={isScanning ? 'spin' : undefined}
fill="muted"
icon={isScanning ? 'spinner' : 'menu'}
size="3xl"
/>
}
label={t('common.menu')}
style={{
cursor: 'pointer',
@@ -7,6 +7,7 @@ import JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png';
import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png';
import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import { useScanStatus } from '/@/renderer/features/shared/hooks/use-scan-status';
import { ServerSelectorItems } from '/@/renderer/features/sidebar/components/server-selector-items';
import { useCurrentServer } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils';
@@ -23,6 +24,7 @@ import { ServerFeature } from '/@/shared/types/features-types';
export const ServerSelector = () => {
const { t } = useTranslation();
const currentServer = useCurrentServer();
const { data: scanStatus, isScanning, isWatching } = useScanStatus();
const { data: musicFolders } = useQuery(
currentServer
@@ -54,6 +56,19 @@ export const ServerSelector = () => {
return selectedMusicFolders[0].name;
})();
const scanProgressParts: string[] = [];
if (scanStatus?.count && scanStatus.count > 0) {
scanProgressParts.push(t('common.scanItemCount', { count: scanStatus.count }));
}
if (scanStatus?.folderCount && scanStatus.folderCount > 0) {
scanProgressParts.push(t('common.scanFolderCount', { count: scanStatus.folderCount }));
}
const scanStatusText =
isWatching && isScanning
? [t('common.scanningLibrary'), ...scanProgressParts].filter(Boolean).join(' · ')
: null;
const logo =
currentServer.type === ServerType.NAVIDROME
? NavidromeLogo
@@ -75,6 +90,11 @@ export const ServerSelector = () => {
<Text isMuted size="xs" truncate>
{musicFolderDisplayText}
</Text>
{scanStatusText && (
<Text isMuted size="xs" truncate>
{scanStatusText}
</Text>
)}
</Stack>
<Icon icon="ellipsisVertical" size="sm" />
</Group>
@@ -15,10 +15,11 @@ import { FIELD_PRIORITY, KNOWN_TAG_MAP, KNOWN_TAGS, type KnownTag } from '../uti
import { base64ToBytes, bytesToBase64, formatBatchFileErrors } from '../utils/utils';
import { controller } from '/@/renderer/api/controller';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { useCurrentServer, useSettingsStoreActions, useTagEditorSettings } from '/@/renderer/store';
import { resolveSongPath } from '/@/renderer/utils/resolve-song-path';
import { toast } from '/@/shared/components/toast/toast';
import { Song } from '/@/shared/types/domain-types';
import { LibraryItem, Song } from '/@/shared/types/domain-types';
export const EDIT_SCOPE_ALL = '__all__';
@@ -493,6 +494,14 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
}
}
if (server) {
eventEmitter.emit('TAG_EDITED', {
id: songsInScope.map((s) => s.id),
itemType: LibraryItem.SONG,
serverId: server.id,
});
}
if (close) {
closeAllModals();
return;
+35
View File
@@ -760,6 +760,40 @@ const serverInfo = z.object({
Version: z.string(),
});
const taskTriggerInfo = z.object({
DayOfWeek: z.string().nullish(),
IntervalTicks: z.number().nullish(),
MaxRuntimeTicks: z.number().nullish(),
TimeOfDayTicks: z.number().nullish(),
Type: z.string().nullish(),
});
const taskResult = z.object({
EndTimeUtc: z.string().nullish(),
ErrorMessage: z.string().nullish(),
Id: z.string().nullish(),
Key: z.string().nullish(),
LongErrorMessage: z.string().nullish(),
Name: z.string().nullish(),
StartTimeUtc: z.string().nullish(),
Status: z.string().nullish(),
});
const taskInfo = z.object({
Category: z.string().nullish(),
CurrentProgressPercentage: z.number().nullish(),
Description: z.string().nullish(),
Id: z.string().nullish(),
IsHidden: z.boolean().optional(),
Key: z.string().nullish(),
LastExecutionResult: taskResult.nullish(),
Name: z.string().nullish(),
State: z.enum(['Idle', 'Cancelling', 'Running']).optional(),
Triggers: z.array(taskTriggerInfo).nullish(),
});
const scheduledTasks = z.array(taskInfo);
const similarSongsParameters = z.object({
Fields: z.array(z.string()).readonly().optional(),
Limit: z.number().optional(),
@@ -914,6 +948,7 @@ export const jfType = {
playlistList,
playlistSongList,
removeFromPlaylist,
scheduledTasks,
scrobble,
search,
serverInfo,
+1 -1
View File
@@ -132,7 +132,7 @@ img.size-5xl {
}
.spin {
animation: spin 1s linear infinite;
animation: spin 1.5s linear infinite;
}
.pulse {
+15
View File
@@ -1332,6 +1332,10 @@ export type FullLyricsMetadata = Omit<InternetProviderLyricResponse, 'id' | 'lyr
source: string;
};
export type GetScanStatusArgs = BaseEndpointArgs;
export type GetScanStatusResponse = ScanStatus;
export type InternetProviderLyricResponse = {
artist: string;
id: string;
@@ -1388,6 +1392,13 @@ export type RefreshItemsArgs = BaseEndpointArgs & { query: { ids: string[] } };
export type RefreshItemsResponse = null;
export type ScanStatus = {
count: number;
folderCount: number;
lastScan?: string;
scanning: boolean;
};
export type ScrobbleArgs = BaseEndpointArgs & {
query: ScrobbleQuery;
};
@@ -1568,6 +1579,7 @@ export type ControllerEndpoint = {
getPlayQueue: (args: GetQueueArgs) => Promise<GetQueueResponse>;
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
getRoles: (args: BaseEndpointArgs) => Promise<Array<string | { label: string; value: string }>>;
getScanStatus: (args: GetScanStatusArgs) => Promise<GetScanStatusResponse>;
getServerInfo: (args: ServerInfoArgs) => Promise<ServerInfo>;
getSimilarSongs: (args: SimilarSongsArgs) => Promise<Song[]>;
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
@@ -1733,6 +1745,9 @@ export type InternalControllerEndpoint = {
getRoles: (
args: ReplaceApiClientProps<BaseEndpointArgs>,
) => Promise<Array<string | { label: string; value: string }>>;
getScanStatus: (
args: ReplaceApiClientProps<GetScanStatusArgs>,
) => Promise<GetScanStatusResponse>;
getServerInfo: (args: ReplaceApiClientProps<ServerInfoArgs>) => Promise<ServerInfo>;
getSimilarSongs: (args: ReplaceApiClientProps<SimilarSongsArgs>) => Promise<Song[]>;
getSongDetail: (args: ReplaceApiClientProps<SongDetailArgs>) => Promise<SongDetailResponse>;