From d004816c5220da1e774825218a7e46d02aa3d924 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 1 Sep 2026 03:03:18 +0800 Subject: [PATCH 01/83] refactor: clean up Sidebar --- apps/ui/src/components/mobile/Navigation.tsx | 6 +- ...tItemV2.test.ts => FolderListItem.test.ts} | 28 +-- ...olderListItemV2.tsx => FolderListItem.tsx} | 6 +- apps/ui/src/components/v2/Sidebar.test.tsx | 4 +- apps/ui/src/components/v2/Sidebar.tsx | 170 ++--------------- apps/ui/src/hooks/useSidebar.ts | 176 ++++++++++++++++++ apps/ui/src/lib/sidebarRowUtils.ts | 8 +- 7 files changed, 221 insertions(+), 177 deletions(-) rename apps/ui/src/components/sidebar/{MediaFolderListItemV2.test.ts => FolderListItem.test.ts} (86%) rename apps/ui/src/components/sidebar/{MediaFolderListItemV2.tsx => FolderListItem.tsx} (97%) create mode 100644 apps/ui/src/hooks/useSidebar.ts diff --git a/apps/ui/src/components/mobile/Navigation.tsx b/apps/ui/src/components/mobile/Navigation.tsx index ee6c5d78..b16c9125 100644 --- a/apps/ui/src/components/mobile/Navigation.tsx +++ b/apps/ui/src/components/mobile/Navigation.tsx @@ -1,8 +1,8 @@ -import { MediaFolderListItemV2, type MediaFolderListItemV2Props } from "@/components/sidebar/MediaFolderListItemV2" +import { FolderListItem, type FolderListItemProps } from "@/components/sidebar/FolderListItem" import { useTranslation } from "@/lib/i18n" export interface NavigationProps { - filteredAndSortedFolders: MediaFolderListItemV2Props[] + filteredAndSortedFolders: FolderListItemProps[] handleMediaFolderListItemClick: (path: string) => void } @@ -43,7 +43,7 @@ export function Navigation({ ) : ( filteredAndSortedFolders.map((folder) => (
- ({ useTranslation: () => ({ t: (key: string) => key, }), })) -describe('MediaFolderListItemV2 context menu callbacks', () => { +describe('FolderListItem context menu callbacks', () => { const path = '/media/tvshows/Old Name' const mediaName = 'Old Name' @@ -26,7 +26,7 @@ describe('MediaFolderListItemV2 context menu callbacks', () => { it('calls callback props from context menu actions', () => { render( - React.createElement(MediaFolderListItemV2, { + React.createElement(FolderListItem, { path, mediaName, mediaType: 'tvshow', @@ -56,13 +56,13 @@ describe('MediaFolderListItemV2 context menu callbacks', () => { }) }) -describe("MediaFolderListItemV2 folder_not_found status", () => { +describe("FolderListItem folder_not_found status", () => { const path = "/media/tvshows/Missing" const mediaName = "Missing Show" it("shows folder basename in sidebar-folder-name", () => { render( - React.createElement(MediaFolderListItemV2, { + React.createElement(FolderListItem, { path: "/root/.cache/smm/smm-test-folder/Missing Show", mediaName: "Missing Show", mediaType: "tvshow", @@ -74,7 +74,7 @@ describe("MediaFolderListItemV2 folder_not_found status", () => { it("renders warning icon with aria-label from translation key", () => { render( - React.createElement(MediaFolderListItemV2, { + React.createElement(FolderListItem, { path, mediaName, mediaType: "tvshow", @@ -87,7 +87,7 @@ describe("MediaFolderListItemV2 folder_not_found status", () => { it("applies muted disabled-style classes on media title and folder name", () => { render( - React.createElement(MediaFolderListItemV2, { + React.createElement(FolderListItem, { path, mediaName, mediaType: "tvshow", @@ -106,7 +106,7 @@ describe("MediaFolderListItemV2 folder_not_found status", () => { it("does not show loading spinner when status is folder_not_found", () => { render( - React.createElement(MediaFolderListItemV2, { + React.createElement(FolderListItem, { path, mediaName, mediaType: "tvshow", @@ -120,7 +120,7 @@ describe("MediaFolderListItemV2 folder_not_found status", () => { it("still fires onClick on the row when folder_not_found (visual-only disabled state)", () => { const onClick = vi.fn() render( - React.createElement(MediaFolderListItemV2, { + React.createElement(FolderListItem, { path, mediaName, mediaType: "tvshow", @@ -136,13 +136,13 @@ describe("MediaFolderListItemV2 folder_not_found status", () => { }) }) -describe("MediaFolderListItemV2 pending_for_initialization status", () => { +describe("FolderListItem pending_for_initialization status", () => { const path = "/media/tvshows/Pending Show" const mediaName = "Pending Show" it("renders a pending-initialization badge", () => { render( - React.createElement(MediaFolderListItemV2, { + React.createElement(FolderListItem, { path, mediaName, mediaType: "tvshow", @@ -155,7 +155,7 @@ describe("MediaFolderListItemV2 pending_for_initialization status", () => { it("renders a clock icon for the pending state", () => { render( - React.createElement(MediaFolderListItemV2, { + React.createElement(FolderListItem, { path, mediaName, mediaType: "tvshow", @@ -169,7 +169,7 @@ describe("MediaFolderListItemV2 pending_for_initialization status", () => { it("shows the pending-initialization label only as sr-only text", () => { render( - React.createElement(MediaFolderListItemV2, { + React.createElement(FolderListItem, { path, mediaName, mediaType: "tvshow", @@ -183,7 +183,7 @@ describe("MediaFolderListItemV2 pending_for_initialization status", () => { it("does not show loading spinner for pending_for_initialization", () => { render( - React.createElement(MediaFolderListItemV2, { + React.createElement(FolderListItem, { path, mediaName, mediaType: "tvshow", diff --git a/apps/ui/src/components/sidebar/MediaFolderListItemV2.tsx b/apps/ui/src/components/sidebar/FolderListItem.tsx similarity index 97% rename from apps/ui/src/components/sidebar/MediaFolderListItemV2.tsx rename to apps/ui/src/components/sidebar/FolderListItem.tsx index 4f1ebf39..ab4d30af 100644 --- a/apps/ui/src/components/sidebar/MediaFolderListItemV2.tsx +++ b/apps/ui/src/components/sidebar/FolderListItem.tsx @@ -11,7 +11,7 @@ import { Clock, Loader2, TriangleAlert } from "lucide-react" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { useTranslation } from "@/lib/i18n" -export interface MediaFolderListItemV2Props { +export interface FolderListItemProps { mediaName: string, mediaType: "tvshow" | "movie" | "music", /** @@ -39,7 +39,7 @@ export interface MediaFolderListItemV2Props { status?: 'idle' | 'pending_for_initialization' | 'initializing' | 'ok' | 'folder_not_found' | 'loading' } -export function MediaFolderListItemV2({ +export function FolderListItem({ mediaName, path, onClick, @@ -49,7 +49,7 @@ export function MediaFolderListItemV2({ onOpenInExplorer, onDelete, status, -}: MediaFolderListItemV2Props) { +}: FolderListItemProps) { const { t } = useTranslation(['components', 'dialogs']) const selected = isSelected const isFolderUnavailable = status === 'folder_not_found' diff --git a/apps/ui/src/components/v2/Sidebar.test.tsx b/apps/ui/src/components/v2/Sidebar.test.tsx index 8646a0c2..059197a7 100644 --- a/apps/ui/src/components/v2/Sidebar.test.tsx +++ b/apps/ui/src/components/v2/Sidebar.test.tsx @@ -70,8 +70,8 @@ vi.mock("@/lib/i18n", () => ({ }), })) -vi.mock("../sidebar/MediaFolderListItemV2", () => ({ - MediaFolderListItemV2: ({ +vi.mock("../sidebar/FolderListItem", () => ({ + FolderListItem: ({ path, mediaName, onDelete, diff --git a/apps/ui/src/components/v2/Sidebar.tsx b/apps/ui/src/components/v2/Sidebar.tsx index 75ac292d..17f92f3c 100644 --- a/apps/ui/src/components/v2/Sidebar.tsx +++ b/apps/ui/src/components/v2/Sidebar.tsx @@ -1,28 +1,8 @@ -import { useCallback, useMemo } from "react" -import { useQueries } from "@tanstack/react-query" import { SearchForm } from "@/components/search-form" import { MediaFolderToolbar, type SortOrder, type FilterType } from "@/components/shared/MediaFolderToolbar" -import { MediaFolderListItemV2 } from "../sidebar/MediaFolderListItemV2" -import { useSidebarStore, compareByDisplayName } from "@/stores/sidebarStore" -import { basename } from "@/lib/path" -import { Path } from "@smm/utils/path" -import { - useUIMediaFolderStoreState, - useUIMediaFolderStoreActions, - useUIMediaFolderSelection, -} from "@/stores/uiMediaFolderStore" -import { useMediaMetadataQuery } from "@/hooks/mediaMetadata/useMediaMetadataQuery" -import { mediaMetadataReadQueryOptions } from "@/lib/mediaMetadataQueryKeys" -import { buildMediaFolderListItemPropsFromFolderAndMetadata } from "@/lib/sidebarRowUtils" -import { useDialogs } from "@/providers/dialog-provider" -import { useConfig } from "@/hooks/userConfig" -import { openInFileManagerApi } from "@/api/openInFileManager" -import { nextTraceId } from "@/lib/utils" -import { deleteMetadata } from "@/api/metadata" +import { FolderListItem } from "../sidebar/FolderListItem" +import { useSidebar } from "@/hooks/useSidebar" import { useTranslation } from "@/lib/i18n" -import { isSmmV3Enabled } from "@/lib/localStorages" -import { useFoldersQuery, useUnimportFolderMutation } from "@/hooks/folders" -import { mergeFolderPathsWithUiStatus } from "@/lib/mergeFolderPathsWithUiStatus" export type { SortOrder, FilterType } @@ -32,134 +12,22 @@ export interface SidebarProps { export function Sidebar({ onDeleteSelected }: SidebarProps) { const { t } = useTranslation(["components"]) - const { sortOrder, filterType, searchQuery, setSortOrder, setFilterType, setSearchQuery } = useSidebarStore() - const { folders } = useUIMediaFolderStoreState() - console.log(`[DIAG] Sidebar render: ${folders.length} folders in store: [${folders.map(f => f.path).join(', ')}]`) - const { applyFolderClick, selectAllFolderPaths, removeFolder } = useUIMediaFolderStoreActions() - const { selectedFolder, selectedFolderPathsSet } = useUIMediaFolderSelection() - const { userConfig, setAndSaveUserConfig } = useConfig() - const unimportFolderMutation = useUnimportFolderMutation() - const { renameFolderDialog } = useDialogs() - const [openRenameForMediaFolder] = renameFolderDialog - const { data: selectedMediaMetadata } = useMediaMetadataQuery(selectedFolder || undefined) - const primarySelectedPath = selectedMediaMetadata?.mediaFolderPath ?? selectedFolder - - const foldersQuery = useFoldersQuery() - const v3 = isSmmV3Enabled() - const listFolders = v3 - ? mergeFolderPathsWithUiStatus(foldersQuery.data ?? [], folders) - : folders - - const folderPaths = useMemo(() => listFolders.map((f) => f.path), [listFolders]) - - const metadataQueries = useQueries({ - queries: folderPaths.map((path) => ({ - ...mediaMetadataReadQueryOptions(path), - staleTime: 5 * 60 * 1000, - })), - }) - - const rowsWithMeta = useMemo(() => { - return listFolders.map((folder, i) => - buildMediaFolderListItemPropsFromFolderAndMetadata(folder, metadataQueries[i]?.data), - ) - }, [listFolders, metadataQueries]) - - const filteredAndSortedFolders = useMemo(() => { - let result = [...rowsWithMeta] - - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase().trim() - result = result.filter((folder) => { - const mediaNameMatch = folder.mediaName.toLowerCase().includes(query) - const pathMatch = folder.path.toLowerCase().includes(query) - const folderName = basename(folder.path) || "" - const folderNameMatch = folderName.toLowerCase().includes(query) - return mediaNameMatch || pathMatch || folderNameMatch - }) - } - - if (filterType !== "all") { - result = result.filter((folder) => folder.mediaType === filterType) - } - - result.sort((a, b) => compareByDisplayName(a.mediaName, b.mediaName, sortOrder)) - - return result - }, [rowsWithMeta, sortOrder, filterType, searchQuery]) - - const handleOpenInExplorer = useCallback(async (path: string) => { - try { - const result = await openInFileManagerApi(path) - if (result.error) { - console.error("[OpenInFileManager] Error:", result.error) - } - } catch (error) { - console.error("[OpenInFileManager] Failed to open folder:", error) - } - }, []) - - const handleRename = useCallback( - (path: string) => { - openRenameForMediaFolder(path, { - title: t("mediaFolder.renameTitle"), - description: t("mediaFolder.renameDescription"), - }) - }, - [openRenameForMediaFolder, t], - ) - - const handleDeletePaths = useCallback( - async (paths: string[]) => { - if (paths.length === 0) return - if (isSmmV3Enabled()) { - await unimportFolderMutation.mutateAsync(paths) - return - } - if (onDeleteSelected) { - await onDeleteSelected(paths) - return - } - - const traceId = `Sidebar-onDeleteSelected-${nextTraceId()}` - const deletedSet = new Set(paths.map((p) => Path.posix(p))) - - await Promise.all(paths.map((path) => deleteMetadata(path))) - - setAndSaveUserConfig(traceId, { - ...userConfig, - folders: userConfig.folders.filter((folder) => !deletedSet.has(Path.posix(folder))), - }) - paths.forEach((path) => removeFolder(path)) - }, - [onDeleteSelected, setAndSaveUserConfig, userConfig, removeFolder, unimportFolderMutation], - ) - - const handleListKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === "a") { - e.preventDefault() - selectAllFolderPaths(filteredAndSortedFolders.map((f) => f.path)) - } - if (e.key === "Delete" && selectedFolderPathsSet.size > 0) { - e.preventDefault() - void handleDeletePaths(Array.from(selectedFolderPathsSet)) - } - }, - [handleDeletePaths, selectAllFolderPaths, filteredAndSortedFolders, selectedFolderPathsSet], - ) - - const handleDeleteItem = useCallback( - (path: string) => { - const posix = Path.posix(path) - const selectedPaths = Array.from(selectedFolderPathsSet) - const shouldDeleteSelection = - selectedPaths.length > 0 && selectedPaths.some((p) => Path.posix(p) === posix) - const paths = shouldDeleteSelection ? selectedPaths : [path] - void handleDeletePaths(paths) - }, - [handleDeletePaths, selectedFolderPathsSet], - ) + const { + sortOrder, + filterType, + searchQuery, + setSortOrder, + setFilterType, + setSearchQuery, + filteredAndSortedFolders, + selectedFolderPathsSet, + primarySelectedPath, + applyFolderClick, + handleListKeyDown, + handleRename, + handleOpenInExplorer, + handleDeleteItem, + } = useSidebar({ onDeleteSelected }) return (
@@ -194,7 +62,7 @@ export function Sidebar({ onDeleteSelected }: SidebarProps) {
{filteredAndSortedFolders.map((folder, index) => (
- void +} + +export function useSidebar({ onDeleteSelected }: UseSidebarOptions = {}) { + const { t } = useTranslation(["components"]) + const { sortOrder, filterType, searchQuery, setSortOrder, setFilterType, setSearchQuery } = useSidebarStore() + const { applyFolderClick, selectAllFolderPaths, removeFolder } = useUIMediaFolderStoreActions() + const { selectedFolder, selectedFolderPathsSet } = useUIMediaFolderSelection() + const { userConfig, setAndSaveUserConfig } = useConfig() + const folders = useMemo(() => { + return uniq(userConfig.folders) + }, [userConfig.folders]) + const unimportFolderMutation = useUnimportFolderMutation() + const { renameFolderDialog } = useDialogs() + const [openRenameForMediaFolder] = renameFolderDialog + const { data: selectedMediaMetadata } = useMediaMetadataQuery(selectedFolder || undefined) + const primarySelectedPath = selectedMediaMetadata?.mediaFolderPath ?? selectedFolder + + const foldersQuery = useFoldersQuery() + const v3 = isSmmV3Enabled() + const listFolders = v3 + ? mergeFolderPathsWithUiStatus(foldersQuery.data ?? [], folders) + : folders + + const folderPaths = useMemo(() => listFolders.map((f) => f.path), [listFolders]) + + const metadataQueries = useQueries({ + queries: folderPaths.map((path) => ({ + ...mediaMetadataReadQueryOptions(path), + staleTime: 5 * 60 * 1000, + })), + }) + + const rowsWithMeta = useMemo(() => { + return listFolders.map((folder, i) => + buildMediaFolderListItemPropsFromFolderAndMetadata(folder, metadataQueries[i]?.data), + ) + }, [listFolders, metadataQueries]) + + const filteredAndSortedFolders = useMemo(() => { + let result = [...rowsWithMeta] + + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase().trim() + result = result.filter((folder) => { + const mediaNameMatch = folder.mediaName.toLowerCase().includes(query) + const pathMatch = folder.path.toLowerCase().includes(query) + const folderName = basename(folder.path) || "" + const folderNameMatch = folderName.toLowerCase().includes(query) + return mediaNameMatch || pathMatch || folderNameMatch + }) + } + + if (filterType !== "all") { + result = result.filter((folder) => folder.mediaType === filterType) + } + + result.sort((a, b) => compareByDisplayName(a.mediaName, b.mediaName, sortOrder)) + + return result + }, [rowsWithMeta, sortOrder, filterType, searchQuery]) + + const handleOpenInExplorer = useCallback(async (path: string) => { + try { + const result = await openInFileManagerApi(path) + if (result.error) { + console.error("[OpenInFileManager] Error:", result.error) + } + } catch (error) { + console.error("[OpenInFileManager] Failed to open folder:", error) + } + }, []) + + const handleRename = useCallback( + (path: string) => { + openRenameForMediaFolder(path, { + title: t("mediaFolder.renameTitle"), + description: t("mediaFolder.renameDescription"), + }) + }, + [openRenameForMediaFolder, t], + ) + + const handleDeletePaths = useCallback( + async (paths: string[]) => { + if (paths.length === 0) return + if (isSmmV3Enabled()) { + await unimportFolderMutation.mutateAsync(paths) + return + } + if (onDeleteSelected) { + await onDeleteSelected(paths) + return + } + + const traceId = `Sidebar-onDeleteSelected-${nextTraceId()}` + const deletedSet = new Set(paths.map((p) => Path.posix(p))) + + await Promise.all(paths.map((path) => deleteMetadata(path))) + + setAndSaveUserConfig(traceId, { + ...userConfig, + folders: userConfig.folders.filter((folder) => !deletedSet.has(Path.posix(folder))), + }) + paths.forEach((path) => removeFolder(path)) + }, + [onDeleteSelected, setAndSaveUserConfig, userConfig, removeFolder, unimportFolderMutation], + ) + + const handleListKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "a") { + e.preventDefault() + selectAllFolderPaths(filteredAndSortedFolders.map((f) => f.path)) + } + if (e.key === "Delete" && selectedFolderPathsSet.size > 0) { + e.preventDefault() + void handleDeletePaths(Array.from(selectedFolderPathsSet)) + } + }, + [handleDeletePaths, selectAllFolderPaths, filteredAndSortedFolders, selectedFolderPathsSet], + ) + + const handleDeleteItem = useCallback( + (path: string) => { + const posix = Path.posix(path) + const selectedPaths = Array.from(selectedFolderPathsSet) + const shouldDeleteSelection = + selectedPaths.length > 0 && selectedPaths.some((p) => Path.posix(p) === posix) + const paths = shouldDeleteSelection ? selectedPaths : [path] + void handleDeletePaths(paths) + }, + [handleDeletePaths, selectedFolderPathsSet], + ) + + return { + sortOrder, + filterType, + searchQuery, + setSortOrder, + setFilterType, + setSearchQuery, + filteredAndSortedFolders, + selectedFolderPathsSet, + primarySelectedPath, + applyFolderClick, + handleListKeyDown, + handleRename, + handleOpenInExplorer, + handleDeleteItem, + } +} diff --git a/apps/ui/src/lib/sidebarRowUtils.ts b/apps/ui/src/lib/sidebarRowUtils.ts index 592ddfcf..8d151fa9 100644 --- a/apps/ui/src/lib/sidebarRowUtils.ts +++ b/apps/ui/src/lib/sidebarRowUtils.ts @@ -1,6 +1,6 @@ import type { MediaMetadata } from "@smm/types" import { basename } from "@/lib/path" -import type { MediaFolderListItemV2Props } from "@/components/sidebar/MediaFolderListItemV2" +import type { FolderListItemProps } from "@/components/sidebar/FolderListItem" import type { UIMediaFolder, UIMediaFolderStatus } from "@/types/UIMediaFolder" function displayNameFromMetadata(metadata: MediaMetadata | undefined, path: string): string { @@ -10,7 +10,7 @@ function displayNameFromMetadata(metadata: MediaMetadata | undefined, path: stri return basename(metadata.mediaFolderPath ?? path) || "未识别媒体名称" } -function mediaTypeFromMetadata(metadata: MediaMetadata | undefined): MediaFolderListItemV2Props["mediaType"] { +function mediaTypeFromMetadata(metadata: MediaMetadata | undefined): FolderListItemProps["mediaType"] { if (!metadata?.type) return "movie" if (metadata.type === "tvshow-folder") return "tvshow" if (metadata.type === "music-folder") return "music" @@ -20,7 +20,7 @@ function mediaTypeFromMetadata(metadata: MediaMetadata | undefined): MediaFolder function mapFolderStatusToItemStatus( status: UIMediaFolderStatus, -): NonNullable { +): NonNullable { if (status === "updating") return "loading" if (status === "error_loading_metadata") return "folder_not_found" if ( @@ -42,7 +42,7 @@ function mapFolderStatusToItemStatus( export function buildMediaFolderListItemPropsFromFolderAndMetadata( folder: UIMediaFolder, metadata: MediaMetadata | undefined, -): MediaFolderListItemV2Props { +): FolderListItemProps { const path = folder.path return { path, From ce3b795a706f86391416913c49e7573eac484f2d Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 1 Sep 2026 18:05:31 +0800 Subject: [PATCH 02/83] refactor(ui): presentational Sidebar list with UI-owned selection and search Split FolderListItem from data hooks via FolderListItemContainer, move v2 layout into components/sidebar, and lift selection/search into Sidebar UI state with Storybook coverage. Also wire import-folder event handling and rename AppV2 to App. Co-authored-by: Cursor --- apps/ui/.storybook/preview.tsx | 4 + apps/ui/src/{AppV2.test.tsx => App.test.tsx} | 10 +- apps/ui/src/{AppV2.tsx => App.tsx} | 44 +-- apps/ui/src/api/importFolder.ts | 66 +++++ .../components/dragdrop/DragDropReceiver.tsx | 4 +- .../ImportFolderEventHandler.test.ts | 128 ++++++++ .../ImportFolderEventHandler.tsx | 71 +++++ .../sidebar/FolderListItem.stories.tsx | 76 +++++ .../components/sidebar/FolderListItem.test.ts | 15 + .../src/components/sidebar/FolderListItem.tsx | 48 +-- .../sidebar/FolderListItemContainer.tsx | 21 ++ .../components/sidebar/Sidebar.stories.tsx | 88 ++++++ .../{v2 => sidebar}/Sidebar.test.tsx | 275 ++++++++++++++---- apps/ui/src/components/sidebar/Sidebar.tsx | 202 +++++++++++++ .../components/{v2 => sidebar}/Toolbar.tsx | 0 .../{v2 => sidebar}/ViewSwitcher.tsx | 0 apps/ui/src/components/v2/Sidebar.tsx | 83 ------ apps/ui/src/hooks/folders/index.ts | 1 + .../hooks/folders/useImportFolderMutation.ts | 8 + apps/ui/src/hooks/useFolderListItem.ts | 41 +++ apps/ui/src/hooks/useSidebar.ts | 66 +---- .../lib/mergeFolderPathsWithUiStatus.test.ts | 44 --- .../src/lib/mergeFolderPathsWithUiStatus.ts | 6 +- apps/ui/src/lib/sidebarFolderSearch.test.ts | 26 ++ apps/ui/src/lib/sidebarFolderSearch.ts | 20 ++ .../ui/src/lib/sidebarFolderSelection.test.ts | 29 ++ apps/ui/src/lib/sidebarFolderSelection.ts | 26 ++ apps/ui/src/lib/sidebarRowUtils.test.ts | 12 + apps/ui/src/lib/sidebarRowUtils.ts | 8 +- apps/ui/src/main.tsx | 9 +- apps/ui/src/stores/sidebarStore.ts | 4 - apps/ui/src/types/UIMediaFolder.ts | 25 -- apps/ui/src/types/eventTypes.ts | 2 +- docs/superpowers/architecture.md | 3 +- 34 files changed, 1142 insertions(+), 323 deletions(-) rename apps/ui/src/{AppV2.test.tsx => App.test.tsx} (97%) rename apps/ui/src/{AppV2.tsx => App.tsx} (91%) create mode 100644 apps/ui/src/api/importFolder.ts create mode 100644 apps/ui/src/components/eventlisteners/ImportFolderEventHandler.test.ts create mode 100644 apps/ui/src/components/eventlisteners/ImportFolderEventHandler.tsx create mode 100644 apps/ui/src/components/sidebar/FolderListItem.stories.tsx create mode 100644 apps/ui/src/components/sidebar/FolderListItemContainer.tsx create mode 100644 apps/ui/src/components/sidebar/Sidebar.stories.tsx rename apps/ui/src/components/{v2 => sidebar}/Sidebar.test.tsx (51%) create mode 100644 apps/ui/src/components/sidebar/Sidebar.tsx rename apps/ui/src/components/{v2 => sidebar}/Toolbar.tsx (100%) rename apps/ui/src/components/{v2 => sidebar}/ViewSwitcher.tsx (100%) delete mode 100644 apps/ui/src/components/v2/Sidebar.tsx create mode 100644 apps/ui/src/hooks/folders/useImportFolderMutation.ts create mode 100644 apps/ui/src/hooks/useFolderListItem.ts delete mode 100644 apps/ui/src/lib/mergeFolderPathsWithUiStatus.test.ts create mode 100644 apps/ui/src/lib/sidebarFolderSearch.test.ts create mode 100644 apps/ui/src/lib/sidebarFolderSearch.ts create mode 100644 apps/ui/src/lib/sidebarFolderSelection.test.ts create mode 100644 apps/ui/src/lib/sidebarFolderSelection.ts delete mode 100644 apps/ui/src/types/UIMediaFolder.ts diff --git a/apps/ui/.storybook/preview.tsx b/apps/ui/.storybook/preview.tsx index 306f15fe..294e79d6 100644 --- a/apps/ui/.storybook/preview.tsx +++ b/apps/ui/.storybook/preview.tsx @@ -1,9 +1,13 @@ import type { Preview } from "@storybook/react-vite" import { I18nextProvider } from "react-i18next" +import { sb } from "storybook/test" import "../src/index.css" import i18n from "../src/lib/i18n" import { ThemeProvider } from "../src/providers/theme-provider" +// Spy so Sidebar.stories can stub list chrome without QueryClient / dialog providers. +sb.mock(import("../src/hooks/useSidebar.ts"), { spy: true }) + const preview: Preview = { parameters: { controls: { diff --git a/apps/ui/src/AppV2.test.tsx b/apps/ui/src/App.test.tsx similarity index 97% rename from apps/ui/src/AppV2.test.tsx rename to apps/ui/src/App.test.tsx index 02ec7769..52656811 100644 --- a/apps/ui/src/AppV2.test.tsx +++ b/apps/ui/src/App.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { render, screen } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import AppV2 from "./AppV2" +import App from "./App" const mockUseUIMediaFolderStoreState = vi.fn() const mockUseMediaMetadataQuery = vi.fn() @@ -51,11 +51,11 @@ vi.mock("@/providers/dialog-provider", () => ({ }), })) -vi.mock("@/components/v2/Sidebar", () => ({ +vi.mock("@/components/sidebar/Sidebar", () => ({ Sidebar: () =>
, })) -vi.mock("@/components/v2/Toolbar", () => ({ +vi.mock("@/components/sidebar/Toolbar", () => ({ Toolbar: () =>
, })) @@ -117,12 +117,12 @@ function renderApp() { }) return render( - + , ) } -describe("AppV2", () => { +describe("App", () => { beforeEach(() => { vi.clearAllMocks() mockSetAndSaveUserConfig.mockReset() diff --git a/apps/ui/src/AppV2.tsx b/apps/ui/src/App.tsx similarity index 91% rename from apps/ui/src/AppV2.tsx rename to apps/ui/src/App.tsx index d2bbfa05..c14749f7 100644 --- a/apps/ui/src/AppV2.tsx +++ b/apps/ui/src/App.tsx @@ -1,8 +1,8 @@ import { useState, useCallback, useEffect, useRef } from "react" import { useQueryClient } from "@tanstack/react-query" -import { Sidebar } from "@/components/v2/Sidebar" -import { Toolbar } from "@/components/v2/Toolbar" -import type { ViewMode } from "@/components/v2/ViewSwitcher" +import { Sidebar } from "@/components/sidebar/Sidebar" +import { Toolbar } from "@/components/sidebar/Toolbar" +import type { ViewMode } from "@/components/sidebar/ViewSwitcher" import { useUIMediaFolderStore, useUIMediaFolderStoreState } from "@/stores/uiMediaFolderStore" import { useDialogs } from "@/providers/dialog-provider" import type { FileItem, FolderType } from "@/providers/dialog-provider" @@ -32,7 +32,7 @@ import { useMediaMetadataQuery, } from "@/hooks/mediaMetadata" import { - UI_MediaFolderImportedEvent, + UI_ImportFolderEvent, UI_MediaLibraryImportedEvent, type OnMediaFolderImportedEventData, type OnMediaLibraryImportedEventData, @@ -47,13 +47,13 @@ import type { ImperativePanelHandle } from "react-resizable-panels" import { AIArea } from "@/components/AIArea" // WebSocketHandlers is now at AppSwitcher level to avoid disconnection on view switch -function AppV2Content() { +function AppContent() { // WebSocket connection is now established at AppSwitcher level to persist across view changes // No need to call useWebSocket() here anymore const { userConfig, setAndSaveUserConfig, isUserConfigLoaded } = useConfig() const unimportFolderMutation = useUnimportFolderMutation() - const { folders: uiFolders, selectedFolder } = useUIMediaFolderStoreState() + const { folders: uiFolders, selectedFolder, selectedFolders } = useUIMediaFolderStoreState() const { isAiAreaEnabled, isAiFeatureEnabled } = useFeatures() // View mode state @@ -108,7 +108,7 @@ function AppV2Content() { useEffect(() => { if (selectedMediaMetadata && selectedMediaMetadata.type === undefined) { logger.error( - `[AppV2] selectedMediaMetadata.type is undefined for folder: ${selectedFolder ?? "(none)"}, folderStatus: ${folderStatus ?? "(none)"}`, + `[App] selectedMediaMetadata.type is undefined for folder: ${selectedFolder ?? "(none)"}, folderStatus: ${folderStatus ?? "(none)"}`, ) } }, [selectedMediaMetadata, selectedMediaMetadata?.type, selectedFolder, folderStatus]) @@ -151,28 +151,28 @@ function AppV2Content() { openNativeFolderDialog().then((selectedFile) => { if (selectedFile) { openOpenFolder((type: FolderType) => { - const traceId = `AppV2:UserOpenFolder:` + nextTraceId() + const traceId = `App:UserOpenFolder:` + nextTraceId() const data: OnMediaFolderImportedEventData = { type: type, folderPathInPlatformFormat: selectedFile.path, traceId: traceId, } - document.dispatchEvent(new CustomEvent(UI_MediaFolderImportedEvent, { detail: data })) + document.dispatchEvent(new CustomEvent(UI_ImportFolderEvent, { detail: data })) }, selectedFile.path) } }) } else { openFilePicker((file: FileItem) => { openOpenFolder((type: FolderType) => { - const traceId = `AppV2:UserOpenFolder:` + nextTraceId() + const traceId = `App:UserOpenFolder:` + nextTraceId() const data: OnMediaFolderImportedEventData = { type: type, folderPathInPlatformFormat: file.path, traceId: traceId, } - document.dispatchEvent(new CustomEvent(UI_MediaFolderImportedEvent, { detail: data })) + document.dispatchEvent(new CustomEvent(UI_ImportFolderEvent, { detail: data })) }, file.path) }, { title: "Select Folder", @@ -190,7 +190,7 @@ function AppV2Content() { const detail: OnMediaLibraryImportedEventData = { libraryPathInPlatformFormat: selectedFile.path, type, - traceId: `AppV2:UserOpenMediaLibrary:${nextTraceId()}`, + traceId: `App:UserOpenMediaLibrary:${nextTraceId()}`, } document.dispatchEvent(new CustomEvent(UI_MediaLibraryImportedEvent, { detail })) }, selectedFile.path) @@ -202,7 +202,7 @@ function AppV2Content() { const detail: OnMediaLibraryImportedEventData = { libraryPathInPlatformFormat: file.path, type, - traceId: `AppV2:UserOpenMediaLibrary:${nextTraceId()}`, + traceId: `App:UserOpenMediaLibrary:${nextTraceId()}`, } document.dispatchEvent(new CustomEvent(UI_MediaLibraryImportedEvent, { detail })) }, file.path) @@ -223,7 +223,7 @@ function AppV2Content() { return } - const traceId = `AppV2-onDeleteSelected-${nextTraceId()}` + const traceId = `App-onDeleteSelected-${nextTraceId()}` const deletedPosix = new Set(paths.map((p) => Path.posix(p))) const deletedNative = new Set(paths) @@ -334,7 +334,17 @@ function AppV2Content() { {/* Sidebar */}
- + { + useUIMediaFolderStore.setState({ + selectedFolder: primaryPath, + selectedFolders: selectedPaths, + }) + }} + />
@@ -421,9 +431,9 @@ function AppV2Content() { ) } -export default function AppV2() { +export default function App() { return ( - + ) } diff --git a/apps/ui/src/api/importFolder.ts b/apps/ui/src/api/importFolder.ts new file mode 100644 index 00000000..2b8b1ccf --- /dev/null +++ b/apps/ui/src/api/importFolder.ts @@ -0,0 +1,66 @@ +import { apiFetch } from '@/lib/apiFetch' +import type { FolderType } from '@smm/types' + +export interface ImportFolderParams { + path: string + type: FolderType | 'anime' + skipInit?: boolean + /** Correlates client logs for import-folder flow. */ + traceId?: string +} + +export interface ImportFolderResponseBody { + data?: { id: string } + error?: string +} + +/** Layer-2 import folder via Core (`POST /api/import-folder`). */ +export async function importFolder( + params: ImportFolderParams, + signal?: AbortSignal, +): Promise { + const { traceId, path, type, skipInit } = params + const body: Record = { + path, + type, + } + if (skipInit === true) { + body.skipInit = true + } + + if (traceId) { + console.log(`[${traceId}] import-folder: POST /api/import-folder`, { path, type, skipInit: skipInit === true }) + } + + const resp = await apiFetch('/api/import-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }) + + if (!resp.ok) { + throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`) + } + + const data = (await resp.json()) as ImportFolderResponseBody + if (traceId) { + console.log(`[${traceId}] import-folder: POST /api/import-folder response`, { + jobId: data.data?.id, + error: data.error, + }) + } + return data +} + +/** Throws on business error; returns job id. */ +export async function importFolderViaCore(params: ImportFolderParams): Promise { + const data = await importFolder(params) + if (data.error) { + throw new Error(data.error) + } + if (!data.data?.id) { + throw new Error('Error Reason: import-folder job id missing') + } + return data.data.id +} diff --git a/apps/ui/src/components/dragdrop/DragDropReceiver.tsx b/apps/ui/src/components/dragdrop/DragDropReceiver.tsx index 053643a1..b8c7a51e 100644 --- a/apps/ui/src/components/dragdrop/DragDropReceiver.tsx +++ b/apps/ui/src/components/dragdrop/DragDropReceiver.tsx @@ -8,7 +8,7 @@ import { FolderOpen, Upload } from "lucide-react" import { useTranslation } from "@/lib/i18n" import { - UI_MediaFolderImportedEvent, + UI_ImportFolderEvent, type OnMediaFolderImportedEventData, } from "@/types/eventTypes" import { nextTraceId } from "@/lib/utils" @@ -142,7 +142,7 @@ export function DragDropReceiver({ children }: { children: ReactNode }) { traceId, } document.dispatchEvent( - new CustomEvent(UI_MediaFolderImportedEvent, { detail: data }), + new CustomEvent(UI_ImportFolderEvent, { detail: data }), ) }, folderPath) }, diff --git a/apps/ui/src/components/eventlisteners/ImportFolderEventHandler.test.ts b/apps/ui/src/components/eventlisteners/ImportFolderEventHandler.test.ts new file mode 100644 index 00000000..4733001e --- /dev/null +++ b/apps/ui/src/components/eventlisteners/ImportFolderEventHandler.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { createElement } from "react" +import { render } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +const { persistHarmonyOSFileAccessMock } = vi.hoisted(() => ({ + persistHarmonyOSFileAccessMock: vi.fn(), +})) + +const { importFolderViaCoreMock } = vi.hoisted(() => ({ + importFolderViaCoreMock: vi.fn(), +})) + +vi.mock("@/lib/persistHarmonyOSFileAccess", () => ({ + persistHarmonyOSFileAccess: persistHarmonyOSFileAccessMock, +})) + +vi.mock("@/api/importFolder", () => ({ + importFolderViaCore: importFolderViaCoreMock, +})) + +import { ImportFolderEventHandler } from "./ImportFolderEventHandler" +import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" +import { UI_ImportFolderEvent, type OnMediaFolderImportedEventData } from "@/types/eventTypes" + +describe("ImportFolderEventHandler", () => { + function renderHandler() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + render( + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(ImportFolderEventHandler), + ), + ) + } + + beforeEach(() => { + importFolderViaCoreMock.mockReset() + persistHarmonyOSFileAccessMock.mockReset() + persistHarmonyOSFileAccessMock.mockResolvedValue(undefined) + useUIMediaFolderStore.setState({ + folders: [], + selectedFolder: "", + selectedFolders: [], + }) + }) + + it("upserts initializing folder and POSTs /api/import-folder", async () => { + importFolderViaCoreMock.mockResolvedValue("core-job-1") + const folderPath = "/media/tvshow/Show A" + + renderHandler() + + document.dispatchEvent( + new CustomEvent(UI_ImportFolderEvent, { + detail: { + folderPathInPlatformFormat: folderPath, + type: "tvshow", + traceId: "test-trace", + } satisfies OnMediaFolderImportedEventData, + }), + ) + + await vi.waitFor(() => { + expect(importFolderViaCoreMock).toHaveBeenCalledWith({ + path: folderPath, + type: "tvshow", + traceId: "test-trace", + }) + }) + + expect(persistHarmonyOSFileAccessMock).toHaveBeenCalledWith([folderPath]) + + const state = useUIMediaFolderStore.getState() + expect(state.selectedFolder).toBe(folderPath) + expect(state.folders).toEqual([ + { path: folderPath, status: "initializing", type: "tvshow-folder" }, + ]) + }) + + it("skips optimistic UI when skipOptimisticUpdate is true", async () => { + importFolderViaCoreMock.mockResolvedValue("core-job-1") + const folderPath = "/media/movie/Movie A" + + renderHandler() + + document.dispatchEvent( + new CustomEvent(UI_ImportFolderEvent, { + detail: { + folderPathInPlatformFormat: folderPath, + type: "movie", + skipOptimisticUpdate: true, + } satisfies OnMediaFolderImportedEventData, + }), + ) + + await vi.waitFor(() => { + expect(importFolderViaCoreMock).toHaveBeenCalled() + }) + + const state = useUIMediaFolderStore.getState() + expect(state.folders).toEqual([]) + expect(state.selectedFolder).toBe("") + }) + + it("marks folder as error when import-folder fails", async () => { + importFolderViaCoreMock.mockRejectedValue(new Error("Error Reason: boom")) + const folderPath = "/media/music/Album" + + renderHandler() + + document.dispatchEvent( + new CustomEvent(UI_ImportFolderEvent, { + detail: { + folderPathInPlatformFormat: folderPath, + type: "music", + } satisfies OnMediaFolderImportedEventData, + }), + ) + + await vi.waitFor(() => { + expect(useUIMediaFolderStore.getState().folders[0]?.status).toBe("error_loading_metadata") + }) + }) +}) diff --git a/apps/ui/src/components/eventlisteners/ImportFolderEventHandler.tsx b/apps/ui/src/components/eventlisteners/ImportFolderEventHandler.tsx new file mode 100644 index 00000000..f770a347 --- /dev/null +++ b/apps/ui/src/components/eventlisteners/ImportFolderEventHandler.tsx @@ -0,0 +1,71 @@ +import { useRef } from "react" +import { useMount, useUnmount } from "react-use" +import debug from "debug" +import { toast } from "sonner" +import { useImportFolderMutation } from "@/hooks/folders/useImportFolderMutation" +import { folderTypeToMediaType } from "@/lib/importLibraryV3" +import { persistHarmonyOSFileAccess } from "@/lib/persistHarmonyOSFileAccess" +import { nextTraceId } from "@/lib/utils" +import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" +import { UI_ImportFolderEvent, type OnMediaFolderImportedEventData } from "@/types/eventTypes" + +export function ImportFolderEventHandler() { + const upsertFolder = useUIMediaFolderStore((s) => s.upsertFolder) + const setSelectedFolder = useUIMediaFolderStore((s) => s.setSelectedFolder) + const importFolderMutation = useImportFolderMutation() + const eventListener = useRef<((event: Event) => void) | null>(null) + + const doImportFolder = async (data: OnMediaFolderImportedEventData) => { + const { folderPathInPlatformFormat, type, skipOptimisticUpdate, onCompleted } = data + const traceId = data.traceId ?? `ImportFolderEventHandler:${nextTraceId()}` + const mediaType = folderTypeToMediaType(type) + + debug(`start ${UI_ImportFolderEvent}: ${JSON.stringify(data)}`) + + if (!skipOptimisticUpdate) { + upsertFolder({ + path: folderPathInPlatformFormat, + status: "initializing", + type: mediaType, + }) + setSelectedFolder(folderPathInPlatformFormat) + } + + try { + await persistHarmonyOSFileAccess([folderPathInPlatformFormat]) + const jobId = await importFolderMutation.mutateAsync({ + path: folderPathInPlatformFormat, + type, + traceId, + }) + console.log(`[${traceId}] import-folder: started job`, { jobId }) + } catch (error) { + console.error(`[${traceId}] import-folder: failed`, error) + upsertFolder({ + path: folderPathInPlatformFormat, + status: "error_loading_metadata", + type: mediaType, + }) + toast.error(error instanceof Error ? error.message : "Import folder failed") + } finally { + onCompleted?.() + } + } + + useMount(() => { + eventListener.current = (event) => { + const detail = (event as CustomEvent).detail + void doImportFolder(detail) + } + + document.addEventListener(UI_ImportFolderEvent, eventListener.current) + }) + + useUnmount(() => { + if (eventListener.current) { + document.removeEventListener(UI_ImportFolderEvent, eventListener.current) + } + }) + + return <> +} diff --git a/apps/ui/src/components/sidebar/FolderListItem.stories.tsx b/apps/ui/src/components/sidebar/FolderListItem.stories.tsx new file mode 100644 index 00000000..5911d2eb --- /dev/null +++ b/apps/ui/src/components/sidebar/FolderListItem.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" +import { FolderListItem } from "./FolderListItem" + +const meta = { + title: "Components/Sidebar/FolderListItem", + component: FolderListItem, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + mediaName: "Breaking Bad", + mediaType: "tvshow", + path: "/media/tvshows/Breaking Bad", + status: "ok", + isSelected: false, + isPrimary: false, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const Selected: Story = { + args: { + isSelected: true, + }, +} + +export const PrimarySelected: Story = { + args: { + isSelected: true, + isPrimary: true, + }, +} + +export const Movie: Story = { + args: { + mediaName: "Inception", + mediaType: "movie", + path: "/media/movies/Inception (2010)", + }, +} + +export const Music: Story = { + args: { + mediaName: "Dark Side of the Moon", + mediaType: "music", + path: "/media/music/Pink Floyd - Dark Side of the Moon", + }, +} + +export const Loading: Story = { + args: { + status: "loading", + }, +} + +export const PendingForInitialization: Story = { + args: { + status: "pending_for_initialization", + }, +} + +export const FolderNotFound: Story = { + args: { + mediaName: "Missing Show", + path: "/media/tvshows/Missing Show", + status: "folder_not_found", + }, +} diff --git a/apps/ui/src/components/sidebar/FolderListItem.test.ts b/apps/ui/src/components/sidebar/FolderListItem.test.ts index cb8c95ef..926cc555 100644 --- a/apps/ui/src/components/sidebar/FolderListItem.test.ts +++ b/apps/ui/src/components/sidebar/FolderListItem.test.ts @@ -194,3 +194,18 @@ describe("FolderListItem pending_for_initialization status", () => { expect(document.querySelector(".animate-spin")).toBeNull() }) }) + +describe("FolderListItem loading status", () => { + it("shows spinner when status is loading", () => { + render( + React.createElement(FolderListItem, { + path: "/media/tvshows/Loading Show", + mediaName: "Loading Show", + mediaType: "tvshow", + status: "loading", + }), + ) + + expect(document.querySelector(".animate-spin")).toBeTruthy() + }) +}) diff --git a/apps/ui/src/components/sidebar/FolderListItem.tsx b/apps/ui/src/components/sidebar/FolderListItem.tsx index ab4d30af..67ca5ce2 100644 --- a/apps/ui/src/components/sidebar/FolderListItem.tsx +++ b/apps/ui/src/components/sidebar/FolderListItem.tsx @@ -12,12 +12,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { useTranslation } from "@/lib/i18n" export interface FolderListItemProps { - mediaName: string, - mediaType: "tvshow" | "movie" | "music", + mediaName: string + mediaType: "tvshow" | "movie" | "music" /** * Absolute path of the media folder, in POSIX format */ - path: string, + path: string /** * Click handler for the folder item (receives event for modifier keys) */ @@ -36,7 +36,7 @@ export interface FolderListItemProps { /** * Status of the media metadata initialization */ - status?: 'idle' | 'pending_for_initialization' | 'initializing' | 'ok' | 'folder_not_found' | 'loading' + status?: "idle" | "pending_for_initialization" | "ok" | "folder_not_found" | "loading" } export function FolderListItem({ @@ -50,9 +50,9 @@ export function FolderListItem({ onDelete, status, }: FolderListItemProps) { - const { t } = useTranslation(['components', 'dialogs']) + const { t } = useTranslation(["components", "dialogs"]) const selected = isSelected - const isFolderUnavailable = status === 'folder_not_found' + const isFolderUnavailable = status === "folder_not_found" const folderName = useMemo(() => { return basename(path) @@ -68,7 +68,7 @@ export function FolderListItem({ (isPrimary ? "border-l-4 border-l-primary bg-primary/5" : "border-l-4 border-l-sidebar-primary bg-sidebar-accent"), - !selected && "bg-sidebar hover:bg-sidebar-accent/80" + !selected && "bg-sidebar hover:bg-sidebar-accent/80", )} onClick={onClick} data-selected={selected ? "true" : "false"} @@ -78,12 +78,11 @@ export function FolderListItem({
@@ -92,12 +91,11 @@ export function FolderListItem({

@@ -105,10 +103,10 @@ export function FolderListItem({

{/* Status indicator */} - {(status === 'initializing' || status === 'loading') && ( + {status === "loading" && ( )} - {status === 'pending_for_initialization' && ( + {status === "pending_for_initialization" && ( - {t('mediaFolder.pendingForInitialization')} + {t("mediaFolder.pendingForInitialization")} - {t('mediaFolder.pendingForInitialization')} + {t("mediaFolder.pendingForInitialization")} )} {isFolderUnavailable && ( @@ -134,12 +132,16 @@ export function FolderListItem({
- {t('mediaFolder.rename')} - {t('mediaFolder.openInExplorer')} + + {t("mediaFolder.rename")} + + + {t("mediaFolder.openInExplorer")} +
- {t('mediaFolder.delete')} - {t('mediaFolder.deleteWarning')} + {t("mediaFolder.delete")} + {t("mediaFolder.deleteWarning")}
diff --git a/apps/ui/src/components/sidebar/FolderListItemContainer.tsx b/apps/ui/src/components/sidebar/FolderListItemContainer.tsx new file mode 100644 index 00000000..f6a3b67b --- /dev/null +++ b/apps/ui/src/components/sidebar/FolderListItemContainer.tsx @@ -0,0 +1,21 @@ +import { FolderListItem, type FolderListItemProps } from "./FolderListItem" +import { useFolderListItem } from "@/hooks/useFolderListItem" + +export type FolderListItemContainerProps = Omit + +export function FolderListItemContainer({ + path, + ...handlers +}: FolderListItemContainerProps) { + const { mediaName, mediaType, status } = useFolderListItem(path) + + return ( + + ) +} diff --git a/apps/ui/src/components/sidebar/Sidebar.stories.tsx b/apps/ui/src/components/sidebar/Sidebar.stories.tsx new file mode 100644 index 00000000..822544b0 --- /dev/null +++ b/apps/ui/src/components/sidebar/Sidebar.stories.tsx @@ -0,0 +1,88 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" +import { fn, mocked } from "storybook/test" +import { basename } from "@/lib/path" +import { folderMatchesSearchQuery } from "@/lib/sidebarFolderSearch" +import { useSidebar } from "@/hooks/useSidebar" +import { FolderListItem } from "./FolderListItem" +import type { FolderListItemContainerProps } from "./FolderListItemContainer" +import { Sidebar } from "./Sidebar" + +const demoFolders = [ + { + path: "/media/tvshows/Breaking Bad", + mediaName: "Breaking Bad", + mediaType: "tvshow" as const, + status: "ok" as const, + }, + { + path: "/media/movies/Inception (2010)", + mediaName: "Inception", + mediaType: "movie" as const, + status: "loading" as const, + }, + { + path: "/media/tvshows/Pending Show", + mediaName: "Pending Show", + mediaType: "tvshow" as const, + status: "pending_for_initialization" as const, + }, + { + path: "/media/tvshows/Missing Show", + mediaName: "Missing Show", + mediaType: "tvshow" as const, + status: "folder_not_found" as const, + }, +] + +const demoByPath = Object.fromEntries(demoFolders.map((f) => [f.path, f])) + +/** Pure presentational row for Storybook — selection/click come from Sidebar. */ +function StoryFolderListItem(props: FolderListItemContainerProps) { + const demo = demoByPath[props.path] + return ( + + ) +} + +const meta = { + title: "Components/Sidebar/Sidebar", + component: Sidebar, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + beforeEach: () => { + mocked(useSidebar).mockImplementation((options = {}) => ({ + sortOrder: "alphabetical", + filterType: "all", + setSortOrder: fn(), + setFilterType: fn(), + filteredAndSortedFolders: demoFolders + .filter((f) => folderMatchesSearchQuery(f, options.searchQuery ?? "")) + .map((f) => f.path), + handleRename: fn(), + handleOpenInExplorer: fn(), + handleDeletePaths: fn(), + })) + }, + args: { + folderListItemSlot: StoryFolderListItem, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +/** + * Uncontrolled selection + search in Sidebar, pure {@link FolderListItem} rows. + * Click to select; Ctrl/Cmd+click to multi-select; type in the search box to filter. + */ +export const WithPureFolderListItems: Story = {} diff --git a/apps/ui/src/components/v2/Sidebar.test.tsx b/apps/ui/src/components/sidebar/Sidebar.test.tsx similarity index 51% rename from apps/ui/src/components/v2/Sidebar.test.tsx rename to apps/ui/src/components/sidebar/Sidebar.test.tsx index 059197a7..ca63af12 100644 --- a/apps/ui/src/components/v2/Sidebar.test.tsx +++ b/apps/ui/src/components/sidebar/Sidebar.test.tsx @@ -15,8 +15,23 @@ vi.mock("@/hooks/folders", () => ({ })) vi.mock("@/components/search-form", () => ({ - SearchForm: ({ placeholder }: { placeholder?: string }) => ( -
{placeholder}
+ SearchForm: ({ + placeholder, + value, + onValueChange, + }: { + placeholder?: string + value?: string + onValueChange?: (value: string) => void + }) => ( +
+ {placeholder} + onValueChange?.(e.target.value)} + /> +
), })) vi.mock("@/components/shared/MediaFolderToolbar", () => ({ @@ -70,18 +85,30 @@ vi.mock("@/lib/i18n", () => ({ }), })) -vi.mock("../sidebar/FolderListItem", () => ({ +vi.mock("./FolderListItem", () => ({ FolderListItem: ({ path, mediaName, onDelete, + onClick, + isSelected, }: { path: string mediaName: string onDelete?: () => void + onClick?: (e: React.MouseEvent) => void + isSelected?: boolean }) => (
{mediaName}
+ @@ -110,10 +137,8 @@ function baseSidebarMocks() { mockUseSidebarStore.mockReturnValue({ sortOrder: "asc", filterType: "all", - searchQuery: "", setSortOrder: vi.fn(), setFilterType: vi.fn(), - setSearchQuery: vi.fn(), }) mockUseUIMediaFolderStoreActions.mockReturnValue({ applyFolderClick: vi.fn(), @@ -153,35 +178,42 @@ describe("Sidebar delete behavior", () => { } as ReturnType) }) - it("deletes full selected set when deleting a selected item", () => { + it("deletes full selected set when deleting a selected item", async () => { const mutateAsync = vi.fn().mockResolvedValue(undefined) vi.mocked(useUnimportFolderMutation).mockReturnValue({ mutateAsync, } as ReturnType) const onDeleteSelected = vi.fn() - render() + render( + , + ) - fireEvent.click(screen.getByTestId(`delete-${Path.toPlatformPath(pathA)}`)) + fireEvent.click(await screen.findByTestId(`delete-${Path.toPlatformPath(pathA)}`)) expect(mutateAsync).toHaveBeenCalledTimes(1) expect(mutateAsync).toHaveBeenCalledWith(expect.arrayContaining([pathA, pathB])) expect(onDeleteSelected).not.toHaveBeenCalled() }) - it("deletes single item when clicked item is not in selected set", () => { - mockUseUIMediaFolderSelection.mockReturnValue({ - selectedFolder: pathA, - selectedFolders: [pathA], - selectedFolderPathsSet: new Set([pathA]), - }) + it("deletes single item when clicked item is not in selected set", async () => { const mutateAsync = vi.fn().mockResolvedValue(undefined) vi.mocked(useUnimportFolderMutation).mockReturnValue({ mutateAsync, } as ReturnType) const onDeleteSelected = vi.fn() - render() + render( + , + ) - fireEvent.click(screen.getByTestId(`delete-${Path.toPlatformPath(pathB)}`)) + fireEvent.click(await screen.findByTestId(`delete-${Path.toPlatformPath(pathB)}`)) expect(mutateAsync).toHaveBeenCalledTimes(1) expect(mutateAsync).toHaveBeenCalledWith([Path.toPlatformPath(pathB)]) @@ -245,68 +277,211 @@ describe("Sidebar mediaName", () => { mockUseMediaMetadataQuery.mockReturnValue({ data: { mediaFolderPath: folderPath } }) }) - it("passes tv show title as mediaName when tvShow is set", () => { + it("passes tv show title as mediaName when tvShow is set", async () => { const showTitle = "Recognized TV Title" - mockUseQueries.mockReturnValue([ - { - data: { - type: "tvshow-folder", - tvShow: { - database: "TMDB", - id: "1", - name: showTitle, - seasons: [], - }, - } as MediaMetadata, + const metadata = { + type: "tvshow-folder", + tvShow: { + database: "TMDB", + id: "1", + name: showTitle, + seasons: [], }, - ]) + } as MediaMetadata + mockUseQueries.mockReturnValue([{ data: metadata }]) + mockUseMediaMetadataQuery.mockImplementation((path?: string) => { + if (!path) return { data: { mediaFolderPath: folderPath }, isPending: false } + return { data: metadata, isPending: false } + }) render() - expect(screen.getByTestId("sidebar-folder-title")).toHaveTextContent(showTitle) + expect(await screen.findByTestId("sidebar-folder-title")).toHaveTextContent(showTitle) }) - it("passes movie title as mediaName when movie is set", () => { + it("passes movie title as mediaName when movie is set", async () => { const movieTitle = "Recognized Movie Title" - mockUseQueries.mockReturnValue([ - { - data: { - type: "movie-folder", - movie: { - database: "TMDB", - id: "2", - name: movieTitle, - }, - } as MediaMetadata, + const metadata = { + type: "movie-folder", + movie: { + database: "TMDB", + id: "2", + name: movieTitle, }, - ]) + } as MediaMetadata + mockUseQueries.mockReturnValue([{ data: metadata }]) + mockUseMediaMetadataQuery.mockImplementation((path?: string) => { + if (!path) return { data: { mediaFolderPath: folderPath }, isPending: false } + return { data: metadata, isPending: false } + }) render() - expect(screen.getByTestId("sidebar-folder-title")).toHaveTextContent(movieTitle) + expect(await screen.findByTestId("sidebar-folder-title")).toHaveTextContent(movieTitle) }) - it("passes folder basename as mediaName when query has no metadata", () => { + it("passes folder basename as mediaName when query has no metadata", async () => { mockUseQueries.mockReturnValue([{ data: undefined }]) + mockUseMediaMetadataQuery.mockImplementation((path?: string) => { + if (!path) return { data: { mediaFolderPath: folderPath }, isPending: false } + return { data: undefined, isPending: false } + }) render() - expect(screen.getByTestId("sidebar-folder-title")).toHaveTextContent(basename(folderPath)) + expect(await screen.findByTestId("sidebar-folder-title")).toHaveTextContent(basename(folderPath)) }) - it("passes basename of mediaFolderPath when metadata has no tvShow or movie", () => { + it("passes basename of mediaFolderPath when metadata has no tvShow or movie", async () => { const aliasPath = "/media/other/AliasFolderName" + const metadata = { + type: "movie-folder", + mediaFolderPath: aliasPath, + } as MediaMetadata + mockUseQueries.mockReturnValue([{ data: metadata }]) + mockUseMediaMetadataQuery.mockImplementation((path?: string) => { + if (!path) return { data: { mediaFolderPath: folderPath }, isPending: false } + return { data: metadata, isPending: false } + }) + + render() + + expect(await screen.findByTestId("sidebar-folder-title")).toHaveTextContent(basename(aliasPath)) + }) +}) + +describe("Sidebar selection UI", () => { + const pathA = "/media/folder-a" + const pathB = "/media/folder-b" + + beforeEach(() => { + vi.clearAllMocks() + baseSidebarMocks() + mockUseUIMediaFolderStoreState.mockReturnValue({ + folders: [ + { path: pathA, status: "ok", test: false }, + { path: pathB, status: "ok", test: false }, + ], + selectedFolder: "", + selectedFolders: [], + }) + mockUseUIMediaFolderSelection.mockReturnValue({ + selectedFolder: "", + selectedFolders: [], + selectedFolderPathsSet: new Set(), + }) + mockUseMediaMetadataQuery.mockReturnValue({ data: undefined, isPending: false }) + mockUseQueries.mockReturnValue([{ data: null }, { data: null }]) + vi.mocked(useFoldersQuery).mockReturnValue({ + data: [pathA, pathB], + isFetching: false, + } as ReturnType) + }) + + it("selects a folder on click (uncontrolled)", async () => { + const onSelectionChange = vi.fn() + render() + + const platformA = Path.toPlatformPath(pathA) + fireEvent.click(await screen.findByTestId(`select-${platformA}`)) + + expect(onSelectionChange).toHaveBeenCalledWith({ + selectedPaths: [platformA], + primaryPath: platformA, + multi: false, + }) + expect(screen.getByTestId(`select-${platformA}`)).toHaveAttribute("data-selected", "true") + }) + + it("toggles multi-select with ctrl/meta click", async () => { + const onSelectionChange = vi.fn() + render() + + const platformA = Path.toPlatformPath(pathA) + const platformB = Path.toPlatformPath(pathB) + fireEvent.click(await screen.findByTestId(`select-${platformA}`)) + fireEvent.click(screen.getByTestId(`select-${platformB}`), { ctrlKey: true }) + + expect(onSelectionChange).toHaveBeenLastCalledWith({ + selectedPaths: [platformA, platformB], + primaryPath: platformB, + multi: true, + }) + }) +}) + +describe("Sidebar search UI", () => { + const pathA = "/media/folder-a" + const pathB = "/media/folder-b" + + beforeEach(() => { + vi.clearAllMocks() + baseSidebarMocks() + mockUseUIMediaFolderStoreState.mockReturnValue({ + folders: [ + { path: pathA, status: "ok", test: false }, + { path: pathB, status: "ok", test: false }, + ], + selectedFolder: "", + selectedFolders: [], + }) + mockUseUIMediaFolderSelection.mockReturnValue({ + selectedFolder: "", + selectedFolders: [], + selectedFolderPathsSet: new Set(), + }) + mockUseMediaMetadataQuery.mockImplementation((path?: string) => { + if (path === Path.toPlatformPath(pathA) || path === pathA) { + return { + data: { + type: "tvshow-folder", + tvShow: { database: "TMDB", id: "1", name: "Alpha Show", seasons: [] }, + }, + isPending: false, + } + } + if (path === Path.toPlatformPath(pathB) || path === pathB) { + return { + data: { + type: "movie-folder", + movie: { database: "TMDB", id: "2", name: "Beta Movie" }, + }, + isPending: false, + } + } + return { data: undefined, isPending: false } + }) mockUseQueries.mockReturnValue([ + { + data: { + type: "tvshow-folder", + tvShow: { database: "TMDB", id: "1", name: "Alpha Show", seasons: [] }, + }, + }, { data: { type: "movie-folder", - mediaFolderPath: aliasPath, - } as MediaMetadata, + movie: { database: "TMDB", id: "2", name: "Beta Movie" }, + }, }, ]) + vi.mocked(useFoldersQuery).mockReturnValue({ + data: [pathA, pathB], + isFetching: false, + } as ReturnType) + }) - render() + it("filters the list when the search query changes (uncontrolled)", async () => { + const onSearchQueryChange = vi.fn() + render() + + expect(await screen.findByTestId(`select-${Path.toPlatformPath(pathA)}`)).toBeInTheDocument() + expect(screen.getByTestId(`select-${Path.toPlatformPath(pathB)}`)).toBeInTheDocument() + + fireEvent.change(screen.getByTestId("sidebar-search-input"), { target: { value: "Beta" } }) - expect(screen.getByTestId("sidebar-folder-title")).toHaveTextContent(basename(aliasPath)) + expect(onSearchQueryChange).toHaveBeenCalledWith("Beta") + expect(screen.queryByTestId(`select-${Path.toPlatformPath(pathA)}`)).toBeNull() + expect(screen.getByTestId(`select-${Path.toPlatformPath(pathB)}`)).toBeInTheDocument() }) }) diff --git a/apps/ui/src/components/sidebar/Sidebar.tsx b/apps/ui/src/components/sidebar/Sidebar.tsx new file mode 100644 index 00000000..7c649d5b --- /dev/null +++ b/apps/ui/src/components/sidebar/Sidebar.tsx @@ -0,0 +1,202 @@ +import { useCallback, useMemo, useState, type ComponentType, type KeyboardEvent, type MouseEvent } from "react" +import { lazy, Suspense } from "react" +import { Loader2 } from "lucide-react" +import { SearchForm } from "@/components/search-form" +import { MediaFolderToolbar, type SortOrder, type FilterType } from "@/components/shared/MediaFolderToolbar" +import { useSidebar } from "@/hooks/useSidebar" +import { useTranslation } from "@/lib/i18n" +import { isPathInSelection, nextFolderSelection } from "@/lib/sidebarFolderSelection" +import type { FolderListItemContainerProps } from "./FolderListItemContainer" + +export type { SortOrder, FilterType } + +const DefaultFolderListItemContainer = lazy(() => + import("./FolderListItemContainer").then((m) => ({ + default: m.FolderListItemContainer, + })), +) + +function FolderListItemFallback() { + return ( +
+ +
+ ) +} + +export interface SidebarSelectionChange { + selectedPaths: string[] + primaryPath: string + multi: boolean +} + +export type FolderListItemSlot = ComponentType + +export interface SidebarProps { + onDeleteSelected?: (paths: string[]) => void + /** + * Optional list-item component (e.g. Storybook mounts pure {@link FolderListItem}). + * When omitted, lazily loads {@link FolderListItemContainer}. + */ + folderListItemSlot?: FolderListItemSlot + /** Controlled selected folder paths (UI). When omitted, Sidebar manages selection internally. */ + selectedPaths?: string[] + /** Controlled primary selection path (UI). */ + primaryPath?: string + /** Fired after selection UI changes (single or multi). */ + onSelectionChange?: (next: SidebarSelectionChange) => void + /** Controlled search query (UI). When omitted, Sidebar manages search internally. */ + searchQuery?: string + /** Fired when the search box value changes. */ + onSearchQueryChange?: (query: string) => void +} + +export function Sidebar({ + onDeleteSelected, + folderListItemSlot, + selectedPaths: selectedPathsProp, + primaryPath: primaryPathProp, + onSelectionChange, + searchQuery: searchQueryProp, + onSearchQueryChange, +}: SidebarProps) { + const { t } = useTranslation(["components"]) + + const isSearchControlled = searchQueryProp !== undefined + const [internalSearchQuery, setInternalSearchQuery] = useState("") + const searchQuery = isSearchControlled ? searchQueryProp : internalSearchQuery + + const setSearchQuery = useCallback( + (query: string) => { + if (!isSearchControlled) { + setInternalSearchQuery(query) + } + onSearchQueryChange?.(query) + }, + [isSearchControlled, onSearchQueryChange], + ) + + const { + sortOrder, + filterType, + setSortOrder, + setFilterType, + filteredAndSortedFolders, + handleRename, + handleOpenInExplorer, + handleDeletePaths, + } = useSidebar({ onDeleteSelected, searchQuery }) + + const isSelectionControlled = selectedPathsProp !== undefined + const [internalSelectedPaths, setInternalSelectedPaths] = useState([]) + const [internalPrimaryPath, setInternalPrimaryPath] = useState("") + + const selectedPaths = isSelectionControlled ? selectedPathsProp : internalSelectedPaths + const primaryPath = isSelectionControlled + ? (primaryPathProp ?? "") + : internalPrimaryPath + + const selectedFolderPathsSet = useMemo(() => new Set(selectedPaths), [selectedPaths]) + + const commitSelection = useCallback( + (next: SidebarSelectionChange) => { + if (!isSelectionControlled) { + setInternalSelectedPaths(next.selectedPaths) + setInternalPrimaryPath(next.primaryPath) + } + onSelectionChange?.(next) + }, + [isSelectionControlled, onSelectionChange], + ) + + const handleFolderClick = useCallback( + (path: string, e: MouseEvent) => { + const multi = e.ctrlKey || e.metaKey + const next = nextFolderSelection(selectedPaths, path, multi) + commitSelection({ ...next, multi }) + }, + [selectedPaths, commitSelection], + ) + + const handleListKeyDown = useCallback( + (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "a") { + e.preventDefault() + const paths = [...filteredAndSortedFolders] + commitSelection({ + selectedPaths: paths, + primaryPath: paths[0] ?? "", + multi: true, + }) + } + if (e.key === "Delete" && selectedPaths.length > 0) { + e.preventDefault() + void handleDeletePaths(selectedPaths) + } + }, + [commitSelection, filteredAndSortedFolders, handleDeletePaths, selectedPaths], + ) + + const handleDeleteItem = useCallback( + (path: string) => { + const shouldDeleteSelection = + selectedPaths.length > 0 && isPathInSelection(path, selectedPaths) + void handleDeletePaths(shouldDeleteSelection ? selectedPaths : [path]) + }, + [handleDeletePaths, selectedPaths], + ) + + const FolderListItemSlot = folderListItemSlot ?? DefaultFolderListItemContainer + + return ( +
+
+ +
+ +
+ +
+ +
+ {filteredAndSortedFolders.length === 0 ? ( +
+ {t("sidebar.emptyState")} +
+ ) : ( +
+ }> + {filteredAndSortedFolders.map((path, index) => ( +
+ handleRename(path)} + onOpenInExplorer={() => void handleOpenInExplorer(path)} + onDelete={() => handleDeleteItem(path)} + onClick={(e) => handleFolderClick(path, e)} + /> +
+ ))} +
+
+ )} +
+
+ ) +} diff --git a/apps/ui/src/components/v2/Toolbar.tsx b/apps/ui/src/components/sidebar/Toolbar.tsx similarity index 100% rename from apps/ui/src/components/v2/Toolbar.tsx rename to apps/ui/src/components/sidebar/Toolbar.tsx diff --git a/apps/ui/src/components/v2/ViewSwitcher.tsx b/apps/ui/src/components/sidebar/ViewSwitcher.tsx similarity index 100% rename from apps/ui/src/components/v2/ViewSwitcher.tsx rename to apps/ui/src/components/sidebar/ViewSwitcher.tsx diff --git a/apps/ui/src/components/v2/Sidebar.tsx b/apps/ui/src/components/v2/Sidebar.tsx deleted file mode 100644 index 17f92f3c..00000000 --- a/apps/ui/src/components/v2/Sidebar.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { SearchForm } from "@/components/search-form" -import { MediaFolderToolbar, type SortOrder, type FilterType } from "@/components/shared/MediaFolderToolbar" -import { FolderListItem } from "../sidebar/FolderListItem" -import { useSidebar } from "@/hooks/useSidebar" -import { useTranslation } from "@/lib/i18n" - -export type { SortOrder, FilterType } - -export interface SidebarProps { - onDeleteSelected?: (paths: string[]) => void -} - -export function Sidebar({ onDeleteSelected }: SidebarProps) { - const { t } = useTranslation(["components"]) - const { - sortOrder, - filterType, - searchQuery, - setSortOrder, - setFilterType, - setSearchQuery, - filteredAndSortedFolders, - selectedFolderPathsSet, - primarySelectedPath, - applyFolderClick, - handleListKeyDown, - handleRename, - handleOpenInExplorer, - handleDeleteItem, - } = useSidebar({ onDeleteSelected }) - - return ( -
-
- -
- -
- -
- -
- {filteredAndSortedFolders.length === 0 ? ( -
- {t("sidebar.emptyState")} -
- ) : ( -
- {filteredAndSortedFolders.map((folder, index) => ( -
- handleRename(folder.path)} - onOpenInExplorer={() => void handleOpenInExplorer(folder.path)} - onDelete={() => handleDeleteItem(folder.path)} - onClick={(e) => - applyFolderClick(folder.path, e.ctrlKey || e.metaKey) - } - /> -
- ))} -
- )} -
-
- ) -} diff --git a/apps/ui/src/hooks/folders/index.ts b/apps/ui/src/hooks/folders/index.ts index d19fce94..1411c4b8 100644 --- a/apps/ui/src/hooks/folders/index.ts +++ b/apps/ui/src/hooks/folders/index.ts @@ -2,3 +2,4 @@ export { useFoldersQuery } from './useFoldersQuery' export { foldersQueryKey, FOLDERS_QUERY_ROOT } from './foldersQueryKeys' export { invalidateFoldersQueryIfV3 } from './invalidateFoldersQuery' export { useUnimportFolderMutation } from './useUnimportFolderMutation' +export { useImportFolderMutation } from './useImportFolderMutation' diff --git a/apps/ui/src/hooks/folders/useImportFolderMutation.ts b/apps/ui/src/hooks/folders/useImportFolderMutation.ts new file mode 100644 index 00000000..50f806e3 --- /dev/null +++ b/apps/ui/src/hooks/folders/useImportFolderMutation.ts @@ -0,0 +1,8 @@ +import { useMutation } from '@tanstack/react-query' +import { importFolderViaCore, type ImportFolderParams } from '@/api/importFolder' + +export function useImportFolderMutation() { + return useMutation({ + mutationFn: (params: ImportFolderParams) => importFolderViaCore(params), + }) +} diff --git a/apps/ui/src/hooks/useFolderListItem.ts b/apps/ui/src/hooks/useFolderListItem.ts new file mode 100644 index 00000000..c98f245c --- /dev/null +++ b/apps/ui/src/hooks/useFolderListItem.ts @@ -0,0 +1,41 @@ +import { useMemo } from "react" +import { Path } from "@smm/utils/path" +import { useMediaMetadataQuery } from "@/hooks/mediaMetadata/useMediaMetadataQuery" +import { useUIMediaFolderStoreState } from "@/stores/uiMediaFolderStore" +import { buildMediaFolderListItemPropsFromFolderAndMetadata } from "@/lib/sidebarRowUtils" +import type { FolderListItemProps } from "@/components/sidebar/FolderListItem" +import type { UIMediaFolder } from "@/types/UIMediaFolder" + +export type FolderListItemViewModel = Pick + +function uiMediaFolderForPath(path: string, folders: UIMediaFolder[]): UIMediaFolder { + const posix = Path.posix(path) + const existing = folders.find((f) => Path.posix(f.path) === posix) + if (existing) return existing + return { + path: Path.toPlatformPath(path), + status: "idle", + test: false, + } +} + +export function useFolderListItem(path: string): FolderListItemViewModel { + const { data: metadata, isPending } = useMediaMetadataQuery(path) + const { folders } = useUIMediaFolderStoreState() + + const folder = useMemo(() => uiMediaFolderForPath(path, folders), [path, folders]) + + const props = useMemo( + () => buildMediaFolderListItemPropsFromFolderAndMetadata(folder, metadata ?? undefined), + [folder, metadata], + ) + + const status = + isPending && metadata === undefined && props.status === "idle" ? "loading" : props.status + + return { + mediaName: props.mediaName, + mediaType: props.mediaType, + status, + } +} diff --git a/apps/ui/src/hooks/useSidebar.ts b/apps/ui/src/hooks/useSidebar.ts index c8b1933a..e65c1129 100644 --- a/apps/ui/src/hooks/useSidebar.ts +++ b/apps/ui/src/hooks/useSidebar.ts @@ -1,15 +1,11 @@ import { useCallback, useMemo } from "react" import { useQueries } from "@tanstack/react-query" import { useSidebarStore, compareByDisplayName } from "@/stores/sidebarStore" -import { basename } from "@/lib/path" import { Path } from "@smm/utils/path" -import { - useUIMediaFolderStoreActions, - useUIMediaFolderSelection, -} from "@/stores/uiMediaFolderStore" -import { useMediaMetadataQuery } from "@/hooks/mediaMetadata/useMediaMetadataQuery" +import { useUIMediaFolderStoreActions } from "@/stores/uiMediaFolderStore" import { mediaMetadataReadQueryOptions } from "@/lib/mediaMetadataQueryKeys" import { buildMediaFolderListItemPropsFromFolderAndMetadata } from "@/lib/sidebarRowUtils" +import { folderMatchesSearchQuery } from "@/lib/sidebarFolderSearch" import { useDialogs } from "@/providers/dialog-provider" import { useConfig } from "@/hooks/userConfig" import { openInFileManagerApi } from "@/api/openInFileManager" @@ -23,13 +19,14 @@ import { uniq } from "es-toolkit/array" export interface UseSidebarOptions { onDeleteSelected?: (paths: string[]) => void + /** Pure UI search query owned by Sidebar (filters the visible folder list). */ + searchQuery?: string } -export function useSidebar({ onDeleteSelected }: UseSidebarOptions = {}) { +export function useSidebar({ onDeleteSelected, searchQuery = "" }: UseSidebarOptions = {}) { const { t } = useTranslation(["components"]) - const { sortOrder, filterType, searchQuery, setSortOrder, setFilterType, setSearchQuery } = useSidebarStore() - const { applyFolderClick, selectAllFolderPaths, removeFolder } = useUIMediaFolderStoreActions() - const { selectedFolder, selectedFolderPathsSet } = useUIMediaFolderSelection() + const { sortOrder, filterType, setSortOrder, setFilterType } = useSidebarStore() + const { removeFolder } = useUIMediaFolderStoreActions() const { userConfig, setAndSaveUserConfig } = useConfig() const folders = useMemo(() => { return uniq(userConfig.folders) @@ -37,8 +34,6 @@ export function useSidebar({ onDeleteSelected }: UseSidebarOptions = {}) { const unimportFolderMutation = useUnimportFolderMutation() const { renameFolderDialog } = useDialogs() const [openRenameForMediaFolder] = renameFolderDialog - const { data: selectedMediaMetadata } = useMediaMetadataQuery(selectedFolder || undefined) - const primarySelectedPath = selectedMediaMetadata?.mediaFolderPath ?? selectedFolder const foldersQuery = useFoldersQuery() const v3 = isSmmV3Enabled() @@ -64,16 +59,7 @@ export function useSidebar({ onDeleteSelected }: UseSidebarOptions = {}) { const filteredAndSortedFolders = useMemo(() => { let result = [...rowsWithMeta] - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase().trim() - result = result.filter((folder) => { - const mediaNameMatch = folder.mediaName.toLowerCase().includes(query) - const pathMatch = folder.path.toLowerCase().includes(query) - const folderName = basename(folder.path) || "" - const folderNameMatch = folderName.toLowerCase().includes(query) - return mediaNameMatch || pathMatch || folderNameMatch - }) - } + result = result.filter((folder) => folderMatchesSearchQuery(folder, searchQuery)) if (filterType !== "all") { result = result.filter((folder) => folder.mediaType === filterType) @@ -81,7 +67,7 @@ export function useSidebar({ onDeleteSelected }: UseSidebarOptions = {}) { result.sort((a, b) => compareByDisplayName(a.mediaName, b.mediaName, sortOrder)) - return result + return result.map((folder) => folder.path) }, [rowsWithMeta, sortOrder, filterType, searchQuery]) const handleOpenInExplorer = useCallback(async (path: string) => { @@ -131,46 +117,14 @@ export function useSidebar({ onDeleteSelected }: UseSidebarOptions = {}) { [onDeleteSelected, setAndSaveUserConfig, userConfig, removeFolder, unimportFolderMutation], ) - const handleListKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === "a") { - e.preventDefault() - selectAllFolderPaths(filteredAndSortedFolders.map((f) => f.path)) - } - if (e.key === "Delete" && selectedFolderPathsSet.size > 0) { - e.preventDefault() - void handleDeletePaths(Array.from(selectedFolderPathsSet)) - } - }, - [handleDeletePaths, selectAllFolderPaths, filteredAndSortedFolders, selectedFolderPathsSet], - ) - - const handleDeleteItem = useCallback( - (path: string) => { - const posix = Path.posix(path) - const selectedPaths = Array.from(selectedFolderPathsSet) - const shouldDeleteSelection = - selectedPaths.length > 0 && selectedPaths.some((p) => Path.posix(p) === posix) - const paths = shouldDeleteSelection ? selectedPaths : [path] - void handleDeletePaths(paths) - }, - [handleDeletePaths, selectedFolderPathsSet], - ) - return { sortOrder, filterType, - searchQuery, setSortOrder, setFilterType, - setSearchQuery, filteredAndSortedFolders, - selectedFolderPathsSet, - primarySelectedPath, - applyFolderClick, - handleListKeyDown, handleRename, handleOpenInExplorer, - handleDeleteItem, + handleDeletePaths, } } diff --git a/apps/ui/src/lib/mergeFolderPathsWithUiStatus.test.ts b/apps/ui/src/lib/mergeFolderPathsWithUiStatus.test.ts deleted file mode 100644 index 2b51aef5..00000000 --- a/apps/ui/src/lib/mergeFolderPathsWithUiStatus.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Path } from '@smm/utils/path' -import { mergeFolderPathsWithUiStatus } from './mergeFolderPathsWithUiStatus' -import type { UIMediaFolder } from '@/types/UIMediaFolder' - -describe('mergeFolderPathsWithUiStatus', () => { - it('defaults status to ok when Zustand has no row', () => { - const result = mergeFolderPathsWithUiStatus(['/m/A'], []) - expect(result).toEqual([ - expect.objectContaining({ - status: 'ok', - path: Path.toPlatformPath('/m/A'), - }), - ]) - }) - - it('preserves Zustand status/type/test when path matches', () => { - const existing: UIMediaFolder[] = [ - { - path: Path.toPlatformPath('/m/A'), - status: 'initializing', - type: 'tvshow-folder', - test: true, - }, - ] - const result = mergeFolderPathsWithUiStatus(['/m/A'], existing) - expect(result[0]?.status).toBe('initializing') - expect(result[0]?.type).toBe('tvshow-folder') - expect(result[0]?.test).toBe(true) - }) - - it('follows query path order', () => { - const existing: UIMediaFolder[] = [ - { path: Path.toPlatformPath('/m/B'), status: 'ok' }, - { path: Path.toPlatformPath('/m/A'), status: 'ok' }, - ] - const result = mergeFolderPathsWithUiStatus(['/m/A', '/m/B'], existing) - expect(result.map((r) => r.path)).toEqual([ - Path.toPlatformPath('/m/A'), - Path.toPlatformPath('/m/B'), - ]) - expect(result).toHaveLength(2) - }) -}) diff --git a/apps/ui/src/lib/mergeFolderPathsWithUiStatus.ts b/apps/ui/src/lib/mergeFolderPathsWithUiStatus.ts index b30ab1e7..ea0c3fb6 100644 --- a/apps/ui/src/lib/mergeFolderPathsWithUiStatus.ts +++ b/apps/ui/src/lib/mergeFolderPathsWithUiStatus.ts @@ -1,5 +1,5 @@ -import { Path } from '@smm/utils/path' -import type { UIMediaFolder } from '@/types/UIMediaFolder' +import { Path } from "@smm/utils/path" +import type { UIMediaFolder } from "@/types/UIMediaFolder" export function mergeFolderPathsWithUiStatus( paths: string[], @@ -14,7 +14,7 @@ export function mergeFolderPathsWithUiStatus( const platform = Path.toPlatformPath(p) return { path: platform, - status: existing?.status ?? 'ok', + status: existing?.status ?? "ok", test: existing?.test, type: existing?.type, } diff --git a/apps/ui/src/lib/sidebarFolderSearch.test.ts b/apps/ui/src/lib/sidebarFolderSearch.test.ts new file mode 100644 index 00000000..8d0cda42 --- /dev/null +++ b/apps/ui/src/lib/sidebarFolderSearch.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest" +import { folderMatchesSearchQuery } from "./sidebarFolderSearch" + +describe("folderMatchesSearchQuery", () => { + const folder = { + mediaName: "Breaking Bad", + path: "/media/tvshows/Breaking Bad", + } + + it("matches all folders when query is empty", () => { + expect(folderMatchesSearchQuery(folder, "")).toBe(true) + expect(folderMatchesSearchQuery(folder, " ")).toBe(true) + }) + + it("matches media name", () => { + expect(folderMatchesSearchQuery(folder, "break")).toBe(true) + expect(folderMatchesSearchQuery(folder, "xyz")).toBe(false) + }) + + it("matches path and basename", () => { + expect(folderMatchesSearchQuery(folder, "tvshows")).toBe(true) + expect(folderMatchesSearchQuery({ mediaName: "X", path: "/a/MyFolder" }, "myfolder")).toBe( + true, + ) + }) +}) diff --git a/apps/ui/src/lib/sidebarFolderSearch.ts b/apps/ui/src/lib/sidebarFolderSearch.ts new file mode 100644 index 00000000..02a19550 --- /dev/null +++ b/apps/ui/src/lib/sidebarFolderSearch.ts @@ -0,0 +1,20 @@ +import { basename } from "@/lib/path" + +export interface FolderSearchFields { + mediaName: string + path: string +} + +/** Whether a folder row matches the sidebar search box (pure UI filter). */ +export function folderMatchesSearchQuery( + folder: FolderSearchFields, + searchQuery: string, +): boolean { + if (!searchQuery.trim()) return true + const query = searchQuery.toLowerCase().trim() + const mediaNameMatch = folder.mediaName.toLowerCase().includes(query) + const pathMatch = folder.path.toLowerCase().includes(query) + const folderName = basename(folder.path) || "" + const folderNameMatch = folderName.toLowerCase().includes(query) + return mediaNameMatch || pathMatch || folderNameMatch +} diff --git a/apps/ui/src/lib/sidebarFolderSelection.test.ts b/apps/ui/src/lib/sidebarFolderSelection.test.ts new file mode 100644 index 00000000..ce139a6e --- /dev/null +++ b/apps/ui/src/lib/sidebarFolderSelection.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest" +import { isPathInSelection, nextFolderSelection } from "./sidebarFolderSelection" + +describe("nextFolderSelection", () => { + it("replaces selection on single click", () => { + expect(nextFolderSelection(["/a", "/b"], "/c", false)).toEqual({ + selectedPaths: ["/c"], + primaryPath: "/c", + }) + }) + + it("toggles path on multi click", () => { + expect(nextFolderSelection(["/a"], "/b", true)).toEqual({ + selectedPaths: ["/a", "/b"], + primaryPath: "/b", + }) + expect(nextFolderSelection(["/a", "/b"], "/a", true)).toEqual({ + selectedPaths: ["/b"], + primaryPath: "/a", + }) + }) +}) + +describe("isPathInSelection", () => { + it("matches posix-equivalent paths", () => { + expect(isPathInSelection("/media/a", ["/media/a", "/media/b"])).toBe(true) + expect(isPathInSelection("/media/c", ["/media/a"])).toBe(false) + }) +}) diff --git a/apps/ui/src/lib/sidebarFolderSelection.ts b/apps/ui/src/lib/sidebarFolderSelection.ts new file mode 100644 index 00000000..0bc3b71e --- /dev/null +++ b/apps/ui/src/lib/sidebarFolderSelection.ts @@ -0,0 +1,26 @@ +import { Path } from "@smm/utils/path" + +/** + * Pure UI selection transition for sidebar folder list (single / multi). + */ +export function nextFolderSelection( + currentSelected: readonly string[], + path: string, + multi: boolean, +): { selectedPaths: string[]; primaryPath: string } { + if (!multi) { + return { selectedPaths: [path], primaryPath: path } + } + const next = new Set(currentSelected) + if (next.has(path)) next.delete(path) + else next.add(path) + return { + selectedPaths: [...next], + primaryPath: path, + } +} + +export function isPathInSelection(path: string, selectedPaths: readonly string[]): boolean { + const posix = Path.posix(path) + return selectedPaths.some((p) => Path.posix(p) === posix) +} diff --git a/apps/ui/src/lib/sidebarRowUtils.test.ts b/apps/ui/src/lib/sidebarRowUtils.test.ts index 1a617182..3aafb0dd 100644 --- a/apps/ui/src/lib/sidebarRowUtils.test.ts +++ b/apps/ui/src/lib/sidebarRowUtils.test.ts @@ -25,4 +25,16 @@ describe("buildMediaFolderListItemPropsFromFolderAndMetadata", () => { expect(row.status).toBe("loading") }) + + it("maps initializing to loading", () => { + const row = buildMediaFolderListItemPropsFromFolderAndMetadata( + { + path: "/media/Test", + status: "initializing", + }, + undefined, + ) + + expect(row.status).toBe("loading") + }) }) diff --git a/apps/ui/src/lib/sidebarRowUtils.ts b/apps/ui/src/lib/sidebarRowUtils.ts index 8d151fa9..62072752 100644 --- a/apps/ui/src/lib/sidebarRowUtils.ts +++ b/apps/ui/src/lib/sidebarRowUtils.ts @@ -21,15 +21,15 @@ function mediaTypeFromMetadata(metadata: MediaMetadata | undefined): FolderListI function mapFolderStatusToItemStatus( status: UIMediaFolderStatus, ): NonNullable { - if (status === "updating") return "loading" + if (status === "updating" || status === "initializing" || status === "loading") { + return "loading" + } if (status === "error_loading_metadata") return "folder_not_found" if ( status === "idle" || status === "pending_for_initialization" || - status === "initializing" || status === "ok" || - status === "folder_not_found" || - status === "loading" + status === "folder_not_found" ) { return status } diff --git a/apps/ui/src/main.tsx b/apps/ui/src/main.tsx index 462fe5c9..87dde481 100644 --- a/apps/ui/src/main.tsx +++ b/apps/ui/src/main.tsx @@ -4,7 +4,8 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import { i18nReady } from './lib/i18n' -import AppV2 from './AppV2.tsx' +import App from './App.tsx' +import { ImportFolderEventHandler } from './components/eventlisteners/ImportFolderEventHandler.tsx' import AppNavigation from './AppNavigation.tsx' import { ThemeProvider } from './providers/theme-provider' import { AppLanguageSync } from './hooks/userConfig' @@ -149,7 +150,7 @@ function EventListeners() { - {/* */} + @@ -174,10 +175,10 @@ function AppSwitcher() { ) } - // On desktop, use AppV2 only + // On desktop, use App only return ( <> - + diff --git a/apps/ui/src/stores/sidebarStore.ts b/apps/ui/src/stores/sidebarStore.ts index 8e8c87d9..a20eff38 100644 --- a/apps/ui/src/stores/sidebarStore.ts +++ b/apps/ui/src/stores/sidebarStore.ts @@ -6,13 +6,11 @@ export type FilterType = "all" | "tvshow" | "movie" | "music" interface SidebarStoreState { sortOrder: SortOrder filterType: FilterType - searchQuery: string } interface SidebarStoreActions { setSortOrder: (order: SortOrder) => void setFilterType: (type: FilterType) => void - setSearchQuery: (query: string) => void } type SidebarStore = SidebarStoreState & SidebarStoreActions @@ -20,11 +18,9 @@ type SidebarStore = SidebarStoreState & SidebarStoreActions const useSidebarStore = create((set) => ({ sortOrder: "alphabetical", filterType: "all", - searchQuery: "", setSortOrder: (order) => set({ sortOrder: order }), setFilterType: (type) => set({ filterType: type }), - setSearchQuery: (query) => set({ searchQuery: query }), })) /** diff --git a/apps/ui/src/types/UIMediaFolder.ts b/apps/ui/src/types/UIMediaFolder.ts deleted file mode 100644 index 43d1b3f4..00000000 --- a/apps/ui/src/types/UIMediaFolder.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Sidebar / folder list entry without embedding full {@link MediaMetadata}. - * Used by the future `UIMediaFolderStore` (Zustand). - */ -export type UIMediaFolderStatus = - | "idle" - | "pending_for_initialization" - | "initializing" - | "ok" - | "folder_not_found" - | "error_loading_metadata" - | "loading" - | "updating" - -export interface UIMediaFolder { - /** - * The path in platform-specific format - */ - path: string - status: UIMediaFolderStatus - /** Test-only folder; may be handled differently in UI logic. */ - test?: boolean - /** Media folder type, set during import to guide initial metadata loading */ - type?: "music-folder" | "tvshow-folder" | "movie-folder" -} diff --git a/apps/ui/src/types/eventTypes.ts b/apps/ui/src/types/eventTypes.ts index d44d636d..190a9e35 100644 --- a/apps/ui/src/types/eventTypes.ts +++ b/apps/ui/src/types/eventTypes.ts @@ -1,4 +1,4 @@ -export const UI_MediaFolderImportedEvent = 'ui.mediaFolderImported' +export const UI_ImportFolderEvent = 'ui.importFolder' export interface OnMediaFolderImportedEventData { type: "tvshow" | "movie" | "music"; diff --git a/docs/superpowers/architecture.md b/docs/superpowers/architecture.md index fe4b1f4f..81d23e27 100644 --- a/docs/superpowers/architecture.md +++ b/docs/superpowers/architecture.md @@ -507,8 +507,7 @@ A separate mobile-optimized view with slide-page navigation (list → detail), d | `src/components/` | UI components | | `src/components/ui/` | Shadcn UI primitives (hand-installed) | | `src/components/dialogs/` | Dialog components | -| `src/components/sidebar/` | Sidebar components | -| `src/components/v2/` | Desktop layout (Sidebar, Toolbar, ViewSwitcher) | +| `src/components/sidebar/` | Sidebar / desktop layout (Sidebar, Toolbar, ViewSwitcher, FolderListItem) | | `src/components/mobile/` | Mobile layout components | | `src/components/eventlisteners/` | Socket.IO event → DOM event bridge | | `src/components/background-jobs/` | Background job UI | From 6ef8abbc1502b118abcce8d04572eb251ef6883d Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 1 Sep 2026 21:02:38 +0800 Subject: [PATCH 03/83] refactor: clean up Sidebar --- apps/ui/public/locales/en/components.json | 1 + apps/ui/public/locales/zh-CN/components.json | 1 + apps/ui/public/locales/zh-HK/components.json | 1 + apps/ui/public/locales/zh-TW/components.json | 1 + apps/ui/src/AppNavigation.tsx | 4 +- .../handleRenamePromptConfirmForTvShow.ts | 8 +- apps/ui/src/components/episode-file.tsx | 6 +- apps/ui/src/components/episode-section.tsx | 6 +- apps/ui/src/components/mobile/Navigation.tsx | 8 +- apps/ui/src/components/movie/MoviePanel.tsx | 24 +- .../ui/src/components/music/MusicHeaderV2.tsx | 9 +- apps/ui/src/components/music/MusicPanel.tsx | 20 +- .../shared/MediaFolderToolbar.test.tsx | 5 +- .../components/shared/MediaFolderToolbar.tsx | 1 + .../src/components/shared/SortingButton.tsx | 10 +- .../components/sidebar/Sidebar.stories.tsx | 4 +- apps/ui/src/components/sidebar/Sidebar.tsx | 10 +- .../src/components/tv/TvShowEpisodeTable.tsx | 6 +- apps/ui/src/components/tv/TvShowPanel.tsx | 17 +- apps/ui/src/components/tv/TvShowPanelUtils.ts | 42 +- .../ui/src/helpers/handleEpisodeFileSelect.ts | 19 +- apps/ui/src/helpers/loadNfo.ts | 21 +- .../helpers/movie/MovieMediaMetadataUtils.ts | 20 +- .../movie/buildMovieFilesFromMediaMetadata.ts | 8 +- .../hooks/folders/invalidateFoldersQuery.ts | 3 +- apps/ui/src/hooks/folders/useFoldersQuery.ts | 3 - .../useMediaMetadataMutation.test.tsx | 67 --- .../mediaMetadata/useMediaMetadataMutation.ts | 3 +- .../mediaMetadata/useMediaMetadataQuery.ts | 3 +- .../useUpdateMediaMetadataMutation.ts | 3 +- .../src/hooks/tv/useRuleBasedRecognizeFlow.ts | 4 +- .../hooks/tv/useSelectAndUnselectFileFlow.ts | 5 +- apps/ui/src/hooks/useGetAssociatedFiles.ts | 19 +- apps/ui/src/hooks/useMediaFolderFilesQuery.ts | 14 + apps/ui/src/hooks/useSidebar.ts | 92 ++-- .../applyRenamePairsToUIMediaMetadata.test.ts | 4 +- .../lib/applyRenamePairsToUIMediaMetadata.ts | 14 +- apps/ui/src/lib/buildMovieEpisodeTableRows.ts | 8 +- .../ui/src/lib/buildTvShowEpisodeTableRows.ts | 29 +- apps/ui/src/lib/log.ts | 7 +- apps/ui/src/lib/mediaFolderFiles.test.ts | 48 -- apps/ui/src/lib/mediaFolderFiles.ts | 47 +- apps/ui/src/lib/mediaMetadataQueryKeys.ts | 21 +- apps/ui/src/lib/mediaMetadataRefreshUtils.ts | 12 +- apps/ui/src/lib/mediaMetadataUtils.test.ts | 460 ++---------------- apps/ui/src/lib/mediaMetadataUtils.ts | 27 +- apps/ui/src/lib/music.ts | 7 +- apps/ui/src/lib/recognizeEpisodes.ts | 22 +- apps/ui/src/lib/recognizeEpisodes.worker.ts | 14 +- apps/ui/src/lib/recognizeEpisodesUi.ts | 14 +- apps/ui/src/lib/sidebarRowUtils.test.ts | 17 +- apps/ui/src/lib/sidebarRowUtils.ts | 21 +- apps/ui/src/lib/utils.ts | 4 +- apps/ui/src/stores/sidebarStore.test.ts | 41 ++ apps/ui/src/stores/sidebarStore.ts | 10 +- apps/ui/src/types/UIMediaFolder.ts | 25 + apps/ui/src/types/i18next.d.ts | 1 + 57 files changed, 441 insertions(+), 880 deletions(-) create mode 100644 apps/ui/src/hooks/useMediaFolderFilesQuery.ts delete mode 100644 apps/ui/src/lib/mediaFolderFiles.test.ts create mode 100644 apps/ui/src/stores/sidebarStore.test.ts create mode 100644 apps/ui/src/types/UIMediaFolder.ts diff --git a/apps/ui/public/locales/en/components.json b/apps/ui/public/locales/en/components.json index 183ac04c..8e66a90e 100644 --- a/apps/ui/public/locales/en/components.json +++ b/apps/ui/public/locales/en/components.json @@ -27,6 +27,7 @@ "toolbar": { "sort": "Sort", "filter": "Filter", + "sortNone": "Original order", "sortAlphabetical": "Alphabetical", "sortReverseAlphabetical": "Reverse alphabetical", "filterAll": "All types", diff --git a/apps/ui/public/locales/zh-CN/components.json b/apps/ui/public/locales/zh-CN/components.json index 2e81c11b..72a7a4f0 100644 --- a/apps/ui/public/locales/zh-CN/components.json +++ b/apps/ui/public/locales/zh-CN/components.json @@ -27,6 +27,7 @@ "toolbar": { "sort": "排序", "filter": "筛选", + "sortNone": "原始顺序", "sortAlphabetical": "按字母顺序", "sortReverseAlphabetical": "按字母倒序", "filterAll": "全部类型", diff --git a/apps/ui/public/locales/zh-HK/components.json b/apps/ui/public/locales/zh-HK/components.json index ba216579..400851aa 100644 --- a/apps/ui/public/locales/zh-HK/components.json +++ b/apps/ui/public/locales/zh-HK/components.json @@ -25,6 +25,7 @@ "toolbar": { "sort": "排序", "filter": "篩選", + "sortNone": "原始順序", "sortAlphabetical": "按字母順序", "sortReverseAlphabetical": "按字母倒序", "filterAll": "全部類型", diff --git a/apps/ui/public/locales/zh-TW/components.json b/apps/ui/public/locales/zh-TW/components.json index f69401aa..d9e5ce42 100644 --- a/apps/ui/public/locales/zh-TW/components.json +++ b/apps/ui/public/locales/zh-TW/components.json @@ -25,6 +25,7 @@ "toolbar": { "sort": "排序", "filter": "篩選", + "sortNone": "原始順序", "sortAlphabetical": "按字母順序", "sortReverseAlphabetical": "按字母倒序", "filterAll": "全部類型", diff --git a/apps/ui/src/AppNavigation.tsx b/apps/ui/src/AppNavigation.tsx index 940ef465..d5300f6f 100644 --- a/apps/ui/src/AppNavigation.tsx +++ b/apps/ui/src/AppNavigation.tsx @@ -11,7 +11,7 @@ export default function AppNavigation() { const [currentPage] = useState("list") // Sidebar state (search, sort, filter) - const [sortOrder, setSortOrder] = useState("alphabetical") + const [sortOrder, setSortOrder] = useState("none") const [filterType, setFilterType] = useState("all") const [searchQuery, setSearchQuery] = useState("") const [isToolboxExpanded, setIsToolboxExpanded] = useState(false) @@ -72,7 +72,7 @@ export default function AppNavigation() { {/* 列表内容 */} {/* */}
diff --git a/apps/ui/src/actions/handleRenamePromptConfirmForTvShow.ts b/apps/ui/src/actions/handleRenamePromptConfirmForTvShow.ts index af78384a..6f86fc06 100644 --- a/apps/ui/src/actions/handleRenamePromptConfirmForTvShow.ts +++ b/apps/ui/src/actions/handleRenamePromptConfirmForTvShow.ts @@ -1,7 +1,7 @@ import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan" import { toast } from "sonner" -import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" -import { getMediaFolderFiles } from "@/lib/mediaFolderFiles" +import type { MediaMetadata } from "@/lib/mediaFolderFiles" +import { listMediaFolderFilePaths } from "@/lib/mediaFolderFiles" import type { UIPlan } from "@/types/UIPlan" import type { UIRenameFilesPlan } from "@/types/UIRenameFilesPlan" import type { PersistUIMediaMetadataFn } from "@/types/persistUIMediaMetadata" @@ -16,7 +16,7 @@ export async function handleRenamePromptConfirmForTvShow( options: { planId: string plan: UIRenameFilesPlan - mediaMetadata: MediaMetadataWithFolderFiles + mediaMetadata: MediaMetadata selectedEpisodePaths: string[] renameFailedLabel: string noMediaPathErrorLabel: string @@ -37,7 +37,7 @@ export async function handleRenamePromptConfirmForTvShow( } = options const { setPlanById, persistUiMediaMetadata, renameFilesApi } = deps - const folderFiles = getMediaFolderFiles(mediaMetadata) + const folderFiles = await listMediaFolderFilePaths(mediaMetadata.mediaFolderPath!) if (!mediaMetadata.mediaFolderPath || folderFiles.length === 0) { console.warn("[rename] cannot apply rename — folder path or file list missing", { planId }) toast.error(noMediaPathErrorLabel) diff --git a/apps/ui/src/components/episode-file.tsx b/apps/ui/src/components/episode-file.tsx index b7886860..58153f33 100644 --- a/apps/ui/src/components/episode-file.tsx +++ b/apps/ui/src/components/episode-file.tsx @@ -12,7 +12,7 @@ import type { FileProps } from "@/lib/types" import { useTranslation } from "@/lib/i18n" import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" import { useMediaMetadataQuery } from "@/hooks/mediaMetadata/useMediaMetadataQuery"; -import { getMediaFolderFiles } from "@/lib/mediaFolderFiles"; +import { useMediaFolderFilesQuery } from "@/hooks/useMediaFolderFilesQuery"; interface EpisodeFileProps { file: FileProps @@ -110,6 +110,7 @@ export function EpisodeFile({ const { t } = useTranslation(['components', 'dialogs']) const { selectedFolder } = useUIMediaFolderStoreState() const { data: selectedMediaMetadata } = useMediaMetadataQuery(selectedFolder || undefined) + const { data: allMediaFiles = [] } = useMediaFolderFilesQuery(selectedFolder || undefined) const { mutate: fetchMediaMetadata } = useFetchMediaMetadataMutation(); const { renameFileDialog } = useDialogs() @@ -212,9 +213,6 @@ export function EpisodeFile({ const newAbsolutePath = join(selectedMediaMetadata.mediaFolderPath, newRelativePath) // All files in the media folder (absolute POSIX paths from metadata) - const allMediaFiles = getMediaFolderFiles(selectedMediaMetadata) - - // Compute renames for every file sharing the same stem as the video const assocRenames = computeAssociatedFileRenames(file.path, newAbsolutePath, allMediaFiles) // Call renameFiles API with video + all associated files in one batch diff --git a/apps/ui/src/components/episode-section.tsx b/apps/ui/src/components/episode-section.tsx index 453f3ff6..98063a4f 100644 --- a/apps/ui/src/components/episode-section.tsx +++ b/apps/ui/src/components/episode-section.tsx @@ -1,4 +1,4 @@ -import { getMediaFolderFiles } from "@/lib/mediaFolderFiles" +import { useMediaFolderFilesQuery } from "@/hooks/useMediaFolderFilesQuery" import type { TMDBTVShowDetails } from "@smm/types" import { ChevronDown, Play, FileVideo, FileText, Music, Image as ImageIcon, Star, XCircle } from "lucide-react" import { cn } from "@/lib/utils" @@ -128,6 +128,7 @@ export function EpisodeSection({ const { t } = useTranslation(['components']) const { selectedFolder } = useUIMediaFolderStoreState() const { data: selectedMediaMetadata } = useMediaMetadataQuery(selectedFolder || undefined) + const { data: localFiles = [] } = useMediaFolderFilesQuery(selectedFolder || undefined) const episodeStillUrl = getTMDBImageUrl(episode.still_path, "w300") const isEpisodeExpanded = expandedEpisodeIds.has(episode.id) @@ -163,7 +164,6 @@ export function EpisodeSection({ return []; } - const localFiles = getMediaFolderFiles(selectedMediaMetadata); if (localFiles.length === 0) { return []; } @@ -187,7 +187,7 @@ export function EpisodeSection({ }); return result; - }, [filesByType, selectedMediaMetadata]) + }, [filesByType, selectedMediaMetadata, localFiles]) return (
void } export function Navigation({ - filteredAndSortedFolders, + folders, handleMediaFolderListItemClick, }: NavigationProps) { const { t } = useTranslation(["components"]) @@ -36,12 +36,12 @@ export function Navigation({ }} className="hide-scrollbar" > - {filteredAndSortedFolders.length === 0 ? ( + {folders.length === 0 ? (
{t("sidebar.emptyState")}
) : ( - filteredAndSortedFolders.map((folder) => ( + folders.map((folder) => (
("simple") + const { data: folderFiles = [] } = useMediaFolderFilesQuery(selectedFolder || undefined) + /** * Frontend-processed media metadata. Adjustments here should not persist to backend. */ - const mediaMetadata: MediaMetadataWithFolderFiles | undefined = useMemo(() => { + const mediaMetadata: MediaMetadata | undefined = useMemo(() => { if (!queriedMediaMetadata) { return undefined } - const clone: MediaMetadataWithFolderFiles = structuredClone(queriedMediaMetadata) + const clone: MediaMetadata = structuredClone(queriedMediaMetadata) // move this step to Media Folder Initialization process - return findMediaFilesForMovieMediaMetadata(clone) - }, [queriedMediaMetadata]) + return findMediaFilesForMovieMediaMetadata(clone, folderFiles) + }, [queriedMediaMetadata, folderFiles]) const { isVideoCompressionEnabled, isUseMediaFileTableEnabled } = useFeatures() @@ -131,18 +133,18 @@ function MoviePanel() { }) const videoRenameFlow = useRenameVideoFileFlow({ mediaFolderPath: mediaMetadata?.mediaFolderPath, - files: getMediaFolderFiles(mediaMetadata), + files: folderFiles, }) const [movieFiles, setMovieFiles] = useState({ files: [] }) const latestMovieFiles = useLatest(movieFiles) // Merge base files with preview modifications useEffect(() => { - const model = buildMovieFilesFromMediaMetadata(mediaMetadata) + const model = buildMovieFilesFromMediaMetadata(mediaMetadata, folderFiles) if (model) { setMovieFiles(model) } - }, [mediaMetadata]) + }, [mediaMetadata, folderFiles]) // Compute preview mode from prompt states const isPreviewingForRename = useMemo(() => { @@ -326,10 +328,10 @@ function MoviePanel() { const tableData = useMemo(() => { if (!mediaMetadata) return [] // eslint-disable-next-line @typescript-eslint/no-explicit-any - return buildMovieEpisodeTableRows(mediaMetadata, folderStatus, (key: string) => t(key as any), { + return buildMovieEpisodeTableRows(mediaMetadata, folderStatus, (key: string) => t(key as any), folderFiles, { renamePreview: renamePreview ?? undefined, }) - }, [mediaMetadata, folderStatus, t, renamePreview]) + }, [mediaMetadata, folderStatus, t, renamePreview, folderFiles]) const handleVideoCompressClick = useCallback( (row: TvShowEpisodeDataRow) => { diff --git a/apps/ui/src/components/music/MusicHeaderV2.tsx b/apps/ui/src/components/music/MusicHeaderV2.tsx index 7322c076..fa352c37 100644 --- a/apps/ui/src/components/music/MusicHeaderV2.tsx +++ b/apps/ui/src/components/music/MusicHeaderV2.tsx @@ -7,11 +7,11 @@ import { DropdownMenuTrigger, } from "../ui/dropdown-menu" import { useTranslation } from "@/lib/i18n" -import { getMediaFolderFiles } from "@/lib/mediaFolderFiles" -import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" +import type { MediaMetadata } from "@/lib/mediaFolderFiles" export interface MusicHeaderV2Props { - selectedMediaMetadata?: MediaMetadataWithFolderFiles + selectedMediaMetadata?: MediaMetadata + folderFiles?: string[] onDownloadClick?: () => void onTranscribeClick?: () => void onTranslateClick?: () => void @@ -36,6 +36,7 @@ export interface MusicHeaderV2Props { export function MusicHeaderV2({ selectedMediaMetadata, + folderFiles = [], onDownloadClick, onTranscribeClick, onTranslateClick, @@ -57,7 +58,7 @@ export function MusicHeaderV2({ const { t } = useTranslation(["components", "common"]) const folderName = selectedMediaMetadata?.mediaFolderPath?.split("/").pop() || "Music" - const trackCount = getMediaFolderFiles(selectedMediaMetadata).length + const trackCount = folderFiles.length const folderReady = !!selectedMediaMetadata?.mediaFolderPath const transcribeDisabled = diff --git a/apps/ui/src/components/music/MusicPanel.tsx b/apps/ui/src/components/music/MusicPanel.tsx index 830d6402..1cb39d89 100644 --- a/apps/ui/src/components/music/MusicPanel.tsx +++ b/apps/ui/src/components/music/MusicPanel.tsx @@ -3,8 +3,7 @@ import { useMediaMetadataQuery } from "@/hooks/mediaMetadata"; import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation"; import { useUpdateMediaMetadataMutation } from "@/hooks/mediaMetadata/useUpdateMediaMetadataMutation"; import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys"; -import { getMediaFolderFiles } from "@/lib/mediaFolderFiles"; -import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles"; +import { useMediaFolderFilesQuery } from "@/hooks/useMediaFolderFilesQuery"; import type { MediaMetadata } from "@smm/types"; import type { UIMediaFolderStatus } from "@/types/UIMediaFolder"; import { @@ -92,6 +91,7 @@ export function MusicPanel() { ]); const mediaMetadata = queriedMediaMetadata ?? undefined; + const { data: folderFiles = [] } = useMediaFolderFilesQuery(selectedFolder || undefined); const { mutateAsync: fetchMediaMetadata } = useFetchMediaMetadataMutation(); const { mutateAsync: saveMediaMetadata } = useUpdateMediaMetadataMutation(); @@ -210,7 +210,7 @@ export function MusicPanel() { return; } - const musicMediaMetadata = newMusicMediaMetadata(mediaMetadata); + const musicMediaMetadata = newMusicMediaMetadata(mediaMetadata, folderFiles); const newTracks = convertMusicFilesToTracks(musicMediaMetadata.musicFiles); setTracks((prev) => { @@ -218,7 +218,7 @@ export function MusicPanel() { const synced = syncTracks(basePrev, newTracks); return mergeLibraryTracksWithJobTracks(synced, jobTracks); }); - }, [mediaMetadata, jobTracks]); + }, [mediaMetadata, jobTracks, folderFiles]); const pathSignature = useMemo( () => @@ -421,7 +421,7 @@ export function MusicPanel() { return; } - const currentFiles = getMediaFolderFiles(mediaMetadata); + const currentFiles = folderFiles; const trackPathPosix = Path.posix(trackPath); const fileIndex = currentFiles.findIndex((file) => file === trackPathPosix); @@ -462,7 +462,7 @@ export function MusicPanel() { console.error('[MusicPanel] Failed to handle delete track:', error); toast.error(`Could not process delete for "${trackTitle}". ${error instanceof Error ? error.message : 'Unknown error'}`); } - }, [mediaMetadata, openConfirmation, confirmDelete, handleDeleteCancel]); + }, [mediaMetadata, folderFiles, openConfirmation, confirmDelete, handleDeleteCancel]); const handleTrackProperties = useCallback((event: CustomEvent) => { const { trackId, trackTitle } = event.detail; @@ -591,7 +591,7 @@ export function MusicPanel() { void showSubtitleMenu?: boolean showDownloadButton?: boolean @@ -644,6 +646,7 @@ interface MusicPanelSubtitleHeaderProps { function MusicPanelSubtitleHeader({ mediaMetadata, + folderFiles = [], onDownloadClick, showSubtitleMenu = true, showDownloadButton = true, @@ -656,6 +659,7 @@ function MusicPanelSubtitleHeader({ return ( { const baseProps = { - sortOrder: "alphabetical" as const, + sortOrder: "none" as const, onSortOrderChange: vi.fn(), filterType: "all" as const, onFilterTypeChange: vi.fn(), @@ -32,6 +32,9 @@ describe("MediaFolderToolbar i18n", () => { it("renders translated sort option labels", async () => { render() fireEvent.click(screen.getByTestId("sort-select-trigger")) + expect(await screen.findByTestId("sort-option-none")).toHaveTextContent( + "sidebar.toolbar.sortNone", + ) expect(await screen.findByTestId("sort-option-alphabetical")).toHaveTextContent( "sidebar.toolbar.sortAlphabetical", ) diff --git a/apps/ui/src/components/shared/MediaFolderToolbar.tsx b/apps/ui/src/components/shared/MediaFolderToolbar.tsx index eeaacf71..de18f42d 100644 --- a/apps/ui/src/components/shared/MediaFolderToolbar.tsx +++ b/apps/ui/src/components/shared/MediaFolderToolbar.tsx @@ -27,6 +27,7 @@ export function MediaFolderToolbar({ const { t } = useTranslation(["components"]) const sortOptions: SortingOption[] = [ + { value: "none", label: t("sidebar.toolbar.sortNone") }, { value: "alphabetical", label: t("sidebar.toolbar.sortAlphabetical") }, { value: "reverse-alphabetical", label: t("sidebar.toolbar.sortReverseAlphabetical") }, ]; diff --git a/apps/ui/src/components/shared/SortingButton.tsx b/apps/ui/src/components/shared/SortingButton.tsx index 53e06b8e..5a2af927 100644 --- a/apps/ui/src/components/shared/SortingButton.tsx +++ b/apps/ui/src/components/shared/SortingButton.tsx @@ -54,7 +54,15 @@ export function SortingButton({ {option.label} diff --git a/apps/ui/src/components/sidebar/Sidebar.stories.tsx b/apps/ui/src/components/sidebar/Sidebar.stories.tsx index 822544b0..2ca8ed4b 100644 --- a/apps/ui/src/components/sidebar/Sidebar.stories.tsx +++ b/apps/ui/src/components/sidebar/Sidebar.stories.tsx @@ -61,11 +61,11 @@ const meta = { ], beforeEach: () => { mocked(useSidebar).mockImplementation((options = {}) => ({ - sortOrder: "alphabetical", + sortOrder: "none", filterType: "all", setSortOrder: fn(), setFilterType: fn(), - filteredAndSortedFolders: demoFolders + folders: demoFolders .filter((f) => folderMatchesSearchQuery(f, options.searchQuery ?? "")) .map((f) => f.path), handleRename: fn(), diff --git a/apps/ui/src/components/sidebar/Sidebar.tsx b/apps/ui/src/components/sidebar/Sidebar.tsx index 7c649d5b..a28ffc32 100644 --- a/apps/ui/src/components/sidebar/Sidebar.tsx +++ b/apps/ui/src/components/sidebar/Sidebar.tsx @@ -81,7 +81,7 @@ export function Sidebar({ filterType, setSortOrder, setFilterType, - filteredAndSortedFolders, + folders, handleRename, handleOpenInExplorer, handleDeletePaths, @@ -122,7 +122,7 @@ export function Sidebar({ (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === "a") { e.preventDefault() - const paths = [...filteredAndSortedFolders] + const paths = [...folders] commitSelection({ selectedPaths: paths, primaryPath: paths[0] ?? "", @@ -134,7 +134,7 @@ export function Sidebar({ void handleDeletePaths(selectedPaths) } }, - [commitSelection, filteredAndSortedFolders, handleDeletePaths, selectedPaths], + [commitSelection, folders, handleDeletePaths, selectedPaths], ) const handleDeleteItem = useCallback( @@ -173,14 +173,14 @@ export function Sidebar({ onKeyDown={handleListKeyDown} data-testid="sidebar-folder-list" > - {filteredAndSortedFolders.length === 0 ? ( + {folders.length === 0 ? (
{t("sidebar.emptyState")}
) : (
}> - {filteredAndSortedFolders.map((path, index) => ( + {folders.map((path, index) => (
{ if (isMediaMetadataError) return "error_loading_metadata" @@ -94,7 +96,7 @@ function TvShowPanel() { const { mutateAsync: fetchMediaMetadata } = useFetchMediaMetadataMutation() const videoRenameFlow = useRenameVideoFileFlow({ mediaFolderPath: mediaMetadata?.mediaFolderPath, - files: getMediaFolderFiles(mediaMetadata), + files: folderFiles, mode: "episode", }) @@ -238,6 +240,7 @@ function TvShowPanel() { const selectFileFlow = useSelectAndUnselectFileFlow({ mediaMetadata, + folderFiles, updateMediaMetadata, }) @@ -277,17 +280,17 @@ function TvShowPanel() { if(plan === undefined) { ret = buildTvShowEpisodeTableRows(mediaMetadata, uiStatus, (key: string) => { return t(key as any) // eslint-disable-line @typescript-eslint/no-explicit-any - }) + }, folderFiles) } else { ret = buildTvShowEpisodeTableRowsForPlan(mediaMetadata, uiStatus, plan, (key: string) => { return t(key as any) // eslint-disable-line @typescript-eslint/no-explicit-any - }) + }, folderFiles) }; setTableData(ret); /* eslint-enable react-hooks/set-state-in-effect */ - }, [mediaMetadata, plan, uiStatus, t]) + }, [mediaMetadata, plan, uiStatus, t, folderFiles]) const handleVideoCompressForRow = useCallback( (row: { season: number; episode: number; episodeTitle?: string }) => { diff --git a/apps/ui/src/components/tv/TvShowPanelUtils.ts b/apps/ui/src/components/tv/TvShowPanelUtils.ts index 9b3f89d4..ab78d24c 100644 --- a/apps/ui/src/components/tv/TvShowPanelUtils.ts +++ b/apps/ui/src/components/tv/TvShowPanelUtils.ts @@ -1,5 +1,4 @@ -import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles"; -import { getMediaFolderFiles } from "@/lib/mediaFolderFiles"; +import { listMediaFolderFilePaths } from "@/lib/mediaFolderFiles"; import type { MediaFileMetadata, MediaMetadata, PrimaryDatabase, TMDBEpisode, TMDBTVShowDetails, TvShowMediaMetadata } from "@smm/types"; import { extname, join } from "@/lib/path"; import { Path } from "@smm/utils/path"; @@ -76,7 +75,7 @@ export function buildFilePropsForVideoPath( ] } -export function buildFileProps(mm: MediaMetadataWithFolderFiles, seasonNumber: number, episodeNumber: number): FileProps[] { +export function buildFileProps(mm: MediaMetadata, seasonNumber: number, episodeNumber: number, folderFiles: string[]): FileProps[] { if(mm.mediaFolderPath === undefined) { console.error(`Media folder path is undefined`) throw new Error(`Media folder path is undefined`) @@ -86,7 +85,7 @@ export function buildFileProps(mm: MediaMetadataWithFolderFiles, seasonNumber: n return []; } - if(mm.files === undefined || mm.files === null) { + if(folderFiles.length === 0) { return []; } @@ -98,7 +97,7 @@ export function buildFileProps(mm: MediaMetadataWithFolderFiles, seasonNumber: n const episodeVideoFilePath = mediaFile.absolutePath - const files = findAssociatedFiles(mm.mediaFolderPath, mm.files, episodeVideoFilePath) + const files = findAssociatedFiles(mm.mediaFolderPath, folderFiles, episodeVideoFilePath) const fileProps: FileProps[] = [ { @@ -270,18 +269,31 @@ export function rebuildRenamePlanWithSelectedEpisodes( * @param signal optional AbortSignal to cancel the operation * @returns return undefined if not recognizable */ -export async function tryToRecognizeTvShowFolderByNFO(_mm: MediaMetadataWithFolderFiles, signal?: AbortSignal): Promise { +export async function tryToRecognizeTvShowFolderByNFO(_mm: MediaMetadata, signal?: AbortSignal): Promise { const mm = structuredClone(_mm) - - if(mm.files === undefined || mm.files === null) { - console.log(`[TvShowPanelUtils] tryToRecognizeMediaFolderByNFO: files is undefined or null`) + + if(!mm.mediaFolderPath) { + console.log(`[TvShowPanelUtils] tryToRecognizeMediaFolderByNFO: mediaFolderPath is undefined`) + return undefined + } + + let folderFiles: string[] + try { + folderFiles = await listMediaFolderFilePaths(mm.mediaFolderPath, signal) + } catch { + console.log(`[TvShowPanelUtils] tryToRecognizeMediaFolderByNFO: failed to list folder files`) + return undefined + } + + if(folderFiles.length === 0) { + console.log(`[TvShowPanelUtils] tryToRecognizeMediaFolderByNFO: files is empty`) return undefined } mm.mediaFiles = mm.mediaFiles ?? []; - const nfoFilePath = mm.files.find(file => file.endsWith('/tvshow.nfo')) + const nfoFilePath = folderFiles.find(file => file.endsWith('/tvshow.nfo')) if(nfoFilePath === undefined) { console.log(`[TvShowPanelUtils] tryToRecognizeMediaFolderByNFO: tvshow.nfo not found`) return undefined @@ -300,7 +312,7 @@ export async function tryToRecognizeTvShowFolderByNFO(_mm: MediaMetadataWithFold mm.tvShow = buildTvShowMediaMetadataByNFO(resp.data) - const episodeNfoFiles = mm.files.filter(file => file.endsWith('.nfo') && !file.endsWith('/tvshow.nfo')) + const episodeNfoFiles = folderFiles.filter(file => file.endsWith('.nfo') && !file.endsWith('/tvshow.nfo')) if(episodeNfoFiles.length === 0) { console.log(`[TvShowPanelUtils] tryToRecognizeMediaFolderByNFO: no episode NFO files found`) return undefined @@ -375,7 +387,7 @@ export async function tryToRecognizeTvShowFolderByNFO(_mm: MediaMetadataWithFold } console.log(`[TvShowPanelUtils] tryToRecognizeMediaFolderByNFO: found episode S${episodeNfo.season}E${episodeNfo.episode} "${episodeNfo.originalFilename}"`) - const mediaFileAbsPath = mm.files.find(file => file.endsWith(episodeNfo.originalFilename!)) + const mediaFileAbsPath = folderFiles.find(file => file.endsWith(episodeNfo.originalFilename!)) if(mediaFileAbsPath === undefined) { console.error(`[TvShowPanelUtils] tryToRecognizeMediaFolderByNFO: media file not found: ${episodeNfo.originalFilename}`) } @@ -936,9 +948,9 @@ export async function executeRenamePlan( * The caller (addTmpPlan) will add id, task, status, and tmp fields */ export async function buildTemporaryRecognitionPlanAsync( - mediaMetadata: MediaMetadataWithFolderFiles, + mediaMetadata: MediaMetadata, + folderFiles: string[], ): Promise<(Partial & { mediaFolderPath: string; files: RecognizedFile[] }) | null> { - const folderFiles = getMediaFolderFiles(mediaMetadata) console.log("[recognize] build temporary plan started", { mediaFolderPath: mediaMetadata.mediaFolderPath, fileCount: folderFiles.length, @@ -954,7 +966,7 @@ export async function buildTemporaryRecognitionPlanAsync( return null } - const collected = await recognizeEpisodesAsync(mediaMetadata); + const collected = await recognizeEpisodesAsync(mediaMetadata, folderFiles); console.log("[recognize] build temporary plan completed", { mediaFolderPath: mediaMetadata.mediaFolderPath, diff --git a/apps/ui/src/helpers/handleEpisodeFileSelect.ts b/apps/ui/src/helpers/handleEpisodeFileSelect.ts index c0205a05..6d96d36b 100644 --- a/apps/ui/src/helpers/handleEpisodeFileSelect.ts +++ b/apps/ui/src/helpers/handleEpisodeFileSelect.ts @@ -1,5 +1,4 @@ -import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" -import { getMediaFolderFiles } from "@/lib/mediaFolderFiles" +import type { MediaMetadata } from "@/lib/mediaFolderFiles" import { Path } from "@smm/utils/path"; import { updateMediaFileMetadatas } from "@/components/tv/TvShowPanelUtils"; @@ -18,15 +17,15 @@ import { updateMediaFileMetadatas } from "@/components/tv/TvShowPanelUtils"; * @param filePath */ export function handleEpisodeFileSelect( - mm: MediaMetadataWithFolderFiles, + mm: MediaMetadata, seasonNumber: number, episodeNumber: number, filePath: string, + folderFiles: string[], onError: (error: string) => void -): MediaMetadataWithFolderFiles { +): MediaMetadata { - const files = getMediaFolderFiles(mm) - if (files.length === 0) { + if (folderFiles.length === 0) { onError("Files list is not available"); return mm; } @@ -46,15 +45,15 @@ export function handleEpisodeFileSelect( const isWindows = Path.isWindows(); const normalizedSelectedPath = isWindows ? filePathInPosix.toLowerCase() : filePathInPosix; - const normalizedFiles = files.map((f: string) => isWindows ? f.toLowerCase() : f); + const normalizedFiles = folderFiles.map((f: string) => isWindows ? f.toLowerCase() : f); let fileFound = false; let matchedFile = ""; - for (let i = 0; i < files.length; i++) { + for (let i = 0; i < folderFiles.length; i++) { const normalizedFile = normalizedFiles[i]; if (normalizedFile === normalizedSelectedPath) { fileFound = true; - matchedFile = files[i]; + matchedFile = folderFiles[i]; break; } } @@ -75,4 +74,4 @@ export function handleEpisodeFileSelect( ...mm, mediaFiles: updatedMediaFiles, }; -} \ No newline at end of file +} diff --git a/apps/ui/src/helpers/loadNfo.ts b/apps/ui/src/helpers/loadNfo.ts index 05a64d56..780a60a3 100644 --- a/apps/ui/src/helpers/loadNfo.ts +++ b/apps/ui/src/helpers/loadNfo.ts @@ -1,7 +1,7 @@ import { readFile } from "@/api/readFile" import NFO from "@/lib/nfo" -import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" -import { getMediaFolderFiles } from "@/lib/mediaFolderFiles" +import type { MediaMetadata } from "@/lib/mediaFolderFiles" +import { listMediaFolderFilePaths } from "@/lib/mediaFolderFiles" import type { TMDBTVShowDetails } from "@smm/types" /** @@ -91,8 +91,21 @@ export function nfoToTmdbTVShowDetails(nfo: NFO): TMDBTVShowDetails { return tvShowDetails } -export async function loadNfo(mediaMetadata: MediaMetadataWithFolderFiles): Promise { - const nfoFilePath = getMediaFolderFiles(mediaMetadata).find((file: string) => file.endsWith('/tvshow.nfo')) +export async function loadNfo(mediaMetadata: MediaMetadata): Promise { + if (!mediaMetadata.mediaFolderPath) { + console.log(`[loadNfo] no media folder path in metadata`) + return undefined + } + + let folderFiles: string[] + try { + folderFiles = await listMediaFolderFilePaths(mediaMetadata.mediaFolderPath) + } catch { + console.log(`[loadNfo] failed to list folder files`) + return undefined + } + + const nfoFilePath = folderFiles.find((file: string) => file.endsWith('/tvshow.nfo')) if(nfoFilePath === undefined) { console.log(`[loadNfo] no nfo file found in media metadata`) diff --git a/apps/ui/src/helpers/movie/MovieMediaMetadataUtils.ts b/apps/ui/src/helpers/movie/MovieMediaMetadataUtils.ts index 62d8ea42..e2d13129 100644 --- a/apps/ui/src/helpers/movie/MovieMediaMetadataUtils.ts +++ b/apps/ui/src/helpers/movie/MovieMediaMetadataUtils.ts @@ -1,8 +1,10 @@ -import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles"; -import { getMediaFolderFiles } from "@/lib/mediaFolderFiles"; +import type { MediaMetadata } from "@/lib/mediaFolderFiles"; import { videoFileExtensions } from "../../lib/utils"; import { extname } from "../../lib/path"; -export function findMediaFilesForMovieMediaMetadata(mediaMetadata: MediaMetadataWithFolderFiles): MediaMetadataWithFolderFiles { +export function findMediaFilesForMovieMediaMetadata( + mediaMetadata: MediaMetadata, + folderFiles: string[], +): MediaMetadata { if(!mediaMetadata.mediaFolderPath) { console.log('[findMediaFilesForMovieMediaMetadata] Media folder path is required, skipping post processing'); @@ -16,7 +18,6 @@ export function findMediaFilesForMovieMediaMetadata(mediaMetadata: MediaMetadata return mediaMetadata } - const folderFiles = getMediaFolderFiles(mediaMetadata) if(folderFiles.length === 0) { console.log('[findMediaFilesForMovieMediaMetadata] No files found in media folder, skipping post processing', { mediaFolderPath: mediaMetadata.mediaFolderPath, @@ -26,15 +27,16 @@ export function findMediaFilesForMovieMediaMetadata(mediaMetadata: MediaMetadata const videoFiles = findVideoFiles(folderFiles); - mediaMetadata.mediaFiles = videoFiles.map(path => ({ + return { + ...mediaMetadata, + mediaFiles: videoFiles.map(path => ({ absolutePath: path, - })) - - return mediaMetadata; + })), + } } export function findVideoFiles(paths: string[]): string[] { return paths.filter(path => { return videoFileExtensions.includes(extname(path).toLowerCase()); }) -} \ No newline at end of file +} diff --git a/apps/ui/src/helpers/movie/buildMovieFilesFromMediaMetadata.ts b/apps/ui/src/helpers/movie/buildMovieFilesFromMediaMetadata.ts index 6499ce5d..19731779 100644 --- a/apps/ui/src/helpers/movie/buildMovieFilesFromMediaMetadata.ts +++ b/apps/ui/src/helpers/movie/buildMovieFilesFromMediaMetadata.ts @@ -1,5 +1,4 @@ -import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" -import { getMediaFolderFiles } from "@/lib/mediaFolderFiles" +import type { MediaMetadata } from "@/lib/mediaFolderFiles" import type { FileProps } from "@/lib/types" import { basename, join } from "@/lib/path" import { findAssociatedFiles, imageFileExtensions } from "@/lib/utils" @@ -67,7 +66,8 @@ function findMovieFolderAssociatedFiles(allFiles: string[]): Array<{ } export function buildMovieFilesFromMediaMetadata( - mediaMetadata: MediaMetadataWithFolderFiles | undefined, + mediaMetadata: MediaMetadata | undefined, + folderFiles: string[] = [], ): MovieFileModel | undefined { if (!mediaMetadata?.mediaFolderPath) { return undefined @@ -76,7 +76,7 @@ export function buildMovieFilesFromMediaMetadata( const mediaFolderPath = mediaMetadata.mediaFolderPath const files: FileProps[] = [] const addedPaths = new Set() - const allFilePaths = getMediaFolderFiles(mediaMetadata) + const allFilePaths = folderFiles for (const file of mediaMetadata.mediaFiles ?? []) { const videoPath = file.absolutePath diff --git a/apps/ui/src/hooks/folders/invalidateFoldersQuery.ts b/apps/ui/src/hooks/folders/invalidateFoldersQuery.ts index 72975cbe..a05b174b 100644 --- a/apps/ui/src/hooks/folders/invalidateFoldersQuery.ts +++ b/apps/ui/src/hooks/folders/invalidateFoldersQuery.ts @@ -1,8 +1,7 @@ import type { QueryClient } from '@tanstack/react-query' -import { isSmmV3Enabled } from '@/lib/localStorages' import { foldersQueryKey } from './foldersQueryKeys' +/** Invalidate the folders list query (v3 is always on). */ export function invalidateFoldersQueryIfV3(queryClient: QueryClient): void { - if (!isSmmV3Enabled()) return void queryClient.invalidateQueries({ queryKey: foldersQueryKey }) } diff --git a/apps/ui/src/hooks/folders/useFoldersQuery.ts b/apps/ui/src/hooks/folders/useFoldersQuery.ts index 616c2e89..1673e40f 100644 --- a/apps/ui/src/hooks/folders/useFoldersQuery.ts +++ b/apps/ui/src/hooks/folders/useFoldersQuery.ts @@ -1,13 +1,10 @@ import { useQuery } from '@tanstack/react-query' import { getFolders } from '@/api/getFolders' -import { isSmmV3Enabled } from '@/lib/localStorages' import { foldersQueryKey } from './foldersQueryKeys' export function useFoldersQuery() { - const enabled = isSmmV3Enabled() return useQuery({ queryKey: foldersQueryKey, - enabled, queryFn: async (): Promise => { const resp = await getFolders() if (resp.error) throw new Error(resp.error) diff --git a/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.test.tsx b/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.test.tsx index 1bf2d970..78684dab 100644 --- a/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.test.tsx +++ b/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.test.tsx @@ -16,7 +16,6 @@ import { } from "@/lib/mediaMetadataQueryKeys" import { useMediaMetadataMutation } from "./useMediaMetadataMutation" import { useMediaMetadataQuery } from "./useMediaMetadataQuery" -import { useUpdateMediaMetadataMutation } from "./useUpdateMediaMetadataMutation" vi.mock("@/api/metadata", async (importOriginal) => { const actual = await importOriginal() @@ -96,73 +95,7 @@ describe("metadata hooks", () => { expect(queryClient.getQueryData(windowsPathKey)).toEqual(updated) }) - it("preserves live folder files when setMetadata returns persisted metadata", async () => { - const withFiles = { - ...metadata, - files: ["/media/show/S01E01.mkv"], - } - const persisted = { - ...metadata, - mediaFiles: [ - { - absolutePath: "/media/show/S01E01.mkv", - seasonNumber: 1, - episodeNumber: 1, - }, - ], - } - vi.mocked(setMetadata).mockResolvedValue(persisted) - const queryClient = new QueryClient() - queryClient.setQueryData(mediaMetadataQueryKey("/media/show"), withFiles) - const { result } = renderHook(() => useMediaMetadataMutation(), { - wrapper: createWrapper(queryClient), - }) - - await act(() => - result.current.set("/media/show", { - mediaFiles: persisted.mediaFiles, - }), - ) - - expect(queryClient.getQueryData(mediaMetadataQueryKey("/media/show"))).toEqual({ - ...persisted, - files: ["/media/show/S01E01.mkv"], - }) - }) - - it("preserves files from the metadata being saved when the cache is empty", async () => { - const incoming = { - ...metadata, - files: ["/media/show/S01E01.mkv"], - mediaFiles: [ - { - absolutePath: "/media/show/S01E01.mkv", - seasonNumber: 1, - episodeNumber: 1, - }, - ], - } - const persisted = { - mediaFolderPath: incoming.mediaFolderPath, - type: incoming.type, - mediaFiles: incoming.mediaFiles, - } - vi.mocked(setMetadata).mockResolvedValue(persisted) - const queryClient = new QueryClient() - const { result } = renderHook(() => useUpdateMediaMetadataMutation(), { - wrapper: createWrapper(queryClient), - }) - - await act(() => result.current.persistMediaMetadata("/media/show", incoming)) - - expect(queryClient.getQueryData(mediaMetadataQueryKey("/media/show"))).toEqual({ - ...persisted, - files: ["/media/show/S01E01.mkv"], - }) - }) - it("remove clears the normalized metadata cache", async () => { - vi.mocked(deleteMetadata).mockResolvedValue() const queryClient = new QueryClient() const windowsPathKey = mediaMetadataQueryKey( normalizeMediaFolderPathForQuery("C:\\media\\show"), diff --git a/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.ts b/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.ts index 08b1a692..d4f2bc87 100644 --- a/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.ts +++ b/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.ts @@ -25,12 +25,11 @@ export function useMediaMetadataMutation() { const createMutation = useMutation({ mutationFn: createMetadata, - onSuccess: (metadata, variables) => { + onSuccess: (metadata) => { setPersistedMetadataQueryData( queryClient, requireMetadataPath(metadata), metadata, - variables, ) }, }) diff --git a/apps/ui/src/hooks/mediaMetadata/useMediaMetadataQuery.ts b/apps/ui/src/hooks/mediaMetadata/useMediaMetadataQuery.ts index 584332f2..5100613d 100644 --- a/apps/ui/src/hooks/mediaMetadata/useMediaMetadataQuery.ts +++ b/apps/ui/src/hooks/mediaMetadata/useMediaMetadataQuery.ts @@ -1,7 +1,6 @@ import { skipToken, useQuery } from "@tanstack/react-query" import { MetadataHttpError } from "@/api/metadata" import { mediaMetadataReadQueryOptions } from "@/lib/mediaMetadataQueryKeys" -import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" import type { MediaMetadata } from "@smm/types" /** Query key when no folder path — `queryFn: skipToken` skips fetch; must not call `mediaMetadataReadQueryOptions("")`. */ @@ -15,7 +14,7 @@ export function useMediaMetadataQuery(path: string | undefined, _opts?: UseMedia const trimmed = path?.trim() ?? "" const readOpts = trimmed ? mediaMetadataReadQueryOptions(trimmed) : null - return useQuery({ + return useQuery({ queryKey: readOpts?.queryKey ?? noFolderMediaMetadataQueryKey, queryFn: readOpts ? async (context) => { diff --git a/apps/ui/src/hooks/mediaMetadata/useUpdateMediaMetadataMutation.ts b/apps/ui/src/hooks/mediaMetadata/useUpdateMediaMetadataMutation.ts index f7c043e4..0579f7ce 100644 --- a/apps/ui/src/hooks/mediaMetadata/useUpdateMediaMetadataMutation.ts +++ b/apps/ui/src/hooks/mediaMetadata/useUpdateMediaMetadataMutation.ts @@ -51,12 +51,11 @@ export function useUpdateMediaMetadataMutation() { } return { folderPathPosix: folder, metadata: persisted } }, - onSuccess: ({ folderPathPosix, metadata }, vars) => { + onSuccess: ({ folderPathPosix, metadata }) => { setPersistedMetadataQueryData( queryClient, folderPathPosix, metadata, - vars.metadata, ) }, }) diff --git a/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.ts b/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.ts index 56ec63ea..6db61b86 100644 --- a/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.ts +++ b/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.ts @@ -14,6 +14,7 @@ import { isRuleBasedRecognizePlanFullyUnchanged, } from "@/lib/isRuleBasedRecognizePlanComplete" import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" +import { listMediaFolderFilePaths } from "@/lib/mediaFolderFiles" import { nextTraceId } from "@/lib/utils" import { useTranslation } from "@/lib/i18n" import type { Plan } from "@/api/getPlans" @@ -156,7 +157,8 @@ export function useRuleBasedRecognizeFlow({ tvShow: mediaMetadata.tvShow?.name, }) - void buildTemporaryRecognitionPlanAsync(mediaMetadata) + void listMediaFolderFilePaths(mediaMetadata.mediaFolderPath!) + .then((folderFiles) => buildTemporaryRecognitionPlanAsync(mediaMetadata, folderFiles)) .then(async (planData) => { if (planData && planData.files.length > 0) { await updatePlanMutation.mutateAsync({ diff --git a/apps/ui/src/hooks/tv/useSelectAndUnselectFileFlow.ts b/apps/ui/src/hooks/tv/useSelectAndUnselectFileFlow.ts index c2d4fef8..d569cda4 100644 --- a/apps/ui/src/hooks/tv/useSelectAndUnselectFileFlow.ts +++ b/apps/ui/src/hooks/tv/useSelectAndUnselectFileFlow.ts @@ -12,6 +12,7 @@ import type { MediaMetadata } from "@smm/types" export interface UseSelectAndUnselectFileFlowOptions { mediaMetadata: MediaMetadata | undefined + folderFiles: string[] updateMediaMetadata: ( path: string, updaterOrMetadata: MediaMetadata | ((current: MediaMetadata) => MediaMetadata), @@ -21,6 +22,7 @@ export interface UseSelectAndUnselectFileFlowOptions { export function useSelectAndUnselectFileFlow({ mediaMetadata, + folderFiles, updateMediaMetadata, }: UseSelectAndUnselectFileFlowOptions) { const { t: i18nT } = useTranslation(["components"]) @@ -67,6 +69,7 @@ export function useSelectAndUnselectFileFlow({ seasonNumber, episodeNumber, file.path, + folderFiles, (errorMessage) => { toast.error(errorMessage) }, @@ -78,7 +81,7 @@ export function useSelectAndUnselectFileFlow({ updateMediaMetadata(currentMediaMetadata.mediaFolderPath!, updated, { traceId }) }, - [requireMediaMetadata, updateMediaMetadata, t], + [requireMediaMetadata, updateMediaMetadata, folderFiles, t], ) const handleOpenFilePickerForEpisode = useCallback( diff --git a/apps/ui/src/hooks/useGetAssociatedFiles.ts b/apps/ui/src/hooks/useGetAssociatedFiles.ts index cccf4fe0..9079dd0b 100644 --- a/apps/ui/src/hooks/useGetAssociatedFiles.ts +++ b/apps/ui/src/hooks/useGetAssociatedFiles.ts @@ -1,7 +1,6 @@ import { useMemo } from "react" import { useQuery, skipToken } from "@tanstack/react-query" -import { listFiles } from "@/api/listFiles" -import { associatedFilesQueryKey } from "@/lib/associatedFilesQueryKeys" +import { mediaFolderFilesReadQueryOptions } from "@/lib/mediaFolderFiles" import { basename, extname } from "@/lib/path" import { extensions } from "@smm/types/mediaFileExtensions" import type { AssociatedFile } from "@/types/associated-files" @@ -52,21 +51,11 @@ export function useGetAssociatedFiles( fileAbsPath: string | undefined, ) { const trimmed = mediaFolderPath?.trim() ?? "" - const key = trimmed - ? associatedFilesQueryKey(trimmed) - : (["associatedFiles", null] as const) + const readOpts = trimmed ? mediaFolderFilesReadQueryOptions(trimmed) : null const { data: allPaths = [] } = useQuery({ - queryKey: key, - queryFn: trimmed - ? async ({ signal }) => { - const resp = await listFiles( - { path: trimmed, onlyFiles: true }, - signal, - ) - return (resp.data?.items ?? []).map((item) => item.path) - } - : skipToken, + queryKey: readOpts?.queryKey ?? (["associatedFiles", null] as const), + queryFn: readOpts?.queryFn ?? skipToken, enabled: Boolean(trimmed) && Boolean(fileAbsPath), staleTime: 30_000, }) diff --git a/apps/ui/src/hooks/useMediaFolderFilesQuery.ts b/apps/ui/src/hooks/useMediaFolderFilesQuery.ts new file mode 100644 index 00000000..45f866b4 --- /dev/null +++ b/apps/ui/src/hooks/useMediaFolderFilesQuery.ts @@ -0,0 +1,14 @@ +import { skipToken, useQuery } from "@tanstack/react-query" +import { mediaFolderFilesReadQueryOptions } from "@/lib/mediaFolderFiles" + +export function useMediaFolderFilesQuery(folderPath: string | undefined) { + const trimmed = folderPath?.trim() ?? "" + const readOpts = trimmed ? mediaFolderFilesReadQueryOptions(trimmed) : null + + return useQuery({ + queryKey: readOpts?.queryKey ?? (["associatedFiles", null] as const), + queryFn: readOpts?.queryFn ?? skipToken, + enabled: Boolean(trimmed), + staleTime: 30_000, + }) +} diff --git a/apps/ui/src/hooks/useSidebar.ts b/apps/ui/src/hooks/useSidebar.ts index e65c1129..7ad2655c 100644 --- a/apps/ui/src/hooks/useSidebar.ts +++ b/apps/ui/src/hooks/useSidebar.ts @@ -1,21 +1,19 @@ import { useCallback, useMemo } from "react" import { useQueries } from "@tanstack/react-query" import { useSidebarStore, compareByDisplayName } from "@/stores/sidebarStore" -import { Path } from "@smm/utils/path" -import { useUIMediaFolderStoreActions } from "@/stores/uiMediaFolderStore" +import { basename } from '../lib/path' +import { + useUIMediaFolderStoreState, +} from "@/stores/uiMediaFolderStore" import { mediaMetadataReadQueryOptions } from "@/lib/mediaMetadataQueryKeys" -import { buildMediaFolderListItemPropsFromFolderAndMetadata } from "@/lib/sidebarRowUtils" +import { buildMediaFolderListItemPropsFromFolderAndMetadata, mediaTypeFromMetadataType } from "@/lib/sidebarRowUtils" import { folderMatchesSearchQuery } from "@/lib/sidebarFolderSearch" import { useDialogs } from "@/providers/dialog-provider" -import { useConfig } from "@/hooks/userConfig" import { openInFileManagerApi } from "@/api/openInFileManager" -import { nextTraceId } from "@/lib/utils" -import { deleteMetadata } from "@/api/metadata" import { useTranslation } from "@/lib/i18n" -import { isSmmV3Enabled } from "@/lib/localStorages" import { useFoldersQuery, useUnimportFolderMutation } from "@/hooks/folders" import { mergeFolderPathsWithUiStatus } from "@/lib/mergeFolderPathsWithUiStatus" -import { uniq } from "es-toolkit/array" +import { Path } from "@smm/utils/path" export interface UseSidebarOptions { onDeleteSelected?: (paths: string[]) => void @@ -23,53 +21,49 @@ export interface UseSidebarOptions { searchQuery?: string } -export function useSidebar({ onDeleteSelected, searchQuery = "" }: UseSidebarOptions = {}) { +export function useSidebar({ searchQuery = "" }: UseSidebarOptions = {}) { const { t } = useTranslation(["components"]) const { sortOrder, filterType, setSortOrder, setFilterType } = useSidebarStore() - const { removeFolder } = useUIMediaFolderStoreActions() - const { userConfig, setAndSaveUserConfig } = useConfig() - const folders = useMemo(() => { - return uniq(userConfig.folders) - }, [userConfig.folders]) + const { _folders } = useUIMediaFolderStoreState() + const unimportFolderMutation = useUnimportFolderMutation() const { renameFolderDialog } = useDialogs() const [openRenameForMediaFolder] = renameFolderDialog - const foldersQuery = useFoldersQuery() - const v3 = isSmmV3Enabled() - const listFolders = v3 - ? mergeFolderPathsWithUiStatus(foldersQuery.data ?? [], folders) - : folders - - const folderPaths = useMemo(() => listFolders.map((f) => f.path), [listFolders]) + const foldersQuery = useFoldersQuery(); const metadataQueries = useQueries({ - queries: folderPaths.map((path) => ({ - ...mediaMetadataReadQueryOptions(path), - staleTime: 5 * 60 * 1000, + queries: (foldersQuery.data ?? []).map((folderAbsPath) => ({ + ...mediaMetadataReadQueryOptions(folderAbsPath) })), }) - const rowsWithMeta = useMemo(() => { - return listFolders.map((folder, i) => - buildMediaFolderListItemPropsFromFolderAndMetadata(folder, metadataQueries[i]?.data), - ) - }, [listFolders, metadataQueries]) + const folders = useMemo(() => { + + let folderSearchFields = (foldersQuery.data ?? []).map((folderAbsPath) => { - const filteredAndSortedFolders = useMemo(() => { - let result = [...rowsWithMeta] + const m = metadataQueries.find((query) => query.data?.mediaFolderPath === Path.posix(folderAbsPath))?.data - result = result.filter((folder) => folderMatchesSearchQuery(folder, searchQuery)) + return { + folderName: basename(folderAbsPath) ?? '', + type: m?.type, + path: folderAbsPath + } + }) + folderSearchFields = folderSearchFields.filter((folder) => (folder.folderName.toLocaleLowerCase() ?? '').includes(searchQuery.toLowerCase())) + if (filterType !== "all") { - result = result.filter((folder) => folder.mediaType === filterType) + folderSearchFields = folderSearchFields.filter((folder) => mediaTypeFromMetadataType(folder.type) === filterType) } - result.sort((a, b) => compareByDisplayName(a.mediaName, b.mediaName, sortOrder)) - - return result.map((folder) => folder.path) - }, [rowsWithMeta, sortOrder, filterType, searchQuery]) + if (sortOrder !== "none") { + folderSearchFields.sort((a, b) => compareByDisplayName(a.folderName, b.folderName, sortOrder)) + } + return folderSearchFields.map((folder) => folder.path) + }, [foldersQuery.data, metadataQueries, sortOrder, filterType, searchQuery]) + const handleOpenInExplorer = useCallback(async (path: string) => { try { const result = await openInFileManagerApi(path) @@ -94,27 +88,9 @@ export function useSidebar({ onDeleteSelected, searchQuery = "" }: UseSidebarOpt const handleDeletePaths = useCallback( async (paths: string[]) => { if (paths.length === 0) return - if (isSmmV3Enabled()) { - await unimportFolderMutation.mutateAsync(paths) - return - } - if (onDeleteSelected) { - await onDeleteSelected(paths) - return - } - - const traceId = `Sidebar-onDeleteSelected-${nextTraceId()}` - const deletedSet = new Set(paths.map((p) => Path.posix(p))) - - await Promise.all(paths.map((path) => deleteMetadata(path))) - - setAndSaveUserConfig(traceId, { - ...userConfig, - folders: userConfig.folders.filter((folder) => !deletedSet.has(Path.posix(folder))), - }) - paths.forEach((path) => removeFolder(path)) + await unimportFolderMutation.mutateAsync(paths) }, - [onDeleteSelected, setAndSaveUserConfig, userConfig, removeFolder, unimportFolderMutation], + [unimportFolderMutation], ) return { @@ -122,7 +98,7 @@ export function useSidebar({ onDeleteSelected, searchQuery = "" }: UseSidebarOpt filterType, setSortOrder, setFilterType, - filteredAndSortedFolders, + folders, handleRename, handleOpenInExplorer, handleDeletePaths, diff --git a/apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.test.ts b/apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.test.ts index cb2948ed..16a05386 100644 --- a/apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.test.ts +++ b/apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.test.ts @@ -3,11 +3,10 @@ import { applyRenamePairsToUIMediaMetadata } from "./applyRenamePairsToUIMediaMe import type { MediaMetadata } from "@smm/types"; describe("applyRenamePairsToUIMediaMetadata", () => { - it("remaps mediaFiles and files paths", () => { + it("remaps mediaFiles paths", () => { const meta = { mediaFolderPath: "/show", type: "tvshow-folder" as const, - files: ["/show/old.mkv", "/show/old.srt"], mediaFiles: [ { absolutePath: "/show/old.mkv", @@ -23,7 +22,6 @@ describe("applyRenamePairsToUIMediaMetadata", () => { { from: "/show/old.srt", to: "/show/new.srt" }, ]); - expect(next.files).toEqual(["/show/new.mkv", "/show/new.srt"]); expect(next.mediaFiles?.[0]?.absolutePath).toBe("/show/new.mkv"); expect(next.mediaFiles?.[0]?.subtitleFilePaths).toEqual(["/show/new.srt"]); }); diff --git a/apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.ts b/apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.ts index 5721de6c..216df44d 100644 --- a/apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.ts +++ b/apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.ts @@ -1,5 +1,4 @@ -import type { MediaFileMetadata } from "@smm/types" -import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" +import type { MediaFileMetadata, MediaMetadata } from "@smm/types" import { Path } from "@smm/utils/path" function pathKey(p: string): string { @@ -11,23 +10,20 @@ function pathKey(p: string): string { } /** - * Apply completed on-disk renames to in-memory metadata (mediaFiles, UI folder file list). + * Apply completed on-disk renames to in-memory metadata (mediaFiles). * Pairs must match what was passed to `/api/renameFiles` (POSIX paths as stored in metadata). */ export function applyRenamePairsToUIMediaMetadata( - metadata: MediaMetadataWithFolderFiles, + metadata: MediaMetadata, pairs: Array<{ from: string; to: string }>, -): MediaMetadataWithFolderFiles { +): MediaMetadata { const map = new Map() for (const { from, to } of pairs) { map.set(pathKey(from), to) } const remap = (p: string) => map.get(pathKey(p)) ?? p - const next: MediaMetadataWithFolderFiles = { ...metadata } - if (Array.isArray(next.files)) { - next.files = next.files.map(remap) - } + const next: MediaMetadata = { ...metadata } if (next.mediaFiles?.length) { next.mediaFiles = next.mediaFiles.map( (mf): MediaFileMetadata => ({ diff --git a/apps/ui/src/lib/buildMovieEpisodeTableRows.ts b/apps/ui/src/lib/buildMovieEpisodeTableRows.ts index d47d8106..c5e7c817 100644 --- a/apps/ui/src/lib/buildMovieEpisodeTableRows.ts +++ b/apps/ui/src/lib/buildMovieEpisodeTableRows.ts @@ -1,6 +1,5 @@ import type { TvShowEpisodeDataRow, TvShowEpisodeTableRow } from "@/components/tv/TvShowEpisodeTable"; -import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" -import { getMediaFolderFiles } from "@/lib/mediaFolderFiles"; +import type { MediaMetadata } from "@/lib/mediaFolderFiles" import type { UIMediaFolderStatus } from "@/types/UIMediaFolder"; import { basename, join } from "@/lib/path"; import { findAssociatedFiles } from "@/lib/utils"; @@ -20,9 +19,10 @@ export interface MovieRenamePreviewData { * - One episode data row (S01E01) with video + stem-matched associated files */ export function buildMovieEpisodeTableRows( - mm: MediaMetadataWithFolderFiles, + mm: MediaMetadata, uiStatus: UIMediaFolderStatus, t: (key: string) => string, + folderFiles: string[] = [], options?: { renamePreview?: MovieRenamePreviewData; } @@ -45,7 +45,7 @@ export function buildMovieEpisodeTableRows( const rows: TvShowEpisodeTableRow[] = []; const mediaFolderPath = mm.mediaFolderPath; const videoFile = mm.mediaFiles[0]; // Only the first/main video file - const allFiles = getMediaFolderFiles(mm); + const allFiles = folderFiles; // ── Folder-level file rows (mirrors TvShowPanel's buildFolderFileRows) ── diff --git a/apps/ui/src/lib/buildTvShowEpisodeTableRows.ts b/apps/ui/src/lib/buildTvShowEpisodeTableRows.ts index 9e0edc97..9cf87aba 100644 --- a/apps/ui/src/lib/buildTvShowEpisodeTableRows.ts +++ b/apps/ui/src/lib/buildTvShowEpisodeTableRows.ts @@ -1,6 +1,5 @@ import type { TvShowEpisodeDataRow, TvShowEpisodeTableRow, TvShowFolderFileRow } from "@/components/tv/TvShowEpisodeTable"; -import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" -import { getMediaFolderFiles } from "@/lib/mediaFolderFiles"; +import type { MediaMetadata } from "@/lib/mediaFolderFiles" import { basename, join } from "@/lib/path"; import type { UIRecognizeMediaFilePlan } from "@/types/UIRecognizeMediaFilePlan"; import { findAssociatedFiles } from "@/lib/utils"; @@ -43,9 +42,10 @@ function buildFolderFileRows(files: string[]): TvShowFolderFileRow[] { } export function buildTvShowEpisodeTableRows( - mm: MediaMetadataWithFolderFiles, + mm: MediaMetadata, uiStatus: UIMediaFolderStatus, t: (key: string) => string, + folderFiles: string[] = [], ): TvShowEpisodeTableRow[] { const rows: TvShowEpisodeTableRow[] = [] @@ -73,14 +73,16 @@ export function buildTvShowEpisodeTableRows( }] } - const folderFiles = getMediaFolderFiles(mm) - if (folderFiles.length > 0 && mm.mediaFolderPath) { - rows.push(...buildFolderFileRows(folderFiles)) + const folderFileRows = folderFiles.length > 0 && mm.mediaFolderPath + ? buildFolderFileRows(folderFiles) + : [] + if (folderFileRows.length > 0) { + rows.push(...folderFileRows) } if (mm.tvShow !== undefined) { debug(`use tmdbTvShow to build episode table rows`) - const rowsFromTmdbTvShow = _buildTvShowEpisodeTableRowsFromTmdb(mm) + const rowsFromTmdbTvShow = _buildTvShowEpisodeTableRowsFromTmdb(mm, folderFiles) rows.push(...rowsFromTmdbTvShow) return rows; } @@ -89,7 +91,7 @@ export function buildTvShowEpisodeTableRows( return rows } -export function _buildTvShowEpisodeTableRowsFromTmdb(_in_mm: MediaMetadataWithFolderFiles) { +export function _buildTvShowEpisodeTableRowsFromTmdb(_in_mm: MediaMetadata, folderFiles: string[] = []) { const rows: TvShowEpisodeTableRow[] = [] @@ -126,7 +128,6 @@ export function _buildTvShowEpisodeTableRowsFromTmdb(_in_mm: MediaMetadataWithFo newPath: undefined } - const folderFiles = getMediaFolderFiles(_in_mm) if (_in_mm.mediaFolderPath && folderFiles.length > 0) { const associatedFiles = findAssociatedFiles(_in_mm.mediaFolderPath, folderFiles, mediaFile.absolutePath) @@ -170,7 +171,7 @@ export function _buildTvShowEpisodeTableRowsFromTmdb(_in_mm: MediaMetadataWithFo return rows; } -export function _buildTvShowEpisodeTableRowsFromTvdb(_in_mm: MediaMetadataWithFolderFiles) { +export function _buildTvShowEpisodeTableRowsFromTvdb(_in_mm: MediaMetadata, folderFiles: string[] = []) { const rows: TvShowEpisodeTableRow[] = [] @@ -207,7 +208,6 @@ export function _buildTvShowEpisodeTableRowsFromTvdb(_in_mm: MediaMetadataWithFo newPath: undefined } - const folderFiles = getMediaFolderFiles(_in_mm) if (_in_mm.mediaFolderPath && folderFiles.length > 0) { const associatedFiles = findAssociatedFiles(_in_mm.mediaFolderPath, folderFiles, mediaFile.absolutePath) @@ -252,10 +252,11 @@ export function _buildTvShowEpisodeTableRowsFromTvdb(_in_mm: MediaMetadataWithFo } export function buildTvShowEpisodeTableRowsForPlan( - mm: MediaMetadataWithFolderFiles, + mm: MediaMetadata, uiStatus: UIMediaFolderStatus, plan: UIRenameFilesPlan | UIRecognizeMediaFilePlan, - t: (key: string) => string + t: (key: string) => string, + folderFiles: string[] = [], ): TvShowEpisodeTableRow[] { if (uiStatus === "initializing") { @@ -282,7 +283,7 @@ export function buildTvShowEpisodeTableRowsForPlan( }] } - const rows: TvShowEpisodeTableRow[] = buildTvShowEpisodeTableRows(mm, uiStatus, t) + const rows: TvShowEpisodeTableRow[] = buildTvShowEpisodeTableRows(mm, uiStatus, t, folderFiles) if(plan.task === "recognize-media-file") { if(plan.status === 'preparing') { diff --git a/apps/ui/src/lib/log.ts b/apps/ui/src/lib/log.ts index cbe250ab..e13a77bc 100644 --- a/apps/ui/src/lib/log.ts +++ b/apps/ui/src/lib/log.ts @@ -1,14 +1,13 @@ -import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles"; -import { isNil } from "es-toolkit"; +import type { MediaMetadata } from "@smm/types"; import pino from 'pino' // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function minimize(mm: MediaMetadataWithFolderFiles): any { +export function minimize(mm: MediaMetadata): any { return { mediaFolderPath: mm.mediaFolderPath, type: mm.type, name: mm.tvShow?.name, - files: `${isNil(mm.files) ? mm.files : `${mm.files?.length ?? 0} files`}`, + mediaFileCount: mm.mediaFiles?.length ?? 0, tvShow: { id: mm.tvShow?.id, name: mm.tvShow?.name, diff --git a/apps/ui/src/lib/mediaFolderFiles.test.ts b/apps/ui/src/lib/mediaFolderFiles.test.ts deleted file mode 100644 index e96f72db..00000000 --- a/apps/ui/src/lib/mediaFolderFiles.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from "vitest" -import type { MediaMetadata } from "@smm/types" -import { withLiveFolderFiles } from "./mediaFolderFiles" - -const persisted: MediaMetadata = { - mediaFolderPath: "/media/show", - type: "tvshow-folder", - mediaFiles: [], -} - -describe("withLiveFolderFiles", () => { - it("keeps live files from previous cache when persisted metadata omits them", () => { - expect( - withLiveFolderFiles(persisted, { - ...persisted, - files: ["/media/show/S01E01.mkv"], - }), - ).toEqual({ - ...persisted, - files: ["/media/show/S01E01.mkv"], - }) - }) - - it("keeps live files from the incoming save payload when cache is empty", () => { - expect( - withLiveFolderFiles(persisted, undefined, { - ...persisted, - files: ["/media/show/S01E01.mkv"], - }), - ).toEqual({ - ...persisted, - files: ["/media/show/S01E01.mkv"], - }) - }) - - it("prefers previous cache files over incoming files", () => { - expect( - withLiveFolderFiles( - persisted, - { ...persisted, files: ["/media/show/cached.mkv"] }, - { ...persisted, files: ["/media/show/incoming.mkv"] }, - ), - ).toEqual({ - ...persisted, - files: ["/media/show/cached.mkv"], - }) - }) -}) diff --git a/apps/ui/src/lib/mediaFolderFiles.ts b/apps/ui/src/lib/mediaFolderFiles.ts index 2a87ba78..a809099a 100644 --- a/apps/ui/src/lib/mediaFolderFiles.ts +++ b/apps/ui/src/lib/mediaFolderFiles.ts @@ -1,30 +1,12 @@ import type { MediaMetadata } from "@smm/types" import { listFiles } from "@/api/listFiles" import { Path } from "@smm/utils/path" +import { associatedFilesQueryKey } from "@/lib/associatedFilesQueryKeys" -/** Live folder listing from `listFiles`; not persisted in metadata cache. */ -export type MediaMetadataWithFolderFiles = MediaMetadata & { - files?: string[] -} +export type { MediaMetadata } -export function getMediaFolderFiles( - mm: MediaMetadataWithFolderFiles | null | undefined, -): string[] { - return mm?.files ?? [] -} - -/** Keep the UI-only live listing when replacing cache with persisted metadata. */ -export function withLiveFolderFiles( - persisted: MediaMetadata, - previous: MediaMetadataWithFolderFiles | null | undefined, - incoming?: MediaMetadataWithFolderFiles, -): MediaMetadataWithFolderFiles { - const files = previous?.files ?? incoming?.files - if (files === undefined) { - return persisted - } - return { ...persisted, files } -} +/** @deprecated Use `MediaMetadata` directly. */ +export type MediaMetadataWithFolderFiles = MediaMetadata export async function listMediaFolderFilePaths( folderPath: string, @@ -43,19 +25,12 @@ export async function listMediaFolderFilePaths( return result.data.items.map((item) => Path.posix(item.path)) } -/** Attach live folder file paths to persisted metadata for UI consumers. */ -export async function hydrateMediaMetadataWithFolderFiles( - metadata: MediaMetadata, - signal?: AbortSignal, -): Promise { - const folderPath = metadata.mediaFolderPath - if (!folderPath) { - return metadata - } - try { - const files = await listMediaFolderFilePaths(folderPath, signal) - return { ...metadata, files } - } catch { - return metadata +/** Shared TanStack Query options for live folder file listings (`associatedFiles` cache). */ +export function mediaFolderFilesReadQueryOptions(folderPath: string) { + const folderPathPosix = Path.posix(folderPath) + return { + queryKey: associatedFilesQueryKey(folderPathPosix), + queryFn: async ({ signal }: { signal?: AbortSignal } = {}) => + listMediaFolderFilePaths(folderPathPosix, signal), } } diff --git a/apps/ui/src/lib/mediaMetadataQueryKeys.ts b/apps/ui/src/lib/mediaMetadataQueryKeys.ts index 9b70b490..86529540 100644 --- a/apps/ui/src/lib/mediaMetadataQueryKeys.ts +++ b/apps/ui/src/lib/mediaMetadataQueryKeys.ts @@ -2,11 +2,6 @@ import { Path } from "@smm/utils/path" import type { MediaMetadata } from "@smm/types" import type { QueryClient } from "@tanstack/react-query" import { getMetadata } from "@/api/metadata" -import { - hydrateMediaMetadataWithFolderFiles, - withLiveFolderFiles, - type MediaMetadataWithFolderFiles, -} from "@/lib/mediaFolderFiles" /** TanStack Query keys for per-folder persisted metadata. */ export function mediaMetadataQueryKey(folderPathPosix: string) { @@ -18,20 +13,13 @@ export function normalizeMediaFolderPathForQuery(path: string): string { return Path.posix(path) } -/** - * Write persisted metadata into the query cache without dropping the - * UI-only live folder listing (`files`). - */ +/** Write persisted metadata into the query cache. */ export function setPersistedMetadataQueryData( queryClient: QueryClient, folderPathPosix: string, persisted: MediaMetadata, - incoming?: MediaMetadata, ): void { - const key = mediaMetadataQueryKey(folderPathPosix) - queryClient.setQueryData(key, (prev) => - withLiveFolderFiles(persisted, prev, incoming), - ) + queryClient.setQueryData(mediaMetadataQueryKey(folderPathPosix), persisted) } /** Shared options for `useQuery` / `queryClient.fetchQuery` so cache identity matches. */ @@ -39,9 +27,8 @@ export function mediaMetadataReadQueryOptions(path: string) { const folderPathPosix = normalizeMediaFolderPathForQuery(path) return { queryKey: mediaMetadataQueryKey(folderPathPosix), - queryFn: async ({ signal }: { signal?: AbortSignal } = {}): Promise => { - const metadata = await getMetadata(folderPathPosix, signal) - return hydrateMediaMetadataWithFolderFiles(metadata, signal) + queryFn: async ({ signal }: { signal?: AbortSignal } = {}): Promise => { + return getMetadata(folderPathPosix, signal) }, } } diff --git a/apps/ui/src/lib/mediaMetadataRefreshUtils.ts b/apps/ui/src/lib/mediaMetadataRefreshUtils.ts index 198da8c8..e8579f49 100644 --- a/apps/ui/src/lib/mediaMetadataRefreshUtils.ts +++ b/apps/ui/src/lib/mediaMetadataRefreshUtils.ts @@ -1,14 +1,8 @@ -import type { MediaMetadataWithFolderFiles } from '@/lib/mediaFolderFiles' import type { MediaMetadata } from '@smm/types' export function mergeRefreshedMetadata( response: MediaMetadata, - currentMediaMetadata: MediaMetadataWithFolderFiles | undefined -): MediaMetadataWithFolderFiles { - if (!currentMediaMetadata) { - return response - } - - const { files } = currentMediaMetadata - return files !== undefined ? { ...response, files } : response + _currentMediaMetadata: MediaMetadata | undefined, +): MediaMetadata { + return response } diff --git a/apps/ui/src/lib/mediaMetadataUtils.test.ts b/apps/ui/src/lib/mediaMetadataUtils.test.ts index f006e101..64906e7c 100644 --- a/apps/ui/src/lib/mediaMetadataUtils.test.ts +++ b/apps/ui/src/lib/mediaMetadataUtils.test.ts @@ -1,187 +1,40 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect } from 'vitest' import { createInitialMediaMetadata, findUpdatedMediaMetadata } from './mediaMetadataUtils' -vi.mock('@/api/listFiles', () => ({ - listFiles: vi.fn(), -})) - -import { listFiles } from '@/api/listFiles' - describe('createInitialMediaMetadata', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should return metadata with mediaFolderPath, status, type, and files', async () => { + it('should return persisted metadata shell for a folder', async () => { const folderPath = '/media/tvshows/Test Show' const type = 'tvshow-folder' as const - const mockFiles = [ - '/media/tvshows/Test Show/episode1.mkv', - '/media/tvshows/Test Show/episode2.mkv', - ] - - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: folderPath, - items: mockFiles.map(path => ({ - path, - size: 0, - mtime: 0, - isDirectory: false, - })), - size: mockFiles.length, - }, - error: undefined, - }) const result = await createInitialMediaMetadata(folderPath, type) - expect(result.mediaFolderPath).toBeDefined() expect(result.mediaFolderPath).toBe(folderPath) - expect(result.status).toBeDefined() - expect(result.status).toBe('idle') - expect(result.type).toBeDefined() expect(result.type).toBe(type) - expect(result.files).toBeDefined() - expect(result.files).toEqual(mockFiles) - }) - - it('should throw error when listFiles API returns error', async () => { - const folderPath = '/media/tvshows/Test Show' - const type = 'tvshow-folder' as const - - vi.mocked(listFiles).mockResolvedValue({ - data: undefined, - error: 'Permission denied', - }) - - await expect(createInitialMediaMetadata(folderPath, type)).rejects.toThrow('Failed to list files: Permission denied') - }) - - it('should throw error when listFiles API returns undefined data', async () => { - const folderPath = '/media/tvshows/Test Show' - const type = 'tvshow-folder' as const - - vi.mocked(listFiles).mockResolvedValue({ - data: undefined, - error: undefined, - }) - - await expect(createInitialMediaMetadata(folderPath, type)).rejects.toThrow('Failed to list files: response.data is undefined') }) - it('should convert Windows local file paths to POSIX format', async () => { + it('should convert Windows local folder paths to POSIX format', async () => { const folderPath = 'C:\\media\\tvshows\\Test Show' const type = 'tvshow-folder' as const - const mockWindowsFiles = [ - 'C:\\media\\tvshows\\Test Show\\episode1.mkv', - 'C:\\media\\tvshows\\Test Show\\episode2.mkv', - ] - const expectedPosixFiles = [ - '/C/media/tvshows/Test Show/episode1.mkv', - '/C/media/tvshows/Test Show/episode2.mkv', - ] - - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: folderPath, - items: mockWindowsFiles.map(path => ({ - path, - size: 0, - mtime: 0, - isDirectory: false, - })), - size: mockWindowsFiles.length, - }, - error: undefined, - }) const result = await createInitialMediaMetadata(folderPath, type) - expect(result.mediaFolderPath).toBeDefined() expect(result.mediaFolderPath).toBe('/C/media/tvshows/Test Show') - expect(result.status).toBeDefined() - expect(result.status).toBe('idle') - expect(result.type).toBeDefined() expect(result.type).toBe(type) - expect(result.files).toBeDefined() - expect(result.files).toEqual(expectedPosixFiles) }) - it('should convert Windows network paths to POSIX format', async () => { + it('should convert Windows network folder paths to POSIX format', async () => { const folderPath = '\\\\nas.local\\share\\media\\tvshows\\Test Show' const type = 'tvshow-folder' as const - const mockNetworkFiles = [ - '\\\\nas.local\\share\\media\\tvshows\\Test Show\\episode1.mkv', - '\\\\nas.local\\share\\media\\tvshows\\Test Show\\episode2.mkv', - ] - const expectedPosixFiles = [ - '/nas.local/share/media/tvshows/Test Show/episode1.mkv', - '/nas.local/share/media/tvshows/Test Show/episode2.mkv', - ] - - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: folderPath, - items: mockNetworkFiles.map(path => ({ - path, - size: 0, - mtime: 0, - isDirectory: false, - })), - size: mockNetworkFiles.length, - }, - error: undefined, - }) const result = await createInitialMediaMetadata(folderPath, type) - expect(result.mediaFolderPath).toBeDefined() expect(result.mediaFolderPath).toBe('/nas.local/share/media/tvshows/Test Show') - expect(result.status).toBeDefined() - expect(result.status).toBe('idle') - expect(result.type).toBeDefined() expect(result.type).toBe(type) - expect(result.files).toBeDefined() - expect(result.files).toEqual(expectedPosixFiles) - }) - - it('should pass abortSignal to listFiles', async () => { - const folderPath = '/media/tvshows/Test Show' - const type = 'tvshow-folder' as const - const mockFiles = [ - '/media/tvshows/Test Show/episode1.mkv', - ] - const abortSignal = new AbortController().signal - - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: folderPath, - items: mockFiles.map(path => ({ - path, - size: 0, - mtime: 0, - isDirectory: false, - })), - size: mockFiles.length, - }, - error: undefined, - }) - - await createInitialMediaMetadata(folderPath, type, { abortSignal }) - - expect(listFiles).toHaveBeenCalledWith( - { path: folderPath, recursively: true, onlyFiles: true }, - abortSignal - ) }) it('should merge mediaMetadataProps into result', async () => { const folderPath = '/media/tvshows/Test Show' const type = 'tvshow-folder' as const - const mockFiles = [ - '/media/tvshows/Test Show/episode1.mkv', - ] const mediaMetadataProps = { tvShow: { id: '12345', @@ -191,20 +44,6 @@ describe('createInitialMediaMetadata', () => { }, } - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: folderPath, - items: mockFiles.map(path => ({ - path, - size: 0, - mtime: 0, - isDirectory: false, - })), - size: mockFiles.length, - }, - error: undefined, - }) - const result = await createInitialMediaMetadata(folderPath, type, { mediaMetadataProps }) expect(result.tvShow?.name).toBe('Custom Name') @@ -216,58 +55,21 @@ describe('createInitialMediaMetadata', () => { it('should work with music-folder type', async () => { const folderPath = '/media/music/Album' const type = 'music-folder' as const - const mockFiles = [ - '/media/music/Album/song1.mp3', - '/media/music/Album/song2.mp3', - ] - - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: folderPath, - items: mockFiles.map(path => ({ - path, - size: 0, - mtime: 0, - isDirectory: false, - })), - size: mockFiles.length, - }, - error: undefined, - }) const result = await createInitialMediaMetadata(folderPath, type) expect(result.type).toBe(type) expect(result.mediaFolderPath).toBe(folderPath) - expect(result.status).toBe('idle') }) it('should work with movie-folder type', async () => { const folderPath = '/media/movies/Movie' const type = 'movie-folder' as const - const mockFiles = [ - '/media/movies/Movie/movie.mkv', - ] - - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: folderPath, - items: mockFiles.map(path => ({ - path, - size: 0, - mtime: 0, - isDirectory: false, - })), - size: mockFiles.length, - }, - error: undefined, - }) const result = await createInitialMediaMetadata(folderPath, type) expect(result.type).toBe(type) expect(result.mediaFolderPath).toBe(folderPath) - expect(result.status).toBe('idle') }) }) @@ -285,54 +87,24 @@ describe('findUpdatedMediaMetadata', () => { it('should return all new items when old array is empty', () => { const newItems = [ { mediaFolderPath: '/media/show1', type: 'tvshow-folder' as const }, - { mediaFolderPath: '/media/show2', type: 'tvshow-folder' as const }, ] - const result = findUpdatedMediaMetadata([], newItems) - expect(result).toEqual(newItems) }) it('should return empty array when new array is empty', () => { const oldItems = [ { mediaFolderPath: '/media/show1', type: 'tvshow-folder' as const }, - { mediaFolderPath: '/media/show2', type: 'tvshow-folder' as const }, ] - const result = findUpdatedMediaMetadata(oldItems, []) - expect(result).toEqual([]) }) it('should return empty array when all items are identical', () => { - const oldItems = [ - { - mediaFolderPath: '/media/show1', - type: 'tvshow-folder' as const, - tvShow: { id: '1', name: 'Show 1', database: 'TMDB' as const, seasons: [] }, - }, - { - mediaFolderPath: '/media/show2', - type: 'tvshow-folder' as const, - tvShow: { id: '2', name: 'Show 2', database: 'TMDB' as const, seasons: [] }, - }, - ] - - const newItems = [ - { - mediaFolderPath: '/media/show1', - type: 'tvshow-folder' as const, - tvShow: { id: '1', name: 'Show 1', database: 'TMDB' as const, seasons: [] }, - }, - { - mediaFolderPath: '/media/show2', - type: 'tvshow-folder' as const, - tvShow: { id: '2', name: 'Show 2', database: 'TMDB' as const, seasons: [] }, - }, + const items = [ + { mediaFolderPath: '/media/show1', type: 'tvshow-folder' as const }, ] - - const result = findUpdatedMediaMetadata(oldItems, newItems) - + const result = findUpdatedMediaMetadata(items, items) expect(result).toEqual([]) }) @@ -344,7 +116,6 @@ describe('findUpdatedMediaMetadata', () => { tvShow: { id: '1', name: 'Old Name', database: 'TMDB' as const, seasons: [] }, }, ] - const newItems = [ { mediaFolderPath: '/media/show1', @@ -352,9 +123,7 @@ describe('findUpdatedMediaMetadata', () => { tvShow: { id: '1', name: 'New Name', database: 'TMDB' as const, seasons: [] }, }, ] - const result = findUpdatedMediaMetadata(oldItems, newItems) - expect(result).toEqual(newItems) }) @@ -363,25 +132,22 @@ describe('findUpdatedMediaMetadata', () => { { mediaFolderPath: '/media/show1', type: 'tvshow-folder' as const, - tvShow: { id: '1', name: 'Show 1', database: 'TMDB' as const, seasons: [] }, + tvShow: { id: '1', name: 'Show', database: 'TMDB' as const, seasons: [] }, }, ] - const newItems = [ { mediaFolderPath: '/media/show1', type: 'tvshow-folder' as const, tvShow: { id: '1', - name: 'Show 1', + name: 'Show', database: 'TMDB' as const, seasons: [{ season: 1, name: 'Season 1', episodes: [] }], }, }, ] - const result = findUpdatedMediaMetadata(oldItems, newItems) - expect(result).toEqual(newItems) }) @@ -390,20 +156,17 @@ describe('findUpdatedMediaMetadata', () => { { mediaFolderPath: '/media/movie1', type: 'movie-folder' as const, - movie: { id: '1', name: 'Movie 1', database: 'TMDB' as const }, + movie: { id: '1', name: 'Old Movie', database: 'TMDB' as const }, }, ] - const newItems = [ { mediaFolderPath: '/media/movie1', type: 'movie-folder' as const, - movie: { id: '1', name: 'Movie 1 Updated', database: 'TMDB' as const }, + movie: { id: '1', name: 'New Movie', database: 'TMDB' as const }, }, ] - const result = findUpdatedMediaMetadata(oldItems, newItems) - expect(result).toEqual(newItems) }) @@ -412,65 +175,28 @@ describe('findUpdatedMediaMetadata', () => { { mediaFolderPath: '/media/show1', type: 'tvshow-folder' as const, - mediaFiles: [{ absolutePath: '/media/show1/episode1.mkv', seasonNumber: 1, episodeNumber: 1 }], - }, - ] - - const newItems = [ - { - mediaFolderPath: '/media/show1', - type: 'tvshow-folder' as const, - mediaFiles: [ - { absolutePath: '/media/show1/episode1.mkv', seasonNumber: 1, episodeNumber: 1 }, - { absolutePath: '/media/show1/episode2.mkv', seasonNumber: 1, episodeNumber: 2 }, - ], - }, - ] - - const result = findUpdatedMediaMetadata(oldItems, newItems) - - expect(result).toEqual(newItems) - }) - - it('should detect changed files list', () => { - const oldItems = [ - { - mediaFolderPath: '/media/show1', - type: 'tvshow-folder' as const, - files: ['/media/show1/a.mkv'], + mediaFiles: [{ absolutePath: '/media/show1/a.mkv', seasonNumber: 1, episodeNumber: 1 }], }, ] - const newItems = [ { mediaFolderPath: '/media/show1', type: 'tvshow-folder' as const, - files: ['/media/show1/b.mkv'], + mediaFiles: [{ absolutePath: '/media/show1/b.mkv', seasonNumber: 1, episodeNumber: 1 }], }, ] - const result = findUpdatedMediaMetadata(oldItems, newItems) - expect(result).toEqual(newItems) }) it('should detect changed type', () => { const oldItems = [ - { - mediaFolderPath: '/media/show1', - type: 'tvshow-folder' as const, - }, + { mediaFolderPath: '/media/show1', type: 'tvshow-folder' as const }, ] - const newItems = [ - { - mediaFolderPath: '/media/show1', - type: 'movie-folder' as const, - }, + { mediaFolderPath: '/media/show1', type: 'movie-folder' as const }, ] - const result = findUpdatedMediaMetadata(oldItems, newItems) - expect(result).toEqual(newItems) }) @@ -478,30 +204,22 @@ describe('findUpdatedMediaMetadata', () => { const oldItems = [ { mediaFolderPath: '/media/show1', type: 'tvshow-folder' as const }, ] - const newItems = [ { mediaFolderPath: '/media/show1', type: 'tvshow-folder' as const }, { mediaFolderPath: '/media/show2', type: 'tvshow-folder' as const }, ] - const result = findUpdatedMediaMetadata(oldItems, newItems) - - expect(result).toEqual([ - { mediaFolderPath: '/media/show2', type: 'tvshow-folder' as const }, - ]) + expect(result).toEqual([newItems[1]]) }) it('should handle items with no mediaFolderPath in old array', () => { const oldItems = [ { type: 'tvshow-folder' as const }, ] - const newItems = [ { mediaFolderPath: '/media/show1', type: 'tvshow-folder' as const }, ] - const result = findUpdatedMediaMetadata(oldItems, newItems) - expect(result).toEqual(newItems) }) @@ -509,193 +227,83 @@ describe('findUpdatedMediaMetadata', () => { const oldItems = [ { mediaFolderPath: '/media/show1', type: 'tvshow-folder' as const }, ] - const newItems = [ { type: 'tvshow-folder' as const }, ] - const result = findUpdatedMediaMetadata(oldItems, newItems) - expect(result).toEqual([]) }) it('should return multiple changed items', () => { const oldItems = [ - { - mediaFolderPath: '/media/show1', - type: 'tvshow-folder' as const, - tvShow: { id: '1', name: 'Show 1', database: 'TMDB' as const, seasons: [] }, - }, - { - mediaFolderPath: '/media/show2', - type: 'tvshow-folder' as const, - tvShow: { id: '2', name: 'Show 2', database: 'TMDB' as const, seasons: [] }, - }, - { - mediaFolderPath: '/media/show3', - type: 'tvshow-folder' as const, - tvShow: { id: '3', name: 'Show 3', database: 'TMDB' as const, seasons: [] }, - }, + { mediaFolderPath: '/media/show1', type: 'tvshow-folder' as const }, + { mediaFolderPath: '/media/show2', type: 'tvshow-folder' as const }, ] - const newItems = [ - { - mediaFolderPath: '/media/show1', - type: 'tvshow-folder' as const, - tvShow: { id: '1', name: 'Show 1', database: 'TMDB' as const, seasons: [] }, - }, - { - mediaFolderPath: '/media/show2', - type: 'tvshow-folder' as const, - tvShow: { id: '2', name: 'Changed Show 2', database: 'TMDB' as const, seasons: [] }, - }, - { - mediaFolderPath: '/media/show3', - type: 'tvshow-folder' as const, - tvShow: { id: '3', name: 'Changed Show 3', database: 'TMDB' as const, seasons: [] }, - }, + { mediaFolderPath: '/media/show1', type: 'movie-folder' as const }, + { mediaFolderPath: '/media/show2', type: 'tvshow-folder' as const }, ] - const result = findUpdatedMediaMetadata(oldItems, newItems) - - expect(result).toEqual([ - { - mediaFolderPath: '/media/show2', - type: 'tvshow-folder' as const, - tvShow: { id: '2', name: 'Changed Show 2', database: 'TMDB' as const, seasons: [] }, - }, - { - mediaFolderPath: '/media/show3', - type: 'tvshow-folder' as const, - tvShow: { id: '3', name: 'Changed Show 3', database: 'TMDB' as const, seasons: [] }, - }, - ]) + expect(result).toEqual([newItems[0]]) }) it('should detect changes when tvShow changes from undefined to object', () => { const oldItems = [ - { - mediaFolderPath: '/media/show1', - type: 'tvshow-folder' as const, - }, + { mediaFolderPath: '/media/show1', type: 'tvshow-folder' as const }, ] - const newItems = [ { mediaFolderPath: '/media/show1', type: 'tvshow-folder' as const, - tvShow: { id: '1', name: 'Show 1', database: 'TMDB' as const, seasons: [] }, + tvShow: { id: '1', name: 'Show', database: 'TMDB' as const, seasons: [] }, }, ] - const result = findUpdatedMediaMetadata(oldItems, newItems) - expect(result).toEqual(newItems) }) it('should detect changes when movie changes from undefined to object', () => { const oldItems = [ - { - mediaFolderPath: '/media/movie1', - type: 'movie-folder' as const, - }, + { mediaFolderPath: '/media/movie1', type: 'movie-folder' as const }, ] - const newItems = [ { mediaFolderPath: '/media/movie1', type: 'movie-folder' as const, - movie: { id: '1', name: 'Movie 1', database: 'TMDB' as const }, + movie: { id: '1', name: 'Movie', database: 'TMDB' as const }, }, ] - const result = findUpdatedMediaMetadata(oldItems, newItems) - expect(result).toEqual(newItems) }) it('should detect changes when mediaFiles changes from undefined to array', () => { const oldItems = [ - { - mediaFolderPath: '/media/show1', - type: 'tvshow-folder' as const, - mediaFiles: undefined, - }, + { mediaFolderPath: '/media/show1', type: 'tvshow-folder' as const }, ] - const newItems = [ { mediaFolderPath: '/media/show1', type: 'tvshow-folder' as const, - mediaFiles: [{ absolutePath: '/media/show1/episode1.mkv', seasonNumber: 1, episodeNumber: 1 }], + mediaFiles: [{ absolutePath: '/media/show1/a.mkv', seasonNumber: 1, episodeNumber: 1 }], }, ] - const result = findUpdatedMediaMetadata(oldItems, newItems) - expect(result).toEqual(newItems) }) it('should handle mixed scenarios with some items changed and some unchanged', () => { const oldItems = [ - { - mediaFolderPath: '/media/show1', - type: 'tvshow-folder' as const, - tvShow: { id: '1', name: 'Show 1', database: 'TMDB' as const, seasons: [] }, - }, - { - mediaFolderPath: '/media/show2', - type: 'tvshow-folder' as const, - tvShow: { id: '2', name: 'Show 2', database: 'TMDB' as const, seasons: [] }, - }, - { - mediaFolderPath: '/media/show3', - type: 'tvshow-folder' as const, - tvShow: { id: '3', name: 'Show 3', database: 'TMDB' as const, seasons: [] }, - }, - { - mediaFolderPath: '/media/show4', - type: 'tvshow-folder' as const, - tvShow: { id: '4', name: 'Show 4', database: 'TMDB' as const, seasons: [] }, - }, + { mediaFolderPath: '/media/show1', type: 'tvshow-folder' as const }, + { mediaFolderPath: '/media/show2', type: 'tvshow-folder' as const }, + { mediaFolderPath: '/media/show3', type: 'tvshow-folder' as const }, ] - const newItems = [ - { - mediaFolderPath: '/media/show1', - type: 'tvshow-folder' as const, - tvShow: { id: '1', name: 'Show 1', database: 'TMDB' as const, seasons: [] }, - }, - { - mediaFolderPath: '/media/show2', - type: 'tvshow-folder' as const, - tvShow: { id: '2', name: 'Changed Show 2', database: 'TMDB' as const, seasons: [] }, - }, - { - mediaFolderPath: '/media/show3', - type: 'tvshow-folder' as const, - tvShow: { id: '3', name: 'Show 3', database: 'TMDB' as const, seasons: [] }, - }, - { - mediaFolderPath: '/media/show5', - type: 'tvshow-folder' as const, - tvShow: { id: '5', name: 'Show 5', database: 'TMDB' as const, seasons: [] }, - }, + { mediaFolderPath: '/media/show1', type: 'tvshow-folder' as const }, + { mediaFolderPath: '/media/show2', type: 'movie-folder' as const }, + { mediaFolderPath: '/media/show4', type: 'tvshow-folder' as const }, ] - const result = findUpdatedMediaMetadata(oldItems, newItems) - - expect(result).toEqual([ - { - mediaFolderPath: '/media/show2', - type: 'tvshow-folder' as const, - tvShow: { id: '2', name: 'Changed Show 2', database: 'TMDB' as const, seasons: [] }, - }, - { - mediaFolderPath: '/media/show5', - type: 'tvshow-folder' as const, - tvShow: { id: '5', name: 'Show 5', database: 'TMDB' as const, seasons: [] }, - }, - ]) + expect(result).toEqual([newItems[1], newItems[2]]) }) }) diff --git a/apps/ui/src/lib/mediaMetadataUtils.ts b/apps/ui/src/lib/mediaMetadataUtils.ts index f85f09df..c4a8404a 100644 --- a/apps/ui/src/lib/mediaMetadataUtils.ts +++ b/apps/ui/src/lib/mediaMetadataUtils.ts @@ -1,24 +1,16 @@ -import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" -import { listMediaFolderFilePaths } from "@/lib/mediaFolderFiles" +import type { MediaMetadata } from "@/lib/mediaFolderFiles" import { createMediaMetadata } from "@smm/core/mediaMetadata" -import type { MediaMetadata } from "@smm/types" +import type { MediaMetadata as PersistedMediaMetadata } from "@smm/types" export async function createInitialMediaMetadata( folderPathInPlatformFormat: string, type: "music-folder" | "tvshow-folder" | "movie-folder", - options?: { traceId?: string, abortSignal?: AbortSignal, mediaMetadataProps?: Partial } -): Promise { - - const mm: MediaMetadataWithFolderFiles = { + options?: { traceId?: string, abortSignal?: AbortSignal, mediaMetadataProps?: Partial } +): Promise { + return { ...createMediaMetadata(folderPathInPlatformFormat, type), ...options?.mediaMetadataProps, - files: [], - }; - - const files = await listMediaFolderFilePaths(folderPathInPlatformFormat, options?.abortSignal) - mm.files = files; - - return mm; + } } /** @@ -29,9 +21,9 @@ export async function createInitialMediaMetadata( * @param newItems * @returns */ -export function findUpdatedMediaMetadata(old: MediaMetadataWithFolderFiles[], newItems: MediaMetadataWithFolderFiles[]): MediaMetadataWithFolderFiles[] { +export function findUpdatedMediaMetadata(old: MediaMetadata[], newItems: MediaMetadata[]): MediaMetadata[] { const oldByPath = new Map(old.filter(m => m.mediaFolderPath).map(m => [m.mediaFolderPath!, m])); - const updated: MediaMetadataWithFolderFiles[] = []; + const updated: MediaMetadata[] = []; for (const item of newItems) { const path = item.mediaFolderPath; @@ -46,9 +38,8 @@ export function findUpdatedMediaMetadata(old: MediaMetadataWithFolderFiles[], ne } // Compare relevant metadata fields - const fieldsToCompare: (keyof MediaMetadataWithFolderFiles)[] = [ + const fieldsToCompare: (keyof MediaMetadata)[] = [ 'mediaFolderPath', - 'files', 'tvShow', 'movie', 'mediaFiles', diff --git a/apps/ui/src/lib/music.ts b/apps/ui/src/lib/music.ts index 77214c88..3a30dde2 100644 --- a/apps/ui/src/lib/music.ts +++ b/apps/ui/src/lib/music.ts @@ -1,15 +1,14 @@ import type { MusicFileProps, MusicMediaMetadata } from "@/types/MusicMediaMetadata"; -import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" -import { getMediaFolderFiles } from "@/lib/mediaFolderFiles" +import type { MediaMetadata } from "@/lib/mediaFolderFiles" import type { Track } from "@/components/MediaPlayer"; import { Path } from "@smm/utils/path"; import { extensions } from "@smm/types/mediaFileExtensions"; import { pathToFileURL } from "@smm/utils/url"; -export function newMusicMediaMetadata(mm: MediaMetadataWithFolderFiles): MusicMediaMetadata { +export function newMusicMediaMetadata(mm: MediaMetadata, folderFiles: string[]): MusicMediaMetadata { return { ...mm, - musicFiles: buildMusicFilePropsArray(getMediaFolderFiles(mm)), + musicFiles: buildMusicFilePropsArray(folderFiles), } } diff --git a/apps/ui/src/lib/recognizeEpisodes.ts b/apps/ui/src/lib/recognizeEpisodes.ts index 688a44ef..d7812416 100644 --- a/apps/ui/src/lib/recognizeEpisodes.ts +++ b/apps/ui/src/lib/recognizeEpisodes.ts @@ -2,7 +2,6 @@ import { uniq } from 'es-toolkit'; import { extname, basename } from './path'; import { videoFileExtensions } from './utils'; import type { MediaMetadataWithFolderFiles } from '@/lib/mediaFolderFiles'; -import { getMediaFolderFiles } from '@/lib/mediaFolderFiles'; export interface RecognizedEpisode { season: number, @@ -130,9 +129,7 @@ export function pattern4( } export function buildEpisodes(mm: MediaMetadataWithFolderFiles): { season: number, episode: number }[] { - const files = getMediaFolderFiles(mm) - if( files.length === 0 - || mm.tvShow === undefined + if(mm.tvShow === undefined || mm.tvShow.seasons === undefined || mm.tvShow.seasons.length === 0 || mm.tvShow.seasons[0].episodes === undefined @@ -217,16 +214,16 @@ export function excludeFiles(files: string[]) { */ export function recognizeEpisodes( mm: MediaMetadataWithFolderFiles, + folderFiles: string[], ): RecognizedEpisode[] { const startTime = performance.now(); - const files = getMediaFolderFiles(mm) console.log('[recognize] start episode matching', { mediaFolderPath: mm.mediaFolderPath, - fileCount: files.length, + fileCount: folderFiles.length, }) - if( files.length === 0 + if( folderFiles.length === 0 || mm.tvShow === undefined || mm.tvShow.seasons === undefined || mm.tvShow.seasons.length === 0 @@ -238,7 +235,7 @@ export function recognizeEpisodes( try { - let videoFiles = files.filter(isVideoFile); + let videoFiles = folderFiles.filter(isVideoFile); videoFiles = excludeFiles(videoFiles); if(videoFiles.length === 0) { @@ -281,10 +278,13 @@ type WorkerMessage = { type: 'result'; id: number; payload: RecognizedEpisode[] * Run recognizeEpisodes in a Web Worker to avoid blocking the main thread. * Uses a singleton worker; concurrent calls are serialized. */ -export function recognizeEpisodesAsync(mm: MediaMetadataWithFolderFiles): Promise { +export function recognizeEpisodesAsync( + mm: MediaMetadataWithFolderFiles, + folderFiles: string[], +): Promise { console.log('[recognize] recognizeEpisodesAsync started', { mediaFolderPath: mm.mediaFolderPath, - fileCount: getMediaFolderFiles(mm).length, + fileCount: folderFiles.length, }) return new Promise((resolve, reject) => { const id = nextRequestId++; @@ -325,7 +325,7 @@ export function recognizeEpisodesAsync(mm: MediaMetadataWithFolderFiles): Promis worker.addEventListener('message', onMessage); worker.addEventListener('error', onError); - worker.postMessage({ type: 'recognize', id, payload: mm }); + worker.postMessage({ type: 'recognize', id, payload: { mm, folderFiles } }); }); } diff --git a/apps/ui/src/lib/recognizeEpisodes.worker.ts b/apps/ui/src/lib/recognizeEpisodes.worker.ts index be3ad0f3..cd15871e 100644 --- a/apps/ui/src/lib/recognizeEpisodes.worker.ts +++ b/apps/ui/src/lib/recognizeEpisodes.worker.ts @@ -1,11 +1,15 @@ /** * Web Worker entry for recognizeEpisodes. - * Receives MediaMetadata via postMessage, runs recognition in this thread, posts back result. + * Receives MediaMetadata + folder file paths via postMessage, runs recognition in this thread, posts back result. */ import { recognizeEpisodes, type RecognizedEpisode } from "./recognizeEpisodesUi"; import type { MediaMetadata } from "@smm/types"; -export type WorkerRequest = { type: "recognize"; id: number; payload: MediaMetadata }; +export type WorkerRequest = { + type: "recognize"; + id: number; + payload: { mm: MediaMetadata; folderFiles: string[] }; +}; export type WorkerResult = { type: "result"; id: number; payload: RecognizedEpisode[] }; export type WorkerError = { type: "error"; id: number; message: string }; @@ -14,13 +18,13 @@ self.onmessage = (e: MessageEvent) => { if (msg?.type !== "recognize") { return; } - const { id, payload: mm } = msg; + const { id, payload } = msg; try { - const payload = recognizeEpisodes(mm); + const result = recognizeEpisodes(payload.mm, payload.folderFiles); (self as unknown as Worker).postMessage({ type: "result", id, - payload, + payload: result, } satisfies WorkerResult); } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/apps/ui/src/lib/recognizeEpisodesUi.ts b/apps/ui/src/lib/recognizeEpisodesUi.ts index b55c38c0..600bdaed 100644 --- a/apps/ui/src/lib/recognizeEpisodesUi.ts +++ b/apps/ui/src/lib/recognizeEpisodesUi.ts @@ -14,7 +14,6 @@ import { type RecognizedEpisode, } from "@smm/core/pipeline/recognizeEpisodes"; import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles"; -import { getMediaFolderFiles } from "@/lib/mediaFolderFiles"; export type { RecognizedEpisode }; export { @@ -30,9 +29,7 @@ export { export function buildEpisodes( mm: MediaMetadataWithFolderFiles, ): { season: number; episode: number }[] { - const files = getMediaFolderFiles(mm); if ( - files.length === 0 || mm.tvShow === undefined || mm.tvShow.seasons === undefined || mm.tvShow.seasons.length === 0 @@ -60,9 +57,11 @@ export function fuzzyRecognizeEpisodes( /** * Sync recognition using folder files from UI metadata. */ -export function recognizeEpisodes(mm: MediaMetadataWithFolderFiles): RecognizedEpisode[] { - const files = getMediaFolderFiles(mm); - return recognizeEpisodesPure(mm, files); +export function recognizeEpisodes( + mm: MediaMetadataWithFolderFiles, + folderFiles: string[], +): RecognizedEpisode[] { + return recognizeEpisodesPure(mm, folderFiles); } /** Request id for matching worker responses when using a singleton worker */ @@ -78,6 +77,7 @@ type WorkerMessage = */ export function recognizeEpisodesAsync( mm: MediaMetadataWithFolderFiles, + folderFiles: string[], ): Promise { return new Promise((resolve, reject) => { const id = nextRequestId++; @@ -103,7 +103,7 @@ export function recognizeEpisodesAsync( worker.addEventListener("message", onMessage); worker.addEventListener("error", onError); - worker.postMessage({ type: "recognize", id, payload: mm }); + worker.postMessage({ type: "recognize", id, payload: { mm, folderFiles } }); }); } diff --git a/apps/ui/src/lib/sidebarRowUtils.test.ts b/apps/ui/src/lib/sidebarRowUtils.test.ts index 3aafb0dd..7e49bafc 100644 --- a/apps/ui/src/lib/sidebarRowUtils.test.ts +++ b/apps/ui/src/lib/sidebarRowUtils.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest" -import { buildMediaFolderListItemPropsFromFolderAndMetadata } from "./sidebarRowUtils" +import { + buildMediaFolderListItemPropsFromFolderAndMetadata, + mediaTypeFromMetadataType, +} from "./sidebarRowUtils" describe("buildMediaFolderListItemPropsFromFolderAndMetadata", () => { it("passes through pending_for_initialization status", () => { @@ -38,3 +41,15 @@ describe("buildMediaFolderListItemPropsFromFolderAndMetadata", () => { expect(row.status).toBe("loading") }) }) + +describe("mediaTypeFromMetadataType", () => { + it("maps raw folder metadata types to plain media types", () => { + expect(mediaTypeFromMetadataType("tvshow-folder")).toBe("tvshow") + expect(mediaTypeFromMetadataType("movie-folder")).toBe("movie") + expect(mediaTypeFromMetadataType("music-folder")).toBe("music") + }) + + it("returns undefined for untyped folders", () => { + expect(mediaTypeFromMetadataType(undefined)).toBeUndefined() + }) +}) diff --git a/apps/ui/src/lib/sidebarRowUtils.ts b/apps/ui/src/lib/sidebarRowUtils.ts index 62072752..2e5874aa 100644 --- a/apps/ui/src/lib/sidebarRowUtils.ts +++ b/apps/ui/src/lib/sidebarRowUtils.ts @@ -10,12 +10,23 @@ function displayNameFromMetadata(metadata: MediaMetadata | undefined, path: stri return basename(metadata.mediaFolderPath ?? path) || "未识别媒体名称" } +/** + * Map a raw media metadata type (`*-folder`) to the plain media type used by + * sidebar filters and row props. Returns `undefined` for untyped folders so + * they are excluded from type-specific filters. + */ +export function mediaTypeFromMetadataType( + type: MediaMetadata["type"] | undefined, +): FolderListItemProps["mediaType"] | undefined { + if (!type) return undefined + if (type === "tvshow-folder") return "tvshow" + if (type === "music-folder") return "music" + if (type === "movie-folder") return "movie" + return undefined +} + function mediaTypeFromMetadata(metadata: MediaMetadata | undefined): FolderListItemProps["mediaType"] { - if (!metadata?.type) return "movie" - if (metadata.type === "tvshow-folder") return "tvshow" - if (metadata.type === "music-folder") return "music" - if (metadata.type === "movie-folder") return "movie" - return "movie" + return mediaTypeFromMetadataType(metadata?.type) ?? "movie" } function mapFolderStatusToItemStatus( diff --git a/apps/ui/src/lib/utils.ts b/apps/ui/src/lib/utils.ts index 1d8b8095..d72c6deb 100644 --- a/apps/ui/src/lib/utils.ts +++ b/apps/ui/src/lib/utils.ts @@ -2,7 +2,6 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" import { type MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" -import { getMediaFolderFiles } from "@/lib/mediaFolderFiles" import type { MediaMetadata } from "@smm/types" import { type MediaFileMetadata, RenameRuleVariables, type RenameRule, type TMDBSeason } from "@smm/types" import { basename, relative, join, dirname } from "@/lib/path" @@ -141,7 +140,8 @@ export function buildTvShowEpisodesPropsFromMediaMetadata( tag: "VID", newPath: '' } - episodeProps.associatedFiles = findAssociatedFiles(mediaFolderPath!, getMediaFolderFiles(mediaMetadata), videoFilePath.absolutePath); + // Deprecated: folder file listing is no longer stored on metadata. + episodeProps.associatedFiles = findAssociatedFiles(mediaFolderPath!, [], videoFilePath.absolutePath); if(renameRule) { episodeProps.videoFilePath.newPath = generateNameByRenameRule(mediaMetadata, renameRule, videoFilePath) } diff --git a/apps/ui/src/stores/sidebarStore.test.ts b/apps/ui/src/stores/sidebarStore.test.ts new file mode 100644 index 00000000..0b8c3266 --- /dev/null +++ b/apps/ui/src/stores/sidebarStore.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeEach } from "vitest" +import { + compareByDisplayName, + sortPathsBySidebarDisplayOrder, + useSidebarStore, +} from "./sidebarStore" + +describe("compareByDisplayName", () => { + it("returns 0 when sortOrder is none", () => { + expect(compareByDisplayName("B", "A", "none")).toBe(0) + }) + + it("sorts ascending for alphabetical", () => { + expect(compareByDisplayName("A", "B", "alphabetical")).toBeLessThan(0) + }) + + it("sorts descending for reverse-alphabetical", () => { + expect(compareByDisplayName("A", "B", "reverse-alphabetical")).toBeGreaterThan(0) + }) +}) + +describe("sortPathsBySidebarDisplayOrder", () => { + beforeEach(() => { + useSidebarStore.setState({ sortOrder: "none", filterType: "all" }) + }) + + it("preserves input order when sortOrder is none", () => { + const paths = ["/z", "/a", "/m"] + expect(sortPathsBySidebarDisplayOrder(paths, (p) => p)).toEqual(["/z", "/a", "/m"]) + }) + + it("sorts by display name when alphabetical", () => { + useSidebarStore.setState({ sortOrder: "alphabetical" }) + const paths = ["/z", "/a", "/m"] + expect(sortPathsBySidebarDisplayOrder(paths, (p) => p.slice(1))).toEqual([ + "/a", + "/m", + "/z", + ]) + }) +}) diff --git a/apps/ui/src/stores/sidebarStore.ts b/apps/ui/src/stores/sidebarStore.ts index a20eff38..99721a0f 100644 --- a/apps/ui/src/stores/sidebarStore.ts +++ b/apps/ui/src/stores/sidebarStore.ts @@ -1,6 +1,6 @@ import { create } from "zustand" -export type SortOrder = "alphabetical" | "reverse-alphabetical" +export type SortOrder = "none" | "alphabetical" | "reverse-alphabetical" export type FilterType = "all" | "tvshow" | "movie" | "music" interface SidebarStoreState { @@ -16,7 +16,7 @@ interface SidebarStoreActions { type SidebarStore = SidebarStoreState & SidebarStoreActions const useSidebarStore = create((set) => ({ - sortOrder: "alphabetical", + sortOrder: "none", filterType: "all", setSortOrder: (order) => set({ sortOrder: order }), @@ -25,13 +25,15 @@ const useSidebarStore = create((set) => ({ /** * Compare two display names using the same logic as Sidebar list sort. - * Used by AppV2 (filteredAndSortedFolders) and MediaLibraryImportedEventHandler (init order). + * Used by AppV2 (folders) and MediaLibraryImportedEventHandler (init order). + * Returns 0 when sortOrder is "none" (callers should skip .sort() for original order). */ export function compareByDisplayName( nameA: string, nameB: string, sortOrder: SortOrder ): number { + if (sortOrder === "none") return 0 const comparison = nameA.localeCompare(nameB, undefined, { sensitivity: "base" }) return sortOrder === "alphabetical" ? comparison : -comparison } @@ -39,12 +41,14 @@ export function compareByDisplayName( /** * Sort paths by Sidebar display order (by display name, using current sortOrder from store). * Use this when the order of operations must match what the user sees in the Sidebar. + * When sortOrder is "none", returns paths in the given order (no reordering). */ export function sortPathsBySidebarDisplayOrder( paths: string[], getDisplayName: (path: string) => string ): string[] { const sortOrder = useSidebarStore.getState().sortOrder + if (sortOrder === "none") return [...paths] return [...paths].sort((a, b) => compareByDisplayName(getDisplayName(a), getDisplayName(b), sortOrder) ) diff --git a/apps/ui/src/types/UIMediaFolder.ts b/apps/ui/src/types/UIMediaFolder.ts new file mode 100644 index 00000000..43d1b3f4 --- /dev/null +++ b/apps/ui/src/types/UIMediaFolder.ts @@ -0,0 +1,25 @@ +/** + * Sidebar / folder list entry without embedding full {@link MediaMetadata}. + * Used by the future `UIMediaFolderStore` (Zustand). + */ +export type UIMediaFolderStatus = + | "idle" + | "pending_for_initialization" + | "initializing" + | "ok" + | "folder_not_found" + | "error_loading_metadata" + | "loading" + | "updating" + +export interface UIMediaFolder { + /** + * The path in platform-specific format + */ + path: string + status: UIMediaFolderStatus + /** Test-only folder; may be handled differently in UI logic. */ + test?: boolean + /** Media folder type, set during import to guide initial metadata loading */ + type?: "music-folder" | "tvshow-folder" | "movie-folder" +} diff --git a/apps/ui/src/types/i18next.d.ts b/apps/ui/src/types/i18next.d.ts index f35b93e4..63a9a584 100644 --- a/apps/ui/src/types/i18next.d.ts +++ b/apps/ui/src/types/i18next.d.ts @@ -70,6 +70,7 @@ interface ComponentsResources { toolbar: { sort: string filter: string + sortNone: string sortAlphabetical: string sortReverseAlphabetical: string filterAll: string From f9c160e112488b6de560aa4e6e902e75c0652e6c Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 1 Sep 2026 22:42:36 +0800 Subject: [PATCH 04/83] refactor: clean up Sidebar --- apps/e2e/common/other/ImportLibrary.e2e.ts | 206 ++++++++++++++++++ apps/ui/src/App.test.tsx | 1 + apps/ui/src/App.tsx | 11 +- apps/ui/src/components/movie/MoviePanel.tsx | 4 +- .../components/shared/MediaFolderToolbar.tsx | 2 +- .../components/sidebar/Sidebar.stories.tsx | 1 - .../src/components/sidebar/Sidebar.test.tsx | 20 -- apps/ui/src/components/sidebar/Sidebar.tsx | 11 +- apps/ui/src/components/tv/TvShowPanel.tsx | 3 + .../src/hooks/useRenameVideoFileFlow.test.ts | 36 ++- apps/ui/src/hooks/useRenameVideoFileFlow.ts | 23 +- apps/ui/src/hooks/useSidebar.ts | 33 +-- apps/ui/src/lib/sidebarSort.ts | 16 ++ apps/ui/src/stores/sidebarStore.test.ts | 41 ---- apps/ui/src/stores/sidebarStore.ts | 57 ----- 15 files changed, 290 insertions(+), 175 deletions(-) create mode 100644 apps/e2e/common/other/ImportLibrary.e2e.ts create mode 100644 apps/ui/src/lib/sidebarSort.ts delete mode 100644 apps/ui/src/stores/sidebarStore.test.ts delete mode 100644 apps/ui/src/stores/sidebarStore.ts diff --git a/apps/e2e/common/other/ImportLibrary.e2e.ts b/apps/e2e/common/other/ImportLibrary.e2e.ts new file mode 100644 index 00000000..db96a7b1 --- /dev/null +++ b/apps/e2e/common/other/ImportLibrary.e2e.ts @@ -0,0 +1,206 @@ +import { expect } from '@wdio/globals' +import { + setup, + cleanup, + expectMediaMetadataViaBrowser, +} from 'test/lib/testbed' +import { + clearFolderViaBrowser, + resolveSmmTestFolderViaBrowser, + listFilesViaBrowser, +} from 'test/lib/browser-fs' +import { given, then, resetStepContext, getStepContext } from 'test/lib/gherkin' +import 'test/steps' +import type { MediaMetadata } from '@smm/core/types' +import { Path } from '@smm/core' + +import { testbedOs } from 'test/lib/e2e-platform' + +/** + * UC1: import tvshow/movie/music libraries via Core import-library (Web UI / Electron / ohos). + * @supports local, Electron, HarmonyOS, Docker + */ +describe('Import Library', () => { + let testFolder = '' + + beforeEach(async () => { + resetStepContext() + await setup({ + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + removeDirInSidebar: true, + openBrowserPage: true, + resetUserConfig: (config) => { + config.primaryDatabase = 'TMDB' + config.preferMediaLanguage = 'zh-CN' + }, + os: testbedOs, + }) + + const { default: Page } = await import('test/pageobjects/page') + await Page.refresh() + + testFolder = await resolveSmmTestFolderViaBrowser() + await clearFolderViaBrowser(testFolder) + }) + + afterEach(async () => { + await cleanup({ + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + removeDirInSidebar: true, + resetUserConfig: true, + os: testbedOs, + }) + if (testFolder) { + await clearFolderViaBrowser(testFolder) + } + }) + + it('Import TV Show Library', async function () { + this.timeout(6 * 60 * 1000) + + await given('Media library was imported with TV show folders', { + base: testFolder, + }) + + const folders = getStepContext()._folders as Array<{ + folderName: string + path: string + type: string + }> + + await then('unknown folder has no tvshow metadata', async () => { + const f = folders.find((x) => x.folderName === 'UnknownFolder')! + await expectMediaMetadataViaBrowser(f.path, (obj) => { + const mm = obj as MediaMetadata + expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) + expect(mm.type).toBe('tvshow-folder') + expect(mm.tvShow).toBeUndefined() + return true + }) + }) + + await then('folder recognized by name has TMDB tvshow metadata', async () => { + const { folder1 } = await import('test/actions/import-folders') + const f = folders.find((x) => x.folderName === folder1.folderName)! + await expectMediaMetadataViaBrowser(f.path, (obj) => { + const mm = obj as MediaMetadata + expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) + expect(mm.type).toBe('tvshow-folder') + expect(mm.tvShow?.database).toBe('TMDB') + return true + }) + }) + + await then('folder recognized by tmdbid has TMDB tvshow metadata', async () => { + const f = folders.find((x) => x.folderName === '{tmdbid=84666}')! + await expectMediaMetadataViaBrowser(f.path, (obj) => { + const mm = obj as MediaMetadata + expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) + expect(mm.type).toBe('tvshow-folder') + expect(mm.tvShow?.database).toBe('TMDB') + return true + }) + }) + + await then('folder recognized by NFO has TMDB tvshow metadata', async () => { + const f = folders.find((x) => x.folderName === 'FolderContainsTvShowNfo')! + await expectMediaMetadataViaBrowser(f.path, (obj) => { + const mm = obj as MediaMetadata + expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) + expect(mm.type).toBe('tvshow-folder') + expect(mm.tvShow?.database).toBe('TMDB') + return true + }) + }) + }) + + it('Import Movie Library', async function () { + this.timeout(6 * 60 * 1000) + + await given('Media library was imported with movie folders', { + base: testFolder, + }) + + const folders = getStepContext()._folders as Array<{ + folderName: string + path: string + type: string + }> + + await then('unknown folder has no movie metadata', async () => { + const f = folders.find((x) => x.folderName === 'UnknownFolder')! + await expectMediaMetadataViaBrowser(f.path, (obj) => { + const mm = obj as MediaMetadata + expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) + expect(mm.type).toBe('movie-folder') + expect(mm.movie).toBeUndefined() + return true + }) + }) + + await then('folder recognized by name has TMDB movie metadata', async () => { + const { folder2 } = await import('test/actions/import-folders') + const f = folders.find((x) => x.folderName === folder2.folderName)! + await expectMediaMetadataViaBrowser(f.path, (obj) => { + const mm = obj as MediaMetadata + expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) + expect(mm.type).toBe('movie-folder') + expect(mm.movie?.database).toBe('TMDB') + return true + }) + }) + + await then('folder recognized by tmdbid has TMDB movie metadata', async () => { + const f = folders.find((x) => x.folderName === '{tmdbid=1539104}')! + await expectMediaMetadataViaBrowser(f.path, (obj) => { + const mm = obj as MediaMetadata + expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) + expect(mm.type).toBe('movie-folder') + expect(mm.movie?.database).toBe('TMDB') + return true + }) + }) + + await then('folder recognized by NFO has TMDB movie metadata', async () => { + const f = folders.find((x) => x.folderName === 'FolderContainsMovieNfo')! + await expectMediaMetadataViaBrowser(f.path, (obj) => { + const mm = obj as MediaMetadata + expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) + expect(mm.type).toBe('movie-folder') + expect(mm.movie?.database).toBe('TMDB') + return true + }) + }) + }) + + it('Import Music Library', async function () { + this.timeout(3 * 60 * 1000) + + await given('Media library was imported with music folders', { + base: testFolder, + }) + + const folders = getStepContext()._folders as Array<{ + folderName: string + path: string + type: string + }> + + await then('music folder has music-folder metadata with listed files', async () => { + const { musicFolder } = await import('test/actions/import-folders') + const f = folders.find((x) => x.folderName === musicFolder.folderName)! + await expectMediaMetadataViaBrowser(f.path, (obj) => { + const mm = obj as MediaMetadata + expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) + expect(mm.type).toBe('music-folder') + return true + }) + const listed = await listFilesViaBrowser(f.path, { recursively: true, onlyFiles: true }) + expect(listed.some((file) => file.path.endsWith('01.mp3'))).toBe(true) + }) + }) +}) diff --git a/apps/ui/src/App.test.tsx b/apps/ui/src/App.test.tsx index 52656811..a25b3e82 100644 --- a/apps/ui/src/App.test.tsx +++ b/apps/ui/src/App.test.tsx @@ -48,6 +48,7 @@ vi.mock("@/providers/dialog-provider", () => ({ useDialogs: () => ({ openFolderDialog: [vi.fn(), vi.fn()], filePickerDialog: [vi.fn(), vi.fn()], + renameFolderDialog: [vi.fn(), vi.fn()], }), })) diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index c14749f7..a64be100 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -6,6 +6,7 @@ import type { ViewMode } from "@/components/sidebar/ViewSwitcher" import { useUIMediaFolderStore, useUIMediaFolderStoreState } from "@/stores/uiMediaFolderStore" import { useDialogs } from "@/providers/dialog-provider" import type { FileItem, FolderType } from "@/providers/dialog-provider" +import { useTranslation } from "@/lib/i18n" import { Toaster } from "./components/ui/sonner" import { toast } from "sonner" import { deleteMetadata } from "@/api/metadata" @@ -50,6 +51,7 @@ import { AIArea } from "@/components/AIArea" function AppContent() { // WebSocket connection is now established at AppSwitcher level to persist across view changes // No need to call useWebSocket() here anymore + const { t } = useTranslation(["components"]) const { userConfig, setAndSaveUserConfig, isUserConfigLoaded } = useConfig() const unimportFolderMutation = useUnimportFolderMutation() @@ -93,10 +95,11 @@ function AppContent() { }, []) // Dialogs - const { openFolderDialog, filePickerDialog } = useDialogs() + const { openFolderDialog, filePickerDialog, renameFolderDialog } = useDialogs() const queryClient = useQueryClient() const [openOpenFolder] = openFolderDialog const [openFilePicker] = filePickerDialog + const [openRenameFolder] = renameFolderDialog const folderStatus = useUIMediaFolderStore((s) => s.folders.find(f => f.path === selectedFolder)?.status) const folderType = useUIMediaFolderStore((s) => s.folders.find(f => f.path === selectedFolder)?.type) @@ -336,6 +339,12 @@ function AppContent() {
+ openRenameFolder(path, { + title: t("mediaFolder.renameTitle"), + description: t("mediaFolder.renameDescription"), + }) + } selectedPaths={selectedFolders} primaryPath={selectedFolder} onSelectionChange={({ selectedPaths, primaryPath }) => { diff --git a/apps/ui/src/components/movie/MoviePanel.tsx b/apps/ui/src/components/movie/MoviePanel.tsx index 3a074e0d..b3548836 100644 --- a/apps/ui/src/components/movie/MoviePanel.tsx +++ b/apps/ui/src/components/movie/MoviePanel.tsx @@ -91,8 +91,9 @@ function MoviePanel() { }, [fetchMediaMetadata], ) - const { scrapeDialog, videoCompressionDialog } = useDialogs() + const { scrapeDialog, videoCompressionDialog, renameFileDialog } = useDialogs() const [openScrape] = scrapeDialog + const [openRenameFile] = renameFileDialog const toolbarOptions: ToolbarOption[] = [ { value: "plex", label: "Plex" } as ToolbarOption, @@ -134,6 +135,7 @@ function MoviePanel() { const videoRenameFlow = useRenameVideoFileFlow({ mediaFolderPath: mediaMetadata?.mediaFolderPath, files: folderFiles, + openRenameDialog: openRenameFile, }) const [movieFiles, setMovieFiles] = useState({ files: [] }) const latestMovieFiles = useLatest(movieFiles) diff --git a/apps/ui/src/components/shared/MediaFolderToolbar.tsx b/apps/ui/src/components/shared/MediaFolderToolbar.tsx index de18f42d..7d07623b 100644 --- a/apps/ui/src/components/shared/MediaFolderToolbar.tsx +++ b/apps/ui/src/components/shared/MediaFolderToolbar.tsx @@ -2,7 +2,7 @@ import { cn } from "@/lib/utils" import { FilterButton } from "./FilterButton"; import { SortingButton } from "./SortingButton"; import type { FilterOption, SortingOption } from "./FilterButton"; -import type { SortOrder, FilterType } from "@/stores/sidebarStore"; +import type { SortOrder, FilterType } from "@/lib/sidebarSort"; import { useTranslation } from "@/lib/i18n"; export type { SortOrder, FilterType } diff --git a/apps/ui/src/components/sidebar/Sidebar.stories.tsx b/apps/ui/src/components/sidebar/Sidebar.stories.tsx index 2ca8ed4b..4385f6b7 100644 --- a/apps/ui/src/components/sidebar/Sidebar.stories.tsx +++ b/apps/ui/src/components/sidebar/Sidebar.stories.tsx @@ -68,7 +68,6 @@ const meta = { folders: demoFolders .filter((f) => folderMatchesSearchQuery(f, options.searchQuery ?? "")) .map((f) => f.path), - handleRename: fn(), handleOpenInExplorer: fn(), handleDeletePaths: fn(), })) diff --git a/apps/ui/src/components/sidebar/Sidebar.test.tsx b/apps/ui/src/components/sidebar/Sidebar.test.tsx index ca63af12..fe34b0cb 100644 --- a/apps/ui/src/components/sidebar/Sidebar.test.tsx +++ b/apps/ui/src/components/sidebar/Sidebar.test.tsx @@ -38,11 +38,6 @@ vi.mock("@/components/shared/MediaFolderToolbar", () => ({ MediaFolderToolbar: () =>
, })) -vi.mock("@/stores/sidebarStore", () => ({ - useSidebarStore: vi.fn(), - compareByDisplayName: (a: string, b: string) => a.localeCompare(b), -})) - vi.mock("@/stores/uiMediaFolderStore", () => ({ useUIMediaFolderStoreState: vi.fn(), useUIMediaFolderStoreActions: vi.fn(), @@ -53,13 +48,6 @@ vi.mock("@/hooks/mediaMetadata/useMediaMetadataQuery", () => ({ useMediaMetadataQuery: vi.fn(), })) -vi.mock("@/providers/dialog-provider", () => ({ - useDialogs: vi.fn(() => ({ - renameFileDialog: [vi.fn(), vi.fn()], - renameFolderDialog: [vi.fn(), vi.fn()], - })), -})) - vi.mock("@/hooks/userConfig", () => ({ useConfig: vi.fn(() => ({ userConfig: { folders: [] }, @@ -117,7 +105,6 @@ vi.mock("./FolderListItem", () => ({ })) import { useQueries } from "@tanstack/react-query" -import { useSidebarStore } from "@/stores/sidebarStore" import { useUIMediaFolderStoreState, useUIMediaFolderStoreActions, @@ -127,19 +114,12 @@ import { useMediaMetadataQuery } from "@/hooks/mediaMetadata/useMediaMetadataQue import { useUnimportFolderMutation, useFoldersQuery } from "@/hooks/folders" const mockUseQueries = useQueries as ReturnType -const mockUseSidebarStore = useSidebarStore as ReturnType const mockUseUIMediaFolderStoreState = useUIMediaFolderStoreState as ReturnType const mockUseUIMediaFolderStoreActions = useUIMediaFolderStoreActions as ReturnType const mockUseUIMediaFolderSelection = useUIMediaFolderSelection as ReturnType const mockUseMediaMetadataQuery = useMediaMetadataQuery as ReturnType function baseSidebarMocks() { - mockUseSidebarStore.mockReturnValue({ - sortOrder: "asc", - filterType: "all", - setSortOrder: vi.fn(), - setFilterType: vi.fn(), - }) mockUseUIMediaFolderStoreActions.mockReturnValue({ applyFolderClick: vi.fn(), selectAllFolderPaths: vi.fn(), diff --git a/apps/ui/src/components/sidebar/Sidebar.tsx b/apps/ui/src/components/sidebar/Sidebar.tsx index a28ffc32..087331db 100644 --- a/apps/ui/src/components/sidebar/Sidebar.tsx +++ b/apps/ui/src/components/sidebar/Sidebar.tsx @@ -34,6 +34,8 @@ export type FolderListItemSlot = ComponentType export interface SidebarProps { onDeleteSelected?: (paths: string[]) => void + /** Fired when the user requests to rename a media folder (e.g. via the list-item menu). */ + onRenameFolder?: (path: string) => void /** * Optional list-item component (e.g. Storybook mounts pure {@link FolderListItem}). * When omitted, lazily loads {@link FolderListItemContainer}. @@ -53,6 +55,7 @@ export interface SidebarProps { export function Sidebar({ onDeleteSelected, + onRenameFolder, folderListItemSlot, selectedPaths: selectedPathsProp, primaryPath: primaryPathProp, @@ -82,7 +85,6 @@ export function Sidebar({ setSortOrder, setFilterType, folders, - handleRename, handleOpenInExplorer, handleDeletePaths, } = useSidebar({ onDeleteSelected, searchQuery }) @@ -137,6 +139,13 @@ export function Sidebar({ [commitSelection, folders, handleDeletePaths, selectedPaths], ) + const handleRename = useCallback( + (path: string) => { + onRenameFolder?.(path) + }, + [onRenameFolder], + ) + const handleDeleteItem = useCallback( (path: string) => { const shouldDeleteSelection = diff --git a/apps/ui/src/components/tv/TvShowPanel.tsx b/apps/ui/src/components/tv/TvShowPanel.tsx index a3811f1e..8ce2a7ff 100644 --- a/apps/ui/src/components/tv/TvShowPanel.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.tsx @@ -94,10 +94,13 @@ function TvShowPanel() { const { selectTvShowForFolderMutation, updateMediaMetadata } = useSelectTvShowForFolderMutation() const { mutateAsync: fetchMediaMetadata } = useFetchMediaMetadataMutation() + const { renameFileDialog } = useDialogs() + const [openRenameFile] = renameFileDialog const videoRenameFlow = useRenameVideoFileFlow({ mediaFolderPath: mediaMetadata?.mediaFolderPath, files: folderFiles, mode: "episode", + openRenameDialog: openRenameFile, }) const [tableData, setTableData] = useState([]) diff --git a/apps/ui/src/hooks/useRenameVideoFileFlow.test.ts b/apps/ui/src/hooks/useRenameVideoFileFlow.test.ts index 3d76d95a..6c765e3d 100644 --- a/apps/ui/src/hooks/useRenameVideoFileFlow.test.ts +++ b/apps/ui/src/hooks/useRenameVideoFileFlow.test.ts @@ -9,10 +9,6 @@ vi.mock("@/api/renameFiles", () => ({ renameFiles: vi.fn().mockResolvedValue({}), })) -vi.mock("@/providers/dialog-provider", () => ({ - useDialogs: vi.fn(), -})) - vi.mock("@/hooks/mediaMetadata/useFetchMediaMetadataMutation", () => ({ useFetchMediaMetadataMutation: vi.fn(), })) @@ -30,26 +26,18 @@ vi.mock("@/lib/i18n", () => ({ })) import { renameFiles } from "@/api/renameFiles" -import { useDialogs } from "@/providers/dialog-provider" import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" import { computeAssociatedFileRenames } from "@/components/episode-file" import { toast } from "sonner" import { useRenameVideoFileFlow } from "./useRenameVideoFileFlow" import type { UIMediaFileDataRow } from "@/components/media/UIMediaFileTable" -// Test-only shapes — keep these as small as possible to make the dependency -// on the mocked modules explicit. The values are not validated; the cast only -// narrows what fields the test reads back from the mocks. -interface MockDialogContextValue { - renameFileDialog: [ReturnType, ReturnType] -} interface MockFetchMutation { mutateAsync: ReturnType } describe("useRenameVideoFileFlow", () => { const renameFilesMock = vi.mocked(renameFiles) - const useDialogsMock = vi.mocked(useDialogs) const useFetchMock = vi.mocked(useFetchMediaMetadataMutation) const computeAssocMock = vi.mocked(computeAssociatedFileRenames) const toastSuccess = vi.mocked(toast.success) @@ -79,10 +67,6 @@ describe("useRenameVideoFileFlow", () => { beforeEach(() => { renameFilesMock.mockReset() renameFilesMock.mockResolvedValue({}) - useDialogsMock.mockReset() - useDialogsMock.mockReturnValue({ - renameFileDialog: [openRename, vi.fn()], - } as unknown as MockDialogContextValue) useFetchMock.mockReset() useFetchMock.mockReturnValue({ mutateAsync: fetchMediaMetadata, @@ -98,7 +82,7 @@ describe("useRenameVideoFileFlow", () => { it("is a no-op when the row has no videoFile", () => { const { result } = renderHook(() => - useRenameVideoFileFlow({ mediaFolderPath, files }), + useRenameVideoFileFlow({ mediaFolderPath, files, openRenameDialog: openRename }), ) act(() => { @@ -110,7 +94,7 @@ describe("useRenameVideoFileFlow", () => { it("is a no-op when mediaFolderPath is undefined", () => { const { result } = renderHook(() => - useRenameVideoFileFlow({ mediaFolderPath: undefined, files }), + useRenameVideoFileFlow({ mediaFolderPath: undefined, files, openRenameDialog: openRename }), ) act(() => { @@ -122,7 +106,7 @@ describe("useRenameVideoFileFlow", () => { it("opens the rename dialog with the relative path as initial value", () => { const { result } = renderHook(() => - useRenameVideoFileFlow({ mediaFolderPath, files }), + useRenameVideoFileFlow({ mediaFolderPath, files, openRenameDialog: openRename }), ) act(() => { @@ -141,7 +125,12 @@ describe("useRenameVideoFileFlow", () => { ]) const onAfterRename = vi.fn().mockResolvedValue(undefined) const { result } = renderHook(() => - useRenameVideoFileFlow({ mediaFolderPath, files, onAfterRename }), + useRenameVideoFileFlow({ + mediaFolderPath, + files, + onAfterRename, + openRenameDialog: openRename, + }), ) act(() => { @@ -175,7 +164,12 @@ describe("useRenameVideoFileFlow", () => { renameFilesMock.mockRejectedValueOnce(new Error("boom")) const onAfterRename = vi.fn() const { result } = renderHook(() => - useRenameVideoFileFlow({ mediaFolderPath, files, onAfterRename }), + useRenameVideoFileFlow({ + mediaFolderPath, + files, + onAfterRename, + openRenameDialog: openRename, + }), ) act(() => { diff --git a/apps/ui/src/hooks/useRenameVideoFileFlow.ts b/apps/ui/src/hooks/useRenameVideoFileFlow.ts index 4cb23a3b..2a576a11 100644 --- a/apps/ui/src/hooks/useRenameVideoFileFlow.ts +++ b/apps/ui/src/hooks/useRenameVideoFileFlow.ts @@ -5,12 +5,18 @@ import { useTranslation } from "@/lib/i18n" import { join, relative } from "@/lib/path" import { renameFiles } from "@/api/renameFiles" import { renameEpisodeFileViaCore } from "@/api/renameEpisodeFile" -import { useDialogs } from "@/providers/dialog-provider" import { computeAssociatedFileRenames } from "@/components/episode-file" import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" import { isSmmV3Enabled } from "@/lib/localStorages" import type { UIMediaFileDataRow } from "@/components/media/UIMediaFileTable" +export interface RenameFileDialogOptions { + initialValue?: string + title?: string + description?: string + suggestions?: string[] +} + export interface UseRenameVideoFileFlowOptions { /** * Absolute path to the media folder the renamed file lives in. @@ -34,6 +40,14 @@ export interface UseRenameVideoFileFlowOptions { * (e.g. clear checked rows) before the server re-fetch lands. */ onAfterRename?: () => void | Promise + /** + * Injected by the component layer — opens the rename-file dialog. + * Decouples this flow hook from the global dialog provider. + */ + openRenameDialog: ( + onConfirm: (newName: string) => void, + options?: RenameFileDialogOptions, + ) => void } export interface RenameVideoFileFlow { @@ -56,11 +70,10 @@ export interface RenameVideoFileFlow { export function useRenameVideoFileFlow( options: UseRenameVideoFileFlowOptions, ): RenameVideoFileFlow { - const { mediaFolderPath, files, onAfterRename } = options + const { mediaFolderPath, files, onAfterRename, openRenameDialog } = options const { t } = useTranslation(["components", "dialogs"]) - const { renameFileDialog } = useDialogs() - const [openRename] = renameFileDialog const { mutateAsync: fetchMediaMetadata } = useFetchMediaMetadataMutation() + const openRename = openRenameDialog const onRenameContextMenuClick = useCallback( (row: UIMediaFileDataRow) => { @@ -123,7 +136,7 @@ export function useRenameVideoFileFlow( mediaFolderPath, files, onAfterRename, - openRename, + openRenameDialog, fetchMediaMetadata, t, ], diff --git a/apps/ui/src/hooks/useSidebar.ts b/apps/ui/src/hooks/useSidebar.ts index 7ad2655c..72a77997 100644 --- a/apps/ui/src/hooks/useSidebar.ts +++ b/apps/ui/src/hooks/useSidebar.ts @@ -1,18 +1,11 @@ -import { useCallback, useMemo } from "react" +import { useCallback, useMemo, useState } from "react" import { useQueries } from "@tanstack/react-query" -import { useSidebarStore, compareByDisplayName } from "@/stores/sidebarStore" import { basename } from '../lib/path' -import { - useUIMediaFolderStoreState, -} from "@/stores/uiMediaFolderStore" import { mediaMetadataReadQueryOptions } from "@/lib/mediaMetadataQueryKeys" -import { buildMediaFolderListItemPropsFromFolderAndMetadata, mediaTypeFromMetadataType } from "@/lib/sidebarRowUtils" -import { folderMatchesSearchQuery } from "@/lib/sidebarFolderSearch" -import { useDialogs } from "@/providers/dialog-provider" +import { mediaTypeFromMetadataType } from "@/lib/sidebarRowUtils" +import { compareByDisplayName, type SortOrder, type FilterType } from "@/lib/sidebarSort" import { openInFileManagerApi } from "@/api/openInFileManager" -import { useTranslation } from "@/lib/i18n" import { useFoldersQuery, useUnimportFolderMutation } from "@/hooks/folders" -import { mergeFolderPathsWithUiStatus } from "@/lib/mergeFolderPathsWithUiStatus" import { Path } from "@smm/utils/path" export interface UseSidebarOptions { @@ -22,13 +15,12 @@ export interface UseSidebarOptions { } export function useSidebar({ searchQuery = "" }: UseSidebarOptions = {}) { - const { t } = useTranslation(["components"]) - const { sortOrder, filterType, setSortOrder, setFilterType } = useSidebarStore() - const { _folders } = useUIMediaFolderStoreState() + // Sort/filter are pure UI state owned by this hook's single consumer (Sidebar). + // Kept local via useState instead of a global store: no cross-component sharing. + const [sortOrder, setSortOrder] = useState("none") + const [filterType, setFilterType] = useState("all") const unimportFolderMutation = useUnimportFolderMutation() - const { renameFolderDialog } = useDialogs() - const [openRenameForMediaFolder] = renameFolderDialog const foldersQuery = useFoldersQuery(); @@ -75,16 +67,6 @@ export function useSidebar({ searchQuery = "" }: UseSidebarOptions = {}) { } }, []) - const handleRename = useCallback( - (path: string) => { - openRenameForMediaFolder(path, { - title: t("mediaFolder.renameTitle"), - description: t("mediaFolder.renameDescription"), - }) - }, - [openRenameForMediaFolder, t], - ) - const handleDeletePaths = useCallback( async (paths: string[]) => { if (paths.length === 0) return @@ -99,7 +81,6 @@ export function useSidebar({ searchQuery = "" }: UseSidebarOptions = {}) { setSortOrder, setFilterType, folders, - handleRename, handleOpenInExplorer, handleDeletePaths, } diff --git a/apps/ui/src/lib/sidebarSort.ts b/apps/ui/src/lib/sidebarSort.ts new file mode 100644 index 00000000..2b16fb4c --- /dev/null +++ b/apps/ui/src/lib/sidebarSort.ts @@ -0,0 +1,16 @@ +export type SortOrder = "none" | "alphabetical" | "reverse-alphabetical" +export type FilterType = "all" | "tvshow" | "movie" | "music" + +/** + * Compare two display names using the same logic as Sidebar list sort. + * Returns 0 when sortOrder is "none" (callers should skip .sort() for original order). + */ +export function compareByDisplayName( + nameA: string, + nameB: string, + sortOrder: SortOrder +): number { + if (sortOrder === "none") return 0 + const comparison = nameA.localeCompare(nameB, undefined, { sensitivity: "base" }) + return sortOrder === "alphabetical" ? comparison : -comparison +} diff --git a/apps/ui/src/stores/sidebarStore.test.ts b/apps/ui/src/stores/sidebarStore.test.ts deleted file mode 100644 index 0b8c3266..00000000 --- a/apps/ui/src/stores/sidebarStore.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest" -import { - compareByDisplayName, - sortPathsBySidebarDisplayOrder, - useSidebarStore, -} from "./sidebarStore" - -describe("compareByDisplayName", () => { - it("returns 0 when sortOrder is none", () => { - expect(compareByDisplayName("B", "A", "none")).toBe(0) - }) - - it("sorts ascending for alphabetical", () => { - expect(compareByDisplayName("A", "B", "alphabetical")).toBeLessThan(0) - }) - - it("sorts descending for reverse-alphabetical", () => { - expect(compareByDisplayName("A", "B", "reverse-alphabetical")).toBeGreaterThan(0) - }) -}) - -describe("sortPathsBySidebarDisplayOrder", () => { - beforeEach(() => { - useSidebarStore.setState({ sortOrder: "none", filterType: "all" }) - }) - - it("preserves input order when sortOrder is none", () => { - const paths = ["/z", "/a", "/m"] - expect(sortPathsBySidebarDisplayOrder(paths, (p) => p)).toEqual(["/z", "/a", "/m"]) - }) - - it("sorts by display name when alphabetical", () => { - useSidebarStore.setState({ sortOrder: "alphabetical" }) - const paths = ["/z", "/a", "/m"] - expect(sortPathsBySidebarDisplayOrder(paths, (p) => p.slice(1))).toEqual([ - "/a", - "/m", - "/z", - ]) - }) -}) diff --git a/apps/ui/src/stores/sidebarStore.ts b/apps/ui/src/stores/sidebarStore.ts deleted file mode 100644 index 99721a0f..00000000 --- a/apps/ui/src/stores/sidebarStore.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { create } from "zustand" - -export type SortOrder = "none" | "alphabetical" | "reverse-alphabetical" -export type FilterType = "all" | "tvshow" | "movie" | "music" - -interface SidebarStoreState { - sortOrder: SortOrder - filterType: FilterType -} - -interface SidebarStoreActions { - setSortOrder: (order: SortOrder) => void - setFilterType: (type: FilterType) => void -} - -type SidebarStore = SidebarStoreState & SidebarStoreActions - -const useSidebarStore = create((set) => ({ - sortOrder: "none", - filterType: "all", - - setSortOrder: (order) => set({ sortOrder: order }), - setFilterType: (type) => set({ filterType: type }), -})) - -/** - * Compare two display names using the same logic as Sidebar list sort. - * Used by AppV2 (folders) and MediaLibraryImportedEventHandler (init order). - * Returns 0 when sortOrder is "none" (callers should skip .sort() for original order). - */ -export function compareByDisplayName( - nameA: string, - nameB: string, - sortOrder: SortOrder -): number { - if (sortOrder === "none") return 0 - const comparison = nameA.localeCompare(nameB, undefined, { sensitivity: "base" }) - return sortOrder === "alphabetical" ? comparison : -comparison -} - -/** - * Sort paths by Sidebar display order (by display name, using current sortOrder from store). - * Use this when the order of operations must match what the user sees in the Sidebar. - * When sortOrder is "none", returns paths in the given order (no reordering). - */ -export function sortPathsBySidebarDisplayOrder( - paths: string[], - getDisplayName: (path: string) => string -): string[] { - const sortOrder = useSidebarStore.getState().sortOrder - if (sortOrder === "none") return [...paths] - return [...paths].sort((a, b) => - compareByDisplayName(getDisplayName(a), getDisplayName(b), sortOrder) - ) -} - -export { useSidebarStore } From 103f61221b2472413f2bc10fff3bfdf534055bbd Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 1 Sep 2026 23:03:51 +0800 Subject: [PATCH 05/83] refactor: clean up codebase --- apps/e2e/common/other/ImportLibrary.e2e.ts | 4 +- apps/ui/src/App.test.tsx | 1 - apps/ui/src/App.tsx | 152 ++------- apps/ui/src/api/tmdb.test.ts | 125 ------- apps/ui/src/api/tmdb.ts | 127 ++------ apps/ui/src/api/tvdbSearch.ts | 29 +- apps/ui/src/components/movie/MoviePanel.tsx | 1 - apps/ui/src/components/sidebar/Toolbar.tsx | 22 +- .../src/components/sidebar/ViewSwitcher.tsx | 59 ---- .../src/components/tv/TvShowEpisodeTable.tsx | 26 +- apps/ui/src/components/tv/TvShowPanel.tsx | 2 - .../src/components/ui/radio-button-group.tsx | 67 ++++ .../useSelectMovieForFolderMutation.test.ts | 307 +++--------------- .../movie/useSelectMovieForFolderMutation.ts | 183 +---------- .../useRenameMediaFolderMutation.test.ts | 39 +-- .../src/hooks/useRenameMediaFolderMutation.ts | 12 +- .../src/hooks/useRenameVideoFileFlow.test.ts | 59 +--- apps/ui/src/hooks/useRenameVideoFileFlow.ts | 50 +-- .../useSelectTvShowForFolderMutation.test.ts | 281 +++------------- .../hooks/useSelectTvShowForFolderMutation.ts | 144 +------- apps/ui/src/hooks/useTvdbQueries.ts | 14 +- apps/ui/src/lib/localStorages.ts | 14 - 22 files changed, 285 insertions(+), 1433 deletions(-) delete mode 100644 apps/ui/src/components/sidebar/ViewSwitcher.tsx create mode 100644 apps/ui/src/components/ui/radio-button-group.tsx diff --git a/apps/e2e/common/other/ImportLibrary.e2e.ts b/apps/e2e/common/other/ImportLibrary.e2e.ts index db96a7b1..2fd9c9ac 100644 --- a/apps/e2e/common/other/ImportLibrary.e2e.ts +++ b/apps/e2e/common/other/ImportLibrary.e2e.ts @@ -11,8 +11,8 @@ import { } from 'test/lib/browser-fs' import { given, then, resetStepContext, getStepContext } from 'test/lib/gherkin' import 'test/steps' -import type { MediaMetadata } from '@smm/core/types' -import { Path } from '@smm/core' +import type { MediaMetadata } from '@smm/types' +import { Path } from '@smm/utils/path' import { testbedOs } from 'test/lib/e2e-platform' diff --git a/apps/ui/src/App.test.tsx b/apps/ui/src/App.test.tsx index a25b3e82..0125093f 100644 --- a/apps/ui/src/App.test.tsx +++ b/apps/ui/src/App.test.tsx @@ -41,7 +41,6 @@ vi.mock("@/hooks/userConfig", () => ({ vi.mock("@/lib/localStorages", () => ({ default: mockLocalStorages, - isSmmV3Enabled: () => false, })) vi.mock("@/providers/dialog-provider", () => ({ diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index a64be100..afdd9186 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -1,15 +1,13 @@ import { useState, useCallback, useEffect, useRef } from "react" -import { useQueryClient } from "@tanstack/react-query" import { Sidebar } from "@/components/sidebar/Sidebar" import { Toolbar } from "@/components/sidebar/Toolbar" -import type { ViewMode } from "@/components/sidebar/ViewSwitcher" +import { RadioButtonGroup } from "@/components/ui/radio-button-group" +import { LayoutGrid, FolderOpen } from "lucide-react" import { useUIMediaFolderStore, useUIMediaFolderStoreState } from "@/stores/uiMediaFolderStore" import { useDialogs } from "@/providers/dialog-provider" import type { FileItem, FolderType } from "@/providers/dialog-provider" import { useTranslation } from "@/lib/i18n" import { Toaster } from "./components/ui/sonner" -import { toast } from "sonner" -import { deleteMetadata } from "@/api/metadata" import { Assistant } from "./ai/Assistant" import { StatusBar } from "./components/StatusBar" import { AppWarningBanner } from "./components/AppWarningBanner" @@ -25,13 +23,7 @@ import { logger } from "@/lib/log" import { nextTraceId } from "@/lib/utils" import { useConfig } from "@/hooks/userConfig" import { useFeatures } from "@/hooks/useFeatures" -import { isNotNil } from "es-toolkit" -import type { MediaMetadata } from "@smm/types" -import { - mediaMetadataQueryKey, - normalizeMediaFolderPathForQuery, - useMediaMetadataQuery, -} from "@/hooks/mediaMetadata" +import { useMediaMetadataQuery } from "@/hooks/mediaMetadata" import { UI_ImportFolderEvent, UI_MediaLibraryImportedEvent, @@ -39,8 +31,8 @@ import { type OnMediaLibraryImportedEventData, } from "./types/eventTypes" import { MusicPanel } from "./components/music/MusicPanel" -import localStorages, { isSmmV3Enabled } from "@/lib/localStorages" -import { useUnimportFolderMutation } from "@/hooks/folders" +import localStorages from "@/lib/localStorages" +import { useFoldersQuery, useUnimportFolderMutation } from "@/hooks/folders" import { isElectron } from "@/lib/isElectron" import { openNativeFolderDialog } from "@/lib/nativeFolderDialog" import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable" @@ -48,14 +40,18 @@ import type { ImperativePanelHandle } from "react-resizable-panels" import { AIArea } from "@/components/AIArea" // WebSocketHandlers is now at AppSwitcher level to avoid disconnection on view switch -function AppContent() { +type ViewMode = "metadata" | "files" + +export default function App() { // WebSocket connection is now established at AppSwitcher level to persist across view changes // No need to call useWebSocket() here anymore const { t } = useTranslation(["components"]) - const { userConfig, setAndSaveUserConfig, isUserConfigLoaded } = useConfig() + const { userConfig, isUserConfigLoaded } = useConfig() const unimportFolderMutation = useUnimportFolderMutation() - const { folders: uiFolders, selectedFolder, selectedFolders } = useUIMediaFolderStoreState() + const { data: folders } = useFoldersQuery() + const { selectedFolder, selectedFolders } = useUIMediaFolderStoreState() + const hasFolders = (folders?.length ?? 0) > 0 const { isAiAreaEnabled, isAiFeatureEnabled } = useFeatures() // View mode state @@ -96,7 +92,6 @@ function AppContent() { // Dialogs const { openFolderDialog, filePickerDialog, renameFolderDialog } = useDialogs() - const queryClient = useQueryClient() const [openOpenFolder] = openFolderDialog const [openFilePicker] = filePickerDialog const [openRenameFolder] = renameFolderDialog @@ -106,6 +101,9 @@ function AppContent() { // Media metadata const { data: selectedMediaMetadata } = useMediaMetadataQuery(selectedFolder || undefined, { defaultType: folderType }) + const viewSwitcherDisabled = + !hasFolders || !selectedMediaMetadata || folderStatus === "folder_not_found" + // Log error when metadata is loaded but type is missing (e.g. race condition during import) useEffect(() => { @@ -221,90 +219,9 @@ function AppContent() { async (paths: string[]) => { if (paths.length === 0) return - if (isSmmV3Enabled()) { - await unimportFolderMutation.mutateAsync(paths) - return - } - - const traceId = `App-onDeleteSelected-${nextTraceId()}` - const deletedPosix = new Set(paths.map((p) => Path.posix(p))) - const deletedNative = new Set(paths) - - const getMediaMetadata = (path: string): MediaMetadata | undefined => { - const normalized = normalizeMediaFolderPathForQuery(path) - if (!normalized) return undefined - return queryClient.getQueryData(mediaMetadataQueryKey(normalized)) - } - - // Snapshot for rollback - const removedMetadataByPath = paths - .map((p) => getMediaMetadata(p)) - const removedMetadata = removedMetadataByPath.filter((m): m is NonNullable => m != null) - const previousFolders = userConfig.folders - const previousUiFolders = [...useUIMediaFolderStore.getState().folders] - const prevUiSelection = { - selectedFolder: useUIMediaFolderStore.getState().selectedFolder, - selectedFolders: [...useUIMediaFolderStore.getState().selectedFolders], - } - - // 1. Optimistic: remove all selected from UI state at once - paths.forEach((path) => { - const normalized = normalizeMediaFolderPathForQuery(path) - if (normalized) { - queryClient.removeQueries({ queryKey: mediaMetadataQueryKey(normalized), exact: true }) - } - }) - const newFolders = userConfig.folders - .filter((f) => isNotNil(f)) - .filter((folder) => !deletedPosix.has(Path.posix(folder))) - setAndSaveUserConfig(traceId, { ...userConfig, folders: newFolders }) - - const st = useUIMediaFolderStore.getState() - const nextUiFolders = st.folders.filter((folder) => !deletedPosix.has(Path.posix(folder.path))) - const nextSelectedFolders = st.selectedFolders.filter((p) => !deletedNative.has(p)) - let nextPrimary = st.selectedFolder - if (deletedNative.has(nextPrimary)) { - nextPrimary = newFolders[0] ? Path.toPlatformPath(newFolders[0]) : "" - } - useUIMediaFolderStore.setState({ - folders: nextUiFolders, - selectedFolders: - nextSelectedFolders.length > 0 - ? nextSelectedFolders - : nextPrimary - ? [nextPrimary] - : [], - selectedFolder: nextPrimary, - }) - - // 2. Async delete; rollback on failure - try { - await Promise.all(paths.map((path) => deleteMetadata(path))) - } catch (error) { - console.error("[onDeleteSelected] Failed to delete some media metadata:", error) - removedMetadata.forEach((metadata) => { - const folder = normalizeMediaFolderPathForQuery(metadata.mediaFolderPath || "") - if (folder) { - queryClient.setQueryData(mediaMetadataQueryKey(folder), metadata) - } - }) - setAndSaveUserConfig(traceId, { ...userConfig, folders: previousFolders }) - useUIMediaFolderStore.setState({ - folders: previousUiFolders, - selectedFolder: prevUiSelection.selectedFolder, - selectedFolders: prevUiSelection.selectedFolders, - }) - toast.error( - error instanceof Error ? error.message : "Failed to delete selected folders. Changes reverted." - ) - } + await unimportFolderMutation.mutateAsync(paths) }, - [ - userConfig, - setAndSaveUserConfig, - queryClient, - unimportFolderMutation, - ] + [unimportFolderMutation], ) return ( @@ -317,19 +234,22 @@ function AppContent() {
{/* Toolbar */}
- + > + +
{/* Sidebar | Content */}
@@ -360,18 +280,18 @@ function AppContent() { {/* Content */}
- {uiFolders.length === 0 && ( + {!hasFolders && (
)} - {uiFolders.length > 0 && selectedFolder && folderStatus === "folder_not_found" && ( + {hasFolders && selectedFolder && folderStatus === "folder_not_found" && ( )} - {uiFolders.length > 0 && selectedFolder && folderStatus === "pending_for_initialization" && ( + {hasFolders && selectedFolder && folderStatus === "pending_for_initialization" && ( )} - {uiFolders.length > 0 && + {hasFolders && folderStatus !== "folder_not_found" && folderStatus !== "pending_for_initialization" && selectedMediaMetadata && ( @@ -440,9 +360,5 @@ function AppContent() { ) } -export default function App() { - return ( - - ) -} + diff --git a/apps/ui/src/api/tmdb.test.ts b/apps/ui/src/api/tmdb.test.ts index 5e70bfff..eb1fe894 100644 --- a/apps/ui/src/api/tmdb.test.ts +++ b/apps/ui/src/api/tmdb.test.ts @@ -38,7 +38,6 @@ import { getTmdbLanguages, } from './tmdb' import { _resetInternalReverseProxyCacheForTesting } from './fetchByInternalReverseProxy' -import * as localStoragesModule from '@/lib/localStorages' const REVERSE_PROXY_URL = 'http://127.0.0.1:30005' const SMM_TMDB_DEFAULT_UPSTREAM = 'https://mediadb.vercel.app/api/tmdb' @@ -479,82 +478,6 @@ describe('tmdb routing through reverse proxy', () => { .mockResolvedValue(new Response(JSON.stringify(body), { status: 200 })) } - beforeEach(() => { - vi.spyOn(localStoragesModule, 'isSmmV3Enabled').mockReturnValue(false) - }) - - it('searches via discovered reverse proxy when TMDB host is empty', async () => { - mockReadUserConfig.mockResolvedValue(userConfigWithTmdb()) - const fetchSpy = mockOkJson({ results: [], page: 1, total_pages: 1, total_results: 0 }) - - const result = await searchTmdb('naruto', 'tv', 'en-US') - - expect(result).toEqual({ results: [], page: 1, total_pages: 1, total_results: 0 }) - expect(fetchSpy).toHaveBeenCalledTimes(1) - expect(fetchSpy.mock.calls[0][0]).toBe('https://proxy-a.example') - const headers = headersOf(fetchSpy.mock.calls[0][1] as RequestInit) - expect(headers['X-Upstream-Base-Url']).toBe('https://tmdb-a.example/api/tmdb') - }) - - it('searches via reverse proxy with configured TMDB host and Authorization', async () => { - mockReadUserConfig.mockResolvedValue( - userConfigWithTmdb({ - host: 'https://api.themoviedb.org/3/', - apiKey: 'abc123', - }), - ) - const fetchSpy = mockOkJson({ results: [], page: 1, total_pages: 1, total_results: 0 }) - - const result = await searchTmdb('inception', 'movie', 'en-US') - - expect(result).toEqual({ results: [], page: 1, total_pages: 1, total_results: 0 }) - expect(fetchSpy).toHaveBeenCalledTimes(1) - expect(fetchSpy.mock.calls[0][0]).toBe( - `${REVERSE_PROXY_URL}/search/movie?query=inception&language=en-US`, - ) - const init = fetchSpy.mock.calls[0][1] as RequestInit - const headers = init.headers as Record - // Trailing slash from user input is stripped. - expect(headers['X-SMM-Proxy-Upstream-BaseURL']).toBe('https://api.themoviedb.org/3') - expect(headers['Authorization']).toBe('Bearer abc123') - }) - - it('routes getMovieById through reverse proxy with user config', async () => { - mockReadUserConfig.mockResolvedValue( - userConfigWithTmdb({ - host: 'https://api.themoviedb.org/3', - apiKey: 'override-key', - }), - ) - const fetchSpy = mockOkJson({ id: 1 }) - - const result = await getMovieById(1, 'en-US') - - expect(result).toEqual({ id: 1 }) - expect(fetchSpy).toHaveBeenCalledTimes(1) - expect(fetchSpy.mock.calls[0][0]).toBe(`${REVERSE_PROXY_URL}/movie/1?language=en-US`) - const init = fetchSpy.mock.calls[0][1] as RequestInit - const headers = init.headers as Record - expect(headers['X-SMM-Proxy-Upstream-BaseURL']).toBe('https://api.themoviedb.org/3') - expect(headers['Authorization']).toBe('Bearer override-key') - }) - - it('routes getTvShowById through reverse proxy', async () => { - mockReadUserConfig.mockResolvedValue( - userConfigWithTmdb({ host: 'https://api.themoviedb.org/3' }), - ) - const fetchSpy = mockOkJson({ id: 84666 }) - - const result = await getTvShowById(84666, 'zh-CN') - - expect(result).toEqual({ id: 84666 }) - expect(fetchSpy).toHaveBeenCalledTimes(1) - expect(fetchSpy.mock.calls[0][0]).toBe(`${REVERSE_PROXY_URL}/tv/84666?language=zh-CN`) - const init = fetchSpy.mock.calls[0][1] as RequestInit - const headers = init.headers as Record - expect(headers['X-SMM-Proxy-Upstream-BaseURL']).toBe('https://api.themoviedb.org/3') - }) - it('routes getSeason through reverse proxy', async () => { mockReadUserConfig.mockResolvedValue( userConfigWithTmdb({ host: 'https://api.themoviedb.org/3' }), @@ -569,54 +492,6 @@ describe('tmdb routing through reverse proxy', () => { `${REVERSE_PROXY_URL}/tv/84666/season/1?language=en-US`, ) }) - - it('throws a clear error when no reverse proxy URL is available', async () => { - mockReadUserConfig.mockResolvedValue( - userConfigWithTmdb({ host: 'https://api.themoviedb.org/3' }), - ) - mockHello.mockResolvedValue({ - reverseProxyUrl: null, - userDataDir: '/tmp/smm', - } as Awaited>) - - await expect(searchTmdb('naruto', 'tv', 'en-US')).rejects.toThrow( - /Reverse proxy URL is not available/, - ) - }) - - it('forwards signal to the underlying fetch', async () => { - mockReadUserConfig.mockResolvedValue( - userConfigWithTmdb({ host: 'https://api.themoviedb.org/3' }), - ) - const controller = new AbortController() - const fetchSpy = mockOkJson({ results: [] }) - - await searchTmdb('naruto', 'tv', 'en-US', { signal: controller.signal }) - - expect(fetchSpy.mock.calls[0][1]).toMatchObject({ signal: controller.signal }) - }) - - it('throws when fetchTmdb returns undefined (all attempts failed)', async () => { - mockReadUserConfig.mockResolvedValue(userConfigWithTmdb()) - vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Failed to fetch')) - - await expect(searchTmdb('naruto', 'tv', 'en-US')).rejects.toThrow( - /Failed to search TMDB: all attempts failed/, - ) - }) - - it('throws when fetchTmdb returns a non-ok response', async () => { - mockReadUserConfig.mockResolvedValue( - userConfigWithTmdb({ host: 'https://api.themoviedb.org/3' }), - ) - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response('Not Found', { status: 404, statusText: 'Not Found' }), - ) - - await expect(searchTmdb('naruto', 'tv', 'en-US')).rejects.toThrow( - /Failed to search TMDB: 404 Not Found/, - ) - }) }) describe('getTmdbPrimaryTranslations', () => { diff --git a/apps/ui/src/api/tmdb.ts b/apps/ui/src/api/tmdb.ts index c08a1462..48907247 100644 --- a/apps/ui/src/api/tmdb.ts +++ b/apps/ui/src/api/tmdb.ts @@ -8,11 +8,10 @@ import localStorages from '@/lib/localStorages' import { fetchDiscoverConfig, type DiscoverConfig, type ReverseProxyEndpoint } from './discover' import { isEmpty } from 'es-toolkit/compat' import { readUserConfig } from './readUserConfig' -import { fetchWithFailover, HttpFailoverExhaustedError } from '@/lib/http' +import { fetchWithFailover } from '@/lib/http' import staticConfig from './staticConfig' import { fetchByInternalReverseProxy } from './fetchByInternalReverseProxy' import { buildTmdbErrorFromResponse } from './tmdbErrors' -import { isSmmV3Enabled } from '@/lib/localStorages' import { getMovieInTmdb, getTvShowInTmdb, searchInTmdb } from './tmdbV3' export const SMM_TMDB_DEFAULT_UPSTREAM = 'https://mediadb.vercel.app/api/tmdb' @@ -102,26 +101,6 @@ export async function fetchTmdb(urlPath: string, options?: { }) } -/** - * Like {@link fetchTmdb}, but turns exhausted HTTP failover into `undefined` - * so search callers can map it to {@link TmdbFetchError} via - * {@link buildTmdbErrorFromResponse}. Scrape/metadata callers should use - * {@link fetchTmdb} directly so {@link HttpFailoverExhaustedError} propagates. - */ -export async function fetchTmdbOrUndefined( - urlPath: string, - options?: Parameters[1], -): Promise { - try { - return await fetchTmdb(urlPath, options) - } catch (error) { - if (error instanceof HttpFailoverExhaustedError) { - return undefined - } - throw error - } -} - /** * Search TMDB for movies or TV shows. */ @@ -131,43 +110,29 @@ export async function searchTmdb( language: string, options?: TmdbRequestOptions, ): Promise { - if (isSmmV3Enabled()) { - const body = await searchInTmdb( - { keyword, type, language }, - options?.signal, - ) - if (body.error) { - return { - error: body.error, - results: [], - page: 0, - total_pages: 0, - total_results: 0, - } - } - if (!body.data) { - return { - error: 'Error Reason: empty search result', - results: [], - page: 0, - total_pages: 0, - total_results: 0, - } + const body = await searchInTmdb( + { keyword, type, language }, + options?.signal, + ) + if (body.error) { + return { + error: body.error, + results: [], + page: 0, + total_pages: 0, + total_results: 0, } - return body.data } - - const queryParams = new URLSearchParams() - queryParams.append('query', keyword) - queryParams.append('language', language) - const resp = await fetchTmdbOrUndefined( - `/search/${type}?${queryParams.toString()}`, - { signal: options?.signal }, - ) - if (!resp || !resp.ok) { - throw await buildTmdbErrorFromResponse(resp) + if (!body.data) { + return { + error: 'Error Reason: empty search result', + results: [], + page: 0, + total_pages: 0, + total_results: 0, + } } - return resp.json() as Promise + return body.data } /** @@ -178,27 +143,14 @@ export async function getTvShowById( language?: string, options?: TmdbRequestOptions, ): Promise { - if (isSmmV3Enabled()) { - const body = await getTvShowInTmdb({ id, language }, options?.signal) - if (body.error) { - throw new Error(body.error) - } - if (!body.data) { - throw new Error('Error Reason: empty TV show result') - } - return body.data + const body = await getTvShowInTmdb({ id, language }, options?.signal) + if (body.error) { + throw new Error(body.error) } - - const queryParams = new URLSearchParams() - if (language) queryParams.append('language', language) - const resp = await fetchTmdb( - `/tv/${id}?${queryParams.toString()}`, - { signal: options?.signal }, - ) - if (!resp || !resp.ok) { - throw await buildTmdbErrorFromResponse(resp) + if (!body.data) { + throw new Error('Error Reason: empty TV show result') } - return resp.json() as Promise + return body.data } /** @@ -209,27 +161,14 @@ export async function getMovieById( language?: string, options?: TmdbRequestOptions, ): Promise { - if (isSmmV3Enabled()) { - const body = await getMovieInTmdb({ id, language }, options?.signal) - if (body.error) { - throw new Error(body.error) - } - if (!body.data) { - throw new Error('Error Reason: empty movie result') - } - return body.data + const body = await getMovieInTmdb({ id, language }, options?.signal) + if (body.error) { + throw new Error(body.error) } - - const queryParams = new URLSearchParams() - if (language) queryParams.append('language', language) - const resp = await fetchTmdb( - `/movie/${id}?${queryParams.toString()}`, - { signal: options?.signal }, - ) - if (!resp || !resp.ok) { - throw await buildTmdbErrorFromResponse(resp) + if (!body.data) { + throw new Error('Error Reason: empty movie result') } - return resp.json() as Promise + return body.data } /** diff --git a/apps/ui/src/api/tvdbSearch.ts b/apps/ui/src/api/tvdbSearch.ts index 113157bb..bcdb82a6 100644 --- a/apps/ui/src/api/tvdbSearch.ts +++ b/apps/ui/src/api/tvdbSearch.ts @@ -1,7 +1,4 @@ import type { TVDBv4SearchResult } from '@smm/tvdb4/types' -import { isSmmV3Enabled } from '@/lib/localStorages' -import { getTVDBv4Client, type GetTVDBv4ClientOverrides } from '@/lib/TvdbUtils' -import { readUserConfig } from './readUserConfig' import { searchInTvdb } from './tvdbV3' export interface SearchTvdbResponse { @@ -11,12 +8,10 @@ export interface SearchTvdbResponse { export interface TvdbSearchRequestOptions { signal?: AbortSignal - overrides?: GetTVDBv4ClientOverrides } /** - * Search TVDB by keyword. When v3 is enabled, calls `POST /api/search-in-tvdb`; - * otherwise uses the legacy browser-side TVDBv4 client. + * Search TVDB by keyword via `POST /api/search-in-tvdb`. */ export async function searchTvdb( keyword: string, @@ -24,23 +19,9 @@ export async function searchTvdb( language?: string, options?: TvdbSearchRequestOptions, ): Promise { - if (isSmmV3Enabled()) { - const body = await searchInTvdb({ keyword, type, language }, options?.signal) - if (body.error) { - return { results: [], error: body.error } - } - return { results: body.data ?? [] } + const body = await searchInTvdb({ keyword, type, language }, options?.signal) + if (body.error) { + return { results: [], error: body.error } } - - const userConfig = await readUserConfig() - const tvdb = getTVDBv4Client({ - ...options?.overrides, - upstreamBaseURL: userConfig?.tvdb?.host?.trim() || options?.overrides?.upstreamBaseURL, - apiKey: userConfig?.tvdb?.apiKey?.trim() || options?.overrides?.apiKey, - }) - const envelope = await tvdb.search({ query: keyword.trim(), type, language }) - if (envelope.status === 'success' && Array.isArray(envelope.data)) { - return { results: envelope.data } - } - return { results: [], error: envelope.message ?? 'TVDB search failed' } + return { results: body.data ?? [] } } diff --git a/apps/ui/src/components/movie/MoviePanel.tsx b/apps/ui/src/components/movie/MoviePanel.tsx index b3548836..5595b6f4 100644 --- a/apps/ui/src/components/movie/MoviePanel.tsx +++ b/apps/ui/src/components/movie/MoviePanel.tsx @@ -134,7 +134,6 @@ function MoviePanel() { }) const videoRenameFlow = useRenameVideoFileFlow({ mediaFolderPath: mediaMetadata?.mediaFolderPath, - files: folderFiles, openRenameDialog: openRenameFile, }) const [movieFiles, setMovieFiles] = useState({ files: [] }) diff --git a/apps/ui/src/components/sidebar/Toolbar.tsx b/apps/ui/src/components/sidebar/Toolbar.tsx index 534d93f0..8f37ec1e 100644 --- a/apps/ui/src/components/sidebar/Toolbar.tsx +++ b/apps/ui/src/components/sidebar/Toolbar.tsx @@ -1,26 +1,23 @@ +import type { ReactNode } from "react" import { Menu } from "@/components/menu" -import { ViewSwitcher, type ViewMode } from "./ViewSwitcher" import { Button } from "@/components/ui/button" import { Bot } from "lucide-react" export interface ToolbarProps { onOpenFolderMenuClick?: () => void onOpenMediaLibraryMenuClick?: () => void - viewMode?: ViewMode - onViewModeChange?: (mode: ViewMode) => void - viewSwitcherDisabled?: boolean onToggleAIArea?: () => void isAIAreaCollapsed?: boolean + /** Extra controls rendered in the right group, before the AI toggle. */ + children?: ReactNode } -export function Toolbar({ +export function Toolbar({ onOpenFolderMenuClick, onOpenMediaLibraryMenuClick, - viewMode, - onViewModeChange, - viewSwitcherDisabled, onToggleAIArea, isAIAreaCollapsed, + children, }: ToolbarProps) { return (
- {viewMode !== undefined && onViewModeChange && ( - - )} + {children} {onToggleAIArea && ( -
- -
- ) -} - diff --git a/apps/ui/src/components/tv/TvShowEpisodeTable.tsx b/apps/ui/src/components/tv/TvShowEpisodeTable.tsx index dd98cf2b..d5d19254 100644 --- a/apps/ui/src/components/tv/TvShowEpisodeTable.tsx +++ b/apps/ui/src/components/tv/TvShowEpisodeTable.tsx @@ -28,17 +28,13 @@ import { useDialogs } from "@/providers/dialog-provider" import { generateFfmpegScreenshots } from "@/api/ffmpeg" import { useFailedCommandLogsStore } from "@/stores/failedCommandLogsStore" import { useUIMediaFolderStoreState } from "@/stores/uiMediaFolderStore" -import { renameFiles } from "@/api/renameFiles" import { renameEpisodeFileViaCore } from "@/api/renameEpisodeFile" import { openFile } from "@/api/openFile" import { toast } from "sonner" import { useTranslation } from "@/lib/i18n" import { cn } from "@/lib/utils" -import { computeAssociatedFileRenames } from "../episode-file" import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" import { useMediaMetadataQuery } from "@/hooks/mediaMetadata/useMediaMetadataQuery" -import { useMediaFolderFilesQuery } from "@/hooks/useMediaFolderFilesQuery" -import { isSmmV3Enabled } from "@/lib/localStorages" export interface TvShowEpisodeDividerRow { id: string @@ -414,7 +410,6 @@ export function TvShowEpisodeTable({ const { t } = useTranslation(['components', 'dialogs']) const { selectedFolder } = useUIMediaFolderStoreState() const { data: selectedMediaMetadata } = useMediaMetadataQuery(selectedFolder || undefined) - const { data: folderFiles = [] } = useMediaFolderFilesQuery(selectedFolder || undefined) const { mutate: fetchMediaMetadata } = useFetchMediaMetadataMutation() const { renameFileDialog } = useDialogs() const [openRename] = renameFileDialog @@ -899,22 +894,11 @@ export function TvShowEpisodeTable({ if (!selectedMediaMetadata?.mediaFolderPath || !row.videoFile) return try { const newAbsolutePath = join(selectedMediaMetadata.mediaFolderPath, newRelativePath) - if (isSmmV3Enabled()) { - await renameEpisodeFileViaCore({ - mediaFolder: Path.posix(selectedMediaMetadata.mediaFolderPath), - from: row.videoFile, - to: newAbsolutePath, - }) - } else { - const assocRenames = computeAssociatedFileRenames(row.videoFile, newAbsolutePath, folderFiles) - await renameFiles({ - files: [ - { from: row.videoFile, to: newAbsolutePath }, - ...assocRenames, - ], - mediaFolder: Path.posix(selectedMediaMetadata.mediaFolderPath), - }) - } + await renameEpisodeFileViaCore({ + mediaFolder: Path.posix(selectedMediaMetadata.mediaFolderPath), + from: row.videoFile, + to: newAbsolutePath, + }) fetchMediaMetadata({ path: selectedMediaMetadata.mediaFolderPath }) toast.success(t('episodeFile.renameSuccess', { ns: 'components' })) } catch (error) { diff --git a/apps/ui/src/components/tv/TvShowPanel.tsx b/apps/ui/src/components/tv/TvShowPanel.tsx index 8ce2a7ff..91a25707 100644 --- a/apps/ui/src/components/tv/TvShowPanel.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.tsx @@ -98,8 +98,6 @@ function TvShowPanel() { const [openRenameFile] = renameFileDialog const videoRenameFlow = useRenameVideoFileFlow({ mediaFolderPath: mediaMetadata?.mediaFolderPath, - files: folderFiles, - mode: "episode", openRenameDialog: openRenameFile, }) diff --git a/apps/ui/src/components/ui/radio-button-group.tsx b/apps/ui/src/components/ui/radio-button-group.tsx new file mode 100644 index 00000000..2beafd04 --- /dev/null +++ b/apps/ui/src/components/ui/radio-button-group.tsx @@ -0,0 +1,67 @@ +import { Fragment } from "react" +import { Button } from "@/components/ui/button" +import type { LucideIcon } from "lucide-react" +import { cn } from "@/lib/utils" + +export interface RadioButtonGroupOption { + /** Option identifier, passed to `onSelect` when selected. */ + value: T + /** Display text (used as button title / sr-only label). */ + label: string + icon?: LucideIcon +} + +export interface RadioButtonGroupProps { + options: RadioButtonGroupOption[] + /** Currently selected option value. */ + value: T + /** Called with the selected option's value. */ + onSelect: (value: T) => void + disabled?: boolean +} + +/** Generic segmented button group with radio semantics (single selection). */ +export function RadioButtonGroup({ + options, + value, + onSelect, + disabled, +}: RadioButtonGroupProps) { + const lastIndex = options.length - 1 + + return ( +
+ {options.map((option, index) => { + const Icon = option.icon + const isFirst = index === 0 + const isLast = index === lastIndex + const isSelected = value === option.value + + return ( + + {index > 0 &&
} + + + ) + })} +
+ ) +} diff --git a/apps/ui/src/hooks/movie/useSelectMovieForFolderMutation.test.ts b/apps/ui/src/hooks/movie/useSelectMovieForFolderMutation.test.ts index 4a6d1c96..5baba98c 100644 --- a/apps/ui/src/hooks/movie/useSelectMovieForFolderMutation.test.ts +++ b/apps/ui/src/hooks/movie/useSelectMovieForFolderMutation.test.ts @@ -2,34 +2,17 @@ import React from "react" import { describe, it, expect, vi, beforeEach } from "vitest" import { renderHook, act, waitFor } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import type { MovieMediaMetadata, TMDBMovie } from "@smm/types" +import type { TMDBMovie } from "@smm/types" import type { TVDBv4SearchResult } from "@smm/tvdb4" import type { TVDBSearchItem } from "@/lib/tvdbSearchNormalize" import { useSelectMovieForFolderMutation } from "./useSelectMovieForFolderMutation" -import { useGetTmdbMovieMutation } from "../useGetTmdbMovieMutation" -import { useGetTvdbMovieMutation } from "../useGetTvdbMovieMutation" -import { isSmmV3Enabled } from "@/lib/localStorages" import { toast } from "sonner" const hoisted = vi.hoisted(() => ({ - fetchMediaMetadataAsync: vi.fn(), - updateMediaMetadataAsync: vi.fn(), updateFolderStatus: vi.fn(), recognizeFolderViaCore: vi.fn(), })) -vi.mock("@/hooks/mediaMetadata/useFetchMediaMetadataMutation", () => ({ - useFetchMediaMetadataMutation: vi.fn(() => ({ - mutateAsync: hoisted.fetchMediaMetadataAsync, - })), -})) - -vi.mock("@/hooks/mediaMetadata/useUpdateMediaMetadataMutation", () => ({ - useUpdateMediaMetadataMutation: vi.fn(() => ({ - mutateAsync: hoisted.updateMediaMetadataAsync, - })), -})) - vi.mock("@/stores/uiMediaFolderStore", () => ({ useUIMediaFolderStore: { getState: () => ({ updateFolderStatus: hoisted.updateFolderStatus }), @@ -40,55 +23,10 @@ vi.mock("@/api/recognizeFolder", () => ({ recognizeFolderViaCore: hoisted.recognizeFolderViaCore, })) -vi.mock("@/lib/localStorages", () => ({ - isSmmV3Enabled: vi.fn().mockReturnValue(false), -})) - -vi.mock("@/lib/utils", async (importOriginal) => { - const mod = await importOriginal() - return { ...mod, nextTraceId: () => "test-trace" } -}) - vi.mock("sonner", () => ({ toast: { error: vi.fn() }, })) -const resolvedMovie: MovieMediaMetadata = { - id: "99", - name: "Resolved Movie", - database: "TMDB", -} - -vi.mock("../useGetTmdbMovieMutation", () => ({ - useGetTmdbMovieMutation: vi.fn((options?: { onMutate?: (v: unknown) => void; onSuccess?: (d: unknown, v: unknown) => void; onError?: (e: Error, v: unknown) => void }) => ({ - mutate: vi.fn((vars: unknown) => { - options?.onMutate?.(vars) - options?.onSuccess?.(resolvedMovie, vars) - }), - mutateAsync: vi.fn(async (vars: unknown) => { - options?.onMutate?.(vars) - options?.onSuccess?.(resolvedMovie, vars) - return resolvedMovie - }), - isPending: false, - })), -})) - -vi.mock("../useGetTvdbMovieMutation", () => ({ - useGetTvdbMovieMutation: vi.fn((options?: { onMutate?: (v: unknown) => void; onSuccess?: (d: unknown, v: unknown) => void; onError?: (e: Error, v: unknown) => void }) => ({ - mutate: vi.fn((vars: unknown) => { - options?.onMutate?.(vars) - options?.onSuccess?.(resolvedMovie, vars) - }), - mutateAsync: vi.fn(async (vars: unknown) => { - options?.onMutate?.(vars) - options?.onSuccess?.(resolvedMovie, vars) - return resolvedMovie - }), - isPending: false, - })), -})) - function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, @@ -138,20 +76,24 @@ function tvdbResult(overrides: Partial = {}): TVDBv4SearchRe describe("useSelectMovieForFolderMutation", () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(isSmmV3Enabled).mockReturnValue(false) - hoisted.fetchMediaMetadataAsync.mockResolvedValue({ - ...baseMovieFolderMetadata, - }) - hoisted.updateMediaMetadataAsync.mockResolvedValue(undefined) hoisted.recognizeFolderViaCore.mockResolvedValue(undefined) }) - it("TMDB: routes mutate to useGetTmdbMovieMutation with id, language, mediaFolderPath, traceId, baseMetadata", () => { + it("TMDB: calls recognizeFolderViaCore with tmdb db and id, and invalidates media metadata query", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries") + + function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(QueryClientProvider, { client: queryClient }, children) + } + const { result } = renderHook(() => useSelectMovieForFolderMutation(), { - wrapper: createWrapper(), + wrapper: Wrapper, }) - act(() => { + await act(async () => { result.current.selectMovieForFolderMutation.mutate({ mediaFolderPath: "/library/movie", baseMetadata: baseMovieFolderMetadata, @@ -161,30 +103,27 @@ describe("useSelectMovieForFolderMutation", () => { }) }) - expect(useGetTmdbMovieMutation).toHaveBeenCalled() - const tmdbReturn = vi.mocked(useGetTmdbMovieMutation).mock.results[0]!.value as { - mutate: ReturnType - } - expect(tmdbReturn.mutate).toHaveBeenCalledWith({ - id: 99, - language: "en-US", - mediaFolderPath: "/library/movie", - traceId: "MovieSearchResultSelected-test-trace", - baseMetadata: baseMovieFolderMetadata, + await waitFor(() => { + expect(hoisted.recognizeFolderViaCore).toHaveBeenCalledWith({ + path: "/library/movie", + db: "tmdb", + id: "99", + }) }) - const tvdbReturn = vi.mocked(useGetTvdbMovieMutation).mock.results[0]!.value as { - mutate: ReturnType - } - expect(tvdbReturn.mutate).not.toHaveBeenCalled() + expect(hoisted.updateFolderStatus).toHaveBeenCalledWith("/library/movie", "loading") + expect(hoisted.updateFolderStatus).toHaveBeenCalledWith("/library/movie", "ok") + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ["mediaMetadata", "/library/movie"], + }) }) - it("TVDB: routes mutate to useGetTvdbMovieMutation with movieId from tvdb_id", () => { + it("TVDB: calls recognizeFolderViaCore with tvdb db and id from tvdb_id", async () => { const { result } = renderHook(() => useSelectMovieForFolderMutation(), { wrapper: createWrapper(), }) - act(() => { + await act(async () => { result.current.selectMovieForFolderMutation.mutate({ mediaFolderPath: "/library/movie", baseMetadata: baseMovieFolderMetadata, @@ -194,30 +133,22 @@ describe("useSelectMovieForFolderMutation", () => { }) }) - const tvdbReturn = vi.mocked(useGetTvdbMovieMutation).mock.results[0]!.value as { - mutate: ReturnType - } - expect(tvdbReturn.mutate).toHaveBeenCalledWith({ - movieId: 888, - language: "zh-CN", - mediaFolderPath: "/library/movie", - traceId: "MovieSearchResultSelected-test-trace", - baseMetadata: baseMovieFolderMetadata, + await waitFor(() => { + expect(hoisted.recognizeFolderViaCore).toHaveBeenCalledWith({ + path: "/library/movie", + db: "tvdb", + id: "888", + }) }) - - const tmdbReturn = vi.mocked(useGetTmdbMovieMutation).mock.results[0]!.value as { - mutate: ReturnType - } - expect(tmdbReturn.mutate).not.toHaveBeenCalled() }) - it("runs onMutate/onSuccess: folder loading, persists base then movie via updateMediaMetadata", async () => { + it("mutateAsync returns when recognizeFolderViaCore resolves", async () => { const { result } = renderHook(() => useSelectMovieForFolderMutation(), { wrapper: createWrapper(), }) - act(() => { - result.current.selectMovieForFolderMutation.mutate({ + await act(async () => { + await result.current.selectMovieForFolderMutation.mutateAsync({ mediaFolderPath: "/library/movie", baseMetadata: baseMovieFolderMetadata, database: "TMDB", @@ -226,127 +157,22 @@ describe("useSelectMovieForFolderMutation", () => { }) }) - expect(hoisted.updateFolderStatus).toHaveBeenCalledWith("/library/movie", "loading") - - await waitFor(() => { - expect(hoisted.fetchMediaMetadataAsync).toHaveBeenCalled() - expect(hoisted.updateMediaMetadataAsync).toHaveBeenCalled() + expect(hoisted.recognizeFolderViaCore).toHaveBeenCalledWith({ + path: "/library/movie", + db: "tmdb", + id: "99", }) - - const writes = hoisted.updateMediaMetadataAsync.mock.calls.map((c) => c[0].metadata) - const withMovie = writes.find((m) => (m as { movie?: MovieMediaMetadata }).movie?.id === "99") - expect(withMovie).toBeDefined() - expect((withMovie as { movie: MovieMediaMetadata }).movie.name).toBe("Resolved Movie") - - const baseWrite = writes.find( - (m) => - (m as { movie?: MovieMediaMetadata }).movie === undefined && - (m as { type?: string }).type === "movie-folder", - ) - expect(baseWrite).toBeDefined() - }) - - it("onTmdbError: toast.error with TMDB prefix", () => { - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) - try { - vi.mocked(useGetTmdbMovieMutation).mockImplementationOnce( - ((options?: { onMutate?: (v: unknown) => void; onError?: (e: Error, v: unknown) => void }) => ({ - mutate: vi.fn((vars: unknown) => { - options?.onMutate?.(vars) - options?.onError?.(new Error("TMDB down"), vars) - }), - mutateAsync: vi.fn(), - isPending: false, - })) as typeof useGetTmdbMovieMutation, - ) - - const { result } = renderHook(() => useSelectMovieForFolderMutation(), { - wrapper: createWrapper(), - }) - - act(() => { - result.current.selectMovieForFolderMutation.mutate({ - mediaFolderPath: "/library/movie", - baseMetadata: baseMovieFolderMetadata, - database: "TMDB", - result: minimalTmdbMovie, - searchLanguage: "en-US", - }) - }) - - expect(toast.error).toHaveBeenCalledWith("Unable to fetch data from TMDB: TMDB down") - } finally { - consoleError.mockRestore() - } - }) - - it("onTvdbError: toast.error with TVDB prefix", () => { - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) - try { - vi.mocked(useGetTvdbMovieMutation).mockImplementationOnce( - ((options?: { onMutate?: (v: unknown) => void; onError?: (e: Error, v: unknown) => void }) => ({ - mutate: vi.fn((vars: unknown) => { - options?.onMutate?.(vars) - options?.onError?.(new Error("TVDB down"), vars) - }), - mutateAsync: vi.fn(), - isPending: false, - })) as typeof useGetTvdbMovieMutation, - ) - - const { result } = renderHook(() => useSelectMovieForFolderMutation(), { - wrapper: createWrapper(), - }) - - act(() => { - result.current.selectMovieForFolderMutation.mutate({ - mediaFolderPath: "/library/movie", - baseMetadata: baseMovieFolderMetadata, - database: "TVDB", - result: tvdbResult() as unknown as TVDBSearchItem, - searchLanguage: "en-US", - }) - }) - - expect(toast.error).toHaveBeenCalledWith("Unable to fetch data from TVDB: TVDB down") - } finally { - consoleError.mockRestore() - } }) - it("isSelectMovieForFolderPending reflects underlying mutations", () => { - vi.mocked(useGetTmdbMovieMutation).mockImplementationOnce( - () => - ({ - mutate: vi.fn(), - mutateAsync: vi.fn(), - isPending: true, - }) as unknown as ReturnType, - ) - vi.mocked(useGetTvdbMovieMutation).mockImplementationOnce( - () => - ({ - mutate: vi.fn(), - mutateAsync: vi.fn(), - isPending: false, - }) as unknown as ReturnType, - ) + it("on recognize error: toast.error and folder status ok", async () => { + hoisted.recognizeFolderViaCore.mockRejectedValueOnce(new Error("not managed")) const { result } = renderHook(() => useSelectMovieForFolderMutation(), { wrapper: createWrapper(), }) - expect(result.current.isSelectMovieForFolderPending).toBe(true) - }) - - it("mutateAsync returns resolved movie metadata for TMDB", async () => { - const { result } = renderHook(() => useSelectMovieForFolderMutation(), { - wrapper: createWrapper(), - }) - - let out: MovieMediaMetadata | undefined await act(async () => { - out = await result.current.selectMovieForFolderMutation.mutateAsync({ + result.current.selectMovieForFolderMutation.mutate({ mediaFolderPath: "/library/movie", baseMetadata: baseMovieFolderMetadata, database: "TMDB", @@ -355,52 +181,9 @@ describe("useSelectMovieForFolderMutation", () => { }) }) - expect(out).toEqual(resolvedMovie) - }) - - describe("when SMM v3 is enabled", () => { - beforeEach(() => { - vi.mocked(isSmmV3Enabled).mockReturnValue(true) - }) - - it("TMDB: calls recognizeFolderViaCore and invalidates media metadata query", async () => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }) - const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries") - - function Wrapper({ children }: { children: React.ReactNode }) { - return React.createElement(QueryClientProvider, { client: queryClient }, children) - } - - const { result } = renderHook(() => useSelectMovieForFolderMutation(), { - wrapper: Wrapper, - }) - - await act(async () => { - result.current.selectMovieForFolderMutation.mutate({ - mediaFolderPath: "/library/movie", - baseMetadata: baseMovieFolderMetadata, - database: "TMDB", - result: minimalTmdbMovie, - searchLanguage: "en-US", - }) - }) - - await waitFor(() => { - expect(hoisted.recognizeFolderViaCore).toHaveBeenCalledWith({ - path: "/library/movie", - db: "tmdb", - id: "99", - }) - }) - - expect(hoisted.updateFolderStatus).toHaveBeenCalledWith("/library/movie", "loading") - expect(hoisted.updateFolderStatus).toHaveBeenCalledWith("/library/movie", "ok") - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ["mediaMetadata", "/library/movie"], - }) - expect(hoisted.updateMediaMetadataAsync).not.toHaveBeenCalled() + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("not managed") }) + expect(hoisted.updateFolderStatus).toHaveBeenCalledWith("/library/movie", "ok") }) }) diff --git a/apps/ui/src/hooks/movie/useSelectMovieForFolderMutation.ts b/apps/ui/src/hooks/movie/useSelectMovieForFolderMutation.ts index 4885207f..24dcb7c2 100644 --- a/apps/ui/src/hooks/movie/useSelectMovieForFolderMutation.ts +++ b/apps/ui/src/hooks/movie/useSelectMovieForFolderMutation.ts @@ -1,35 +1,13 @@ -import { useCallback, useMemo } from "react" +import { useMemo } from "react" import { useMutation, useQueryClient } from "@tanstack/react-query" -import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" -import { useUpdateMediaMetadataMutation } from "@/hooks/mediaMetadata/useUpdateMediaMetadataMutation" import { normalizeMediaFolderPathForQuery, mediaMetadataQueryKey } from "@/lib/mediaMetadataQueryKeys" import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" -import type { MediaMetadata, MovieMediaMetadata, TMDBMovie, TMDBTVShow } from "@smm/types" -import { useGetTmdbMovieMutation } from "@/hooks/useGetTmdbMovieMutation" -import { useGetTvdbMovieMutation } from "@/hooks/useGetTvdbMovieMutation" +import type { MediaMetadata, TMDBMovie, TMDBTVShow } from "@smm/types" import { recognizeFolderViaCore } from "@/api/recognizeFolder" -import { isSmmV3Enabled } from "@/lib/localStorages" -import { nextTraceId } from "@/lib/utils" import { toast } from "sonner" import type { SearchLanguage } from "@/components/MediaDatabaseSearchbox" import type { TVDBSearchItem } from "@/lib/tvdbSearchNormalize" -type ApplyMovieSelectionShared = { - mediaFolderPath: string - traceId: string - baseMetadata: MediaMetadata -} - -type ApplyTmdbMovieSelectionVars = ApplyMovieSelectionShared & { - id: number - language?: string -} - -type ApplyTvdbMovieSelectionVars = ApplyMovieSelectionShared & { - movieId: number - language?: string -} - export type SelectMovieForFolderVariables = | { mediaFolderPath: string @@ -62,76 +40,6 @@ function recognizeId( export function useSelectMovieForFolderMutation() { const queryClient = useQueryClient() - const { mutateAsync: fetchMediaMetadata } = useFetchMediaMetadataMutation() - const updateMediaMetadataMutation = useUpdateMediaMetadataMutation() - - const updateMediaMetadata = useCallback( - async ( - path: string, - updaterOrMetadata: MediaMetadata | ((current: MediaMetadata) => MediaMetadata), - options?: { traceId?: string }, - ) => { - const pathPosix = normalizeMediaFolderPathForQuery(path) - if (!pathPosix) return - const current = (await fetchMediaMetadata({ path: pathPosix, traceId: options?.traceId })) as MediaMetadata - const next = - typeof updaterOrMetadata === "function" - ? updaterOrMetadata(current) - : updaterOrMetadata - await updateMediaMetadataMutation.mutateAsync({ - pathPosix, - metadata: next, - traceId: options?.traceId, - }) - }, - [fetchMediaMetadata, updateMediaMetadataMutation], - ) - - const onMutateLegacy = useCallback( - (variables: ApplyMovieSelectionShared) => { - useUIMediaFolderStore.getState().updateFolderStatus(variables.mediaFolderPath, "loading") - void updateMediaMetadata(variables.mediaFolderPath, { ...variables.baseMetadata }, { - traceId: variables.traceId, - }) - }, - [updateMediaMetadata], - ) - - const onSuccessLegacy = useCallback( - (movie: MovieMediaMetadata, variables: ApplyMovieSelectionShared) => { - void updateMediaMetadata( - variables.mediaFolderPath, - (prev) => ({ - ...prev, - movie, - }), - { traceId: variables.traceId }, - ) - }, - [updateMediaMetadata], - ) - - const onTmdbError = useCallback((error: Error, _variables: ApplyTmdbMovieSelectionVars) => { - console.error("Failed to get TMDB movie:", error) - toast.error(`Unable to fetch data from TMDB: ${error.message}`) - }, []) - - const onTvdbError = useCallback((error: Error, _variables: ApplyTvdbMovieSelectionVars) => { - console.error("Failed to get TVDB movie:", error) - toast.error(`Unable to fetch data from TVDB: ${error.message}`) - }, []) - - const applyTmdbMovieSelectionMutation = useGetTmdbMovieMutation({ - onMutate: onMutateLegacy, - onSuccess: onSuccessLegacy, - onError: onTmdbError, - }) - - const applyTvdbMovieSelectionMutation = useGetTvdbMovieMutation({ - onMutate: onMutateLegacy, - onSuccess: onSuccessLegacy, - onError: onTvdbError, - }) const recognizeFolderMutation = useMutation({ mutationFn: async (variables: SelectMovieForFolderVariables) => { @@ -157,91 +65,16 @@ export function useSelectMovieForFolderMutation() { }, }) - const mutateLegacy = useCallback( - (variables: SelectMovieForFolderVariables) => { - const { database, result, searchLanguage, mediaFolderPath, baseMetadata } = variables - const traceId = `MovieSearchResultSelected-${nextTraceId()}` - - if (database === "TVDB") { - applyTvdbMovieSelectionMutation.mutate({ - movieId: parseInt(String(result.tvdb_id), 10), - language: searchLanguage, - mediaFolderPath, - traceId, - baseMetadata, - }) - } else { - applyTmdbMovieSelectionMutation.mutate({ - id: parseInt(String(result.id), 10), - language: searchLanguage, - mediaFolderPath, - traceId, - baseMetadata, - }) - } - }, - [applyTmdbMovieSelectionMutation, applyTvdbMovieSelectionMutation], - ) - - const mutateAsyncLegacy = useCallback( - async (variables: SelectMovieForFolderVariables) => { - const { database, result, searchLanguage, mediaFolderPath, baseMetadata } = variables - const traceId = `MovieSearchResultSelected-${nextTraceId()}` - - if (database === "TVDB") { - return applyTvdbMovieSelectionMutation.mutateAsync({ - movieId: parseInt(String(result.tvdb_id), 10), - language: searchLanguage, - mediaFolderPath, - traceId, - baseMetadata, - }) - } - return applyTmdbMovieSelectionMutation.mutateAsync({ - id: parseInt(String(result.id), 10), - language: searchLanguage, - mediaFolderPath, - traceId, - baseMetadata, - }) - }, - [applyTmdbMovieSelectionMutation, applyTvdbMovieSelectionMutation], - ) - - const mutate = useCallback( - (variables: SelectMovieForFolderVariables) => { - if (isSmmV3Enabled()) { - recognizeFolderMutation.mutate(variables) - return - } - mutateLegacy(variables) - }, - [mutateLegacy, recognizeFolderMutation], - ) - - const mutateAsync = useCallback( - async (variables: SelectMovieForFolderVariables) => { - if (isSmmV3Enabled()) { - await recognizeFolderMutation.mutateAsync(variables) - return - } - return mutateAsyncLegacy(variables) - }, - [mutateAsyncLegacy, recognizeFolderMutation], - ) - const selectMovieForFolderMutation = useMemo( - () => ({ mutate, mutateAsync }), - [mutate, mutateAsync], + () => ({ + mutate: recognizeFolderMutation.mutate, + mutateAsync: recognizeFolderMutation.mutateAsync, + }), + [recognizeFolderMutation], ) - const isSelectMovieForFolderPending = - recognizeFolderMutation.isPending || - applyTmdbMovieSelectionMutation.isPending || - applyTvdbMovieSelectionMutation.isPending - return { selectMovieForFolderMutation, - isSelectMovieForFolderPending, + isSelectMovieForFolderPending: recognizeFolderMutation.isPending, } } diff --git a/apps/ui/src/hooks/useRenameMediaFolderMutation.test.ts b/apps/ui/src/hooks/useRenameMediaFolderMutation.test.ts index 67ff8daf..94442381 100644 --- a/apps/ui/src/hooks/useRenameMediaFolderMutation.test.ts +++ b/apps/ui/src/hooks/useRenameMediaFolderMutation.test.ts @@ -4,9 +4,7 @@ import { renderHook, waitFor } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { useRenameMediaFolderMutation } from "./useRenameMediaFolderMutation" import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" -import { renameFolder } from "@/api/renameFolder" import { renameFolderViaCore } from "@/api/renameFolderV3" -import { isSmmV3Enabled } from "@/lib/localStorages" import { helloQueryKey } from "@/lib/appQueryKeys" import { mediaMetadataQueryKey, normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" import { userConfigQueryKey } from "@/lib/userConfigQueryKeys" @@ -15,15 +13,9 @@ vi.mock("@/lib/i18n", () => ({ useTranslation: () => ({ t: (key: string) => key }), })) vi.mock("sonner", () => ({ toast: { error: vi.fn() } })) -vi.mock("@/api/renameFolder", () => ({ - renameFolder: vi.fn().mockResolvedValue({}), -})) vi.mock("@/api/renameFolderV3", () => ({ renameFolderViaCore: vi.fn().mockResolvedValue(undefined), })) -vi.mock("@/lib/localStorages", () => ({ - isSmmV3Enabled: vi.fn().mockReturnValue(false), -})) vi.mock("@/hooks/folders/invalidateFoldersQuery", () => ({ invalidateFoldersQueryIfV3: vi.fn(), })) @@ -35,17 +27,15 @@ describe("useRenameMediaFolderMutation", () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(isSmmV3Enabled).mockReturnValue(false) vi.mocked(useUIMediaFolderStore).mockReturnValue({ folders: [{ path, status: "idle", test: false }], setFolders: vi.fn(), setSelectedFolder: vi.fn(), } as unknown as ReturnType) - vi.mocked(renameFolder).mockResolvedValue({}) vi.mocked(renameFolderViaCore).mockResolvedValue(undefined) }) - it("renames folder and refreshes userConfig + mediaMetadata queries", async () => { + it("renames folder via Core and refreshes userConfig + mediaMetadata queries", async () => { const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } }, }) @@ -63,10 +53,9 @@ describe("useRenameMediaFolderMutation", () => { await result.current.mutateAsync({ mediaFolderPath: path, newName }) await waitFor(() => { - expect(renameFolder).toHaveBeenCalledTimes(1) + expect(renameFolderViaCore).toHaveBeenCalledTimes(1) }) - expect(renameFolderViaCore).not.toHaveBeenCalled() - expect(renameFolder).toHaveBeenCalledWith({ + expect(renameFolderViaCore).toHaveBeenCalledWith({ from: path, to: "/media/New", }) @@ -82,27 +71,6 @@ describe("useRenameMediaFolderMutation", () => { }) }) - it("uses Core rename-folder API when SMM v3 is enabled", async () => { - vi.mocked(isSmmV3Enabled).mockReturnValue(true) - const queryClient = new QueryClient({ - defaultOptions: { mutations: { retry: false } }, - }) - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children) - - const { result } = renderHook(() => useRenameMediaFolderMutation(), { - wrapper, - }) - - await result.current.mutateAsync({ mediaFolderPath: path, newName }) - - expect(renameFolderViaCore).toHaveBeenCalledWith({ - from: path, - to: "/media/New", - }) - expect(renameFolder).not.toHaveBeenCalled() - }) - it("fails when folder is missing", async () => { vi.mocked(useUIMediaFolderStore).mockReturnValue({ folders: [], @@ -122,7 +90,6 @@ describe("useRenameMediaFolderMutation", () => { await expect( result.current.mutateAsync({ mediaFolderPath: path, newName }) ).rejects.toThrow(/Media folder not found/) - expect(renameFolder).not.toHaveBeenCalled() expect(renameFolderViaCore).not.toHaveBeenCalled() }) }) diff --git a/apps/ui/src/hooks/useRenameMediaFolderMutation.ts b/apps/ui/src/hooks/useRenameMediaFolderMutation.ts index f9e04fb7..58307ef4 100644 --- a/apps/ui/src/hooks/useRenameMediaFolderMutation.ts +++ b/apps/ui/src/hooks/useRenameMediaFolderMutation.ts @@ -1,10 +1,8 @@ import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query' import { toast } from 'sonner' import { useTranslation } from '@/lib/i18n' -import { renameFolder } from '@/api/renameFolder' import { renameFolderViaCore } from '@/api/renameFolderV3' import { refreshUiAfterFolderRename } from '@/lib/refreshUiAfterFolderRename' -import { isSmmV3Enabled } from '@/lib/localStorages' import { invalidateFoldersQueryIfV3 } from '@/hooks/folders/invalidateFoldersQuery' import { useUIMediaFolderStore } from '@/stores/uiMediaFolderStore' import { dirname, join } from '@/lib/path' @@ -15,8 +13,8 @@ export interface RenameMediaFolderVariables { } /** - * Renames a media folder via API and refreshes client state. - * When `smm.v3.enabled` is on, uses `POST /api/rename-folder` → Core.renameFolder. + * Renames a media folder via `POST /api/rename-folder` → Core.renameFolder + * and refreshes client state. */ export function useRenameMediaFolderMutation( options?: Omit< @@ -38,11 +36,7 @@ export function useRenameMediaFolderMutation( } const newFolderPath = join(dirname(mediaFolderPath), newName) - if (isSmmV3Enabled()) { - await renameFolderViaCore({ from: mediaFolderPath, to: newFolderPath }) - } else { - await renameFolder({ from: mediaFolderPath, to: newFolderPath }) - } + await renameFolderViaCore({ from: mediaFolderPath, to: newFolderPath }) await refreshUiAfterFolderRename({ queryClient, diff --git a/apps/ui/src/hooks/useRenameVideoFileFlow.test.ts b/apps/ui/src/hooks/useRenameVideoFileFlow.test.ts index 6c765e3d..90c1dbb0 100644 --- a/apps/ui/src/hooks/useRenameVideoFileFlow.test.ts +++ b/apps/ui/src/hooks/useRenameVideoFileFlow.test.ts @@ -1,22 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { renderHook, act } from "@testing-library/react" -vi.mock("@/lib/localStorages", () => ({ - isSmmV3Enabled: vi.fn().mockReturnValue(false), -})) - -vi.mock("@/api/renameFiles", () => ({ - renameFiles: vi.fn().mockResolvedValue({}), +vi.mock("@/api/renameEpisodeFile", () => ({ + renameEpisodeFileViaCore: vi.fn().mockResolvedValue(undefined), })) vi.mock("@/hooks/mediaMetadata/useFetchMediaMetadataMutation", () => ({ useFetchMediaMetadataMutation: vi.fn(), })) -vi.mock("@/components/episode-file", () => ({ - computeAssociatedFileRenames: vi.fn().mockReturnValue([]), -})) - vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() }, })) @@ -25,9 +17,8 @@ vi.mock("@/lib/i18n", () => ({ useTranslation: () => ({ t: (key: string) => key }), })) -import { renameFiles } from "@/api/renameFiles" +import { renameEpisodeFileViaCore } from "@/api/renameEpisodeFile" import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" -import { computeAssociatedFileRenames } from "@/components/episode-file" import { toast } from "sonner" import { useRenameVideoFileFlow } from "./useRenameVideoFileFlow" import type { UIMediaFileDataRow } from "@/components/media/UIMediaFileTable" @@ -37,9 +28,8 @@ interface MockFetchMutation { } describe("useRenameVideoFileFlow", () => { - const renameFilesMock = vi.mocked(renameFiles) + const renameViaCoreMock = vi.mocked(renameEpisodeFileViaCore) const useFetchMock = vi.mocked(useFetchMediaMetadataMutation) - const computeAssocMock = vi.mocked(computeAssociatedFileRenames) const toastSuccess = vi.mocked(toast.success) const toastError = vi.mocked(toast.error) @@ -47,11 +37,6 @@ describe("useRenameVideoFileFlow", () => { const fetchMediaMetadata = vi.fn().mockResolvedValue({}) const mediaFolderPath = "/media/show" - const files = [ - "/media/show/S01E01.mkv", - "/media/show/S01E01.srt", - "/media/show/S01E01.nfo", - ] const baseRow: UIMediaFileDataRow = { season: 1, @@ -65,14 +50,12 @@ describe("useRenameVideoFileFlow", () => { } beforeEach(() => { - renameFilesMock.mockReset() - renameFilesMock.mockResolvedValue({}) + renameViaCoreMock.mockReset() + renameViaCoreMock.mockResolvedValue(undefined) useFetchMock.mockReset() useFetchMock.mockReturnValue({ mutateAsync: fetchMediaMetadata, } as unknown as MockFetchMutation) - computeAssocMock.mockReset() - computeAssocMock.mockReturnValue([]) toastSuccess.mockReset() toastError.mockReset() openRename.mockReset() @@ -82,7 +65,7 @@ describe("useRenameVideoFileFlow", () => { it("is a no-op when the row has no videoFile", () => { const { result } = renderHook(() => - useRenameVideoFileFlow({ mediaFolderPath, files, openRenameDialog: openRename }), + useRenameVideoFileFlow({ mediaFolderPath, openRenameDialog: openRename }), ) act(() => { @@ -94,7 +77,7 @@ describe("useRenameVideoFileFlow", () => { it("is a no-op when mediaFolderPath is undefined", () => { const { result } = renderHook(() => - useRenameVideoFileFlow({ mediaFolderPath: undefined, files, openRenameDialog: openRename }), + useRenameVideoFileFlow({ mediaFolderPath: undefined, openRenameDialog: openRename }), ) act(() => { @@ -106,7 +89,7 @@ describe("useRenameVideoFileFlow", () => { it("opens the rename dialog with the relative path as initial value", () => { const { result } = renderHook(() => - useRenameVideoFileFlow({ mediaFolderPath, files, openRenameDialog: openRename }), + useRenameVideoFileFlow({ mediaFolderPath, openRenameDialog: openRename }), ) act(() => { @@ -119,15 +102,11 @@ describe("useRenameVideoFileFlow", () => { expect(options?.initialValue).toBe("S01E01.mkv") }) - it("renames the video file plus associated files and refetches metadata on success", async () => { - computeAssocMock.mockReturnValue([ - { from: "/media/show/S01E01.srt", to: "/media/show/S01E02.srt" }, - ]) + it("renames the video file via Core and refetches metadata on success", async () => { const onAfterRename = vi.fn().mockResolvedValue(undefined) const { result } = renderHook(() => useRenameVideoFileFlow({ mediaFolderPath, - files, onAfterRename, openRenameDialog: openRename, }), @@ -142,17 +121,10 @@ describe("useRenameVideoFileFlow", () => { await confirm("S01E02.mkv") }) - expect(computeAssocMock).toHaveBeenCalledWith( - "/media/show/S01E01.mkv", - "/media/show/S01E02.mkv", - files, - ) - expect(renameFilesMock).toHaveBeenCalledWith({ - files: [ - { from: "/media/show/S01E01.mkv", to: "/media/show/S01E02.mkv" }, - { from: "/media/show/S01E01.srt", to: "/media/show/S01E02.srt" }, - ], + expect(renameViaCoreMock).toHaveBeenCalledWith({ mediaFolder: "/media/show", + from: "/media/show/S01E01.mkv", + to: "/media/show/S01E02.mkv", }) expect(onAfterRename).toHaveBeenCalledTimes(1) expect(fetchMediaMetadata).toHaveBeenCalledWith({ path: mediaFolderPath }) @@ -160,13 +132,12 @@ describe("useRenameVideoFileFlow", () => { expect(toastError).not.toHaveBeenCalled() }) - it("toasts an error and rethrows when renameFiles fails", async () => { - renameFilesMock.mockRejectedValueOnce(new Error("boom")) + it("toasts an error and rethrows when renameEpisodeFileViaCore fails", async () => { + renameViaCoreMock.mockRejectedValueOnce(new Error("boom")) const onAfterRename = vi.fn() const { result } = renderHook(() => useRenameVideoFileFlow({ mediaFolderPath, - files, onAfterRename, openRenameDialog: openRename, }), diff --git a/apps/ui/src/hooks/useRenameVideoFileFlow.ts b/apps/ui/src/hooks/useRenameVideoFileFlow.ts index 2a576a11..2d29a7c8 100644 --- a/apps/ui/src/hooks/useRenameVideoFileFlow.ts +++ b/apps/ui/src/hooks/useRenameVideoFileFlow.ts @@ -3,11 +3,8 @@ import { toast } from "sonner" import { Path } from "@smm/utils/path" import { useTranslation } from "@/lib/i18n" import { join, relative } from "@/lib/path" -import { renameFiles } from "@/api/renameFiles" import { renameEpisodeFileViaCore } from "@/api/renameEpisodeFile" -import { computeAssociatedFileRenames } from "@/components/episode-file" import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" -import { isSmmV3Enabled } from "@/lib/localStorages" import type { UIMediaFileDataRow } from "@/components/media/UIMediaFileTable" export interface RenameFileDialogOptions { @@ -23,17 +20,6 @@ export interface UseRenameVideoFileFlowOptions { * `undefined` disables the flow (the click handler becomes a no-op). */ mediaFolderPath: string | undefined - /** - * Every file path (absolute) that belongs to the media folder. Used by - * `computeAssociatedFileRenames` to find sibling subtitle / thumbnail / nfo - * files that share the video file's stem (legacy / movie path only). - */ - files: string[] - /** - * When `smm.v3.enabled`, confirm calls Core `renameEpisodeFile` (TV + movie). - * Default `"generic"` keeps legacy `/api/renameFiles` when v3 is off. - */ - mode?: "episode" | "generic" /** * Optional hook called after rename succeeds and before * `fetchMediaMetadata`. Lets the panel refresh local state synchronously @@ -53,7 +39,7 @@ export interface UseRenameVideoFileFlowOptions { export interface RenameVideoFileFlow { /** * Open the rename dialog for `row` and, on confirm, rename the video file - * (and any associated files), then refetch the media folder metadata. + * via Core `renameEpisodeFile`, then refetch the media folder metadata. * No-op when the row has no `videoFile` or the hook was constructed without * a `mediaFolderPath`. */ @@ -64,16 +50,14 @@ export interface RenameVideoFileFlow { * Encapsulates the "rename the selected video file" right-click flow that * `TvShowPanel` and `MoviePanel` inject into `MediaFileTable`. * - * TV + movie + v3 ON: `POST /api/rename-episode-file` → Core. - * Otherwise: client expands associates and calls `POST /api/renameFiles`. + * TV + movie: `POST /api/rename-episode-file` → Core. */ export function useRenameVideoFileFlow( options: UseRenameVideoFileFlowOptions, ): RenameVideoFileFlow { - const { mediaFolderPath, files, onAfterRename, openRenameDialog } = options + const { mediaFolderPath, onAfterRename, openRenameDialog } = options const { t } = useTranslation(["components", "dialogs"]) const { mutateAsync: fetchMediaMetadata } = useFetchMediaMetadataMutation() - const openRename = openRenameDialog const onRenameContextMenuClick = useCallback( (row: UIMediaFileDataRow) => { @@ -86,31 +70,16 @@ export function useRenameVideoFileFlow( initialValue = row.videoFile } - openRename( + openRenameDialog( async (newRelativePath: string) => { if (!row.videoFile) return const newAbsolutePath = join(mediaFolderPath, newRelativePath) try { - if (isSmmV3Enabled()) { - await renameEpisodeFileViaCore({ - mediaFolder: Path.posix(mediaFolderPath), - from: row.videoFile, - to: newAbsolutePath, - }) - } else { - const assocRenames = computeAssociatedFileRenames( - row.videoFile, - newAbsolutePath, - files, - ) - await renameFiles({ - files: [ - { from: row.videoFile, to: newAbsolutePath }, - ...assocRenames, - ], - mediaFolder: Path.posix(mediaFolderPath), - }) - } + await renameEpisodeFileViaCore({ + mediaFolder: Path.posix(mediaFolderPath), + from: row.videoFile, + to: newAbsolutePath, + }) await onAfterRename?.() await fetchMediaMetadata({ path: mediaFolderPath }) toast.success(t("episodeFile.renameSuccess")) @@ -134,7 +103,6 @@ export function useRenameVideoFileFlow( }, [ mediaFolderPath, - files, onAfterRename, openRenameDialog, fetchMediaMetadata, diff --git a/apps/ui/src/hooks/useSelectTvShowForFolderMutation.test.ts b/apps/ui/src/hooks/useSelectTvShowForFolderMutation.test.ts index 31a7126f..5c744560 100644 --- a/apps/ui/src/hooks/useSelectTvShowForFolderMutation.test.ts +++ b/apps/ui/src/hooks/useSelectTvShowForFolderMutation.test.ts @@ -1,15 +1,11 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import React from "react" import { describe, it, expect, vi, beforeEach } from "vitest" import { renderHook, act, waitFor } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import type { TMDBTVShow, TvShowMediaMetadata } from "@smm/types" +import type { TMDBTVShow } from "@smm/types" import type { TVDBv4SearchResult } from "@smm/tvdb4" import type { TVDBSearchItem } from "@/lib/tvdbSearchNormalize" import { useSelectTvShowForFolderMutation } from "./useSelectTvShowForFolderMutation" -import { useGetTmdbTvShowMutation } from "./useGetTmdbTvShowMutation" -import { useGetTvdbTvShowMutation } from "./useGetTvdbTvShowMutation" -import { isSmmV3Enabled } from "@/lib/localStorages" import { toast } from "sonner" const hoisted = vi.hoisted(() => ({ @@ -41,56 +37,10 @@ vi.mock("@/api/recognizeFolder", () => ({ recognizeFolderViaCore: hoisted.recognizeFolderViaCore, })) -vi.mock("@/lib/localStorages", () => ({ - isSmmV3Enabled: vi.fn().mockReturnValue(false), -})) - -vi.mock("@/lib/utils", async (importOriginal) => { - const mod = await importOriginal() - return { ...mod, nextTraceId: () => "test-trace" } -}) - vi.mock("sonner", () => ({ toast: { error: vi.fn() }, })) -const resolvedTvShow: TvShowMediaMetadata = { - id: "42", - name: "Resolved Show", - database: "TMDB", - seasons: [], -} - -vi.mock("./useGetTmdbTvShowMutation", () => ({ - useGetTmdbTvShowMutation: vi.fn((options?: { onMutate?: (v: unknown) => void; onSuccess?: (d: unknown, v: unknown) => void; onError?: (e: Error, v: unknown) => void }) => ({ - mutate: vi.fn((vars: unknown) => { - options?.onMutate?.(vars) - options?.onSuccess?.(resolvedTvShow, vars) - }), - mutateAsync: vi.fn(async (vars: unknown) => { - options?.onMutate?.(vars) - options?.onSuccess?.(resolvedTvShow, vars) - return resolvedTvShow - }), - isPending: false, - })), -})) - -vi.mock("./useGetTvdbTvShowMutation", () => ({ - useGetTvdbTvShowMutation: vi.fn((options?: { onMutate?: (v: unknown) => void; onSuccess?: (d: unknown, v: unknown) => void; onError?: (e: Error, v: unknown) => void }) => ({ - mutate: vi.fn((vars: unknown) => { - options?.onMutate?.(vars) - options?.onSuccess?.(resolvedTvShow, vars) - }), - mutateAsync: vi.fn(async (vars: unknown) => { - options?.onMutate?.(vars) - options?.onSuccess?.(resolvedTvShow, vars) - return resolvedTvShow - }), - isPending: false, - })), -})) - function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, @@ -134,7 +84,6 @@ function tvdbResult(overrides: Partial = {}): TVDBv4SearchRe describe("useSelectTvShowForFolderMutation", () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(isSmmV3Enabled).mockReturnValue(false) hoisted.fetchMediaMetadataAsync.mockResolvedValue({ mediaFolderPath: "/library/show", type: "tvshow-folder", @@ -143,12 +92,21 @@ describe("useSelectTvShowForFolderMutation", () => { hoisted.recognizeFolderViaCore.mockResolvedValue(undefined) }) - it("TMDB: routes mutate to useGetTmdbTvShowMutation with id, language, mediaFolderPath, traceId", () => { + it("TMDB: calls recognizeFolderViaCore and invalidates media metadata query", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries") + + function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(QueryClientProvider, { client: queryClient }, children) + } + const { result } = renderHook(() => useSelectTvShowForFolderMutation(), { - wrapper: createWrapper(), + wrapper: Wrapper, }) - act(() => { + await act(async () => { result.current.selectTvShowForFolderMutation.mutate({ mediaFolderPath: "/library/show", database: "TMDB", @@ -157,29 +115,28 @@ describe("useSelectTvShowForFolderMutation", () => { }) }) - expect(useGetTmdbTvShowMutation).toHaveBeenCalled() - const tmdbReturn = vi.mocked(useGetTmdbTvShowMutation).mock.results[0]!.value as { - mutate: ReturnType - } - expect(tmdbReturn.mutate).toHaveBeenCalledWith({ - id: 42, - language: "en-US", - mediaFolderPath: "/library/show", - traceId: "TvShowSearchResultSelected-test-trace", + await waitFor(() => { + expect(hoisted.recognizeFolderViaCore).toHaveBeenCalledWith({ + path: "/library/show", + db: "tmdb", + id: "42", + }) }) - const tvdbReturn = vi.mocked(useGetTvdbTvShowMutation).mock.results[0]!.value as { - mutate: ReturnType - } - expect(tvdbReturn.mutate).not.toHaveBeenCalled() + expect(hoisted.updateFolderStatus).toHaveBeenCalledWith(expect.any(String), "loading") + expect(hoisted.updateFolderStatus).toHaveBeenCalledWith(expect.any(String), "ok") + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ["mediaMetadata", "/library/show"], + }) + expect(hoisted.updateMediaMetadataAsync).not.toHaveBeenCalled() }) - it("TVDB: routes mutate to useGetTvdbTvShowMutation with seriesId from tvdb_id", () => { + it("TVDB: calls recognizeFolderViaCore with tvdb db and id", async () => { const { result } = renderHook(() => useSelectTvShowForFolderMutation(), { wrapper: createWrapper(), }) - act(() => { + await act(async () => { result.current.selectTvShowForFolderMutation.mutate({ mediaFolderPath: "/library/show", database: "TVDB", @@ -188,28 +145,23 @@ describe("useSelectTvShowForFolderMutation", () => { }) }) - const tvdbReturn = vi.mocked(useGetTvdbTvShowMutation).mock.results[0]!.value as { - mutate: ReturnType - } - expect(tvdbReturn.mutate).toHaveBeenCalledWith({ - seriesId: 999, - language: "zh-CN", - mediaFolderPath: "/library/show", - traceId: "TvShowSearchResultSelected-test-trace", + await waitFor(() => { + expect(hoisted.recognizeFolderViaCore).toHaveBeenCalledWith({ + path: "/library/show", + db: "tvdb", + id: "999", + }) }) - - const tmdbReturn = vi.mocked(useGetTmdbTvShowMutation).mock.results[0]!.value as { - mutate: ReturnType - } - expect(tmdbReturn.mutate).not.toHaveBeenCalled() }) - it("runs onMutate/onSuccess: folder loading then ok, persists tvShow via updateMediaMetadata", async () => { + it("on recognize error: toast.error and folder status ok", async () => { + hoisted.recognizeFolderViaCore.mockRejectedValueOnce(new Error("not managed")) + const { result } = renderHook(() => useSelectTvShowForFolderMutation(), { wrapper: createWrapper(), }) - act(() => { + await act(async () => { result.current.selectTvShowForFolderMutation.mutate({ mediaFolderPath: "/library/show", database: "TMDB", @@ -218,21 +170,10 @@ describe("useSelectTvShowForFolderMutation", () => { }) }) - expect(hoisted.updateFolderStatus).toHaveBeenCalledWith( - expect.any(String), - "loading", - ) - expect(hoisted.updateFolderStatus).toHaveBeenCalledWith(expect.any(String), "ok") - await waitFor(() => { - expect(hoisted.fetchMediaMetadataAsync).toHaveBeenCalled() - expect(hoisted.updateMediaMetadataAsync).toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledWith("not managed") }) - - const writes = hoisted.updateMediaMetadataAsync.mock.calls.map((c) => c[0].metadata) - const withTvShow = writes.find((m) => (m as { tvShow?: TvShowMediaMetadata }).tvShow?.id === "42") - expect(withTvShow).toBeDefined() - expect((withTvShow as { tvShow: TvShowMediaMetadata }).tvShow.name).toBe("Resolved Show") + expect(hoisted.updateFolderStatus).toHaveBeenCalledWith(expect.any(String), "ok") }) it("updateMediaMetadata: fetch then persist merged metadata", async () => { @@ -264,148 +205,4 @@ describe("useSelectTvShowForFolderMutation", () => { }), ) }) - - it("onError: toast.error and folder status ok", () => { - vi.mocked(useGetTmdbTvShowMutation).mockImplementationOnce( - ((options?: { onMutate?: (v: unknown) => void; onError?: (e: Error, v: unknown) => void }) => ({ - mutate: vi.fn((vars: unknown) => { - options?.onMutate?.(vars) - options?.onError?.(new Error("TMDB down"), vars) - }), - mutateAsync: vi.fn(), - isPending: false, - })) as any, - ) - - const { result } = renderHook(() => useSelectTvShowForFolderMutation(), { - wrapper: createWrapper(), - }) - - act(() => { - result.current.selectTvShowForFolderMutation.mutate({ - mediaFolderPath: "/library/show", - database: "TMDB", - result: minimalTmdbTv, - searchLanguage: "en-US", - }) - }) - - expect(toast.error).toHaveBeenCalledWith("TMDB down") - expect(hoisted.updateFolderStatus).toHaveBeenCalledWith(expect.any(String), "ok") - }) - - it("isSelectTvShowForFolderPending reflects underlying mutations", () => { - vi.mocked(useGetTmdbTvShowMutation).mockImplementationOnce( - () => - ({ - mutate: vi.fn(), - mutateAsync: vi.fn(), - isPending: true, - }) as unknown as ReturnType, - ) - vi.mocked(useGetTvdbTvShowMutation).mockImplementationOnce( - () => - ({ - mutate: vi.fn(), - mutateAsync: vi.fn(), - isPending: false, - }) as unknown as ReturnType, - ) - - const { result } = renderHook(() => useSelectTvShowForFolderMutation(), { - wrapper: createWrapper(), - }) - - expect(result.current.isSelectTvShowForFolderPending).toBe(true) - }) - - describe("when SMM v3 is enabled", () => { - beforeEach(() => { - vi.mocked(isSmmV3Enabled).mockReturnValue(true) - }) - - it("TMDB: calls recognizeFolderViaCore and invalidates media metadata query", async () => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }) - const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries") - - function Wrapper({ children }: { children: React.ReactNode }) { - return React.createElement(QueryClientProvider, { client: queryClient }, children) - } - - const { result } = renderHook(() => useSelectTvShowForFolderMutation(), { - wrapper: Wrapper, - }) - - await act(async () => { - result.current.selectTvShowForFolderMutation.mutate({ - mediaFolderPath: "/library/show", - database: "TMDB", - result: minimalTmdbTv, - searchLanguage: "en-US", - }) - }) - - await waitFor(() => { - expect(hoisted.recognizeFolderViaCore).toHaveBeenCalledWith({ - path: "/library/show", - db: "tmdb", - id: "42", - }) - }) - - expect(hoisted.updateFolderStatus).toHaveBeenCalledWith(expect.any(String), "loading") - expect(hoisted.updateFolderStatus).toHaveBeenCalledWith(expect.any(String), "ok") - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ["mediaMetadata", "/library/show"], - }) - expect(hoisted.updateMediaMetadataAsync).not.toHaveBeenCalled() - }) - - it("TVDB: calls recognizeFolderViaCore with tvdb db and id", async () => { - const { result } = renderHook(() => useSelectTvShowForFolderMutation(), { - wrapper: createWrapper(), - }) - - await act(async () => { - result.current.selectTvShowForFolderMutation.mutate({ - mediaFolderPath: "/library/show", - database: "TVDB", - result: tvdbResult({ tvdb_id: "999" }) as unknown as TVDBSearchItem, - searchLanguage: "zh-CN", - }) - }) - - await waitFor(() => { - expect(hoisted.recognizeFolderViaCore).toHaveBeenCalledWith({ - path: "/library/show", - db: "tvdb", - id: "999", - }) - }) - }) - - it("on recognize error: toast.error and folder status ok", async () => { - hoisted.recognizeFolderViaCore.mockRejectedValueOnce(new Error("not managed")) - - const { result } = renderHook(() => useSelectTvShowForFolderMutation(), { - wrapper: createWrapper(), - }) - - await act(async () => { - result.current.selectTvShowForFolderMutation.mutate({ - mediaFolderPath: "/library/show", - database: "TMDB", - result: minimalTmdbTv, - searchLanguage: "en-US", - }) - }) - - await waitFor(() => { - expect(toast.error).toHaveBeenCalledWith("not managed") - }) - expect(hoisted.updateFolderStatus).toHaveBeenCalledWith(expect.any(String), "ok") - }) - }) }) diff --git a/apps/ui/src/hooks/useSelectTvShowForFolderMutation.ts b/apps/ui/src/hooks/useSelectTvShowForFolderMutation.ts index 0a7789a3..87538e13 100644 --- a/apps/ui/src/hooks/useSelectTvShowForFolderMutation.ts +++ b/apps/ui/src/hooks/useSelectTvShowForFolderMutation.ts @@ -5,31 +5,13 @@ import { useUpdateMediaMetadataMutation } from "@/hooks/mediaMetadata/useUpdateM import { normalizeMediaFolderPathForQuery, mediaMetadataQueryKey } from "@/lib/mediaMetadataQueryKeys" import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" import { Path } from "@smm/utils/path" -import type { MediaMetadata, TMDBMovie, TMDBTVShow, TvShowMediaMetadata } from "@smm/types" -import { useGetTmdbTvShowMutation } from "@/hooks/useGetTmdbTvShowMutation" -import { useGetTvdbTvShowMutation } from "@/hooks/useGetTvdbTvShowMutation" +import type { MediaMetadata, TMDBMovie, TMDBTVShow } from "@smm/types" import { recognizeFolderViaCore } from "@/api/recognizeFolder" -import { isSmmV3Enabled } from "@/lib/localStorages" -import { nextTraceId } from "@/lib/utils" import { toast } from "sonner" import type { SearchLanguage } from "@/components/MediaDatabaseSearchbox" import type { TVDBSearchItem } from "@/lib/tvdbSearchNormalize" import type { TVDBv4SearchResult } from "@smm/tvdb4" -type ApplyTmdbTvShowSelectionVars = { - id: number - language?: string - mediaFolderPath: string - traceId: string -} - -type ApplyTvdbTvShowSelectionVars = { - seriesId: number - language?: string - mediaFolderPath: string - traceId: string -} - export type SelectTvShowForFolderVariables = | { mediaFolderPath: string @@ -87,45 +69,6 @@ export function useSelectTvShowForFolderMutation() { const setFolderStatus = useUIMediaFolderStore.getState().updateFolderStatus - const onMutateLegacy = useCallback( - (variables: { mediaFolderPath: string }) => { - setFolderStatus(Path.toPlatformPath(variables.mediaFolderPath), "loading") - void updateMediaMetadata(variables.mediaFolderPath, (prev) => ({ - ...prev, - tvShow: undefined, - })) - }, - [setFolderStatus, updateMediaMetadata], - ) - - const onSuccessLegacy = useCallback( - (tvShow: TvShowMediaMetadata, variables: { mediaFolderPath: string }) => { - void updateMediaMetadata(variables.mediaFolderPath, (prev) => ({ - ...prev, - tvShow, - })) - setFolderStatus(Path.toPlatformPath(variables.mediaFolderPath), "ok") - }, - [setFolderStatus, updateMediaMetadata], - ) - - const onErrorLegacy = useCallback((error: Error, variables: { mediaFolderPath: string }) => { - toast.error(error instanceof Error ? error.message : "Failed to get TV show details") - setFolderStatus(Path.toPlatformPath(variables.mediaFolderPath), "ok") - }, [setFolderStatus]) - - const applyTmdbTvShowSelectionMutation = useGetTmdbTvShowMutation({ - onMutate: onMutateLegacy, - onSuccess: onSuccessLegacy, - onError: onErrorLegacy, - }) - - const applyTvdbTvShowSelectionMutation = useGetTvdbTvShowMutation({ - onMutate: onMutateLegacy, - onSuccess: onSuccessLegacy, - onError: onErrorLegacy, - }) - const recognizeFolderMutation = useMutation({ mutationFn: async (variables: SelectTvShowForFolderVariables) => { await recognizeFolderViaCore({ @@ -150,90 +93,17 @@ export function useSelectTvShowForFolderMutation() { }, }) - const mutateLegacy = useCallback( - (variables: SelectTvShowForFolderVariables) => { - const { database, result, searchLanguage, mediaFolderPath } = variables - const traceId = `TvShowSearchResultSelected-${nextTraceId()}` - - if (database === "TVDB") { - const selectedTvdbSearchResult = result as TVDBv4SearchResult - applyTvdbTvShowSelectionMutation.mutate({ - seriesId: Number(selectedTvdbSearchResult.tvdb_id), - language: searchLanguage, - mediaFolderPath, - traceId, - }) - } else { - applyTmdbTvShowSelectionMutation.mutate({ - id: result.id, - language: searchLanguage, - mediaFolderPath, - traceId, - }) - } - }, - [applyTmdbTvShowSelectionMutation, applyTvdbTvShowSelectionMutation], - ) - - const mutateAsyncLegacy = useCallback( - async (variables: SelectTvShowForFolderVariables) => { - const { database, result, searchLanguage, mediaFolderPath } = variables - const traceId = `TvShowSearchResultSelected-${nextTraceId()}` - - if (database === "TVDB") { - const selectedTvdbSearchResult = result as TVDBv4SearchResult - return applyTvdbTvShowSelectionMutation.mutateAsync({ - seriesId: Number(selectedTvdbSearchResult.tvdb_id), - language: searchLanguage, - mediaFolderPath, - traceId, - }) - } - return applyTmdbTvShowSelectionMutation.mutateAsync({ - id: result.id, - language: searchLanguage, - mediaFolderPath, - traceId, - }) - }, - [applyTmdbTvShowSelectionMutation, applyTvdbTvShowSelectionMutation], - ) - - const mutate = useCallback( - (variables: SelectTvShowForFolderVariables) => { - if (isSmmV3Enabled()) { - recognizeFolderMutation.mutate(variables) - return - } - mutateLegacy(variables) - }, - [mutateLegacy, recognizeFolderMutation], - ) - - const mutateAsync = useCallback( - async (variables: SelectTvShowForFolderVariables) => { - if (isSmmV3Enabled()) { - await recognizeFolderMutation.mutateAsync(variables) - return - } - return mutateAsyncLegacy(variables) - }, - [mutateAsyncLegacy, recognizeFolderMutation], - ) - const selectTvShowForFolderMutation = useMemo( - () => ({ mutate, mutateAsync }), - [mutate, mutateAsync], + () => ({ + mutate: recognizeFolderMutation.mutate, + mutateAsync: recognizeFolderMutation.mutateAsync, + }), + [recognizeFolderMutation], ) - const isSelectTvShowForFolderPending = - recognizeFolderMutation.isPending || - applyTmdbTvShowSelectionMutation.isPending || - applyTvdbTvShowSelectionMutation.isPending - return { selectTvShowForFolderMutation, - isSelectTvShowForFolderPending, + isSelectTvShowForFolderPending: recognizeFolderMutation.isPending, updateMediaMetadata, } } diff --git a/apps/ui/src/hooks/useTvdbQueries.ts b/apps/ui/src/hooks/useTvdbQueries.ts index 4e35eaf3..9dd84527 100644 --- a/apps/ui/src/hooks/useTvdbQueries.ts +++ b/apps/ui/src/hooks/useTvdbQueries.ts @@ -1,7 +1,6 @@ import { useQueryClient } from "@tanstack/react-query" import { useCallback } from "react" import { searchTvdb } from "@/api/tvdbSearch" -import { isSmmV3Enabled } from "@/lib/localStorages" import { fetchTvdbAndBuildMovieMediaMetadata, fetchTvdbAndBuildTvShowMediaMetadata, getTVDBv4Client } from "@/lib/TvdbUtils" import { tvdbArtworkTypesQueryKey, @@ -162,19 +161,14 @@ export function useTvdbQueries() { return queryClient.fetchQuery({ queryKey: tvdbSearchQueryKey(params), queryFn: async () => { - if (isSmmV3Enabled()) { - const body = await searchTvdb(params.query, params.type, params.language) - if (body.error) return undefined - return body.results - } - const tvdb = getClient() - const resp = await tvdb.search(params) - return resp.status === "success" ? resp.data : undefined + const body = await searchTvdb(params.query, params.type, params.language) + if (body.error) return undefined + return body.results }, staleTime: TVDB_SEARCH_STALE_MS, }) }, - [queryClient, getClient] + [queryClient] ) const getTvShowMediaMetadata = useCallback( diff --git a/apps/ui/src/lib/localStorages.ts b/apps/ui/src/lib/localStorages.ts index d428366b..7b3df814 100644 --- a/apps/ui/src/lib/localStorages.ts +++ b/apps/ui/src/lib/localStorages.ts @@ -8,7 +8,6 @@ const STORAGE_KEY_PREFER_REVERSE_PROXY_BASE_URL = 'preferReverseProxyBaseUrl'; const STORAGE_KEY_LAST_SELECTED_TMDB_LANGUAGE = 'lastSelectedTmdbLanguage'; const STORAGE_KEY_LAST_SELECTED_TVDB_LANGUAGE = 'lastSelectedTvdbLanguage'; const STORAGE_KEY_DISABLED_DOMAINS = 'disabledDomains'; -const STORAGE_KEY_SMM_V3_ENABLED = 'smm.v3.enabled'; function readDisabledDomainsSet(): Set { try { @@ -196,19 +195,6 @@ const localStorages = { set disabledDomains(domains: Set) { writeDisabledDomainsSet(domains); }, - get isSmmV3Enabled(): boolean { - try { - const stored = localStorage.getItem(STORAGE_KEY_SMM_V3_ENABLED) - if (stored === 'false') return false - return true - } catch { - return true - } - }, -} - -export function isSmmV3Enabled(): boolean { - return localStorages.isSmmV3Enabled } export default localStorages; \ No newline at end of file From 24fa3f7272bc3912015aff12592d29b674f3d427 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 2 Sep 2026 01:48:54 +0800 Subject: [PATCH 06/83] refactor: clean up codebase --- apps/ui/src/App.test.tsx | 20 + apps/ui/src/App.tsx | 8 + ...handleRenamePromptConfirmForTvShow.test.ts | 4 + apps/ui/src/components/episode-file.tsx | 7 +- .../format-converter/FormatConverter.test.tsx | 71 ++ .../format-converter/FormatConverter.tsx | 64 ++ .../media/MediaFileTable.stories.tsx | 80 -- .../components/media/MediaFileTable.test.tsx | 1 - .../src/components/media/MediaFileTable.tsx | 8 + .../components/media/MediaFileTableRow.tsx | 6 +- .../media/UIMediaFileTable.stories.tsx | 13 +- .../src/components/media/UIMediaFileTable.tsx | 61 +- apps/ui/src/components/menu.tsx | 15 +- apps/ui/src/components/movie/MoviePanel.tsx | 62 +- .../components/movie/tmdb-movie-overview.tsx | 6 +- .../music/MusicPanel.downloadJobs.test.tsx | 22 +- .../src/components/music/MusicPanel.test.tsx | 84 +- apps/ui/src/components/music/MusicPanel.tsx | 34 +- .../rename-file/RenameFile.test.tsx | 68 ++ .../src/components/rename-file/RenameFile.tsx | 64 ++ .../components/scrape/ScrapeMetadata.test.tsx | 61 ++ .../src/components/scrape/ScrapeMetadata.tsx | 63 ++ .../src/components/sidebar/Sidebar.test.tsx | 37 +- .../src/components/tv/TvShowEpisodeTable.tsx | 954 ------------------ apps/ui/src/components/tv/TvShowPanel.tsx | 151 +-- .../components/tv/TvShowPanelUtils.test.ts | 32 +- .../VideoCompression.test.tsx | 82 ++ .../video-compression/VideoCompression.tsx | 70 ++ apps/ui/src/components/welcome.test.tsx | 19 +- apps/ui/src/components/welcome.tsx | 6 +- .../buildMovieFilesFromMediaMetadata.test.ts | 6 +- .../tv/useRuleBasedRecognizeFlow.test.tsx | 4 + .../tv/useTvShowEpisodeFormatConvert.test.tsx | 61 ++ .../hooks/tv/useTvShowEpisodeFormatConvert.ts | 44 + .../tv/useTvShowEpisodeVideoCompress.test.tsx | 85 ++ .../hooks/tv/useTvShowEpisodeVideoCompress.ts | 58 ++ apps/ui/src/hooks/useFeatures.test.ts | 17 - apps/ui/src/hooks/useFeatures.ts | 45 - .../lib/buildMovieEpisodeTableRows.test.ts | 51 +- apps/ui/src/lib/buildMovieEpisodeTableRows.ts | 11 +- .../lib/buildTvShowEpisodeTableRows.test.ts | 300 +++--- .../ui/src/lib/buildTvShowEpisodeTableRows.ts | 146 ++- apps/ui/src/lib/dialogRequestEvents.ts | 34 + apps/ui/src/lib/initializeMusicFolder.test.ts | 307 +----- apps/ui/src/lib/mediaMetadataRefreshUtils.ts | 19 +- apps/ui/src/lib/recognizeEpisodes.test.ts | 28 +- apps/ui/src/lib/uiDomainMapper.test.ts | 141 --- apps/ui/src/lib/uiDomainMapper.ts | 50 - apps/ui/src/providers/dialog-provider.tsx | 172 ---- apps/ui/src/types/eventTypes.ts | 69 ++ 50 files changed, 1587 insertions(+), 2234 deletions(-) create mode 100644 apps/ui/src/components/format-converter/FormatConverter.test.tsx create mode 100644 apps/ui/src/components/format-converter/FormatConverter.tsx delete mode 100644 apps/ui/src/components/media/MediaFileTable.stories.tsx create mode 100644 apps/ui/src/components/rename-file/RenameFile.test.tsx create mode 100644 apps/ui/src/components/rename-file/RenameFile.tsx create mode 100644 apps/ui/src/components/scrape/ScrapeMetadata.test.tsx create mode 100644 apps/ui/src/components/scrape/ScrapeMetadata.tsx delete mode 100644 apps/ui/src/components/tv/TvShowEpisodeTable.tsx create mode 100644 apps/ui/src/components/video-compression/VideoCompression.test.tsx create mode 100644 apps/ui/src/components/video-compression/VideoCompression.tsx create mode 100644 apps/ui/src/hooks/tv/useTvShowEpisodeFormatConvert.test.tsx create mode 100644 apps/ui/src/hooks/tv/useTvShowEpisodeFormatConvert.ts create mode 100644 apps/ui/src/hooks/tv/useTvShowEpisodeVideoCompress.test.tsx create mode 100644 apps/ui/src/hooks/tv/useTvShowEpisodeVideoCompress.ts create mode 100644 apps/ui/src/lib/dialogRequestEvents.ts delete mode 100644 apps/ui/src/lib/uiDomainMapper.test.ts delete mode 100644 apps/ui/src/lib/uiDomainMapper.ts diff --git a/apps/ui/src/App.test.tsx b/apps/ui/src/App.test.tsx index 0125093f..b253e752 100644 --- a/apps/ui/src/App.test.tsx +++ b/apps/ui/src/App.test.tsx @@ -39,6 +39,11 @@ vi.mock("@/hooks/userConfig", () => ({ }), })) +vi.mock("@/hooks/folders", () => ({ + useFoldersQuery: () => ({ data: ["/media/local-folder"], isFetching: false }), + useUnimportFolderMutation: () => ({ mutateAsync: vi.fn() }), +})) + vi.mock("@/lib/localStorages", () => ({ default: mockLocalStorages, })) @@ -51,6 +56,21 @@ vi.mock("@/providers/dialog-provider", () => ({ }), })) +// App-level dialog controllers render real (heavy) dialogs that need the full +// provider stack (JobOrchestratorProvider etc.); stub them for the App shell test. +vi.mock("@/components/video-compression/VideoCompression", () => ({ + VideoCompression: () => null, +})) +vi.mock("@/components/format-converter/FormatConverter", () => ({ + FormatConverter: () => null, +})) +vi.mock("@/components/scrape/ScrapeMetadata", () => ({ + ScrapeMetadata: () => null, +})) +vi.mock("@/components/rename-file/RenameFile", () => ({ + RenameFile: () => null, +})) + vi.mock("@/components/sidebar/Sidebar", () => ({ Sidebar: () =>
, })) diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index afdd9186..4d734f14 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -31,6 +31,10 @@ import { type OnMediaLibraryImportedEventData, } from "./types/eventTypes" import { MusicPanel } from "./components/music/MusicPanel" +import { VideoCompression } from "./components/video-compression/VideoCompression" +import { FormatConverter } from "./components/format-converter/FormatConverter" +import { ScrapeMetadata } from "./components/scrape/ScrapeMetadata" +import { RenameFile } from "./components/rename-file/RenameFile" import localStorages from "@/lib/localStorages" import { useFoldersQuery, useUnimportFolderMutation } from "@/hooks/folders" import { isElectron } from "@/lib/isElectron" @@ -355,6 +359,10 @@ export default function App() {
{isAiFeatureEnabled && } + + + +
) diff --git a/apps/ui/src/actions/handleRenamePromptConfirmForTvShow.test.ts b/apps/ui/src/actions/handleRenamePromptConfirmForTvShow.test.ts index f9a2ff53..e5e567a5 100644 --- a/apps/ui/src/actions/handleRenamePromptConfirmForTvShow.test.ts +++ b/apps/ui/src/actions/handleRenamePromptConfirmForTvShow.test.ts @@ -20,6 +20,10 @@ vi.mock("sonner", () => ({ }, })) +vi.mock("@/lib/mediaFolderFiles", () => ({ + listMediaFolderFilePaths: vi.fn(async () => ["/media/show/1.mkv"]), +})) + describe("handleRenamePromptConfirmForTvShow", () => { const mediaFolderPath = "/media/show" const planId = "plan-1" diff --git a/apps/ui/src/components/episode-file.tsx b/apps/ui/src/components/episode-file.tsx index 58153f33..3367195e 100644 --- a/apps/ui/src/components/episode-file.tsx +++ b/apps/ui/src/components/episode-file.tsx @@ -2,7 +2,7 @@ import { XCircle } from "lucide-react" import type { LucideIcon } from "lucide-react" import { cn } from "@/lib/utils" import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu" -import { useDialogs } from "@/providers/dialog-provider" +import { askForRenameFile } from "@/lib/dialogRequestEvents" import { useUIMediaFolderStoreState } from "@/stores/uiMediaFolderStore"; import { Path } from "@smm/utils/path" import { relative, join, basename, dirname, extname } from "@/lib/path" @@ -112,8 +112,6 @@ export function EpisodeFile({ const { data: selectedMediaMetadata } = useMediaMetadataQuery(selectedFolder || undefined) const { data: allMediaFiles = [] } = useMediaFolderFilesQuery(selectedFolder || undefined) const { mutate: fetchMediaMetadata } = useFetchMediaMetadataMutation(); - const { renameFileDialog } = useDialogs() - const mediaFolderPath = selectedMediaMetadata?.mediaFolderPath const relativePath = getRelativePath(mediaFolderPath, file.path) const newRelativePath = file.newPath ? getRelativePath(mediaFolderPath, file.newPath) : null @@ -200,8 +198,7 @@ export function EpisodeFile({ relativePath = file.path } - const [openRename] = renameFileDialog - openRename( + askForRenameFile( async (newRelativePath: string) => { if (!selectedMediaMetadata?.mediaFolderPath || !file.path) { console.error("Missing required paths for rename") diff --git a/apps/ui/src/components/format-converter/FormatConverter.test.tsx b/apps/ui/src/components/format-converter/FormatConverter.test.tsx new file mode 100644 index 00000000..5a690dd8 --- /dev/null +++ b/apps/ui/src/components/format-converter/FormatConverter.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, act, cleanup } from "@testing-library/react" +import { UI_AskForFormatConverter } from "@/types/eventTypes" + +let lastProps: Record | undefined +let featureEnabled = true + +vi.mock("@/components/dialogs", () => ({ + FormatConverterDialog: (props: Record) => { + lastProps = props + return
+ }, +})) + +vi.mock("@/providers/dialog-provider", () => ({ + useDialogs: () => ({ filePickerDialog: [vi.fn(), vi.fn()] }), +})) + +vi.mock("@/hooks/useFeatures", () => ({ + useFeatures: () => ({ isFormatConverterEnabled: featureEnabled }), +})) + +import { FormatConverter } from "./FormatConverter" + +function ask(detail: { filePath?: string; title?: string }): void { + document.dispatchEvent(new CustomEvent(UI_AskForFormatConverter, { detail })) +} + +describe("FormatConverter (top-level, event-driven)", () => { + beforeEach(() => { + cleanup() + lastProps = undefined + featureEnabled = true + }) + + it("opens the dialog with a normalized track for the requested file path", () => { + render() + + act(() => { + ask({ filePath: "/media/show/S01E01.mkv", title: "Pilot" }) + }) + + expect(lastProps?.isOpen).toBe(true) + const track = lastProps?.track as { filePath?: string; path?: string; title?: string } + expect(track?.filePath).toBe("/media/show/S01E01.mkv") + expect(track?.path).toBe("/media/show/S01E01.mkv") + expect(track?.title).toBe("Pilot") + }) + + it("opens the dialog in select-a-file mode when no path is provided", () => { + render() + + act(() => { + ask({}) + }) + + expect(lastProps?.isOpen).toBe(true) + expect(lastProps?.track).toBeUndefined() + }) + + it("ignores requests while the formatConverter feature is disabled", () => { + featureEnabled = false + render() + + act(() => { + ask({ filePath: "/media/show/S01E01.mkv" }) + }) + + expect(lastProps?.isOpen).toBe(false) + }) +}) diff --git a/apps/ui/src/components/format-converter/FormatConverter.tsx b/apps/ui/src/components/format-converter/FormatConverter.tsx new file mode 100644 index 00000000..d1cc00aa --- /dev/null +++ b/apps/ui/src/components/format-converter/FormatConverter.tsx @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useState } from "react" +import { FormatConverterDialog, type TrackProperties } from "@/components/dialogs" +import { useDialogs } from "@/providers/dialog-provider" +import { useFeatures } from "@/hooks/useFeatures" +import { + UI_AskForFormatConverter, + type OnAskForFormatConverterEventData, +} from "@/types/eventTypes" + +/** + * Top-level owner of the format-converter feature (rendered once at App level). + * + * TvShowPanel / MusicPanel / Welcome / the app menu only dispatch a + * {@link UI_AskForFormatConverter} document event; this component owns the UI + * state (dialog open/close), normalizes a request into the dialog's track + * model, wires the file picker / source selection, and gates on the feature + * flag — fully decoupled from the requesting panels. + */ +export function FormatConverter() { + const { filePickerDialog } = useDialogs() + const [openFilePicker] = filePickerDialog + const { isFormatConverterEnabled } = useFeatures() + + const [isOpen, setIsOpen] = useState(false) + const [track, setTrack] = useState(undefined) + + const openFromRequest = useCallback( + (detail: OnAskForFormatConverterEventData | undefined) => { + if (!isFormatConverterEnabled) return + const filePath = detail?.filePath + setTrack( + filePath + ? { id: 0, path: filePath, filePath, title: detail?.title ?? "" } + : undefined, + ) + setIsOpen(true) + }, + [isFormatConverterEnabled], + ) + + const close = useCallback(() => { + setIsOpen(false) + }, []) + + useEffect(() => { + const handler = (event: Event) => { + openFromRequest((event as CustomEvent).detail) + } + document.addEventListener(UI_AskForFormatConverter, handler) + return () => { + document.removeEventListener(UI_AskForFormatConverter, handler) + } + }, [openFromRequest]) + + return ( + setTrack(nextTrack)} + /> + ) +} diff --git a/apps/ui/src/components/media/MediaFileTable.stories.tsx b/apps/ui/src/components/media/MediaFileTable.stories.tsx deleted file mode 100644 index 337d527e..00000000 --- a/apps/ui/src/components/media/MediaFileTable.stories.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite" -import { action } from "storybook/actions" -import { - type UIMediaFileDataContextMenuItem, - type UIMediaFileDataRow, - type UIMediaFileDividerRow, - type UIMediaFileFolderRow, - type UIMediaFileTableRow, -} from "./UIMediaFileTable" -import { MediaFileTable } from "./MediaFileTable" - -const mediaFolderPath = "/media/movies/Inception (2010)" - -const posterRow: UIMediaFileFolderRow = { - id: "poster", - type: "folderFile", - path: "poster.jpg", -} - -const movieDivider: UIMediaFileDividerRow = { - id: "movie", - type: "divider", - text: "Movie", -} - -const movieRow: UIMediaFileDataRow = { - season: 1, - episode: 1, - type: "episode", - videoFile: "/media/movies/Inception (2010)/Inception.mkv", - thumbnail: "/media/movies/Inception (2010)/thumb.jpg", - subtitle: "/media/movies/Inception (2010)/Inception.srt", - nfo: "/media/movies/Inception (2010)/movie.nfo", - episodeTitle: "Inception", - checked: false, -} - -const data: UIMediaFileTableRow[] = [posterRow, movieDivider, movieRow] - -const meta = { - title: "Components/MediaFileTable", - component: MediaFileTable, - decorators: [ - (Story) => ( -
- -
- ), - ], - args: { - data, - mediaFolderPath, - layout: "simple", - }, -} satisfies Meta - -export default meta -type Story = StoryObj - -/** - * Default — only the built-in "Open" and "Properties" right-click items. - */ -export const Default: Story = {} - -/** - * Caller injects a panel-private "Rename" item via `extraEpisodeContextMenu`. - * It is appended after the built-in items. - */ -export const WithExtraContextMenu: Story = { - args: { - extraEpisodeContextMenu: [ - { - id: "rename", - label: "Rename", - onClick: action("dataRow:rename"), - disabled: (row) => !row.videoFile, - }, - ] satisfies UIMediaFileDataContextMenuItem[], - }, -} diff --git a/apps/ui/src/components/media/MediaFileTable.test.tsx b/apps/ui/src/components/media/MediaFileTable.test.tsx index 811826d0..82c3e0ca 100644 --- a/apps/ui/src/components/media/MediaFileTable.test.tsx +++ b/apps/ui/src/components/media/MediaFileTable.test.tsx @@ -41,7 +41,6 @@ const baseRow: UIMediaFileDataRow = { thumbnail: undefined, subtitle: undefined, nfo: undefined, - checked: false, } const data: UIMediaFileTableRow[] = [baseRow] diff --git a/apps/ui/src/components/media/MediaFileTable.tsx b/apps/ui/src/components/media/MediaFileTable.tsx index c67636be..71c7268e 100644 --- a/apps/ui/src/components/media/MediaFileTable.tsx +++ b/apps/ui/src/components/media/MediaFileTable.tsx @@ -7,6 +7,7 @@ import type { UIMediaFileTableContextMenuConfig, UIMediaFileDataRow, UIMediaFileTableRow, + UIMediaEpisodeSelection, } from "./UIMediaFileTable" import { useMediaFileTableController } from "./useMediaFileTableController" @@ -36,6 +37,11 @@ export interface MediaFileTableProps { layout?: "simple" | "detail" | "preview" /** Checkbox state callback. Omit → checkbox column is hidden. */ onCheck?: (row: UIMediaFileDataRow, checked: boolean) => void + /** + * Controlled checkbox selection — which episodes are currently checked. + * Omit → the underlying table manages the selection internally. + */ + selectedEpisodes?: UIMediaEpisodeSelection[] /** * Renders the extra content area below the video path in `preview` layout * (e.g. video screenshots). Omit → the area is hidden. @@ -68,6 +74,7 @@ export function MediaFileTable(props: MediaFileTableProps) { previewStatus, layout, onCheck, + selectedEpisodes, renderPreviewContent, extraEpisodeContextMenu, } = props @@ -119,6 +126,7 @@ export function MediaFileTable(props: MediaFileTableProps) { previewStatus={previewStatus} layout={layout} onCheck={onCheck} + selectedEpisodes={selectedEpisodes} renderPreviewContent={renderPreviewContent} onDoubleClick={ctrl.handleDoubleClick} /> diff --git a/apps/ui/src/components/media/MediaFileTableRow.tsx b/apps/ui/src/components/media/MediaFileTableRow.tsx index f419112f..10d4ff85 100644 --- a/apps/ui/src/components/media/MediaFileTableRow.tsx +++ b/apps/ui/src/components/media/MediaFileTableRow.tsx @@ -35,6 +35,8 @@ export interface MediaFileTableRowContext { previewStatus?: "loading" | "ok" layout: "simple" | "detail" | "preview" onCheck?: (row: UIMediaFileDataRow, checked: boolean) => void + /** Whether the given episode row is currently checked (selection membership). */ + isSelected: (row: UIMediaFileDataRow) => boolean renderPreviewContent?: (row: UIMediaFileDataRow) => ReactNode onDoubleClick?: (row: UIMediaFileDataRow | UIMediaFileFolderRow) => void isSimpleLayout: boolean @@ -235,7 +237,7 @@ function renderEpisodeSimpleVideoContent( isRowDisabled: boolean, ): ReactNode { if (row.videoFile) { - if (ctx.preview === "rename" && !row.newVideoFile && row.checked) { + if (ctx.preview === "rename" && !row.newVideoFile && ctx.isSelected(row)) { return (
{ if (isRowDisabled) return diff --git a/apps/ui/src/components/media/UIMediaFileTable.stories.tsx b/apps/ui/src/components/media/UIMediaFileTable.stories.tsx index 52a4ca63..1ef6aa64 100644 --- a/apps/ui/src/components/media/UIMediaFileTable.stories.tsx +++ b/apps/ui/src/components/media/UIMediaFileTable.stories.tsx @@ -48,7 +48,6 @@ const movieRow: UIMediaFileDataRow = { subtitle: `${mediaFolderPath}/Inception (2010).en.srt`, nfo: `${mediaFolderPath}/Inception (2010).nfo`, episodeTitle: "Inception", - checked: false, } // TV show fixtures @@ -67,7 +66,6 @@ const season1Ep1: UIMediaFileDataRow = { subtitle: `${mediaFolderPath}/S01E01.srt`, nfo: `${mediaFolderPath}/S01E01.nfo`, episodeTitle: "Pilot", - checked: true, } const season1Ep2: UIMediaFileDataRow = { @@ -79,7 +77,6 @@ const season1Ep2: UIMediaFileDataRow = { subtitle: undefined, nfo: `${mediaFolderPath}/S01E02.nfo`, episodeTitle: "Episode 2", - checked: false, } const season1Ep3: UIMediaFileDataRow = { @@ -91,7 +88,6 @@ const season1Ep3: UIMediaFileDataRow = { subtitle: `${mediaFolderPath}/S01E03.srt`, nfo: undefined, episodeTitle: "Episode 3", - checked: false, disabled: true, // Inactive in current plan } @@ -110,7 +106,6 @@ const season2Ep1: UIMediaFileDataRow = { subtitle: `${mediaFolderPath}/S02E01.srt`, nfo: `${mediaFolderPath}/S02E01.nfo`, episodeTitle: "Season Premiere", - checked: true, } // ------------------------------------------------------------------------ @@ -385,15 +380,15 @@ export const RenamePreviewPartialFilesRename: Story = { { ...season1Ep1, newVideoFile: `${mediaFolderPath}/Show - S01E01 - Pilot.mkv`, - checked: true, }, { ...season1Ep2, - checked: false, disabled: true, }, ], preview: "rename", + // Controlled: only S01E01 is selected. + selectedEpisodes: [{ season: 1, episode: 1 }], }, } @@ -406,6 +401,7 @@ export const RenamePreviewWithCheckboxes: Story = { season1Ep3, // disabled — should be greyed out ], preview: "rename", + selectedEpisodes: [{ season: 1, episode: 1 }], onCheck: action("onCheck"), }, } @@ -430,11 +426,12 @@ export const RenamePreviewRemoved: Story = { season1Divider, { ...season1Ep1, - // checked + no newVideoFile → row will be removed (strikethrough in simple layout) + // selected + no newVideoFile → row will be removed (strikethrough in simple layout) newVideoFile: undefined, }, ], preview: "rename", + selectedEpisodes: [{ season: 1, episode: 1 }], }, } diff --git a/apps/ui/src/components/media/UIMediaFileTable.tsx b/apps/ui/src/components/media/UIMediaFileTable.tsx index 057f55d6..3b11844e 100644 --- a/apps/ui/src/components/media/UIMediaFileTable.tsx +++ b/apps/ui/src/components/media/UIMediaFileTable.tsx @@ -21,7 +21,7 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible" import { ChevronRightIcon } from "lucide-react" -import { useState, useMemo, type ReactNode } from "react" +import { useCallback, useState, useMemo, type ReactNode } from "react" import { useTranslation } from "@/lib/i18n" import { cn } from "@/lib/utils" import { @@ -48,6 +48,18 @@ export interface UIMediaFileDividerRow { text: string } +/** + * Identifies one TV episode row for checkbox selection. + * + * Selection is kept separate from the row data (controlled by the table via + * the `selectedEpisodes` prop, or managed internally), so user toggles survive + * row rebuilds that derive from refetched metadata / plans. + */ +export interface UIMediaEpisodeSelection { + season: number + episode: number +} + /** * A single playable file row (e.g. one TV episode or one movie). * @@ -69,7 +81,6 @@ export interface UIMediaFileDataRow { newThumbnail?: string newSubtitle?: string newNfo?: string - checked: boolean /** * In preview mode, row does not participate in the current plan: * checkbox is disabled and the row is rendered in a muted style. @@ -145,6 +156,12 @@ export interface UIMediaFileTableProps { * - `preview` no ID column, larger cover, extra content area for video screenshots */ layout?: "simple" | "detail" | "preview" + /** + * Controlled checkbox selection — which episodes are currently checked. + * Omit → the table manages the selection internally (uncontrolled mode). + * Checkboxes are only rendered while `preview` is set. + */ + selectedEpisodes?: UIMediaEpisodeSelection[] /** Checkbox state callback. Omit → checkbox column is hidden. */ onCheck?: (row: UIMediaFileDataRow, checked: boolean) => void /** @@ -296,6 +313,7 @@ export function UIMediaFileTable({ preview, previewStatus, layout = "simple", + selectedEpisodes, onCheck, renderPreviewContent, onDoubleClick, @@ -304,6 +322,42 @@ export function UIMediaFileTable({ const [columnVisibility, setColumnVisibility] = useState>( defaultColumnVisibility, ) + const [internalSelectedEpisodes, setInternalSelectedEpisodes] = useState< + UIMediaEpisodeSelection[] + >([]) + + // Controlled when the caller provides `selectedEpisodes`; otherwise the table + // keeps the selection in internal state (uncontrolled mode). + const isSelectionControlled = selectedEpisodes !== undefined + const effectiveSelection = selectedEpisodes ?? internalSelectedEpisodes + const selectedEpisodeKeys = useMemo( + () => new Set(effectiveSelection.map((e) => `${e.season}-${e.episode}`)), + [effectiveSelection], + ) + + const isEpisodeSelected = useCallback( + (row: UIMediaFileDataRow) => selectedEpisodeKeys.has(`${row.season}-${row.episode}`), + [selectedEpisodeKeys], + ) + + const handleRowCheck = useCallback( + (row: UIMediaFileDataRow, checked: boolean) => { + if (!isSelectionControlled) { + setInternalSelectedEpisodes((prev) => { + const exists = prev.some( + (e) => e.season === row.season && e.episode === row.episode, + ) + if (checked === exists) return prev + if (checked) return [...prev, { season: row.season, episode: row.episode }] + return prev.filter( + (e) => !(e.season === row.season && e.episode === row.episode), + ) + }) + } + onCheck?.(row, checked) + }, + [isSelectionControlled, onCheck], + ) const { t } = useTranslation("components") @@ -362,7 +416,8 @@ export function UIMediaFileTable({ preview, previewStatus, layout, - onCheck, + onCheck: handleRowCheck, + isSelected: isEpisodeSelected, renderPreviewContent, onDoubleClick, isSimpleLayout, diff --git a/apps/ui/src/components/menu.tsx b/apps/ui/src/components/menu.tsx index 924f3411..be0bf4be 100644 --- a/apps/ui/src/components/menu.tsx +++ b/apps/ui/src/components/menu.tsx @@ -15,6 +15,7 @@ import { MenubarTrigger, } from "@/components/ui/menubar" import { useDialogs } from "@/providers/dialog-provider" +import { askForFormatConverter } from "@/lib/dialogRequestEvents" import { useFeatures } from "@/hooks/useFeatures" import { useTranslation } from "@/lib/i18n" // import { cleanUp } from "@/api/cleanUp" @@ -25,7 +26,9 @@ import { Path } from "@smm/utils/path" import type { FolderType, FileItem } from "@/providers/dialog-provider" import { nextTraceId } from "@/lib/utils" import { + UI_AskForVideoCompression, UI_MediaLibraryImportedEvent, + type OnAskForVideoCompressionEventData, type OnMediaLibraryImportedEventData, } from "@/types/eventTypes" import { writeFrontendLog } from "@/api/log" @@ -163,8 +166,6 @@ export function Menu({onOpenFolderMenuClick, onOpenMediaLibraryMenuClick}: MenuP const { configDialog, downloadVideoDialog, - formatConverterDialog, - videoCompressionDialog, openFolderDialog, filePickerDialog, executeCmdDialog, @@ -180,8 +181,6 @@ export function Menu({onOpenFolderMenuClick, onOpenMediaLibraryMenuClick}: MenuP const [openConfig] = configDialog const [openDownloadVideo] = downloadVideoDialog - const [openFormatConverter] = formatConverterDialog - const [openVideoCompression] = videoCompressionDialog const [openOpenFolder] = openFolderDialog const [openFilePicker] = filePickerDialog const [openExecuteCmd] = executeCmdDialog @@ -271,7 +270,7 @@ export function Menu({onOpenFolderMenuClick, onOpenMediaLibraryMenuClick}: MenuP id: 'format-conversion', onClick: () => { logMenuAction("format-conversion.click") - openFormatConverter() + askForFormatConverter() } } as const] : []), @@ -281,7 +280,11 @@ export function Menu({onOpenFolderMenuClick, onOpenMediaLibraryMenuClick}: MenuP id: 'video-compression', onClick: () => { logMenuAction("video-compression.click") - openVideoCompression() + document.dispatchEvent( + new CustomEvent(UI_AskForVideoCompression, { + detail: {}, + }), + ) } } as const] : []), diff --git a/apps/ui/src/components/movie/MoviePanel.tsx b/apps/ui/src/components/movie/MoviePanel.tsx index 5595b6f4..45c5b9d2 100644 --- a/apps/ui/src/components/movie/MoviePanel.tsx +++ b/apps/ui/src/components/movie/MoviePanel.tsx @@ -6,7 +6,7 @@ import { generateNewFileName } from "@smm/core/pipeline/renameRules" import { Path } from "@smm/utils/path" import { join, extname } from "@/lib/path" import { useLatest } from "react-use" -import { useDialogs } from "@/providers/dialog-provider" +import { askForRenameFile, askForScrape } from "@/lib/dialogRequestEvents" import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" import { useSelectMovieForFolderMutation } from "@/hooks/movie/useSelectMovieForFolderMutation" import { renameFiles } from "@/api/renameFiles" @@ -20,15 +20,19 @@ import { buildMovieEpisodeTableRows, type MovieRenamePreviewData } from "@/lib/b import type { MediaMetadata } from "@/lib/mediaFolderFiles" import { useMediaFolderFilesQuery } from "@/hooks/useMediaFolderFilesQuery" import type { UIMediaFolderStatus } from "@/types/UIMediaFolder" +import { + UI_AskForVideoCompression, + type OnAskForVideoCompressionEventData, +} from "@/types/eventTypes" import { MovieHeaderV2 } from "./MovieHeaderV2" import type { EpisodeTableLayout } from "../tv/TvShowPanelHeader" import { MediaFileTable } from "../media/MediaFileTable" import type { UIMediaFileDataContextMenuItem, + UIMediaFileDataRow, UIMediaFileTableRow, } from "../media/UIMediaFileTable" import { useRenameVideoFileFlow } from "@/hooks/useRenameVideoFileFlow" -import { TvShowEpisodeTable, type TvShowEpisodeDataRow, type TvShowEpisodeTableRow } from "../tv/TvShowEpisodeTable" import { RuleBasedRenameFilePrompt } from "../RuleBasedRenameFilePrompt" import { MediaPanelInitializingHint } from "../MediaPanelInitializingHint" import type { SearchResultSelectedArgs } from "../MediaDatabaseSearchbox" @@ -91,10 +95,6 @@ function MoviePanel() { }, [fetchMediaMetadata], ) - const { scrapeDialog, videoCompressionDialog, renameFileDialog } = useDialogs() - const [openScrape] = scrapeDialog - const [openRenameFile] = renameFileDialog - const toolbarOptions: ToolbarOption[] = [ { value: "plex", label: "Plex" } as ToolbarOption, { value: "emby", label: "Emby" } as ToolbarOption, @@ -125,7 +125,7 @@ function MoviePanel() { return findMediaFilesForMovieMediaMetadata(clone, folderFiles) }, [queriedMediaMetadata, folderFiles]) - const { isVideoCompressionEnabled, isUseMediaFileTableEnabled } = useFeatures() + const { isVideoCompressionEnabled } = useFeatures() const subtitleFlow = useSubtitleFlow({ mediaMetadata, @@ -134,7 +134,7 @@ function MoviePanel() { }) const videoRenameFlow = useRenameVideoFileFlow({ mediaFolderPath: mediaMetadata?.mediaFolderPath, - openRenameDialog: openRenameFile, + openRenameDialog: askForRenameFile, }) const [movieFiles, setMovieFiles] = useState({ files: [] }) const latestMovieFiles = useLatest(movieFiles) @@ -326,7 +326,7 @@ function MoviePanel() { }, [mediaMetadata, latestMovieFiles, refreshMediaMetadata, t]) // Build table data using the movie→tv-show adapter - const tableData = useMemo(() => { + const tableData = useMemo(() => { if (!mediaMetadata) return [] // eslint-disable-next-line @typescript-eslint/no-explicit-any return buildMovieEpisodeTableRows(mediaMetadata, folderStatus, (key: string) => t(key as any), folderFiles, { @@ -335,12 +335,15 @@ function MoviePanel() { }, [mediaMetadata, folderStatus, t, renamePreview, folderFiles]) const handleVideoCompressClick = useCallback( - (row: TvShowEpisodeDataRow) => { + (row: UIMediaFileDataRow) => { if (!row.videoFile) return - const [openVideoCompression] = videoCompressionDialog - openVideoCompression({ filePath: row.videoFile }) + document.dispatchEvent( + new CustomEvent(UI_AskForVideoCompression, { + detail: { filePath: row.videoFile }, + }), + ) }, - [videoCompressionDialog], + [], ) return ( @@ -358,7 +361,7 @@ function MoviePanel() { {...subtitleFlow.header} selectedMediaMetadata={mediaMetadata} selectedMediaFolder={uiFolderRow} - openScrape={openScrape} + openScrape={askForScrape} episodeTableLayout={layout} onEpisodeTableLayoutChange={setLayout} /> @@ -366,28 +369,27 @@ function MoviePanel() {
{folderStatus === "initializing" ? ( - ) : isUseMediaFileTableEnabled ? ( - !row.videoFile, - } satisfies UIMediaFileDataContextMenuItem]} - /> ) : ( - !row.videoFile, + } satisfies UIMediaFileDataContextMenuItem, + { + id: "video-compress", + label: t("tvShowEpisodeTable.contextMenu.videoCompress"), + onClick: isVideoCompressionEnabled ? handleVideoCompressClick : undefined, + disabled: (row) => !row.videoFile, + } satisfies UIMediaFileDataContextMenuItem, + ]} /> )}
diff --git a/apps/ui/src/components/movie/tmdb-movie-overview.tsx b/apps/ui/src/components/movie/tmdb-movie-overview.tsx index f8bd02f8..18b5d659 100644 --- a/apps/ui/src/components/movie/tmdb-movie-overview.tsx +++ b/apps/ui/src/components/movie/tmdb-movie-overview.tsx @@ -12,7 +12,7 @@ import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMed import { useUpdateMediaMetadataMutation } from "@/hooks/mediaMetadata/useUpdateMediaMetadataMutation"; import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys"; import { Button } from "../ui/button" -import { useDialogs } from "@/providers/dialog-provider" +import { askForScrape } from "@/lib/dialogRequestEvents" import type { MovieFileModel } from "./MoviePanel" import { MovieFilesSection } from "./movie-files-section" import { useTranslation } from "@/lib/i18n" @@ -76,8 +76,6 @@ export function TMDBMovieOverview({ movie, className, onRenameClick, movieFiles, const next = typeof updaterOrMetadata === "function" ? updaterOrMetadata(current) : updaterOrMetadata await saveMediaMetadata({ pathPosix, metadata: next, traceId: options?.traceId }) }, [fetchMediaMetadata, saveMediaMetadata]) - const { scrapeDialog } = useDialogs() - const [openScrape] = scrapeDialog const [searchResults, setSearchResults] = useState([]) const [isSearching, setIsSearching] = useState(false) const [searchError, setSearchError] = useState(null) @@ -349,7 +347,7 @@ export function TMDBMovieOverview({ movie, className, onRenameClick, movieFiles, size="sm" onClick={() => { if (!selectedMediaMetadata?.mediaFiles || !selectedMediaMetadata.movie) return - openScrape({ + askForScrape({ mediaMetadata: selectedMediaMetadata }) }} diff --git a/apps/ui/src/components/music/MusicPanel.downloadJobs.test.tsx b/apps/ui/src/components/music/MusicPanel.downloadJobs.test.tsx index ec869f7a..25fc4d68 100644 --- a/apps/ui/src/components/music/MusicPanel.downloadJobs.test.tsx +++ b/apps/ui/src/components/music/MusicPanel.downloadJobs.test.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MusicPanel } from './MusicPanel' import { useUIMediaFolderStoreState } from '@/stores/uiMediaFolderStore' import { useMediaMetadataQuery } from '@/hooks/mediaMetadata' +import { useMediaFolderFilesQuery } from '@/hooks/useMediaFolderFilesQuery' import { useDialogs } from '@/providers/dialog-provider' import { openFile } from '@/api/openFile' import { moveFileToTrash } from '@/api/moveFileToTrash' @@ -126,6 +127,20 @@ vi.mock('@/hooks/useJobOrchestrator', () => ({ useJobs: () => mockJobRecords, })) +vi.mock('@/hooks/useMediaFolderFilesQuery', () => ({ + useMediaFolderFilesQuery: vi.fn(), +})) + +vi.mock('@/hooks/useFeatures', () => ({ + useFeatures: vi.fn(() => ({ + isAiFeatureEnabled: false, + isDownloadVideoEnabled: true, + isFormatConverterEnabled: false, + isVideoCompressionEnabled: false, + isSubtitleFeaturesEnabled: false, + })), +})) + vi.mock('@/stores/backgroundJobsStore', () => { const state = { jobs: h.jobs, @@ -168,10 +183,11 @@ describe('MusicPanel download-video jobs', () => { vi.mocked(useMediaMetadataQuery).mockReturnValue( mockQueryOk() as ReturnType ) + vi.mocked(useMediaFolderFilesQuery).mockReturnValue({ + data: [], + } as never) vi.mocked(useDialogs).mockReturnValue({ mediaFilePropertyDialog: [vi.fn(), vi.fn()], - formatConverterDialog: [vi.fn(), vi.fn()], - videoCompressionDialog: [vi.fn(), vi.fn()], downloadVideoDialog: [vi.fn(), vi.fn()], confirmationDialog: [vi.fn(), vi.fn()], spinnerDialog: [vi.fn(), vi.fn()], @@ -179,9 +195,7 @@ describe('MusicPanel download-video jobs', () => { openFolderDialog: [vi.fn(), vi.fn()], filePickerDialog: [vi.fn(), vi.fn()], mediaSearchDialog: [vi.fn(), vi.fn()], - renameFileDialog: [vi.fn(), vi.fn()], renameFolderDialog: [vi.fn(), vi.fn()], - scrapeDialog: [vi.fn(), vi.fn()], }) vi.mocked(openFile).mockResolvedValue({ data: {} as any, error: undefined }) vi.mocked(moveFileToTrash).mockResolvedValue({ data: { path: '/media/music/song1.mp3' } }) diff --git a/apps/ui/src/components/music/MusicPanel.test.tsx b/apps/ui/src/components/music/MusicPanel.test.tsx index 759f8816..f518471d 100644 --- a/apps/ui/src/components/music/MusicPanel.test.tsx +++ b/apps/ui/src/components/music/MusicPanel.test.tsx @@ -15,6 +15,8 @@ import { getMediaTags } from '@/api/ffmpeg'; import { useVideoCaptionerStatus } from '@/hooks/useVideoCaptionerStatus'; import { useFeatures } from '@/hooks/useFeatures'; import { convertMusicFilesToTracks } from '@/lib/music'; +import { useMediaFolderFilesQuery } from '@/hooks/useMediaFolderFilesQuery'; +import { UI_AskForFormatConverter } from '@/types/eventTypes'; const NESTED_FILE_POSIX = '/path/to/music/a/b/c/d/test.mp4'; const NESTED_FILE_PLATFORM = '/path/to/music/a/b/c/d/test.mp4'; @@ -201,6 +203,10 @@ vi.mock('@/hooks/useJobOrchestrator', () => ({ useJobs: () => h.emptyJobRecords, })); +vi.mock('@/hooks/useMediaFolderFilesQuery', () => ({ + useMediaFolderFilesQuery: vi.fn(), +})); + const mockTrack: Track = { id: 1, title: 'Test Song', @@ -239,13 +245,14 @@ describe('MusicPanel', () => { selectedFolder: '/media/music', }); vi.mocked(useMediaMetadataQuery).mockReturnValue(mockQueryOk(mockSelectedMediaMetadata) as ReturnType); + vi.mocked(useMediaFolderFilesQuery).mockReturnValue({ + data: mockSelectedMediaMetadata.files, + } as never); h.mockOpenFormatConverter.mockReset(); vi.mocked(useDialogs).mockReturnValue({ mediaFilePropertyDialog: [vi.fn(), vi.fn()], - formatConverterDialog: [h.mockOpenFormatConverter, vi.fn()], - videoCompressionDialog: [h.mockOpenFormatConverter, vi.fn()], downloadVideoDialog: [vi.fn(), vi.fn()], confirmationDialog: [vi.fn(), vi.fn()], spinnerDialog: [vi.fn(), vi.fn()], @@ -253,9 +260,7 @@ describe('MusicPanel', () => { openFolderDialog: [vi.fn(), vi.fn()], filePickerDialog: [vi.fn(), vi.fn()], mediaSearchDialog: [vi.fn(), vi.fn()], - renameFileDialog: [vi.fn(), vi.fn()], renameFolderDialog: [vi.fn(), vi.fn()], - scrapeDialog: [vi.fn(), vi.fn()], }); vi.mocked(toast).mockImplementation(() => 'test-id'); @@ -402,8 +407,6 @@ describe('MusicPanel', () => { const mockOpenConfirmation = vi.fn(); vi.mocked(useDialogs).mockReturnValue({ mediaFilePropertyDialog: [vi.fn(), vi.fn()], - formatConverterDialog: [vi.fn(), vi.fn()], - videoCompressionDialog: [vi.fn(), vi.fn()], downloadVideoDialog: [vi.fn(), vi.fn()], confirmationDialog: [mockOpenConfirmation, vi.fn()], spinnerDialog: [vi.fn(), vi.fn()], @@ -411,9 +414,7 @@ describe('MusicPanel', () => { openFolderDialog: [vi.fn(), vi.fn()], filePickerDialog: [vi.fn(), vi.fn()], mediaSearchDialog: [vi.fn(), vi.fn()], - renameFileDialog: [vi.fn(), vi.fn()], renameFolderDialog: [vi.fn(), vi.fn()], - scrapeDialog: [vi.fn(), vi.fn()], }); renderHook(() => MusicPanel()); @@ -531,8 +532,6 @@ describe('MusicPanel', () => { vi.mocked(useDialogs).mockReturnValue({ mediaFilePropertyDialog: [vi.fn(), vi.fn()], - formatConverterDialog: [vi.fn(), vi.fn()], - videoCompressionDialog: [vi.fn(), vi.fn()], downloadVideoDialog: [vi.fn(), vi.fn()], confirmationDialog: [mockOpenConfirmation, mockCloseConfirmation], spinnerDialog: [vi.fn(), vi.fn()], @@ -540,9 +539,7 @@ describe('MusicPanel', () => { openFolderDialog: [vi.fn(), vi.fn()], filePickerDialog: [vi.fn(), vi.fn()], mediaSearchDialog: [vi.fn(), vi.fn()], - renameFileDialog: [vi.fn(), vi.fn()], renameFolderDialog: [vi.fn(), vi.fn()], - scrapeDialog: [vi.fn(), vi.fn()], }); vi.spyOn(Path, 'toPlatformPath').mockImplementation((path: string) => path); @@ -602,8 +599,6 @@ describe('MusicPanel', () => { const mockOpenConfirmation = vi.fn(); vi.mocked(useDialogs).mockReturnValue({ mediaFilePropertyDialog: [vi.fn(), vi.fn()], - formatConverterDialog: [vi.fn(), vi.fn()], - videoCompressionDialog: [vi.fn(), vi.fn()], downloadVideoDialog: [vi.fn(), vi.fn()], confirmationDialog: [mockOpenConfirmation, vi.fn()], spinnerDialog: [vi.fn(), vi.fn()], @@ -611,9 +606,7 @@ describe('MusicPanel', () => { openFolderDialog: [vi.fn(), vi.fn()], filePickerDialog: [vi.fn(), vi.fn()], mediaSearchDialog: [vi.fn(), vi.fn()], - renameFileDialog: [vi.fn(), vi.fn()], renameFolderDialog: [vi.fn(), vi.fn()], - scrapeDialog: [vi.fn(), vi.fn()], }); renderHook(() => MusicPanel()); @@ -648,8 +641,6 @@ describe('MusicPanel', () => { vi.mocked(useDialogs).mockReturnValue({ mediaFilePropertyDialog: [vi.fn(), vi.fn()], - formatConverterDialog: [vi.fn(), vi.fn()], - videoCompressionDialog: [vi.fn(), vi.fn()], downloadVideoDialog: [vi.fn(), vi.fn()], confirmationDialog: [mockOpenConfirmation, vi.fn()], spinnerDialog: [vi.fn(), vi.fn()], @@ -657,9 +648,7 @@ describe('MusicPanel', () => { openFolderDialog: [vi.fn(), vi.fn()], filePickerDialog: [vi.fn(), vi.fn()], mediaSearchDialog: [vi.fn(), vi.fn()], - renameFileDialog: [vi.fn(), vi.fn()], renameFolderDialog: [vi.fn(), vi.fn()], - scrapeDialog: [vi.fn(), vi.fn()], }); renderHook(() => MusicPanel()); @@ -771,8 +760,6 @@ describe('MusicPanel', () => { mediaFilePropertyDialog: [vi.fn(() => { throw mockError; }), vi.fn()], - formatConverterDialog: [vi.fn(), vi.fn()], - videoCompressionDialog: [vi.fn(), vi.fn()], downloadVideoDialog: [vi.fn(), vi.fn()], confirmationDialog: [vi.fn(), vi.fn()], spinnerDialog: [vi.fn(), vi.fn()], @@ -780,9 +767,7 @@ describe('MusicPanel', () => { openFolderDialog: [vi.fn(), vi.fn()], filePickerDialog: [vi.fn(), vi.fn()], mediaSearchDialog: [vi.fn(), vi.fn()], - renameFileDialog: [vi.fn(), vi.fn()], renameFolderDialog: [vi.fn(), vi.fn()], - scrapeDialog: [vi.fn(), vi.fn()], }); renderHook(() => MusicPanel()); @@ -920,8 +905,6 @@ describe('MusicPanel', () => { const mockOpenConfirmation = vi.fn(); vi.mocked(useDialogs).mockReturnValue({ mediaFilePropertyDialog: [vi.fn(), vi.fn()], - formatConverterDialog: [vi.fn(), vi.fn()], - videoCompressionDialog: [vi.fn(), vi.fn()], downloadVideoDialog: [vi.fn(), vi.fn()], confirmationDialog: [mockOpenConfirmation, vi.fn()], spinnerDialog: [vi.fn(), vi.fn()], @@ -929,9 +912,7 @@ describe('MusicPanel', () => { openFolderDialog: [vi.fn(), vi.fn()], filePickerDialog: [vi.fn(), vi.fn()], mediaSearchDialog: [vi.fn(), vi.fn()], - renameFileDialog: [vi.fn(), vi.fn()], renameFolderDialog: [vi.fn(), vi.fn()], - scrapeDialog: [vi.fn(), vi.fn()], }); vi.mocked(moveFileToTrash).mockRejectedValue(new Error('Move to trash failed')); @@ -1014,8 +995,6 @@ describe('MusicPanel', () => { ); vi.mocked(useDialogs).mockReturnValue({ mediaFilePropertyDialog: [vi.fn(), vi.fn()], - formatConverterDialog: [h.mockOpenFormatConverter, vi.fn()], - videoCompressionDialog: [h.mockOpenFormatConverter, vi.fn()], downloadVideoDialog: [vi.fn(), vi.fn()], confirmationDialog: [vi.fn(), vi.fn()], spinnerDialog: [vi.fn(), vi.fn()], @@ -1023,35 +1002,36 @@ describe('MusicPanel', () => { openFolderDialog: [vi.fn(), vi.fn()], filePickerDialog: [vi.fn(), vi.fn()], mediaSearchDialog: [vi.fn(), vi.fn()], - renameFileDialog: [vi.fn(), vi.fn()], renameFolderDialog: [vi.fn(), vi.fn()], - scrapeDialog: [vi.fn(), vi.fn()], }); }); it('opens format converter with full nested path on track:formatConvert', async () => { + const listener = vi.fn(); + document.addEventListener(UI_AskForFormatConverter, listener); renderHook(() => MusicPanel()); + try { + await act(async () => { + document.dispatchEvent( + new CustomEvent('track:formatConvert', { + bubbles: true, + composed: true, + detail: { trackId: 0, timestamp: Date.now() }, + }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); - await act(async () => { - document.dispatchEvent( - new CustomEvent('track:formatConvert', { - bubbles: true, - composed: true, - detail: { trackId: 0, timestamp: Date.now() }, - }), - ); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(h.mockOpenFormatConverter).toHaveBeenCalledWith( - expect.objectContaining({ - path: NESTED_FILE_PLATFORM, - filePath: NESTED_FILE_PLATFORM, - }), - ); - const arg = h.mockOpenFormatConverter.mock.calls[0]![0]; - expect(arg.path).toContain('a/b/c/d'); - expect(arg.path).not.toBe('test.mp4'); + expect(listener).toHaveBeenCalledTimes(1); + const event = listener.mock.calls[0]?.[0] as CustomEvent<{ + filePath?: string; + }>; + expect(event.detail.filePath).toBe(NESTED_FILE_PLATFORM); + expect(event.detail.filePath).toContain('a/b/c/d'); + expect(event.detail.filePath).not.toBe('test.mp4'); + } finally { + document.removeEventListener(UI_AskForFormatConverter, listener); + } }); it('opens nested file with platform path on track:open', async () => { diff --git a/apps/ui/src/components/music/MusicPanel.tsx b/apps/ui/src/components/music/MusicPanel.tsx index 1cb39d89..7d7a3c13 100644 --- a/apps/ui/src/components/music/MusicPanel.tsx +++ b/apps/ui/src/components/music/MusicPanel.tsx @@ -6,6 +6,11 @@ import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys"; import { useMediaFolderFilesQuery } from "@/hooks/useMediaFolderFilesQuery"; import type { MediaMetadata } from "@smm/types"; import type { UIMediaFolderStatus } from "@/types/UIMediaFolder"; +import { + UI_AskForVideoCompression, + type OnAskForVideoCompressionEventData, +} from "@/types/eventTypes"; +import { askForFormatConverter } from "@/lib/dialogRequestEvents"; import { MusicFileTable, type LocalFileTableRowData, @@ -180,14 +185,10 @@ export function MusicPanel() { mediaFilePropertyDialog, confirmationDialog, downloadVideoDialog, - formatConverterDialog, - videoCompressionDialog, } = useDialogs(); const [openMediaFileProperty] = mediaFilePropertyDialog; const [openConfirmation, closeConfirmation] = confirmationDialog; const [openDownloadVideo] = downloadVideoDialog; - const [openFormatConverter] = formatConverterDialog; - const [openVideoCompression] = videoCompressionDialog; const [tracks, setTracks] = useState([]); const [currentTrackId, setCurrentTrackId] = useState(null); @@ -498,15 +499,12 @@ export function MusicPanel() { toast.error("This track has no file path."); return; } - openFormatConverter({ - id: track.id, + askForFormatConverter({ + filePath: track.path, title: track.title, - artist: track.artist, duration: track.duration, - path: track.path, - filePath: track.path, }); - }, [tracks, openFormatConverter]); + }, [tracks]); const handleTrackVideoCompress = useCallback((event: CustomEvent) => { const { trackId } = event.detail; @@ -519,12 +517,16 @@ export function MusicPanel() { toast.error("This track has no file path."); return; } - openVideoCompression({ - filePath: track.path, - title: track.title, - duration: track.duration, - }); - }, [tracks, openVideoCompression]); + document.dispatchEvent( + new CustomEvent(UI_AskForVideoCompression, { + detail: { + filePath: track.path, + title: track.title, + duration: track.duration, + }, + }), + ); + }, [tracks]); diff --git a/apps/ui/src/components/rename-file/RenameFile.test.tsx b/apps/ui/src/components/rename-file/RenameFile.test.tsx new file mode 100644 index 00000000..d7587a70 --- /dev/null +++ b/apps/ui/src/components/rename-file/RenameFile.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, act, cleanup } from "@testing-library/react" +import { UI_AskForRenameFile } from "@/types/eventTypes" + +let lastProps: Record | undefined + +vi.mock("@/components/dialogs", () => ({ + RenameFileDialog: (props: Record) => { + lastProps = props + return
+ }, +})) + +import { RenameFile } from "./RenameFile" + +function ask( + onConfirm: (newName: string) => void, + options?: { initialValue?: string; title?: string }, +): void { + document.dispatchEvent(new CustomEvent(UI_AskForRenameFile, { detail: { onConfirm, options } })) +} + +describe("RenameFile (top-level, event-driven)", () => { + beforeEach(() => { + cleanup() + lastProps = undefined + }) + + it("opens the dialog with the requested options", () => { + const onConfirm = vi.fn() + render() + + act(() => { + ask(onConfirm, { initialValue: "S01E01.mkv", title: "Rename file" }) + }) + + expect(lastProps?.isOpen).toBe(true) + expect(lastProps?.initialValue).toBe("S01E01.mkv") + expect(lastProps?.title).toBe("Rename file") + }) + + it("routes the confirmed name back to the requester and closes", () => { + const onConfirm = vi.fn() + render() + + act(() => { + ask(onConfirm) + }) + const dialogConfirm = lastProps?.onConfirm as (newName: string) => void + + act(() => { + dialogConfirm("S01E02.mkv") + }) + + expect(onConfirm).toHaveBeenCalledWith("S01E02.mkv") + expect(lastProps?.isOpen).toBe(false) + }) + + it("ignores requests without an onConfirm callback", () => { + render() + + act(() => { + document.dispatchEvent(new CustomEvent(UI_AskForRenameFile, { detail: {} })) + }) + + expect(lastProps?.isOpen).toBe(false) + }) +}) diff --git a/apps/ui/src/components/rename-file/RenameFile.tsx b/apps/ui/src/components/rename-file/RenameFile.tsx new file mode 100644 index 00000000..f2fc2458 --- /dev/null +++ b/apps/ui/src/components/rename-file/RenameFile.tsx @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useState } from "react" +import { RenameFileDialog } from "@/components/dialogs" +import { + UI_AskForRenameFile, + type OnAskForRenameFileEventData, + type RenameFileDialogOptions, +} from "@/types/eventTypes" + +/** + * Top-level owner of the single-file rename dialog (rendered once at App level). + * + * Renaming a file is transactional: requesters (TvShowPanel / MoviePanel + * rename flows, episode-file context menu) dispatch a + * {@link UI_AskForRenameFile} document event carrying their `onConfirm` + * callback plus dialog options. This component owns the dialog UI state and + * routes the confirmed new name back to the requester — decoupled from the + * panels that request the rename. + */ +export function RenameFile() { + const [isOpen, setIsOpen] = useState(false) + const [options, setOptions] = useState({}) + const [onConfirm, setOnConfirm] = useState<((newName: string) => void) | null>(null) + + const openFromRequest = useCallback((detail: OnAskForRenameFileEventData | undefined) => { + if (!detail?.onConfirm) return + setOnConfirm(() => detail.onConfirm) + setOptions(detail.options ?? {}) + setIsOpen(true) + }, []) + + const close = useCallback(() => { + setIsOpen(false) + }, []) + + const handleConfirm = useCallback( + (newName: string) => { + onConfirm?.(newName) + close() + }, + [onConfirm, close], + ) + + useEffect(() => { + const handler = (event: Event) => { + openFromRequest((event as CustomEvent).detail) + } + document.addEventListener(UI_AskForRenameFile, handler) + return () => { + document.removeEventListener(UI_AskForRenameFile, handler) + } + }, [openFromRequest]) + + return ( + + ) +} diff --git a/apps/ui/src/components/scrape/ScrapeMetadata.test.tsx b/apps/ui/src/components/scrape/ScrapeMetadata.test.tsx new file mode 100644 index 00000000..373a125e --- /dev/null +++ b/apps/ui/src/components/scrape/ScrapeMetadata.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, act, cleanup } from "@testing-library/react" +import type { MediaMetadata } from "@smm/types" +import { UI_AskForScrape } from "@/types/eventTypes" + +let lastDialogProps: Record | undefined +let scrapeInput: { isOpen: boolean; mediaMetadata?: MediaMetadata } | undefined + +vi.mock("@/components/dialogs", () => ({ + UIScrapeDialog: (props: Record) => { + lastDialogProps = props + return
+ }, + useScrapeDialog: (input: { isOpen: boolean; mediaMetadata?: MediaMetadata }) => { + scrapeInput = input + return { + tasks: [], + isRunning: false, + allTasksDone: false, + showButtons: true, + cancelDisabled: false, + canDismissIncidentally: true, + handleCancel: vi.fn(), + handleStart: vi.fn(async () => {}), + } + }, +})) + +import { ScrapeMetadata } from "./ScrapeMetadata" + +function ask(mediaMetadata?: MediaMetadata): void { + document.dispatchEvent(new CustomEvent(UI_AskForScrape, { detail: { mediaMetadata } })) +} + +describe("ScrapeMetadata (top-level, event-driven)", () => { + beforeEach(() => { + cleanup() + lastDialogProps = undefined + scrapeInput = undefined + }) + + it("opens the scrape dialog with the requested media metadata", () => { + const mediaMetadata = { mediaFolderPath: "/media/show" } as MediaMetadata + render() + + act(() => { + ask(mediaMetadata) + }) + + expect(scrapeInput?.isOpen).toBe(true) + expect(scrapeInput?.mediaMetadata).toBe(mediaMetadata) + expect(lastDialogProps?.isOpen).toBe(true) + }) + + it("does not open when no request arrives", () => { + render() + + expect(scrapeInput?.isOpen).toBe(false) + expect(lastDialogProps?.isOpen).toBe(false) + }) +}) diff --git a/apps/ui/src/components/scrape/ScrapeMetadata.tsx b/apps/ui/src/components/scrape/ScrapeMetadata.tsx new file mode 100644 index 00000000..9ab5e480 --- /dev/null +++ b/apps/ui/src/components/scrape/ScrapeMetadata.tsx @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useState } from "react" +import { UIScrapeDialog, useScrapeDialog } from "@/components/dialogs" +import { + UI_AskForScrape, + type OnAskForScrapeEventData, +} from "@/types/eventTypes" + +/** + * Top-level owner of the metadata scrape flow (rendered once at App level). + * + * The TV / movie headers only dispatch a {@link UI_AskForScrape} document + * event carrying the folder's media metadata; this component owns the dialog + * UI state and runs the scrape orchestration (tasks derivation, job polling, + * cancel/start) via `useScrapeDialog` — decoupled from the requesting panels. + */ +export function ScrapeMetadata() { + const [isOpen, setIsOpen] = useState(false) + const [options, setOptions] = useState({}) + + const openFromRequest = useCallback((detail: OnAskForScrapeEventData | undefined) => { + setOptions({ + mediaMetadata: detail?.mediaMetadata, + title: detail?.title, + description: detail?.description, + }) + setIsOpen(true) + }, []) + + const close = useCallback(() => { + setIsOpen(false) + }, []) + + useEffect(() => { + const handler = (event: Event) => { + openFromRequest((event as CustomEvent).detail) + } + document.addEventListener(UI_AskForScrape, handler) + return () => { + document.removeEventListener(UI_AskForScrape, handler) + } + }, [openFromRequest]) + + const scrape = useScrapeDialog({ + isOpen, + onClose: close, + mediaMetadata: options.mediaMetadata, + }) + + return ( + + ) +} diff --git a/apps/ui/src/components/sidebar/Sidebar.test.tsx b/apps/ui/src/components/sidebar/Sidebar.test.tsx index fe34b0cb..dba7491a 100644 --- a/apps/ui/src/components/sidebar/Sidebar.test.tsx +++ b/apps/ui/src/components/sidebar/Sidebar.test.tsx @@ -172,7 +172,7 @@ describe("Sidebar delete behavior", () => { />, ) - fireEvent.click(await screen.findByTestId(`delete-${Path.toPlatformPath(pathA)}`)) + fireEvent.click(await screen.findByTestId(`delete-${pathA}`)) expect(mutateAsync).toHaveBeenCalledTimes(1) expect(mutateAsync).toHaveBeenCalledWith(expect.arrayContaining([pathA, pathB])) @@ -193,10 +193,10 @@ describe("Sidebar delete behavior", () => { />, ) - fireEvent.click(await screen.findByTestId(`delete-${Path.toPlatformPath(pathB)}`)) + fireEvent.click(await screen.findByTestId(`delete-${pathB}`)) expect(mutateAsync).toHaveBeenCalledTimes(1) - expect(mutateAsync).toHaveBeenCalledWith([Path.toPlatformPath(pathB)]) + expect(mutateAsync).toHaveBeenCalledWith([pathB]) expect(onDeleteSelected).not.toHaveBeenCalled() }) }) @@ -362,29 +362,26 @@ describe("Sidebar selection UI", () => { const onSelectionChange = vi.fn() render() - const platformA = Path.toPlatformPath(pathA) - fireEvent.click(await screen.findByTestId(`select-${platformA}`)) + fireEvent.click(await screen.findByTestId(`select-${pathA}`)) expect(onSelectionChange).toHaveBeenCalledWith({ - selectedPaths: [platformA], - primaryPath: platformA, + selectedPaths: [pathA], + primaryPath: pathA, multi: false, }) - expect(screen.getByTestId(`select-${platformA}`)).toHaveAttribute("data-selected", "true") + expect(screen.getByTestId(`select-${pathA}`)).toHaveAttribute("data-selected", "true") }) it("toggles multi-select with ctrl/meta click", async () => { const onSelectionChange = vi.fn() render() - const platformA = Path.toPlatformPath(pathA) - const platformB = Path.toPlatformPath(pathB) - fireEvent.click(await screen.findByTestId(`select-${platformA}`)) - fireEvent.click(screen.getByTestId(`select-${platformB}`), { ctrlKey: true }) + fireEvent.click(await screen.findByTestId(`select-${pathA}`)) + fireEvent.click(screen.getByTestId(`select-${pathB}`), { ctrlKey: true }) expect(onSelectionChange).toHaveBeenLastCalledWith({ - selectedPaths: [platformA, platformB], - primaryPath: platformB, + selectedPaths: [pathA, pathB], + primaryPath: pathB, multi: true, }) }) @@ -455,13 +452,13 @@ describe("Sidebar search UI", () => { const onSearchQueryChange = vi.fn() render() - expect(await screen.findByTestId(`select-${Path.toPlatformPath(pathA)}`)).toBeInTheDocument() - expect(screen.getByTestId(`select-${Path.toPlatformPath(pathB)}`)).toBeInTheDocument() + expect(await screen.findByTestId(`select-${pathA}`)).toBeInTheDocument() + expect(screen.getByTestId(`select-${pathB}`)).toBeInTheDocument() - fireEvent.change(screen.getByTestId("sidebar-search-input"), { target: { value: "Beta" } }) + fireEvent.change(screen.getByTestId("sidebar-search-input"), { target: { value: "folder-b" } }) - expect(onSearchQueryChange).toHaveBeenCalledWith("Beta") - expect(screen.queryByTestId(`select-${Path.toPlatformPath(pathA)}`)).toBeNull() - expect(screen.getByTestId(`select-${Path.toPlatformPath(pathB)}`)).toBeInTheDocument() + expect(onSearchQueryChange).toHaveBeenCalledWith("folder-b") + expect(screen.queryByTestId(`select-${pathA}`)).toBeNull() + expect(screen.getByTestId(`select-${pathB}`)).toBeInTheDocument() }) }) diff --git a/apps/ui/src/components/tv/TvShowEpisodeTable.tsx b/apps/ui/src/components/tv/TvShowEpisodeTable.tsx deleted file mode 100644 index d5d19254..00000000 --- a/apps/ui/src/components/tv/TvShowEpisodeTable.tsx +++ /dev/null @@ -1,954 +0,0 @@ -import { Spinner } from "@/components/ui/spinner" -import { basename, isAbsPath, join, relative } from "@/lib/path" -import { Path } from "@smm/utils/path" -import { pathToFileURL } from "@smm/utils/url" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { - ContextMenu, - ContextMenuCheckboxItem, - ContextMenuContent, - ContextMenuItem, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, - ContextMenuTrigger, -} from "@/components/ui/context-menu" -import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card" -import { CheckIcon, ChevronDownIcon, ChevronRightIcon, MinusIcon, Loader2, Video } from "lucide-react" -import Image from "@/components/Image" -import { useState, useMemo, useEffect, useRef } from "react" -import { useDialogs } from "@/providers/dialog-provider" -import { generateFfmpegScreenshots } from "@/api/ffmpeg" -import { useFailedCommandLogsStore } from "@/stores/failedCommandLogsStore" -import { useUIMediaFolderStoreState } from "@/stores/uiMediaFolderStore" -import { renameEpisodeFileViaCore } from "@/api/renameEpisodeFile" -import { openFile } from "@/api/openFile" -import { toast } from "sonner" -import { useTranslation } from "@/lib/i18n" -import { cn } from "@/lib/utils" -import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" -import { useMediaMetadataQuery } from "@/hooks/mediaMetadata/useMediaMetadataQuery" - -export interface TvShowEpisodeDividerRow { - id: string - type: "divider" - text: string -} - -export interface TvShowEpisodeDataRow { - season: number, - episode: number, - type: "episode" - videoFile: string | undefined - thumbnail: string | undefined - subtitle: string | undefined - nfo: string | undefined - /** Episode title from TMDB (for detail layout). */ - episodeTitle?: string - /** Preview target paths (used when preview mode is active) */ - newVideoFile?: string - newThumbnail?: string - newSubtitle?: string - newNfo?: string - checked: boolean - /** - * Preview 模式下,该行不参与当前 plan 操作:checkbox 不可选,行以 muted 样式展示。 - * 仍保留 videoFile 等只读信息(如已有 mediaFiles 映射)。 - */ - disabled?: boolean -} - -export type FolderFileId = "clearlogo" | "fanart" | "poster" | "theme" | "nfo" - -export interface TvShowFolderFileRow { - id: FolderFileId - type: "folderFile" - path: string -} - -export type TvShowEpisodeTableRow = TvShowEpisodeDividerRow | TvShowEpisodeDataRow | TvShowFolderFileRow - -interface TvShowEpisodeTableProps { - data: TvShowEpisodeTableRow[] - /** Checkboxes state, used to show checked state in table. */ - // checkboxes: {season: number, episode: number, checked: boolean}[] - /** When set, video paths are shown relative to this path. */ - mediaFolderPath?: string - /** Called when user chooses "Select File" from context menu; row is the row data. */ - onSelectFileContextMenuClick?: (row: { season: number; episode: number }) => void - /** Called when user chooses "Video Compression" from context menu; row is the row data. */ - onVideoCompressContextMenuClick?: (row: TvShowEpisodeDataRow) => void - /** Called when user chooses "Unlink" from context menu; row is the row data. */ - onUnlinkContextMenuClick?: (row: { season: number; episode: number }) => void - /** - * NOTE: preview mode is different concept from preview layout. - * ** Preivew Mode ** Preview the recognition or rename plan - * ** Preivew Layout ** Display video screenshots for video - */ - preview?: "rename" | "recognize" - previewStatus?: "loading" | "ok" - /** - * NOTE: preview mode is different concept from preview layout. - * ** Preivew Mode ** Preview the recognition or rename plan - * ** Preivew Layout ** Display video screenshots for video - * Table layout: simple | detail (cover + title + path) | preview (no ID, larger cover, video screenshot). - * */ - layout?: "simple" | "detail" | "preview" - onCheck?: (row: TvShowEpisodeDataRow, checked: boolean) => void -} - -/** - * 重要说明: - * - * 当 TvShowEpisodeTable 进入预览模式 (preview prop 为 true) 时,父组件需要自动将 layout 切换为 'simple' 模式。 - * - * 原因: 前台模式 (detail 和 preview layout) 不支持预览功能,因为: - * 1. Preview 布局本身就是为了展示预览内容,与 preview prop 的功能重叠 - * 2. Detail 布局包含额外的元数据显示,与预览模式的简化显示冲突 - * 3. Simple 布局是最适合展示重命名预览的布局,能够清晰显示旧文件名和新文件名 - * - * 参见: TvShowPanel.tsx 中 renameFlow / aiRenameFlow 与 plan 驱动的 preview 实现 - */ - -function CheckCell({ value }: { value: string | undefined }) { - const checked = value !== undefined - - if (checked) { - return ( -
- -
- ) - } - - return ( -
- -
- ) -} - -function getDisplayPath(fullPath: string, basePath: string | undefined): string { - if (!basePath) return fullPath - try { - return relative(basePath, fullPath) - } catch { - return fullPath - } -} - -/** Builds a file:// URL for the thumbnail that the backend can resolve (platform path → file URL). */ -function getThumbnailImageUrl(thumbnailPath: string, mediaFolderPath: string | undefined): string { - const absolutePath = - mediaFolderPath && !isAbsPath(thumbnailPath) - ? join(mediaFolderPath, thumbnailPath) - : thumbnailPath - const platformPath = Path.toPlatformPath(absolutePath) - const url = pathToFileURL(platformPath) - if (import.meta.env.DEV) { - console.debug("[TvShowEpisodeTable] thumbnail image url", { - thumbnailPath, - absolutePath, - platformPath, - url, - }) - } - return url -} - -function ThumbnailImage({ - thumbnailPath, - mediaFolderPath, - className = "max-h-[240px] w-auto rounded object-contain", -}: { - thumbnailPath: string - mediaFolderPath: string | undefined - className?: string -}) { - const url = getThumbnailImageUrl(thumbnailPath, mediaFolderPath) - return -} - -/** Global queue to ensure only one screenshot generation runs at a time. */ -let screenshotQueue: Promise = Promise.resolve() - -function enqueueScreenshotTask(task: () => Promise) { - screenshotQueue = screenshotQueue.then(task).catch((error) => { - console.error("[TvShowEpisodeTable] screenshot task error", error) - }) - return screenshotQueue -} - -const SCREENSHOT_SLOT_COUNT = 5 - -const LOG = "[EpisodeVideoScreenshot]" - -/** Fetches and displays video screenshots (multiple) for a video file path. */ -function EpisodeVideoScreenshot({ - videoPath, - mediaFolderPath, - folderAbortSignal, - className, -}: { - videoPath: string - mediaFolderPath: string | undefined - /** When aborted (e.g. user switched folder), pending/queued screenshot requests are cancelled. */ - folderAbortSignal?: AbortSignal - className?: string -}) { - const [screenshots, setScreenshots] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(false) - /** Ref to detect stale results: only apply when task's path still matches current. */ - const loadingPathRef = useRef(null) - - useEffect(() => { - const label = videoPath ? basename(videoPath) || videoPath : "(no path)" - if (!videoPath) { - console.log(LOG, "effect run (no videoPath)", { label }) - setScreenshots([]) - setLoading(false) - loadingPathRef.current = null - return - } - setLoading(true) - setError(false) - const absolutePath = - mediaFolderPath && !isAbsPath(videoPath) ? join(mediaFolderPath, videoPath) : videoPath - const posixPath = Path.posix(absolutePath) - loadingPathRef.current = posixPath - const signal = folderAbortSignal - console.log(LOG, "effect run, enqueue task", { label, posixPath: posixPath.slice(-60), signalAborted: signal?.aborted }) - - enqueueScreenshotTask(async () => { - if (signal?.aborted) { - console.log(LOG, "task start skip (signal aborted)", { label }) - return - } - console.log(LOG, "task start, calling API", { label }) - try { - const result = await generateFfmpegScreenshots(posixPath, { signal }) - const screenshotsLen = result.screenshots?.length ?? 0 - const stillCurrent = loadingPathRef.current === posixPath - console.log(LOG, "API returned", { - label, - screenshotsLen, - signalAborted: signal?.aborted, - loadingPathRefCurrent: loadingPathRef.current?.slice(-60) ?? null, - posixPathTail: posixPath.slice(-60), - stillCurrent, - }) - if (signal?.aborted) { - console.log(LOG, "skip setState (signal aborted)", { label }) - return - } - if (loadingPathRef.current !== posixPath) { - console.log(LOG, "skip setState (path no longer current)", { label }) - return - } - if (result.error) { - console.warn(LOG, "screenshot generation error", label, result.error) - if (result.executionId && stillCurrent) { - useFailedCommandLogsStore.getState().addEntry({ - executionId: result.executionId, - title: `Screenshots: ${label}`, - command: "ffmpeg", - error: result.error, - timestamp: Date.now(), - }) - } - setError(true) - setScreenshots([]) - } else if (result.screenshots && result.screenshots.length > 0) { - setScreenshots(result.screenshots) - console.log(LOG, "setScreenshots called", { label, count: result.screenshots.length }) - } else { - setScreenshots([]) - console.log(LOG, "setScreenshots([]) (no screenshots in result)", { label }) - } - } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err) - const errName = err instanceof Error ? err.name : "" - console.log(LOG, "API error", label, errName, errMsg, "signalAborted:", signal?.aborted, "stillCurrent:", loadingPathRef.current === posixPath) - if (signal?.aborted) return - if (loadingPathRef.current === posixPath) { - setError(true) - setScreenshots([]) - } - } finally { - const doSetLoadingFalse = loadingPathRef.current === posixPath && !signal?.aborted - console.log(LOG, "finally", { label, doSetLoadingFalse, loadingPathRefCurrent: loadingPathRef.current?.slice(-60) ?? null, posixPathTail: posixPath.slice(-60) }) - if (doSetLoadingFalse) { - setLoading(false) - } - } - }) - - return () => { - console.log(LOG, "effect cleanup", { label }) - loadingPathRef.current = null - } - }, [videoPath, mediaFolderPath, folderAbortSignal]) - - if (loading) { - return ( -
- {Array.from({ length: SCREENSHOT_SLOT_COUNT }, (_, i) => ( -
- -
- ))} -
- ) - } - if (error || screenshots.length === 0) { - return ( -
- {Array.from({ length: SCREENSHOT_SLOT_COUNT }, (_, i) => ( -
-
- ))} -
- ) - } - return ( -
- {screenshots.map((path, i) => ( - - ))} -
- ) -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const COLUMN_KEYS = ["video", "thumbnail", "subtitle", "nfo"] as const -type ColumnKey = (typeof COLUMN_KEYS)[number] - -const getColumnLabels = (t: (key: string, options?: Record) => string): Record => ({ - video: t('tvShowEpisodeTable.columns.video'), - thumbnail: t('tvShowEpisodeTable.columns.thumbnail'), - subtitle: t('tvShowEpisodeTable.columns.subtitle'), - nfo: t('tvShowEpisodeTable.columns.nfo'), -}) - -const defaultColumnVisibility: Record = { - video: true, - thumbnail: true, - subtitle: true, - nfo: true, -} - -export function TvShowEpisodeTable({ - data, - // checkboxes, - mediaFolderPath, - onSelectFileContextMenuClick: onSelectFileContextMenuClick, - onVideoCompressContextMenuClick, - onUnlinkContextMenuClick, - preview, - previewStatus, - layout = "simple", - onCheck }: TvShowEpisodeTableProps) { - const [collapsedIds, setCollapsedIds] = useState>(new Set()) - const [columnVisibility, setColumnVisibility] = useState>(defaultColumnVisibility) - const folderAbortRef = useRef(null) - const [folderAbortSignal, setFolderAbortSignal] = useState(undefined) - useEffect(() => { - if (layout === "preview" && !folderAbortRef.current) { - folderAbortRef.current = new AbortController() - setFolderAbortSignal(folderAbortRef.current.signal) - } - }, [layout]) - - // When leaving preview layout, cancel in-flight/queued screenshot requests. - useEffect(() => { - if (layout !== "preview") { - folderAbortRef.current?.abort() - folderAbortRef.current = null - // eslint-disable-next-line react-hooks/set-state-in-effect - setFolderAbortSignal(undefined) - } - }, [layout]) - - // On unmount, cancel any screenshot requests. - useEffect(() => { - return () => { - folderAbortRef.current?.abort() - folderAbortRef.current = null - } - }, []) - - const { t } = useTranslation(['components', 'dialogs']) - const { selectedFolder } = useUIMediaFolderStoreState() - const { data: selectedMediaMetadata } = useMediaMetadataQuery(selectedFolder || undefined) - const { mutate: fetchMediaMetadata } = useFetchMediaMetadataMutation() - const { renameFileDialog } = useDialogs() - const [openRename] = renameFileDialog - - const columnLabels = getColumnLabels(t as (key: string, options?: Record) => string) - - const sectionIdByIndex = useMemo(() => { - const map = new Map() - let currentId = "" - data.forEach((row, index) => { - if (row.type === "divider") { - currentId = row.id - } - map.set(index, currentId) - }) - return map - }, [data]) - - const toggleCollapsed = (dividerId: string) => { - setCollapsedIds((prev) => { - const next = new Set(prev) - if (next.has(dividerId)) next.delete(dividerId) - else next.add(dividerId) - return next - }) - } - - const toggleColumn = (key: ColumnKey) => { - setColumnVisibility((prev) => ({ ...prev, [key]: !prev[key] })) - } - - const isSimpleLayout = layout === "simple" - const isPreviewLayout = layout === "preview" - const showThumbnailColumn = (!isSimpleLayout && layout === "detail") || isPreviewLayout || columnVisibility.thumbnail - const showIdColumn = layout !== "preview" - const showCheckboxColumn = preview !== undefined - const visibleColumnCount = - (showCheckboxColumn ? 1 : 0) + - (showIdColumn ? 1 : 0) + - (showThumbnailColumn ? 1 : 0) + - (columnVisibility.video ? 1 : 0) + - (columnVisibility.subtitle ? 1 : 0) + - (columnVisibility.nfo ? 1 : 0) - - const thumbnailCellWidth = isPreviewLayout ? "w-[160px] min-w-[160px]" : layout === "detail" ? "w-[100px] min-w-[100px]" : "" - - const headerRow = ( - - {showCheckboxColumn && ( - - {t('tvShowEpisodeTable.renameCheckboxHeader', { defaultValue: '' })} - - )} - {showIdColumn && ( - {t('tvShowEpisodeTable.columns.id')} - )} - {isSimpleLayout ? ( - <> - {columnVisibility.video && ( - {t('tvShowEpisodeTable.header.videoFile')} - )} - {showThumbnailColumn && ( - - {t('tvShowEpisodeTable.header.thumb')} - - )} - - ) : ( - <> - {showThumbnailColumn && ( - - {t('tvShowEpisodeTable.header.thumb')} - - )} - {columnVisibility.video && ( - {t('tvShowEpisodeTable.header.videoFile')} - )} - - )} - {columnVisibility.subtitle && ( - {t('tvShowEpisodeTable.header.sub')} - )} - {columnVisibility.nfo && ( - {t('tvShowEpisodeTable.columns.nfo')} - )} - - ) - - return ( -
- - - - - {headerRow} - - - - {t('tvShowEpisodeTable.contextMenu.showColumns')} - - toggleColumn("video")} - onSelect={(e) => e.preventDefault()} - > - {columnLabels.video} - - toggleColumn("thumbnail")} - onSelect={(e) => e.preventDefault()} - > - {columnLabels.thumbnail} - - toggleColumn("subtitle")} - onSelect={(e) => e.preventDefault()} - > - {columnLabels.subtitle} - - toggleColumn("nfo")} - onSelect={(e) => e.preventDefault()} - > - {columnLabels.nfo} - - - - - - - - {data.map((row, index) => { - if (row.type === "divider") { - const isCollapsed = collapsedIds.has(row.id) - return ( - - {showCheckboxColumn && } - -
- {row.text} - -
-
-
- ) - } - - if (row.type === "folderFile") { - const folderFileRow = ( - - {showCheckboxColumn && } - {row.id} - - {getDisplayPath(row.path, mediaFolderPath)} - - - ) - - return ( - - - {folderFileRow} - - - { - const absolutePath = mediaFolderPath && !isAbsPath(row.path) ? join(mediaFolderPath, row.path) : row.path - const platformPath = Path.toPlatformPath(absolutePath) - openFile(platformPath).catch((error) => { - console.error('[TvShowEpisodeTable] Failed to open folder file:', error) - }) - }} - > - {t('episodeFile.open', { ns: 'components' })} - - - - ) - } - - const sectionId = sectionIdByIndex.get(index) - if (sectionId && collapsedIds.has(sectionId)) { - return null - } - - const isRowDisabled = row.disabled === true - - const episodeRow = ( - - {showCheckboxColumn && ( - - { - if (isRowDisabled) return - onCheck?.(row, e.target.checked) - }} - /> - - )} - {showIdColumn && ( - {`S${String(row.season).padStart(2, "0")}E${String(row.episode).padStart(2, "0")}`} - )} - {isSimpleLayout && columnVisibility.video && ( - - {row.videoFile ? ( - preview === 'rename' && !row.newVideoFile ? ( -
- {getDisplayPath(row.videoFile, mediaFolderPath)} -
- ) : preview === 'rename' && row.newVideoFile && basename(row.videoFile) !== basename(row.newVideoFile) ? ( -
-
- {getDisplayPath(row.videoFile, mediaFolderPath)} -
-
- {getDisplayPath(row.newVideoFile, mediaFolderPath)} -
-
- ) : ( -
- {getDisplayPath(row.videoFile, mediaFolderPath)} -
- ) - ) : preview === 'recognize' ? ( - previewStatus === 'loading' ? ( - - ) : ( - {t('tvShowEpisodeTable.unrecognizedVideoFile', { defaultValue: 'Cannot recognize video file' })} - ) - ) : ( - - - )} -
- )} - {isSimpleLayout && showThumbnailColumn && ( - - {row.thumbnail ? ( - - -
- -
-
- - - -
- ) : ( - - )} -
- )} - {!isSimpleLayout && showThumbnailColumn && ( - - {isPreviewLayout ? ( - row.thumbnail ? ( - - ) : ( - - - ) - ) : layout === "detail" ? ( - row.thumbnail ? ( - - ) : ( - - - ) - ) : row.thumbnail ? ( - - -
- -
-
- - - -
- ) : ( - - )} -
- )} - {!isSimpleLayout && columnVisibility.video && ( - - {isPreviewLayout ? ( -
-
- {`S${String(row.season).padStart(2, "0")}E${String(row.episode).padStart(2, "0")}`} {row.episodeTitle ? `· ${row.episodeTitle}` : ""} -
- {row.videoFile ? ( - <> -
- {getDisplayPath(row.videoFile, mediaFolderPath)} -
- {!isRowDisabled && ( - - )} - - ) : ( - - - )} -
- ) : layout === "detail" ? ( -
-
- {row.episodeTitle || `S${String(row.season).padStart(2, "0")}E${String(row.episode).padStart(2, "0")}` || "-"} -
- {row.videoFile ? ( - preview === 'rename' && row.newVideoFile && basename(row.videoFile) !== basename(row.newVideoFile) ? ( - <> -
- {getDisplayPath(row.videoFile, mediaFolderPath)} -
-
- {getDisplayPath(row.newVideoFile, mediaFolderPath)} -
- - ) : ( -
- {getDisplayPath(row.videoFile, mediaFolderPath)} -
- ) - ) : ( - - - )} -
- ) : row.videoFile ? ( - preview === 'rename' && row.newVideoFile && basename(row.videoFile) !== basename(row.newVideoFile) ? ( -
-
- {getDisplayPath(row.videoFile, mediaFolderPath)} -
-
- {getDisplayPath(row.newVideoFile, mediaFolderPath)} -
-
- ) : ( -
- {getDisplayPath(row.videoFile, mediaFolderPath)} -
- ) - ) : ( - - - )} -
- )} - {columnVisibility.subtitle && ( - - - - )} - {columnVisibility.nfo && ( - - - - )} -
- ) - - return ( - - - {episodeRow} - - - { - if (!row.videoFile) return - const absolutePath = mediaFolderPath && !isAbsPath(row.videoFile) ? join(mediaFolderPath, row.videoFile) : row.videoFile - const platformPath = Path.toPlatformPath(absolutePath) - openFile(platformPath).catch((error) => { - console.error('[TvShowEpisodeTable] Failed to open file:', error) - }) - }} - > - {t('episodeFile.open', { ns: 'components' })} - - { - if (!row.videoFile || !mediaFolderPath || !selectedMediaMetadata?.mediaFolderPath) return - let relativePath: string - try { - relativePath = relative(mediaFolderPath, row.videoFile) - } catch { - relativePath = row.videoFile - } - openRename( - async (newRelativePath: string) => { - if (!selectedMediaMetadata?.mediaFolderPath || !row.videoFile) return - try { - const newAbsolutePath = join(selectedMediaMetadata.mediaFolderPath, newRelativePath) - await renameEpisodeFileViaCore({ - mediaFolder: Path.posix(selectedMediaMetadata.mediaFolderPath), - from: row.videoFile, - to: newAbsolutePath, - }) - fetchMediaMetadata({ path: selectedMediaMetadata.mediaFolderPath }) - toast.success(t('episodeFile.renameSuccess', { ns: 'components' })) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : t('episodeFile.renameFailed', { ns: 'components' }) - toast.error(t('episodeFile.renameFailed', { ns: 'components' }), { - description: errorMessage, - }) - throw error - } - }, - { - initialValue: relativePath, - title: t('dialogs:rename.title'), - description: t('dialogs:rename.fileDescription'), - } - ) - }} - > - {t('episodeFile.rename', { ns: 'components' })} - - { - onSelectFileContextMenuClick?.(row) - }} - > - {t('episodeFile.selectFile', { ns: 'components' })} - - { - onUnlinkContextMenuClick?.(row) - }} - > - {t('tvShowEpisodeTable.contextMenu.unlink')} - - {onVideoCompressContextMenuClick && ( - onVideoCompressContextMenuClick(row)} - > - {t('tvShowEpisodeTable.contextMenu.videoCompress')} - - )} - - - ) - })} -
-
-
- ) -} diff --git a/apps/ui/src/components/tv/TvShowPanel.tsx b/apps/ui/src/components/tv/TvShowPanel.tsx index 91a25707..3385c31c 100644 --- a/apps/ui/src/components/tv/TvShowPanel.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.tsx @@ -2,7 +2,7 @@ import { useUIMediaFolderStore, useUIMediaFolderStoreState } from "@/stores/uiMe import { useMediaMetadataQuery } from "@/hooks/mediaMetadata" import { useSelectTvShowForFolderMutation } from "@/hooks/useSelectTvShowForFolderMutation" import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" -import { useState, useEffect, useCallback, useMemo } from "react" +import { useState, useEffect, useCallback, useMemo, useRef } from "react" import type { MediaMetadata } from "@/lib/mediaFolderFiles" import { useMediaFolderFilesQuery } from "@/hooks/useMediaFolderFilesQuery" import type { TMDBTVShow, TMDBTVShowDetails } from "@smm/types" @@ -11,28 +11,31 @@ import { useTranslation } from "@/lib/i18n" import { TvShowPanelPrompts } from "./TvShowPanelPrompts" import { useTvShowPromptsStore } from "@/stores/tvShowPromptsStore" import { useTvShowPanelState } from "@/hooks/tv/useTvShowPanelState" +import { useTvShowEpisodeVideoCompress } from "@/hooks/tv/useTvShowEpisodeVideoCompress" +import { useTvShowEpisodeFormatConvert } from "@/hooks/tv/useTvShowEpisodeFormatConvert" import { useRuleBasedRenameFilesFlow } from "@/hooks/tv/useRuleBasedRenameFilesFlow" import { useRuleBasedRecognizeFlow } from "@/hooks/tv/useRuleBasedRecognizeFlow" import { useAiBasedRenameFilesFlow } from "@/hooks/tv/useAiBasedRenameFilesFlow" import { useAiBasedRecognizeFlow } from "@/hooks/tv/useAiBasedRecognizeFlow" import { useSelectAndUnselectFileFlow } from "@/hooks/tv/useSelectAndUnselectFileFlow" import { useResolvedLanguages } from "@/hooks/useResolvedLanguages" -import { useDialogs } from "@/providers/dialog-provider" +import { askForRenameFile, askForScrape } from "@/lib/dialogRequestEvents" import { usePlansQuery } from "@/hooks/plans" import { MediaFileTable } from "@/components/media/MediaFileTable" import type { UIMediaFileDataContextMenuItem, + UIMediaFileDataRow, UIMediaFileTableRow, + UIMediaEpisodeSelection, } from "@/components/media/UIMediaFileTable" import { useRenameVideoFileFlow } from "@/hooks/useRenameVideoFileFlow" -import { TvShowEpisodeTable, type TvShowEpisodeDataRow, type TvShowEpisodeTableRow } from "./TvShowEpisodeTable" import { TvShowPanelHeader } from "./TvShowPanelHeader" import { MediaPanelInitializingHint } from "../MediaPanelInitializingHint" import { TranscribeDialog, SubtitleTranslationDialog, SynthesizeSubtitleDialog, ProcessPipelineDialog } from "@/components/dialogs" import { useFeatures } from "@/hooks/useFeatures" import { useSubtitleFlow } from "@/hooks/useSubtitleFlow" import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" -import { buildTvShowEpisodeTableRows, buildTvShowEpisodeTableRowsForPlan } from "@/lib/buildTvShowEpisodeTableRows" +import { buildTvShowEpisodeTableRowsForPanel } from "@/lib/buildTvShowEpisodeTableRows" import { rebuildPlanWithSelectedEpisodes, rebuildRenamePlanWithSelectedEpisodes, @@ -94,31 +97,37 @@ function TvShowPanel() { const { selectTvShowForFolderMutation, updateMediaMetadata } = useSelectTvShowForFolderMutation() const { mutateAsync: fetchMediaMetadata } = useFetchMediaMetadataMutation() - const { renameFileDialog } = useDialogs() - const [openRenameFile] = renameFileDialog const videoRenameFlow = useRenameVideoFileFlow({ mediaFolderPath: mediaMetadata?.mediaFolderPath, - openRenameDialog: openRenameFile, + openRenameDialog: askForRenameFile, }) - const [tableData, setTableData] = useState([]) + const [tableData, setTableData] = useState([]) const latestTableData = useLatest(tableData) + // Checkbox selection — separate UI state, kept apart from row data so that + // user toggles survive the row rebuilds triggered by metadata / plan refetches. + const [selectedEpisodes, setSelectedEpisodes] = useState([]) + // The plan instance the current selection was seeded from. + const prevPlanRef = useRef(undefined) + const getSelectedEpisodePaths = useCallback( () => - latestTableData.current - .filter((row): row is TvShowEpisodeDataRow => row.type === "episode" && row.checked) - .map((row) => row.videoFile) + selectedEpisodes + .map(({ season, episode }) => { + const row = latestTableData.current.find( + (r): r is UIMediaFileDataRow => + r.type === "episode" && r.season === season && r.episode === episode, + ) + return row?.videoFile + }) .filter((path): path is string => path !== undefined), - [latestTableData], + [selectedEpisodes, latestTableData], ) const getSelectedEpisodes = useCallback( - () => - latestTableData.current - .filter((row): row is TvShowEpisodeDataRow => row.type === "episode" && row.checked) - .map((row) => ({ season: row.season, episode: row.episode })), - [latestTableData], + () => selectedEpisodes, + [selectedEpisodes], ) const recognizeBeforeConfirm = useCallback( @@ -144,13 +153,14 @@ function TvShowPanel() { }, [mediaMetadata?.mediaFolderPath, selectTvShowForFolderMutation], ) - const { scrapeDialog, videoCompressionDialog, formatConverterDialog } = useDialogs() - const [openScrape] = scrapeDialog + const { mediaLanguage } = useResolvedLanguages() const [episodeTableLayout, setEpisodeTableLayout] = useState<'simple' | 'detail' | 'preview'>('simple') - const { isVideoCompressionEnabled, isUseMediaFileTableEnabled, isFormatConverterEnabled } = useFeatures() + const { isVideoCompressionEnabled, isFormatConverterEnabled } = useFeatures() + const { handleVideoCompressForRow } = useTvShowEpisodeVideoCompress(mediaMetadata) + const { handleFormatConvertForRow } = useTvShowEpisodeFormatConvert(mediaMetadata) const subtitleFlow = useSubtitleFlow({ mediaMetadata, @@ -277,61 +287,23 @@ function TvShowPanel() { /* eslint-disable react-hooks/set-state-in-effect */ if (!mediaMetadata) return; - let ret: TvShowEpisodeTableRow[] = []; - if(plan === undefined) { - ret = buildTvShowEpisodeTableRows(mediaMetadata, uiStatus, (key: string) => { - return t(key as any) // eslint-disable-line @typescript-eslint/no-explicit-any - }, folderFiles) - } else { - ret = buildTvShowEpisodeTableRowsForPlan(mediaMetadata, uiStatus, plan, (key: string) => { - return t(key as any) // eslint-disable-line @typescript-eslint/no-explicit-any - }, folderFiles) - }; + const built = buildTvShowEpisodeTableRowsForPanel(mediaMetadata, uiStatus, plan, (key: string) => { + return t(key as any) // eslint-disable-line @typescript-eslint/no-explicit-any + }, folderFiles) + + setTableData(built.rows); - setTableData(ret); + // Re-seed the selection only when a (new) plan instance arrives. The + // selection is separate UI state, so unrelated row rebuilds (metadata / + // folderFiles refetches) must not wipe the user's check toggles. + if (plan !== prevPlanRef.current) { + prevPlanRef.current = plan + setSelectedEpisodes(built.defaultChecked) + } /* eslint-enable react-hooks/set-state-in-effect */ }, [mediaMetadata, plan, uiStatus, t, folderFiles]) - const handleVideoCompressForRow = useCallback( - (row: { season: number; episode: number; episodeTitle?: string }) => { - const seasonNo = row.season; - const episodeNo = row.episode; - const videoPath = mediaMetadata?.mediaFiles?.find( - (f) => f.seasonNumber === seasonNo && f.episodeNumber === episodeNo, - )?.absolutePath - if (!videoPath) { - console.warn( - `[TvShowPanel] handleVideoCompressForRow: no video path found for season ${seasonNo} episode ${episodeNo}`, - ) - return - } - const [openVideoCompression] = videoCompressionDialog - openVideoCompression({ - filePath: videoPath, - title: row.episodeTitle ?? `S${seasonNo}E${episodeNo}`, - }) - }, - [mediaMetadata, videoCompressionDialog], - ) - - const handleFormatConvertForRow = useCallback( - (row: { season: number; episode: number }) => { - const videoPath = mediaMetadata?.mediaFiles?.find( - (f) => f.seasonNumber === row.season && f.episodeNumber === row.episode, - )?.absolutePath - if (!videoPath) { - console.warn( - `[TvShowPanel] handleFormatConvertForRow: no video path found for S${row.season}E${row.episode}`, - ) - return - } - const [openFormatConverter] = formatConverterDialog - openFormatConverter(videoPath) - }, - [mediaMetadata, formatConverterDialog], - ) - const extraEpisodeContextMenu: UIMediaFileDataContextMenuItem[] = useMemo( () => [ { @@ -422,7 +394,7 @@ function TvShowPanel() { onRenameClick={renameFlow.startRenameFlow} selectedMediaMetadata={mediaMetadata} selectedMediaFolder={uiFolderRow} - openScrape={openScrape} + openScrape={askForScrape} showSubtitleMenu={subtitleFlow.showSubtitleMenu} {...subtitleFlow.header} episodeTableLayout={episodeTableLayout} @@ -432,39 +404,26 @@ function TvShowPanel() {
{uiStatus === "initializing" ? ( - ) : isUseMediaFileTableEnabled ? ( - ) : ( - { - - setTableData(prev => { - return prev.map(r => { - if(r.type !== 'episode') return r; - if(r.season !== row.season || r.episode !== row.episode) return r; - return { - ...r, - checked: checked, - } - }) + setSelectedEpisodes((prev) => { + const exists = prev.some( + (e) => e.season === row.season && e.episode === row.episode, + ) + if (checked === exists) return prev + if (checked) return [...prev, { season: row.season, episode: row.episode }] + return prev.filter( + (e) => !(e.season === row.season && e.episode === row.episode), + ) }) }} /> diff --git a/apps/ui/src/components/tv/TvShowPanelUtils.test.ts b/apps/ui/src/components/tv/TvShowPanelUtils.test.ts index a275950a..1558c7ce 100644 --- a/apps/ui/src/components/tv/TvShowPanelUtils.test.ts +++ b/apps/ui/src/components/tv/TvShowPanelUtils.test.ts @@ -17,11 +17,19 @@ vi.mock('@/lib/recognizeEpisodesUi', async (importOriginal) => { const mod = await importOriginal() return { ...mod, - recognizeEpisodesAsync: vi.fn((mm: Parameters[0]) => - Promise.resolve(mod.recognizeEpisodes(mm)) + recognizeEpisodesAsync: vi.fn( + (mm: Parameters[0], folderFiles: string[]) => + Promise.resolve(mod.recognizeEpisodes(mm, folderFiles)), ), } }) +vi.mock('@/lib/mediaFolderFiles', () => ({ + listMediaFolderFilePaths: vi.fn(async () => [ + '/media/testshow/tvshow.nfo', + '/media/testshow/episode1.nfo', + '/media/testshow/episode1.mkv', + ]), +})) vi.mock('sonner', () => ({ toast: { success: vi.fn(), @@ -137,7 +145,7 @@ describe('buildFileProps', () => { it('should build file props for valid media metadata', () => { const mm = createMockMediaMetadata() - const result = buildFileProps(mm, 1, 1) + const result = buildFileProps(mm, 1, 1, mm.files ?? []) expect(result.length).toBeGreaterThan(0) expect(result[0]).toEqual({ @@ -163,7 +171,7 @@ describe('buildFileProps', () => { it('should return empty array when files is undefined', () => { const mm = createMockMediaMetadata({ files: undefined }) - const result = buildFileProps(mm, 1, 1) + const result = buildFileProps(mm, 1, 1, mm.files ?? []) expect(result).toEqual([]) }) @@ -171,7 +179,7 @@ describe('buildFileProps', () => { it('should return empty array when files is null', () => { const mm = createMockMediaMetadata({ files: null }) - const result = buildFileProps(mm, 1, 1) + const result = buildFileProps(mm, 1, 1, mm.files ?? []) expect(result).toEqual([]) }) @@ -179,7 +187,7 @@ describe('buildFileProps', () => { it('should return empty array when mediaFile for season/episode is not found', () => { const mm = createMockMediaMetadata() - const result = buildFileProps(mm, 2, 5) + const result = buildFileProps(mm, 2, 5, mm.files ?? []) expect(result).toEqual([]) }) @@ -187,7 +195,7 @@ describe('buildFileProps', () => { it('should include associated files in the result', () => { const mm = createMockMediaMetadata() - const result = buildFileProps(mm, 1, 1) + const result = buildFileProps(mm, 1, 1, mm.files ?? []) // Should have video file plus associated files expect(result.length).toBeGreaterThan(1) @@ -1063,7 +1071,7 @@ describe('buildTemporaryRecognitionPlanAsync', () => { files: ['/media/S01E01.mkv'], tvShow: tvShowWithS1E1, } - const result = await buildTemporaryRecognitionPlanAsync(mm) + const result = await buildTemporaryRecognitionPlanAsync(mm, mm.files ?? []) expect(result).toBeNull() }) @@ -1073,7 +1081,7 @@ describe('buildTemporaryRecognitionPlanAsync', () => { files: undefined, tvShow: tvShowWithS1E1, } - const result = await buildTemporaryRecognitionPlanAsync(mm) + const result = await buildTemporaryRecognitionPlanAsync(mm, mm.files ?? []) expect(result).toBeNull() }) @@ -1083,7 +1091,7 @@ describe('buildTemporaryRecognitionPlanAsync', () => { files: ['/media/S01E01.mkv'], tvShow: undefined, } - const result = await buildTemporaryRecognitionPlanAsync(mm) + const result = await buildTemporaryRecognitionPlanAsync(mm, mm.files ?? []) expect(result).toBeNull() }) @@ -1098,7 +1106,7 @@ describe('buildTemporaryRecognitionPlanAsync', () => { seasons: [{ season: 1, name: '', episodes: [{ season: 1, episode: 1, name: '' }] }], }, } - const result = await buildTemporaryRecognitionPlanAsync(mm) + const result = await buildTemporaryRecognitionPlanAsync(mm, mm.files ?? []) expect(result).not.toBeNull() expect(result!.mediaFolderPath).toBe('/media') expect(result!.files).toHaveLength(0) @@ -1125,7 +1133,7 @@ describe('buildTemporaryRecognitionPlanAsync', () => { ], }, } - const result = await buildTemporaryRecognitionPlanAsync(mm) + const result = await buildTemporaryRecognitionPlanAsync(mm, mm.files ?? []) expect(result).not.toBeNull() expect(result!.mediaFolderPath).toBe(mediaFolderPath) expect(result!.files).toHaveLength(2) diff --git a/apps/ui/src/components/video-compression/VideoCompression.test.tsx b/apps/ui/src/components/video-compression/VideoCompression.test.tsx new file mode 100644 index 00000000..8f9608ca --- /dev/null +++ b/apps/ui/src/components/video-compression/VideoCompression.test.tsx @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, act, cleanup } from "@testing-library/react" +import { UI_AskForVideoCompression } from "@/types/eventTypes" + +let lastProps: Record | undefined +let featureEnabled = true + +vi.mock("@/components/dialogs", () => ({ + VideoCompressionDialog: (props: Record) => { + lastProps = props + return
+ }, +})) + +vi.mock("@/providers/dialog-provider", () => ({ + useDialogs: () => ({ filePickerDialog: [vi.fn(), vi.fn()] }), +})) + +vi.mock("@/hooks/useFeatures", () => ({ + useFeatures: () => ({ isVideoCompressionEnabled: featureEnabled }), +})) + +import { VideoCompression } from "./VideoCompression" + +function ask(detail: { filePath?: string; title?: string; duration?: number }): void { + document.dispatchEvent(new CustomEvent(UI_AskForVideoCompression, { detail })) +} + +describe("VideoCompression (top-level, event-driven)", () => { + beforeEach(() => { + cleanup() + lastProps = undefined + featureEnabled = true + }) + + it("opens the dialog with the context carried by UI_AskForVideoCompression", () => { + render() + + act(() => { + ask({ filePath: "/media/movie.mkv", title: "My Movie", duration: 5400 }) + }) + + expect(lastProps?.isOpen).toBe(true) + expect(lastProps?.filePath).toBe("/media/movie.mkv") + expect(lastProps?.title).toBe("My Movie") + expect(lastProps?.duration).toBe(5400) + }) + + it("opens the dialog without a source file (empty-state 'select a file' mode)", () => { + render() + + act(() => { + ask({}) + }) + + expect(lastProps?.isOpen).toBe(true) + expect(lastProps?.filePath).toBeUndefined() + }) + + it("ignores requests while the videoCompression feature is disabled", () => { + featureEnabled = false + render() + + act(() => { + ask({ filePath: "/media/movie.mkv" }) + }) + + expect(lastProps?.isOpen).toBe(false) + }) + + it("unsubscribes from the event on unmount", () => { + const { unmount } = render() + unmount() + lastProps = undefined + + act(() => { + ask({ filePath: "/media/movie.mkv" }) + }) + + expect(lastProps).toBeUndefined() + }) +}) diff --git a/apps/ui/src/components/video-compression/VideoCompression.tsx b/apps/ui/src/components/video-compression/VideoCompression.tsx new file mode 100644 index 00000000..d90ed0fa --- /dev/null +++ b/apps/ui/src/components/video-compression/VideoCompression.tsx @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useState } from "react" +import { VideoCompressionDialog } from "@/components/dialogs" +import { useDialogs } from "@/providers/dialog-provider" +import { useFeatures } from "@/hooks/useFeatures" +import { + UI_AskForVideoCompression, + type OnAskForVideoCompressionEventData, +} from "@/types/eventTypes" + +/** + * Top-level owner of the video compression feature. + * + * Rendered once at App level and fully decoupled from the content panels: + * TvShowPanel / MoviePanel / MusicPanel / the app menu only dispatch a + * {@link UI_AskForVideoCompression} document event with the source video + * context; this component owns + * - UI logic: dialog open/close state, + * - business logic: turning a request event into dialog context, wiring the + * folder/file picker, handling "select a source video" results, and + * gating on the `videoCompression` feature flag. + */ +export function VideoCompression() { + const { filePickerDialog } = useDialogs() + const [openFilePicker] = filePickerDialog + const { isVideoCompressionEnabled } = useFeatures() + + const [isOpen, setIsOpen] = useState(false) + const [request, setRequest] = useState({}) + + const openFromRequest = useCallback( + (detail: OnAskForVideoCompressionEventData | undefined) => { + if (!isVideoCompressionEnabled) return + setRequest({ + filePath: detail?.filePath, + title: detail?.title, + duration: detail?.duration, + }) + setIsOpen(true) + }, + [isVideoCompressionEnabled], + ) + + const close = useCallback(() => { + setIsOpen(false) + }, []) + + useEffect(() => { + const handler = (event: Event) => { + openFromRequest((event as CustomEvent).detail) + } + document.addEventListener(UI_AskForVideoCompression, handler) + return () => { + document.removeEventListener(UI_AskForVideoCompression, handler) + } + }, [openFromRequest]) + + return ( + { + setRequest((prev) => ({ ...prev, filePath })) + }} + /> + ) +} diff --git a/apps/ui/src/components/welcome.test.tsx b/apps/ui/src/components/welcome.test.tsx index 3bc5fab7..d35e5ab7 100644 --- a/apps/ui/src/components/welcome.test.tsx +++ b/apps/ui/src/components/welcome.test.tsx @@ -1,17 +1,16 @@ import React from "react" import { describe, it, expect, vi, beforeEach } from "vitest" import { render, screen, fireEvent } from "@testing-library/react" +import { UI_AskForFormatConverter } from "@/types/eventTypes" const h = vi.hoisted(() => ({ mockOpenDownloadVideo: vi.fn(), - mockOpenFormatConverter: vi.fn(), mockUseFeatures: vi.fn(), })) vi.mock("@/providers/dialog-provider", () => ({ useDialogs: () => ({ downloadVideoDialog: [h.mockOpenDownloadVideo, vi.fn()], - formatConverterDialog: [h.mockOpenFormatConverter, vi.fn()], }), })) @@ -36,7 +35,6 @@ const defaultFeatureFlags = { describe("Welcome", () => { beforeEach(() => { h.mockOpenDownloadVideo.mockReset() - h.mockOpenFormatConverter.mockReset() h.mockUseFeatures.mockReset() h.mockUseFeatures.mockReturnValue(defaultFeatureFlags) }) @@ -92,14 +90,19 @@ describe("Welcome", () => { expect(h.mockOpenDownloadVideo).toHaveBeenCalledTimes(1) }) - it("invokes openFormatConverter dialog when Format Conversion card is clicked", () => { + it("asks the top-level FormatConverter when the Format Conversion card is clicked", () => { h.mockUseFeatures.mockReturnValue(defaultFeatureFlags) + const listener = vi.fn() + document.addEventListener(UI_AskForFormatConverter, listener) + try { + render() - render() - - fireEvent.click(screen.getByTestId("welcome-card-format-conversion")) + fireEvent.click(screen.getByTestId("welcome-card-format-conversion")) - expect(h.mockOpenFormatConverter).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledTimes(1) + } finally { + document.removeEventListener(UI_AskForFormatConverter, listener) + } }) it("hides download and format conversion cards when HarmonyOS feature flags are off", () => { diff --git a/apps/ui/src/components/welcome.tsx b/apps/ui/src/components/welcome.tsx index 7c3c7320..3ae01b91 100644 --- a/apps/ui/src/components/welcome.tsx +++ b/apps/ui/src/components/welcome.tsx @@ -2,6 +2,7 @@ import type { ComponentType, FC } from "react" import { FolderOpen, Download, FileVideo, Github, ArrowUpRight } from "lucide-react" import { Separator } from "./ui/separator" import { useDialogs } from "@/providers/dialog-provider" +import { askForFormatConverter } from "@/lib/dialogRequestEvents" import { useTranslation } from "@/lib/i18n" import { useFeatures } from "@/hooks/useFeatures" import { cn } from "@/lib/utils" @@ -161,9 +162,8 @@ const FeatureCard: FC<{ } const Welcome: FC = ({ onImportFolderClick }) => { - const { downloadVideoDialog, formatConverterDialog } = useDialogs() + const { downloadVideoDialog } = useDialogs() const [openDownloadVideo] = downloadVideoDialog - const [openFormatConverter] = formatConverterDialog const { isDisplayFeatureCardsInWelcomeEnabled, isDownloadVideoEnabled, isFormatConverterEnabled } = useFeatures() const { t } = useTranslation("components") @@ -246,7 +246,7 @@ const Welcome: FC = ({ onImportFolderClick }) => { key={spec.id} spec={spec} title={title} - onClick={() => openFormatConverter()} + onClick={() => askForFormatConverter()} className={cardClassName} /> ) diff --git a/apps/ui/src/helpers/movie/buildMovieFilesFromMediaMetadata.test.ts b/apps/ui/src/helpers/movie/buildMovieFilesFromMediaMetadata.test.ts index 9e226126..7cf41fde 100644 --- a/apps/ui/src/helpers/movie/buildMovieFilesFromMediaMetadata.test.ts +++ b/apps/ui/src/helpers/movie/buildMovieFilesFromMediaMetadata.test.ts @@ -53,7 +53,7 @@ describe("buildMovieFilesFromMediaMetadata", () => { files: [videoPath, subtitlePath, nfoPath], } - expect(buildMovieFilesFromMediaMetadata(mediaMetadata)).toEqual({ + expect(buildMovieFilesFromMediaMetadata(mediaMetadata, mediaMetadata.files ?? [])).toEqual({ files: [ { type: "video", path: videoPath, newPath: undefined }, { type: "subtitle", path: subtitlePath, newPath: undefined }, @@ -73,7 +73,7 @@ describe("buildMovieFilesFromMediaMetadata", () => { files: [videoPath, posterPath, audioPath], } - expect(buildMovieFilesFromMediaMetadata(mediaMetadata)).toEqual({ + expect(buildMovieFilesFromMediaMetadata(mediaMetadata, mediaMetadata.files ?? [])).toEqual({ files: [ { type: "video", path: videoPath, newPath: undefined }, { type: "poster", path: posterPath, newPath: undefined }, @@ -94,7 +94,7 @@ describe("buildMovieFilesFromMediaMetadata", () => { files: [fanartPath, nfoPath, posterPath, videoPath], } - expect(buildMovieFilesFromMediaMetadata(mediaMetadata)).toEqual({ + expect(buildMovieFilesFromMediaMetadata(mediaMetadata, mediaMetadata.files ?? [])).toEqual({ files: [ { type: "video", path: videoPath, newPath: undefined }, { type: "nfo", path: nfoPath, newPath: undefined }, diff --git a/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.test.tsx b/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.test.tsx index 78164dd0..3575b63d 100644 --- a/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.test.tsx +++ b/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.test.tsx @@ -29,6 +29,10 @@ vi.mock("@/components/tv/TvShowPanelUtils", async (importOriginal) => { } }) +vi.mock("@/lib/mediaFolderFiles", () => ({ + listMediaFolderFilePaths: vi.fn(async () => ["/media/folder/S01E01.mkv"]), +})) + vi.mock("@/hooks/plans", () => ({ useCreatePlanMutation: () => ({ createPlanOptimistic: createPlanOptimisticMock, diff --git a/apps/ui/src/hooks/tv/useTvShowEpisodeFormatConvert.test.tsx b/apps/ui/src/hooks/tv/useTvShowEpisodeFormatConvert.test.tsx new file mode 100644 index 00000000..b3472484 --- /dev/null +++ b/apps/ui/src/hooks/tv/useTvShowEpisodeFormatConvert.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, afterEach } from "vitest" +import { renderHook, cleanup } from "@testing-library/react" +import type { MediaMetadata } from "@smm/types" +import { UI_AskForFormatConverter } from "@/types/eventTypes" +import { useTvShowEpisodeFormatConvert } from "./useTvShowEpisodeFormatConvert" + +interface MediaFileFixture { + seasonNumber: number + episodeNumber: number + absolutePath: string +} + +function makeMetadata(mediaFiles: MediaFileFixture[]): MediaMetadata { + return { mediaFiles } as unknown as MediaMetadata +} + +describe("useTvShowEpisodeFormatConvert", () => { + afterEach(() => { + cleanup() + }) + + it("dispatches UI_AskForFormatConverter with the episode's video file", () => { + const listener = vi.fn() + document.addEventListener(UI_AskForFormatConverter, listener) + try { + const mm = makeMetadata([ + { seasonNumber: 1, episodeNumber: 1, absolutePath: "/media/show/S01E01.mkv" }, + ]) + const { result } = renderHook(() => useTvShowEpisodeFormatConvert(mm)) + + result.current.handleFormatConvertForRow({ season: 1, episode: 1 }) + + expect(listener).toHaveBeenCalledTimes(1) + const event = listener.mock.calls[0]?.[0] as CustomEvent<{ filePath?: string }> + expect(event.type).toBe(UI_AskForFormatConverter) + expect(event.detail.filePath).toBe("/media/show/S01E01.mkv") + } finally { + document.removeEventListener(UI_AskForFormatConverter, listener) + } + }) + + it("warns and dispatches nothing when no video file matches the episode", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const listener = vi.fn() + document.addEventListener(UI_AskForFormatConverter, listener) + try { + const mm = makeMetadata([ + { seasonNumber: 1, episodeNumber: 1, absolutePath: "/media/show/S01E01.mkv" }, + ]) + const { result } = renderHook(() => useTvShowEpisodeFormatConvert(mm)) + + result.current.handleFormatConvertForRow({ season: 4, episode: 8 }) + + expect(listener).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith(expect.stringContaining("S4E8")) + } finally { + document.removeEventListener(UI_AskForFormatConverter, listener) + warn.mockRestore() + } + }) +}) diff --git a/apps/ui/src/hooks/tv/useTvShowEpisodeFormatConvert.ts b/apps/ui/src/hooks/tv/useTvShowEpisodeFormatConvert.ts new file mode 100644 index 00000000..581877e0 --- /dev/null +++ b/apps/ui/src/hooks/tv/useTvShowEpisodeFormatConvert.ts @@ -0,0 +1,44 @@ +import { useCallback } from "react" +import type { MediaMetadata } from "@smm/types" +import { askForFormatConverter } from "@/lib/dialogRequestEvents" + +/** An episode table row that can be format-converted (season/episode identity). */ +export interface TvShowEpisodeFormatConvertRow { + season: number + episode: number +} + +/** Resolves the linked video file for a season/episode from media metadata. */ +function findEpisodeVideoPath( + mediaMetadata: MediaMetadata | undefined, + season: number, + episode: number, +): string | undefined { + return mediaMetadata?.mediaFiles?.find( + (f) => f.seasonNumber === season && f.episodeNumber === episode, + )?.absolutePath +} + +/** + * Business logic for the episode row "Format Conversion" context-menu action: + * resolves the episode's video file from media metadata and asks the top-level + * `FormatConverter` component (via the {@link askForFormatConverter} document + * event) to convert it. Kept in a hook so TvShowPanel stays thin. + */ +export function useTvShowEpisodeFormatConvert(mediaMetadata: MediaMetadata | undefined) { + const handleFormatConvertForRow = useCallback( + (row: TvShowEpisodeFormatConvertRow) => { + const videoPath = findEpisodeVideoPath(mediaMetadata, row.season, row.episode) + if (!videoPath) { + console.warn( + `[useTvShowEpisodeFormatConvert] no video file found for S${row.season}E${row.episode}`, + ) + return + } + askForFormatConverter({ filePath: videoPath }) + }, + [mediaMetadata], + ) + + return { handleFormatConvertForRow } +} diff --git a/apps/ui/src/hooks/tv/useTvShowEpisodeVideoCompress.test.tsx b/apps/ui/src/hooks/tv/useTvShowEpisodeVideoCompress.test.tsx new file mode 100644 index 00000000..c4ddbbe8 --- /dev/null +++ b/apps/ui/src/hooks/tv/useTvShowEpisodeVideoCompress.test.tsx @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, afterEach } from "vitest" +import { renderHook, cleanup } from "@testing-library/react" +import type { MediaMetadata } from "@smm/types" +import { UI_AskForVideoCompression } from "@/types/eventTypes" +import { useTvShowEpisodeVideoCompress } from "./useTvShowEpisodeVideoCompress" + +interface MediaFileFixture { + seasonNumber: number + episodeNumber: number + absolutePath: string +} + +function makeMetadata(mediaFiles: MediaFileFixture[]): MediaMetadata { + return { mediaFiles } as unknown as MediaMetadata +} + +describe("useTvShowEpisodeVideoCompress", () => { + afterEach(() => { + cleanup() + }) + + it("dispatches UI_AskForVideoCompression with the episode's video file and title", () => { + const listener = vi.fn() + document.addEventListener(UI_AskForVideoCompression, listener) + try { + const mm = makeMetadata([ + { seasonNumber: 1, episodeNumber: 2, absolutePath: "/media/show/S01E02.mkv" }, + ]) + const { result } = renderHook(() => useTvShowEpisodeVideoCompress(mm)) + + result.current.handleVideoCompressForRow({ season: 1, episode: 2, episodeTitle: "Pilot" }) + + expect(listener).toHaveBeenCalledTimes(1) + const event = listener.mock.calls[0]?.[0] as CustomEvent<{ + filePath?: string + title?: string + }> + expect(event.type).toBe(UI_AskForVideoCompression) + expect(event.detail.filePath).toBe("/media/show/S01E02.mkv") + expect(event.detail.title).toBe("Pilot") + } finally { + document.removeEventListener(UI_AskForVideoCompression, listener) + } + }) + + it("falls back to an SxxExx title when episodeTitle is missing", () => { + const listener = vi.fn() + document.addEventListener(UI_AskForVideoCompression, listener) + try { + const mm = makeMetadata([ + { seasonNumber: 2, episodeNumber: 3, absolutePath: "/media/show/S02E03.mkv" }, + ]) + const { result } = renderHook(() => useTvShowEpisodeVideoCompress(mm)) + + result.current.handleVideoCompressForRow({ season: 2, episode: 3 }) + + const event = listener.mock.calls[0]?.[0] as CustomEvent<{ title?: string }> + expect(event.detail.title).toBe("S2E3") + } finally { + document.removeEventListener(UI_AskForVideoCompression, listener) + } + }) + + it("warns and dispatches nothing when no video file matches the episode", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const listener = vi.fn() + document.addEventListener(UI_AskForVideoCompression, listener) + try { + const mm = makeMetadata([ + { seasonNumber: 1, episodeNumber: 1, absolutePath: "/media/show/S01E01.mkv" }, + ]) + const { result } = renderHook(() => useTvShowEpisodeVideoCompress(mm)) + + result.current.handleVideoCompressForRow({ season: 5, episode: 99 }) + + expect(listener).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("S5E99"), + ) + } finally { + document.removeEventListener(UI_AskForVideoCompression, listener) + warn.mockRestore() + } + }) +}) diff --git a/apps/ui/src/hooks/tv/useTvShowEpisodeVideoCompress.ts b/apps/ui/src/hooks/tv/useTvShowEpisodeVideoCompress.ts new file mode 100644 index 00000000..1f281c6b --- /dev/null +++ b/apps/ui/src/hooks/tv/useTvShowEpisodeVideoCompress.ts @@ -0,0 +1,58 @@ +import { useCallback } from "react" +import type { MediaMetadata } from "@smm/types" +import { + UI_AskForVideoCompression, + type OnAskForVideoCompressionEventData, +} from "@/types/eventTypes" + +/** An episode table row that can be compressed (season/episode identity). */ +export interface TvShowEpisodeVideoCompressRow { + season: number + episode: number + episodeTitle?: string +} + +/** Resolves the linked video file for a season/episode from media metadata. */ +function findEpisodeVideoPath( + mediaMetadata: MediaMetadata | undefined, + season: number, + episode: number, +): string | undefined { + return mediaMetadata?.mediaFiles?.find( + (f) => f.seasonNumber === season && f.episodeNumber === episode, + )?.absolutePath +} + +/** + * Business logic for the episode row "Video Compression" context-menu action. + * + * Resolves the episode's video file from media metadata and asks the top-level + * `VideoCompression` component to compress it by dispatching a + * {@link UI_AskForVideoCompression} document event. Keeping this logic in a + * hook (rather than inline in TvShowPanel) keeps the UI component thin and the + * action testable in isolation. + */ +export function useTvShowEpisodeVideoCompress(mediaMetadata: MediaMetadata | undefined) { + const handleVideoCompressForRow = useCallback( + (row: TvShowEpisodeVideoCompressRow) => { + const videoPath = findEpisodeVideoPath(mediaMetadata, row.season, row.episode) + if (!videoPath) { + console.warn( + `[useTvShowEpisodeVideoCompress] no video file found for S${row.season}E${row.episode}`, + ) + return + } + document.dispatchEvent( + new CustomEvent(UI_AskForVideoCompression, { + detail: { + filePath: videoPath, + title: row.episodeTitle ?? `S${row.season}E${row.episode}`, + }, + }), + ) + }, + [mediaMetadata], + ) + + return { handleVideoCompressForRow } +} diff --git a/apps/ui/src/hooks/useFeatures.test.ts b/apps/ui/src/hooks/useFeatures.test.ts index 4dabded6..d4a093f3 100644 --- a/apps/ui/src/hooks/useFeatures.test.ts +++ b/apps/ui/src/hooks/useFeatures.test.ts @@ -72,21 +72,4 @@ describe("useFeatures HarmonyOS gating", () => { expect(result.current.isAiFeatureEnabled).toBe(false) }) - - it("defaults isUseMediaFileTableEnabled to false", () => { - vi.mocked(isHarmonyOS).mockReturnValue(false) - - const { result } = renderHook(() => useFeatures()) - - expect(result.current.isUseMediaFileTableEnabled).toBe(false) - }) - - it("reads isUseMediaFileTableEnabled from localStorage", () => { - vi.mocked(isHarmonyOS).mockReturnValue(false) - localStorage.setItem("features.useMediaFileTable", "true") - - const { result } = renderHook(() => useFeatures()) - - expect(result.current.isUseMediaFileTableEnabled).toBe(true) - }) }) diff --git a/apps/ui/src/hooks/useFeatures.ts b/apps/ui/src/hooks/useFeatures.ts index 1d7a1e32..dd9d769b 100644 --- a/apps/ui/src/hooks/useFeatures.ts +++ b/apps/ui/src/hooks/useFeatures.ts @@ -10,7 +10,6 @@ const DISPLAY_FEATURE_CARDS_IN_WELCOME_STORAGE_KEY = "features.isDisplayFeatureC const AI_AREA_STORAGE_KEY = "features.isAiAreaEnabled" const AI_FEATURE_STORAGE_KEY = "features.isAiFeatureEnabled" const UI_AI_CHAT_TRANSPORT_STORAGE_KEY = "features.isUIAiChatTransportEnabled" -const USE_MEDIA_FILE_TABLE_STORAGE_KEY = "features.useMediaFileTable" /** Default: enabled when the user has never set a preference (`null`). */ function readVideoCaptionerAsrOptionsEnabled(): boolean { @@ -215,27 +214,6 @@ function writeDisplayFeatureCardsInWelcomeEnabled(enabled: boolean): void { } } -/** Default: disabled until the user opts in via localStorage. */ -function readUseMediaFileTableEnabled(): boolean { - if (typeof window === "undefined") return false - try { - const v = window.localStorage.getItem(USE_MEDIA_FILE_TABLE_STORAGE_KEY) - if (v === null) return false - return v === "true" - } catch { - return false - } -} - -function writeUseMediaFileTableEnabled(enabled: boolean): void { - if (typeof window === "undefined") return - try { - window.localStorage.setItem(USE_MEDIA_FILE_TABLE_STORAGE_KEY, enabled ? "true" : "false") - } catch { - // ignore quota / private mode - } -} - /** * Best-effort runtime OS detection for renderer (Electron + browser dev). * Mirrors patterns used in `@smm/utils/path` for Electron vs UA fallback. @@ -355,13 +333,6 @@ export interface UseFeaturesResult { */ isUIAiChatTransportEnabled: boolean setIsUIAiChatTransportEnabled: (enabled: boolean) => void - /** - * When true, MoviePanel and TvShowPanel render `MediaFileTable` instead of - * `TvShowEpisodeTable`. Persisted under `features.useMediaFileTable`. - * Defaults to false until the user opts in. - */ - isUseMediaFileTableEnabled: boolean - setUseMediaFileTableEnabled: (enabled: boolean) => void } export function useFeatures(): UseFeaturesResult { @@ -417,10 +388,6 @@ export function useFeatures(): UseFeaturesResult { readUiAiChatTransportEnabled, ) - const [isUseMediaFileTableEnabled, setIsUseMediaFileTableEnabledState] = useState( - readUseMediaFileTableEnabled, - ) - useEffect(() => { const onStorage = (event: StorageEvent) => { if (event.key === VIDEOCAPTIONER_ASR_OPTIONS_STORAGE_KEY) { @@ -450,9 +417,6 @@ export function useFeatures(): UseFeaturesResult { if (event.key === UI_AI_CHAT_TRANSPORT_STORAGE_KEY) { setIsUIAiChatTransportEnabledState(readUiAiChatTransportEnabled()) } - if (event.key === USE_MEDIA_FILE_TABLE_STORAGE_KEY) { - setIsUseMediaFileTableEnabledState(readUseMediaFileTableEnabled()) - } } window.addEventListener("storage", onStorage) return () => window.removeEventListener("storage", onStorage) @@ -504,11 +468,6 @@ export function useFeatures(): UseFeaturesResult { setIsUIAiChatTransportEnabledState(enabled) }, []) - const setUseMediaFileTableEnabled = useCallback((enabled: boolean) => { - writeUseMediaFileTableEnabled(enabled) - setIsUseMediaFileTableEnabledState(enabled) - }, []) - return useMemo( () => ({ isAiFeatureEnabled, @@ -535,8 +494,6 @@ export function useFeatures(): UseFeaturesResult { setIsDisplayFeatureCardsInWelcomeEnabled: setIsDisplayFeatureCardsInWelcomeEnabledCallback, isUIAiChatTransportEnabled, setIsUIAiChatTransportEnabled, - isUseMediaFileTableEnabled, - setUseMediaFileTableEnabled, }), [ isAiFeatureEnabled, @@ -563,8 +520,6 @@ export function useFeatures(): UseFeaturesResult { setIsDisplayFeatureCardsInWelcomeEnabledCallback, isUIAiChatTransportEnabled, setIsUIAiChatTransportEnabled, - isUseMediaFileTableEnabled, - setUseMediaFileTableEnabled, ], ) } diff --git a/apps/ui/src/lib/buildMovieEpisodeTableRows.test.ts b/apps/ui/src/lib/buildMovieEpisodeTableRows.test.ts index d38c3e0b..f1242bb0 100644 --- a/apps/ui/src/lib/buildMovieEpisodeTableRows.test.ts +++ b/apps/ui/src/lib/buildMovieEpisodeTableRows.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { buildMovieEpisodeTableRows } from "./buildMovieEpisodeTableRows"; import type { MediaMetadata } from "@smm/types"; -import type { TvShowEpisodeDataRow } from "@/components/tv/TvShowEpisodeTable"; +import type { UIMediaFileDataRow } from "@/components/media/UIMediaFileTable"; const t = (key: string) => key; @@ -17,8 +17,8 @@ function makeMediaMetadata(overrides: Partial = {}): MediaMetadat } /** Returns the last row in the array (always the episode data row). */ -function episodeRow(rows: ReturnType): TvShowEpisodeDataRow { - return rows[rows.length - 1] as TvShowEpisodeDataRow; +function episodeRow(rows: ReturnType): UIMediaFileDataRow { + return rows[rows.length - 1] as UIMediaFileDataRow; } describe("buildMovieEpisodeTableRows", () => { @@ -46,21 +46,21 @@ describe("buildMovieEpisodeTableRows", () => { it("returns no-video divider when mediaFolderPath is missing", () => { const mm = makeMediaMetadata({ mediaFolderPath: undefined }); - const rows = buildMovieEpisodeTableRows(mm, "ok", t); + const rows = buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? []); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ type: "divider" }); }); it("returns no-video divider when mediaFiles is empty", () => { const mm = makeMediaMetadata({ mediaFiles: [] }); - const rows = buildMovieEpisodeTableRows(mm, "ok", t); + const rows = buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? []); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ type: "divider" }); }); it("returns no-video divider when mediaFiles is undefined", () => { const mm = makeMediaMetadata({ mediaFiles: undefined }); - const rows = buildMovieEpisodeTableRows(mm, "ok", t); + const rows = buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? []); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ type: "divider" }); }); @@ -71,7 +71,7 @@ describe("buildMovieEpisodeTableRows", () => { const mm = makeMediaMetadata({ mediaFiles: [{ absolutePath: "/media/movies/TestMovie/video.mkv" }], }); - const rows = buildMovieEpisodeTableRows(mm, "ok", t); + const rows = buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? []); expect(rows).toHaveLength(2); // divider + episode expect(rows[0]).toMatchObject({ type: "divider", id: "movie", text: "Movie" }); expect(rows[1].type).toBe("episode"); @@ -83,13 +83,12 @@ describe("buildMovieEpisodeTableRows", () => { const mm = makeMediaMetadata({ mediaFiles: [{ absolutePath: "/media/movies/TestMovie/video.mkv" }], }); - const rows = buildMovieEpisodeTableRows(mm, "ok", t); + const rows = buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? []); const row = episodeRow(rows); expect(row.type).toBe("episode"); expect(row.season).toBe(1); expect(row.episode).toBe(1); expect(row.videoFile).toBe("/media/movies/TestMovie/video.mkv"); - expect(row.checked).toBe(false); expect(row.episodeTitle).toBe("Test Movie"); }); @@ -100,7 +99,7 @@ describe("buildMovieEpisodeTableRows", () => { { absolutePath: "/media/movies/TestMovie/extra.mkv" }, ], }); - const rows = buildMovieEpisodeTableRows(mm, "ok", t); + const rows = buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? []); expect(episodeRow(rows).videoFile).toBe("/media/movies/TestMovie/main.mkv"); }); @@ -114,7 +113,7 @@ describe("buildMovieEpisodeTableRows", () => { ], mediaFiles: [{ absolutePath: "/media/movies/TestMovie/video.mkv" }], }); - const rows = buildMovieEpisodeTableRows(mm, "ok", t); + const rows = buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? []); expect(episodeRow(rows).subtitle).toContain("/media/movies/TestMovie/video.srt"); }); @@ -126,7 +125,7 @@ describe("buildMovieEpisodeTableRows", () => { ], mediaFiles: [{ absolutePath: "/media/movies/TestMovie/video.mkv" }], }); - const rows = buildMovieEpisodeTableRows(mm, "ok", t); + const rows = buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? []); expect(episodeRow(rows).thumbnail).toContain("video.jpg"); }); @@ -140,7 +139,7 @@ describe("buildMovieEpisodeTableRows", () => { ], mediaFiles: [{ absolutePath: "/media/movies/TestMovie/video.mkv" }], }); - const rows = buildMovieEpisodeTableRows(mm, "ok", t); + const rows = buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? []); expect(rows).toHaveLength(3); // poster folderFile + divider + episode expect(rows[0]).toMatchObject({ type: "folderFile", id: "poster", path: "/media/movies/TestMovie/poster.jpg" }); expect(rows[1]).toMatchObject({ type: "divider", id: "movie" }); @@ -155,7 +154,7 @@ describe("buildMovieEpisodeTableRows", () => { ], mediaFiles: [{ absolutePath: "/media/movies/TestMovie/video.mkv" }], }); - const rows = buildMovieEpisodeTableRows(mm, "ok", t); + const rows = buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? []); expect(rows).toHaveLength(3); expect(rows[0]).toMatchObject({ type: "folderFile", id: "fanart", path: "/media/movies/TestMovie/fanart.png" }); }); @@ -168,7 +167,7 @@ describe("buildMovieEpisodeTableRows", () => { ], mediaFiles: [{ absolutePath: "/media/movies/TestMovie/video.mkv" }], }); - const rows = buildMovieEpisodeTableRows(mm, "ok", t); + const rows = buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? []); expect(rows).toHaveLength(3); expect(rows[0]).toMatchObject({ type: "folderFile", id: "nfo", path: "/media/movies/TestMovie/movie.nfo" }); expect(episodeRow(rows).nfo).toBe("/media/movies/TestMovie/movie.nfo"); @@ -184,7 +183,7 @@ describe("buildMovieEpisodeTableRows", () => { ], mediaFiles: [{ absolutePath: "/media/movies/TestMovie/video.mkv" }], }); - const rows = buildMovieEpisodeTableRows(mm, "ok", t); + const rows = buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? []); expect(rows).toHaveLength(5); // 3 folderFiles + divider + episode expect(rows.filter((r) => r.type === "folderFile")).toHaveLength(3); expect(rows.filter((r) => r.type === "divider")).toHaveLength(1); @@ -203,7 +202,7 @@ describe("buildMovieEpisodeTableRows", () => { ], mediaFiles: [{ absolutePath: "/media/movies/Movie (2024)/Movie (2024).mkv" }], }); - const row = episodeRow(buildMovieEpisodeTableRows(mm, "ok", t)); + const row = episodeRow(buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? [])); expect(row.thumbnail).toBe("/media/movies/Movie (2024)/poster.jpg"); expect(row.nfo).toBe("/media/movies/Movie (2024)/movie.nfo"); expect(row.subtitle).toBeUndefined(); @@ -218,7 +217,7 @@ describe("buildMovieEpisodeTableRows", () => { ], mediaFiles: [{ absolutePath: "/media/movies/TestMovie/video.mkv" }], }); - const row = episodeRow(buildMovieEpisodeTableRows(mm, "ok", t)); + const row = episodeRow(buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? [])); expect(row.thumbnail).toBe("/media/movies/TestMovie/video.jpg"); }); @@ -231,7 +230,7 @@ describe("buildMovieEpisodeTableRows", () => { ], mediaFiles: [{ absolutePath: "/media/movies/TestMovie/video.mkv" }], }); - const row = episodeRow(buildMovieEpisodeTableRows(mm, "ok", t)); + const row = episodeRow(buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? [])); expect(row.nfo).toBe("/media/movies/TestMovie/video.nfo"); }); @@ -240,7 +239,7 @@ describe("buildMovieEpisodeTableRows", () => { files: ["/media/movies/TestMovie/video.mkv"], mediaFiles: [{ absolutePath: "/media/movies/TestMovie/video.mkv" }], }); - const row = episodeRow(buildMovieEpisodeTableRows(mm, "ok", t)); + const row = episodeRow(buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? [])); expect(row.thumbnail).toBeUndefined(); expect(row.nfo).toBeUndefined(); expect(row.subtitle).toBeUndefined(); @@ -259,7 +258,7 @@ describe("buildMovieEpisodeTableRows", () => { }, ], }); - const row = episodeRow(buildMovieEpisodeTableRows(mm, "ok", t)); + const row = episodeRow(buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? [])); expect(row.subtitle).toBe("/media/movies/Movie (2024)/Movie.srt"); }); @@ -269,7 +268,7 @@ describe("buildMovieEpisodeTableRows", () => { const mm = makeMediaMetadata({ mediaFiles: [{ absolutePath: "/media/movies/TestMovie/video.mkv" }], }); - const rows = buildMovieEpisodeTableRows(mm, "ok", t, { + const rows = buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? [], { renamePreview: { newVideoFile: "/media/movies/TestMovie/New Name (2024).mkv", newSubtitle: "/media/movies/TestMovie/New Name (2024).srt", @@ -286,7 +285,7 @@ describe("buildMovieEpisodeTableRows", () => { const mm = makeMediaMetadata({ mediaFiles: [{ absolutePath: "/media/movies/TestMovie/video.mkv" }], }); - const rows = buildMovieEpisodeTableRows(mm, "ok", t); + const rows = buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? []); const row = episodeRow(rows); expect(row.newVideoFile).toBeUndefined(); expect(row.newSubtitle).toBeUndefined(); @@ -297,7 +296,7 @@ describe("buildMovieEpisodeTableRows", () => { const mm = makeMediaMetadata({ mediaFiles: [{ absolutePath: "/media/movies/TestMovie/video.mkv" }], }); - const rows = buildMovieEpisodeTableRows(mm, "ok", t, { + const rows = buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? [], { renamePreview: { newVideoFile: "/media/movies/TestMovie/New Name (2024).mkv", }, @@ -315,7 +314,7 @@ describe("buildMovieEpisodeTableRows", () => { movie: { id: "456", name: "Inception", database: "TMDB" }, mediaFiles: [{ absolutePath: "/media/movies/Inception/video.mkv" }], }); - const rows = buildMovieEpisodeTableRows(mm, "ok", t); + const rows = buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? []); expect(episodeRow(rows).episodeTitle).toBe("Inception"); }); @@ -324,7 +323,7 @@ describe("buildMovieEpisodeTableRows", () => { movie: undefined, mediaFiles: [{ absolutePath: "/media/movies/TestMovie/video.mkv" }], }); - const rows = buildMovieEpisodeTableRows(mm, "ok", t); + const rows = buildMovieEpisodeTableRows(mm, "ok", t, mm.files ?? []); expect(episodeRow(rows).episodeTitle).toBeUndefined(); }); }); diff --git a/apps/ui/src/lib/buildMovieEpisodeTableRows.ts b/apps/ui/src/lib/buildMovieEpisodeTableRows.ts index c5e7c817..3bf88769 100644 --- a/apps/ui/src/lib/buildMovieEpisodeTableRows.ts +++ b/apps/ui/src/lib/buildMovieEpisodeTableRows.ts @@ -1,4 +1,4 @@ -import type { TvShowEpisodeDataRow, TvShowEpisodeTableRow } from "@/components/tv/TvShowEpisodeTable"; +import type { UIMediaFileDataRow, UIMediaFileTableRow } from "@/components/media/UIMediaFileTable"; import type { MediaMetadata } from "@/lib/mediaFolderFiles" import type { UIMediaFolderStatus } from "@/types/UIMediaFolder"; import { basename, join } from "@/lib/path"; @@ -11,7 +11,7 @@ export interface MovieRenamePreviewData { } /** - * Builds TvShowEpisodeTableRow[] from movie MediaMetadata. + * Builds UIMediaFileTableRow[] from movie MediaMetadata. * Treats the movie as a "one season, one episode" TV show (S01E01). * * Output includes: @@ -26,7 +26,7 @@ export function buildMovieEpisodeTableRows( options?: { renamePreview?: MovieRenamePreviewData; } -): TvShowEpisodeTableRow[] { +): UIMediaFileTableRow[] { // Empty states — mirror buildTvShowEpisodeTableRows behaviour if (uiStatus === "initializing") { return [{ id: "initializing", type: "divider", text: t("mediaFolder.initializing") }]; @@ -42,7 +42,7 @@ export function buildMovieEpisodeTableRows( return [{ id: "no-video", type: "divider", text: "No video file" }]; } - const rows: TvShowEpisodeTableRow[] = []; + const rows: UIMediaFileTableRow[] = []; const mediaFolderPath = mm.mediaFolderPath; const videoFile = mm.mediaFiles[0]; // Only the first/main video file const allFiles = folderFiles; @@ -112,7 +112,7 @@ export function buildMovieEpisodeTableRows( if (!thumbnail && posterFile) thumbnail = posterFile; if (!nfo && movieNfoFile) nfo = movieNfoFile; - const row: TvShowEpisodeDataRow = { + const row: UIMediaFileDataRow = { season: 1, episode: 1, type: "episode", @@ -121,7 +121,6 @@ export function buildMovieEpisodeTableRows( subtitle, nfo, episodeTitle: mm.movie?.name, - checked: false, }; if (options?.renamePreview) { diff --git a/apps/ui/src/lib/buildTvShowEpisodeTableRows.test.ts b/apps/ui/src/lib/buildTvShowEpisodeTableRows.test.ts index fe6e6073..7d7c5844 100644 --- a/apps/ui/src/lib/buildTvShowEpisodeTableRows.test.ts +++ b/apps/ui/src/lib/buildTvShowEpisodeTableRows.test.ts @@ -5,12 +5,16 @@ import { fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan, fillTvShowEpisodeTableRowByRenameFilesPlan, } from './buildTvShowEpisodeTableRows' -import type { TvShowEpisodeTableRow, TvShowEpisodeDataRow } from '@/components/tv/TvShowEpisodeTable' +import type { + UIMediaFileTableRow, + UIMediaFileDataRow, + UIMediaEpisodeSelection, +} from '@/components/media/UIMediaFileTable' import type { UIRecognizeMediaFilePlan } from '@/types/UIRecognizeMediaFilePlan' import type { UIRenameFilesPlan } from '@/types/UIRenameFilesPlan' import type { MediaMetadata } from '@smm/types' -function episodeRow(season: number, episode: number, videoFile?: string, checked = false): TvShowEpisodeDataRow { +function episodeRow(season: number, episode: number, videoFile?: string): UIMediaFileDataRow { return { season, episode, @@ -20,10 +24,14 @@ function episodeRow(season: number, episode: number, videoFile?: string, checked subtitle: undefined, nfo: undefined, episodeTitle: '', - checked, } } +/** Set of "s-e" keys for a defaultChecked list, for easy membership asserts. */ +function selectedKeys(defaultChecked: UIMediaEpisodeSelection[]): Set { + return new Set(defaultChecked.map((e) => `${e.season}-${e.episode}`)) +} + function recognizePlan(files: { season: number; episode: number; path: string }[]): UIRecognizeMediaFilePlan { return { id: 'plan-1', @@ -68,121 +76,123 @@ describe('fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan', () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) }) - it('fills matching episode row with video path and sets checked true', () => { - const rows: TvShowEpisodeTableRow[] = [ - episodeRow(1, 1, undefined, false), - episodeRow(1, 2, undefined, false), + it('fills matching episode row with video path and adds it to defaultChecked', () => { + const rows: UIMediaFileTableRow[] = [ + episodeRow(1, 1), + episodeRow(1, 2), ] const plan = recognizePlan([ { season: 1, episode: 1, path: '/media/show/S01E01.mkv' }, ]) - const result = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) + const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) + const keys = selectedKeys(defaultChecked) - const row1 = result.find((r) => r.type === 'episode' && r.season === 1 && r.episode === 1) as TvShowEpisodeDataRow + const row1 = result.find((r) => r.type === 'episode' && r.season === 1 && r.episode === 1) as UIMediaFileDataRow expect(row1.videoFile).toBe('/media/show/S01E01.mkv') - expect(row1.checked).toBe(true) + expect(keys.has('1-1')).toBe(true) expect(row1.disabled).toBe(false) expect(row1.newVideoFile).toBeUndefined() - const row2 = result.find((r) => r.type === 'episode' && r.season === 1 && r.episode === 2) as TvShowEpisodeDataRow + const row2 = result.find((r) => r.type === 'episode' && r.season === 1 && r.episode === 2) as UIMediaFileDataRow expect(row2.videoFile).toBeUndefined() - expect(row2.checked).toBe(false) + expect(keys.has('1-2')).toBe(false) expect(row2.disabled).toBe(true) }) it('keeps existing videoFile but disables row when episode is not in plan', () => { - const rows: TvShowEpisodeTableRow[] = [ - episodeRow(1, 1, '/media/show/existing-S01E01.mkv', true), - episodeRow(1, 2, '/media/show/existing-S01E02.mkv', true), + const rows: UIMediaFileTableRow[] = [ + episodeRow(1, 1, '/media/show/existing-S01E01.mkv'), + episodeRow(1, 2, '/media/show/existing-S01E02.mkv'), ] const plan = recognizePlan([ { season: 1, episode: 1, path: '/media/show/S01E01.mkv' }, ]) - const result = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) + const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) + const keys = selectedKeys(defaultChecked) - const row1 = result.find((r) => r.type === 'episode' && r.season === 1 && r.episode === 1) as TvShowEpisodeDataRow + const row1 = result.find((r) => r.type === 'episode' && r.season === 1 && r.episode === 1) as UIMediaFileDataRow expect(row1.videoFile).toBe('/media/show/S01E01.mkv') - expect(row1.checked).toBe(true) + expect(keys.has('1-1')).toBe(true) expect(row1.disabled).toBe(false) - const row2 = result.find((r) => r.type === 'episode' && r.season === 1 && r.episode === 2) as TvShowEpisodeDataRow + const row2 = result.find((r) => r.type === 'episode' && r.season === 1 && r.episode === 2) as UIMediaFileDataRow expect(row2.videoFile).toBe('/media/show/existing-S01E02.mkv') - expect(row2.checked).toBe(false) + expect(keys.has('1-2')).toBe(false) expect(row2.disabled).toBe(true) }) it('disables row when plan path matches existing mediaFiles path', () => { - const rows: TvShowEpisodeTableRow[] = [ - episodeRow(1, 1, '/media/show/S01E01.mkv', true), - episodeRow(1, 2, '/media/show/S01E02.mkv', true), + const rows: UIMediaFileTableRow[] = [ + episodeRow(1, 1, '/media/show/S01E01.mkv'), + episodeRow(1, 2, '/media/show/S01E02.mkv'), ] const plan = recognizePlan([ { season: 1, episode: 1, path: '/media/show/S01E01.mkv' }, { season: 1, episode: 2, path: '/media/show/S01E02.mkv' }, ]) - const result = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) + const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) + + expect(defaultChecked).toEqual([]) - const row1 = result.find((r) => r.type === 'episode' && r.season === 1 && r.episode === 1) as TvShowEpisodeDataRow + const row1 = result.find((r) => r.type === 'episode' && r.season === 1 && r.episode === 1) as UIMediaFileDataRow expect(row1.videoFile).toBe('/media/show/S01E01.mkv') - expect(row1.checked).toBe(false) expect(row1.disabled).toBe(true) - const row2 = result.find((r) => r.type === 'episode' && r.season === 1 && r.episode === 2) as TvShowEpisodeDataRow + const row2 = result.find((r) => r.type === 'episode' && r.season === 1 && r.episode === 2) as UIMediaFileDataRow expect(row2.videoFile).toBe('/media/show/S01E02.mkv') - expect(row2.checked).toBe(false) expect(row2.disabled).toBe(true) }) it('enables row when plan path differs from existing mediaFiles path', () => { - const rows: TvShowEpisodeTableRow[] = [ - episodeRow(1, 1, '/media/show/old-S01E01.mkv', true), + const rows: UIMediaFileTableRow[] = [ + episodeRow(1, 1, '/media/show/old-S01E01.mkv'), ] const plan = recognizePlan([ { season: 1, episode: 1, path: '/media/show/S01E01.mkv' }, ]) - const result = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) + const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) - const row = result[0] as TvShowEpisodeDataRow + const row = result[0] as UIMediaFileDataRow expect(row.videoFile).toBe('/media/show/S01E01.mkv') - expect(row.checked).toBe(true) + expect(selectedKeys(defaultChecked).has('1-1')).toBe(true) expect(row.disabled).toBe(false) }) it('clears newVideoFile when filling from plan', () => { - const rows: TvShowEpisodeTableRow[] = [ - episodeRow(1, 1, '/media/show/old.mkv', true), + const rows: UIMediaFileTableRow[] = [ + episodeRow(1, 1, '/media/show/old.mkv'), ] - ;(rows[0] as TvShowEpisodeDataRow).newVideoFile = '/media/show/new.mkv' + ;(rows[0] as UIMediaFileDataRow).newVideoFile = '/media/show/new.mkv' const plan = recognizePlan([{ season: 1, episode: 1, path: '/media/show/S01E01.mkv' }]) - const result = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) + const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) - const row = result[0] as TvShowEpisodeDataRow + const row = result[0] as UIMediaFileDataRow expect(row.videoFile).toBe('/media/show/S01E01.mkv') expect(row.newVideoFile).toBeUndefined() - expect(row.checked).toBe(true) + expect(selectedKeys(defaultChecked).has('1-1')).toBe(true) expect(row.disabled).toBe(false) }) it('does not mutate input rows', () => { - const rows: TvShowEpisodeTableRow[] = [episodeRow(1, 1, undefined, false)] + const rows: UIMediaFileTableRow[] = [episodeRow(1, 1)] const plan = recognizePlan([{ season: 1, episode: 1, path: '/media/show/S01E01.mkv' }]) fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) - expect((rows[0] as TvShowEpisodeDataRow).videoFile).toBeUndefined() - expect((rows[0] as TvShowEpisodeDataRow).checked).toBe(false) + expect((rows[0] as UIMediaFileDataRow).videoFile).toBeUndefined() + expect((rows[0] as UIMediaFileDataRow).newVideoFile).toBeUndefined() }) it('handles multiple recognized files in one plan', () => { - const rows: TvShowEpisodeTableRow[] = [ - episodeRow(1, 1, undefined, false), - episodeRow(1, 2, undefined, false), - episodeRow(2, 1, undefined, false), + const rows: UIMediaFileTableRow[] = [ + episodeRow(1, 1), + episodeRow(1, 2), + episodeRow(2, 1), ] const plan = recognizePlan([ { season: 1, episode: 1, path: '/media/show/S01E01.mkv' }, @@ -190,173 +200,176 @@ describe('fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan', () => { { season: 2, episode: 1, path: '/media/show/S02E01.mkv' }, ]) - const result = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) - - expect((result[0] as TvShowEpisodeDataRow).videoFile).toBe('/media/show/S01E01.mkv') - expect((result[0] as TvShowEpisodeDataRow).checked).toBe(true) - expect((result[0] as TvShowEpisodeDataRow).disabled).toBe(false) - expect((result[1] as TvShowEpisodeDataRow).videoFile).toBe('/media/show/S01E02.mkv') - expect((result[1] as TvShowEpisodeDataRow).checked).toBe(true) - expect((result[1] as TvShowEpisodeDataRow).disabled).toBe(false) - expect((result[2] as TvShowEpisodeDataRow).videoFile).toBe('/media/show/S02E01.mkv') - expect((result[2] as TvShowEpisodeDataRow).checked).toBe(true) - expect((result[2] as TvShowEpisodeDataRow).disabled).toBe(false) + const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) + const keys = selectedKeys(defaultChecked) + + expect((result[0] as UIMediaFileDataRow).videoFile).toBe('/media/show/S01E01.mkv') + expect(keys.has('1-1')).toBe(true) + expect((result[0] as UIMediaFileDataRow).disabled).toBe(false) + expect((result[1] as UIMediaFileDataRow).videoFile).toBe('/media/show/S01E02.mkv') + expect(keys.has('1-2')).toBe(true) + expect((result[1] as UIMediaFileDataRow).disabled).toBe(false) + expect((result[2] as UIMediaFileDataRow).videoFile).toBe('/media/show/S02E01.mkv') + expect(keys.has('2-1')).toBe(true) + expect((result[2] as UIMediaFileDataRow).disabled).toBe(false) }) it('skips recognized files that do not match any row and warns', () => { - const rows: TvShowEpisodeTableRow[] = [episodeRow(1, 1, undefined, false)] + const rows: UIMediaFileTableRow[] = [episodeRow(1, 1)] const plan = recognizePlan([ { season: 1, episode: 1, path: '/media/show/S01E01.mkv' }, { season: 5, episode: 99, path: '/media/show/unknown.mkv' }, ]) - const result = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) + const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) expect(console.warn).toHaveBeenCalledWith( expect.stringContaining('season 5 episode 99'), ) - expect((result[0] as TvShowEpisodeDataRow).videoFile).toBe('/media/show/S01E01.mkv') + expect((result[0] as UIMediaFileDataRow).videoFile).toBe('/media/show/S01E01.mkv') + expect(selectedKeys(defaultChecked).has('1-1')).toBe(true) }) it('ignores non-episode rows (dividers, folder files)', () => { - const rows: TvShowEpisodeTableRow[] = [ + const rows: UIMediaFileTableRow[] = [ { id: 'season-1', type: 'divider', text: 'Season 1' }, - episodeRow(1, 1, undefined, false), + episodeRow(1, 1), { id: 'poster', type: 'folderFile', path: '/media/show/poster.jpg' }, ] const plan = recognizePlan([{ season: 1, episode: 1, path: '/media/show/S01E01.mkv' }]) - const result = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) + const { rows: result } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) expect(result).toHaveLength(3) expect(result[0]).toEqual({ id: 'season-1', type: 'divider', text: 'Season 1' }) - expect((result[1] as TvShowEpisodeDataRow).videoFile).toBe('/media/show/S01E01.mkv') + expect((result[1] as UIMediaFileDataRow).videoFile).toBe('/media/show/S01E01.mkv') expect(result[2]).toEqual({ id: 'poster', type: 'folderFile', path: '/media/show/poster.jpg' }) }) it('returns unchanged clone when plan has no files', () => { - const rows: TvShowEpisodeTableRow[] = [episodeRow(1, 1, undefined, false)] + const rows: UIMediaFileTableRow[] = [episodeRow(1, 1)] const plan = recognizePlan([]) - const result = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) + const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) expect(result).toHaveLength(1) - expect((result[0] as TvShowEpisodeDataRow).videoFile).toBeUndefined() - expect((result[0] as TvShowEpisodeDataRow).checked).toBe(false) - expect((result[0] as TvShowEpisodeDataRow).disabled).toBe(true) + expect((result[0] as UIMediaFileDataRow).videoFile).toBeUndefined() + expect(defaultChecked).toEqual([]) + expect((result[0] as UIMediaFileDataRow).disabled).toBe(true) }) it('sets disabled true for episodes not in plan when plan has no files', () => { - const rows: TvShowEpisodeTableRow[] = [ - episodeRow(1, 1, '/media/show/existing.mkv', true), + const rows: UIMediaFileTableRow[] = [ + episodeRow(1, 1, '/media/show/existing.mkv'), ] const plan = recognizePlan([]) - const result = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) + const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) - const row = result[0] as TvShowEpisodeDataRow + const row = result[0] as UIMediaFileDataRow expect(row.videoFile).toBe('/media/show/existing.mkv') - expect(row.checked).toBe(false) + expect(defaultChecked).toEqual([]) expect(row.disabled).toBe(true) }) - it('sets checked to false when recognized file path is undefined', () => { - const rows: TvShowEpisodeTableRow[] = [episodeRow(1, 1, undefined, false)] + it('leaves row enabled and unselected when recognized file path is undefined', () => { + const rows: UIMediaFileTableRow[] = [episodeRow(1, 1)] const plan = recognizePlan([{ season: 1, episode: 1, path: undefined! }]) as UIRecognizeMediaFilePlan - const result = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) + const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) - const row = result[0] as TvShowEpisodeDataRow + const row = result[0] as UIMediaFileDataRow expect(row.videoFile).toBeUndefined() - expect(row.checked).toBe(false) + expect(defaultChecked).toEqual([]) expect(row.disabled).toBe(false) }) }) describe('fillTvShowEpisodeTableRowByRenameFilesPlan', () => { - it('sets newVideoFile and checked when rename from matches episode videoFile', () => { - const rows: TvShowEpisodeTableRow[] = [ - episodeRow(1, 1, '/media/show/S01E01.mkv', false), - episodeRow(1, 2, '/media/show/S01E02.mkv', false), + it('sets newVideoFile and adds to defaultChecked when rename from matches episode videoFile', () => { + const rows: UIMediaFileTableRow[] = [ + episodeRow(1, 1, '/media/show/S01E01.mkv'), + episodeRow(1, 2, '/media/show/S01E02.mkv'), ] const plan = renamePlan([ { from: '/media/show/S01E01.mkv', to: '/media/show/Season 01/Episode 01.mkv' }, ]) - const result = fillTvShowEpisodeTableRowByRenameFilesPlan(rows, plan) + const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRenameFilesPlan(rows, plan) + const keys = selectedKeys(defaultChecked) - expect((result[0] as TvShowEpisodeDataRow).newVideoFile).toBe('/media/show/Season 01/Episode 01.mkv') - expect((result[0] as TvShowEpisodeDataRow).checked).toBe(true) - expect((result[0] as TvShowEpisodeDataRow).disabled).toBe(false) - expect((result[1] as TvShowEpisodeDataRow).newVideoFile).toBeUndefined() - expect((result[1] as TvShowEpisodeDataRow).checked).toBe(false) - expect((result[1] as TvShowEpisodeDataRow).disabled).toBe(true) + expect((result[0] as UIMediaFileDataRow).newVideoFile).toBe('/media/show/Season 01/Episode 01.mkv') + expect(keys.has('1-1')).toBe(true) + expect((result[0] as UIMediaFileDataRow).disabled).toBe(false) + expect((result[1] as UIMediaFileDataRow).newVideoFile).toBeUndefined() + expect(keys.has('1-2')).toBe(false) + expect((result[1] as UIMediaFileDataRow).disabled).toBe(true) }) it('applies multiple rename mappings to multiple episode rows', () => { - const rows: TvShowEpisodeTableRow[] = [ - episodeRow(1, 1, '/media/show/S01E01.mkv', false), - episodeRow(1, 2, '/media/show/S01E02.mkv', false), - episodeRow(2, 1, '/media/show/S02E01.mkv', false), + const rows: UIMediaFileTableRow[] = [ + episodeRow(1, 1, '/media/show/S01E01.mkv'), + episodeRow(1, 2, '/media/show/S01E02.mkv'), + episodeRow(2, 1, '/media/show/S02E01.mkv'), ] const plan = renamePlan([ { from: '/media/show/S01E01.mkv', to: '/media/show/new/S01E01.mkv' }, { from: '/media/show/S02E01.mkv', to: '/media/show/new/S02E01.mkv' }, ]) - const result = fillTvShowEpisodeTableRowByRenameFilesPlan(rows, plan) - - expect((result[0] as TvShowEpisodeDataRow).newVideoFile).toBe('/media/show/new/S01E01.mkv') - expect((result[0] as TvShowEpisodeDataRow).checked).toBe(true) - expect((result[0] as TvShowEpisodeDataRow).disabled).toBe(false) - expect((result[1] as TvShowEpisodeDataRow).newVideoFile).toBeUndefined() - expect((result[1] as TvShowEpisodeDataRow).checked).toBe(false) - expect((result[1] as TvShowEpisodeDataRow).disabled).toBe(true) - expect((result[2] as TvShowEpisodeDataRow).newVideoFile).toBe('/media/show/new/S02E01.mkv') - expect((result[2] as TvShowEpisodeDataRow).checked).toBe(true) - expect((result[2] as TvShowEpisodeDataRow).disabled).toBe(false) + const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRenameFilesPlan(rows, plan) + const keys = selectedKeys(defaultChecked) + + expect((result[0] as UIMediaFileDataRow).newVideoFile).toBe('/media/show/new/S01E01.mkv') + expect(keys.has('1-1')).toBe(true) + expect((result[0] as UIMediaFileDataRow).disabled).toBe(false) + expect((result[1] as UIMediaFileDataRow).newVideoFile).toBeUndefined() + expect(keys.has('1-2')).toBe(false) + expect((result[1] as UIMediaFileDataRow).disabled).toBe(true) + expect((result[2] as UIMediaFileDataRow).newVideoFile).toBe('/media/show/new/S02E01.mkv') + expect(keys.has('2-1')).toBe(true) + expect((result[2] as UIMediaFileDataRow).disabled).toBe(false) }) it('keeps rows unchanged when no rename from path matches', () => { - const rows: TvShowEpisodeTableRow[] = [ - episodeRow(1, 1, '/media/show/S01E01.mkv', false), - episodeRow(1, 2, '/media/show/S01E02.mkv', false), + const rows: UIMediaFileTableRow[] = [ + episodeRow(1, 1, '/media/show/S01E01.mkv'), + episodeRow(1, 2, '/media/show/S01E02.mkv'), ] const plan = renamePlan([ { from: '/media/show/UNKNOWN.mkv', to: '/media/show/new/UNKNOWN.mkv' }, ]) - const result = fillTvShowEpisodeTableRowByRenameFilesPlan(rows, plan) + const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRenameFilesPlan(rows, plan) - expect((result[0] as TvShowEpisodeDataRow).newVideoFile).toBeUndefined() - expect((result[0] as TvShowEpisodeDataRow).checked).toBe(false) - expect((result[0] as TvShowEpisodeDataRow).disabled).toBe(true) - expect((result[1] as TvShowEpisodeDataRow).newVideoFile).toBeUndefined() - expect((result[1] as TvShowEpisodeDataRow).checked).toBe(false) - expect((result[1] as TvShowEpisodeDataRow).disabled).toBe(true) + expect((result[0] as UIMediaFileDataRow).newVideoFile).toBeUndefined() + expect(defaultChecked).toEqual([]) + expect((result[0] as UIMediaFileDataRow).disabled).toBe(true) + expect((result[1] as UIMediaFileDataRow).newVideoFile).toBeUndefined() + expect((result[1] as UIMediaFileDataRow).disabled).toBe(true) }) it('ignores non-episode rows', () => { - const rows: TvShowEpisodeTableRow[] = [ + const rows: UIMediaFileTableRow[] = [ { id: 'season-1', type: 'divider', text: 'Season 1' }, - episodeRow(1, 1, '/media/show/S01E01.mkv', false), + episodeRow(1, 1, '/media/show/S01E01.mkv'), { id: 'fanart', type: 'folderFile', path: '/media/show/fanart.jpg' }, ] const plan = renamePlan([ { from: '/media/show/S01E01.mkv', to: '/media/show/new/S01E01.mkv' }, ]) - const result = fillTvShowEpisodeTableRowByRenameFilesPlan(rows, plan) + const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRenameFilesPlan(rows, plan) expect(result[0]).toEqual({ id: 'season-1', type: 'divider', text: 'Season 1' }) - expect((result[1] as TvShowEpisodeDataRow).newVideoFile).toBe('/media/show/new/S01E01.mkv') - expect((result[1] as TvShowEpisodeDataRow).checked).toBe(true) + expect((result[1] as UIMediaFileDataRow).newVideoFile).toBe('/media/show/new/S01E01.mkv') + expect(selectedKeys(defaultChecked).has('1-1')).toBe(true) expect(result[2]).toEqual({ id: 'fanart', type: 'folderFile', path: '/media/show/fanart.jpg' }) }) it('does not mutate input rows', () => { - const rows: TvShowEpisodeTableRow[] = [ - episodeRow(1, 1, '/media/show/S01E01.mkv', false), + const rows: UIMediaFileTableRow[] = [ + episodeRow(1, 1, '/media/show/S01E01.mkv'), ] const plan = renamePlan([ { from: '/media/show/S01E01.mkv', to: '/media/show/new/S01E01.mkv' }, @@ -364,8 +377,7 @@ describe('fillTvShowEpisodeTableRowByRenameFilesPlan', () => { fillTvShowEpisodeTableRowByRenameFilesPlan(rows, plan) - expect((rows[0] as TvShowEpisodeDataRow).newVideoFile).toBeUndefined() - expect((rows[0] as TvShowEpisodeDataRow).checked).toBe(false) + expect((rows[0] as UIMediaFileDataRow).newVideoFile).toBeUndefined() }) }) @@ -423,7 +435,7 @@ describe('buildTvShowEpisodeTableRows', () => { ], } as MediaMetadata - const rows = buildTvShowEpisodeTableRows(mm, 'ok', (key) => key) + const rows = buildTvShowEpisodeTableRows(mm, 'ok', (key) => key, mm.files ?? []) const folderRows = rows.filter((row) => row.type === 'folderFile') expect(folderRows).toEqual([ @@ -449,11 +461,10 @@ describe('buildTvShowEpisodeTableRows with tmdb/tvdb branches', () => { tvShow: tvShowForPlanTests(), } as MediaMetadata - const rows = buildTvShowEpisodeTableRows(mm, 'ok', (key) => key) - const ep = rows.find((row) => row.type === 'episode' && row.season === 1 && row.episode === 1) as TvShowEpisodeDataRow + const rows = buildTvShowEpisodeTableRows(mm, 'ok', (key) => key, mm.files ?? []) + const ep = rows.find((row) => row.type === 'episode' && row.season === 1 && row.episode === 1) as UIMediaFileDataRow expect(ep.videoFile).toBe('/media/show/S01E01.mkv') - expect(ep.checked).toBe(true) }) it('includes fanart row when tmdbTvShow branch is used', () => { @@ -485,7 +496,7 @@ describe('buildTvShowEpisodeTableRows with tmdb/tvdb branches', () => { }, } as MediaMetadata - const rows = buildTvShowEpisodeTableRows(mm, 'ok', (key) => key) + const rows = buildTvShowEpisodeTableRows(mm, 'ok', (key) => key, mm.files ?? []) expect(rows).toContainEqual({ id: 'fanart', @@ -506,7 +517,7 @@ describe('buildTvShowEpisodeTableRows with tmdb/tvdb branches', () => { }, } as MediaMetadata - const rows = buildTvShowEpisodeTableRows(mm, 'ok', (key) => key) + const rows = buildTvShowEpisodeTableRows(mm, 'ok', (key) => key, mm.files ?? []) expect(rows).toContainEqual({ id: 'fanart', @@ -521,7 +532,7 @@ describe('buildTvShowEpisodeTableRowsForPlan', () => { const mm = {} as MediaMetadata const plan = recognizePlan([{ season: 1, episode: 1, path: '/media/show/S01E01.mkv' }]) - const rows = buildTvShowEpisodeTableRowsForPlan(mm, 'initializing', plan, (key) => key) + const { rows, defaultChecked } = buildTvShowEpisodeTableRowsForPlan(mm, 'initializing', plan, (key) => key) expect(rows).toEqual([ { @@ -530,13 +541,14 @@ describe('buildTvShowEpisodeTableRowsForPlan', () => { text: 'mediaFolder.initializing', }, ]) + expect(defaultChecked).toEqual([]) }) it('returns folder_not_found divider when uiStatus is folder_not_found', () => { const mm = {} as MediaMetadata const plan = recognizePlan([{ season: 1, episode: 1, path: '/media/show/S01E01.mkv' }]) - const rows = buildTvShowEpisodeTableRowsForPlan(mm, 'folder_not_found', plan, (key) => key) + const { rows, defaultChecked } = buildTvShowEpisodeTableRowsForPlan(mm, 'folder_not_found', plan, (key) => key) expect(rows).toEqual([ { @@ -545,13 +557,14 @@ describe('buildTvShowEpisodeTableRowsForPlan', () => { text: 'mediaFolder.folderNotFound', }, ]) + expect(defaultChecked).toEqual([]) }) it('returns error_loading_metadata divider when uiStatus is error_loading_metadata', () => { const mm = {} as MediaMetadata const plan = recognizePlan([{ season: 1, episode: 1, path: '/media/show/S01E01.mkv' }]) - const rows = buildTvShowEpisodeTableRowsForPlan(mm, 'error_loading_metadata', plan, (key) => key) + const { rows, defaultChecked } = buildTvShowEpisodeTableRowsForPlan(mm, 'error_loading_metadata', plan, (key) => key) expect(rows).toEqual([ { @@ -560,9 +573,10 @@ describe('buildTvShowEpisodeTableRowsForPlan', () => { text: 'mediaFolder.errorLoadingMetadata', }, ]) + expect(defaultChecked).toEqual([]) }) - it('returns base rows unchanged when recognize plan is preparing', () => { + it('returns base rows unchanged and no default selection when recognize plan is preparing', () => { const mm = { tvShow: tvShowForPlanTests(), } as MediaMetadata @@ -571,11 +585,11 @@ describe('buildTvShowEpisodeTableRowsForPlan', () => { status: 'preparing', } as UIRecognizeMediaFilePlan - const rows = buildTvShowEpisodeTableRowsForPlan(mm, 'ok', plan, (key) => key) - const ep = rows.find((row) => row.type === 'episode' && row.season === 1 && row.episode === 1) as TvShowEpisodeDataRow + const { rows, defaultChecked } = buildTvShowEpisodeTableRowsForPlan(mm, 'ok', plan, (key) => key) + const ep = rows.find((row) => row.type === 'episode' && row.season === 1 && row.episode === 1) as UIMediaFileDataRow expect(ep.videoFile).toBeUndefined() - expect(ep.checked).toBe(false) + expect(defaultChecked).toEqual([]) }) it('fills episode row from recognize plan when recognize plan is completed', () => { @@ -584,11 +598,11 @@ describe('buildTvShowEpisodeTableRowsForPlan', () => { } as MediaMetadata const plan = recognizePlan([{ season: 1, episode: 1, path: '/media/show/S01E01.mkv' }]) - const rows = buildTvShowEpisodeTableRowsForPlan(mm, 'ok', plan, (key) => key) - const ep = rows.find((row) => row.type === 'episode' && row.season === 1 && row.episode === 1) as TvShowEpisodeDataRow + const { rows, defaultChecked } = buildTvShowEpisodeTableRowsForPlan(mm, 'ok', plan, (key) => key) + const ep = rows.find((row) => row.type === 'episode' && row.season === 1 && row.episode === 1) as UIMediaFileDataRow expect(ep.videoFile).toBe('/media/show/S01E01.mkv') - expect(ep.checked).toBe(true) + expect(selectedKeys(defaultChecked).has('1-1')).toBe(true) expect(ep.newVideoFile).toBeUndefined() }) @@ -609,11 +623,11 @@ describe('buildTvShowEpisodeTableRowsForPlan', () => { { from: '/media/show/S01E01.mkv', to: '/media/show/new/S01E01.mkv' }, ]) - const rows = buildTvShowEpisodeTableRowsForPlan(mm, 'ok', plan, (key) => key) - const ep = rows.find((row) => row.type === 'episode' && row.season === 1 && row.episode === 1) as TvShowEpisodeDataRow + const { rows, defaultChecked } = buildTvShowEpisodeTableRowsForPlan(mm, 'ok', plan, (key) => key) + const ep = rows.find((row) => row.type === 'episode' && row.season === 1 && row.episode === 1) as UIMediaFileDataRow expect(ep.videoFile).toBe('/media/show/S01E01.mkv') expect(ep.newVideoFile).toBe('/media/show/new/S01E01.mkv') - expect(ep.checked).toBe(true) + expect(selectedKeys(defaultChecked).has('1-1')).toBe(true) }) }) diff --git a/apps/ui/src/lib/buildTvShowEpisodeTableRows.ts b/apps/ui/src/lib/buildTvShowEpisodeTableRows.ts index 9cf87aba..1ef81adc 100644 --- a/apps/ui/src/lib/buildTvShowEpisodeTableRows.ts +++ b/apps/ui/src/lib/buildTvShowEpisodeTableRows.ts @@ -1,4 +1,4 @@ -import type { TvShowEpisodeDataRow, TvShowEpisodeTableRow, TvShowFolderFileRow } from "@/components/tv/TvShowEpisodeTable"; +import type { UIMediaFileTableRow, UIMediaFileFolderRow, UIMediaEpisodeSelection } from "@/components/media/UIMediaFileTable"; import type { MediaMetadata } from "@/lib/mediaFolderFiles" import { basename, join } from "@/lib/path"; import type { UIRecognizeMediaFilePlan } from "@/types/UIRecognizeMediaFilePlan"; @@ -9,9 +9,30 @@ import type { UIRenameFilesPlan } from "@/types/UIRenameFilesPlan"; import { mediaFilePathEqual } from "@smm/core/pipeline/mediaFilePathEqual"; import Debug from 'debug' const debug = Debug('buildTvShowEpisodeTableRows') -const FOLDER_FILE_IDS: TvShowFolderFileRow["id"][] = ["clearlogo", "fanart", "poster", "theme", "nfo"] +const FOLDER_FILE_IDS: UIMediaFileFolderRow["id"][] = ["clearlogo", "fanart", "poster", "theme", "nfo"] -function matchFolderFile(files: string[], id: TvShowFolderFileRow["id"]): string | undefined { +/** + * Result of building episode rows: rows to render plus the episodes that should + * be pre-checked for the current plan / preview. Selection state itself stays + * in the caller; this only describes the derived default. + */ +export interface BuiltTvShowEpisodeTableRows { + rows: UIMediaFileTableRow[] + defaultChecked: UIMediaEpisodeSelection[] +} + +/** Episodes that currently have a linked video file (base default selection). */ +function episodesWithVideoFile(rows: UIMediaFileTableRow[]): UIMediaEpisodeSelection[] { + const out: UIMediaEpisodeSelection[] = [] + for (const row of rows) { + if (row.type === "episode" && row.videoFile !== undefined) { + out.push({ season: row.season, episode: row.episode }) + } + } + return out +} + +function matchFolderFile(files: string[], id: UIMediaFileFolderRow["id"]): string | undefined { if (!files.length) return undefined if (id === "nfo") { return files.find((f) => basename(f) === "tvshow.nfo") @@ -28,9 +49,9 @@ function matchFolderFile(files: string[], id: TvShowFolderFileRow["id"]): string * @param files * @returns */ -function buildFolderFileRows(files: string[]): TvShowFolderFileRow[] { +function buildFolderFileRows(files: string[]): UIMediaFileFolderRow[] { - const rows: TvShowFolderFileRow[] = [] + const rows: UIMediaFileFolderRow[] = [] for (const id of FOLDER_FILE_IDS) { const path = matchFolderFile(files, id) if (path) rows.push({ id, type: "folderFile", path }) @@ -46,8 +67,8 @@ export function buildTvShowEpisodeTableRows( uiStatus: UIMediaFolderStatus, t: (key: string) => string, folderFiles: string[] = [], -): TvShowEpisodeTableRow[] { - const rows: TvShowEpisodeTableRow[] = [] +): UIMediaFileTableRow[] { + const rows: UIMediaFileTableRow[] = [] if (uiStatus === "initializing") { return [{ @@ -93,7 +114,7 @@ export function buildTvShowEpisodeTableRows( export function _buildTvShowEpisodeTableRowsFromTmdb(_in_mm: MediaMetadata, folderFiles: string[] = []) { - const rows: TvShowEpisodeTableRow[] = [] + const rows: UIMediaFileTableRow[] = [] if (!_in_mm.tvShow) { return rows @@ -163,7 +184,6 @@ export function _buildTvShowEpisodeTableRowsFromTmdb(_in_mm: MediaMetadata, fold newThumbnail: thumbnailFile?.newPath, newSubtitle: subtitleFile?.newPath, newNfo: nfoFile?.newPath, - checked: videoFile?.path ? true : false, }) } } @@ -173,7 +193,7 @@ export function _buildTvShowEpisodeTableRowsFromTmdb(_in_mm: MediaMetadata, fold export function _buildTvShowEpisodeTableRowsFromTvdb(_in_mm: MediaMetadata, folderFiles: string[] = []) { - const rows: TvShowEpisodeTableRow[] = [] + const rows: UIMediaFileTableRow[] = [] if(!_in_mm.tvShow || !_in_mm.tvShow.seasons) { return rows; @@ -243,7 +263,6 @@ export function _buildTvShowEpisodeTableRowsFromTvdb(_in_mm: MediaMetadata, fold newThumbnail: thumbnailFile?.newPath, newSubtitle: subtitleFile?.newPath, newNfo: nfoFile?.newPath, - checked: videoFile?.path ? true : false, }) } } @@ -257,37 +276,46 @@ export function buildTvShowEpisodeTableRowsForPlan( plan: UIRenameFilesPlan | UIRecognizeMediaFilePlan, t: (key: string) => string, folderFiles: string[] = [], -): TvShowEpisodeTableRow[] { +): BuiltTvShowEpisodeTableRows { if (uiStatus === "initializing") { - return [{ - id: "initializing", - type: "divider", - text: t ? t('mediaFolder.initializing') : "Initializing", - }] + return { + rows: [{ + id: "initializing", + type: "divider", + text: t ? t('mediaFolder.initializing') : "Initializing", + }], + defaultChecked: [], + } } if (uiStatus === "folder_not_found") { - return [{ - id: "folder_not_found", - type: "divider", - text: t ? t('mediaFolder.folderNotFound') : "Folder not found", - }] + return { + rows: [{ + id: "folder_not_found", + type: "divider", + text: t ? t('mediaFolder.folderNotFound') : "Folder not found", + }], + defaultChecked: [], + } } if (uiStatus === "error_loading_metadata") { - return [{ - id: "error_loading_metadata", - type: "divider", - text: t ? t('mediaFolder.errorLoadingMetadata') : "Error loading metadata", - }] + return { + rows: [{ + id: "error_loading_metadata", + type: "divider", + text: t ? t('mediaFolder.errorLoadingMetadata') : "Error loading metadata", + }], + defaultChecked: [], + } } - const rows: TvShowEpisodeTableRow[] = buildTvShowEpisodeTableRows(mm, uiStatus, t, folderFiles) + const rows: UIMediaFileTableRow[] = buildTvShowEpisodeTableRows(mm, uiStatus, t, folderFiles) if(plan.task === "recognize-media-file") { if(plan.status === 'preparing') { - return rows; + return { rows, defaultChecked: episodesWithVideoFile(rows) } } return fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) @@ -298,15 +326,40 @@ export function buildTvShowEpisodeTableRowsForPlan( debug(`buildTvShowEpisodeTableRowsForPlan RETURNED: %O`, rows) - return rows + return { rows, defaultChecked: episodesWithVideoFile(rows) } +} + +/** + * Builds the rows shown by the TV show panel together with the episodes that + * should be pre-checked. Selection defaults are co-located with row building; + * the selection state itself lives in the caller (TvShowPanel). + */ +export function buildTvShowEpisodeTableRowsForPanel( + mm: MediaMetadata, + uiStatus: UIMediaFolderStatus, + plan: UIRenameFilesPlan | UIRecognizeMediaFilePlan | undefined, + t: (key: string) => string, + folderFiles: string[] = [], +): BuiltTvShowEpisodeTableRows { + + if (plan === undefined) { + // No plan → no preview checkboxes; no episodes are pre-selected. + return { + rows: buildTvShowEpisodeTableRows(mm, uiStatus, t, folderFiles), + defaultChecked: [], + } + } + + return buildTvShowEpisodeTableRowsForPlan(mm, uiStatus, plan, t, folderFiles) } export function fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan( - _in_rows: TvShowEpisodeTableRow[], + _in_rows: UIMediaFileTableRow[], plan: UIRecognizeMediaFilePlan, -) { +): BuiltTvShowEpisodeTableRows { - const rows = structuredClone(_in_rows) as TvShowEpisodeTableRow[] + const rows = structuredClone(_in_rows) as UIMediaFileTableRow[] + const defaultChecked: UIMediaEpisodeSelection[] = [] const planFilesByKey = new Map( plan.files.map((file) => [`${file.season}:${file.episode}`, file] as const), ) @@ -329,14 +382,14 @@ export function fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan( && mediaFilePathEqual(existingVideoFile, planPath) if (unchanged) { - row.checked = false row.disabled = true } else { - row.checked = planPath !== undefined row.disabled = false + if (planPath !== undefined) { + defaultChecked.push({ season: row.season, episode: row.episode }) + } } } else { - row.checked = false row.disabled = true } } @@ -352,15 +405,15 @@ export function fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan( } } - return rows - + return { rows, defaultChecked } } export function fillTvShowEpisodeTableRowByRenameFilesPlan( - _in_rows: TvShowEpisodeTableRow[], + _in_rows: UIMediaFileTableRow[], plan: UIRenameFilesPlan, -) { - const rows = structuredClone(_in_rows) as TvShowEpisodeDataRow[] +): BuiltTvShowEpisodeTableRows { + const rows = structuredClone(_in_rows) as UIMediaFileTableRow[] + const defaultChecked: UIMediaEpisodeSelection[] = [] const renameFiles = plan.files for (const row of rows) { @@ -369,9 +422,6 @@ export function fillTvShowEpisodeTableRowByRenameFilesPlan( } row.newVideoFile = undefined row.disabled = undefined - if (row.videoFile) { - row.checked = false - } } for(const renameFile of renameFiles) { @@ -383,8 +433,12 @@ export function fillTvShowEpisodeTableRowByRenameFilesPlan( if(row.videoFile === renameFile.from) { row.newVideoFile = renameFile.to; - row.checked = true; row.disabled = false; + if (!defaultChecked.some( + (e) => e.season === row.season && e.episode === row.episode, + )) { + defaultChecked.push({ season: row.season, episode: row.episode }) + } } } @@ -399,5 +453,5 @@ export function fillTvShowEpisodeTableRowByRenameFilesPlan( } } - return rows; + return { rows, defaultChecked }; } \ No newline at end of file diff --git a/apps/ui/src/lib/dialogRequestEvents.ts b/apps/ui/src/lib/dialogRequestEvents.ts new file mode 100644 index 00000000..0af6142f --- /dev/null +++ b/apps/ui/src/lib/dialogRequestEvents.ts @@ -0,0 +1,34 @@ +import { + UI_AskForFormatConverter, + UI_AskForRenameFile, + UI_AskForScrape, + type OnAskForFormatConverterEventData, + type OnAskForRenameFileEventData, + type OnAskForScrapeEventData, + type RenameFileDialogOptions, +} from "@/types/eventTypes" + +/** + * Document-event dispatchers for the App-level dialog controllers + * (`FormatConverter`, `ScrapeMetadata`, `RenameFile`). Panels / headers / + * menu items call these instead of opening dialogs via dialog-provider, + * which keeps the feature decoupled from the panels that request it. + */ + +export function askForFormatConverter(detail: OnAskForFormatConverterEventData = {}): void { + document.dispatchEvent( + new CustomEvent(UI_AskForFormatConverter, { detail }), + ) +} + +export function askForScrape(detail: OnAskForScrapeEventData): void { + document.dispatchEvent(new CustomEvent(UI_AskForScrape, { detail })) +} + +export function askForRenameFile( + onConfirm: (newName: string) => void, + options?: RenameFileDialogOptions, +): void { + const detail: OnAskForRenameFileEventData = { onConfirm, options } + document.dispatchEvent(new CustomEvent(UI_AskForRenameFile, { detail })) +} diff --git a/apps/ui/src/lib/initializeMusicFolder.test.ts b/apps/ui/src/lib/initializeMusicFolder.test.ts index 33726147..7eb5c60f 100644 --- a/apps/ui/src/lib/initializeMusicFolder.test.ts +++ b/apps/ui/src/lib/initializeMusicFolder.test.ts @@ -1,17 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { initializeMusicFolder } from './initializeMusicFolder' -vi.mock('@/api/listFiles', () => ({ - listFiles: vi.fn(), +vi.mock('@/lib/mediaMetadataUtils', () => ({ + createInitialMediaMetadata: vi.fn(), })) -vi.mock('@smm/core/mediaMetadata', () => ({ - createMediaMetadata: vi.fn(), -})) - -import { listFiles } from '@/api/listFiles' -import { createMediaMetadata } from '@smm/core/mediaMetadata' -import type { MediaMetadata } from '@smm/types' +import { createInitialMediaMetadata } from '@/lib/mediaMetadataUtils' describe('initializeMusicFolder', () => { const mockAddMediaFolderInUserConfig = vi.fn() @@ -19,29 +13,21 @@ describe('initializeMusicFolder', () => { const mockAddMediaMetadata = vi.fn() const traceId = 'test-trace-id' + const fullMetadata = { + mediaFolderPath: '/media/music/Album', + type: 'music-folder', + status: 'ok', + files: ['/media/music/Album/song1.mp3'], + } + beforeEach(() => { vi.clearAllMocks() + vi.mocked(createInitialMediaMetadata).mockResolvedValue(fullMetadata as never) }) it('should add folder to user config', async () => { const folderPath = '/media/music/Album' - mockGetMediaMetadata.mockReturnValue(undefined) - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: folderPath, - items: [ - { path: '/media/music/Album/song1.mp3', size: 0, mtime: 0, isDirectory: false }, - { path: '/media/music/Album/song2.mp3', size: 0, mtime: 0, isDirectory: false }, - ], - size: 2, - }, - error: undefined, - }) - vi.mocked(createMediaMetadata).mockReturnValue({ - mediaFolderPath: '/media/music/Album', - type: 'music-folder', - }) await initializeMusicFolder(folderPath, { addMediaFolderInUserConfig: mockAddMediaFolderInUserConfig, @@ -55,23 +41,7 @@ describe('initializeMusicFolder', () => { it('should create new media metadata when folder does not exist', async () => { const folderPath = '/media/music/NewAlbum' - const posixPath = '/media/music/NewAlbum' - mockGetMediaMetadata.mockReturnValue(undefined) - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: folderPath, - items: [ - { path: '/media/music/NewAlbum/song1.mp3', size: 0, mtime: 0, isDirectory: false }, - ], - size: 1, - }, - error: undefined, - }) - vi.mocked(createMediaMetadata).mockReturnValue({ - mediaFolderPath: posixPath, - type: 'music-folder', - }) await initializeMusicFolder(folderPath, { addMediaFolderInUserConfig: mockAddMediaFolderInUserConfig, @@ -80,27 +50,20 @@ describe('initializeMusicFolder', () => { traceId, }) - expect(mockAddMediaMetadata).toHaveBeenCalledWith( - expect.objectContaining({ - mediaFolderPath: posixPath, - type: 'music-folder', - status: 'ok', - files: ['/media/music/NewAlbum/song1.mp3'], - }) - ) + expect(createInitialMediaMetadata).toHaveBeenCalledWith(folderPath, 'music-folder', { + traceId, + }) + expect(mockAddMediaMetadata).toHaveBeenCalledWith(fullMetadata) }) - it('should not create media metadata when folder already exists with status ok', async () => { + it('should not create media metadata when folder already exists', async () => { const folderPath = '/media/music/ExistingAlbum' const posixPath = '/media/music/ExistingAlbum' - - const existingMetadata = { + mockGetMediaMetadata.mockReturnValue({ mediaFolderPath: posixPath, type: 'music-folder', - status: 'ok' as const, - } - - mockGetMediaMetadata.mockReturnValue(existingMetadata) + status: 'ok', + }) await initializeMusicFolder(folderPath, { addMediaFolderInUserConfig: mockAddMediaFolderInUserConfig, @@ -109,73 +72,39 @@ describe('initializeMusicFolder', () => { traceId, }) + expect(createInitialMediaMetadata).not.toHaveBeenCalled() expect(mockAddMediaMetadata).not.toHaveBeenCalled() }) - it('should update placeholder to full metadata when folder exists with status initializing', async () => { + it('should update placeholder to full metadata when folder is initializing', async () => { const folderPath = '/media/music/ExistingAlbum' const posixPath = '/media/music/ExistingAlbum' - - const placeholderMetadata = { - mediaFolderPath: posixPath, - type: 'music-folder', - status: 'initializing' as const, - } - - mockGetMediaMetadata.mockReturnValue(placeholderMetadata) - const mockUpdateMediaMetadata = vi.fn().mockResolvedValue(undefined) - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: folderPath, - items: [ - { path: '/media/music/ExistingAlbum/song1.mp3', size: 0, mtime: 0, isDirectory: false }, - ], - size: 1, - }, - error: undefined, - }) - vi.mocked(createMediaMetadata).mockReturnValue({ + mockGetMediaMetadata.mockReturnValue({ mediaFolderPath: posixPath, type: 'music-folder', + status: 'initializing', }) + const mockUpdateMediaMetadata = vi.fn().mockResolvedValue(undefined) await initializeMusicFolder(folderPath, { addMediaFolderInUserConfig: mockAddMediaFolderInUserConfig, getMediaMetadata: mockGetMediaMetadata, addMediaMetadata: mockAddMediaMetadata, updateMediaMetadata: mockUpdateMediaMetadata, + isInitializing: () => true, traceId, }) + expect(createInitialMediaMetadata).toHaveBeenCalledWith(folderPath, 'music-folder', { + traceId, + }) expect(mockAddMediaMetadata).not.toHaveBeenCalled() - expect(mockUpdateMediaMetadata).toHaveBeenCalledWith( - posixPath, - expect.objectContaining({ - mediaFolderPath: posixPath, - type: 'music-folder', - status: 'ok', - files: ['/media/music/ExistingAlbum/song1.mp3'], - }) - ) + expect(mockUpdateMediaMetadata).toHaveBeenCalledWith(posixPath, fullMetadata) }) it('should convert folder path to POSIX format when checking for existing metadata', async () => { const folderPath = 'C:\\media\\music\\Album' - const posixPath = '/C/media/music/Album' - mockGetMediaMetadata.mockReturnValue(undefined) - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: folderPath, - items: [], - size: 0, - }, - error: undefined, - }) - vi.mocked(createMediaMetadata).mockReturnValue({ - mediaFolderPath: posixPath, - type: 'music-folder', - }) await initializeMusicFolder(folderPath, { addMediaFolderInUserConfig: mockAddMediaFolderInUserConfig, @@ -184,59 +113,12 @@ describe('initializeMusicFolder', () => { traceId, }) - expect(mockGetMediaMetadata).toHaveBeenCalledWith(posixPath) + expect(mockGetMediaMetadata).toHaveBeenCalledWith('/C/media/music/Album') }) it('should pass traceId to createInitialMediaMetadata', async () => { const folderPath = '/media/music/Album' - - mockGetMediaMetadata.mockReturnValue(undefined) - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: folderPath, - items: [], - size: 0, - }, - error: undefined, - }) - vi.mocked(createMediaMetadata).mockReturnValue({ - mediaFolderPath: '/media/music/Album', - type: 'music-folder', - }) - - await initializeMusicFolder(folderPath, { - addMediaFolderInUserConfig: mockAddMediaFolderInUserConfig, - getMediaMetadata: mockGetMediaMetadata, - addMediaMetadata: mockAddMediaMetadata, - traceId, - }) - - expect(listFiles).toHaveBeenCalledWith( - { path: folderPath, recursively: true, onlyFiles: true }, - undefined - ) - }) - - it('should handle multiple files in music folder', async () => { - const folderPath = '/media/music/Album' - mockGetMediaMetadata.mockReturnValue(undefined) - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: folderPath, - items: [ - { path: '/media/music/Album/song1.mp3', size: 0, mtime: 0, isDirectory: false }, - { path: '/media/music/Album/song2.mp3', size: 0, mtime: 0, isDirectory: false }, - { path: '/media/music/Album/song3.mp3', size: 0, mtime: 0, isDirectory: false }, - ], - size: 3, - }, - error: undefined, - }) - vi.mocked(createMediaMetadata).mockReturnValue({ - mediaFolderPath: '/media/music/Album', - type: 'music-folder', - }) await initializeMusicFolder(folderPath, { addMediaFolderInUserConfig: mockAddMediaFolderInUserConfig, @@ -245,121 +127,25 @@ describe('initializeMusicFolder', () => { traceId, }) - expect(mockAddMediaMetadata).toHaveBeenCalledWith( - expect.objectContaining({ - files: [ - '/media/music/Album/song1.mp3', - '/media/music/Album/song2.mp3', - '/media/music/Album/song3.mp3', - ], - }) - ) - }) - - it('should handle Windows network paths', async () => { - const folderPath = '\\\\server\\share\\music\\Album' - const posixPath = '/server/share/music/Album' - - mockGetMediaMetadata.mockReturnValue(undefined) - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: folderPath, - items: [], - size: 0, - }, - error: undefined, - }) - vi.mocked(createMediaMetadata).mockReturnValue({ - mediaFolderPath: posixPath, - type: 'music-folder', - }) - - await initializeMusicFolder(folderPath, { - addMediaFolderInUserConfig: mockAddMediaFolderInUserConfig, - getMediaMetadata: mockGetMediaMetadata, - addMediaMetadata: mockAddMediaMetadata, + expect(createInitialMediaMetadata).toHaveBeenCalledWith(folderPath, 'music-folder', { traceId, }) - - expect(mockGetMediaMetadata).toHaveBeenCalledWith(posixPath) }) - it('should set status to ok in mediaMetadataProps', async () => { + it('should add the media metadata returned for the folder', async () => { const folderPath = '/media/music/Album' - - mockGetMediaMetadata.mockReturnValue(undefined) - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: folderPath, - items: [], - size: 0, - }, - error: undefined, - }) - vi.mocked(createMediaMetadata).mockReturnValue({ - mediaFolderPath: '/media/music/Album', - type: 'music-folder', - }) - - await initializeMusicFolder(folderPath, { - addMediaFolderInUserConfig: mockAddMediaFolderInUserConfig, - getMediaMetadata: mockGetMediaMetadata, - addMediaMetadata: mockAddMediaMetadata, - traceId, - }) - - expect(mockAddMediaMetadata).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'ok', - }) - ) - }) - - it('should log appropriate message for new folder', async () => { - const folderPath = '/media/music/NewAlbum' - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - mockGetMediaMetadata.mockReturnValue(undefined) - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: folderPath, - items: [], - size: 0, - }, - error: undefined, - }) - vi.mocked(createMediaMetadata).mockReturnValue({ - mediaFolderPath: '/media/music/NewAlbum', - type: 'music-folder', - }) - - await initializeMusicFolder(folderPath, { - addMediaFolderInUserConfig: mockAddMediaFolderInUserConfig, - getMediaMetadata: mockGetMediaMetadata, - addMediaMetadata: mockAddMediaMetadata, - traceId, - }) - - expect(consoleSpy).toHaveBeenCalledWith( - `[${traceId}] add "${folderPath}" to user config` - ) - expect(consoleSpy).toHaveBeenCalledWith( - `[${traceId}] Imported music folder and create media metadata for folder "${folderPath}"` - ) - - consoleSpy.mockRestore() - }) - - it('should log appropriate message for existing folder', async () => { - const folderPath = '/media/music/ExistingAlbum' - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - const existingMetadata: MediaMetadata = { - mediaFolderPath: '/media/music/ExistingAlbum', + const metadata = { + mediaFolderPath: folderPath, type: 'music-folder', + status: 'ok' as const, + files: [ + '/media/music/Album/song1.mp3', + '/media/music/Album/song2.mp3', + '/media/music/Album/song3.mp3', + ], } - - mockGetMediaMetadata.mockReturnValue(existingMetadata) + vi.mocked(createInitialMediaMetadata).mockResolvedValue(metadata as never) + mockGetMediaMetadata.mockReturnValue(undefined) await initializeMusicFolder(folderPath, { addMediaFolderInUserConfig: mockAddMediaFolderInUserConfig, @@ -368,13 +154,6 @@ describe('initializeMusicFolder', () => { traceId, }) - expect(consoleSpy).toHaveBeenCalledWith( - `[${traceId}] add "${folderPath}" to user config` - ) - expect(consoleSpy).toHaveBeenCalledWith( - `[${traceId}] Imported music folder "${folderPath}" and skip creating media metadata because it already exists` - ) - - consoleSpy.mockRestore() + expect(mockAddMediaMetadata).toHaveBeenCalledWith(metadata) }) }) diff --git a/apps/ui/src/lib/mediaMetadataRefreshUtils.ts b/apps/ui/src/lib/mediaMetadataRefreshUtils.ts index e8579f49..a57c6d6d 100644 --- a/apps/ui/src/lib/mediaMetadataRefreshUtils.ts +++ b/apps/ui/src/lib/mediaMetadataRefreshUtils.ts @@ -1,8 +1,21 @@ import type { MediaMetadata } from '@smm/types' +import type { UIMediaFolderStatus } from '@/types/UIMediaFolder' +/** + * Merges freshly fetched (backend) media metadata with the current UI-held + * metadata: media content fields come from `response`, while UI-only props + * (e.g. loading status) are preserved from `currentMediaMetadata`. + */ export function mergeRefreshedMetadata( response: MediaMetadata, - _currentMediaMetadata: MediaMetadata | undefined, -): MediaMetadata { - return response + currentMediaMetadata: MediaMetadata | undefined, +): MediaMetadata & { status: UIMediaFolderStatus } { + const currentStatus = (currentMediaMetadata as { status?: UIMediaFolderStatus } | undefined) + ?.status + const status: UIMediaFolderStatus = currentStatus ?? 'idle' + return { + ...currentMediaMetadata, + ...response, + status, + } } diff --git a/apps/ui/src/lib/recognizeEpisodes.test.ts b/apps/ui/src/lib/recognizeEpisodes.test.ts index 1fd93190..a613f297 100644 --- a/apps/ui/src/lib/recognizeEpisodes.test.ts +++ b/apps/ui/src/lib/recognizeEpisodes.test.ts @@ -138,39 +138,39 @@ describe('recognizeEpisodes', () => { it('returns empty array when files is undefined', () => { const mm = makeMM({ files: undefined }) - expect(recognizeEpisodes(mm)).toEqual([]) + expect(recognizeEpisodes(mm, mm.files ?? [])).toEqual([]) }) it('returns empty array when files is null', () => { const mm = makeMM({ files: null }) - expect(recognizeEpisodes(mm)).toEqual([]) + expect(recognizeEpisodes(mm, mm.files ?? [])).toEqual([]) }) it('returns empty array when files is empty', () => { const mm = makeMM({ files: [] }) - expect(recognizeEpisodes(mm)).toEqual([]) + expect(recognizeEpisodes(mm, mm.files ?? [])).toEqual([]) }) it('returns empty array when tvShow is undefined', () => { const mm = makeMM({ files: ['Show - 1.mp4'], tvShow: undefined }) - expect(recognizeEpisodes(mm)).toEqual([]) + expect(recognizeEpisodes(mm, mm.files ?? [])).toEqual([]) }) it('returns empty array when tvShow.seasons is empty', () => { const mm = makeMM({ files: ['Show - 1.mp4'] }) if (mm.tvShow) mm.tvShow.seasons = [] - expect(recognizeEpisodes(mm)).toEqual([]) + expect(recognizeEpisodes(mm, mm.files ?? [])).toEqual([]) }) it('returns empty array when first season has no episodes', () => { const mm = makeMM({ files: ['Show - 1.mp4'] }) if (mm.tvShow?.seasons?.[0]) mm.tvShow.seasons[0].episodes = [] - expect(recognizeEpisodes(mm)).toEqual([]) + expect(recognizeEpisodes(mm, mm.files ?? [])).toEqual([]) }) it('returns empty array when no video files (only non-video extensions)', () => { const mm = makeMM({ files: ['Show - 1.txt', 'Readme.srt', 'poster.jpg'] }) - expect(recognizeEpisodes(mm)).toEqual([]) + expect(recognizeEpisodes(mm, mm.files ?? [])).toEqual([]) }) it('excludes files under /Extras/ and /Subtitles/', () => { @@ -181,7 +181,7 @@ describe('recognizeEpisodes', () => { '/media/Show/Show - 1.mp4', ], }) - const result = recognizeEpisodes(mm) + const result = recognizeEpisodes(mm, mm.files ?? []) expect(result).toHaveLength(1) expect(result[0].file).toBe('/media/Show/Show - 1.mp4') }) @@ -190,7 +190,7 @@ describe('recognizeEpisodes', () => { const mm = makeMM({ files: ['/media/Show/Show.S01E01.1080p.mp4', '/media/Show/Show.S01E02.mkv', '/media/Show/Show.S01E03.avi'], }) - const result = recognizeEpisodes(mm) + const result = recognizeEpisodes(mm, mm.files ?? []) expect(result).toEqual([ { season: 1, episode: 1, file: '/media/Show/Show.S01E01.1080p.mp4' }, { season: 1, episode: 2, file: '/media/Show/Show.S01E02.mkv' }, @@ -202,7 +202,7 @@ describe('recognizeEpisodes', () => { const mm = makeMM({ files: ['/media/Show/Show 第1季第1集.mp4', '/media/Show/Show 第1季第2集.mkv', '/media/Show/Show 第1季第3集.avi'], }) - const result = recognizeEpisodes(mm) + const result = recognizeEpisodes(mm, mm.files ?? []) expect(result).toEqual([ { season: 1, episode: 1, file: '/media/Show/Show 第1季第1集.mp4' }, { season: 1, episode: 2, file: '/media/Show/Show 第1季第2集.mkv' }, @@ -214,7 +214,7 @@ describe('recognizeEpisodes', () => { const mm = makeMM({ files: ['/media/Show/Show 第01季第01集.mp4', '/media/Show/Show 第01季第02集.mkv', '/media/Show/Show 第01季第03集.avi'], }) - const result = recognizeEpisodes(mm) + const result = recognizeEpisodes(mm, mm.files ?? []) expect(result).toEqual([ { season: 1, episode: 1, file: '/media/Show/Show 第01季第01集.mp4' }, { season: 1, episode: 2, file: '/media/Show/Show 第01季第02集.mkv' }, @@ -226,7 +226,7 @@ describe('recognizeEpisodes', () => { const mm = makeMM({ files: ['/media/Show/Show - 1.mp4', '/media/Show/Show.2.mkv', '/media/Show/Show_3.avi'], }) - const result = recognizeEpisodes(mm) + const result = recognizeEpisodes(mm, mm.files ?? []) expect(result).toEqual([ { season: 1, episode: 1, file: '/media/Show/Show - 1.mp4' }, { season: 1, episode: 2, file: '/media/Show/Show.2.mkv' }, @@ -238,14 +238,14 @@ describe('recognizeEpisodes', () => { const mm = makeMM({ files: ['/media/Show/UnknownFormat_x_y_z.mp4', '/media/Show/Other.mkv'], }) - expect(recognizeEpisodes(mm)).toEqual([]) + expect(recognizeEpisodes(mm, mm.files ?? [])).toEqual([]) }) it('prefers pattern1 over pattern4 when both could match', () => { const mm = makeMM({ files: ['/media/Show/Show.S01E01.mp4', '/media/Show/Show - 1.mp4'], }) - const result = recognizeEpisodes(mm) + const result = recognizeEpisodes(mm, mm.files ?? []) expect(result).toHaveLength(1) expect(result[0].file).toContain('S01E01') }) diff --git a/apps/ui/src/lib/uiDomainMapper.test.ts b/apps/ui/src/lib/uiDomainMapper.test.ts deleted file mode 100644 index 37660541..00000000 --- a/apps/ui/src/lib/uiDomainMapper.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - hasDomainMetadataChanged, - extractPersistableMediaMetadata, - toUIMediaMetadata, - mergeUIMetadata, -} from './uiDomainMapper' -import type { MediaMetadata, TvShowMediaMetadata } from '@smm/types' -import type { UIMediaMetadata } from '@/types/UIMediaMetadata' - -const baseTvShow = (): TvShowMediaMetadata => ({ - id: '1', - name: 'Show 1', - database: 'TMDB', - seasons: [], -}) - -const createMockMediaMetadata = (overrides?: Partial): MediaMetadata => ({ - mediaFolderPath: '/media/show1', - type: 'tvshow-folder', - tvShow: baseTvShow(), - files: ['/media/show1/episode1.mp4'], - mediaFiles: [], - ...overrides, -}) - -const createMockUIMediaMetadata = (overrides?: Partial): UIMediaMetadata => ({ - ...createMockMediaMetadata(overrides), - status: 'idle', -} as UIMediaMetadata) - -describe('UiDomainMapper', () => { - describe('hasDomainMetadataChanged', () => { - it('should return true when current metadata is undefined', () => { - const updated = createMockUIMediaMetadata() - const result = hasDomainMetadataChanged(undefined, updated) - expect(result).toBe(true) - }) - - it('should return false when only UI properties changed', () => { - const tv = baseTvShow() - const current = createMockUIMediaMetadata({ status: 'idle', tvShow: tv }) - const updated = createMockUIMediaMetadata({ status: 'loading', tvShow: tv }) - const result = hasDomainMetadataChanged(current, updated) - expect(result).toBe(false) - }) - - it('should return true when domain properties changed', () => { - const current = createMockUIMediaMetadata({ - tvShow: { ...baseTvShow(), name: 'Show 1' }, - }) - const updated = createMockUIMediaMetadata({ - tvShow: { ...baseTvShow(), name: 'Show 2' }, - }) - const result = hasDomainMetadataChanged(current, updated) - expect(result).toBe(true) - }) - - it('should handle array changes', () => { - const current = createMockUIMediaMetadata({ - mediaFiles: [{ seasonNumber: 1, episodeNumber: 1, absolutePath: '/media/show1/ep1.mp4' }], - }) - const updated = createMockUIMediaMetadata({ - mediaFiles: [{ seasonNumber: 1, episodeNumber: 2, absolutePath: '/media/show1/ep1.mp4' }], - }) - const result = hasDomainMetadataChanged(current, updated) - expect(result).toBe(true) - }) - }) - - describe('extractPersistableMediaMetadata', () => { - it('should extract domain metadata by removing UI-only properties', () => { - const tvShow: TvShowMediaMetadata = { - id: '123', - name: 'Show 1', - database: 'TMDB', - seasons: [], - } - const uiMetadata = createMockUIMediaMetadata({ - status: 'loading', - tvShow, - }) - - const result = extractPersistableMediaMetadata(uiMetadata) - - expect(result).toEqual({ - mediaFolderPath: '/media/show1', - type: 'tvshow-folder', - files: ['/media/show1/episode1.mp4'], - mediaFiles: [], - tvShow, - }) - expect(result).not.toHaveProperty('status') - }) - }) - - describe('toUIMediaMetadata', () => { - it('should convert domain metadata to UI metadata with default status', () => { - const domainMetadata = createMockMediaMetadata() - const result = toUIMediaMetadata(domainMetadata) - - expect(result).toEqual({ - ...domainMetadata, - status: 'idle', - }) - }) - - it('should allow overriding UI properties', () => { - const domainMetadata = createMockMediaMetadata() - const result = toUIMediaMetadata(domainMetadata, { status: 'loading' }) - - expect(result.status).toBe('loading') - }) - }) - - describe('mergeUIMetadata', () => { - it('should merge updates into existing UI metadata', () => { - const base = createMockUIMediaMetadata({ status: 'idle' }) - const updates = { - status: 'loading' as const, - tvShow: { ...baseTvShow(), name: 'Updated Show' }, - } - - const result = mergeUIMetadata(base, updates) - - expect(result.mediaFolderPath).toBe('/media/show1') // preserved - expect(result.type).toBe('tvshow-folder') // preserved - expect(result.status).toBe('loading') // updated - expect(result.tvShow?.name).toBe('Updated Show') // updated - }) - - it('should not mutate the original metadata', () => { - const base = createMockUIMediaMetadata({ status: 'idle' }) - const originalStatus = base.status - - mergeUIMetadata(base, { status: 'loading' }) - - expect(base.status).toBe(originalStatus) - }) - }) -}) diff --git a/apps/ui/src/lib/uiDomainMapper.ts b/apps/ui/src/lib/uiDomainMapper.ts deleted file mode 100644 index 81ca170a..00000000 --- a/apps/ui/src/lib/uiDomainMapper.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { MediaMetadata } from "@smm/types"; - -/** - * UiDomainMapper handles conversion between UI metadata and domain metadata. - * This ensures clean separation between UI-specific properties and domain data. - */ - -/** - * Determines if the domain metadata has changed compared to current metadata. - */ -export function hasDomainMetadataChanged( - current: MediaMetadata | undefined, - updated: MediaMetadata -): boolean { - if (!current) { - return true; - } - - for (const key of Object.keys(updated) as (keyof MediaMetadata)[]) { - const currentValue = current[key]; - const updatedValue = updated[key]; - - if (Array.isArray(currentValue) && Array.isArray(updatedValue)) { - if (JSON.stringify(currentValue) !== JSON.stringify(updatedValue)) { - return true; - } - } else if (currentValue !== updatedValue) { - return true; - } - } - - return false; -} - -/** @deprecated Identity helper; metadata cache stores domain data only. */ -export function extractPersistableMediaMetadata(metadata: MediaMetadata): MediaMetadata { - return metadata; -} - -/** @deprecated Use domain metadata directly; folder status lives in uiMediaFolderStore. */ -export function toUIMediaMetadata(domainMetadata: MediaMetadata): MediaMetadata { - return domainMetadata; -} - -export function mergeUIMetadata(base: MediaMetadata, updates: Partial): MediaMetadata { - return { - ...base, - ...updates, - }; -} diff --git a/apps/ui/src/providers/dialog-provider.tsx b/apps/ui/src/providers/dialog-provider.tsx index 34f0d98e..0d72278f 100644 --- a/apps/ui/src/providers/dialog-provider.tsx +++ b/apps/ui/src/providers/dialog-provider.tsx @@ -8,19 +8,14 @@ import { FilePickerDialog, DownloadVideoDialog, MediaSearchDialog, - RenameFileDialog, TextDialog, RenameFolderDialog, OpenFolderDialog, - UIScrapeDialog, - FormatConverterDialog, - VideoCompressionDialog, MediaFilePropertyDialog, ExecuteCmdDialog, AddTestBackgroundJobDialog, FunctionCheckDialog, LogDialog, - useScrapeDialog, type DialogConfig, type FolderType, type FileItem, @@ -62,10 +57,6 @@ interface DialogContextValue { openMediaSearch: (onSelect?: (tmdbId: number) => void) => void, closeMediaSearch: () => void ] - renameFileDialog: [ - openRenameFile: (onConfirm: (newName: string) => void, options?: { initialValue?: string; title?: string; description?: string; suggestions?: string[] }) => void, - closeRenameFile: () => void - ] textDialog: [ openTextDialog: (onConfirm: (text: string) => void, options?: { initialValue?: string; title?: string; description?: string; label?: string }) => void, closeTextDialog: () => void @@ -74,22 +65,10 @@ interface DialogContextValue { openRenameFolder: (mediaFolderPath: string, options?: { title?: string; description?: string }) => void, closeRenameFolder: () => void ] - scrapeDialog: [ - openScrape: (options?: { title?: string; description?: string; mediaMetadata?: import("@smm/types").MediaMetadata }) => void, - closeScrape: () => void - ] mediaFilePropertyDialog: [ openMediaFileProperty: (options: { filePath: string; track?: TrackProperties }) => void, closeMediaFileProperty: () => void ] - formatConverterDialog: [ - openFormatConverter: (track?: TrackProperties | string) => void, - closeFormatConverter: () => void - ] - videoCompressionDialog: [ - openVideoCompression: (input?: { filePath?: string; title?: string; duration?: number } | string) => void, - closeVideoCompression: () => void - ] executeCmdDialog: [ openExecuteCmd: (initialCommand?: ExecuteCmdType) => void, closeExecuteCmd: () => void @@ -148,11 +127,6 @@ export function DialogProvider({ children }: DialogProviderProps) { const [isMediaSearchOpen, setIsMediaSearchOpen] = useState(false) const [mediaSearchOnSelect, setMediaSearchOnSelect] = useState<((tmdbId: number) => void) | null>(null) - // Rename file dialog state - const [isRenameFileOpen, setIsRenameFileOpen] = useState(false) - const [renameFileOnConfirm, setRenameFileOnConfirm] = useState<((newName: string) => void) | null>(null) - const [renameFileOptions, setRenameFileOptions] = useState<{ initialValue?: string; title?: string; description?: string; suggestions?: string[] }>({}) - const [isTextDialogOpen, setIsTextDialogOpen] = useState(false) const [textDialogOnConfirm, setTextDialogOnConfirm] = useState<((text: string) => void) | null>(null) const [textDialogOptions, setTextDialogOptions] = useState<{ @@ -167,25 +141,11 @@ export function DialogProvider({ children }: DialogProviderProps) { const [renameFolderPath, setRenameFolderPath] = useState(null) const [renameFolderOptions, setRenameFolderOptions] = useState<{ title?: string; description?: string }>({}) - // Scrape dialog state - const [isScrapeOpen, setIsScrapeOpen] = useState(false) - const [scrapeOptions, setScrapeOptions] = useState<{ title?: string; description?: string; mediaMetadata?: import("@smm/types").MediaMetadata }>({}) - // Media file property dialog state const [isMediaFilePropertyOpen, setIsMediaFilePropertyOpen] = useState(false) const [mediaFilePropertyTrack, setMediaFilePropertyTrack] = useState(undefined) const [mediaFilePropertyPath, setMediaFilePropertyPath] = useState("") - // Format converter dialog state - const [isFormatConverterOpen, setIsFormatConverterOpen] = useState(false) - const [formatConverterTrack, setFormatConverterTrack] = useState(undefined) - - // Video compression dialog state - const [isVideoCompressionOpen, setIsVideoCompressionOpen] = useState(false) - const [videoCompressionFilePath, setVideoCompressionFilePath] = useState(undefined) - const [videoCompressionTitle, setVideoCompressionTitle] = useState(undefined) - const [videoCompressionDuration, setVideoCompressionDuration] = useState(undefined) - // Execute command dialog state const [isExecuteCmdOpen, setIsExecuteCmdOpen] = useState(false) const [executeCmdInitialCommand, setExecuteCmdInitialCommand] = useState(undefined) @@ -307,12 +267,6 @@ export function DialogProvider({ children }: DialogProviderProps) { }, 200) }, []) - const openRenameFile = useCallback((onConfirm: (newName: string) => void, options?: { initialValue?: string; title?: string; description?: string; suggestions?: string[] }) => { - setRenameFileOnConfirm(() => onConfirm) - setRenameFileOptions(options || {}) - setIsRenameFileOpen(true) - }, []) - const openRenameFolder = useCallback( (mediaFolderPath: string, options?: { title?: string; description?: string }) => { setRenameFolderPath(mediaFolderPath) @@ -322,14 +276,6 @@ export function DialogProvider({ children }: DialogProviderProps) { [] ) - const closeRenameFile = useCallback(() => { - setIsRenameFileOpen(false) - setTimeout(() => { - setRenameFileOnConfirm(null) - setRenameFileOptions({}) - }, 200) - }, []) - const openTextDialog = useCallback( ( onConfirm: (text: string) => void, @@ -366,32 +312,6 @@ export function DialogProvider({ children }: DialogProviderProps) { }, 200) }, []) - const handleRenameFileConfirm = useCallback( - (newName: string) => { - renameFileOnConfirm?.(newName) - closeRenameFile() - }, - [renameFileOnConfirm, closeRenameFile] - ) - - const openScrape = useCallback((options?: { title?: string; description?: string; mediaMetadata?: import("@smm/types").MediaMetadata }) => { - setScrapeOptions(options || {}) - setIsScrapeOpen(true) - }, []) - - const closeScrape = useCallback(() => { - setIsScrapeOpen(false) - setTimeout(() => { - setScrapeOptions({}) - }, 200) - }, []) - - const scrape = useScrapeDialog({ - isOpen: isScrapeOpen, - onClose: closeScrape, - mediaMetadata: scrapeOptions.mediaMetadata, - }) - const openMediaFileProperty = useCallback((options: { filePath: string; track?: TrackProperties }) => { setMediaFilePropertyPath(options.filePath) setMediaFilePropertyTrack(options.track) @@ -406,55 +326,6 @@ export function DialogProvider({ children }: DialogProviderProps) { }, 200) }, []) - const openFormatConverter = useCallback((trackOrPath?: TrackProperties | string) => { - const track: TrackProperties | undefined = - trackOrPath === undefined - ? undefined - : typeof trackOrPath === 'string' - ? { id: 0, path: trackOrPath, filePath: trackOrPath, title: '' } - : trackOrPath - setFormatConverterTrack(track) - setIsFormatConverterOpen(true) - }, []) - - const closeFormatConverter = useCallback(() => { - setIsFormatConverterOpen(false) - setTimeout(() => { - setFormatConverterTrack(undefined) - }, 200) - }, []) - - const openVideoCompression = useCallback( - (input?: { filePath?: string; title?: string; duration?: number } | string) => { - if (input === undefined) { - setVideoCompressionFilePath(undefined) - setVideoCompressionTitle(undefined) - setVideoCompressionDuration(undefined) - } else if (typeof input === "string") { - setVideoCompressionFilePath(input) - setVideoCompressionTitle(undefined) - setVideoCompressionDuration(undefined) - } else { - setVideoCompressionFilePath(input.filePath) - setVideoCompressionTitle(input.title) - setVideoCompressionDuration(input.duration) - } - setIsVideoCompressionOpen(true) - }, - [], - ) - - const closeVideoCompression = useCallback(() => { - setIsVideoCompressionOpen(false) - setTimeout(() => { - setVideoCompressionFilePath(undefined) - setVideoCompressionTitle(undefined) - setVideoCompressionDuration(undefined) - }, 200) - }, []) - - - const openExecuteCmd = useCallback((initialCommand?: ExecuteCmdType) => { setExecuteCmdInitialCommand(initialCommand) setIsExecuteCmdOpen(true) @@ -507,13 +378,9 @@ export function DialogProvider({ children }: DialogProviderProps) { filePickerDialog: [openFilePicker, closeFilePicker], downloadVideoDialog: [openDownloadVideo, closeDownloadVideo], mediaSearchDialog: [openMediaSearch, closeMediaSearch], - renameFileDialog: [openRenameFile, closeRenameFile], textDialog: [openTextDialog, closeTextDialog], renameFolderDialog: [openRenameFolder, closeRenameFolder], - scrapeDialog: [openScrape, closeScrape], mediaFilePropertyDialog: [openMediaFileProperty, closeMediaFileProperty], - formatConverterDialog: [openFormatConverter, closeFormatConverter], - videoCompressionDialog: [openVideoCompression, closeVideoCompression], executeCmdDialog: [openExecuteCmd, closeExecuteCmd], addTestBackgroundJobDialog: [openAddTestBackgroundJob, closeAddTestBackgroundJob], functionCheckDialog: [openFunctionCheck, closeFunctionCheck], @@ -563,15 +430,6 @@ export function DialogProvider({ children }: DialogProviderProps) { onClose={closeMediaSearch} onSelect={mediaSearchOnSelect || undefined} /> - )} - - setFormatConverterTrack(track)} - /> - { - setVideoCompressionFilePath(filePath) - }} - /> void + options?: RenameFileDialogOptions +} + From 175ab303efd7a47470ba941e1cf0fc417ce9f7b9 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 2 Sep 2026 23:54:43 +0800 Subject: [PATCH 07/83] refactor(media): export shared episode row utilities for layout split --- apps/ui/src/components/media/MediaFileTableRow.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/ui/src/components/media/MediaFileTableRow.tsx b/apps/ui/src/components/media/MediaFileTableRow.tsx index 10d4ff85..73b367ab 100644 --- a/apps/ui/src/components/media/MediaFileTableRow.tsx +++ b/apps/ui/src/components/media/MediaFileTableRow.tsx @@ -62,7 +62,7 @@ function resolveDisabled( return rule } -function getDisplayPath(fullPath: string, basePath: string | undefined): string { +export function getDisplayPath(fullPath: string, basePath: string | undefined): string { if (!basePath) return fullPath try { return relative(basePath, fullPath) @@ -71,7 +71,7 @@ function getDisplayPath(fullPath: string, basePath: string | undefined): string } } -function getThumbnailImageUrl(thumbnailPath: string, mediaFolderPath: string | undefined): string { +export function getThumbnailImageUrl(thumbnailPath: string, mediaFolderPath: string | undefined): string { const absolutePath = mediaFolderPath && !isAbsPath(thumbnailPath) ? join(mediaFolderPath, thumbnailPath) @@ -80,7 +80,7 @@ function getThumbnailImageUrl(thumbnailPath: string, mediaFolderPath: string | u return pathToFileURL(platformPath) } -function UICheckCell({ value }: { value: string | undefined }) { +export function UICheckCell({ value }: { value: string | undefined }) { const checked = value !== undefined if (checked) { return ( @@ -96,7 +96,7 @@ function UICheckCell({ value }: { value: string | undefined }) { ) } -function UIThumbnailImage({ +export function UIThumbnailImage({ thumbnailPath, mediaFolderPath, className = "max-h-[240px] w-auto rounded object-contain", @@ -109,7 +109,7 @@ function UIThumbnailImage({ return } -function getMediaFileTableRowKey( +export function getMediaFileTableRowKey( row: UIMediaFileFolderRow | UIMediaFileDataRow, index: number, ): string { @@ -141,7 +141,7 @@ export const MediaFileTableTr = forwardRef< ) }) -function withContextMenu( +export function withContextMenu( rowKey: string, row: R, items: Array<{ id: string; label: string; onClick?: (row: R) => void; disabled?: boolean | ((row: R) => boolean) }>, From 8228c26878e71c4384e81db23ab276fb7945f98f Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 2 Sep 2026 23:55:38 +0800 Subject: [PATCH 08/83] refactor(media): add MediaFileTableEpisodeSimpleRow component --- .../MediaFileTableEpisodeSimpleRow.tsx | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeSimpleRow.tsx diff --git a/apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeSimpleRow.tsx b/apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeSimpleRow.tsx new file mode 100644 index 00000000..093df5a2 --- /dev/null +++ b/apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeSimpleRow.tsx @@ -0,0 +1,129 @@ +import { cn } from "@/lib/utils" +import { Spinner } from "@/components/ui/spinner" +import { + MediaFileTableTr, + MediaFileTableRowCells, + withContextMenu, + UICheckCell, + getMediaFileTableRowKey, + getDisplayPath, + type MediaFileTableRowContext, +} from "../MediaFileTableRow" +import type { UIMediaFileDataRow } from "../UIMediaFileTable" + +function renderVideoContent( + ctx: MediaFileTableRowContext, + row: UIMediaFileDataRow, + isRowDisabled: boolean, +): React.ReactNode { + if (row.videoFile) { + if (ctx.preview === "rename" && !row.newVideoFile && ctx.isSelected(row)) { + return ( +
+ {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} +
+ ) + } + if ( + ctx.preview === "rename" && + row.newVideoFile && + row.videoFile !== row.newVideoFile + ) { + return ( +
+
+ {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} +
+
+ {getDisplayPath(row.newVideoFile, ctx.mediaFolderPath)} +
+
+ ) + } + return ( +
+ {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} +
+ ) + } + if (ctx.preview === "recognize") { + return ctx.previewStatus === "loading" ? ( + + + + ) : ( + + {ctx.t("mediaFileTable.unrecognizedVideoFile", { + defaultValue: "Cannot recognize video file", + })} + + ) + } + return - +} + +export function MediaFileTableEpisodeSimpleRow({ + ctx, + row, + index, +}: { + ctx: MediaFileTableRowContext + row: UIMediaFileDataRow + index: number +}) { + const isRowDisabled = row.disabled === true + const rowKey = getMediaFileTableRowKey(row, index) + + const inner = ( + ctx.onDoubleClick?.(row) : undefined} + > + { + if (isRowDisabled) return + ctx.onCheck?.(row, e.target.checked) + }} + /> + } + videoContent={renderVideoContent(ctx, row, isRowDisabled)} + thumbnailContent={} + subtitleContent={} + nfoContent={} + /> + + ) + + return withContextMenu( + rowKey, + row, + ctx.contextMenuConfig?.dataRowItems ?? [], + inner, + ) +} From fc487a4f7e570320175d14c70af0ef36882893a3 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 2 Sep 2026 23:57:19 +0800 Subject: [PATCH 09/83] refactor(media): add MediaFileTableEpisodeDetailRow component --- .../MediaFileTableEpisodeDetailRow.tsx | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeDetailRow.tsx diff --git a/apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeDetailRow.tsx b/apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeDetailRow.tsx new file mode 100644 index 00000000..aa8dbefc --- /dev/null +++ b/apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeDetailRow.tsx @@ -0,0 +1,129 @@ +import { cn } from "@/lib/utils" +import { + MediaFileTableTr, + MediaFileTableRowCells, + withContextMenu, + UICheckCell, + UIThumbnailImage, + getMediaFileTableRowKey, + getDisplayPath, + type MediaFileTableRowContext, +} from "../MediaFileTableRow" +import type { UIMediaFileDataRow } from "../UIMediaFileTable" + +function renderVideoContent( + ctx: MediaFileTableRowContext, + row: UIMediaFileDataRow, + isRowDisabled: boolean, +): React.ReactNode { + if (row.videoFile) { + if ( + ctx.preview === "rename" && + row.newVideoFile && + row.videoFile !== row.newVideoFile + ) { + return ( +
+
+ {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} +
+
+ {getDisplayPath(row.newVideoFile, ctx.mediaFolderPath)} +
+
+ ) + } + return ( +
+
+ {row.episodeTitle || + `S${String(row.season).padStart(2, "0")}E${String(row.episode).padStart(2, "0")}` || + "-"} +
+
+ {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} +
+
+ ) + } + return - +} + +function renderThumbnailContent( + ctx: MediaFileTableRowContext, + row: UIMediaFileDataRow, +): React.ReactNode { + return row.thumbnail ? ( + + ) : ( + - + ) +} + +export function MediaFileTableEpisodeDetailRow({ + ctx, + row, + index, +}: { + ctx: MediaFileTableRowContext + row: UIMediaFileDataRow + index: number +}) { + const isRowDisabled = row.disabled === true + const rowKey = getMediaFileTableRowKey(row, index) + + const inner = ( + ctx.onDoubleClick?.(row) : undefined} + > + { + if (isRowDisabled) return + ctx.onCheck?.(row, e.target.checked) + }} + /> + } + videoContent={renderVideoContent(ctx, row, isRowDisabled)} + thumbnailContent={renderThumbnailContent(ctx, row)} + subtitleContent={} + nfoContent={} + /> + + ) + + return withContextMenu( + rowKey, + row, + ctx.contextMenuConfig?.dataRowItems ?? [], + inner, + ) +} From c69cbfd76683288df80b3f79a002032e20dc3829 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 2 Sep 2026 23:59:27 +0800 Subject: [PATCH 10/83] refactor(media): add MediaFileTableEpisodePreviewRow component --- .../MediaFileTableEpisodePreviewRow.tsx | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 apps/ui/src/components/media/episodeRows/MediaFileTableEpisodePreviewRow.tsx diff --git a/apps/ui/src/components/media/episodeRows/MediaFileTableEpisodePreviewRow.tsx b/apps/ui/src/components/media/episodeRows/MediaFileTableEpisodePreviewRow.tsx new file mode 100644 index 00000000..82ed4d9e --- /dev/null +++ b/apps/ui/src/components/media/episodeRows/MediaFileTableEpisodePreviewRow.tsx @@ -0,0 +1,113 @@ +import { cn } from "@/lib/utils" +import { + MediaFileTableTr, + MediaFileTableRowCells, + withContextMenu, + UICheckCell, + UIThumbnailImage, + getMediaFileTableRowKey, + getDisplayPath, + type MediaFileTableRowContext, +} from "../MediaFileTableRow" +import type { UIMediaFileDataRow } from "../UIMediaFileTable" + +function renderVideoContent( + ctx: MediaFileTableRowContext, + row: UIMediaFileDataRow, + isRowDisabled: boolean, +): React.ReactNode { + return ( +
+
+ {`S${String(row.season).padStart(2, "0")}E${String(row.episode).padStart(2, "0")}`}{" "} + {row.episodeTitle ? `· ${row.episodeTitle}` : ""} +
+ {row.videoFile ? ( + <> +
+ {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} +
+ {!isRowDisabled && ctx.renderPreviewContent?.(row)} + + ) : ( + - + )} +
+ ) +} + +function renderThumbnailContent( + ctx: MediaFileTableRowContext, + row: UIMediaFileDataRow, +): React.ReactNode { + return row.thumbnail ? ( + + ) : ( + - + ) +} + +export function MediaFileTableEpisodePreviewRow({ + ctx, + row, + index, +}: { + ctx: MediaFileTableRowContext + row: UIMediaFileDataRow + index: number +}) { + const isRowDisabled = row.disabled === true + const rowKey = getMediaFileTableRowKey(row, index) + + const inner = ( + ctx.onDoubleClick?.(row) : undefined} + > + { + if (isRowDisabled) return + ctx.onCheck?.(row, e.target.checked) + }} + /> + } + videoContent={renderVideoContent(ctx, row, isRowDisabled)} + thumbnailContent={renderThumbnailContent(ctx, row)} + subtitleContent={} + nfoContent={} + /> + + ) + + return withContextMenu( + rowKey, + row, + ctx.contextMenuConfig?.dataRowItems ?? [], + inner, + ) +} From fa0fd557249f3cf8fbfa97509cb177cce645edca Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 3 Sep 2026 16:53:48 +0800 Subject: [PATCH 11/83] feat: pass metadataFiles to UIMediaFileTable and render MetadataFileTableNameValueRow per field --- .../src/components/media/MediaFileTable.tsx | 8 + ...MediaFileTableEpisodeSimpleRow.stories.tsx | 125 ++++ .../components/media/MediaFileTableRow.tsx | 584 +++++++++++++++++- .../src/components/media/UIMediaFileTable.tsx | 334 +++++++++- .../MediaFileTableEpisodeDetailRow.tsx | 129 ---- .../MediaFileTableEpisodePreviewRow.tsx | 113 ---- .../MediaFileTableEpisodeSimpleRow.tsx | 129 ---- .../media/mediaFileTableColumns.tsx | 2 +- apps/ui/src/components/tv/TvShowPanel.tsx | 54 ++ ...2-media-file-table-episode-layout-split.md | 575 +++++++++++++++++ packages/types/MetadataFiles.ts | 8 + refactoring.md | 205 ------ 12 files changed, 1685 insertions(+), 581 deletions(-) create mode 100644 apps/ui/src/components/media/MediaFileTableEpisodeSimpleRow.stories.tsx delete mode 100644 apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeDetailRow.tsx delete mode 100644 apps/ui/src/components/media/episodeRows/MediaFileTableEpisodePreviewRow.tsx delete mode 100644 apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeSimpleRow.tsx create mode 100644 docs/superpowers/plans/2026-09-02-media-file-table-episode-layout-split.md create mode 100644 packages/types/MetadataFiles.ts delete mode 100644 refactoring.md diff --git a/apps/ui/src/components/media/MediaFileTable.tsx b/apps/ui/src/components/media/MediaFileTable.tsx index 71c7268e..6cbe6c39 100644 --- a/apps/ui/src/components/media/MediaFileTable.tsx +++ b/apps/ui/src/components/media/MediaFileTable.tsx @@ -8,7 +8,9 @@ import type { UIMediaFileDataRow, UIMediaFileTableRow, UIMediaEpisodeSelection, + MediaFileTableSeasonData, } from "./UIMediaFileTable" +import type { MetadataFiles } from "@smm/types/MetadataFiles" import { useMediaFileTableController } from "./useMediaFileTableController" /** @@ -18,6 +20,8 @@ import { useMediaFileTableController } from "./useMediaFileTableController" * `MediaFileTable` owns the right-click menu and exposes only Open / Properties. */ export interface MediaFileTableProps { + seasonData?: MediaFileTableSeasonData[], + metadataFiles?: MetadataFiles, data: UIMediaFileTableRow[] /** When set, relative file paths are resolved against this base before opening. */ mediaFolderPath?: string @@ -69,6 +73,8 @@ export interface MediaFileTableProps { export function MediaFileTable(props: MediaFileTableProps) { const { data, + seasonData, + metadataFiles, mediaFolderPath, preview, previewStatus, @@ -120,6 +126,8 @@ export function MediaFileTable(props: MediaFileTableProps) { return ( + + + {hasCheckbox && } + + + + + + + + + +
+
+ ) +} + +// ------------------------------------------------------------------------ +// Meta +// ------------------------------------------------------------------------ + +const meta = { + title: "Components/MediaFileTableEpisodeSimpleRow", + component: SimpleRowTable, + args: { + season: 1, + episode: 1, + title: "Pilot", + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +// ------------------------------------------------------------------------ +// Scenarios +// ------------------------------------------------------------------------ + +/** Plain row with a linked file and all associated files present. */ +export const Default: Story = { + args: { + path: currentPath, + thumbnailPath, + subtitlePath, + nfoPath, + }, +} + +/** Row participating in a preview plan: checkbox column + checked state. */ +export const Checked: Story = { + args: { + path: currentPath, + thumbnailPath, + subtitlePath, + nfoPath, + isChecked: true, + onCheck: action("onCheck"), + }, +} + +/** Row that does not participate in the current plan: muted + disabled box. */ +export const Disabled: Story = { + args: { + path: currentPath, + thumbnailPath, + subtitlePath, + isDisabled: true, + onCheck: action("onCheck"), + }, +} + +/** Rename preview: current path struck through, rename target below. */ +export const PreviewRename: Story = { + args: { + path: currentPath, + newFilePath: renameTargetPath, + thumbnailPath, + subtitlePath, + nfoPath, + isChecked: true, + onCheck: action("onCheck"), + }, +} + +/** Recognize preview: currently linked path struck through, recognized file below. */ +export const PreviewRecognition: Story = { + args: { + path: currentPath, + newRecognizedFilePath: recognizedTargetPath, + thumbnailPath, + subtitlePath, + isChecked: true, + onCheck: action("onCheck"), + }, +} diff --git a/apps/ui/src/components/media/MediaFileTableRow.tsx b/apps/ui/src/components/media/MediaFileTableRow.tsx index 73b367ab..c88f3ca6 100644 --- a/apps/ui/src/components/media/MediaFileTableRow.tsx +++ b/apps/ui/src/components/media/MediaFileTableRow.tsx @@ -2,7 +2,7 @@ import { Spinner } from "@/components/ui/spinner" import { isAbsPath, join, relative } from "@/lib/path" import { Path } from "@smm/utils/path" import { pathToFileURL } from "@smm/utils/url" -import { TableBody } from "@/components/ui/table" +import { TableBody, TableCell } from "@/components/ui/table" import { ContextMenu, ContextMenuContent, @@ -15,6 +15,7 @@ import Image from "@/components/Image" import { cn } from "@/lib/utils" import { forwardRef, type ComponentProps, type ReactNode } from "react" import type { + MediaFileTableEpisodeData, UIMediaFileDataRow, UIMediaFileFolderRow, UIMediaFileTableContextMenuConfig, @@ -22,6 +23,7 @@ import type { import { MediaFileTableColGroup, MediaFileTableRowCells, + buildMediaFileTableColumnLayout, type MediaFileTableColumnLayout, } from "./mediaFileTableColumns" @@ -53,7 +55,7 @@ export type MediaFileTableBodyRow = | { row: UIMediaFileFolderRow; index: number } | { row: UIMediaFileDataRow; index: number } -function resolveDisabled( +function resolveDisabled( rule: boolean | ((row: R) => boolean) | undefined, row: R, ): boolean { @@ -171,7 +173,7 @@ export function withContextMenu + {value}} + thumbnailContent={} + subtitleContent={} + nfoContent={} + /> + + ) +} + +/** Simple layout (no checkbox, all icon columns) used by `MediaFileTableNameValueRow`. */ +const nameValueRowSimpleLayout: MediaFileTableColumnLayout = buildMediaFileTableColumnLayout({ + layout: "simple", + preview: undefined, + columnVisibility: { video: true, thumbnail: true, subtitle: true, nfo: true }, +}) + function renderEpisodeSimpleVideoContent( ctx: MediaFileTableRowContext, row: UIMediaFileDataRow, @@ -583,3 +633,531 @@ export function MediaFileTableSectionRows({ ) } + +/** + * A single item in an episode row's right-click menu. + * Callbacks receive the episode's data (`MediaFileTableEpisodeData`). + */ +export interface EpisodeContextMenuItem { + /** Unique id. */ + id: string + /** Display label (already translated). */ + label: string + /** Called on click. Falsy → the item is hidden. */ + onClick?: (episode: MediaFileTableEpisodeData) => void + /** Disabled state. Function form receives the episode for per-row logic. */ + disabled?: boolean | ((episode: MediaFileTableEpisodeData) => boolean) +} + +/** + * Wraps an episode row (e.g. a `MediaFileTableEpisodeSimpleRow`) with its + * right-click menu. + * + * Context-menu wrapper for the `MediaFileTableEpisodeData` data model (the + * deprecated `UIMediaFileDataRow`-based path keeps using `withContextMenu`). + * Returns the row unchanged when no item provides an `onClick`. + * + * NOTE (Radix): `ContextMenuTrigger` is used with `asChild`, so the child row + * element must forward the trigger props/ref it receives onto its DOM `` + * (that is what `MediaFileTableTr` exists for). `MediaFileTableEpisodeSimpleRow` + * forwards unknown props to its native ``, so it can be passed directly. + */ +export function EpisodeContextMenu({ + episode, + items = [], + children, +}: { + /** Episode data passed to item `onClick`/`disabled` callbacks. */ + episode: MediaFileTableEpisodeData + /** Right-click menu items. Omit for no context menu. */ + items?: EpisodeContextMenuItem[] + /** The rendered row element (must forward props to its DOM ``). */ + children: ReactNode +}) { + const hasMenu = items.some((item) => item.onClick) + if (!hasMenu) return <>{children} + + return ( + + {children} + + {items.map((item) => { + if (!item.onClick) return null + return ( + item.onClick?.(episode)} + > + {item.label} + + ) + })} + + + ) +} + +/** `S01E01`-style id shown in the ID column of a simple episode row. */ +function formatEpisodeId(season: number, episode: number): string { + return `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}` +} + +function renderSimpleRowVideoContent({ + path, + renameTarget, + recognizeTarget, + isDisabled, +}: { + path: string + /** Rename preview target (old path is struck through). */ + renameTarget?: string + /** Recognize preview target (currently linked path is struck through). */ + recognizeTarget?: string + isDisabled: boolean +}): ReactNode { + if (renameTarget !== undefined) { + return ( +
+
+ {path} +
+
+ {renameTarget} +
+
+ ) + } + + if (recognizeTarget !== undefined) { + return ( +
+
+ {path} +
+
+ {recognizeTarget} +
+
+ ) + } + + if (path !== "") { + return ( +
+ {path} +
+ ) + } + + return - +} + +export interface MediaFileTableEpisodeSimpleRowProps { + season: number, + episode: number, + title: string, + path: string, + /** Absolute path of the thumbnail file (shown on hover, if present). */ + thumbnailPath?: string, + /** Absolute path of the subtitle file (presence indicator only). */ + subtitlePath?: string, + /** Absolute path of the nfo file (presence indicator only). */ + nfoPath?: string, + isChecked?: boolean, + isDisabled?: boolean, + newFilePath?: string, + newRecognizedFilePath?: string + onCheck?: (isChecked: boolean) => void, + onDoubleClick?: () => void, +} + +/** + * Compact single-line row for the `simple` layout, rendered from plain data + * props (no `UIMediaFileDataRow`/layout context) so panels can feed it from + * season/episode data directly. + * + * Cell order is fixed and matches a simple-layout colgroup: + * `[checkbox] [SxxExx id] [video path] [thumbnail] [subtitle] [nfo]`. The + * checkbox column is only rendered while `onCheck` is provided (e.g. during a + * rename/recognize preview). Icon columns show a check/minus; hovering the + * thumbnail check opens an image preview of `thumbnailPath`. + * + * The video cell renders the old→new path pair while a preview target + * (`newFilePath`/`newRecognizedFilePath`) differs from `path`; otherwise it + * shows `path` (muted when `isDisabled`) or `-` when no file is linked. + * + * `title` is kept in the props API (episode data carries it) but is not + * rendered by this layout. + * + * Renders a native `` + `TableCell`s (no `MediaFileTableTr`/ + * `MediaFileTableRowCells`). Unknown props are forwarded to the ``, which + * lets `EpisodeContextMenu` attach a right-click menu via + * `ContextMenuTrigger asChild`. + */ +export function MediaFileTableEpisodeSimpleRow({ + season, + episode, + path, + thumbnailPath, + subtitlePath, + nfoPath, + isChecked = false, + isDisabled = false, + newFilePath = undefined, + newRecognizedFilePath = undefined, + onCheck = undefined, + onDoubleClick = undefined, + // `title` is part of the episode data API but is not rendered in this + // layout; alias it out so it does not leak onto the `` as a native + // tooltip via `...rowProps`. + title: _title, + ...rowProps +}: MediaFileTableEpisodeSimpleRowProps) { + const showCheckbox = onCheck !== undefined + + const renameTarget = + newFilePath !== undefined && newFilePath !== path ? newFilePath : undefined + const recognizeTarget = + renameTarget === undefined && + newRecognizedFilePath !== undefined && + newRecognizedFilePath !== path + ? newRecognizedFilePath + : undefined + + return ( + + {showCheckbox && ( + + { + if (isDisabled) return + onCheck?.(e.target.checked) + }} + /> + + )} + + {formatEpisodeId(season, episode)} + + + {renderSimpleRowVideoContent({ + path, + renameTarget, + recognizeTarget, + isDisabled, + })} + + + {thumbnailPath ? ( + + +
+ +
+
+ + + +
+ ) : ( + + )} +
+ + + + + + + + ) +} + +// ======================================================================== +// Detail & preview episode rows (new seasonData path) +// ======================================================================== + +/** Shared checkbox cell for the path-based episode rows. */ +function EpisodeRowCheckboxCell({ + isChecked, + isDisabled, + onCheck, +}: { + isChecked: boolean + isDisabled: boolean + onCheck?: (checked: boolean) => void +}) { + return ( + + { + if (isDisabled) return + onCheck?.(e.target.checked) + }} + /> + + ) +} + +export interface MediaFileTableEpisodeDetailRowProps { + season: number + episode: number + /** Episode title (e.g. from TMDB). Shown as the first video-cell line. */ + title: string + /** Video file path shown under the title. Empty → `-`. */ + path: string + /** Absolute path of the thumbnail file (rendered as a cover image). */ + thumbnailPath?: string + /** Absolute path of the subtitle file (presence indicator only). */ + subtitlePath?: string + /** Absolute path of the nfo file (presence indicator only). */ + nfoPath?: string + isChecked?: boolean + isDisabled?: boolean + onCheck?: (isChecked: boolean) => void + onDoubleClick?: () => void +} + +/** + * Row for the `detail` layout, rendered from plain data props (no + * `UIMediaFileDataRow`/layout context) so panels can feed it from + * season/episode data directly. + * + * Cell order is fixed and matches a detail-layout colgroup: + * `[checkbox] [SxxExx id] [cover thumbnail] [episode title + video path] + * [subtitle] [nfo]`. The checkbox column is only rendered while `onCheck` is + * provided. The thumbnail cell renders `thumbnailPath` as a cover image (or + * `-`); subtitle/nfo cells show presence icons. + * + * Renders a native `` + `TableCell`s (no `MediaFileTableTr`/ + * `MediaFileTableRowCells`). Unknown props are forwarded to the ``, which + * lets `EpisodeContextMenu` attach a right-click menu via + * `ContextMenuTrigger asChild`. + */ +export function MediaFileTableEpisodeDetailRow({ + season, + episode, + title, + path, + thumbnailPath, + subtitlePath, + nfoPath, + isChecked = false, + isDisabled = false, + onCheck = undefined, + onDoubleClick = undefined, + ...rowProps +}: MediaFileTableEpisodeDetailRowProps) { + return ( + + {onCheck !== undefined && ( + + )} + + {formatEpisodeId(season, episode)} + + + {thumbnailPath ? ( + + ) : ( + - + )} + + +
+
+ {title || formatEpisodeId(season, episode)} +
+ {path !== "" ? ( +
+ {path} +
+ ) : ( + - + )} +
+
+ + + + + + + + ) +} + +export interface MediaFileTableEpisodePreviewRowProps { + season: number + episode: number + /** Episode title (e.g. from TMDB). Shown after the `SxxExx` id. */ + title: string + /** Video file path shown under the id + title line. Empty → `-`. */ + path: string + /** Absolute path of the thumbnail file (rendered as a cover image). */ + thumbnailPath?: string + /** Absolute path of the subtitle file (presence indicator only). */ + subtitlePath?: string + /** Absolute path of the nfo file (presence indicator only). */ + nfoPath?: string + isChecked?: boolean + isDisabled?: boolean + onCheck?: (isChecked: boolean) => void + onDoubleClick?: () => void +} + +/** + * Row for the `preview` layout, rendered from plain data props (no + * `UIMediaFileDataRow`/layout context) so panels can feed it from + * season/episode data directly. + * + * Cell order is fixed and matches a preview-layout colgroup (no ID column): + * `[checkbox] [cover thumbnail] [SxxExx · title + video path] [subtitle] + * [nfo]`. The checkbox column is only rendered while `onCheck` is provided. + * The thumbnail cell renders `thumbnailPath` as a larger cover image (or + * `-`); subtitle/nfo cells show presence icons. + * + * Renders a native `` + `TableCell`s (no `MediaFileTableTr`/ + * `MediaFileTableRowCells`). Unknown props are forwarded to the ``, which + * lets `EpisodeContextMenu` attach a right-click menu via + * `ContextMenuTrigger asChild`. + */ +export function MediaFileTableEpisodePreviewRow({ + season, + episode, + title, + path, + thumbnailPath, + subtitlePath, + nfoPath, + isChecked = false, + isDisabled = false, + onCheck = undefined, + onDoubleClick = undefined, + ...rowProps +}: MediaFileTableEpisodePreviewRowProps) { + return ( + + {onCheck !== undefined && ( + + )} + + {thumbnailPath ? ( + + ) : ( + - + )} + + +
+
+ {formatEpisodeId(season, episode)} + {title ? ` · ${title}` : ""} +
+ {path !== "" ? ( +
+ {path} +
+ ) : ( + - + )} +
+
+ + + + + + + + ) +} diff --git a/apps/ui/src/components/media/UIMediaFileTable.tsx b/apps/ui/src/components/media/UIMediaFileTable.tsx index 3b11844e..4a0d968d 100644 --- a/apps/ui/src/components/media/UIMediaFileTable.tsx +++ b/apps/ui/src/components/media/UIMediaFileTable.tsx @@ -1,3 +1,4 @@ +import type { MetadataFiles } from "@smm/types/MetadataFiles" import { Table, TableBody, @@ -21,7 +22,15 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible" import { ChevronRightIcon } from "lucide-react" -import { useCallback, useState, useMemo, type ReactNode } from "react" +import { + cloneElement, + isValidElement, + useCallback, + useState, + useMemo, + type ReactElement, + type ReactNode, +} from "react" import { useTranslation } from "@/lib/i18n" import { cn } from "@/lib/utils" import { @@ -31,6 +40,12 @@ import { type MediaFileTableBodyRow, type MediaFileTableColumnKey, type MediaFileTableRowContext, + EpisodeContextMenu, + type EpisodeContextMenuItem, + MediaFileTableEpisodeSimpleRow, + MediaFileTableEpisodeDetailRow, + MediaFileTableEpisodePreviewRow, + MediaFileTableNameValueRow, } from "./MediaFileTableRow" import { buildMediaFileTableColumnLayout, @@ -61,6 +76,7 @@ export interface UIMediaEpisodeSelection { } /** + * @deprecated * A single playable file row (e.g. one TV episode or one movie). * * `season` and `episode` are kept on every data row for layout compatibility @@ -98,6 +114,20 @@ export interface UIMediaFileFolderRow { path: string } + +export interface MediaFileTableEpisodeData { + season: number, + episode: number, + title: string, + path?: string, +} + +export interface MediaFileTableSeasonData { + season: number, + title: string, + episodes: MediaFileTableEpisodeData[], +} + export type UIMediaFileTableRow = UIMediaFileDividerRow | UIMediaFileDataRow | UIMediaFileFolderRow // ======================================================================== @@ -137,6 +167,8 @@ export interface UIMediaFileTableContextMenuConfig { // ======================================================================== export interface UIMediaFileTableProps { + seasonData?: MediaFileTableSeasonData[], + metadataFiles?: MetadataFiles, data: UIMediaFileTableRow[] /** When set, paths are shown relative to this base. */ mediaFolderPath?: string @@ -308,6 +340,8 @@ function groupSegmentsForRender(segments: TableSegment[]): TableRenderBlock[] { export function UIMediaFileTable({ data, + metadataFiles, + seasonData = [], mediaFolderPath, contextMenuConfig, preview, @@ -430,6 +464,13 @@ export function UIMediaFileTable({ t: t as (key: string, options?: Record) => string, } + // Context menu items for the seasonData-driven episode rows. Built once per + // config from the (deprecated) UIMediaFileDataRow-based `dataRowItems`. + const episodeContextMenuItems = useMemo( + () => buildEpisodeContextMenuItems(contextMenuConfig), + [contextMenuConfig], + ) + // ── Render: header row with column-visibility context menu ──────────── const headerRow = ( @@ -548,6 +589,74 @@ export function UIMediaFileTable({ + + + + + + + {/* New path, will be the default in the future */} + { + layout === "simple" && seasonData.map((season) => { + const collapsibleId = `season-${season.season}` + const isCollapsed = collapsedIds.has(collapsibleId) + return ( + setSectionCollapsed(collapsibleId, !open)} + showCheckboxColumn={showCheckboxColumn} + visibleColumnCount={visibleColumnCount} + > + + + ) + }) + } + +{ + layout === "detail" && seasonData.map((season) => { + const collapsibleId = `season-${season.season}` + const isCollapsed = collapsedIds.has(collapsibleId) + return ( + setSectionCollapsed(collapsibleId, !open)} + showCheckboxColumn={showCheckboxColumn} + visibleColumnCount={visibleColumnCount} + > + + + ) + }) + } + + { + layout === "preview" && seasonData.map((season) => { + const collapsibleId = `season-${season.season}` + const isCollapsed = collapsedIds.has(collapsibleId) + return ( + setSectionCollapsed(collapsibleId, !open)} + showCheckboxColumn={showCheckboxColumn} + visibleColumnCount={visibleColumnCount} + > + + + ) + }) + } + + {/* Legacy path, will be removed in the future */} {renderBlocks.map((block, blockIndex) => { if (block.kind === "rows") { const nextBlock = renderBlocks[blockIndex + 1] @@ -616,3 +725,226 @@ export function UIMediaFileTable({ ) } + + +/** + * Collapsible season section for the `seasonData`-driven path: a header row + * (season title + collapse toggle) above the season content. + * + * The season content is supplied as `children`, e.g. + * ``. When the child is an + * `UIMediaFileTableEpisodeBlock`, this block's `items` are forwarded into it, + * so the caller can compose the episode rows as children while the episode + * menu items stay owned by the section. + */ +export function UIMediaFileTableSeasonBlock({ + season, + items = [], + isCollapsed, + onOpenChange, + showCheckboxColumn, + visibleColumnCount, + children, +}: { + season: MediaFileTableSeasonData + /** Right-click menu items for each episode row (forwarded to the episode block child). */ + items?: EpisodeContextMenuItem[] + /** Whether the section is currently collapsed (controlled by the table). */ + isCollapsed: boolean + /** Called with the new open state when the user toggles the section. */ + onOpenChange: (open: boolean) => void + showCheckboxColumn: boolean + visibleColumnCount: number + /** Content rendered below the season header (e.g. `UIMediaFileTableEpisodeBlock`). */ + children?: ReactNode +}) { + const { t } = useTranslation("components") + const expandLabel = t("mediaFileTable.expand") + const collapseLabel = t("mediaFileTable.collapse") + + // Compose the caller-supplied content. When it is one of the episode blocks + // (simple/detail/preview), forward this section's `items` so the per-row + // context menus keep working without the caller having to repeat the items + // on the child. + const content = + isValidElement(children) && + (children.type === UIMediaFileTableEpisodeBlock || + children.type === UIMediaFileTableEpisodeDetailBlock || + children.type === UIMediaFileTableEpisodePreviewBlock) + ? cloneElement(children as ReactElement, { + items, + }) + : children + + return ( + + + + {showCheckboxColumn && } + +
+ {season.title} + + + +
+
+
+ + + + {content} + + + +
+
+ ) +} + +/** Shared props of the season content blocks (`EpisodeBlock` / `EpisodeDetailBlock` / + * `EpisodePreviewBlock`) rendered inside `UIMediaFileTableSeasonBlock`. */ +export interface UIMediaFileTableEpisodeBlockProps { + season: MediaFileTableSeasonData + /** Right-click menu items for each episode row. */ + items?: EpisodeContextMenuItem[] +} + +export function UIMediaFileTableEpisodeBlock({ + season, + items = [], +}: UIMediaFileTableEpisodeBlockProps) { + return ( + + + {season.episodes.map((episode) => ( + + + + ))} + +
+ ) +} + +/** + * `detail`-layout season content block: one `MediaFileTableEpisodeDetailRow` + * per episode (id + cover thumbnail + title/path), each wrapped with its + * right-click menu. + */ +export function UIMediaFileTableEpisodeDetailBlock({ + season, + items = [], +}: UIMediaFileTableEpisodeBlockProps) { + return ( + + + {season.episodes.map((episode) => ( + + + + ))} + +
+ ) +} + +/** + * `preview`-layout season content block: one `MediaFileTableEpisodePreviewRow` + * per episode (larger cover + id·title/path, no ID column), each wrapped with + * its right-click menu. + */ +export function UIMediaFileTableEpisodePreviewBlock({ + season, + items = [], +}: UIMediaFileTableEpisodeBlockProps) { + return ( + + + {season.episodes.map((episode) => ( + + + + ))} + +
+ ) +} + +/** + * Adapts the deprecated `UIMediaFileDataRow`-based episode menu items + * (`UIMediaFileTableContextMenuConfig.dataRowItems`) to the new + * `MediaFileTableEpisodeData` model, bridging each episode to a + * `UIMediaFileDataRow` (`videoFile` → `path`) so the existing actions + * (Open / Properties / panel extras) keep working on seasonData-driven rows. + */ +function buildEpisodeContextMenuItems( + config: UIMediaFileTableContextMenuConfig | undefined, +): EpisodeContextMenuItem[] { + const dataRowItems = config?.dataRowItems + if (!dataRowItems) return [] + + const toDataRow = (episode: MediaFileTableEpisodeData): UIMediaFileDataRow => ({ + season: episode.season, + episode: episode.episode, + type: "episode", + videoFile: episode.path, + thumbnail: undefined, + subtitle: undefined, + nfo: undefined, + episodeTitle: episode.title, + }) + + return dataRowItems.map((item) => { + const disabledRule = item.disabled + return { + id: item.id, + label: item.label, + onClick: item.onClick + ? (episode: MediaFileTableEpisodeData) => item.onClick?.(toDataRow(episode)) + : undefined, + disabled: + typeof disabledRule === "function" + ? (episode: MediaFileTableEpisodeData) => disabledRule(toDataRow(episode)) + : disabledRule, + } + }) +} \ No newline at end of file diff --git a/apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeDetailRow.tsx b/apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeDetailRow.tsx deleted file mode 100644 index aa8dbefc..00000000 --- a/apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeDetailRow.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { cn } from "@/lib/utils" -import { - MediaFileTableTr, - MediaFileTableRowCells, - withContextMenu, - UICheckCell, - UIThumbnailImage, - getMediaFileTableRowKey, - getDisplayPath, - type MediaFileTableRowContext, -} from "../MediaFileTableRow" -import type { UIMediaFileDataRow } from "../UIMediaFileTable" - -function renderVideoContent( - ctx: MediaFileTableRowContext, - row: UIMediaFileDataRow, - isRowDisabled: boolean, -): React.ReactNode { - if (row.videoFile) { - if ( - ctx.preview === "rename" && - row.newVideoFile && - row.videoFile !== row.newVideoFile - ) { - return ( -
-
- {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} -
-
- {getDisplayPath(row.newVideoFile, ctx.mediaFolderPath)} -
-
- ) - } - return ( -
-
- {row.episodeTitle || - `S${String(row.season).padStart(2, "0")}E${String(row.episode).padStart(2, "0")}` || - "-"} -
-
- {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} -
-
- ) - } - return - -} - -function renderThumbnailContent( - ctx: MediaFileTableRowContext, - row: UIMediaFileDataRow, -): React.ReactNode { - return row.thumbnail ? ( - - ) : ( - - - ) -} - -export function MediaFileTableEpisodeDetailRow({ - ctx, - row, - index, -}: { - ctx: MediaFileTableRowContext - row: UIMediaFileDataRow - index: number -}) { - const isRowDisabled = row.disabled === true - const rowKey = getMediaFileTableRowKey(row, index) - - const inner = ( - ctx.onDoubleClick?.(row) : undefined} - > - { - if (isRowDisabled) return - ctx.onCheck?.(row, e.target.checked) - }} - /> - } - videoContent={renderVideoContent(ctx, row, isRowDisabled)} - thumbnailContent={renderThumbnailContent(ctx, row)} - subtitleContent={} - nfoContent={} - /> - - ) - - return withContextMenu( - rowKey, - row, - ctx.contextMenuConfig?.dataRowItems ?? [], - inner, - ) -} diff --git a/apps/ui/src/components/media/episodeRows/MediaFileTableEpisodePreviewRow.tsx b/apps/ui/src/components/media/episodeRows/MediaFileTableEpisodePreviewRow.tsx deleted file mode 100644 index 82ed4d9e..00000000 --- a/apps/ui/src/components/media/episodeRows/MediaFileTableEpisodePreviewRow.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { cn } from "@/lib/utils" -import { - MediaFileTableTr, - MediaFileTableRowCells, - withContextMenu, - UICheckCell, - UIThumbnailImage, - getMediaFileTableRowKey, - getDisplayPath, - type MediaFileTableRowContext, -} from "../MediaFileTableRow" -import type { UIMediaFileDataRow } from "../UIMediaFileTable" - -function renderVideoContent( - ctx: MediaFileTableRowContext, - row: UIMediaFileDataRow, - isRowDisabled: boolean, -): React.ReactNode { - return ( -
-
- {`S${String(row.season).padStart(2, "0")}E${String(row.episode).padStart(2, "0")}`}{" "} - {row.episodeTitle ? `· ${row.episodeTitle}` : ""} -
- {row.videoFile ? ( - <> -
- {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} -
- {!isRowDisabled && ctx.renderPreviewContent?.(row)} - - ) : ( - - - )} -
- ) -} - -function renderThumbnailContent( - ctx: MediaFileTableRowContext, - row: UIMediaFileDataRow, -): React.ReactNode { - return row.thumbnail ? ( - - ) : ( - - - ) -} - -export function MediaFileTableEpisodePreviewRow({ - ctx, - row, - index, -}: { - ctx: MediaFileTableRowContext - row: UIMediaFileDataRow - index: number -}) { - const isRowDisabled = row.disabled === true - const rowKey = getMediaFileTableRowKey(row, index) - - const inner = ( - ctx.onDoubleClick?.(row) : undefined} - > - { - if (isRowDisabled) return - ctx.onCheck?.(row, e.target.checked) - }} - /> - } - videoContent={renderVideoContent(ctx, row, isRowDisabled)} - thumbnailContent={renderThumbnailContent(ctx, row)} - subtitleContent={} - nfoContent={} - /> - - ) - - return withContextMenu( - rowKey, - row, - ctx.contextMenuConfig?.dataRowItems ?? [], - inner, - ) -} diff --git a/apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeSimpleRow.tsx b/apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeSimpleRow.tsx deleted file mode 100644 index 093df5a2..00000000 --- a/apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeSimpleRow.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { cn } from "@/lib/utils" -import { Spinner } from "@/components/ui/spinner" -import { - MediaFileTableTr, - MediaFileTableRowCells, - withContextMenu, - UICheckCell, - getMediaFileTableRowKey, - getDisplayPath, - type MediaFileTableRowContext, -} from "../MediaFileTableRow" -import type { UIMediaFileDataRow } from "../UIMediaFileTable" - -function renderVideoContent( - ctx: MediaFileTableRowContext, - row: UIMediaFileDataRow, - isRowDisabled: boolean, -): React.ReactNode { - if (row.videoFile) { - if (ctx.preview === "rename" && !row.newVideoFile && ctx.isSelected(row)) { - return ( -
- {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} -
- ) - } - if ( - ctx.preview === "rename" && - row.newVideoFile && - row.videoFile !== row.newVideoFile - ) { - return ( -
-
- {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} -
-
- {getDisplayPath(row.newVideoFile, ctx.mediaFolderPath)} -
-
- ) - } - return ( -
- {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} -
- ) - } - if (ctx.preview === "recognize") { - return ctx.previewStatus === "loading" ? ( - - - - ) : ( - - {ctx.t("mediaFileTable.unrecognizedVideoFile", { - defaultValue: "Cannot recognize video file", - })} - - ) - } - return - -} - -export function MediaFileTableEpisodeSimpleRow({ - ctx, - row, - index, -}: { - ctx: MediaFileTableRowContext - row: UIMediaFileDataRow - index: number -}) { - const isRowDisabled = row.disabled === true - const rowKey = getMediaFileTableRowKey(row, index) - - const inner = ( - ctx.onDoubleClick?.(row) : undefined} - > - { - if (isRowDisabled) return - ctx.onCheck?.(row, e.target.checked) - }} - /> - } - videoContent={renderVideoContent(ctx, row, isRowDisabled)} - thumbnailContent={} - subtitleContent={} - nfoContent={} - /> - - ) - - return withContextMenu( - rowKey, - row, - ctx.contextMenuConfig?.dataRowItems ?? [], - inner, - ) -} diff --git a/apps/ui/src/components/media/mediaFileTableColumns.tsx b/apps/ui/src/components/media/mediaFileTableColumns.tsx index 014c14c1..eca7cc54 100644 --- a/apps/ui/src/components/media/mediaFileTableColumns.tsx +++ b/apps/ui/src/components/media/mediaFileTableColumns.tsx @@ -63,7 +63,7 @@ const checkboxCellEmptyClassName = "w-10 shrink-0 px-0 py-1" const videoCellClassName = "max-w-px px-2 py-1" const iconCellClassName = "w-10 shrink-0 px-0 py-1 text-center" -function thumbnailCellClassName(layout: MediaFileTableColumnLayout): string { +export function thumbnailCellClassName(layout: MediaFileTableColumnLayout): string { return cn( layout.isPreviewLayout && "w-[160px] min-w-[160px] px-1 py-1 align-top", layout.layout === "detail" && diff --git a/apps/ui/src/components/tv/TvShowPanel.tsx b/apps/ui/src/components/tv/TvShowPanel.tsx index 3385c31c..57435c6d 100644 --- a/apps/ui/src/components/tv/TvShowPanel.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.tsx @@ -3,6 +3,7 @@ import { useMediaMetadataQuery } from "@/hooks/mediaMetadata" import { useSelectTvShowForFolderMutation } from "@/hooks/useSelectTvShowForFolderMutation" import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" import { useState, useEffect, useCallback, useMemo, useRef } from "react" +import type { MetadataFiles } from "@smm/types/MetadataFiles" import type { MediaMetadata } from "@/lib/mediaFolderFiles" import { useMediaFolderFilesQuery } from "@/hooks/useMediaFolderFilesQuery" import type { TMDBTVShow, TMDBTVShowDetails } from "@smm/types" @@ -27,6 +28,7 @@ import type { UIMediaFileDataRow, UIMediaFileTableRow, UIMediaEpisodeSelection, + MediaFileTableSeasonData, } from "@/components/media/UIMediaFileTable" import { useRenameVideoFileFlow } from "@/hooks/useRenameVideoFileFlow" import { TvShowPanelHeader } from "./TvShowPanelHeader" @@ -49,6 +51,33 @@ import { type TvShowAppPlanPromptContextValue, } from "./plans/TvShowAppPlanPromptContext" + +export function buildMediaFileTableSeasonData(m: MediaMetadata): MediaFileTableSeasonData[] { + + if(m.type === 'tvshow-folder' || m.type === 'movie-folder') { + const seasons: MediaFileTableSeasonData[] = m.tvShow?.seasons?.map(s => { + return { + season: s.season, + title: s.name, + episodes: s.episodes.map(e => { + return { + season: s.season, + episode: e.episode, + title: e.name, + path: m.mediaFiles?.find(f => f.seasonNumber === s.season && f.episodeNumber === e.episode)?.absolutePath, + } + }), + } + }) ?? []; + + return seasons; + } + + // Should NOT reach this line in normal case. + console.warn(`Unsupported media type: ${m.type}, returned dummy MediaFileTableSeasonData`) + return [] +} + function TvShowPanel() { const { t } = useTranslation(['components', 'errors']) const { folders, selectedFolder } = useUIMediaFolderStoreState() @@ -59,6 +88,8 @@ function TvShowPanel() { fetchStatus: mediaMetadataFetchStatus, } = useMediaMetadataQuery(selectedFolder || undefined) + + const uiFolderRow = useMemo( () => selectedFolder @@ -162,6 +193,10 @@ function TvShowPanel() { const { handleVideoCompressForRow } = useTvShowEpisodeVideoCompress(mediaMetadata) const { handleFormatConvertForRow } = useTvShowEpisodeFormatConvert(mediaMetadata) + const mediaFileTableSeasonData = useMemo(() => { + return !!mediaMetadata ? buildMediaFileTableSeasonData(mediaMetadata) : [] + }, [mediaMetadata]) + const subtitleFlow = useSubtitleFlow({ mediaMetadata, uiStatus, @@ -377,6 +412,23 @@ function TvShowPanel() { } }, [renameFlow, aiRenameFlow, aiRecognizeFlow, recognizeFlow]) + const metadataFiles: MetadataFiles = useMemo(() => { + + if(mediaMetadata === undefined) { + return {}; + } + + return { + nfoPath: folderFiles.find(f => f === `${mediaMetadata.mediaFolderPath}/tvshow.nfo`), + posterPath: folderFiles.find(f => f === `${mediaMetadata.mediaFolderPath}/poster.jpg`), + fanartPath: folderFiles.find(f => f === `${mediaMetadata.mediaFolderPath}/fanart.jpg`), + // TODO: support in the future + seasonPosters: [], + clearlogoPath: folderFiles.find(f => f === `${mediaMetadata.mediaFolderPath}/clearlogo.png`), + themePath: folderFiles.find(f => f === `${mediaMetadata.mediaFolderPath}/theme.mp3`), + } + }, [mediaMetadata, folderFiles]) + return (
@@ -407,6 +459,8 @@ function TvShowPanel() { ) : ( **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split `MediaFileTableEpisodeRow` into three layout-specific components (`MediaFileTableEpisodeSimpleRow`, `MediaFileTableEpisodeDetailRow`, `MediaFileTableEpisodePreviewRow`) to eliminate mixed layout HTML and improve maintainability. + +**Architecture:** Create three new component files in `apps/ui/src/components/media/episodeRows/`, each responsible for one layout. The existing `MediaFileTableRow.tsx` exports shared utilities (`MediaFileTableTr`, `withContextMenu`, `UICheckCell`, `UIThumbnailImage`, etc.) that the new components import. `MediaFileTableEpisodeRow` remains unchanged. + +**Tech Stack:** React 19, TypeScript, Tailwind CSS 4, Vitest + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|---------------| +| Create | `apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeSimpleRow.tsx` | Simple layout episode row | +| Create | `apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeDetailRow.tsx` | Detail layout episode row | +| Create | `apps/ui/src/components/media/episodeRows/MediaFileTableEpisodePreviewRow.tsx` | Preview layout episode row | +| Modify | `apps/ui/src/components/media/MediaFileTableRow.tsx` | Export shared utilities | + +--- + +## Task 1: Export shared utilities from MediaFileTableRow.tsx + +**Files:** +- Modify: `apps/ui/src/components/media/MediaFileTableRow.tsx` + +**Interfaces:** +- Consumes: None +- Produces: `MediaFileTableTr`, `withContextMenu`, `UICheckCell`, `UIThumbnailImage`, `getMediaFileTableRowKey`, `getDisplayPath`, `getThumbnailImageUrl` as named exports + +- [ ] **Step 1: Add `export` keyword to shared functions** + +In `MediaFileTableRow.tsx`, add `export` to these existing functions: + +```typescript +// Line 83: Add export +export function UICheckCell({ value }: { value: string | undefined }) { + +// Line 99: Add export +export function UIThumbnailImage({ + +// Line 112: Add export +export function getMediaFileTableRowKey( + +// Line 65: Add export +export function getDisplayPath(fullPath: string, basePath: string | undefined): string { + +// Line 74: Add export +export function getThumbnailImageUrl(thumbnailPath: string, mediaFolderPath: string | undefined): string { + +// Line 144: Add export +export function withContextMenu( +``` + +- [ ] **Step 2: Run typecheck** + +```bash +pnpm typecheck +``` + +Expected: PASS (adding exports doesn't break anything) + +- [ ] **Step 3: Commit** + +```bash +git add apps/ui/src/components/media/MediaFileTableRow.tsx +git commit -m "refactor(media): export shared episode row utilities for layout split" +``` + +--- + +## Task 2: Create MediaFileTableEpisodeSimpleRow + +**Files:** +- Create: `apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeSimpleRow.tsx` + +**Interfaces:** +- Consumes: `MediaFileTableRowContext`, `UIMediaFileDataRow` from `MediaFileTableRow.tsx` +- Produces: `MediaFileTableEpisodeSimpleRow` component (renders `` with simple layout cells) + +- [ ] **Step 1: Create the episodeRows directory** + +```bash +mkdir -p apps/ui/src/components/media/episodeRows +``` + +- [ ] **Step 2: Write MediaFileTableEpisodeSimpleRow.tsx** + +```typescript +import { cn } from "@/lib/utils" +import { Spinner } from "@/components/ui/spinner" +import { + MediaFileTableTr, + MediaFileTableRowCells, + withContextMenu, + UICheckCell, + getMediaFileTableRowKey, + getDisplayPath, + type MediaFileTableRowContext, +} from "../MediaFileTableRow" +import type { UIMediaFileDataRow } from "../UIMediaFileTable" + +function renderVideoContent( + ctx: MediaFileTableRowContext, + row: UIMediaFileDataRow, + isRowDisabled: boolean, +): React.ReactNode { + if (row.videoFile) { + if (ctx.preview === "rename" && !row.newVideoFile && ctx.isSelected(row)) { + return ( +
+ {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} +
+ ) + } + if ( + ctx.preview === "rename" && + row.newVideoFile && + row.videoFile !== row.newVideoFile + ) { + return ( +
+
+ {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} +
+
+ {getDisplayPath(row.newVideoFile, ctx.mediaFolderPath)} +
+
+ ) + } + return ( +
+ {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} +
+ ) + } + if (ctx.preview === "recognize") { + return ctx.previewStatus === "loading" ? ( + + + + ) : ( + + {ctx.t("mediaFileTable.unrecognizedVideoFile", { + defaultValue: "Cannot recognize video file", + })} + + ) + } + return - +} + +export function MediaFileTableEpisodeSimpleRow({ + ctx, + row, + index, +}: { + ctx: MediaFileTableRowContext + row: UIMediaFileDataRow + index: number +}) { + const isRowDisabled = row.disabled === true + const rowKey = getMediaFileTableRowKey(row, index) + + const inner = ( + ctx.onDoubleClick?.(row) : undefined} + > + { + if (isRowDisabled) return + ctx.onCheck?.(row, e.target.checked) + }} + /> + } + videoContent={renderVideoContent(ctx, row, isRowDisabled)} + thumbnailContent={} + subtitleContent={} + nfoContent={} + /> + + ) + + return withContextMenu( + rowKey, + row, + ctx.contextMenuConfig?.dataRowItems ?? [], + inner, + ) +} +``` + +- [ ] **Step 3: Run typecheck** + +```bash +pnpm typecheck +``` + +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeSimpleRow.tsx +git commit -m "refactor(media): add MediaFileTableEpisodeSimpleRow component" +``` + +--- + +## Task 3: Create MediaFileTableEpisodeDetailRow + +**Files:** +- Create: `apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeDetailRow.tsx` + +**Interfaces:** +- Consumes: `MediaFileTableRowContext`, `UIMediaFileDataRow` from `MediaFileTableRow.tsx` +- Produces: `MediaFileTableEpisodeDetailRow` component (renders `` with detail layout cells) + +- [ ] **Step 1: Write MediaFileTableEpisodeDetailRow.tsx** + +```typescript +import { cn } from "@/lib/utils" +import { + MediaFileTableTr, + MediaFileTableRowCells, + withContextMenu, + UICheckCell, + UIThumbnailImage, + getMediaFileTableRowKey, + getDisplayPath, + type MediaFileTableRowContext, +} from "../MediaFileTableRow" +import type { UIMediaFileDataRow } from "../UIMediaFileTable" + +function renderVideoContent( + ctx: MediaFileTableRowContext, + row: UIMediaFileDataRow, + isRowDisabled: boolean, +): React.ReactNode { + if (row.videoFile) { + if ( + ctx.preview === "rename" && + row.newVideoFile && + row.videoFile !== row.newVideoFile + ) { + return ( +
+
+ {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} +
+
+ {getDisplayPath(row.newVideoFile, ctx.mediaFolderPath)} +
+
+ ) + } + return ( +
+
+ {row.episodeTitle || + `S${String(row.season).padStart(2, "0")}E${String(row.episode).padStart(2, "0")}` || + "-"} +
+
+ {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} +
+
+ ) + } + return - +} + +function renderThumbnailContent( + ctx: MediaFileTableRowContext, + row: UIMediaFileDataRow, +): React.ReactNode { + return row.thumbnail ? ( + + ) : ( + - + ) +} + +export function MediaFileTableEpisodeDetailRow({ + ctx, + row, + index, +}: { + ctx: MediaFileTableRowContext + row: UIMediaFileDataRow + index: number +}) { + const isRowDisabled = row.disabled === true + const rowKey = getMediaFileTableRowKey(row, index) + + const inner = ( + ctx.onDoubleClick?.(row) : undefined} + > + { + if (isRowDisabled) return + ctx.onCheck?.(row, e.target.checked) + }} + /> + } + videoContent={renderVideoContent(ctx, row, isRowDisabled)} + thumbnailContent={renderThumbnailContent(ctx, row)} + subtitleContent={} + nfoContent={} + /> + + ) + + return withContextMenu( + rowKey, + row, + ctx.contextMenuConfig?.dataRowItems ?? [], + inner, + ) +} +``` + +- [ ] **Step 2: Run typecheck** + +```bash +pnpm typecheck +``` + +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add apps/ui/src/components/media/episodeRows/MediaFileTableEpisodeDetailRow.tsx +git commit -m "refactor(media): add MediaFileTableEpisodeDetailRow component" +``` + +--- + +## Task 4: Create MediaFileTableEpisodePreviewRow + +**Files:** +- Create: `apps/ui/src/components/media/episodeRows/MediaFileTableEpisodePreviewRow.tsx` + +**Interfaces:** +- Consumes: `MediaFileTableRowContext`, `UIMediaFileDataRow` from `MediaFileTableRow.tsx` +- Produces: `MediaFileTableEpisodePreviewRow` component (renders `` with preview layout cells) + +- [ ] **Step 1: Write MediaFileTableEpisodePreviewRow.tsx** + +```typescript +import { cn } from "@/lib/utils" +import { + MediaFileTableTr, + MediaFileTableRowCells, + withContextMenu, + UICheckCell, + UIThumbnailImage, + getMediaFileTableRowKey, + getDisplayPath, + type MediaFileTableRowContext, +} from "../MediaFileTableRow" +import type { UIMediaFileDataRow } from "../UIMediaFileTable" + +function renderVideoContent( + ctx: MediaFileTableRowContext, + row: UIMediaFileDataRow, + isRowDisabled: boolean, +): React.ReactNode { + return ( +
+
+ {`S${String(row.season).padStart(2, "0")}E${String(row.episode).padStart(2, "0")}`}{" "} + {row.episodeTitle ? `· ${row.episodeTitle}` : ""} +
+ {row.videoFile ? ( + <> +
+ {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} +
+ {!isRowDisabled && ctx.renderPreviewContent?.(row)} + + ) : ( + - + )} +
+ ) +} + +function renderThumbnailContent( + ctx: MediaFileTableRowContext, + row: UIMediaFileDataRow, +): React.ReactNode { + return row.thumbnail ? ( + + ) : ( + - + ) +} + +export function MediaFileTableEpisodePreviewRow({ + ctx, + row, + index, +}: { + ctx: MediaFileTableRowContext + row: UIMediaFileDataRow + index: number +}) { + const isRowDisabled = row.disabled === true + const rowKey = getMediaFileTableRowKey(row, index) + + const inner = ( + ctx.onDoubleClick?.(row) : undefined} + > + { + if (isRowDisabled) return + ctx.onCheck?.(row, e.target.checked) + }} + /> + } + videoContent={renderVideoContent(ctx, row, isRowDisabled)} + thumbnailContent={renderThumbnailContent(ctx, row)} + subtitleContent={} + nfoContent={} + /> + + ) + + return withContextMenu( + rowKey, + row, + ctx.contextMenuConfig?.dataRowItems ?? [], + inner, + ) +} +``` + +- [ ] **Step 2: Run typecheck** + +```bash +pnpm typecheck +``` + +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add apps/ui/src/components/media/episodeRows/MediaFileTableEpisodePreviewRow.tsx +git commit -m "refactor(media): add MediaFileTableEpisodePreviewRow component" +``` + +--- + +## Task 5: Verify full build and run tests + +- [ ] **Step 1: Run typecheck** + +```bash +pnpm typecheck +``` + +Expected: PASS + +- [ ] **Step 2: Run UI tests** + +```bash +pnpm test:ui +``` + +Expected: PASS (existing tests should still pass since `MediaFileTableEpisodeRow` is unchanged) + +- [ ] **Step 3: Run build** + +```bash +pnpm build +``` + +Expected: PASS + +- [ ] **Step 4: Final commit if any fixes needed** + +```bash +git add -A +git commit -m "refactor(media): verify episode row layout split builds and passes tests" +``` diff --git a/packages/types/MetadataFiles.ts b/packages/types/MetadataFiles.ts new file mode 100644 index 00000000..f1e6f6a4 --- /dev/null +++ b/packages/types/MetadataFiles.ts @@ -0,0 +1,8 @@ +export interface MetadataFiles { + nfoPath?: string + posterPath?: string + fanartPath?: string + seasonPosters: { season: number, path: string }[] + clearlogoPath?: string + themePath?: string +} \ No newline at end of file diff --git a/refactoring.md b/refactoring.md deleted file mode 100644 index ff2f0001..00000000 --- a/refactoring.md +++ /dev/null @@ -1,205 +0,0 @@ -# Refatoring of 3-layers app - -Current implementation mix business logic into UI layer, makes it very different to test and extend. - -This refactoring aims to extract the core logic into below model: - -``` -Layer 1: Web UI, AI tools, MCP tools, external HTTP API -Layer 2: Core -Layer 3: internal HTTP API -``` - -Layer 1 is presentation layer which holds the UI, don't care of any business logic. -Using TanStack Query and socket.io to sync states - -Layer 2 is headless business logic layer built in TypeScript. -Use adapter to support both browser runtime or node.js runtime(Electron or Node.js) - -Layer 3 is infrastructure layer to support basic function like fs or logging. - - -## 目标架构 - -三层的依赖方向自上而下:**Layer 1 → Layer 2 → Layer 3**。上层依赖下层的接口,下层不感知上层的存在。 - -每一层只关心一件事: - -| 层 | 名称 | 关心的问题 | -|----|------|-----------| -| Layer 1 | 表现层 (Presentation) | 如何与用户 / AI / 外部系统交互 | -| Layer 2 | 核心层 (Core) | 业务规则与流程是什么 | -| Layer 3 | 基础设施层 (Infrastructure) | 在具体宿主机上如何做到 fs / logging / 进程 / 网络 | - -### Layer 1: 表现层 (Presentation) - -**职责**:捕捉用户 / AI / 外部系统的意图,把它翻译成 Core 命令;渲染 Core 暴露的状态。不含任何业务编排、识别、重命名、文件扫描逻辑。 - -**内容清单**: -- **Web UI** (`apps/ui`):组件、hooks、Zustand(仅保存纯 UI 状态,如选中项、对话框开关)、TanStack Query(服务端状态缓存)、API client -- **AI tools / MCP**:把 LLM 的工具调用翻译成 Core 命令,并渲染工具返回结果 -- **External HTTP API**:面向应用外部的公开契约,委托给 Core -- **Socket.IO 客户端**:订阅 Core 推送的状态变化 - -**状态同步**(Layer 1 获取状态的两个手段,二者配合): -- **拉取 (pull)**:TanStack Query 定时轮询状态查询接口(如每 1s 轮询一次初始化状态) -- **推送 (push)**:Socket.IO 事件使对应 query key 失效 / 直接写入缓存,立即触发重渲染 - -### Layer 2: 核心层 (Core) - -**职责**:全部业务逻辑,以 headless TypeScript 实现,与运行时和框架完全解耦。 - -**内容清单**: -- **领域模型**:MediaFolder、MediaMetadata、Episode、RenameOperation、Plan、Job 等 -- **用例 (use-cases) / 编排**:初始化媒体文件夹、识别媒体文件夹、识别剧集、重命名计划、下载 / 转码任务编排 -- **纯逻辑**:识别流水线 (recognition pipeline)、重命名规则 (rename rules)、NFO 解析、剧集匹配算法 -- **Ports(端口)定义**:`fs`、`logger`、`subprocess`、`env`、`network`、`clock`、`db` —— 只声明接口,不实现 -- **Domain events**:业务状态变化以事件形式广播,供表现层订阅 - -**约束**: -- 不 import 任何平台 API(`node:fs`、`process`、`window`、`navigator`…),只能通过注入的适配器访问能力 -- 框架无关:不依赖 Hono / Express / React / Zod 之外的运行时框架 -- 可单测:给定输入 + mock 适配器,断言用例输出与副作用调用 -- 可运行在任何宿主:Node.js / Bun 后端、Electron 主进程、浏览器 Web Worker - -### Layer 3: 基础设施层 (Infrastructure) - -**职责**:在具体宿主机上实现 Core 声明的 Ports,并承载统一的应用内部 HTTP 服务。 - -**内容清单**: -- **Ports 的具体实现**:文件系统访问、pino 日志、yt-dlp / ffmpeg 子进程、TMDB / TVDB 网络请求、数据库 -- **Internal HTTP API**:应用内部命令入口与状态查询的统一 HTTP 服务(即当前 `packages/core-routes` 与 `apps/cli` 的路由) -- **Socket.IO 服务端**:向表现层推送状态 -- **宿主进程**:`apps/cli`(独立后端)、`apps/electron`(主进程内嵌)、`apps/ohos` - -**Internal vs External HTTP API 的区别**: -- **External HTTP API**(Layer 1 表现面之一)面向应用之外,是公开契约 -- **Internal HTTP API**(Layer 3)面向应用之内,是 UI、MCP、外部 API 触发 Core 与访问基础设施的统一通道。两者可能共享同一物理服务器,区别在于用途与暴露范围 - -## 通信模型 - -### 命令流(command) - -``` -Web UI / AI(MCP) / External API - │ HTTP POST /api/... - ▼ -Internal HTTP API (Layer 3) - │ 委托用例 - ▼ -Core use-case (Layer 2) - │ 通过注入的适配器 - ▼ -Infrastructure: fs / logging / subprocess / network / db -``` - -### 状态流(state) - -``` -Core 用例变更状态 - │ 持久化 + 广播 domain event - ▼ -Internal HTTP API 持久化到磁盘 / db - │ - ├─ Socket.IO push(立即失效 query 缓存) - └─ GET 状态接口(供 TanStack Query 轮询兜底) - ▼ -Web UI 重渲染 -``` - -## 示例: 媒体文件夹初始化 - -媒体文件夹初始化是当前实现最典型的反面教材。 - -`apps\ui\src\hooks\initialization\useInitializeImportedMediaFolder.ts` 把整套初始化流程(更新用户配置、识别媒体文件夹、识别剧集、保存元数据、任务状态管理)全部实现于 UI 侧,使得该流程几乎不可能被外部 HTTP API 或 MCP 用户触发,也难以单元测试。 - -**Current Implementation** -1. User import folder -2. UI update user config file -3. UI start initialization process -4. UI update UI states accordingly - -**Target Implementation** -1. User import folder -2. UI call "POST /api/importFolder" -3. Core (sitting in backend side) update user config file -4. Core start initialization process -5. UI sycn initialization state by TanStack Query (fetch state every 1s) - -**Target 详细时序** - -``` -UI Internal HTTP API Core Infrastructure - │ POST /api/importFolder │ │ │ - ├───────────────────────────────►│ │ │ - │ │ importFolder(folder) │ │ - │ ├────────────────────────►│ │ - │ │ │ fs.write(userConfig) │ - │ │ ├────────────────────────────────►│ - │ │ │ 识别流水线 (tvshow/movie/music) │ - │ │ ├────────────────────────────────►│ (TMDB/TVDB/NFO/ffprobe) - │ │ │ metadata 持久化 + 广播事件 │ - │ │ ├────────────────────────────────►│ - │ │ socket.io: folder.status=initializing/ok ◄───────────┤ - │ ◄─────────────────────────────┤ │ │ - │ GET /api/mediaFolder/:path/status (每 1s 轮询兜底) │ │ - │ ├─────────────────────────────►│ │ │ - │ ◄─────────────────────────────┤ │ │ - │ TanStack Query 更新 → 重渲染 │ │ │ -``` - -**现状代码位置 vs 目标代码位置** - -| 逻辑 | 现状 | 目标 | -|------|------|------| -| 初始化编排 | `apps/ui/src/hooks/initialization/useInitializeImportedMediaFolder.ts` | Layer 2 (Core use-case) | -| 识别流水线 | `apps/ui/src/lib/mediaFolderRecognitionPipeline.ts` | Layer 2 | -| 识别媒体文件夹 | `apps/ui/src/lib/recognizeMediaFolder.ts`、`recognizeMediaFolderByTmdbIdInFolderName.ts`、`recognizeMediaFolderByTvdbIdInFolderName.ts`、`tryToRecognizeMediaFolderBySearchingFolderNameInTMDB.ts`、`tryToRecognizeMediaFolderBySearchingFolderNameInTVDB.ts` | Layer 2 | -| 识别剧集 | `apps/ui/src/lib/recognizeEpisodes.ts` | Layer 2 | -| NFO 解析 | `apps/ui/src/lib/nfo.ts` | Layer 2 | -| 重命名规则 | `apps/ui/src/lib/renameRules.ts` | Layer 2 | -| 任务工厂 | `apps/ui/src/lib/*JobFactory.ts` | Layer 2 | -| 音乐目录初始化 | `apps/ui/src/lib/initializeMusicFolder.ts`、`music.ts` | Layer 2 | -| 状态同步 | UI Zustand + TanStack Query | 不变(表现层职责) | -| fs / 子进程 / 日志 | `apps/cli` + `packages/core-routes` | Layer 3(host 适配器) | - -## 与现有 monorepo 的映射 - -| 现有包 / 应用 | 目标层 | 演进方向 | -|---------------|--------|----------| -| `packages/core` | Layer 2 | 扩展为 headless 业务逻辑层:引入 Ports 定义,新增 use-cases 与编排,沉淀领域模型 | -| `packages/core-routes` | Layer 3 | 已是框架无关的通用 HTTP 路由(含 auth / allowlist / socket.io),作为 Internal HTTP API 的核心 | -| `apps/cli` | Layer 3 宿主 | 独立后端:提供基础设施实现(fs / yt-dlp / ffmpeg / pino)+ MCP 服务 + Socket.IO | -| `apps/electron` | Layer 3 宿主 | 主进程内嵌 Core + Core-routes,提供 Node.js 适配器 | -| `apps/ohos` | Layer 3 宿主 | 提供 HarmonyOS 适配器 | -| `apps/ui` | Layer 1 | 只保留表现层:组件、hooks、Zustand(纯 UI 状态)、TanStack Query、API client | -| MCP(`apps/cli/src/mcp`、`packages/core-routes/src/mcp`) | Layer 1 表现面 | 将 LLM 工具调用翻译成 Core 命令,不再内联业务逻辑 | - -## 迁移路径 - -分四阶段,每阶段保持可构建、可测试。 - -**Phase 1: 提取纯逻辑** -- 将 `apps/ui/src/lib` 中无 React 依赖的纯逻辑(nfo、renameRules、recognizeEpisodes、识别流水线等)移入 `packages/core` -- 在 core 中定义 Ports 接口(fs / logger / subprocess / network / db) -- 为纯逻辑补齐单元测试(core 已有 vitest) - -**Phase 2: 编排用例下沉** -- 初始化 / 识别 / 重命名 / 任务编排作为 use-cases 移入 core,依赖注入适配器 -- UI hooks 改为调用 HTTP API + TanStack Query,删除本地编排代码 - -**Phase 3: Internal HTTP API 补齐** -- 新增 `POST /api/importFolder` 等命令路由,将 HTTP 请求映射到 Core 用例 -- 状态通过 Socket.IO 推送 + 轮询查询接口暴露 - -**Phase 4: 宿主适配器收敛** -- 各宿主(cli / electron / ohos)只保留基础设施实现与启动代码 -- UI 不再包含任何业务逻辑,只剩表现层 - -## 测试策略 - -| 层 | 测试方式 | 说明 | -|----|----------|------| -| Layer 2 (core) | 单元测试 (vitest) | mock 适配器,断言用例输出与副作用调用 | -| Layer 3 | 路由级集成测试 | 现有 `*.test.ts` 风格,验证 HTTP 契约与 auth / allowlist | -| Layer 1 | 组件测试 + e2e | `apps/e2e` (WebdriverIO) 保持不变,验证端到端行为 | From 444cde89dd90828f738a31eb139778ea6a466d13 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 3 Sep 2026 17:09:33 +0800 Subject: [PATCH 12/83] feat: support to display metadata files --- .../src/components/media/UIMediaFileTable.tsx | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/ui/src/components/media/UIMediaFileTable.tsx b/apps/ui/src/components/media/UIMediaFileTable.tsx index 4a0d968d..eabf0518 100644 --- a/apps/ui/src/components/media/UIMediaFileTable.tsx +++ b/apps/ui/src/components/media/UIMediaFileTable.tsx @@ -1,4 +1,5 @@ import type { MetadataFiles } from "@smm/types/MetadataFiles" +import path from 'path-browserify'; import { Table, TableBody, @@ -51,6 +52,7 @@ import { buildMediaFileTableColumnLayout, MediaFileTableColGroup, } from "./mediaFileTableColumns" +import { Path } from "@smm/utils/path"; // ======================================================================== // Row types @@ -338,6 +340,18 @@ function groupSegmentsForRender(segments: TableSegment[]): TableRenderBlock[] { // Main component // ======================================================================== + + +function rel(folder?: string, file?: string): string { + + if(folder === undefined || file === undefined) { + return ''; + } + + // TODO: check if Windows UNC supported + return path.relative(Path.posix(folder), Path.posix(file)) +} + export function UIMediaFileTable({ data, metadataFiles, @@ -542,6 +556,8 @@ export function UIMediaFileTable({ ) + + return (
@@ -589,11 +605,11 @@ export function UIMediaFileTable({ - - - - - + + + + + {/* New path, will be the default in the future */} { From 6271a1d563a8c7f26cfe11db9a2194d7c90617c9 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 3 Sep 2026 17:21:54 +0800 Subject: [PATCH 13/83] feat: display relative path for episode path --- .../src/components/media/UIMediaFileTable.tsx | 30 ++++++++----------- apps/ui/src/lib/path.ts | 7 +++++ 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/apps/ui/src/components/media/UIMediaFileTable.tsx b/apps/ui/src/components/media/UIMediaFileTable.tsx index eabf0518..8ab02df2 100644 --- a/apps/ui/src/components/media/UIMediaFileTable.tsx +++ b/apps/ui/src/components/media/UIMediaFileTable.tsx @@ -1,5 +1,4 @@ import type { MetadataFiles } from "@smm/types/MetadataFiles" -import path from 'path-browserify'; import { Table, TableBody, @@ -52,7 +51,7 @@ import { buildMediaFileTableColumnLayout, MediaFileTableColGroup, } from "./mediaFileTableColumns" -import { Path } from "@smm/utils/path"; +import { rel } from "@/lib/path"; // ======================================================================== // Row types @@ -342,16 +341,6 @@ function groupSegmentsForRender(segments: TableSegment[]): TableRenderBlock[] { -function rel(folder?: string, file?: string): string { - - if(folder === undefined || file === undefined) { - return ''; - } - - // TODO: check if Windows UNC supported - return path.relative(Path.posix(folder), Path.posix(file)) -} - export function UIMediaFileTable({ data, metadataFiles, @@ -626,7 +615,7 @@ export function UIMediaFileTable({ showCheckboxColumn={showCheckboxColumn} visibleColumnCount={visibleColumnCount} > - + ) }) @@ -646,7 +635,7 @@ export function UIMediaFileTable({ showCheckboxColumn={showCheckboxColumn} visibleColumnCount={visibleColumnCount} > - + ) }) @@ -666,7 +655,7 @@ export function UIMediaFileTable({ showCheckboxColumn={showCheckboxColumn} visibleColumnCount={visibleColumnCount} > - + ) }) @@ -835,11 +824,14 @@ export interface UIMediaFileTableEpisodeBlockProps { season: MediaFileTableSeasonData /** Right-click menu items for each episode row. */ items?: EpisodeContextMenuItem[] + /** When set, paths are shown relative to this base. */ + mediaFolderPath?: string } export function UIMediaFileTableEpisodeBlock({ season, items = [], + mediaFolderPath, }: UIMediaFileTableEpisodeBlockProps) { return (
@@ -854,7 +846,7 @@ export function UIMediaFileTableEpisodeBlock({ season={season.season} episode={episode.episode} title={episode.title} - path={episode.path ?? ""} + path={rel(mediaFolderPath, episode.path) || (episode.path ?? "")} /> ))} @@ -871,6 +863,7 @@ export function UIMediaFileTableEpisodeBlock({ export function UIMediaFileTableEpisodeDetailBlock({ season, items = [], + mediaFolderPath, }: UIMediaFileTableEpisodeBlockProps) { return (
@@ -885,7 +878,7 @@ export function UIMediaFileTableEpisodeDetailBlock({ season={season.season} episode={episode.episode} title={episode.title} - path={episode.path ?? ""} + path={rel(mediaFolderPath, episode.path) || (episode.path ?? "")} /> ))} @@ -902,6 +895,7 @@ export function UIMediaFileTableEpisodeDetailBlock({ export function UIMediaFileTableEpisodePreviewBlock({ season, items = [], + mediaFolderPath, }: UIMediaFileTableEpisodeBlockProps) { return (
@@ -916,7 +910,7 @@ export function UIMediaFileTableEpisodePreviewBlock({ season={season.season} episode={episode.episode} title={episode.title} - path={episode.path ?? ""} + path={rel(mediaFolderPath, episode.path) || (episode.path ?? "")} /> ))} diff --git a/apps/ui/src/lib/path.ts b/apps/ui/src/lib/path.ts index 18240265..a4ff5b9e 100644 --- a/apps/ui/src/lib/path.ts +++ b/apps/ui/src/lib/path.ts @@ -79,6 +79,13 @@ export function relative(from: string, to: string) { } +export function rel(folder?: string, file?: string): string { + if (folder === undefined || file === undefined) { + return ''; + } + return relative(folder, file); +} + /** * * @param path From 9430eb9274a2df081871a9abc10ab0ec927776a5 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 3 Sep 2026 22:01:11 +0800 Subject: [PATCH 14/83] refactor: enhance code to query associated files --- .../components/media/MediaFileTable.test.tsx | 91 +++----- .../src/components/media/MediaFileTable.tsx | 75 +++---- .../src/components/media/UIMediaFileTable.tsx | 211 ++++++++++++++++-- apps/ui/src/components/movie/MoviePanel.tsx | 21 +- apps/ui/src/components/tv/TvShowPanel.tsx | 125 ++++------- apps/ui/src/hooks/useTvShowPanel.ts | 158 +++++++++++++ 6 files changed, 450 insertions(+), 231 deletions(-) create mode 100644 apps/ui/src/hooks/useTvShowPanel.ts diff --git a/apps/ui/src/components/media/MediaFileTable.test.tsx b/apps/ui/src/components/media/MediaFileTable.test.tsx index 82c3e0ca..3399809b 100644 --- a/apps/ui/src/components/media/MediaFileTable.test.tsx +++ b/apps/ui/src/components/media/MediaFileTable.test.tsx @@ -1,22 +1,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { render, cleanup } from "@testing-library/react" import type { - UIMediaFileDataContextMenuItem, + MediaFileTableContextMenuProps, UIMediaFileDataRow, - UIMediaFileTableContextMenuConfig, UIMediaFileTableRow, } from "./UIMediaFileTable" -// Capture the latest contextMenuConfig the wrapper passes to the underlying +// Capture the latest contextMenuProps the wrapper passes to the underlying // pure UI component, so the test can assert against it without rendering // the real table (which depends on UI primitives that need Radix portals). -let lastContextMenuConfig: UIMediaFileTableContextMenuConfig | undefined +let lastContextMenuProps: MediaFileTableContextMenuProps | undefined vi.mock("./UIMediaFileTable", () => ({ UIMediaFileTable: ( - props: { contextMenuConfig?: UIMediaFileTableContextMenuConfig } & Record, + props: { contextMenuProps?: MediaFileTableContextMenuProps } & Record, ) => { - lastContextMenuConfig = props.contextMenuConfig + lastContextMenuProps = props.contextMenuProps return
}, })) @@ -47,84 +46,44 @@ const data: UIMediaFileTableRow[] = [baseRow] beforeEach(() => { cleanup() - lastContextMenuConfig = undefined + lastContextMenuProps = undefined }) describe("MediaFileTable right-click menu", () => { - it("exposes only the built-in Open and Properties items by default", () => { + it("passes contextMenuProps with Open and Properties handlers by default", () => { render() - const items = lastContextMenuConfig?.dataRowItems ?? [] - expect(items.map((i) => i.id)).toEqual(["open", "properties"]) + expect(lastContextMenuProps).toBeDefined() + expect(lastContextMenuProps?.onOpenMenuClick).toBeTypeOf("function") + expect(lastContextMenuProps?.onPropertiesMenuClick).toBeTypeOf("function") }) - it("appends caller-provided extraEpisodeContextMenu items after Open and Properties", () => { + it("forwards caller-provided contextMenuProps", () => { const renameClick = vi.fn() - const extra: UIMediaFileDataContextMenuItem[] = [ - { - id: "rename", - label: "Rename", - onClick: renameClick, - disabled: (row) => !row.videoFile, - }, - ] render( , ) - const items = lastContextMenuConfig?.dataRowItems ?? [] - expect(items.map((i) => i.id)).toEqual(["open", "properties", "rename"]) - expect(items[2]?.onClick).toBe(renameClick) + expect(lastContextMenuProps?.renameMenuVisible).toBe(true) + expect(lastContextMenuProps?.onRenameMenuClick).toBe(renameClick) }) - it("extra item's disabled predicate runs against the row and toggles per row", () => { - const extra: UIMediaFileDataContextMenuItem[] = [ - { - id: "rename", - label: "Rename", - onClick: vi.fn(), - disabled: (row) => !row.videoFile, - }, - ] - - render( - , - ) - - const items = lastContextMenuConfig?.dataRowItems ?? [] - const rename = items.find((i) => i.id === "rename") - if (!rename) throw new Error("expected rename item") - const isDisabled = rename.disabled - if (typeof isDisabled !== "function") { - throw new Error("expected function-form disabled on rename item") - } - expect(isDisabled(baseRow)).toBe(false) - expect(isDisabled({ ...baseRow, videoFile: undefined })).toBe(true) - }) - - it("does not leak extraEpisodeContextMenu into folder file rows", () => { - const extra: UIMediaFileDataContextMenuItem[] = [ - { id: "rename", label: "Rename", onClick: vi.fn() }, - ] - - render( - , - ) + it("defaults Open/Properties handlers to ctrl.openFile/openPropertiesDialog", () => { + render() - // folderFileRowItems is independent — designed to skip data-row-only entries - expect(lastContextMenuConfig?.folderFileRowItems?.map((i) => i.id)).toEqual(["open"]) + // Open handler should call openFile when videoFile is present + const openHandler = lastContextMenuProps?.onOpenMenuClick + expect(openHandler).toBeDefined() + // Properties handler should call openPropertiesDialog when videoFile is present + const propsHandler = lastContextMenuProps?.onPropertiesMenuClick + expect(propsHandler).toBeDefined() }) }) diff --git a/apps/ui/src/components/media/MediaFileTable.tsx b/apps/ui/src/components/media/MediaFileTable.tsx index 6cbe6c39..3ebd9b57 100644 --- a/apps/ui/src/components/media/MediaFileTable.tsx +++ b/apps/ui/src/components/media/MediaFileTable.tsx @@ -1,10 +1,7 @@ -import { useMemo, type ReactNode } from "react" -import { useTranslation } from "@/lib/i18n" +import { type ReactNode, useMemo } from "react" import { UIMediaFileTable } from "./UIMediaFileTable" import type { - UIMediaFileDataContextMenuItem, - UIMediaFileFolderContextMenuItem, - UIMediaFileTableContextMenuConfig, + MediaFileTableContextMenuProps, UIMediaFileDataRow, UIMediaFileTableRow, UIMediaEpisodeSelection, @@ -22,6 +19,9 @@ import { useMediaFileTableController } from "./useMediaFileTableController" export interface MediaFileTableProps { seasonData?: MediaFileTableSeasonData[], metadataFiles?: MetadataFiles, + subtitleFiles?: { season: number, episode: number, files: string[] }[], + nfoFiles?: { season: number, episode: number, files: string[] }[], + thumbnailFiles?: { season: number, episode: number, files: string[] }[], data: UIMediaFileTableRow[] /** When set, relative file paths are resolved against this base before opening. */ mediaFolderPath?: string @@ -52,19 +52,18 @@ export interface MediaFileTableProps { */ renderPreviewContent?: (row: UIMediaFileDataRow) => ReactNode /** - * Extra data-row context menu items appended after the built-in "Open" and - * "Properties" entries. Use this for panel-private actions (e.g. TvShow - * and Movie panels inject their "Rename" item here) so `MediaFileTable` - * stays free of panel-specific business logic. + * Right-click context menu props forwarded to `UIMediaFileTable`. + * `UIMediaFileTable` hardcodes the menu items and uses these props to + * control visibility / disabled / callbacks. */ - extraEpisodeContextMenu?: UIMediaFileDataContextMenuItem[] + contextMenuProps?: MediaFileTableContextMenuProps } /** * Business-logic wrapper around `UIMediaFileTable`. Provides: * - "Open" context menu item → `openFile` API * - "Properties" context menu item → `MediaFilePropertyDialog` - * - caller-supplied extra items via `extraEpisodeContextMenu` + * - caller-supplied extra items via `contextMenuProps` * - row double-click → `openFile` API * * The right-click menu and double-click behavior are owned by this component; @@ -75,6 +74,9 @@ export function MediaFileTable(props: MediaFileTableProps) { data, seasonData, metadataFiles, + subtitleFiles, + nfoFiles, + thumbnailFiles, mediaFolderPath, preview, previewStatus, @@ -82,54 +84,31 @@ export function MediaFileTable(props: MediaFileTableProps) { onCheck, selectedEpisodes, renderPreviewContent, - extraEpisodeContextMenu, + contextMenuProps: contextMenuPropsProp, } = props - const { t } = useTranslation("components") const ctrl = useMediaFileTableController(mediaFolderPath) - const contextMenuConfig = useMemo(() => { - const dataRowItems: UIMediaFileDataContextMenuItem[] = [ - { - id: "open", - label: t("mediaFileTable.contextMenu.open"), - onClick: (row) => { - if (row.videoFile) ctrl.openFile(row.videoFile) - }, - disabled: (row) => !row.videoFile, - }, - { - id: "properties", - label: t("mediaFileTable.contextMenu.properties"), - onClick: (row) => { - if (row.videoFile) ctrl.openPropertiesDialog(row.videoFile) - }, - disabled: (row) => !row.videoFile, - }, - ...(extraEpisodeContextMenu ?? []), - ] - - const folderFileRowItems: UIMediaFileFolderContextMenuItem[] = [ - { - id: "open", - label: t("mediaFileTable.contextMenu.open"), - onClick: (row) => { - if (row.path) ctrl.openFile(row.path) - }, - disabled: (row) => !row.path, - }, - ] - - return { dataRowItems, folderFileRowItems } - }, [ctrl, t, extraEpisodeContextMenu]) + const contextMenuProps = useMemo(() => ({ + ...contextMenuPropsProp, + onOpenMenuClick: contextMenuPropsProp?.onOpenMenuClick ?? ((row) => { + if (row.videoFile) ctrl.openFile(row.videoFile) + }), + onPropertiesMenuClick: contextMenuPropsProp?.onPropertiesMenuClick ?? ((row) => { + if (row.videoFile) ctrl.openPropertiesDialog(row.videoFile) + }), + }), [contextMenuPropsProp, ctrl]) return ( void + /** Handler for "Properties". Always visible when provided. */ + onPropertiesMenuClick?: (row: UIMediaFileDataRow) => void + + /** Show "Rename" menu item. */ + renameMenuVisible?: boolean + /** Disabled state for "Rename". Defaults to `!row.videoFile` when omitted. */ + renameMenuDisabled?: boolean + /** Handler for "Rename". */ + onRenameMenuClick?: (row: UIMediaFileDataRow) => void + + /** Show "Select File" menu item. */ + selectFileMenuVisible?: boolean + /** Disabled state for "Select File". Defaults to `false` when omitted. */ + selectFileMenuDisabled?: boolean + /** Handler for "Select File". */ + onSelectFileMenuClick?: (row: UIMediaFileDataRow) => void + + /** Show "Unlink" menu item. */ + unlinkMenuVisible?: boolean + /** Disabled state for "Unlink". Defaults to `!row.videoFile` when omitted. */ + unlinkMenuDisabled?: boolean + /** Handler for "Unlink". */ + onUnlinkMenuClick?: (row: UIMediaFileDataRow) => void + + /** Show "Video Compress" menu item. */ + videoCompressMenuVisible?: boolean + /** Disabled state for "Video Compress". Defaults to `!row.videoFile` when omitted. */ + videoCompressMenuDisabled?: boolean + /** Handler for "Video Compress". */ + onVideoCompressMenuClick?: (row: UIMediaFileDataRow) => void + + /** Show "Format Convert" menu item. */ + formatConvertMenuVisible?: boolean + /** Disabled state for "Format Convert". Defaults to `!row.videoFile` when omitted. */ + formatConvertMenuDisabled?: boolean + /** Handler for "Format Convert". */ + onFormatConvertMenuClick?: (row: UIMediaFileDataRow) => void +} + // ======================================================================== // Component props // ======================================================================== @@ -170,11 +219,20 @@ export interface UIMediaFileTableContextMenuConfig { export interface UIMediaFileTableProps { seasonData?: MediaFileTableSeasonData[], metadataFiles?: MetadataFiles, + subtitleFiles?: { season: number, episode: number, files: string[] }[], + nfoFiles?: { season: number, episode: number, files: string[] }[], + thumbnailFiles?: { season: number, episode: number, files: string[] }[], data: UIMediaFileTableRow[] /** When set, paths are shown relative to this base. */ mediaFolderPath?: string /** Right-click menu configuration. Omit for no row context menus. */ contextMenuConfig?: UIMediaFileTableContextMenuConfig + /** + * Right-click context menu props. When provided, `UIMediaFileTable` hardcodes + * the menu items and uses these props to control visibility / disabled / callbacks. + * Takes precedence over `contextMenuConfig` for data-row items. + */ + contextMenuProps?: MediaFileTableContextMenuProps /** * NOTE: `preview` mode is a different concept from `preview` layout. * - `preview` mode: preview a recognition or rename plan (shows old→new paths, etc.) @@ -344,9 +402,13 @@ function groupSegmentsForRender(segments: TableSegment[]): TableRenderBlock[] { export function UIMediaFileTable({ data, metadataFiles, + subtitleFiles, + nfoFiles, + thumbnailFiles, seasonData = [], mediaFolderPath, - contextMenuConfig, + contextMenuConfig: contextMenuConfigProp, + contextMenuProps, preview, previewStatus, layout = "simple", @@ -447,9 +509,87 @@ export function UIMediaFileTable({ t as (key: string, options?: Record) => string, ) + // Build contextMenuConfig from contextMenuProps when provided. + // contextMenuProps takes precedence over contextMenuConfig for data-row items. + const effectiveContextMenuConfig = useMemo(() => { + if (contextMenuProps) { + const { onOpenMenuClick, onPropertiesMenuClick } = contextMenuProps + const dataRowItems: UIMediaFileDataContextMenuItem[] = [ + { + id: "open", + label: t("mediaFileTable.contextMenu.open"), + onClick: onOpenMenuClick, + disabled: (row) => !row.videoFile, + }, + { + id: "properties", + label: t("mediaFileTable.contextMenu.properties"), + onClick: onPropertiesMenuClick, + disabled: (row) => !row.videoFile, + }, + ] + + if (contextMenuProps.renameMenuVisible !== false && contextMenuProps.onRenameMenuClick) { + dataRowItems.push({ + id: "rename", + label: t("episodeFile.rename"), + onClick: contextMenuProps.onRenameMenuClick, + disabled: (row) => contextMenuProps.renameMenuDisabled ?? !row.videoFile, + }) + } + if (contextMenuProps.selectFileMenuVisible !== false && contextMenuProps.onSelectFileMenuClick) { + dataRowItems.push({ + id: "select-file", + label: t("episodeFile.selectFile"), + onClick: contextMenuProps.onSelectFileMenuClick, + disabled: contextMenuProps.selectFileMenuDisabled, + }) + } + if (contextMenuProps.unlinkMenuVisible !== false && contextMenuProps.onUnlinkMenuClick) { + dataRowItems.push({ + id: "unlink", + label: t("tvShowEpisodeTable.contextMenu.unlink"), + onClick: contextMenuProps.onUnlinkMenuClick, + disabled: (row) => contextMenuProps.unlinkMenuDisabled ?? !row.videoFile, + }) + } + if (contextMenuProps.videoCompressMenuVisible !== false && contextMenuProps.onVideoCompressMenuClick) { + dataRowItems.push({ + id: "video-compress", + label: t("tvShowEpisodeTable.contextMenu.videoCompress"), + onClick: contextMenuProps.onVideoCompressMenuClick, + disabled: (row) => contextMenuProps.videoCompressMenuDisabled ?? !row.videoFile, + }) + } + if (contextMenuProps.formatConvertMenuVisible !== false && contextMenuProps.onFormatConvertMenuClick) { + dataRowItems.push({ + id: "format-convert", + label: t("tvShowEpisodeTable.contextMenu.formatConvert"), + onClick: contextMenuProps.onFormatConvertMenuClick, + disabled: (row) => contextMenuProps.formatConvertMenuDisabled ?? !row.videoFile, + }) + } + + const folderFileRowItems: UIMediaFileFolderContextMenuItem[] = [ + { + id: "open", + label: t("mediaFileTable.contextMenu.open"), + onClick: onOpenMenuClick + ? (row) => (onOpenMenuClick as unknown as (row: UIMediaFileFolderRow) => void)(row) + : undefined, + disabled: (row) => !row.path, + }, + ] + + return { dataRowItems, folderFileRowItems } + } + + return contextMenuConfigProp + }, [contextMenuProps, contextMenuConfigProp, t]) + const renderContext: MediaFileTableRowContext = { mediaFolderPath, - contextMenuConfig, + contextMenuConfig: effectiveContextMenuConfig, preview, previewStatus, layout, @@ -470,8 +610,8 @@ export function UIMediaFileTable({ // Context menu items for the seasonData-driven episode rows. Built once per // config from the (deprecated) UIMediaFileDataRow-based `dataRowItems`. const episodeContextMenuItems = useMemo( - () => buildEpisodeContextMenuItems(contextMenuConfig), - [contextMenuConfig], + () => buildEpisodeContextMenuItems(effectiveContextMenuConfig), + [effectiveContextMenuConfig], ) // ── Render: header row with column-visibility context menu ──────────── @@ -615,7 +755,7 @@ export function UIMediaFileTable({ showCheckboxColumn={showCheckboxColumn} visibleColumnCount={visibleColumnCount} > - + ) }) @@ -635,7 +775,7 @@ export function UIMediaFileTable({ showCheckboxColumn={showCheckboxColumn} visibleColumnCount={visibleColumnCount} > - + ) }) @@ -655,7 +795,7 @@ export function UIMediaFileTable({ showCheckboxColumn={showCheckboxColumn} visibleColumnCount={visibleColumnCount} > - + ) }) @@ -826,30 +966,53 @@ export interface UIMediaFileTableEpisodeBlockProps { items?: EpisodeContextMenuItem[] /** When set, paths are shown relative to this base. */ mediaFolderPath?: string + subtitleFiles?: { season: number, episode: number, files: string[] }[] + nfoFiles?: { season: number, episode: number, files: string[] }[] + thumbnailFiles?: { season: number, episode: number, files: string[] }[] } export function UIMediaFileTableEpisodeBlock({ season, items = [], mediaFolderPath, + subtitleFiles, + nfoFiles, + thumbnailFiles, }: UIMediaFileTableEpisodeBlockProps) { return (
- {season.episodes.map((episode) => ( - - - - ))} + {season.episodes.map((episode) => { + + console.log(thumbnailFiles) + + const subtitle = subtitleFiles?.find((subtitle) => subtitle.season === season.season && subtitle.episode === episode.episode) + const subtitlePath = head(subtitle?.files ?? []) + + const nfo = nfoFiles?.find((nfo) => nfo.season === season.season && nfo.episode === episode.episode) + const nfoPath = head(nfo?.files ?? []) + + const thumbnail = thumbnailFiles?.find((thumbnail) => thumbnail.season === season.season && thumbnail.episode === episode.episode) + const thumbnailPath = head(thumbnail?.files ?? []) + + return ( + + + + ) + })}
) @@ -864,6 +1027,9 @@ export function UIMediaFileTableEpisodeDetailBlock({ season, items = [], mediaFolderPath, + subtitleFiles: _subtitleFiles, + nfoFiles: _nfoFiles, + thumbnailFiles: _thumbnailFiles, }: UIMediaFileTableEpisodeBlockProps) { return ( @@ -896,6 +1062,9 @@ export function UIMediaFileTableEpisodePreviewBlock({ season, items = [], mediaFolderPath, + subtitleFiles: _subtitleFiles, + nfoFiles: _nfoFiles, + thumbnailFiles: _thumbnailFiles, }: UIMediaFileTableEpisodeBlockProps) { return (
diff --git a/apps/ui/src/components/movie/MoviePanel.tsx b/apps/ui/src/components/movie/MoviePanel.tsx index 45c5b9d2..fd3d8c2f 100644 --- a/apps/ui/src/components/movie/MoviePanel.tsx +++ b/apps/ui/src/components/movie/MoviePanel.tsx @@ -28,7 +28,6 @@ import { MovieHeaderV2 } from "./MovieHeaderV2" import type { EpisodeTableLayout } from "../tv/TvShowPanelHeader" import { MediaFileTable } from "../media/MediaFileTable" import type { - UIMediaFileDataContextMenuItem, UIMediaFileDataRow, UIMediaFileTableRow, } from "../media/UIMediaFileTable" @@ -376,20 +375,12 @@ function MoviePanel() { mediaFolderPath={mediaMetadata?.mediaFolderPath} layout={isPreviewingForRename ? "simple" : layout} preview={isPreviewingForRename ? "rename" : undefined} - extraEpisodeContextMenu={[ - { - id: "rename", - label: t("episodeFile.rename"), - onClick: videoRenameFlow.onRenameContextMenuClick, - disabled: (row) => !row.videoFile, - } satisfies UIMediaFileDataContextMenuItem, - { - id: "video-compress", - label: t("tvShowEpisodeTable.contextMenu.videoCompress"), - onClick: isVideoCompressionEnabled ? handleVideoCompressClick : undefined, - disabled: (row) => !row.videoFile, - } satisfies UIMediaFileDataContextMenuItem, - ]} + contextMenuProps={{ + renameMenuVisible: true, + onRenameMenuClick: videoRenameFlow.onRenameContextMenuClick, + videoCompressMenuVisible: isVideoCompressionEnabled, + onVideoCompressMenuClick: handleVideoCompressClick, + }} /> )} diff --git a/apps/ui/src/components/tv/TvShowPanel.tsx b/apps/ui/src/components/tv/TvShowPanel.tsx index 57435c6d..94fde166 100644 --- a/apps/ui/src/components/tv/TvShowPanel.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.tsx @@ -2,13 +2,11 @@ import { useUIMediaFolderStore, useUIMediaFolderStoreState } from "@/stores/uiMe import { useMediaMetadataQuery } from "@/hooks/mediaMetadata" import { useSelectTvShowForFolderMutation } from "@/hooks/useSelectTvShowForFolderMutation" import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" -import { useState, useEffect, useCallback, useMemo, useRef } from "react" -import type { MetadataFiles } from "@smm/types/MetadataFiles" +import { useState, useCallback, useMemo } from "react" import type { MediaMetadata } from "@/lib/mediaFolderFiles" import { useMediaFolderFilesQuery } from "@/hooks/useMediaFolderFilesQuery" import type { TMDBTVShow, TMDBTVShowDetails } from "@smm/types" import type { SearchResultSelectedArgs } from "../MediaDatabaseSearchbox" -import { useTranslation } from "@/lib/i18n" import { TvShowPanelPrompts } from "./TvShowPanelPrompts" import { useTvShowPromptsStore } from "@/stores/tvShowPromptsStore" import { useTvShowPanelState } from "@/hooks/tv/useTvShowPanelState" @@ -24,7 +22,7 @@ import { askForRenameFile, askForScrape } from "@/lib/dialogRequestEvents" import { usePlansQuery } from "@/hooks/plans" import { MediaFileTable } from "@/components/media/MediaFileTable" import type { - UIMediaFileDataContextMenuItem, + MediaFileTableContextMenuProps, UIMediaFileDataRow, UIMediaFileTableRow, UIMediaEpisodeSelection, @@ -37,7 +35,6 @@ import { TranscribeDialog, SubtitleTranslationDialog, SynthesizeSubtitleDialog, import { useFeatures } from "@/hooks/useFeatures" import { useSubtitleFlow } from "@/hooks/useSubtitleFlow" import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" -import { buildTvShowEpisodeTableRowsForPanel } from "@/lib/buildTvShowEpisodeTableRows" import { rebuildPlanWithSelectedEpisodes, rebuildRenamePlanWithSelectedEpisodes, @@ -50,6 +47,7 @@ import { TvShowAppPlanPromptProvider, type TvShowAppPlanPromptContextValue, } from "./plans/TvShowAppPlanPromptContext" +import { useTvShowPanel } from "@/hooks/useTvShowPanel" export function buildMediaFileTableSeasonData(m: MediaMetadata): MediaFileTableSeasonData[] { @@ -79,7 +77,6 @@ export function buildMediaFileTableSeasonData(m: MediaMetadata): MediaFileTableS } function TvShowPanel() { - const { t } = useTranslation(['components', 'errors']) const { folders, selectedFolder } = useUIMediaFolderStoreState() const { data: queriedMediaMetadata, @@ -88,8 +85,8 @@ function TvShowPanel() { fetchStatus: mediaMetadataFetchStatus, } = useMediaMetadataQuery(selectedFolder || undefined) + const { metadataFiles, subtitleFiles, nfoFiles, thumbnailFiles } = useTvShowPanel(selectedFolder) - const uiFolderRow = useMemo( () => selectedFolder @@ -133,14 +130,12 @@ function TvShowPanel() { openRenameDialog: askForRenameFile, }) - const [tableData, setTableData] = useState([]) + const [tableData] = useState([]) const latestTableData = useLatest(tableData) // Checkbox selection — separate UI state, kept apart from row data so that // user toggles survive the row rebuilds triggered by metadata / plan refetches. const [selectedEpisodes, setSelectedEpisodes] = useState([]) - // The plan instance the current selection was seeded from. - const prevPlanRef = useRef(undefined) const getSelectedEpisodePaths = useCallback( () => @@ -318,61 +313,41 @@ function TvShowPanel() { } }, [plan]) - useEffect(() => { - /* eslint-disable react-hooks/set-state-in-effect */ - if (!mediaMetadata) return; - - const built = buildTvShowEpisodeTableRowsForPanel(mediaMetadata, uiStatus, plan, (key: string) => { - return t(key as any) // eslint-disable-line @typescript-eslint/no-explicit-any - }, folderFiles) - - setTableData(built.rows); - - // Re-seed the selection only when a (new) plan instance arrives. The - // selection is separate UI state, so unrelated row rebuilds (metadata / - // folderFiles refetches) must not wipe the user's check toggles. - if (plan !== prevPlanRef.current) { - prevPlanRef.current = plan - setSelectedEpisodes(built.defaultChecked) - } - /* eslint-enable react-hooks/set-state-in-effect */ - - }, [mediaMetadata, plan, uiStatus, t, folderFiles]) - - const extraEpisodeContextMenu: UIMediaFileDataContextMenuItem[] = useMemo( - () => [ - { - id: "rename", - label: t("episodeFile.rename", { ns: "components" }), - onClick: videoRenameFlow.onRenameContextMenuClick, - disabled: (row) => !row.videoFile, - }, - { - id: "select-file", - label: t("episodeFile.selectFile", { ns: "components" }), - onClick: selectFileFlow.onSelectFileContextMenuClick, - }, - { - id: "unlink", - label: t("tvShowEpisodeTable.contextMenu.unlink"), - onClick: selectFileFlow.onUnlinkContextMenuClick, - disabled: (row) => !row.videoFile, - }, - { - id: "video-compress", - label: t("tvShowEpisodeTable.contextMenu.videoCompress"), - onClick: isVideoCompressionEnabled ? handleVideoCompressForRow : undefined, - disabled: (row) => !row.videoFile, - }, - { - id: "format-convert", - label: t("tvShowEpisodeTable.contextMenu.formatConvert"), - onClick: isFormatConverterEnabled ? handleFormatConvertForRow : undefined, - disabled: (row) => !row.videoFile, - }, - ], + // useEffect(() => { + // /* eslint-disable react-hooks/set-state-in-effect */ + // if (!mediaMetadata) return; + + // const built = buildTvShowEpisodeTableRowsForPanel(mediaMetadata, uiStatus, plan, (key: string) => { + // return t(key as any) // eslint-disable-line @typescript-eslint/no-explicit-any + // }, folderFiles) + + // setTableData(built.rows); + + // // Re-seed the selection only when a (new) plan instance arrives. The + // // selection is separate UI state, so unrelated row rebuilds (metadata / + // // folderFiles refetches) must not wipe the user's check toggles. + // if (plan !== prevPlanRef.current) { + // prevPlanRef.current = plan + // setSelectedEpisodes(built.defaultChecked) + // } + // /* eslint-enable react-hooks/set-state-in-effect */ + + // }, [mediaMetadata, plan, uiStatus, t, folderFiles]) + + const contextMenuProps: MediaFileTableContextMenuProps = useMemo( + () => ({ + renameMenuVisible: true, + onRenameMenuClick: videoRenameFlow.onRenameContextMenuClick, + selectFileMenuVisible: true, + onSelectFileMenuClick: selectFileFlow.onSelectFileContextMenuClick, + unlinkMenuVisible: true, + onUnlinkMenuClick: selectFileFlow.onUnlinkContextMenuClick, + videoCompressMenuVisible: isVideoCompressionEnabled, + onVideoCompressMenuClick: handleVideoCompressForRow, + formatConvertMenuVisible: isFormatConverterEnabled, + onFormatConvertMenuClick: handleFormatConvertForRow, + }), [ - t, videoRenameFlow.onRenameContextMenuClick, selectFileFlow.onSelectFileContextMenuClick, selectFileFlow.onUnlinkContextMenuClick, @@ -412,22 +387,7 @@ function TvShowPanel() { } }, [renameFlow, aiRenameFlow, aiRecognizeFlow, recognizeFlow]) - const metadataFiles: MetadataFiles = useMemo(() => { - - if(mediaMetadata === undefined) { - return {}; - } - - return { - nfoPath: folderFiles.find(f => f === `${mediaMetadata.mediaFolderPath}/tvshow.nfo`), - posterPath: folderFiles.find(f => f === `${mediaMetadata.mediaFolderPath}/poster.jpg`), - fanartPath: folderFiles.find(f => f === `${mediaMetadata.mediaFolderPath}/fanart.jpg`), - // TODO: support in the future - seasonPosters: [], - clearlogoPath: folderFiles.find(f => f === `${mediaMetadata.mediaFolderPath}/clearlogo.png`), - themePath: folderFiles.find(f => f === `${mediaMetadata.mediaFolderPath}/theme.mp3`), - } - }, [mediaMetadata, folderFiles]) + return ( @@ -461,12 +421,15 @@ function TvShowPanel() { key={mediaMetadata?.mediaFolderPath ?? "no-folder"} seasonData={mediaFileTableSeasonData} metadataFiles={metadataFiles} + subtitleFiles={subtitleFiles} + nfoFiles={nfoFiles} + thumbnailFiles={thumbnailFiles} data={tableData} mediaFolderPath={mediaMetadata?.mediaFolderPath} preview={previewMode} previewStatus={previewStatus} layout={episodeTableLayout} - extraEpisodeContextMenu={extraEpisodeContextMenu} + contextMenuProps={contextMenuProps} selectedEpisodes={selectedEpisodes} onCheck={(row, checked) => { setSelectedEpisodes((prev) => { diff --git a/apps/ui/src/hooks/useTvShowPanel.ts b/apps/ui/src/hooks/useTvShowPanel.ts new file mode 100644 index 00000000..e6ac4891 --- /dev/null +++ b/apps/ui/src/hooks/useTvShowPanel.ts @@ -0,0 +1,158 @@ +import type { MetadataFiles } from "@smm/types/MetadataFiles"; +import { useMemo } from "react"; +import { useMediaFolderFilesQuery } from "./useMediaFolderFilesQuery"; +import { useMediaMetadataQuery } from "./mediaMetadata"; +import { findFilesByExtensions } from "@/lib/music"; +import { extensions, imageFileExtensions, subtitleFileExtensions } from "@smm/types/mediaFileExtensions"; +import { basename, extname } from "@/lib/path"; +import type { MediaMetadata } from "@smm/types/types"; + +const INIT_METADATA_FILES: MetadataFiles = { + nfoPath: undefined, + posterPath: undefined, + fanartPath: undefined, + seasonPosters: [], + clearlogoPath: undefined, + themePath: undefined, +}; + +export function findMetadataFiles(metadata: MediaMetadata, files: string[]) { + const images = findFilesByExtensions(files, extensions.imageFileExtensions) + + return { + nfoPath: files.find(f => f === `${metadata.mediaFolderPath}/tvshow.nfo`), + posterPath: images.find(f => { + return basename(f)?.toLowerCase().includes('poster'); + }), + fanartPath: images.find(f => { + return basename(f)?.toLowerCase().includes('fanart'); + }), + // TODO: support in the future + seasonPosters: [], + clearlogoPath: images.find(f => { + return basename(f)?.toLowerCase().includes('clearlogo'); + }), + themePath: findFilesByExtensions(files, extensions.musicFileExtensions) + .find(f => { + return basename(f)?.toLowerCase().includes('theme'); + }), + } +} + +export function findThumbnails(files: string[], videoFile: string): string[] { + const videoFileExt = extname(videoFile) + const possibleThumbnailFilePaths = imageFileExtensions.map(ext => `${videoFile.replace(videoFileExt, ext)}`) + return files.filter(file => possibleThumbnailFilePaths.includes(file)) +} + +export function findSubtitles(files: string[], videoFile: string): string[] { + const videoFileExt = extname(videoFile) + const possibleSubtitleFilePaths = subtitleFileExtensions.map(ext => `${videoFile.replace(videoFileExt, ext)}`) + return files.filter(file => possibleSubtitleFilePaths.includes(file)) +} + +export function findNfos(files: string[], videoFile: string): string[] { + const videoFileExt = extname(videoFile) + const nfoFilePath = `${videoFile.replace(videoFileExt, '.nfo')}` + return files.filter(file => file === nfoFilePath) +} + +export function useTvShowPanel(folderPath?: string) { + + if (folderPath === undefined) { + return { + metadataFiles: INIT_METADATA_FILES + } + } + + const metadataQuery = useMediaMetadataQuery(folderPath) + const filesQuery = useMediaFolderFilesQuery(folderPath) + + const metadataFiles: MetadataFiles = useMemo(() => { + + if (metadataQuery.data === undefined + || metadataQuery.isError + || metadataQuery.isPending + || metadataQuery.fetchStatus !== 'idle' + || metadataQuery.data === null + || filesQuery.data === undefined + || filesQuery.isError + || filesQuery.isPending + || filesQuery.fetchStatus !== 'idle' + ) { + return INIT_METADATA_FILES; + } + + const files = filesQuery.data + const metadata = metadataQuery.data + return findMetadataFiles(metadata, files) + + }, [metadataQuery.data, filesQuery.data]) + + const subtitleFiles: { season: number, episode: number, files: string[] }[] = useMemo(() => { + + if (metadataQuery.data === undefined + || filesQuery.data === undefined + ) { + return [] + } + + return metadataQuery.data?.mediaFiles + ?.filter((mediaFile) => mediaFile.seasonNumber !== undefined && mediaFile.episodeNumber !== undefined) + ?.map((mediaFile) => { + return { + season: mediaFile.seasonNumber!!, + episode: mediaFile.episodeNumber!!, + files: findSubtitles(filesQuery.data, mediaFile.absolutePath) + } + }) ?? [] + + }, [metadataQuery.data, filesQuery.data]) + + const nfoFiles: { season: number, episode: number, files: string[] }[] = useMemo(() => { + + if (metadataQuery.data === undefined + || filesQuery.data === undefined + ) { + return [] + } + + return metadataQuery.data?.mediaFiles + ?.filter((mediaFile) => mediaFile.seasonNumber !== undefined && mediaFile.episodeNumber !== undefined) + ?.map((mediaFile) => { + return { + season: mediaFile.seasonNumber!!, + episode: mediaFile.episodeNumber!!, + files: findNfos(filesQuery.data, mediaFile.absolutePath) + } + }) ?? [] + + }, [metadataQuery.data, filesQuery.data]) + + const thumbnailFiles: { season: number, episode: number, files: string[] }[] = useMemo(() => { + + if (metadataQuery.data === undefined + || filesQuery.data === undefined + ) { + return [] + } + + return metadataQuery.data?.mediaFiles + ?.filter((mediaFile) => mediaFile.seasonNumber !== undefined && mediaFile.episodeNumber !== undefined) + ?.map((mediaFile) => { + return { + season: mediaFile.seasonNumber!!, + episode: mediaFile.episodeNumber!!, + files: findThumbnails(filesQuery.data, mediaFile.absolutePath) + } + }) ?? [] + + }, [metadataQuery.data, filesQuery.data]) + + return { + metadataFiles, + subtitleFiles, + nfoFiles, + thumbnailFiles + } +} \ No newline at end of file From 28e6389382757e611dcef5376f47b90e7776ac9d Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Fri, 4 Sep 2026 18:02:10 +0800 Subject: [PATCH 15/83] docs: add UC3 apply-plan-selected-files design spec Co-Authored-By: Claude Opus 4.7 --- ...04-uc3-apply-plan-selected-files-design.md | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-04-uc3-apply-plan-selected-files-design.md diff --git a/docs/superpowers/specs/2026-09-04-uc3-apply-plan-selected-files-design.md b/docs/superpowers/specs/2026-09-04-uc3-apply-plan-selected-files-design.md new file mode 100644 index 00000000..c8b74223 --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-uc3-apply-plan-selected-files-design.md @@ -0,0 +1,163 @@ +# UC3 Apply Plan With Selected Episodes (Rename Selected Files) + +This design document describe the high level design of a feature. +The design document is golden source and reference by one or more features. + +## 1. Background + +[Rename Episodes](../../dev/rename-episodes.md) UC3: user reviews a rename plan and confirms only a subset of episodes. `POST /api/apply-plan` carries an optional `data.files` list of "from" paths. + +A plan has no partial-approved status, so Core cannot apply a subset and mark the original done. Instead Core **rejects the original plan, creates a new plan with only the selected files, and applies that plan**. On disk: a full apply leaves no plan file; a partial apply leaves exactly one plan file — the rejected original. + +**Already implemented** + +- `Core.applyPlan(plan)` / `applyPlanPipeline` → `applyRenameFilesPlanPipeline` (rename + metadata rewrite + delete plan) +- `Core.rejectPlan(id)` (status → `rejected`, file kept) +- `createRenameEpisodePlanPipeline` (validation + write pending plan) +- `POST /api/apply-plan` (`{ id }` → apply → broadcast `mediaMetadataUpdated`) + +**Still missing** + +- `data?: { files?: string[] }` on apply-plan (HTTP body and `Core.applyPlan` argument) +- Validation that selected files are in the plan's `from` list; error in RFC 9457 ProblemDetails format +- Reject-original + create-subset + apply orchestration + +**Agreed product decisions** + +- Error surface: `400` + `Content-Type: application/problem+json` (true RFC 9457) for selection errors. All other errors on this route keep the existing `{ error }` + HTTP 200 pattern. +- `data` is honored only for `rename-files` plans; `recognize-media-file` ignores it. +- `data` or `data.files` absent → full apply (today's behavior). `data.files` present but not a non-empty array of strings → 400 ProblemDetails (`detail`: `data.files must be a non-empty array of strings`). +- Membership comparison uses `mediaFilePathEqual` (POSIX normalization, Windows separators tolerated). +- New plan inherits the original plan's `creator`; id is a fresh UUID. +- Orchestration order is reject → create → apply. If creation fails after reject, nothing is renamed and the original stays rejected (no partial-approved state). +- Response body and `mediaMetadataUpdated` broadcast unchanged. + +## 2. Architecture + +## 2.1 Project Level Architecture + +```mermaid +sequenceDiagram + participant U as User + participant W as UI + participant S as apps/cli + participant C as Core + participant Fs as FsPort + + U->>W: confirm with selected episodes + W->>S: POST /api/apply-plan { id, data: { files } } + S->>C: getPlan(id) + S->>C: applyPlan(plan, { files }) + C->>C: validate files ∈ plan.files (mediaFilePathEqual) + C->>Fs: rejectPlan → original .plan.json (rejected, kept) + C->>Fs: createRenameEpisodePlanPipeline → new .plan.json (pending) + C->>Fs: rename selected + associated files, rewrite metadata + C->>Fs: delete new .plan.json + S-->>W: { data: { id } } + broadcast mediaMetadataUpdated +``` + +- No UI changes in this feature (UI sends `data.files` in its own change). +- MCP/AI tools unaffected; they gain the capability implicitly via `Core.applyPlan(plan, data)`. + +## 2.2 App Level Architecture + +| Piece | Location | Role | +|--------|----------|------| +| Dispatch with data | `apps/core/src/pipeline/applyPlan.ts` | `applyPlanPipeline(plan, deps, data?)`; routes `rename-files` + non-empty `data.files` to selected pipeline | +| Selected apply pipeline | `apps/core/src/pipeline/applySelectedRenameFilesPlan.ts` (new) | Membership validation, reject → create subset → apply | +| Selection error | same new file | `SelectedFilesNotInPlanError extends Error`, carries offending paths | +| Core API | `apps/core/src/Core.ts` | `applyPlan(plan, data?: ApplyPlanData)` | +| HTTP surface | `apps/cli/src/route/RenameEpisodesPlan.ts` | Read `data`, map selection errors to ProblemDetails 400 | +| Core unit tests | `apps/core/src/pipeline/applySelectedRenameFilesPlan.test.ts` (new) | Pipeline behavior | + +## 2.3 Key Design + +### Core API + +```ts +interface ApplyPlanData { files?: string[] } + +// Core.ts +applyPlan(plan: Plan, data?: ApplyPlanData): Promise + +// applySelectedRenameFilesPlan.ts +class SelectedFilesNotInPlanError extends Error { + readonly files: string[] // offending paths +} + +function applySelectedRenameFilesPlanPipeline( + plan: RenameFilesPlan, + selectedFiles: string[], + deps: ApplyPlanDeps, +): Promise +``` + +Pipeline steps: + +1. `selectedFiles` empty → plain `Error` (caller bug, not an HTTP concern). +2. For each selected path, require a `plan.files[].from` match via `mediaFilePathEqual`; any miss → `SelectedFilesNotInPlanError(offenders)` **before** any disk write. +3. `filtered` = `plan.files` entries whose `from` was selected (duplicate selections collapse). +4. `rejectPlan(fs, appDataDir, plan.id)`. +5. `createRenameEpisodePlanPipeline(plan.mediaFolderPath, filtered, { creator: plan.creator }, deps)` — reuses existing validation (metadata exists, episode assertions, rename operations), writes a fresh pending plan. +6. `applyRenameFilesPlanPipeline(newPlan, deps)` — renames selected entries plus their associated files, rewrites metadata, deletes the new plan file. + +### HTTP surface + +`POST /api/apply-plan` body: `{ id: string, data?: { files?: string[] } }`. + +- Malformed/empty `data.files`, or `SelectedFilesNotInPlanError` → 400 `application/problem+json`: + +```json +{ + "type": "about:blank", + "title": "Bad Request", + "status": 400, + "detail": "Files not in plan: /a.mkv", + "instance": "/api/apply-plan" +} +``` + +`ProblemDetails` type comes from `@smm/types`. + +### Out of scope + +- UI changes (`useApplyPlanMutation`, plan table selection) — separate change +- CLI binary commands (`smm apply` gains no selection flag) +- e2e changes (UC3 e2e drives the UI) +- New plan status or `data` handling for `recognize-media-file` plans + +## 3. User Stories + +### 3.1 Apply selected episodes + +* **Given** a pending rename-files plan for 3 episodes +* **When** client applies with `data: { files: [from1, from3] }` +* **Then** only episodes 1 and 3 (plus associated files) are renamed, the original plan file remains `rejected`, the subset plan is applied and deleted, and metadata points to the new paths + +### 3.2 Reject unknown selection + +* **Given** a pending rename-files plan +* **When** client applies with a `files` entry not in the plan's `from` list +* **Then** HTTP 400 ProblemDetails lists the offender and no file, plan, or metadata changes + +### 3.3 Full apply unchanged + +* **Given** a pending rename-files plan +* **When** client applies without `data` +* **Then** behavior identical to today: all entries applied, plan file deleted + +## 4. Test Plan + +**Core unit** (`applySelectedRenameFilesPlan.test.ts`, in-memory `FsPort`) + +1. Selected subset: only chosen entries + associated files renamed; original plan `rejected` on disk; new plan file deleted after apply; metadata rewritten +2. Unmatched file → `SelectedFilesNotInPlanError` with offending paths; zero disk changes +3. Empty selection → error +4. Windows-style separators in selection match POSIX `from` +5. `applyPlanPipeline` dispatch: `data.files` routes to selected pipeline; absent `data` keeps full apply; `recognize-media-file` ignores `data` + +**CLI unit/route tests** (follow existing pattern in `apps/cli`, if present) + +6. `data.files` malformed/empty → 400 problem+json +7. `SelectedFilesNotInPlanError` → 400 problem+json with offender list in `detail` +8. No `data` → existing behavior unchanged From caa773482e76e90b2081877fbda272b03798db93 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Fri, 4 Sep 2026 18:45:35 +0800 Subject: [PATCH 16/83] feat(core): apply rename plan with selected files Co-Authored-By: Claude Opus 4.7 --- .../applySelectedRenameFilesPlan.test.ts | 157 ++++++++++++++++++ .../pipeline/applySelectedRenameFilesPlan.ts | 57 +++++++ 2 files changed, 214 insertions(+) create mode 100644 apps/core/src/pipeline/applySelectedRenameFilesPlan.test.ts create mode 100644 apps/core/src/pipeline/applySelectedRenameFilesPlan.ts diff --git a/apps/core/src/pipeline/applySelectedRenameFilesPlan.test.ts b/apps/core/src/pipeline/applySelectedRenameFilesPlan.test.ts new file mode 100644 index 00000000..5ce7d991 --- /dev/null +++ b/apps/core/src/pipeline/applySelectedRenameFilesPlan.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MediaMetadata } from "@smm/types"; +import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan"; +import type { FsPort } from "../ports/FsPort"; +import { planFilePath } from "./paths"; +import { + applySelectedRenameFilesPlanPipeline, + SelectedFilesNotInPlanError, +} from "./applySelectedRenameFilesPlan"; + +const appDataDir = "/data"; +const folder = "/m/Show"; + +function inMemoryFs(seed: Record = {}): FsPort & { raw: Map } { + const files = new Map(Object.entries(seed)); + return { + raw: files, + readTextFile: vi.fn(async (path: string) => { + const v = files.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }), + writeTextFile: vi.fn(async (path: string, content: string) => { + files.set(path, content); + }), + writeBinaryFile: vi.fn(async () => {}), + exists: vi.fn(async (path: string) => files.has(path)), + listFiles: vi.fn(async (dir: string) => { + const prefix = dir.endsWith("/") ? dir : `${dir}/`; + return [...files.keys()].filter((p) => p.startsWith(prefix)); + }), + deleteFile: vi.fn(async (path: string) => { + files.delete(path); + }), + rename: vi.fn(async (from: string, to: string) => { + const v = files.get(from); + if (v === undefined) throw new Error("ENOENT: " + from); + files.delete(from); + files.set(to, v); + }), + mkdir: vi.fn(async () => {}), + listSubdirectories: vi.fn(async () => []), + }; +} + +function basePlan(): RenameFilesPlan { + return { + id: "plan-1", + task: "rename-files", + status: "pending", + creator: "app", + mediaFolderPath: folder, + files: [ + { from: `${folder}/old1.mkv`, to: `${folder}/S01E01.mkv` }, + { from: `${folder}/old2.mkv`, to: `${folder}/S01E02.mkv` }, + ], + }; +} + +function baseMetadata(): MediaMetadata { + return { + mediaFolderPath: folder, + type: "tvshow-folder", + mediaFiles: [ + { absolutePath: `${folder}/old1.mkv`, seasonNumber: 1, episodeNumber: 1 }, + { absolutePath: `${folder}/old2.mkv`, seasonNumber: 1, episodeNumber: 2 }, + ], + } as never; +} + +function baseDeps(fs: ReturnType) { + return { + fs, + appDataDir, + normalizePosix: (p: string) => p, + getMediaMetadata: async () => baseMetadata(), + setMetadata: vi.fn(async (_mm: MediaMetadata) => {}), + }; +} + +function seedFs(plan: RenameFilesPlan) { + return inMemoryFs({ + [planFilePath(appDataDir, plan.id)]: JSON.stringify(plan), + [`${folder}/old1.mkv`]: "v1", + [`${folder}/old1.srt`]: "srt", + [`${folder}/old2.mkv`]: "v2", + }); +} + +describe("applySelectedRenameFilesPlanPipeline", () => { + it("applies only the selected entries and leaves the original rejected", async () => { + const plan = basePlan(); + const fs = seedFs(plan); + const deps = baseDeps(fs); + + await applySelectedRenameFilesPlanPipeline(plan, [`${folder}/old1.mkv`], deps); + + expect(fs.raw.has(`${folder}/S01E01.mkv`)).toBe(true); + expect(fs.raw.has(`${folder}/old1.mkv`)).toBe(false); + expect(fs.raw.has(`${folder}/S01E01.srt`)).toBe(true); + expect(fs.raw.has(`${folder}/old1.srt`)).toBe(false); + expect(fs.raw.has(`${folder}/old2.mkv`)).toBe(true); + expect(fs.raw.has(`${folder}/S01E02.mkv`)).toBe(false); + + const planFiles = [...fs.raw.keys()].filter((p) => p.endsWith(".plan.json")); + expect(planFiles).toEqual([planFilePath(appDataDir, "plan-1")]); + const rejected = JSON.parse(fs.raw.get(planFilePath(appDataDir, "plan-1"))!); + expect(rejected.status).toBe("rejected"); + + expect(deps.setMetadata).toHaveBeenCalledTimes(1); + const mm = deps.setMetadata.mock.calls[0][0] as MediaMetadata; + expect(mm.mediaFiles?.map((f) => f.absolutePath)).toEqual([ + `${folder}/S01E01.mkv`, + `${folder}/old2.mkv`, + ]); + }); + + it("throws SelectedFilesNotInPlanError and changes nothing for unknown files", async () => { + const plan = basePlan(); + const fs = seedFs(plan); + const deps = baseDeps(fs); + const before = new Map(fs.raw); + + const error = await applySelectedRenameFilesPlanPipeline(plan, [`${folder}/nope.mkv`], deps).then( + () => null, + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(SelectedFilesNotInPlanError); + expect((error as SelectedFilesNotInPlanError).files).toEqual([`${folder}/nope.mkv`]); + expect(fs.raw).toEqual(before); + expect(deps.setMetadata).not.toHaveBeenCalled(); + }); + + it("throws for an empty selection", async () => { + const plan = basePlan(); + const fs = seedFs(plan); + const deps = baseDeps(fs); + + await expect( + applySelectedRenameFilesPlanPipeline(plan, [], deps), + ).rejects.toThrow(/non-empty/); + }); + + it("matches selected files written with windows separators", async () => { + const plan = basePlan(); + const fs = seedFs(plan); + const deps = baseDeps(fs); + const winPath = `${folder}/old1.mkv`.replaceAll("/", "\\"); + + await expect( + applySelectedRenameFilesPlanPipeline(plan, [winPath], deps), + ).resolves.toBeUndefined(); + + expect(fs.raw.has(`${folder}/S01E01.mkv`)).toBe(true); + }); +}); diff --git a/apps/core/src/pipeline/applySelectedRenameFilesPlan.ts b/apps/core/src/pipeline/applySelectedRenameFilesPlan.ts new file mode 100644 index 00000000..1939ce5c --- /dev/null +++ b/apps/core/src/pipeline/applySelectedRenameFilesPlan.ts @@ -0,0 +1,57 @@ +import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan"; +import type { ApplyPlanDeps } from "./applyPlan"; +import { applyRenameFilesPlanPipeline } from "./applyRenameFilesPlan"; +import { createRenameEpisodePlanPipeline } from "./createRenameEpisodePlan"; +import { mediaFilePathEqual } from "./mediaFilePathEqual"; +import { rejectPlan } from "./plans"; + +export class SelectedFilesNotInPlanError extends Error { + readonly files: string[]; + + constructor(files: string[]) { + super(`Files not in plan: ${files.join(", ")}`); + this.name = "SelectedFilesNotInPlanError"; + this.files = files; + } +} + +/** + * UC3: apply only the selected "from" files of a pending rename plan. + * Rejects the original plan (kept on disk), creates a subset plan, applies it. + */ +export async function applySelectedRenameFilesPlanPipeline( + plan: RenameFilesPlan, + selectedFiles: string[], + deps: ApplyPlanDeps, +): Promise { + if (plan.task !== "rename-files") { + throw new Error(`Unsupported plan task: ${plan.task}`); + } + if (selectedFiles.length === 0) { + throw new Error("data.files must be a non-empty array"); + } + + // Normalize Windows separators first: mediaFilePathEqual's Path.posix + // fallback can't parse paths like "\m\Show\old1.mkv" (no drive letter). + const toPosix = (p: string) => p.replaceAll("\\", "/"); + + const offenders = selectedFiles.filter( + (file) => !plan.files.some((entry) => mediaFilePathEqual(entry.from, toPosix(file))), + ); + if (offenders.length > 0) { + throw new SelectedFilesNotInPlanError(offenders); + } + + const filtered = plan.files.filter((entry) => + selectedFiles.some((file) => mediaFilePathEqual(entry.from, toPosix(file))), + ); + + await rejectPlan(deps.fs, deps.appDataDir, plan.id); + const newPlan = await createRenameEpisodePlanPipeline( + plan.mediaFolderPath, + filtered, + { creator: plan.creator }, + deps, + ); + await applyRenameFilesPlanPipeline(newPlan, deps); +} From 3ec9d8e96e8ddeb88e2af782d960c2c9b2869688 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Fri, 4 Sep 2026 18:52:18 +0800 Subject: [PATCH 17/83] feat(core): dispatch applyPlan data to selected rename pipeline Co-Authored-By: Claude Opus 4.7 --- apps/core/src/Core.ts | 22 ++++---- apps/core/src/pipeline/applyPlan.ts | 14 ++++- .../applySelectedRenameFilesPlan.test.ts | 52 ++++++++++++++++++- 3 files changed, 77 insertions(+), 11 deletions(-) diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index 7251349b..e056cb1c 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -58,7 +58,7 @@ import { type RenameEpisodeFileInput, type RenameEpisodeFileResult, } from "./pipeline/renameEpisodeFile"; -import { applyPlanPipeline } from "./pipeline/applyPlan"; +import { applyPlanPipeline, type ApplyPlanData } from "./pipeline/applyPlan"; import { listPlans, readPlan, @@ -558,14 +558,18 @@ export class Core { return rejectPlan(this.fs, this.getMetadataRoot(), id); } - async applyPlan(plan: Plan): Promise { - await applyPlanPipeline(plan, { - fs: this.fs, - appDataDir: this.getMetadataRoot(), - normalizePosix: (p) => this.normalizePosix(p), - setMetadata: (mm) => this.writeMetadata(mm), - getMediaMetadata: (folder) => this.readMetadata(folder), - }); + async applyPlan(plan: Plan, data?: ApplyPlanData): Promise { + await applyPlanPipeline( + plan, + { + fs: this.fs, + appDataDir: this.getMetadataRoot(), + normalizePosix: (p) => this.normalizePosix(p), + setMetadata: (mm) => this.writeMetadata(mm), + getMediaMetadata: (folder) => this.readMetadata(folder), + }, + data, + ); } async scrapeFolder(path: string, options?: ScrapeFolderOptions): Promise { diff --git a/apps/core/src/pipeline/applyPlan.ts b/apps/core/src/pipeline/applyPlan.ts index 898625cd..9bff610d 100644 --- a/apps/core/src/pipeline/applyPlan.ts +++ b/apps/core/src/pipeline/applyPlan.ts @@ -1,6 +1,7 @@ import type { MediaMetadata } from "@smm/types"; import type { FsPort } from "../ports/FsPort"; import { applyRenameFilesPlanPipeline } from "./applyRenameFilesPlan"; +import { applySelectedRenameFilesPlanPipeline } from "./applySelectedRenameFilesPlan"; import { deletePlan, type Plan } from "./plans"; import { updateMediaFileMetadatas } from "./updateMediaFileMetadatas"; @@ -12,13 +13,24 @@ export interface ApplyPlanDeps { setMetadata: (mm: MediaMetadata) => Promise; } +export interface ApplyPlanData { + files?: string[]; +} + /** Dispatches apply by plan task (recognize-media-file or rename-files). */ -export async function applyPlanPipeline(plan: Plan, deps: ApplyPlanDeps): Promise { +export async function applyPlanPipeline( + plan: Plan, + deps: ApplyPlanDeps, + data?: ApplyPlanData, +): Promise { const task = plan.task; if (task === "recognize-media-file") { return applyRecognizeMediaFilePlanPipeline(plan, deps); } if (task === "rename-files") { + if (Array.isArray(data?.files)) { + return applySelectedRenameFilesPlanPipeline(plan, data.files, deps); + } return applyRenameFilesPlanPipeline(plan, deps); } throw new Error(`Unsupported plan task: ${task}`); diff --git a/apps/core/src/pipeline/applySelectedRenameFilesPlan.test.ts b/apps/core/src/pipeline/applySelectedRenameFilesPlan.test.ts index 5ce7d991..7c74bb72 100644 --- a/apps/core/src/pipeline/applySelectedRenameFilesPlan.test.ts +++ b/apps/core/src/pipeline/applySelectedRenameFilesPlan.test.ts @@ -7,6 +7,7 @@ import { applySelectedRenameFilesPlanPipeline, SelectedFilesNotInPlanError, } from "./applySelectedRenameFilesPlan"; +import { applyPlanPipeline } from "./applyPlan"; const appDataDir = "/data"; const folder = "/m/Show"; @@ -108,7 +109,7 @@ describe("applySelectedRenameFilesPlanPipeline", () => { expect(rejected.status).toBe("rejected"); expect(deps.setMetadata).toHaveBeenCalledTimes(1); - const mm = deps.setMetadata.mock.calls[0][0] as MediaMetadata; + const mm = deps.setMetadata.mock.calls[0]![0] as MediaMetadata; expect(mm.mediaFiles?.map((f) => f.absolutePath)).toEqual([ `${folder}/S01E01.mkv`, `${folder}/old2.mkv`, @@ -155,3 +156,52 @@ describe("applySelectedRenameFilesPlanPipeline", () => { expect(fs.raw.has(`${folder}/S01E01.mkv`)).toBe(true); }); }); + +describe("applyPlanPipeline dispatch with data", () => { + it("routes data.files to the selected pipeline", async () => { + const plan = basePlan(); + const fs = seedFs(plan); + const deps = baseDeps(fs); + + await applyPlanPipeline(plan, deps, { files: [`${folder}/old1.mkv`] }); + + expect(fs.raw.has(`${folder}/S01E01.mkv`)).toBe(true); + expect(fs.raw.has(`${folder}/S01E02.mkv`)).toBe(false); + const planFiles = [...fs.raw.keys()].filter((p) => p.endsWith(".plan.json")); + expect(planFiles).toEqual([planFilePath(appDataDir, "plan-1")]); + }); + + it("applies everything when data is absent", async () => { + const plan = basePlan(); + const fs = seedFs(plan); + const deps = baseDeps(fs); + + await applyPlanPipeline(plan, deps); + + expect(fs.raw.has(`${folder}/S01E01.mkv`)).toBe(true); + expect(fs.raw.has(`${folder}/S01E02.mkv`)).toBe(true); + const planFiles = [...fs.raw.keys()].filter((p) => p.endsWith(".plan.json")); + expect(planFiles).toEqual([]); + }); + + it("ignores data for recognize-media-file plans", async () => { + const plan = { + id: "rec-1", + task: "recognize-media-file" as const, + status: "pending" as const, + creator: "app" as const, + mediaFolderPath: folder, + files: [{ season: 1, episode: 2, path: `${folder}/old1.mkv` }], + }; + const fs = inMemoryFs({ + [planFilePath(appDataDir, "rec-1")]: JSON.stringify(plan), + [`${folder}/old1.mkv`]: "v1", + }); + const deps = baseDeps(fs); + + await applyPlanPipeline(plan, deps, { files: [`${folder}/nope.mkv`] }); + + expect(deps.setMetadata).toHaveBeenCalledTimes(1); + expect(fs.raw.has(planFilePath(appDataDir, "rec-1"))).toBe(false); + }); +}); From bb8c0d990b95f0f983da30f554b74c8d4a4e806e Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Fri, 4 Sep 2026 18:58:43 +0800 Subject: [PATCH 18/83] feat(cli): support selected files on apply-plan with ProblemDetails errors Co-Authored-By: Claude Opus 4.7 --- apps/cli/src/route/RenameEpisodesPlan.test.ts | 105 ++++++++++++++++++ apps/cli/src/route/RenameEpisodesPlan.ts | 46 +++++++- 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/route/RenameEpisodesPlan.test.ts b/apps/cli/src/route/RenameEpisodesPlan.test.ts index 7b53c35e..7d3b54e7 100644 --- a/apps/cli/src/route/RenameEpisodesPlan.test.ts +++ b/apps/cli/src/route/RenameEpisodesPlan.test.ts @@ -1,8 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { Hono } from 'hono' +import { SelectedFilesNotInPlanError } from '@smm/core/pipeline/applySelectedRenameFilesPlan' + const mocks = vi.hoisted(() => ({ createRenameEpisodePlan: vi.fn(), + getPlan: vi.fn(), + applyPlan: vi.fn(), broadcast: vi.fn(), })) @@ -35,6 +39,8 @@ describe('POST /api/create-rename-episode-plan', () => { beforeEach(() => { mocks.createRenameEpisodePlan.mockReset() + mocks.getPlan.mockReset() + mocks.applyPlan.mockReset() mocks.broadcast.mockReset() app = new Hono() handleRenameEpisodesPlan(app) @@ -116,3 +122,102 @@ describe('POST /api/create-rename-episode-plan', () => { expect(json.error.match(/Error Reason:/g)).toHaveLength(1) }) }) + +describe('POST /api/apply-plan', () => { + let app: Hono + + beforeEach(() => { + mocks.getPlan.mockReset() + mocks.applyPlan.mockReset() + app = new Hono() + handleRenameEpisodesPlan(app) + }) + + async function post(body: unknown) { + return app.request('/api/apply-plan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + it('applies with selected files when data.files is given', async () => { + mocks.getPlan.mockResolvedValue(plan) + mocks.applyPlan.mockResolvedValue(undefined) + + const response = await post({ + id: 'plan-1', + data: { files: ['/media/Show/old.mkv'] }, + }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ data: { id: 'plan-1' } }) + expect(mocks.getPlan).toHaveBeenCalledWith('plan-1') + expect(mocks.applyPlan).toHaveBeenCalledWith(plan, { + files: ['/media/Show/old.mkv'], + }) + expect(mocks.broadcast).toHaveBeenCalledWith({ + clientId: undefined, + event: 'mediaMetadataUpdated', + data: { folderPath: '/media/Show' }, + }) + }) + + it('applies with undefined data when data is absent', async () => { + mocks.getPlan.mockResolvedValue(plan) + mocks.applyPlan.mockResolvedValue(undefined) + + const response = await post({ id: 'plan-1' }) + + expect(response.status).toBe(200) + expect(mocks.applyPlan).toHaveBeenCalledWith(plan, undefined) + }) + + it('returns 400 ProblemDetails for malformed data.files', async () => { + const response = await post({ id: 'plan-1', data: { files: [] } }) + + expect(response.status).toBe(400) + expect(response.headers.get('Content-Type')).toContain('application/problem+json') + await expect(response.json()).resolves.toEqual({ + type: 'about:blank', + title: 'Bad Request', + status: 400, + detail: 'data.files must be a non-empty array of strings', + instance: '/api/apply-plan', + }) + expect(mocks.getPlan).not.toHaveBeenCalled() + }) + + it('returns 400 ProblemDetails when Core reports files not in plan', async () => { + mocks.getPlan.mockResolvedValue(plan) + mocks.applyPlan.mockRejectedValue( + new SelectedFilesNotInPlanError(['/media/Show/nope.mkv']), + ) + + const response = await post({ + id: 'plan-1', + data: { files: ['/media/Show/nope.mkv'] }, + }) + + expect(response.status).toBe(400) + expect(response.headers.get('Content-Type')).toContain('application/problem+json') + await expect(response.json()).resolves.toEqual({ + type: 'about:blank', + title: 'Bad Request', + status: 400, + detail: 'Files not in plan: /media/Show/nope.mkv', + instance: '/api/apply-plan', + }) + }) + + it('keeps the legacy Error Reason body for other Core errors', async () => { + mocks.getPlan.mockRejectedValue(new Error('Plan not found: plan-1')) + + const response = await post({ id: 'plan-1' }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + error: 'Error Reason: Plan not found: plan-1', + }) + }) +}) diff --git a/apps/cli/src/route/RenameEpisodesPlan.ts b/apps/cli/src/route/RenameEpisodesPlan.ts index 2b84bbb2..5d61be36 100644 --- a/apps/cli/src/route/RenameEpisodesPlan.ts +++ b/apps/cli/src/route/RenameEpisodesPlan.ts @@ -7,6 +7,8 @@ import { type RenameFilesPlanReadyRequestData, } from '@smm/types/event-types' import { formatToolError } from '@smm/core/ai-tool/toolResult' +import { SelectedFilesNotInPlanError } from '@smm/core/pipeline/applySelectedRenameFilesPlan' +import type { ProblemDetails } from '@smm/types' import { getCore } from '../core/getCore' import { broadcast } from '@/utils/socketIO' import { getAppDataDir } from '@/utils/config' @@ -35,6 +37,7 @@ export interface CreateRenameEpisodePlanResponseBody { export interface ApplyPlanRequestBody { id: string + data?: { files?: string[] } } export interface ApplyPlanResponseBody { @@ -77,6 +80,34 @@ function readRenameFiles( return files as Array<{ from: string; to: string }> } +type ApplyPlanDataSelection = + | { kind: 'absent' } + | { kind: 'selected'; files: string[] } + | { kind: 'invalid' } + +function readApplyPlanData(body: unknown): ApplyPlanDataSelection { + if (typeof body !== 'object' || body === null || !('data' in body)) { + return { kind: 'absent' } + } + const data = (body as Record).data + if (data === undefined || data === null) return { kind: 'absent' } + if (typeof data !== 'object') return { kind: 'invalid' } + const files = (data as Record).files + if (!Array.isArray(files) || files.length === 0) return { kind: 'invalid' } + if (!files.every((file) => typeof file === 'string')) return { kind: 'invalid' } + return { kind: 'selected', files: files as string[] } +} + +function problemDetails(detail: string): ProblemDetails { + return { + type: 'about:blank', + title: 'Bad Request', + status: 400, + detail, + instance: '/api/apply-plan', + } +} + export async function createRenameEpisodePlanFromBody( body: unknown, ): Promise { @@ -184,9 +215,18 @@ export function handleRenameEpisodesPlan(app: Hono): void { return c.json(err, 200) } + const selection = readApplyPlanData(body) + if (selection.kind === 'invalid') { + const problem = problemDetails('data.files must be a non-empty array of strings') + return c.json(problem, 400, { 'Content-Type': 'application/problem+json' }) + } + const clientId = c.req.header('clientId') const plan = await getCore().getPlan(id) - await getCore().applyPlan(plan) + await getCore().applyPlan( + plan, + selection.kind === 'selected' ? { files: selection.files } : undefined, + ) if (plan.task === 'rename-files') { broadcast({ @@ -200,6 +240,10 @@ export function handleRenameEpisodesPlan(app: Hono): void { return c.json(ok, 200) } catch (error) { logger.error({ error }, '[POST /api/apply-plan] route error') + if (error instanceof SelectedFilesNotInPlanError) { + const problem = problemDetails(error.message) + return c.json(problem, 400, { 'Content-Type': 'application/problem+json' }) + } const err: ApplyPlanResponseBody = { error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, } From 9db702eb59edc32bf7b345769c169f67b05b70b0 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Fri, 4 Sep 2026 19:04:24 +0800 Subject: [PATCH 19/83] refactor(cli): skip error-level log for apply-plan selection errors Co-Authored-By: Claude Opus 4.7 --- apps/cli/src/route/RenameEpisodesPlan.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/route/RenameEpisodesPlan.ts b/apps/cli/src/route/RenameEpisodesPlan.ts index 5d61be36..44f0b519 100644 --- a/apps/cli/src/route/RenameEpisodesPlan.ts +++ b/apps/cli/src/route/RenameEpisodesPlan.ts @@ -239,11 +239,11 @@ export function handleRenameEpisodesPlan(app: Hono): void { const ok: ApplyPlanResponseBody = { data: { id: plan.id } } return c.json(ok, 200) } catch (error) { - logger.error({ error }, '[POST /api/apply-plan] route error') if (error instanceof SelectedFilesNotInPlanError) { const problem = problemDetails(error.message) return c.json(problem, 400, { 'Content-Type': 'application/problem+json' }) } + logger.error({ error }, '[POST /api/apply-plan] route error') const err: ApplyPlanResponseBody = { error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, } From 6a093ec3b9b45d8fb759aceff2df270738e309ae Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Fri, 4 Sep 2026 19:10:11 +0800 Subject: [PATCH 20/83] docs: add UC3 apply-plan-selected-files implementation plan Co-Authored-By: Claude Opus 4.7 --- ...026-09-04-uc3-apply-plan-selected-files.md | 708 ++++++++++++++++++ ...04-uc3-apply-plan-selected-files-design.md | 2 + 2 files changed, 710 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-04-uc3-apply-plan-selected-files.md diff --git a/docs/superpowers/plans/2026-09-04-uc3-apply-plan-selected-files.md b/docs/superpowers/plans/2026-09-04-uc3-apply-plan-selected-files.md new file mode 100644 index 00000000..34241864 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-uc3-apply-plan-selected-files.md @@ -0,0 +1,708 @@ +# UC3 Apply Plan With Selected Episodes Implementation Plan + +**Status**: Implemented (2026-09-04, commits caa77348..9db702eb) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `POST /api/apply-plan` accepts `data: { files }` to apply only selected episodes of a rename plan; Core rejects the original plan, creates a subset plan, and applies it; selection errors return RFC 9457 ProblemDetails (400). + +**Architecture:** New core pipeline `applySelectedRenameFilesPlanPipeline` (validate membership → reject → create subset plan → apply). `applyPlanPipeline`/`Core.applyPlan` gain an optional `data?: { files?: string[] }` argument. The CLI route reads `data` from the body and maps `SelectedFilesNotInPlanError` + shape errors to `400 application/problem+json`. + +**Tech Stack:** TypeScript, Vitest (core + cli), Hono, `@smm/types` (`ProblemDetails`, `RenameFilesPlan`). + +**Spec:** `docs/superpowers/specs/2026-09-04-uc3-apply-plan-selected-files-design.md` + +## File Structure + +| Action | Path | Responsibility | +|--------|------|----------------| +| Create | `apps/core/src/pipeline/applySelectedRenameFilesPlan.ts` | `SelectedFilesNotInPlanError` + selected-apply pipeline (reject → create subset → apply) | +| Create | `apps/core/src/pipeline/applySelectedRenameFilesPlan.test.ts` | Pipeline + dispatch tests (in-memory `FsPort`) | +| Modify | `apps/core/src/pipeline/applyPlan.ts` | `ApplyPlanData` type; `applyPlanPipeline(plan, deps, data?)` dispatch | +| Modify | `apps/core/src/Core.ts` (~line 561) | `applyPlan(plan, data?)` passes data through | +| Modify | `apps/cli/src/route/RenameEpisodesPlan.ts` | `POST /api/apply-plan` reads `data`, returns ProblemDetails 400 for selection errors | +| Modify | `apps/cli/src/route/RenameEpisodesPlan.test.ts` | apply-plan route tests | + +Established facts (verified in repo): +- `ApplyPlanDeps` = `{ fs, appDataDir, normalizePosix, getMediaMetadata, setMetadata }` (`apps/core/src/pipeline/applyPlan.ts:7`) — structurally satisfies `CreateRenameEpisodePlanDeps` (its `createId?` is optional and omitted → `randomUUID`). +- `rejectPlan(fs, appDataDir, id)` marks `rejected` and keeps the file (`apps/core/src/pipeline/plans.ts:89`). +- `planFilePath(appDataDir, id)` = `/plans/.plan.json` (`apps/core/src/pipeline/paths.ts:34`). +- `mediaFilePathEqual(a, b)` compares via `Path.posix` with fallback (`apps/core/src/pipeline/mediaFilePathEqual.ts`). +- `createRenameEpisodePlanPipeline(folder, files, options, deps)` validates metadata/episodes/rename-ops and writes a `pending` plan (`apps/core/src/pipeline/createRenameEpisodePlan.ts:36`). +- `applyRenameFilesPlanPipeline` renames entries + associated files, rewrites metadata, deletes the plan file (`apps/core/src/pipeline/applyRenameFilesPlan.ts:14`). +- Core exports subpaths: `"./pipeline/*": "./src/pipeline/*"`; CLI already imports `@smm/core/ai-tool/toolResult` the same way. +- `ProblemDetails` is exported from `@smm/types` root (`packages/types/types.ts:884`). +- Existing circular-import pattern is fine: value import one direction, type-only the other (`applyRenameFilesPlan.ts` ↔ `applyPlan.ts`). + +--- + +### Task 1: Core — selected-files apply pipeline + +**Files:** +- Create: `apps/core/src/pipeline/applySelectedRenameFilesPlan.test.ts` +- Create: `apps/core/src/pipeline/applySelectedRenameFilesPlan.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `apps/core/src/pipeline/applySelectedRenameFilesPlan.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import type { MediaMetadata } from "@smm/types"; +import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan"; +import type { FsPort } from "../ports/FsPort"; +import { planFilePath } from "./paths"; +import { + applySelectedRenameFilesPlanPipeline, + SelectedFilesNotInPlanError, +} from "./applySelectedRenameFilesPlan"; + +const appDataDir = "/data"; +const folder = "/m/Show"; + +function inMemoryFs(seed: Record = {}): FsPort & { raw: Map } { + const files = new Map(Object.entries(seed)); + return { + raw: files, + readTextFile: vi.fn(async (path: string) => { + const v = files.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }), + writeTextFile: vi.fn(async (path: string, content: string) => { + files.set(path, content); + }), + writeBinaryFile: vi.fn(async () => {}), + exists: vi.fn(async (path: string) => files.has(path)), + listFiles: vi.fn(async (dir: string) => { + const prefix = dir.endsWith("/") ? dir : `${dir}/`; + return [...files.keys()].filter((p) => p.startsWith(prefix)); + }), + deleteFile: vi.fn(async (path: string) => { + files.delete(path); + }), + rename: vi.fn(async (from: string, to: string) => { + const v = files.get(from); + if (v === undefined) throw new Error("ENOENT: " + from); + files.delete(from); + files.set(to, v); + }), + mkdir: vi.fn(async () => {}), + listSubdirectories: vi.fn(async () => []), + }; +} + +function basePlan(): RenameFilesPlan { + return { + id: "plan-1", + task: "rename-files", + status: "pending", + creator: "app", + mediaFolderPath: folder, + files: [ + { from: `${folder}/old1.mkv`, to: `${folder}/S01E01.mkv` }, + { from: `${folder}/old2.mkv`, to: `${folder}/S01E02.mkv` }, + ], + }; +} + +function baseMetadata(): MediaMetadata { + return { + mediaFolderPath: folder, + type: "tvshow-folder", + mediaFiles: [ + { absolutePath: `${folder}/old1.mkv`, seasonNumber: 1, episodeNumber: 1 }, + { absolutePath: `${folder}/old2.mkv`, seasonNumber: 1, episodeNumber: 2 }, + ], + } as never; +} + +function baseDeps(fs: ReturnType) { + return { + fs, + appDataDir, + normalizePosix: (p: string) => p, + getMediaMetadata: async () => baseMetadata(), + setMetadata: vi.fn(async (_mm: MediaMetadata) => {}), + }; +} + +function seedFs(plan: RenameFilesPlan) { + return inMemoryFs({ + [planFilePath(appDataDir, plan.id)]: JSON.stringify(plan), + [`${folder}/old1.mkv`]: "v1", + [`${folder}/old1.srt`]: "srt", + [`${folder}/old2.mkv`]: "v2", + }); +} + +describe("applySelectedRenameFilesPlanPipeline", () => { + it("applies only the selected entries and leaves the original rejected", async () => { + const plan = basePlan(); + const fs = seedFs(plan); + const deps = baseDeps(fs); + + await applySelectedRenameFilesPlanPipeline(plan, [`${folder}/old1.mkv`], deps); + + expect(fs.raw.has(`${folder}/S01E01.mkv`)).toBe(true); + expect(fs.raw.has(`${folder}/old1.mkv`)).toBe(false); + expect(fs.raw.has(`${folder}/S01E01.srt`)).toBe(true); + expect(fs.raw.has(`${folder}/old1.srt`)).toBe(false); + expect(fs.raw.has(`${folder}/old2.mkv`)).toBe(true); + expect(fs.raw.has(`${folder}/S01E02.mkv`)).toBe(false); + + const planFiles = [...fs.raw.keys()].filter((p) => p.endsWith(".plan.json")); + expect(planFiles).toEqual([planFilePath(appDataDir, "plan-1")]); + const rejected = JSON.parse(fs.raw.get(planFilePath(appDataDir, "plan-1"))!); + expect(rejected.status).toBe("rejected"); + + expect(deps.setMetadata).toHaveBeenCalledTimes(1); + const mm = deps.setMetadata.mock.calls[0][0] as MediaMetadata; + expect(mm.mediaFiles?.map((f) => f.absolutePath)).toEqual([ + `${folder}/S01E01.mkv`, + `${folder}/old2.mkv`, + ]); + }); + + it("throws SelectedFilesNotInPlanError and changes nothing for unknown files", async () => { + const plan = basePlan(); + const fs = seedFs(plan); + const deps = baseDeps(fs); + const before = new Map(fs.raw); + + const error = await applySelectedRenameFilesPlanPipeline(plan, [`${folder}/nope.mkv`], deps).then( + () => null, + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(SelectedFilesNotInPlanError); + expect((error as SelectedFilesNotInPlanError).files).toEqual([`${folder}/nope.mkv`]); + expect(fs.raw).toEqual(before); + expect(deps.setMetadata).not.toHaveBeenCalled(); + }); + + it("throws for an empty selection", async () => { + const plan = basePlan(); + const fs = seedFs(plan); + const deps = baseDeps(fs); + + await expect( + applySelectedRenameFilesPlanPipeline(plan, [], deps), + ).rejects.toThrow(/non-empty/); + }); + + it("matches selected files written with windows separators", async () => { + const plan = basePlan(); + const fs = seedFs(plan); + const deps = baseDeps(fs); + const winPath = `${folder}/old1.mkv`.replaceAll("/", "\\"); + + await expect( + applySelectedRenameFilesPlanPipeline(plan, [winPath], deps), + ).resolves.toBeUndefined(); + + expect(fs.raw.has(`${folder}/S01E01.mkv`)).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @smm/core exec vitest run src/pipeline/applySelectedRenameFilesPlan.test.ts` +Expected: FAIL — cannot resolve `./applySelectedRenameFilesPlan` + +- [ ] **Step 3: Write the implementation** + +Create `apps/core/src/pipeline/applySelectedRenameFilesPlan.ts`: + +```ts +import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan"; +import type { ApplyPlanDeps } from "./applyPlan"; +import { applyRenameFilesPlanPipeline } from "./applyRenameFilesPlan"; +import { createRenameEpisodePlanPipeline } from "./createRenameEpisodePlan"; +import { mediaFilePathEqual } from "./mediaFilePathEqual"; +import { rejectPlan } from "./plans"; + +export class SelectedFilesNotInPlanError extends Error { + readonly files: string[]; + + constructor(files: string[]) { + super(`Files not in plan: ${files.join(", ")}`); + this.name = "SelectedFilesNotInPlanError"; + this.files = files; + } +} + +/** + * UC3: apply only the selected "from" files of a pending rename plan. + * Rejects the original plan (kept on disk), creates a subset plan, applies it. + */ +export async function applySelectedRenameFilesPlanPipeline( + plan: RenameFilesPlan, + selectedFiles: string[], + deps: ApplyPlanDeps, +): Promise { + if (plan.task !== "rename-files") { + throw new Error(`Unsupported plan task: ${plan.task}`); + } + if (selectedFiles.length === 0) { + throw new Error("data.files must be a non-empty array"); + } + + const offenders = selectedFiles.filter( + (file) => !plan.files.some((entry) => mediaFilePathEqual(entry.from, file)), + ); + if (offenders.length > 0) { + throw new SelectedFilesNotInPlanError(offenders); + } + + const filtered = plan.files.filter((entry) => + selectedFiles.some((file) => mediaFilePathEqual(entry.from, file)), + ); + + await rejectPlan(deps.fs, deps.appDataDir, plan.id); + const newPlan = await createRenameEpisodePlanPipeline( + plan.mediaFolderPath, + filtered, + { creator: plan.creator }, + deps, + ); + await applyRenameFilesPlanPipeline(newPlan, deps); +} +``` + +Note: passing `deps` (an `ApplyPlanDeps`) where `CreateRenameEpisodePlanDeps` is expected is fine — it has every required property (`createId` omitted → `randomUUID`). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter @smm/core exec vitest run src/pipeline/applySelectedRenameFilesPlan.test.ts` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/pipeline/applySelectedRenameFilesPlan.ts apps/core/src/pipeline/applySelectedRenameFilesPlan.test.ts +git commit -m "$(cat <<'EOF' +feat(core): apply rename plan with selected files + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +### Task 2: Core — dispatch `data` through `applyPlanPipeline` and `Core.applyPlan` + +**Files:** +- Modify: `apps/core/src/pipeline/applyPlan.ts` +- Modify: `apps/core/src/Core.ts:561-569` +- Test: `apps/core/src/pipeline/applySelectedRenameFilesPlan.test.ts` (append a `describe` block) + +- [ ] **Step 1: Write the failing dispatch tests** + +Append to `apps/core/src/pipeline/applySelectedRenameFilesPlan.test.ts` (add `applyPlanPipeline` to the imports from the local pipeline): + +```ts +import { applyPlanPipeline } from "./applyPlan"; +``` + +```ts +describe("applyPlanPipeline dispatch with data", () => { + it("routes data.files to the selected pipeline", async () => { + const plan = basePlan(); + const fs = seedFs(plan); + const deps = baseDeps(fs); + + await applyPlanPipeline(plan, deps, { files: [`${folder}/old1.mkv`] }); + + expect(fs.raw.has(`${folder}/S01E01.mkv`)).toBe(true); + expect(fs.raw.has(`${folder}/S01E02.mkv`)).toBe(false); + const planFiles = [...fs.raw.keys()].filter((p) => p.endsWith(".plan.json")); + expect(planFiles).toEqual([planFilePath(appDataDir, "plan-1")]); + }); + + it("applies everything when data is absent", async () => { + const plan = basePlan(); + const fs = seedFs(plan); + const deps = baseDeps(fs); + + await applyPlanPipeline(plan, deps); + + expect(fs.raw.has(`${folder}/S01E01.mkv`)).toBe(true); + expect(fs.raw.has(`${folder}/S01E02.mkv`)).toBe(true); + const planFiles = [...fs.raw.keys()].filter((p) => p.endsWith(".plan.json")); + expect(planFiles).toEqual([]); + }); + + it("ignores data for recognize-media-file plans", async () => { + const plan = { + id: "rec-1", + task: "recognize-media-file" as const, + status: "pending" as const, + creator: "app" as const, + mediaFolderPath: folder, + files: [{ season: 1, episode: 2, path: `${folder}/old1.mkv` }], + }; + const fs = inMemoryFs({ + [planFilePath(appDataDir, "rec-1")]: JSON.stringify(plan), + [`${folder}/old1.mkv`]: "v1", + }); + const deps = baseDeps(fs); + + await applyPlanPipeline(plan, deps, { files: [`${folder}/nope.mkv`] }); + + expect(deps.setMetadata).toHaveBeenCalledTimes(1); + expect(fs.raw.has(planFilePath(appDataDir, "rec-1"))).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @smm/core exec vitest run src/pipeline/applySelectedRenameFilesPlan.test.ts` +Expected: FAIL — `applyPlanPipeline` doesn't accept a third argument / TS error "Expected 2 arguments" + +- [ ] **Step 3: Implement the dispatch** + +In `apps/core/src/pipeline/applyPlan.ts` — add the import and `ApplyPlanData`, change the signature and the `rename-files` branch: + +```ts +import { applyRenameFilesPlanPipeline } from "./applyRenameFilesPlan"; +import { applySelectedRenameFilesPlanPipeline } from "./applySelectedRenameFilesPlan"; +``` + +```ts +export interface ApplyPlanData { + files?: string[]; +} + +/** Dispatches apply by plan task (recognize-media-file or rename-files). */ +export async function applyPlanPipeline( + plan: Plan, + deps: ApplyPlanDeps, + data?: ApplyPlanData, +): Promise { + const task = plan.task; + if (task === "recognize-media-file") { + return applyRecognizeMediaFilePlanPipeline(plan, deps); + } + if (task === "rename-files") { + if (Array.isArray(data?.files)) { + return applySelectedRenameFilesPlanPipeline(plan, data.files, deps); + } + return applyRenameFilesPlanPipeline(plan, deps); + } + throw new Error(`Unsupported plan task: ${task}`); +} +``` + +In `apps/core/src/Core.ts` (~line 561) — extend the import from `./pipeline/applyPlan` with `type ApplyPlanData` and change the method: + +```ts +async applyPlan(plan: Plan, data?: ApplyPlanData): Promise { + await applyPlanPipeline(plan, { + fs: this.fs, + appDataDir: this.getMetadataRoot(), + normalizePosix: (p) => this.normalizePosix(p), + setMetadata: (mm) => this.writeMetadata(mm), + getMediaMetadata: (folder) => this.readMetadata(folder), + }, data); +} +``` + +- [ ] **Step 4: Run tests and typecheck to verify they pass** + +Run: `pnpm --filter @smm/core exec vitest run src/pipeline/applySelectedRenameFilesPlan.test.ts && pnpm --filter @smm/core typecheck` +Expected: PASS (7 tests), typecheck clean + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/pipeline/applyPlan.ts apps/core/src/Core.ts apps/core/src/pipeline/applySelectedRenameFilesPlan.test.ts +git commit -m "$(cat <<'EOF' +feat(core): dispatch applyPlan data to selected rename pipeline + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +### Task 3: CLI — `POST /api/apply-plan` reads `data`, ProblemDetails errors + +**Files:** +- Modify: `apps/cli/src/route/RenameEpisodesPlan.test.ts` +- Modify: `apps/cli/src/route/RenameEpisodesPlan.ts` (apply-plan handler, ~line 172) + +- [ ] **Step 1: Write the failing route tests** + +In `apps/cli/src/route/RenameEpisodesPlan.test.ts`: + +Add to the hoisted mocks and imports: + +```ts +import { SelectedFilesNotInPlanError } from '@smm/core/pipeline/applySelectedRenameFilesPlan' + +const mocks = vi.hoisted(() => ({ + createRenameEpisodePlan: vi.fn(), + getPlan: vi.fn(), + applyPlan: vi.fn(), + broadcast: vi.fn(), +})) +``` + +In `beforeEach`, add `mocks.getPlan.mockReset()` and `mocks.applyPlan.mockReset()`. + +Append a new describe block: + +```ts +describe('POST /api/apply-plan', () => { + let app: Hono + + beforeEach(() => { + mocks.getPlan.mockReset() + mocks.applyPlan.mockReset() + app = new Hono() + handleRenameEpisodesPlan(app) + }) + + async function post(body: unknown) { + return app.request('/api/apply-plan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + it('applies with selected files when data.files is given', async () => { + mocks.getPlan.mockResolvedValue(plan) + mocks.applyPlan.mockResolvedValue(undefined) + + const response = await post({ + id: 'plan-1', + data: { files: ['/media/Show/old.mkv'] }, + }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ data: { id: 'plan-1' } }) + expect(mocks.getPlan).toHaveBeenCalledWith('plan-1') + expect(mocks.applyPlan).toHaveBeenCalledWith(plan, { + files: ['/media/Show/old.mkv'], + }) + expect(mocks.broadcast).toHaveBeenCalledWith({ + clientId: undefined, + event: 'mediaMetadataUpdated', + data: { folderPath: '/media/Show' }, + }) + }) + + it('applies with undefined data when data is absent', async () => { + mocks.getPlan.mockResolvedValue(plan) + mocks.applyPlan.mockResolvedValue(undefined) + + const response = await post({ id: 'plan-1' }) + + expect(response.status).toBe(200) + expect(mocks.applyPlan).toHaveBeenCalledWith(plan, undefined) + }) + + it('returns 400 ProblemDetails for malformed data.files', async () => { + const response = await post({ id: 'plan-1', data: { files: [] } }) + + expect(response.status).toBe(400) + expect(response.headers.get('Content-Type')).toContain('application/problem+json') + await expect(response.json()).resolves.toEqual({ + type: 'about:blank', + title: 'Bad Request', + status: 400, + detail: 'data.files must be a non-empty array of strings', + instance: '/api/apply-plan', + }) + expect(mocks.getPlan).not.toHaveBeenCalled() + }) + + it('returns 400 ProblemDetails when Core reports files not in plan', async () => { + mocks.getPlan.mockResolvedValue(plan) + mocks.applyPlan.mockRejectedValue( + new SelectedFilesNotInPlanError(['/media/Show/nope.mkv']), + ) + + const response = await post({ + id: 'plan-1', + data: { files: ['/media/Show/nope.mkv'] }, + }) + + expect(response.status).toBe(400) + expect(response.headers.get('Content-Type')).toContain('application/problem+json') + await expect(response.json()).resolves.toEqual({ + type: 'about:blank', + title: 'Bad Request', + status: 400, + detail: 'Files not in plan: /media/Show/nope.mkv', + instance: '/api/apply-plan', + }) + }) + + it('keeps the legacy Error Reason body for other Core errors', async () => { + mocks.getPlan.mockRejectedValue(new Error('Plan not found: plan-1')) + + const response = await post({ id: 'plan-1' }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + error: 'Error Reason: Plan not found: plan-1', + }) + }) +}) +``` + +(The `plan` fixture already exists at the top of the file: `id: 'plan-1'`, `task: 'rename-files'`, `mediaFolderPath: '/media/Show'`, `files: [{ from: '/media/Show/old.mkv', to: '/media/Show/S01E01.mkv' }]`.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter cli exec vitest run src/route/RenameEpisodesPlan.test.ts` +Expected: FAIL — new `POST /api/apply-plan` describe block fails (route ignores `data`, never returns 400) + +- [ ] **Step 3: Implement the route changes** + +In `apps/cli/src/route/RenameEpisodesPlan.ts`: + +Add imports: + +```ts +import type { ProblemDetails } from '@smm/types' +import { SelectedFilesNotInPlanError } from '@smm/core/pipeline/applySelectedRenameFilesPlan' +``` + +Replace the `ApplyPlanRequestBody` interface: + +```ts +export interface ApplyPlanRequestBody { + id: string + data?: { files?: string[] } +} +``` + +Add these helpers next to `readRenameFiles`: + +```ts +type ApplyPlanDataSelection = + | { kind: 'absent' } + | { kind: 'selected'; files: string[] } + | { kind: 'invalid' } + +function readApplyPlanData(body: unknown): ApplyPlanDataSelection { + if (typeof body !== 'object' || body === null || !('data' in body)) { + return { kind: 'absent' } + } + const data = (body as Record).data + if (data === undefined || data === null) return { kind: 'absent' } + if (typeof data !== 'object') return { kind: 'invalid' } + const files = (data as Record).files + if (!Array.isArray(files) || files.length === 0) return { kind: 'invalid' } + if (!files.every((file) => typeof file === 'string')) return { kind: 'invalid' } + return { kind: 'selected', files: files as string[] } +} + +function problemDetails(detail: string): ProblemDetails { + return { + type: 'about:blank', + title: 'Bad Request', + status: 400, + detail, + instance: '/api/apply-plan', + } +} +``` + +Replace the `app.post('/api/apply-plan', ...)` handler body between `try {` and the final `catch` closing brace: + +```ts +app.post('/api/apply-plan', async (c) => { + try { + let body: unknown = {} + try { + body = await c.req.json() + } catch { + /* empty */ + } + + const id = readStringField(body, 'id') + if (!id?.trim()) { + const err: ApplyPlanResponseBody = { error: 'Error Reason: id is required' } + return c.json(err, 200) + } + + const selection = readApplyPlanData(body) + if (selection.kind === 'invalid') { + const problem = problemDetails('data.files must be a non-empty array of strings') + return c.json(problem, 400, { 'Content-Type': 'application/problem+json' }) + } + + const clientId = c.req.header('clientId') + const plan = await getCore().getPlan(id) + await getCore().applyPlan( + plan, + selection.kind === 'selected' ? { files: selection.files } : undefined, + ) + + if (plan.task === 'rename-files') { + broadcast({ + clientId: clientId ?? undefined, + event: 'mediaMetadataUpdated', + data: { folderPath: Path.posix(plan.mediaFolderPath) }, + }) + } + + const ok: ApplyPlanResponseBody = { data: { id: plan.id } } + return c.json(ok, 200) + } catch (error) { + logger.error({ error }, '[POST /api/apply-plan] route error') + if (error instanceof SelectedFilesNotInPlanError) { + const problem = problemDetails(error.message) + return c.json(problem, 400, { 'Content-Type': 'application/problem+json' }) + } + const err: ApplyPlanResponseBody = { + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(err, 200) + } +}) +``` + +- [ ] **Step 4: Run tests and typecheck to verify they pass** + +Run: `pnpm --filter cli exec vitest run src/route/RenameEpisodesPlan.test.ts && pnpm --filter cli typecheck` +Expected: PASS (existing 4 + new 5), typecheck clean + +- [ ] **Step 5: Commit** + +```bash +git add apps/cli/src/route/RenameEpisodesPlan.ts apps/cli/src/route/RenameEpisodesPlan.test.ts +git commit -m "$(cat <<'EOF' +feat(cli): support selected files on apply-plan with ProblemDetails errors + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +### Task 4: Full verification (AGENTS.md post-change + pre-commit) + +**Files:** none (verification only) + +- [ ] **Step 1: Build, typecheck, and unit tests (per AGENTS.md)** + +```bash +pnpm build && pnpm typecheck && pnpm test:core && pnpm test:cli +``` + +Expected: all green. Fix any fallout before continuing (e.g., other `applyPlan` callers — none expected; signature change is additive/optional). + +- [ ] **Step 2: No commit needed if nothing changed** + +If Step 1 required fixes, commit them with an appropriate message; otherwise finish. diff --git a/docs/superpowers/specs/2026-09-04-uc3-apply-plan-selected-files-design.md b/docs/superpowers/specs/2026-09-04-uc3-apply-plan-selected-files-design.md index c8b74223..7d46034f 100644 --- a/docs/superpowers/specs/2026-09-04-uc3-apply-plan-selected-files-design.md +++ b/docs/superpowers/specs/2026-09-04-uc3-apply-plan-selected-files-design.md @@ -1,5 +1,7 @@ # UC3 Apply Plan With Selected Episodes (Rename Selected Files) +**Status**: Implemented (2026-09-04, commits caa77348..9db702eb) + This design document describe the high level design of a feature. The design document is golden source and reference by one or more features. From 49fc489d24ed8c957b2dfaa4abd6877828a870d9 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Fri, 4 Sep 2026 23:53:46 +0800 Subject: [PATCH 21/83] refactor: enhance the useRuleBasedRenameFilesFlow.ts --- apps/ui/src/api/applyPlan.ts | 2 + .../components/RuleBasedRenameFilePrompt.tsx | 16 +- .../src/components/media/MediaFileTable.tsx | 8 +- .../components/media/MediaFileTableBlocks.tsx | 296 ++++++ .../media/MediaFileTableDetailLayout.tsx | 165 +++ .../media/MediaFileTablePreviewLayout.tsx | 162 +++ .../components/media/MediaFileTableRow.tsx | 37 +- ...ediaFileTableSeasonDataLayouts.stories.tsx | 237 +++++ .../media/MediaFileTableSimpleLayout.test.tsx | 365 +++++++ .../media/MediaFileTableSimpleLayout.tsx | 142 +++ .../src/components/media/UIMediaFileTable.tsx | 989 +++--------------- apps/ui/src/components/tv/TvShowPanel.tsx | 143 ++- .../src/components/tv/TvShowPanelPrompts.tsx | 4 +- apps/ui/src/hooks/plans/index.ts | 6 + .../src/hooks/plans/useApplyPlanMutation.ts | 44 + .../src/hooks/plans/useRejectPlanMutation.ts | 33 + .../plans/useTryToRenameEpisodesMutation.ts | 38 + .../tv/useRuleBasedRenameFilesFlow.test.tsx | 320 ++++-- .../hooks/tv/useRuleBasedRenameFilesFlow.ts | 337 ++---- apps/ui/src/hooks/useTvShowPanel.ts | 50 +- docs/dev/rename-episodes.md | 56 +- 21 files changed, 2164 insertions(+), 1286 deletions(-) create mode 100644 apps/ui/src/components/media/MediaFileTableBlocks.tsx create mode 100644 apps/ui/src/components/media/MediaFileTableDetailLayout.tsx create mode 100644 apps/ui/src/components/media/MediaFileTablePreviewLayout.tsx create mode 100644 apps/ui/src/components/media/MediaFileTableSeasonDataLayouts.stories.tsx create mode 100644 apps/ui/src/components/media/MediaFileTableSimpleLayout.test.tsx create mode 100644 apps/ui/src/components/media/MediaFileTableSimpleLayout.tsx create mode 100644 apps/ui/src/hooks/plans/useApplyPlanMutation.ts create mode 100644 apps/ui/src/hooks/plans/useRejectPlanMutation.ts create mode 100644 apps/ui/src/hooks/plans/useTryToRenameEpisodesMutation.ts diff --git a/apps/ui/src/api/applyPlan.ts b/apps/ui/src/api/applyPlan.ts index 04f3f9e1..8f858b60 100644 --- a/apps/ui/src/api/applyPlan.ts +++ b/apps/ui/src/api/applyPlan.ts @@ -2,6 +2,8 @@ import { apiFetch } from '@/lib/apiFetch' export interface ApplyPlanRequest { id: string + /** UC3: apply only the selected "from" files of a rename-files plan. */ + data?: { files?: string[] } } export interface ApplyPlanResponseBody { diff --git a/apps/ui/src/components/RuleBasedRenameFilePrompt.tsx b/apps/ui/src/components/RuleBasedRenameFilePrompt.tsx index 09ce9f53..2938845e 100644 --- a/apps/ui/src/components/RuleBasedRenameFilePrompt.tsx +++ b/apps/ui/src/components/RuleBasedRenameFilePrompt.tsx @@ -1,4 +1,5 @@ import { FloatingPrompt, type FloatingPromptProps, type FloatingPromptOption } from "./FloatingPrompt" +import { type RenameRuleName } from "@/lib/renameRules" import { Select, SelectContent, @@ -17,15 +18,12 @@ export interface RuleBasedRenameFilePromptProps extends Omit void + selectedNamingRule: RenameRuleName /** * Callback when naming rules are selected and ready */ - onNamingRulesSelected?: (rule: string) => void + onNamingRulesSelected?: (rule: RenameRuleName) => Promise + loading?: boolean } /** @@ -35,11 +33,11 @@ export interface RuleBasedRenameFilePromptProps extends Omit { - onNamingRuleChange(value) - onNamingRulesSelected?.(value) + onNamingRulesSelected?.(value as RenameRuleName) }} + disabled={loading} > void + onCheck?: (season: number, episode: number, checked: boolean) => void /** * Controlled checkbox selection — which episodes are currently checked. * Omit → the underlying table manages the selection internally. @@ -57,6 +57,8 @@ export interface MediaFileTableProps { * control visibility / disabled / callbacks. */ contextMenuProps?: MediaFileTableContextMenuProps + newFilePaths?: { season: number, episode: number, newFilePath: string }[] + checboxVisible?: boolean } /** @@ -85,6 +87,8 @@ export function MediaFileTable(props: MediaFileTableProps) { selectedEpisodes, renderPreviewContent, contextMenuProps: contextMenuPropsProp, + newFilePaths, + checboxVisible = false, } = props const ctrl = useMediaFileTableController(mediaFolderPath) @@ -116,6 +120,8 @@ export function MediaFileTable(props: MediaFileTableProps) { selectedEpisodes={selectedEpisodes} renderPreviewContent={renderPreviewContent} onDoubleClick={ctrl.handleDoubleClick} + newFilePaths={newFilePaths} + checboxVisible={checboxVisible} /> ) } diff --git a/apps/ui/src/components/media/MediaFileTableBlocks.tsx b/apps/ui/src/components/media/MediaFileTableBlocks.tsx new file mode 100644 index 00000000..d3f9b76d --- /dev/null +++ b/apps/ui/src/components/media/MediaFileTableBlocks.tsx @@ -0,0 +1,296 @@ +import { TableBody, TableCell, TableRow } from "@/components/ui/table" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { ChevronRightIcon } from "lucide-react" +import { + cloneElement, + isValidElement, + type ReactElement, + type ReactNode, +} from "react" +import { useTranslation } from "@/lib/i18n" +import { cn } from "@/lib/utils" +import { rel } from "@/lib/path" +import { head } from "es-toolkit" +import { + EpisodeContextMenu, + type EpisodeContextMenuItem, + MediaFileTableEpisodeDetailRow, + MediaFileTableEpisodePreviewRow, + MediaFileTableEpisodeSimpleRow, +} from "./MediaFileTableRow" +import type { MediaFileTableSeasonData } from "./UIMediaFileTable" + +const collapsibleSectionContentClassName = cn( + "media-file-table-section-content overflow-hidden", + "data-[state=closed]:animate-[media-file-table-collapsible-up_200ms_ease-out]", + "data-[state=open]:animate-[media-file-table-collapsible-down_200ms_ease-out]", +) + +/** + * Collapsible season section for the `seasonData`-driven path: a header row + * (season title + collapse toggle) above the season content. + * + * The season content is supplied as `children`, e.g. + * ``. When the child is an + * `UIMediaFileTableEpisodeBlock`, this block's `items` are forwarded into it, + * so the caller can compose the episode rows as children while the episode + * menu items stay owned by the section. + */ +export function UIMediaFileTableSeasonBlock({ + season, + items = [], + isCollapsed, + onOpenChange, + showCheckboxColumn, + visibleColumnCount, + children, +}: { + season: MediaFileTableSeasonData + /** Right-click menu items for each episode row (forwarded to the episode block child). */ + items?: EpisodeContextMenuItem[] + /** Whether the section is currently collapsed (controlled by the table). */ + isCollapsed: boolean + /** Called with the new open state when the user toggles the section. */ + onOpenChange: (open: boolean) => void + showCheckboxColumn: boolean + visibleColumnCount: number + /** Content rendered below the season header (e.g. `UIMediaFileTableEpisodeBlock`). */ + children?: ReactNode +}) { + const { t } = useTranslation("components") + const expandLabel = t("mediaFileTable.expand") + const collapseLabel = t("mediaFileTable.collapse") + + // Compose the caller-supplied content. When it is one of the episode blocks + // (simple/detail/preview), forward this section's `items` so the per-row + // context menus keep working without the caller having to repeat the items + // on the child. + const content = + isValidElement(children) && + (children.type === UIMediaFileTableEpisodeBlock || + children.type === UIMediaFileTableEpisodeDetailBlock || + children.type === UIMediaFileTableEpisodePreviewBlock) + ? cloneElement(children as ReactElement, { + items, + }) + : children + + return ( + + + + {showCheckboxColumn && } + +
+ {season.title} + + + +
+
+
+ + + + {content} + + + +
+
+ ) +} + +/** Shared props of the season content blocks (`EpisodeBlock` / `EpisodeDetailBlock` / + * `EpisodePreviewBlock`) rendered inside `UIMediaFileTableSeasonBlock`. */ +export interface UIMediaFileTableEpisodeBlockProps { + season: MediaFileTableSeasonData + /** Right-click menu items for each episode row. */ + items?: EpisodeContextMenuItem[] + /** When set, paths are shown relative to this base. */ + mediaFolderPath?: string + subtitleFiles?: { season: number, episode: number, files: string[] }[] + nfoFiles?: { season: number, episode: number, files: string[] }[] + thumbnailFiles?: { season: number, episode: number, files: string[] }[] + newFilePaths?: { season: number, episode: number, newFilePath: string }[] + checboxVisible?: boolean, + onCheck?: (season: number, episode: number, checked: boolean) => void, + selectedEpisodes?: { season: number, episode: number }[] + /** + * When `true` (default), episodes without a video file (`MediaFileTableEpisodeData.path` + * is undefined) render their checkbox as disabled. + */ + disableCheckboxIfEpisodeVideoNotAvailable?: boolean +} + +export function UIMediaFileTableEpisodeBlock({ + season, + items = [], + mediaFolderPath, + subtitleFiles, + nfoFiles, + thumbnailFiles, + newFilePaths, + checboxVisible = false, + onCheck, + selectedEpisodes, + disableCheckboxIfEpisodeVideoNotAvailable = true, +}: UIMediaFileTableEpisodeBlockProps) { + return ( +
+ + {season.episodes.map((episode) => { + + const subtitle = subtitleFiles?.find((subtitle) => subtitle.season === season.season && subtitle.episode === episode.episode) + const subtitlePath = head(subtitle?.files ?? []) + + const nfo = nfoFiles?.find((nfo) => nfo.season === season.season && nfo.episode === episode.episode) + const nfoPath = head(nfo?.files ?? []) + + const thumbnail = thumbnailFiles?.find((thumbnail) => thumbnail.season === season.season && thumbnail.episode === episode.episode) + const thumbnailPath = head(thumbnail?.files ?? []) + + const newFilePath: string | undefined = newFilePaths?.find((newFilePath) => newFilePath.season === season.season && newFilePath.episode === episode.episode)?.newFilePath + + return ( + + onCheck?.(season.season, episode.episode, isChecked)} + isChecked={selectedEpisodes?.some((selectedEpisode) => selectedEpisode.season === season.season && selectedEpisode.episode === episode.episode)} + /> + + ) + })} + +
+ ) +} + +/** + * `detail`-layout season content block: one `MediaFileTableEpisodeDetailRow` + * per episode (id + cover thumbnail + title/path), each wrapped with its + * right-click menu. + */ +export function UIMediaFileTableEpisodeDetailBlock({ + season, + items = [], + mediaFolderPath, + subtitleFiles: _subtitleFiles, + nfoFiles: _nfoFiles, + thumbnailFiles: _thumbnailFiles, + checboxVisible = false, + onCheck, + selectedEpisodes, + disableCheckboxIfEpisodeVideoNotAvailable = true, +}: UIMediaFileTableEpisodeBlockProps) { + return ( + + + {season.episodes.map((episode) => ( + + + selectedEpisode.season === season.season && + selectedEpisode.episode === episode.episode, + )} + isCheckboxDisabled={disableCheckboxIfEpisodeVideoNotAvailable && !episode.path} + onCheck={ + checboxVisible + ? (isChecked) => onCheck?.(season.season, episode.episode, isChecked) + : undefined + } + /> + + ))} + +
+ ) +} + +/** + * `preview`-layout season content block: one `MediaFileTableEpisodePreviewRow` + * per episode (larger cover + id·title/path, no ID column), each wrapped with + * its right-click menu. + */ +export function UIMediaFileTableEpisodePreviewBlock({ + season, + items = [], + mediaFolderPath, + subtitleFiles: _subtitleFiles, + nfoFiles: _nfoFiles, + thumbnailFiles: _thumbnailFiles, + checboxVisible = false, + onCheck, + selectedEpisodes, + disableCheckboxIfEpisodeVideoNotAvailable = true, +}: UIMediaFileTableEpisodeBlockProps) { + return ( + + + {season.episodes.map((episode) => ( + + + selectedEpisode.season === season.season && + selectedEpisode.episode === episode.episode, + )} + isCheckboxDisabled={disableCheckboxIfEpisodeVideoNotAvailable && !episode.path} + onCheck={ + checboxVisible + ? (isChecked) => onCheck?.(season.season, episode.episode, isChecked) + : undefined + } + /> + + ))} + +
+ ) +} diff --git a/apps/ui/src/components/media/MediaFileTableDetailLayout.tsx b/apps/ui/src/components/media/MediaFileTableDetailLayout.tsx new file mode 100644 index 00000000..52b7f6f6 --- /dev/null +++ b/apps/ui/src/components/media/MediaFileTableDetailLayout.tsx @@ -0,0 +1,165 @@ +import { Table, TableCell, TableHead, TableHeader } from "@/components/ui/table" +import { useTranslation } from "@/lib/i18n" +import { rel } from "@/lib/path" +import { + MediaFileTableTr, + UICheckCell, +} from "./MediaFileTableRow" +import { + UIMediaFileTableEpisodeDetailBlock, + UIMediaFileTableSeasonBlock, +} from "./MediaFileTableBlocks" +import type { UIMediaFileTableProps } from "./UIMediaFileTable" +import type { MediaFileTableLayoutState } from "./UIMediaFileTable" +import type { MetadataFiles } from "@smm/types/MetadataFiles" + +const DETAIL_FIXED_COLUMN_COUNT = 5 + +function DetailColGroup({ showCheckboxColumn }: { showCheckboxColumn: boolean }) { + return ( + + {showCheckboxColumn && } + + + + + + + ) +} + +function DetailMetadataRows({ + metadataFiles, + mediaFolderPath, + showCheckboxColumn, +}: { + metadataFiles?: MetadataFiles + mediaFolderPath?: string + showCheckboxColumn: boolean +}) { + const fieldRows = [ + { name: "poster", path: metadataFiles?.posterPath }, + { name: "fanart", path: metadataFiles?.fanartPath }, + { name: "nfo", path: metadataFiles?.nfoPath }, + { name: "clearlogo", path: metadataFiles?.clearlogoPath }, + { name: "theme", path: metadataFiles?.themePath }, + ] + + return ( + <> + {fieldRows.map( + ({ name, path }) => + path && ( + + {showCheckboxColumn && } + {name} + + + {rel(mediaFolderPath, path)} + + + + + + + + + ), + )} + + ) +} + +export interface MediaFileTableDetailLayoutProps extends UIMediaFileTableProps { + tableState: MediaFileTableLayoutState +} + +export function MediaFileTableDetailLayout({ + seasonData = [], + metadataFiles, + mediaFolderPath, + checboxVisible = false, + onCheck, + selectedEpisodes, + disableCheckboxIfEpisodeVideoNotAvailable = true, + tableState, +}: MediaFileTableDetailLayoutProps) { + const { t } = useTranslation("components") + + const totalColumns = DETAIL_FIXED_COLUMN_COUNT + (checboxVisible ? 1 : 0) + + return ( +
+ + + + + {checboxVisible && ( + + )} + + {t("mediaFileTable.columns.id")} + + + {t("mediaFileTable.header.thumb")} + + + {t("mediaFileTable.header.videoFile")} + + + {t("mediaFileTable.header.sub")} + + + {t("mediaFileTable.header.nfo")} + + + + + + + {seasonData.map((season) => { + const collapsibleId = `season-${season.season}` + const isCollapsed = tableState.collapsedIds.has(collapsibleId) + return ( + tableState.setSectionCollapsed(collapsibleId, !open)} + showCheckboxColumn={checboxVisible} + visibleColumnCount={totalColumns} + > + + + ) + })} +
+
+ ) +} diff --git a/apps/ui/src/components/media/MediaFileTablePreviewLayout.tsx b/apps/ui/src/components/media/MediaFileTablePreviewLayout.tsx new file mode 100644 index 00000000..0be6ee79 --- /dev/null +++ b/apps/ui/src/components/media/MediaFileTablePreviewLayout.tsx @@ -0,0 +1,162 @@ +import { Table, TableCell, TableHead, TableHeader } from "@/components/ui/table" +import { useTranslation } from "@/lib/i18n" +import { rel } from "@/lib/path" +import { MediaFileTableTr, UICheckCell } from "./MediaFileTableRow" +import { + UIMediaFileTableEpisodePreviewBlock, + UIMediaFileTableSeasonBlock, +} from "./MediaFileTableBlocks" +import type { UIMediaFileTableProps } from "./UIMediaFileTable" +import type { MediaFileTableLayoutState } from "./UIMediaFileTable" +import type { MetadataFiles } from "@smm/types/MetadataFiles" + +const PREVIEW_FIXED_COLUMN_COUNT = 4 + +function PreviewColGroup({ showCheckboxColumn }: { showCheckboxColumn: boolean }) { + return ( + + {showCheckboxColumn && } + + + + + + ) +} + +function PreviewMetadataRows({ + metadataFiles, + mediaFolderPath, + showCheckboxColumn, +}: { + metadataFiles?: MetadataFiles + mediaFolderPath?: string + showCheckboxColumn: boolean +}) { + const fieldRows = [ + { name: "poster", path: metadataFiles?.posterPath }, + { name: "fanart", path: metadataFiles?.fanartPath }, + { name: "nfo", path: metadataFiles?.nfoPath }, + { name: "clearlogo", path: metadataFiles?.clearlogoPath }, + { name: "theme", path: metadataFiles?.themePath }, + ] + + return ( + <> + {fieldRows.map( + ({ name, path }) => + path && ( + + {showCheckboxColumn && } + + +
+
{name}
+
+ {rel(mediaFolderPath, path)} +
+
+
+ + + + + + +
+ ), + )} + + ) +} + +export interface MediaFileTablePreviewLayoutProps extends UIMediaFileTableProps { + tableState: MediaFileTableLayoutState +} + +export function MediaFileTablePreviewLayout({ + seasonData = [], + metadataFiles, + mediaFolderPath, + checboxVisible = false, + onCheck, + selectedEpisodes, + disableCheckboxIfEpisodeVideoNotAvailable = true, + tableState, +}: MediaFileTablePreviewLayoutProps) { + const { t } = useTranslation("components") + + const totalColumns = PREVIEW_FIXED_COLUMN_COUNT + (checboxVisible ? 1 : 0) + + return ( +
+ + + + + {checboxVisible && ( + + )} + + {t("mediaFileTable.header.thumb")} + + + {t("mediaFileTable.header.videoFile")} + + + {t("mediaFileTable.header.sub")} + + + {t("mediaFileTable.header.nfo")} + + + + + + + {seasonData.map((season) => { + const collapsibleId = `season-${season.season}` + const isCollapsed = tableState.collapsedIds.has(collapsibleId) + return ( + tableState.setSectionCollapsed(collapsibleId, !open)} + showCheckboxColumn={checboxVisible} + visibleColumnCount={totalColumns} + > + + + ) + })} +
+
+ ) +} diff --git a/apps/ui/src/components/media/MediaFileTableRow.tsx b/apps/ui/src/components/media/MediaFileTableRow.tsx index c88f3ca6..d483814a 100644 --- a/apps/ui/src/components/media/MediaFileTableRow.tsx +++ b/apps/ui/src/components/media/MediaFileTableRow.tsx @@ -250,6 +250,7 @@ export function MediaFileTableNameValueRow({ value, hoverTitle, className, + showCheckboxColumn = false, }: { /** Row label shown in the ID column (e.g. "subtitle"). */ name: string @@ -258,12 +259,14 @@ export function MediaFileTableNameValueRow({ /** Tooltip text shown when hovering the value. */ hoverTitle?: string className?: string + /** Render a leading empty checkbox spacer cell (keeps columns aligned when the table shows a checkbox column). */ + showCheckboxColumn?: boolean }) { return ( {value}} thumbnailContent={} @@ -780,10 +783,12 @@ export interface MediaFileTableEpisodeSimpleRowProps { nfoPath?: string, isChecked?: boolean, isDisabled?: boolean, + isCheckboxDisabled?: boolean, newFilePath?: string, newRecognizedFilePath?: string onCheck?: (isChecked: boolean) => void, onDoubleClick?: () => void, + checboxVisible?: boolean, } /** @@ -818,6 +823,7 @@ export function MediaFileTableEpisodeSimpleRow({ nfoPath, isChecked = false, isDisabled = false, + isCheckboxDisabled = false, newFilePath = undefined, newRecognizedFilePath = undefined, onCheck = undefined, @@ -826,9 +832,11 @@ export function MediaFileTableEpisodeSimpleRow({ // layout; alias it out so it does not leak onto the `` as a native // tooltip via `...rowProps`. title: _title, + checboxVisible = false, ...rowProps }: MediaFileTableEpisodeSimpleRowProps) { - const showCheckbox = onCheck !== undefined + const showCheckbox = checboxVisible + const checkboxDisabled = isDisabled || isCheckboxDisabled const renameTarget = newFilePath !== undefined && newFilePath !== path ? newFilePath : undefined @@ -856,12 +864,12 @@ export function MediaFileTableEpisodeSimpleRow({ role="checkbox" className={cn( "h-3.5 w-3.5", - isDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer", + checkboxDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer", )} - checked={isDisabled ? false : isChecked} - disabled={isDisabled} + checked={checkboxDisabled ? false : isChecked} + disabled={checkboxDisabled} onChange={(e) => { - if (isDisabled) return + if (checkboxDisabled) return onCheck?.(e.target.checked) }} /> @@ -912,12 +920,15 @@ export function MediaFileTableEpisodeSimpleRow({ function EpisodeRowCheckboxCell({ isChecked, isDisabled, + isCheckboxDisabled = false, onCheck, }: { isChecked: boolean isDisabled: boolean + isCheckboxDisabled?: boolean onCheck?: (checked: boolean) => void }) { + const checkboxDisabled = isDisabled || isCheckboxDisabled return ( { - if (isDisabled) return + if (checkboxDisabled) return onCheck?.(e.target.checked) }} /> @@ -953,6 +964,7 @@ export interface MediaFileTableEpisodeDetailRowProps { nfoPath?: string isChecked?: boolean isDisabled?: boolean + isCheckboxDisabled?: boolean onCheck?: (isChecked: boolean) => void onDoubleClick?: () => void } @@ -983,6 +995,7 @@ export function MediaFileTableEpisodeDetailRow({ nfoPath, isChecked = false, isDisabled = false, + isCheckboxDisabled = false, onCheck = undefined, onDoubleClick = undefined, ...rowProps @@ -1001,6 +1014,7 @@ export function MediaFileTableEpisodeDetailRow({ )} @@ -1066,6 +1080,7 @@ export interface MediaFileTableEpisodePreviewRowProps { nfoPath?: string isChecked?: boolean isDisabled?: boolean + isCheckboxDisabled?: boolean onCheck?: (isChecked: boolean) => void onDoubleClick?: () => void } @@ -1096,6 +1111,7 @@ export function MediaFileTableEpisodePreviewRow({ nfoPath, isChecked = false, isDisabled = false, + isCheckboxDisabled = false, onCheck = undefined, onDoubleClick = undefined, ...rowProps @@ -1114,6 +1130,7 @@ export function MediaFileTableEpisodePreviewRow({ )} diff --git a/apps/ui/src/components/media/MediaFileTableSeasonDataLayouts.stories.tsx b/apps/ui/src/components/media/MediaFileTableSeasonDataLayouts.stories.tsx new file mode 100644 index 00000000..e17db64c --- /dev/null +++ b/apps/ui/src/components/media/MediaFileTableSeasonDataLayouts.stories.tsx @@ -0,0 +1,237 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" +import { action } from "storybook/actions" +import { useState } from "react" +import { + UIMediaFileTable, + type MediaFileTableSeasonData, + type UIMediaEpisodeSelection, + type UIMediaFileTableProps, +} from "./UIMediaFileTable" +import type { MetadataFiles } from "@smm/types/MetadataFiles" + +// ------------------------------------------------------------------------ +// Fixtures (seasonData-driven path used by TvShowPanel) +// ------------------------------------------------------------------------ + +const mediaFolderPath = "/media/tv/Breaking Bad (2008)" + +const episodePath = (season: number, episode: number, title: string) => + `${mediaFolderPath}/Breaking Bad - S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")} - ${title}.mkv` + +const season1: MediaFileTableSeasonData = { + season: 1, + title: "Season 1", + episodes: [ + { season: 1, episode: 1, title: "Pilot", path: episodePath(1, 1, "Pilot") }, + { + season: 1, + episode: 2, + title: "Cat's in the Bag...", + path: episodePath(1, 2, "Cat's in the Bag"), + }, + { + season: 1, + episode: 3, + title: "...And the Bag's in the River", + path: episodePath(1, 3, "And the Bag's in the River"), + }, + { + season: 1, + episode: 4, + title: "Cancer Man", + // No video file linked → checkbox is disabled (default behavior). + path: undefined, + }, + ], +} + +const season2: MediaFileTableSeasonData = { + season: 2, + title: "Season 2", + episodes: [ + { + season: 2, + episode: 1, + title: "Seven Thirty-Seven", + path: episodePath(2, 1, "Seven Thirty-Seven"), + }, + { + season: 2, + episode: 2, + title: "Grilled", + path: episodePath(2, 2, "Grilled"), + }, + ], +} + +const seasonData: MediaFileTableSeasonData[] = [season1, season2] + +const metadataFiles: MetadataFiles = { + nfoPath: `${mediaFolderPath}/tvshow.nfo`, + posterPath: `${mediaFolderPath}/poster.jpg`, + fanartPath: `${mediaFolderPath}/fanart.jpg`, + clearlogoPath: `${mediaFolderPath}/clearlogo.png`, + themePath: `${mediaFolderPath}/theme.mp3`, + seasonPosters: [ + { season: 1, path: `${mediaFolderPath}/Season01.jpg` }, + { season: 2, path: `${mediaFolderPath}/Season02.jpg` }, + ], +} + +const subtitleFiles = [ + { + season: 1, + episode: 1, + files: [`${mediaFolderPath}/Breaking Bad - S01E01 - Pilot.en.srt`], + }, + { + season: 1, + episode: 3, + files: [`${mediaFolderPath}/Breaking Bad - S01E03 - And the Bag's in the River.zh.srt`], + }, + { + season: 2, + episode: 1, + files: [`${mediaFolderPath}/Breaking Bad - S02E01 - Seven Thirty-Seven.en.srt`], + }, +] + +const nfoFiles = [ + { + season: 1, + episode: 1, + files: [`${mediaFolderPath}/Breaking Bad - S01E01 - Pilot.nfo`], + }, + { + season: 2, + episode: 2, + files: [`${mediaFolderPath}/Breaking Bad - S02E02 - Grilled.nfo`], + }, +] + +const thumbnailFiles = [ + { + season: 1, + episode: 1, + files: [`${mediaFolderPath}/Breaking Bad - S01E01 - Pilot-thumb.jpg`], + }, + { + season: 2, + episode: 1, + files: [`${mediaFolderPath}/Breaking Bad - S02E01 - Seven Thirty-Seven-thumb.jpg`], + }, +] + +const defaultContextMenuProps = { + onOpenMenuClick: action("menu:open"), + onPropertiesMenuClick: action("menu:properties"), + renameMenuVisible: true, + onRenameMenuClick: action("menu:rename"), + unlinkMenuVisible: true, + onUnlinkMenuClick: action("menu:unlink"), +} + +// ------------------------------------------------------------------------ +// Stateful harness: mirrors TvShowPanel's controlled selection state +// ------------------------------------------------------------------------ + +const initialChecked: UIMediaEpisodeSelection[] = [{ season: 1, episode: 1 }] + +function SeasonDataHarness(props: UIMediaFileTableProps) { + const [checkedEpisodes, setCheckedEpisodes] = useState(initialChecked) + + return ( + { + action("onCheck")({ season, episode, isChecked }) + setCheckedEpisodes((prev) => { + const exists = prev.some( + (e) => e.season === season && e.episode === episode, + ) + if (isChecked === exists) return prev + if (isChecked) return [...prev, { season, episode }] + return prev.filter((e) => !(e.season === season && e.episode === episode)) + }) + }} + /> + ) +} + +// ------------------------------------------------------------------------ +// Meta +// ------------------------------------------------------------------------ + +const meta = { + title: "Components/UIMediaFileTable/Season Data Layouts", + component: UIMediaFileTable, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + render: (args) => , + args: { + data: [], + seasonData, + metadataFiles, + mediaFolderPath, + subtitleFiles, + nfoFiles, + thumbnailFiles, + contextMenuProps: defaultContextMenuProps, + layout: "simple", + checboxVisible: false, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +// ------------------------------------------------------------------------ +// Simple layout +// ------------------------------------------------------------------------ + +export const Simple: Story = { + args: { layout: "simple" }, +} + +export const SimpleWithCheckbox: Story = { + args: { + layout: "simple", + checboxVisible: true, + }, +} + +// ------------------------------------------------------------------------ +// Detail layout +// ------------------------------------------------------------------------ + +export const Detail: Story = { + args: { layout: "detail" }, +} + +export const DetailWithCheckbox: Story = { + args: { + layout: "detail", + checboxVisible: true, + }, +} + +// ------------------------------------------------------------------------ +// Preview layout +// ------------------------------------------------------------------------ + +export const Preview: Story = { + args: { layout: "preview" }, +} + +export const PreviewWithCheckbox: Story = { + args: { + layout: "preview", + checboxVisible: true, + }, +} diff --git a/apps/ui/src/components/media/MediaFileTableSimpleLayout.test.tsx b/apps/ui/src/components/media/MediaFileTableSimpleLayout.test.tsx new file mode 100644 index 00000000..29a05612 --- /dev/null +++ b/apps/ui/src/components/media/MediaFileTableSimpleLayout.test.tsx @@ -0,0 +1,365 @@ +import { describe, it, expect, vi } from "vitest" +import { useState } from "react" +import { fireEvent, render, screen, within } from "@testing-library/react" +import type { MetadataFiles } from "@smm/types/MetadataFiles" +import { MediaFileTableSimpleLayout } from "./MediaFileTableSimpleLayout" +import type { + MediaFileTableLayoutState, + MediaFileTableSeasonData, + UIMediaEpisodeSelection, + UIMediaFileTableProps, +} from "./UIMediaFileTable" + +// Translations used by the layout under test (column headers, checkbox +// spacer, season collapse toggles). The i18n runtime is exercised elsewhere; +// here we return fixed text so assertions read clearly. +const translations: Record = { + "mediaFileTable.columns.id": "ID", + "mediaFileTable.header.videoFile": "Video", + "mediaFileTable.header.thumb": "Thumb", + "mediaFileTable.header.sub": "Sub", + "mediaFileTable.header.nfo": "NFO", + "mediaFileTable.renameCheckboxTitle": "Include in rename", + "mediaFileTable.expand": "Expand season", + "mediaFileTable.collapse": "Collapse season", +} + +vi.mock("@/lib/i18n", () => ({ + useTranslation: () => ({ + t: (key: string, _options?: Record) => + translations[key] ?? key, + }), +})) + +// UIThumbnailImage mounts inside a HoverCard when a row has a +// thumbnail; it would try to load a file:// URL in jsdom. Stub it out. +vi.mock("@/components/Image", () => ({ + default: () =>
, +})) + +const mediaFolderPath = "/media/tv/Breaking Bad (2008)" + +const episodePath = (episode: number, title: string) => + `${mediaFolderPath}/Breaking Bad - S01E${String(episode).padStart(2, "0")} - ${title}.mkv` + +const season1: MediaFileTableSeasonData = { + season: 1, + title: "Season 1", + episodes: [ + { season: 1, episode: 1, title: "Pilot", path: episodePath(1, "Pilot") }, + { + season: 1, + episode: 2, + title: "Cat's in the Bag...", + path: episodePath(2, "Cat's in the Bag"), + }, + { + season: 1, + episode: 3, + title: "Cancer Man", + // No video file linked. + path: undefined, + }, + ], +} + +const season2: MediaFileTableSeasonData = { + season: 2, + title: "Season 2", + episodes: [ + { + season: 2, + episode: 1, + title: "Grilled", + path: episodePath(1, "Grilled"), + }, + ], +} + +const metadataFiles: MetadataFiles = { + nfoPath: `${mediaFolderPath}/tvshow.nfo`, + posterPath: `${mediaFolderPath}/poster.jpg`, + fanartPath: `${mediaFolderPath}/fanart.jpg`, + seasonPosters: [], + clearlogoPath: `${mediaFolderPath}/clearlogo.png`, +} + +const subtitleFiles = [ + { season: 1, episode: 1, files: [`${mediaFolderPath}/Breaking Bad - S01E01 - Pilot.en.srt`] }, +] + +const nfoFiles = [ + { season: 1, episode: 1, files: [`${mediaFolderPath}/Breaking Bad - S01E01 - Pilot.nfo`] }, +] + +const thumbnailFiles = [ + { season: 1, episode: 1, files: [`${mediaFolderPath}/Breaking Bad - S01E01 - Pilot-thumb.jpg`] }, +] + +interface LayoutProps extends UIMediaFileTableProps { + tableState: MediaFileTableLayoutState +} + +function renderLayout({ + checboxVisible, + selectedEpisodes, + onCheck, + newFilePaths, + tableState, + ...props +}: Partial & { + tableState?: Pick< + MediaFileTableLayoutState, + "collapsedIds" | "episodeContextMenuItems" + > +} = {}) { + const baseProps: LayoutProps = { + data: [], + seasonData: [season1, season2], + metadataFiles, + mediaFolderPath, + subtitleFiles, + nfoFiles, + thumbnailFiles, + checboxVisible: checboxVisible ?? false, + ...props, + selectedEpisodes, + onCheck, + newFilePaths, + tableState: { + collapsedIds: tableState?.collapsedIds ?? new Set(), + episodeContextMenuItems: tableState?.episodeContextMenuItems ?? [], + setSectionCollapsed: vi.fn(), + }, + } + return render() +} + +/** The episode row that contains the given SxxExx id. */ +function getEpisodeRow(id: string): HTMLTableRowElement { + const cell = screen.getByText(id) + return cell.closest("tr") as HTMLTableRowElement +} + +function getCheckIconCountInRow(row: HTMLTableRowElement): number { + return row.querySelectorAll(".text-emerald-600").length +} + +describe("MediaFileTableSimpleLayout", () => { + it("renders the column headers without a checkbox column by default", () => { + renderLayout() + + expect( + screen.getByRole("columnheader", { name: "ID" }), + ).toBeInTheDocument() + expect( + screen.getByRole("columnheader", { name: "Video" }), + ).toBeInTheDocument() + expect( + screen.getByRole("columnheader", { name: "Thumb" }), + ).toBeInTheDocument() + expect( + screen.getByRole("columnheader", { name: "Sub" }), + ).toBeInTheDocument() + expect( + screen.getByRole("columnheader", { name: "NFO" }), + ).toBeInTheDocument() + expect(screen.queryAllByRole("checkbox")).toHaveLength(0) + }) + + it("adds a checkbox spacer column when checboxVisible is true", () => { + renderLayout({ checboxVisible: true }) + + // One checkbox per episode row (season 1 ×3 + season 2 ×1; the episode + // without a video file still renders a disabled checkbox). + expect(screen.getAllByRole("checkbox")).toHaveLength(4) + expect(screen.getByTitle("Include in rename")).toBeInTheDocument() + }) + + it("renders a name/value row for each metadata file path present", () => { + renderLayout() + + // Only poster/fanart/nfo/clearlogo were supplied; values are shown + // relative to mediaFolderPath. + expect(screen.getByText("poster")).toBeInTheDocument() + expect(screen.getByText("poster.jpg")).toBeInTheDocument() + expect(screen.getByText("fanart")).toBeInTheDocument() + expect(screen.getByText("fanart.jpg")).toBeInTheDocument() + expect(screen.getByText("nfo")).toBeInTheDocument() + expect(screen.getByText("tvshow.nfo")).toBeInTheDocument() + expect(screen.getByText("clearlogo")).toBeInTheDocument() + expect(screen.getByText("clearlogo.png")).toBeInTheDocument() + // theme was not provided → no row. + expect(screen.queryByText("theme")).not.toBeInTheDocument() + }) + + it("renders no metadata rows when no metadata files are present", () => { + renderLayout({ metadataFiles: undefined }) + + expect(screen.queryByText("poster")).not.toBeInTheDocument() + expect(screen.queryByText("fanart")).not.toBeInTheDocument() + expect(screen.queryByText("nfo")).not.toBeInTheDocument() + expect(screen.queryByText("clearlogo")).not.toBeInTheDocument() + expect(screen.queryByText("theme")).not.toBeInTheDocument() + }) + + it("renders each season heading and episode row with relative video paths", () => { + renderLayout() + + expect(screen.getByText("Season 1")).toBeInTheDocument() + expect(screen.getByText("Season 2")).toBeInTheDocument() + expect(screen.getByText("S01E01")).toBeInTheDocument() + expect(screen.getByText("S02E01")).toBeInTheDocument() + + const s1e1Row = getEpisodeRow("S01E01") + expect(within(s1e1Row).getByText("Breaking Bad - S01E01 - Pilot.mkv")).toBeInTheDocument() + // An episode without a linked video file shows the path cell as "-". + const s1e3Row = getEpisodeRow("S01E03") + expect(within(s1e3Row).getByText("-")).toBeInTheDocument() + }) + + it("shows presence indicators for linked thumbnail/subtitle/nfo files", () => { + renderLayout() + + // S01E01 has a thumbnail, subtitle and nfo → three check icons. + expect(getCheckIconCountInRow(getEpisodeRow("S01E01"))).toBe(3) + // S01E02 has none → zero check icons. + expect(getCheckIconCountInRow(getEpisodeRow("S01E02"))).toBe(0) + }) + + it("fires onCheck with (season, episode, checked) when a row checkbox is toggled", () => { + const onCheck = vi.fn() + renderLayout({ checboxVisible: true, onCheck }) + + const s1e2Row = getEpisodeRow("S01E02") + fireEvent.click(within(s1e2Row).getByRole("checkbox")) + expect(onCheck).toHaveBeenCalledWith(1, 2, true) + + const s2e1Row = getEpisodeRow("S02E01") + fireEvent.click(within(s2e1Row).getByRole("checkbox")) + expect(onCheck).toHaveBeenCalledWith(2, 1, true) + }) + + it("checks the row checkbox when the episode is in selectedEpisodes", () => { + const selectedEpisodes: UIMediaEpisodeSelection[] = [ + { season: 1, episode: 1 }, + { season: 2, episode: 1 }, + ] + renderLayout({ checboxVisible: true, selectedEpisodes }) + + expect( + within(getEpisodeRow("S01E01")).getByRole("checkbox"), + ).toBeChecked() + expect( + within(getEpisodeRow("S02E01")).getByRole("checkbox"), + ).toBeChecked() + expect( + within(getEpisodeRow("S01E02")).getByRole("checkbox"), + ).not.toBeChecked() + }) + + it("disables checkboxes for episodes without a video file", () => { + renderLayout({ checboxVisible: true }) + + expect( + within(getEpisodeRow("S01E03")).getByRole("checkbox"), + ).toBeDisabled() + expect( + within(getEpisodeRow("S01E01")).getByRole("checkbox"), + ).not.toBeDisabled() + }) + + it("enables the checkbox for a video-less episode when disableCheckboxIfEpisodeVideoNotAvailable is false", () => { + renderLayout({ + checboxVisible: true, + disableCheckboxIfEpisodeVideoNotAvailable: false, + }) + + expect( + within(getEpisodeRow("S01E03")).getByRole("checkbox"), + ).not.toBeDisabled() + }) + + it("renders the old and new file paths when newFilePaths has a different target", () => { + const newFilePath = `${mediaFolderPath}/Breaking Bad - S01E01 - Pilot (renamed).mkv` + renderLayout({ + newFilePaths: [{ season: 1, episode: 1, newFilePath }], + }) + + const s1e1Row = getEpisodeRow("S01E01") + expect( + within(s1e1Row).getByTestId("media-file-table-new-video-file"), + ).toHaveTextContent("Breaking Bad - S01E01 - Pilot (renamed).mkv") + expect( + within(s1e1Row).getByTestId("media-file-table-old-video-file"), + ).toHaveTextContent("Breaking Bad - S01E01 - Pilot.mkv") + }) + + describe("season collapse", () => { + function CollapseHarness() { + const [collapsedIds, setCollapsedIds] = useState>(new Set()) + const tableState: MediaFileTableLayoutState = { + collapsedIds, + setSectionCollapsed: (id, collapsed) => + setCollapsedIds((prev) => { + const next = new Set(prev) + if (collapsed) next.add(id) + else next.delete(id) + return next + }), + episodeContextMenuItems: [], + } + return ( + + ) + } + + it("hides the season's episode rows once collapsed and shows them again when expanded", () => { + render() + + expect(screen.getByText("S01E01")).toBeInTheDocument() + expect(screen.getByText("Breaking Bad - S01E01 - Pilot.mkv")).toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: "Collapse season" })) + + expect(screen.getByRole("button", { name: "Expand season" })).toHaveAttribute( + "aria-expanded", + "false", + ) + expect(screen.queryByText("S01E01")).not.toBeInTheDocument() + // Season heading stays visible while collapsed. + expect(screen.getByText("Season 1")).toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: "Expand season" })) + expect(screen.getByText("S01E01")).toBeInTheDocument() + }) + + it("starts collapsed when the season id is in tableState.collapsedIds", () => { + const collapsedIds = new Set(["season-1"]) + const tableState: MediaFileTableLayoutState = { + collapsedIds, + setSectionCollapsed: vi.fn(), + episodeContextMenuItems: [], + } + render( + , + ) + + expect(screen.getByRole("button", { name: "Expand season" })).toHaveAttribute( + "aria-expanded", + "false", + ) + expect(screen.queryByText("S01E01")).not.toBeInTheDocument() + }) + }) +}) diff --git a/apps/ui/src/components/media/MediaFileTableSimpleLayout.tsx b/apps/ui/src/components/media/MediaFileTableSimpleLayout.tsx new file mode 100644 index 00000000..cc60a845 --- /dev/null +++ b/apps/ui/src/components/media/MediaFileTableSimpleLayout.tsx @@ -0,0 +1,142 @@ +import { Table, TableCell, TableHead, TableHeader } from "@/components/ui/table" +import { useTranslation } from "@/lib/i18n" +import { rel } from "@/lib/path" +import { MediaFileTableNameValueRow, MediaFileTableTr } from "./MediaFileTableRow" +import { + UIMediaFileTableEpisodeBlock, + UIMediaFileTableSeasonBlock, +} from "./MediaFileTableBlocks" +import type { UIMediaFileTableProps } from "./UIMediaFileTable" +import type { MediaFileTableLayoutState } from "./UIMediaFileTable" + +const SIMPLE_FIXED_COLUMN_COUNT = 5 + +function SimpleColGroup({ showCheckboxColumn }: { showCheckboxColumn: boolean }) { + return ( + + {showCheckboxColumn && } + + + + + + + ) +} + +export interface MediaFileTableSimpleLayoutProps extends UIMediaFileTableProps { + tableState: MediaFileTableLayoutState +} + +export function MediaFileTableSimpleLayout({ + seasonData = [], + metadataFiles, + subtitleFiles, + nfoFiles, + thumbnailFiles, + mediaFolderPath, + checboxVisible = false, + selectedEpisodes, + onCheck, + newFilePaths, + disableCheckboxIfEpisodeVideoNotAvailable = true, + tableState, +}: MediaFileTableSimpleLayoutProps) { + const { t } = useTranslation("components") + + const metadataFieldRows = [ + { name: "poster", path: metadataFiles?.posterPath }, + { name: "fanart", path: metadataFiles?.fanartPath }, + { name: "nfo", path: metadataFiles?.nfoPath }, + { name: "clearlogo", path: metadataFiles?.clearlogoPath }, + { name: "theme", path: metadataFiles?.themePath }, + ] + + const totalColumns = SIMPLE_FIXED_COLUMN_COUNT + (checboxVisible ? 1 : 0) + + return ( +
+ + + + + {checboxVisible && ( + + )} + + {t("mediaFileTable.columns.id")} + + + {t("mediaFileTable.header.videoFile")} + + + {t("mediaFileTable.header.thumb")} + + + {t("mediaFileTable.header.sub")} + + + {t("mediaFileTable.header.nfo")} + + + + + {metadataFieldRows.map( + ({ name, path }) => + path && ( + + ), + )} + + {seasonData.map((season) => { + const collapsibleId = `season-${season.season}` + const isCollapsed = tableState.collapsedIds.has(collapsibleId) + return ( + tableState.setSectionCollapsed(collapsibleId, !open)} + showCheckboxColumn={checboxVisible} + visibleColumnCount={totalColumns} + > + + + ) + })} +
+
+ ) +} diff --git a/apps/ui/src/components/media/UIMediaFileTable.tsx b/apps/ui/src/components/media/UIMediaFileTable.tsx index 5994853f..5c5f806d 100644 --- a/apps/ui/src/components/media/UIMediaFileTable.tsx +++ b/apps/ui/src/components/media/UIMediaFileTable.tsx @@ -1,58 +1,11 @@ import type { MetadataFiles } from "@smm/types/MetadataFiles" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { - ContextMenu, - ContextMenuCheckboxItem, - ContextMenuContent, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, - ContextMenuTrigger, -} from "@/components/ui/context-menu" -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible" -import { ChevronRightIcon } from "lucide-react" -import { - cloneElement, - isValidElement, - useCallback, - useState, - useMemo, - type ReactElement, - type ReactNode, -} from "react" +import { useCallback, useMemo, useState } from "react" +import type { ReactNode } from "react" import { useTranslation } from "@/lib/i18n" -import { cn } from "@/lib/utils" -import { - MediaFileTableTr, - MediaFileTableRowsBody, - MediaFileTableSectionRows, - type MediaFileTableBodyRow, - type MediaFileTableColumnKey, - type MediaFileTableRowContext, - EpisodeContextMenu, - type EpisodeContextMenuItem, - MediaFileTableEpisodeSimpleRow, - MediaFileTableEpisodeDetailRow, - MediaFileTableEpisodePreviewRow, - MediaFileTableNameValueRow, -} from "./MediaFileTableRow" -import { - buildMediaFileTableColumnLayout, - MediaFileTableColGroup, -} from "./mediaFileTableColumns" -import { rel } from "@/lib/path"; -import { head } from "es-toolkit" +import type { EpisodeContextMenuItem } from "./MediaFileTableRow" +import { MediaFileTableDetailLayout } from "./MediaFileTableDetailLayout" +import { MediaFileTablePreviewLayout } from "./MediaFileTablePreviewLayout" +import { MediaFileTableSimpleLayout } from "./MediaFileTableSimpleLayout" // ======================================================================== // Row types @@ -254,7 +207,7 @@ export interface UIMediaFileTableProps { */ selectedEpisodes?: UIMediaEpisodeSelection[] /** Checkbox state callback. Omit → checkbox column is hidden. */ - onCheck?: (row: UIMediaFileDataRow, checked: boolean) => void + onCheck?: (season: number, episode: number, checked: boolean) => void /** * Renders the extra content area below the video path in `preview` layout * (e.g. video screenshots). Omit → the area is hidden. @@ -267,346 +220,148 @@ export interface UIMediaFileTableProps { * Omit → double-click has no effect. */ onDoubleClick?: (row: UIMediaFileDataRow | UIMediaFileFolderRow) => void + newFilePaths?: { season: number, episode: number, newFilePath: string }[] + checboxVisible?: boolean + /** + * When `true` (default), episodes without a video file (`MediaFileTableEpisodeData.path` + * is undefined) render their checkbox as disabled. + */ + disableCheckboxIfEpisodeVideoNotAvailable?: boolean } // ======================================================================== -// Column visibility +// Shared layout-independent table state // ======================================================================== -type ColumnKey = MediaFileTableColumnKey - -const getColumnLabels = ( - t: (key: string, options?: Record) => string, -): Record => ({ - video: t("mediaFileTable.columns.video"), - thumbnail: t("mediaFileTable.columns.thumbnail"), - subtitle: t("mediaFileTable.columns.subtitle"), - nfo: t("mediaFileTable.columns.nfo"), -}) - -const defaultColumnVisibility: Record = { - video: true, - thumbnail: true, - subtitle: true, - nfo: true, +/** + * State owned by the `UIMediaFileTable` dispatcher and shared with the + * per-layout presentational components (kept outside them so switching layout + * does not remount the state). + */ +export interface MediaFileTableLayoutState { + /** Divider ids currently collapsed. */ + collapsedIds: Set + /** Toggle a divider section's collapsed state. */ + setSectionCollapsed: (dividerId: string, collapsed: boolean) => void + /** Context-menu items forwarded to every episode row. */ + episodeContextMenuItems: EpisodeContextMenuItem[] } -// ======================================================================== -// Table segmentation (divider sections) -// ======================================================================== - -type TableSegment = - | { kind: "standalone"; row: UIMediaFileTableRow; index: number } - | { - kind: "section" - divider: UIMediaFileDividerRow - dividerIndex: number - rows: Array<{ row: UIMediaFileTableRow; index: number }> +type Translator = (key: string, options?: Record) => string + +/** Build the effective context-menu config from `contextMenuProps` (which takes + * precedence over the raw `contextMenuConfig` for data-row items). */ +function buildEffectiveContextMenuConfig( + contextMenuProps: MediaFileTableContextMenuProps | undefined, + contextMenuConfig: UIMediaFileTableContextMenuConfig | undefined, + t: Translator, +): UIMediaFileTableContextMenuConfig | undefined { + if (contextMenuProps) { + const { onOpenMenuClick, onPropertiesMenuClick } = contextMenuProps + const dataRowItems: UIMediaFileDataContextMenuItem[] = [ + { + id: "open", + label: t("mediaFileTable.contextMenu.open"), + onClick: onOpenMenuClick, + disabled: (row) => !row.videoFile, + }, + { + id: "properties", + label: t("mediaFileTable.contextMenu.properties"), + onClick: onPropertiesMenuClick, + disabled: (row) => !row.videoFile, + }, + ] + + if (contextMenuProps.renameMenuVisible !== false && contextMenuProps.onRenameMenuClick) { + dataRowItems.push({ + id: "rename", + label: t("episodeFile.rename"), + onClick: contextMenuProps.onRenameMenuClick, + disabled: (row) => contextMenuProps.renameMenuDisabled ?? !row.videoFile, + }) } - -function groupTableData(data: UIMediaFileTableRow[]): TableSegment[] { - const segments: TableSegment[] = [] - let index = 0 - - while (index < data.length) { - const row = data[index] - if (row.type === "divider") { - const divider = row - const dividerIndex = index - index += 1 - const rows: Array<{ row: UIMediaFileTableRow; index: number }> = [] - while (index < data.length && data[index].type !== "divider") { - rows.push({ row: data[index], index }) - index += 1 - } - segments.push({ kind: "section", divider, dividerIndex, rows }) - } else { - segments.push({ kind: "standalone", row, index }) - index += 1 + if (contextMenuProps.selectFileMenuVisible !== false && contextMenuProps.onSelectFileMenuClick) { + dataRowItems.push({ + id: "select-file", + label: t("episodeFile.selectFile"), + onClick: contextMenuProps.onSelectFileMenuClick, + disabled: contextMenuProps.selectFileMenuDisabled, + }) } - } - - return segments -} - -const collapsibleSectionContentClassName = cn( - "media-file-table-section-content overflow-hidden", - "data-[state=closed]:animate-[media-file-table-collapsible-up_200ms_ease-out]", - "data-[state=open]:animate-[media-file-table-collapsible-down_200ms_ease-out]", -) - -type TableRenderBlock = - | { kind: "rows"; key: string; rows: MediaFileTableBodyRow[] } - | { - kind: "section" - divider: UIMediaFileDividerRow - dividerIndex: number - rows: MediaFileTableBodyRow[] + if (contextMenuProps.unlinkMenuVisible !== false && contextMenuProps.onUnlinkMenuClick) { + dataRowItems.push({ + id: "unlink", + label: t("tvShowEpisodeTable.contextMenu.unlink"), + onClick: contextMenuProps.onUnlinkMenuClick, + disabled: (row) => contextMenuProps.unlinkMenuDisabled ?? !row.videoFile, + }) } - -function toBodyRow( - row: UIMediaFileTableRow, - index: number, -): MediaFileTableBodyRow | null { - if (row.type === "folderFile") return { row, index } - if (row.type === "episode") return { row, index } - return null -} - -/** Merge consecutive standalone rows into one tbody so row borders render correctly. */ -function groupSegmentsForRender(segments: TableSegment[]): TableRenderBlock[] { - const blocks: TableRenderBlock[] = [] - let standaloneBatch: MediaFileTableBodyRow[] = [] - - const flushStandalone = () => { - if (standaloneBatch.length === 0) return - blocks.push({ - kind: "rows", - key: `standalone-${standaloneBatch[0].index}`, - rows: standaloneBatch, - }) - standaloneBatch = [] - } - - for (const segment of segments) { - if (segment.kind === "standalone") { - const bodyRow = toBodyRow(segment.row, segment.index) - if (bodyRow) standaloneBatch.push(bodyRow) - continue + if (contextMenuProps.videoCompressMenuVisible !== false && contextMenuProps.onVideoCompressMenuClick) { + dataRowItems.push({ + id: "video-compress", + label: t("tvShowEpisodeTable.contextMenu.videoCompress"), + onClick: contextMenuProps.onVideoCompressMenuClick, + disabled: (row) => contextMenuProps.videoCompressMenuDisabled ?? !row.videoFile, + }) + } + if (contextMenuProps.formatConvertMenuVisible !== false && contextMenuProps.onFormatConvertMenuClick) { + dataRowItems.push({ + id: "format-convert", + label: t("tvShowEpisodeTable.contextMenu.formatConvert"), + onClick: contextMenuProps.onFormatConvertMenuClick, + disabled: (row) => contextMenuProps.formatConvertMenuDisabled ?? !row.videoFile, + }) } - flushStandalone() - - const sectionRows = segment.rows - .map(({ row, index }) => toBodyRow(row, index)) - .filter((row): row is MediaFileTableBodyRow => row !== null) - - blocks.push({ - kind: "section", - divider: segment.divider, - dividerIndex: segment.dividerIndex, - rows: sectionRows, - }) + const folderFileRowItems: UIMediaFileFolderContextMenuItem[] = [ + { + id: "open", + label: t("mediaFileTable.contextMenu.open"), + onClick: onOpenMenuClick + ? (row) => (onOpenMenuClick as unknown as (row: UIMediaFileFolderRow) => void)(row) + : undefined, + disabled: (row) => !row.path, + }, + ] + + return { dataRowItems, folderFileRowItems } } - flushStandalone() - return blocks + return contextMenuConfig } // ======================================================================== -// Main component +// Main component (dispatcher) // ======================================================================== - - -export function UIMediaFileTable({ - data, - metadataFiles, - subtitleFiles, - nfoFiles, - thumbnailFiles, - seasonData = [], - mediaFolderPath, - contextMenuConfig: contextMenuConfigProp, - contextMenuProps, - preview, - previewStatus, - layout = "simple", - selectedEpisodes, - onCheck, - renderPreviewContent, - onDoubleClick, -}: UIMediaFileTableProps) { - const [collapsedIds, setCollapsedIds] = useState>(new Set()) - const [columnVisibility, setColumnVisibility] = useState>( - defaultColumnVisibility, - ) - const [internalSelectedEpisodes, setInternalSelectedEpisodes] = useState< - UIMediaEpisodeSelection[] - >([]) - - // Controlled when the caller provides `selectedEpisodes`; otherwise the table - // keeps the selection in internal state (uncontrolled mode). - const isSelectionControlled = selectedEpisodes !== undefined - const effectiveSelection = selectedEpisodes ?? internalSelectedEpisodes - const selectedEpisodeKeys = useMemo( - () => new Set(effectiveSelection.map((e) => `${e.season}-${e.episode}`)), - [effectiveSelection], - ) - - const isEpisodeSelected = useCallback( - (row: UIMediaFileDataRow) => selectedEpisodeKeys.has(`${row.season}-${row.episode}`), - [selectedEpisodeKeys], - ) - - const handleRowCheck = useCallback( - (row: UIMediaFileDataRow, checked: boolean) => { - if (!isSelectionControlled) { - setInternalSelectedEpisodes((prev) => { - const exists = prev.some( - (e) => e.season === row.season && e.episode === row.episode, - ) - if (checked === exists) return prev - if (checked) return [...prev, { season: row.season, episode: row.episode }] - return prev.filter( - (e) => !(e.season === row.season && e.episode === row.episode), - ) - }) - } - onCheck?.(row, checked) - }, - [isSelectionControlled, onCheck], - ) - +/** + * Dispatches to a per-layout presentational component (`simple` / `detail` / + * `preview`). Owns the layout-independent table state (season collapse, + * context-menu items) so it survives layout switches. + * + * A rename preview (`newFilePaths` non-empty) always renders the `simple` + * layout, since only that layout supports reviewing old→new file paths. + */ +export function UIMediaFileTable(props: UIMediaFileTableProps) { + const { layout = "simple", newFilePaths, contextMenuProps, contextMenuConfig } = props const { t } = useTranslation("components") + const [collapsedIds, setCollapsedIds] = useState>(new Set()) - const isSimpleLayout = layout === "simple" - const isPreviewLayout = layout === "preview" - const showThumbnailColumn = - (!isSimpleLayout && layout === "detail") || isPreviewLayout || columnVisibility.thumbnail - const showIdColumn = layout !== "preview" - const showCheckboxColumn = preview !== undefined - const visibleColumnCount = - (showCheckboxColumn ? 1 : 0) + - (showIdColumn ? 1 : 0) + - (showThumbnailColumn ? 1 : 0) + - (columnVisibility.video ? 1 : 0) + - (columnVisibility.subtitle ? 1 : 0) + - (columnVisibility.nfo ? 1 : 0) - - const thumbnailCellWidth = isPreviewLayout - ? "w-[160px] min-w-[160px]" - : layout === "detail" - ? "w-[100px] min-w-[100px]" - : "" - - const segments = useMemo(() => groupTableData(data), [data]) - const renderBlocks = useMemo(() => groupSegmentsForRender(segments), [segments]) - const columnLayout = useMemo( - () => - buildMediaFileTableColumnLayout({ - layout, - preview, - columnVisibility, - }), - [layout, preview, columnVisibility], - ) - - const setSectionCollapsed = (dividerId: string, collapsed: boolean) => { + const setSectionCollapsed = useCallback((dividerId: string, collapsed: boolean) => { setCollapsedIds((prev) => { const next = new Set(prev) if (collapsed) next.add(dividerId) else next.delete(dividerId) return next }) - } - - const toggleColumn = (key: ColumnKey) => { - setColumnVisibility((prev) => ({ ...prev, [key]: !prev[key] })) - } + }, []) - const columnLabels = getColumnLabels( - t as (key: string, options?: Record) => string, + const effectiveContextMenuConfig = useMemo( + () => buildEffectiveContextMenuConfig(contextMenuProps, contextMenuConfig, t as Translator), + [contextMenuProps, contextMenuConfig, t], ) - // Build contextMenuConfig from contextMenuProps when provided. - // contextMenuProps takes precedence over contextMenuConfig for data-row items. - const effectiveContextMenuConfig = useMemo(() => { - if (contextMenuProps) { - const { onOpenMenuClick, onPropertiesMenuClick } = contextMenuProps - const dataRowItems: UIMediaFileDataContextMenuItem[] = [ - { - id: "open", - label: t("mediaFileTable.contextMenu.open"), - onClick: onOpenMenuClick, - disabled: (row) => !row.videoFile, - }, - { - id: "properties", - label: t("mediaFileTable.contextMenu.properties"), - onClick: onPropertiesMenuClick, - disabled: (row) => !row.videoFile, - }, - ] - - if (contextMenuProps.renameMenuVisible !== false && contextMenuProps.onRenameMenuClick) { - dataRowItems.push({ - id: "rename", - label: t("episodeFile.rename"), - onClick: contextMenuProps.onRenameMenuClick, - disabled: (row) => contextMenuProps.renameMenuDisabled ?? !row.videoFile, - }) - } - if (contextMenuProps.selectFileMenuVisible !== false && contextMenuProps.onSelectFileMenuClick) { - dataRowItems.push({ - id: "select-file", - label: t("episodeFile.selectFile"), - onClick: contextMenuProps.onSelectFileMenuClick, - disabled: contextMenuProps.selectFileMenuDisabled, - }) - } - if (contextMenuProps.unlinkMenuVisible !== false && contextMenuProps.onUnlinkMenuClick) { - dataRowItems.push({ - id: "unlink", - label: t("tvShowEpisodeTable.contextMenu.unlink"), - onClick: contextMenuProps.onUnlinkMenuClick, - disabled: (row) => contextMenuProps.unlinkMenuDisabled ?? !row.videoFile, - }) - } - if (contextMenuProps.videoCompressMenuVisible !== false && contextMenuProps.onVideoCompressMenuClick) { - dataRowItems.push({ - id: "video-compress", - label: t("tvShowEpisodeTable.contextMenu.videoCompress"), - onClick: contextMenuProps.onVideoCompressMenuClick, - disabled: (row) => contextMenuProps.videoCompressMenuDisabled ?? !row.videoFile, - }) - } - if (contextMenuProps.formatConvertMenuVisible !== false && contextMenuProps.onFormatConvertMenuClick) { - dataRowItems.push({ - id: "format-convert", - label: t("tvShowEpisodeTable.contextMenu.formatConvert"), - onClick: contextMenuProps.onFormatConvertMenuClick, - disabled: (row) => contextMenuProps.formatConvertMenuDisabled ?? !row.videoFile, - }) - } - - const folderFileRowItems: UIMediaFileFolderContextMenuItem[] = [ - { - id: "open", - label: t("mediaFileTable.contextMenu.open"), - onClick: onOpenMenuClick - ? (row) => (onOpenMenuClick as unknown as (row: UIMediaFileFolderRow) => void)(row) - : undefined, - disabled: (row) => !row.path, - }, - ] - - return { dataRowItems, folderFileRowItems } - } - - return contextMenuConfigProp - }, [contextMenuProps, contextMenuConfigProp, t]) - - const renderContext: MediaFileTableRowContext = { - mediaFolderPath, - contextMenuConfig: effectiveContextMenuConfig, - preview, - previewStatus, - layout, - onCheck: handleRowCheck, - isSelected: isEpisodeSelected, - renderPreviewContent, - onDoubleClick, - isSimpleLayout, - isPreviewLayout, - showThumbnailColumn, - showIdColumn, - showCheckboxColumn, - columnVisibility, - columnLayout, - t: t as (key: string, options?: Record) => string, - } - // Context menu items for the seasonData-driven episode rows. Built once per // config from the (deprecated) UIMediaFileDataRow-based `dataRowItems`. const episodeContextMenuItems = useMemo( @@ -614,479 +369,47 @@ export function UIMediaFileTable({ [effectiveContextMenuConfig], ) - // ── Render: header row with column-visibility context menu ──────────── - const headerRow = ( - - {showCheckboxColumn && ( - - )} - {showIdColumn && ( - - {t("mediaFileTable.columns.id")} - - )} - {isSimpleLayout ? ( - <> - {columnVisibility.video && ( - - {t("mediaFileTable.header.videoFile")} - - )} - {showThumbnailColumn && ( - - {t("mediaFileTable.header.thumb")} - - )} - - ) : ( - <> - {showThumbnailColumn && ( - - {t("mediaFileTable.header.thumb")} - - )} - {columnVisibility.video && ( - - {t("mediaFileTable.header.videoFile")} - - )} - - )} - {columnVisibility.subtitle && ( - - {t("mediaFileTable.header.sub")} - - )} - {columnVisibility.nfo && ( - - {t("mediaFileTable.header.nfo")} - - )} - - ) - - - - return ( -
- - - - - {headerRow} - - - - {t("mediaFileTable.contextMenu.showColumns")} - - - toggleColumn("video")} - onSelect={(e) => e.preventDefault()} - > - {columnLabels.video} - - toggleColumn("thumbnail")} - onSelect={(e) => e.preventDefault()} - > - {columnLabels.thumbnail} - - toggleColumn("subtitle")} - onSelect={(e) => e.preventDefault()} - > - {columnLabels.subtitle} - - toggleColumn("nfo")} - onSelect={(e) => e.preventDefault()} - > - {columnLabels.nfo} - - - - - - - - - - - - - - {/* New path, will be the default in the future */} - { - layout === "simple" && seasonData.map((season) => { - const collapsibleId = `season-${season.season}` - const isCollapsed = collapsedIds.has(collapsibleId) - return ( - setSectionCollapsed(collapsibleId, !open)} - showCheckboxColumn={showCheckboxColumn} - visibleColumnCount={visibleColumnCount} - > - - - ) - }) - } - -{ - layout === "detail" && seasonData.map((season) => { - const collapsibleId = `season-${season.season}` - const isCollapsed = collapsedIds.has(collapsibleId) - return ( - setSectionCollapsed(collapsibleId, !open)} - showCheckboxColumn={showCheckboxColumn} - visibleColumnCount={visibleColumnCount} - > - - - ) - }) - } - - { - layout === "preview" && seasonData.map((season) => { - const collapsibleId = `season-${season.season}` - const isCollapsed = collapsedIds.has(collapsibleId) - return ( - setSectionCollapsed(collapsibleId, !open)} - showCheckboxColumn={showCheckboxColumn} - visibleColumnCount={visibleColumnCount} - > - - - ) - }) - } - - {/* Legacy path, will be removed in the future */} - {renderBlocks.map((block, blockIndex) => { - if (block.kind === "rows") { - const nextBlock = renderBlocks[blockIndex + 1] - const preserveLastRowBorder = nextBlock?.kind === "section" - return ( - - ) - } - - const { divider, dividerIndex, rows } = block - const isCollapsed = collapsedIds.has(divider.id) - const expandLabel = t("mediaFileTable.expand") - const collapseLabel = t("mediaFileTable.collapse") - - return ( - setSectionCollapsed(divider.id, !open)} - > - - - {showCheckboxColumn && } - -
- {divider.text} - - - -
-
-
- - - - - - - -
-
- ) - })} -
-
- ) -} - - -/** - * Collapsible season section for the `seasonData`-driven path: a header row - * (season title + collapse toggle) above the season content. - * - * The season content is supplied as `children`, e.g. - * ``. When the child is an - * `UIMediaFileTableEpisodeBlock`, this block's `items` are forwarded into it, - * so the caller can compose the episode rows as children while the episode - * menu items stay owned by the section. - */ -export function UIMediaFileTableSeasonBlock({ - season, - items = [], - isCollapsed, - onOpenChange, - showCheckboxColumn, - visibleColumnCount, - children, -}: { - season: MediaFileTableSeasonData - /** Right-click menu items for each episode row (forwarded to the episode block child). */ - items?: EpisodeContextMenuItem[] - /** Whether the section is currently collapsed (controlled by the table). */ - isCollapsed: boolean - /** Called with the new open state when the user toggles the section. */ - onOpenChange: (open: boolean) => void - showCheckboxColumn: boolean - visibleColumnCount: number - /** Content rendered below the season header (e.g. `UIMediaFileTableEpisodeBlock`). */ - children?: ReactNode -}) { - const { t } = useTranslation("components") - const expandLabel = t("mediaFileTable.expand") - const collapseLabel = t("mediaFileTable.collapse") - - // Compose the caller-supplied content. When it is one of the episode blocks - // (simple/detail/preview), forward this section's `items` so the per-row - // context menus keep working without the caller having to repeat the items - // on the child. - const content = - isValidElement(children) && - (children.type === UIMediaFileTableEpisodeBlock || - children.type === UIMediaFileTableEpisodeDetailBlock || - children.type === UIMediaFileTableEpisodePreviewBlock) - ? cloneElement(children as ReactElement, { - items, - }) - : children - - return ( - - - - {showCheckboxColumn && } - -
- {season.title} - - - -
-
-
- - - - {content} - - - -
-
- ) -} - -/** Shared props of the season content blocks (`EpisodeBlock` / `EpisodeDetailBlock` / - * `EpisodePreviewBlock`) rendered inside `UIMediaFileTableSeasonBlock`. */ -export interface UIMediaFileTableEpisodeBlockProps { - season: MediaFileTableSeasonData - /** Right-click menu items for each episode row. */ - items?: EpisodeContextMenuItem[] - /** When set, paths are shown relative to this base. */ - mediaFolderPath?: string - subtitleFiles?: { season: number, episode: number, files: string[] }[] - nfoFiles?: { season: number, episode: number, files: string[] }[] - thumbnailFiles?: { season: number, episode: number, files: string[] }[] -} - -export function UIMediaFileTableEpisodeBlock({ - season, - items = [], - mediaFolderPath, - subtitleFiles, - nfoFiles, - thumbnailFiles, -}: UIMediaFileTableEpisodeBlockProps) { - return ( - - - {season.episodes.map((episode) => { - - console.log(thumbnailFiles) - - const subtitle = subtitleFiles?.find((subtitle) => subtitle.season === season.season && subtitle.episode === episode.episode) - const subtitlePath = head(subtitle?.files ?? []) - - const nfo = nfoFiles?.find((nfo) => nfo.season === season.season && nfo.episode === episode.episode) - const nfoPath = head(nfo?.files ?? []) + const tableState: MediaFileTableLayoutState = { + collapsedIds, + setSectionCollapsed, + episodeContextMenuItems, + } - const thumbnail = thumbnailFiles?.find((thumbnail) => thumbnail.season === season.season && thumbnail.episode === episode.episode) - const thumbnailPath = head(thumbnail?.files ?? []) + const effectiveLayout = (newFilePaths?.length ?? 0) > 0 ? "simple" : layout - return ( - - - - ) - })} - -
- ) + if (effectiveLayout === "detail") { + return + } + if (effectiveLayout === "preview") { + return + } + return } -/** - * `detail`-layout season content block: one `MediaFileTableEpisodeDetailRow` - * per episode (id + cover thumbnail + title/path), each wrapped with its - * right-click menu. - */ -export function UIMediaFileTableEpisodeDetailBlock({ - season, - items = [], - mediaFolderPath, - subtitleFiles: _subtitleFiles, - nfoFiles: _nfoFiles, - thumbnailFiles: _thumbnailFiles, -}: UIMediaFileTableEpisodeBlockProps) { - return ( - - - {season.episodes.map((episode) => ( - - - - ))} - -
- ) -} +// ======================================================================== +// Season / episode content blocks (moved to MediaFileTableBlocks) +// ======================================================================== -/** - * `preview`-layout season content block: one `MediaFileTableEpisodePreviewRow` - * per episode (larger cover + id·title/path, no ID column), each wrapped with - * its right-click menu. - */ -export function UIMediaFileTableEpisodePreviewBlock({ - season, - items = [], - mediaFolderPath, - subtitleFiles: _subtitleFiles, - nfoFiles: _nfoFiles, - thumbnailFiles: _thumbnailFiles, -}: UIMediaFileTableEpisodeBlockProps) { - return ( - - - {season.episodes.map((episode) => ( - - - - ))} - -
- ) -} +export { + UIMediaFileTableSeasonBlock, + UIMediaFileTableEpisodeBlock, + UIMediaFileTableEpisodeDetailBlock, + UIMediaFileTableEpisodePreviewBlock, + type UIMediaFileTableEpisodeBlockProps, +} from "./MediaFileTableBlocks" + +export { + MediaFileTableSimpleLayout, + type MediaFileTableSimpleLayoutProps, +} from "./MediaFileTableSimpleLayout" +export { + MediaFileTableDetailLayout, + type MediaFileTableDetailLayoutProps, +} from "./MediaFileTableDetailLayout" +export { + MediaFileTablePreviewLayout, + type MediaFileTablePreviewLayoutProps, +} from "./MediaFileTablePreviewLayout" /** * Adapts the deprecated `UIMediaFileDataRow`-based episode menu items @@ -1126,4 +449,4 @@ function buildEpisodeContextMenuItems( : disabledRule, } }) -} \ No newline at end of file +} diff --git a/apps/ui/src/components/tv/TvShowPanel.tsx b/apps/ui/src/components/tv/TvShowPanel.tsx index 94fde166..424962ef 100644 --- a/apps/ui/src/components/tv/TvShowPanel.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.tsx @@ -2,7 +2,7 @@ import { useUIMediaFolderStore, useUIMediaFolderStoreState } from "@/stores/uiMe import { useMediaMetadataQuery } from "@/hooks/mediaMetadata" import { useSelectTvShowForFolderMutation } from "@/hooks/useSelectTvShowForFolderMutation" import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" -import { useState, useCallback, useMemo } from "react" +import { useState, useCallback, useMemo, useEffect, useRef } from "react" import type { MediaMetadata } from "@/lib/mediaFolderFiles" import { useMediaFolderFilesQuery } from "@/hooks/useMediaFolderFilesQuery" import type { TMDBTVShow, TMDBTVShowDetails } from "@smm/types" @@ -27,6 +27,7 @@ import type { UIMediaFileTableRow, UIMediaEpisodeSelection, MediaFileTableSeasonData, + MediaFileTableEpisodeData, } from "@/components/media/UIMediaFileTable" import { useRenameVideoFileFlow } from "@/hooks/useRenameVideoFileFlow" import { TvShowPanelHeader } from "./TvShowPanelHeader" @@ -48,6 +49,9 @@ import { type TvShowAppPlanPromptContextValue, } from "./plans/TvShowAppPlanPromptContext" import { useTvShowPanel } from "@/hooks/useTvShowPanel" +import { RuleBasedRenameFilePrompt } from "../RuleBasedRenameFilePrompt" +import type { RenameRuleName } from "@/lib/renameRules" +import type { string } from "zod" export function buildMediaFileTableSeasonData(m: MediaMetadata): MediaFileTableSeasonData[] { @@ -84,8 +88,6 @@ function TvShowPanel() { isPending: isMediaMetadataPending, fetchStatus: mediaMetadataFetchStatus, } = useMediaMetadataQuery(selectedFolder || undefined) - - const { metadataFiles, subtitleFiles, nfoFiles, thumbnailFiles } = useTvShowPanel(selectedFolder) const uiFolderRow = useMemo( () => @@ -254,7 +256,7 @@ function TvShowPanel() { const aiRenameFlow = useAiBasedRenameFilesFlow({ plans, mediaMetadata, - onAppRenameConfirm: renameFlow.onConfirm, + onAppRenameConfirm: async (planId: string) => {}, setSelectedMediaMetadataByMediaFolderPath: setSelectedByMediaFolderPath, onFlowStart: () => setEpisodeTableLayout("simple"), }) @@ -275,64 +277,18 @@ function TvShowPanel() { const plan = renameFlow.plan ?? - aiRenameFlow.plan ?? recognizeFlow.plan ?? + aiRenameFlow.plan ?? aiRecognizeFlow.plan + const { metadataFiles, subtitleFiles, nfoFiles, thumbnailFiles, newFilePaths } = useTvShowPanel(selectedFolder, plan) + const selectFileFlow = useSelectAndUnselectFileFlow({ mediaMetadata, folderFiles, updateMediaMetadata, }) - const previewMode: "rename" | "recognize" | undefined = useMemo(() => { - - if(plan === undefined) { - return undefined; - } - - const task = plan.task; - if(task === 'recognize-media-file') { - return 'recognize'; - } else if(task === 'rename-files') { - return 'rename'; - } else { - console.warn(`[TvShowPanel] previewMode: unknown plan task: ${task}`) - return undefined; - } - }, [plan]) - - const previewStatus: "loading" | "ok" | undefined = useMemo(() => { - if(plan === undefined) { - return undefined; - } - if(plan.status === 'preparing') { - return 'loading'; - } else { - return 'ok'; - } - }, [plan]) - - // useEffect(() => { - // /* eslint-disable react-hooks/set-state-in-effect */ - // if (!mediaMetadata) return; - - // const built = buildTvShowEpisodeTableRowsForPanel(mediaMetadata, uiStatus, plan, (key: string) => { - // return t(key as any) // eslint-disable-line @typescript-eslint/no-explicit-any - // }, folderFiles) - - // setTableData(built.rows); - - // // Re-seed the selection only when a (new) plan instance arrives. The - // // selection is separate UI state, so unrelated row rebuilds (metadata / - // // folderFiles refetches) must not wipe the user's check toggles. - // if (plan !== prevPlanRef.current) { - // prevPlanRef.current = plan - // setSelectedEpisodes(built.defaultChecked) - // } - // /* eslint-enable react-hooks/set-state-in-effect */ - - // }, [mediaMetadata, plan, uiStatus, t, folderFiles]) const contextMenuProps: MediaFileTableContextMenuProps = useMemo( () => ({ @@ -368,10 +324,10 @@ function TvShowPanel() { aiRecognizePromptStatus: aiRecognizeFlow.promptStatus, renameToolbarOptions: renameFlow.namingRuleOptions, selectedNamingRule: renameFlow.selectedNamingRule, - setSelectedNamingRule: renameFlow.setSelectedNamingRule, - onAppRenameNamingRuleSelected: renameFlow.onNamingRuleSelected, - onAppRenameConfirm: renameFlow.onConfirm, - onAppRenameCancel: renameFlow.onCancel, + setSelectedNamingRule: () => {}, + onAppRenameNamingRuleSelected: () => {}, + onAppRenameConfirm: () => {}, + onAppRenameCancel: () => {}, onAiRenameConfirm: aiRenameFlow.onConfirm, onAiRenameCancel: aiRenameFlow.onCancel, onAiRecognizeConfirm: aiRecognizeFlow.onConfirm, @@ -383,17 +339,59 @@ function TvShowPanel() { isRuleBasedRecognizeLoading: recognizeFlow.loading, notAllEpisodesRecognized: recognizeFlow.notAllEpisodesRecognized, allPlanFilesUnchanged: recognizeFlow.allPlanFilesUnchanged, - allRenamePlanFilesUnchanged: renameFlow.allRenamePlanFilesUnchanged, + allRenamePlanFilesUnchanged: false, } }, [renameFlow, aiRenameFlow, aiRecognizeFlow, recognizeFlow]) - + const latestMediaMetadata = useLatest(mediaMetadata) + const planId = useMemo(() => { return plan?.id ?? '' }, [plan]) + const selectedEpisodesByPlanId = useRef>(new Map()) + + useEffect(() => { + const m = latestMediaMetadata.current + const selectedEpisodes = m?.mediaFiles + ?.filter(f => f.seasonNumber !== undefined && f.episodeNumber !== undefined) + ?.map(f => { return { season: f.seasonNumber!, episode: f.episodeNumber!} }) + const episodes = selectedEpisodes ?? [] + selectedEpisodesByPlanId.current.set(planId, episodes) + setSelectedEpisodes(episodes) + }, [planId]) + + const ruleBasedRenameFilePromptProps = useMemo(() => { + return { + loading: renameFlow.loading, + isOpen: renameFlow.open, + namingRuleOptions: renameFlow.namingRuleOptions, + selectedNamingRule: renameFlow.selectedNamingRule, + onNamingRulesSelected: renameFlow.selectNamingRule, + onConfirm: async () => { + + const episodes = mediaFileTableSeasonData.flatMap(s => s.episodes) + + const selectedFiles = episodes + .filter((e) => { + return selectedEpisodes.some(s => s.season === e.season && s.episode === e.episode) + }) + .flatMap(e => e.path) + .filter((path): path is string => path !== undefined) + + renameFlow.confirm(selectedFiles) + }, + onCancel: () => { + renameFlow.cancel(plan?.id ?? '') + }, + } + }, [renameFlow]) return (
+ { + + } + @@ -403,7 +401,7 @@ function TvShowPanel() { { - setSelectedEpisodes((prev) => { - const exists = prev.some( - (e) => e.season === row.season && e.episode === row.episode, - ) - if (checked === exists) return prev - if (checked) return [...prev, { season: row.season, episode: row.episode }] - return prev.filter( - (e) => !(e.season === row.season && e.episode === row.episode), - ) + newFilePaths={newFilePaths} + checboxVisible={plan !== undefined} + onCheck={(season, episode, checked) => { + setSelectedEpisodes(prev => { + const newSelected = checked + ? prev.some(e => e.season === season && e.episode === episode) + ? prev + : [...prev, { season, episode }] + : prev.some(e => e.season === season && e.episode === episode) + ? prev.filter(e => e.season !== season || e.episode !== episode) + : prev + selectedEpisodesByPlanId.current.set(planId, newSelected) + return newSelected }) }} /> diff --git a/apps/ui/src/components/tv/TvShowPanelPrompts.tsx b/apps/ui/src/components/tv/TvShowPanelPrompts.tsx index 354313a6..6ebb8275 100644 --- a/apps/ui/src/components/tv/TvShowPanelPrompts.tsx +++ b/apps/ui/src/components/tv/TvShowPanelPrompts.tsx @@ -79,7 +79,7 @@ export function TvShowPanelPrompts() { }} /> - + /> */} ({ + mutationFn: async ({ id, files }): Promise => { + const resp = await applyPlan({ + id, + data: files && files.length > 0 ? { files } : undefined, + }) + if (resp.error) { + throw new Error(resp.error) + } + return null + }, + onSuccess: (_data, { id, mediaFolderPath }) => { + const plansKey = plansQueryKey(normalizeMediaFolderPathForQuery(mediaFolderPath)) + queryClient.setQueryData(plansKey, (prev) => + (prev ?? []).filter((p) => p.id !== id), + ) + const pathPosix = normalizeMediaFolderPathForQuery(mediaFolderPath) + void queryClient.invalidateQueries({ queryKey: mediaMetadataQueryKey(pathPosix) }) + }, + }) +} diff --git a/apps/ui/src/hooks/plans/useRejectPlanMutation.ts b/apps/ui/src/hooks/plans/useRejectPlanMutation.ts new file mode 100644 index 00000000..66bfd3b0 --- /dev/null +++ b/apps/ui/src/hooks/plans/useRejectPlanMutation.ts @@ -0,0 +1,33 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { rejectPlan } from "@/api/rejectPlan" +import type { Plan } from "@/api/getPlans" +import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" +import { plansQueryKey } from "./plansQueryKeys" + +export interface RejectPlanVariables { + id: string + mediaFolderPath: string +} + +/** + * Reject a plan and remove it from the plans cache. + */ +export function useRejectPlanMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ id }): Promise => { + const resp = await rejectPlan({ id }) + if (resp.error) { + throw new Error(resp.error) + } + return null + }, + onSuccess: (_data, { id, mediaFolderPath }) => { + const key = plansQueryKey(normalizeMediaFolderPathForQuery(mediaFolderPath)) + queryClient.setQueryData(key, (prev) => + (prev ?? []).filter((p) => p.id !== id), + ) + }, + }) +} diff --git a/apps/ui/src/hooks/plans/useTryToRenameEpisodesMutation.ts b/apps/ui/src/hooks/plans/useTryToRenameEpisodesMutation.ts new file mode 100644 index 00000000..2f3ca0f7 --- /dev/null +++ b/apps/ui/src/hooks/plans/useTryToRenameEpisodesMutation.ts @@ -0,0 +1,38 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { + tryToRenameEpisodes, + type RenameRuleName, +} from "@/api/tryToRenameEpisodes" +import type { Plan } from "@/api/getPlans" +import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" +import { plansQueryKey } from "./plansQueryKeys" + +export interface TryToRenameEpisodesVariables { + mediaFolderPath: string + rule?: RenameRuleName +} + +/** + * POST /api/try-to-rename-episodes — build a pending rename-files plan and + * add it to the plans cache. + */ +export function useTryToRenameEpisodesMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ mediaFolderPath, rule }): Promise => { + const resp = await tryToRenameEpisodes({ mediaFolderPath, rule }) + if (resp.error || !resp.data?.plan) { + throw new Error(resp.error ?? "Failed to create rename plan") + } + return resp.data.plan as Plan + }, + onSuccess: (plan, { mediaFolderPath }) => { + const key = plansQueryKey(normalizeMediaFolderPathForQuery(mediaFolderPath)) + queryClient.setQueryData(key, (prev) => { + const rest = (prev ?? []).filter((p) => p.id !== plan.id) + return [...rest, plan] + }) + }, + }) +} diff --git a/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.test.tsx b/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.test.tsx index eb03111a..e9aff1d1 100644 --- a/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.test.tsx +++ b/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.test.tsx @@ -3,23 +3,27 @@ import { renderHook, waitFor, act } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import type { ReactNode } from "react" import { useRuleBasedRenameFilesFlow } from "./useRuleBasedRenameFilesFlow" -import { plansQueryKey } from "@/hooks/plans/plansQueryKeys" -import type { UIRenameFilesPlan } from "@/types/UIRenameFilesPlan" import type { MediaMetadata } from "@smm/types" const { toastErrorMock, - tryToRenameEpisodesMock, - rejectPlanMock, - applyPlanMock, - fetchMediaMetadataMock, -} = vi.hoisted(() => ({ - toastErrorMock: vi.fn(), - tryToRenameEpisodesMock: vi.fn(), - rejectPlanMock: vi.fn(), - applyPlanMock: vi.fn(), - fetchMediaMetadataMock: vi.fn(), -})) + tryToRenameEpisodesMutationMock, + rejectPlanMutationMock, + applyPlanMutationMock, +} = vi.hoisted(() => { + const makeMutation = () => ({ + mutateAsync: vi.fn(), + mutate: vi.fn(), + reset: vi.fn(), + isPending: false, + }) + return { + toastErrorMock: vi.fn(), + tryToRenameEpisodesMutationMock: makeMutation(), + rejectPlanMutationMock: makeMutation(), + applyPlanMutationMock: makeMutation(), + } +}) vi.mock("sonner", () => ({ toast: { @@ -28,22 +32,16 @@ vi.mock("sonner", () => ({ }, })) -vi.mock("@/api/tryToRenameEpisodes", () => ({ - tryToRenameEpisodes: (...args: unknown[]) => tryToRenameEpisodesMock(...args), -})) - -vi.mock("@/api/rejectPlan", () => ({ - rejectPlan: (...args: unknown[]) => rejectPlanMock(...args), +vi.mock("@/hooks/plans/useTryToRenameEpisodesMutation", () => ({ + useTryToRenameEpisodesMutation: () => tryToRenameEpisodesMutationMock, })) -vi.mock("@/api/applyPlan", () => ({ - applyPlan: (...args: unknown[]) => applyPlanMock(...args), +vi.mock("@/hooks/plans/useRejectPlanMutation", () => ({ + useRejectPlanMutation: () => rejectPlanMutationMock, })) -vi.mock("@/hooks/mediaMetadata/useFetchMediaMetadataMutation", () => ({ - useFetchMediaMetadataMutation: () => ({ - mutateAsync: fetchMediaMetadataMock, - }), +vi.mock("@/hooks/plans/useApplyPlanMutation", () => ({ + useApplyPlanMutation: () => applyPlanMutationMock, })) vi.mock("@/lib/i18n", () => ({ @@ -54,14 +52,14 @@ vi.mock("@/lib/i18n", () => ({ describe("useRuleBasedRenameFilesFlow", () => { const mediaFolderPath = "/storage/Users/currentUser/Download/Anime/show" - const pendingPlan: UIRenameFilesPlan = { + const pendingPlan = { id: "plan-1", task: "rename-files", status: "pending", creator: "app", mediaFolderPath, files: [{ from: `${mediaFolderPath}/S01E01.mkv`, to: `${mediaFolderPath}/plex.mkv` }], - } + } as const const mediaMetadata = { mediaFolderPath, @@ -77,136 +75,246 @@ describe("useRuleBasedRenameFilesFlow", () => { {children} ) + const renderFlow = () => + renderHook(() => useRuleBasedRenameFilesFlow({ mediaMetadata }), { wrapper }) + beforeEach(() => { queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) vi.clearAllMocks() - tryToRenameEpisodesMock.mockResolvedValue({ data: { plan: pendingPlan } }) - rejectPlanMock.mockResolvedValue({ data: { plan: { ...pendingPlan, status: "rejected" } } }) - applyPlanMock.mockResolvedValue({ data: { id: pendingPlan.id } }) - fetchMediaMetadataMock.mockResolvedValue(mediaMetadata) + tryToRenameEpisodesMutationMock.mutateAsync.mockResolvedValue(pendingPlan) + rejectPlanMutationMock.mutateAsync.mockResolvedValue(null) + applyPlanMutationMock.mutateAsync.mockResolvedValue(null) + tryToRenameEpisodesMutationMock.isPending = false }) - it("startRenameFlow calls try-to-rename-episodes with the default rule", async () => { - const { result } = renderHook( - () => - useRuleBasedRenameFilesFlow({ - plans: [], - mediaMetadata, - uiStatus: "ok", - beforeConfirm: (plan) => plan, - }), - { wrapper }, - ) + it("start calls try-to-rename-episodes with the default rule", async () => { + const { result } = renderFlow() act(() => { - result.current.startRenameFlow() + result.current.start() }) await waitFor(() => { - expect(tryToRenameEpisodesMock).toHaveBeenCalledWith({ + expect(tryToRenameEpisodesMutationMock.mutateAsync).toHaveBeenCalledWith({ mediaFolderPath, rule: "plex", }) }) - expect(rejectPlanMock).not.toHaveBeenCalled() + expect(rejectPlanMutationMock.mutateAsync).not.toHaveBeenCalled() + expect(result.current.open).toBe(true) }) it("shows failure toast when try-to-rename-episodes fails", async () => { - tryToRenameEpisodesMock.mockResolvedValue({ error: "Error Reason: boom" }) - - const { result } = renderHook( - () => - useRuleBasedRenameFilesFlow({ - plans: [], - mediaMetadata, - uiStatus: "ok", - beforeConfirm: (plan) => plan, - }), - { wrapper }, - ) + tryToRenameEpisodesMutationMock.mutateAsync.mockRejectedValue(new Error("boom")) + + const { result } = renderFlow() act(() => { - result.current.startRenameFlow() + result.current.start() }) await waitFor(() => { - expect(toastErrorMock).toHaveBeenCalledWith("Error Reason: boom") + expect(toastErrorMock).toHaveBeenCalledWith("Rename failed. Please try again.") }) }) it("switches naming rule via reject-plan then try-to-rename-episodes", async () => { - const embyPlan: UIRenameFilesPlan = { + const embyPlan = { ...pendingPlan, id: "plan-2", files: [{ from: `${mediaFolderPath}/S01E01.mkv`, to: `${mediaFolderPath}/emby.mkv` }], } - queryClient.setQueryData(plansQueryKey(mediaFolderPath), [pendingPlan]) - tryToRenameEpisodesMock.mockResolvedValue({ data: { plan: embyPlan } }) - - const { result } = renderHook( - () => - useRuleBasedRenameFilesFlow({ - plans: [pendingPlan], - mediaMetadata, - uiStatus: "ok", - beforeConfirm: (plan) => plan, - }), - { wrapper }, - ) + tryToRenameEpisodesMutationMock.mutateAsync + .mockResolvedValueOnce(pendingPlan) + .mockResolvedValueOnce(embyPlan) + + const { result } = renderFlow() + + await act(async () => { + await result.current.start() + }) await act(async () => { - await result.current.onNamingRuleSelected("emby") + await result.current.selectNamingRule("emby") }) - expect(rejectPlanMock).toHaveBeenCalledWith({ id: "plan-1" }) - expect(tryToRenameEpisodesMock).toHaveBeenCalledWith({ + expect(rejectPlanMutationMock.mutateAsync).toHaveBeenCalledWith({ + id: "plan-1", + mediaFolderPath, + }) + expect(tryToRenameEpisodesMutationMock.mutateAsync).toHaveBeenLastCalledWith({ mediaFolderPath, rule: "emby", }) + expect(result.current.plan?.id).toBe("plan-2") }) - it("confirm calls apply-plan and refreshes media metadata", async () => { - queryClient.setQueryData(plansQueryKey(mediaFolderPath), [pendingPlan]) - - const { result } = renderHook( - () => - useRuleBasedRenameFilesFlow({ - plans: [pendingPlan], - mediaMetadata, - uiStatus: "ok", - beforeConfirm: (plan) => plan, - }), - { wrapper }, - ) + it("confirm calls apply-plan without manual metadata fetch", async () => { + const { result } = renderFlow() + + await act(async () => { + await result.current.start() + }) await act(async () => { - await result.current.onConfirm("plan-1") + await result.current.confirm([`${mediaFolderPath}/S01E01.mkv`]) }) - expect(applyPlanMock).toHaveBeenCalledWith({ id: "plan-1" }) - expect(fetchMediaMetadataMock).toHaveBeenCalledWith({ path: mediaFolderPath }) + expect(applyPlanMutationMock.mutateAsync).toHaveBeenCalledWith({ + id: "plan-1", + mediaFolderPath, + files: [`${mediaFolderPath}/S01E01.mkv`], + }) + expect(result.current.open).toBe(false) }) - it("cancel calls reject-plan", async () => { - queryClient.setQueryData(plansQueryKey(mediaFolderPath), [pendingPlan]) - - const { result } = renderHook( - () => - useRuleBasedRenameFilesFlow({ - plans: [pendingPlan], - mediaMetadata, - uiStatus: "ok", - beforeConfirm: (plan) => plan, - }), - { wrapper }, + it("Start to rename and then cancel", async () => { + // Step 1: start — mutation in flight → loading is true, dialog open + let resolveTryToRename: (plan: typeof pendingPlan) => void = () => {} + tryToRenameEpisodesMutationMock.mutateAsync.mockImplementation( + () => new Promise((resolve) => { resolveTryToRename = resolve }), ) + tryToRenameEpisodesMutationMock.isPending = true + + const { result } = renderFlow() + + act(() => { + result.current.start() + }) + + expect(result.current.open).toBe(true) + expect(tryToRenameEpisodesMutationMock.mutateAsync).toHaveBeenCalledWith({ + mediaFolderPath, + rule: "plex", + }) + expect(result.current.loading).toBe(true) + + // Step 2: mutation succeeds → loading false, plan assigned + await act(async () => { + tryToRenameEpisodesMutationMock.isPending = false + resolveTryToRename(pendingPlan) + }) + + expect(result.current.loading).toBe(false) + expect(result.current.plan).toEqual(pendingPlan) + + // Step 3: cancel — dialog closed, pending plan rejected, mutations reset + await act(async () => { + await result.current.cancel() + }) + + expect(result.current.open).toBe(false) + expect(rejectPlanMutationMock.mutateAsync).toHaveBeenCalledWith({ + id: "plan-1", + mediaFolderPath, + }) + expect(tryToRenameEpisodesMutationMock.reset).toHaveBeenCalled() + expect(rejectPlanMutationMock.reset).toHaveBeenCalled() + expect(applyPlanMutationMock.reset).toHaveBeenCalled() + expect(result.current.plan).toBeUndefined() + }) + + it("Start to rename, switch naming rule to Emby, then confirm", async () => { + const embyPlan = { + ...pendingPlan, + id: "plan-2", + files: [{ from: `${mediaFolderPath}/S01E01.mkv`, to: `${mediaFolderPath}/emby.mkv` }], + } + tryToRenameEpisodesMutationMock.mutateAsync + .mockResolvedValueOnce(pendingPlan) + .mockResolvedValueOnce(embyPlan) + + const { result } = renderFlow() + + await act(async () => { + await result.current.start() + }) + + await act(async () => { + await result.current.selectNamingRule("emby") + }) + + expect(result.current.plan?.id).toBe("plan-2") await act(async () => { - await result.current.onCancel("plan-1") + await result.current.confirm([`${mediaFolderPath}/S01E01.mkv`]) + }) + + expect(rejectPlanMutationMock.mutateAsync).toHaveBeenCalledWith({ + id: "plan-1", + mediaFolderPath, + }) + expect(applyPlanMutationMock.mutateAsync).toHaveBeenCalledWith({ + id: "plan-2", + mediaFolderPath, + files: [`${mediaFolderPath}/S01E01.mkv`], + }) + expect(result.current.open).toBe(false) + expect(result.current.plan).toBeUndefined() + expect(toastErrorMock).not.toHaveBeenCalled() + }) + + it("Start to rename but try-to-rename API fails", async () => { + tryToRenameEpisodesMutationMock.mutateAsync.mockRejectedValue(new Error("boom")) + + const { result } = renderFlow() + + act(() => { + result.current.start() + }) + + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith("Rename failed. Please try again.") }) - expect(rejectPlanMock).toHaveBeenCalledWith({ id: "plan-1" }) + expect(result.current.open).toBe(true) + expect(result.current.plan).toBeUndefined() + expect(rejectPlanMutationMock.mutateAsync).not.toHaveBeenCalled() + }) + + it("Start to rename and then confirm", async () => { + const { result } = renderFlow() + + await act(async () => { + await result.current.start() + }) + + await act(async () => { + await result.current.confirm() + }) + + expect(applyPlanMutationMock.mutateAsync).toHaveBeenCalledWith({ + id: "plan-1", + mediaFolderPath, + files: undefined, + }) + expect(result.current.open).toBe(false) + expect(result.current.plan).toBeUndefined() + expect(toastErrorMock).not.toHaveBeenCalled() + }) + + it("Start to rename, confirm but apply-plan API fails", async () => { + applyPlanMutationMock.mutateAsync.mockRejectedValue(new Error("boom")) + + const { result } = renderFlow() + + await act(async () => { + await result.current.start() + }) + + await act(async () => { + await result.current.confirm() + }) + + expect(applyPlanMutationMock.mutateAsync).toHaveBeenCalledWith({ + id: "plan-1", + mediaFolderPath, + files: undefined, + }) + expect(toastErrorMock).toHaveBeenCalledWith("Rename failed. Please try again.") + expect(result.current.open).toBe(true) + expect(result.current.plan).toEqual(pendingPlan) }) }) diff --git a/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts b/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts index a16201dc..e2cd43f8 100644 --- a/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts +++ b/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts @@ -1,36 +1,16 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { useQueryClient } from "@tanstack/react-query" +import { useCallback, useMemo, useState } from "react" import { toast } from "sonner" -import { applyPlan } from "@/api/applyPlan" -import { rejectPlan } from "@/api/rejectPlan" -import { tryToRenameEpisodes, type RenameRuleName } from "@/api/tryToRenameEpisodes" -import { selectActiveAppPlan } from "@/components/tv/plans/selectActiveAppPlan" -import { plansQueryKey } from "@/hooks/plans/plansQueryKeys" -import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" -import { - mediaMetadataQueryKey, - normalizeMediaFolderPathForQuery, -} from "@/lib/mediaMetadataQueryKeys" +import { type RenameRuleName } from "@/api/tryToRenameEpisodes" +import { useApplyPlanMutation } from "@/hooks/plans/useApplyPlanMutation" +import { useRejectPlanMutation } from "@/hooks/plans/useRejectPlanMutation" +import { useTryToRenameEpisodesMutation } from "@/hooks/plans/useTryToRenameEpisodesMutation" import { useTranslation } from "@/lib/i18n" import type { RenameToolbarOption } from "@/components/tv/plans/TvShowAppPlanPromptContext" -import type { Plan } from "@/api/getPlans" -import type { UIPlan } from "@/types/UIPlan" import type { MediaMetadata } from "@smm/types" -import type { UIMediaFolderStatus } from "@/types/UIMediaFolder" -import type { UIRenameFilesPlan } from "@/types/UIRenameFilesPlan" +import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan" export interface UseRuleBasedRenameFilesFlowOptions { - plans: UIPlan[] mediaMetadata: MediaMetadata | undefined - uiStatus: UIMediaFolderStatus | undefined - beforeConfirm: (plan: UIRenameFilesPlan) => UIRenameFilesPlan - /** Called when the rename flow starts (e.g. switch episode table to simple layout). */ - onFlowStart?: () => void -} - -function fileBaseName(path: string): string { - const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) - return i >= 0 ? path.slice(i + 1) : path } /** @@ -38,18 +18,23 @@ function fileBaseName(path: string): string { * try-to-rename-episodes → (reject-plan + try-to-rename-episodes on rule switch) → apply-plan. */ export function useRuleBasedRenameFilesFlow({ - plans, mediaMetadata, - uiStatus: _uiStatus, - beforeConfirm, - onFlowStart, }: UseRuleBasedRenameFilesFlowOptions) { const { t } = useTranslation(["components"]) - const queryClient = useQueryClient() + + const [open, setOpen] = useState(false) + const [plan, setPlan] = useState(undefined) + const mediaFolderPath = mediaMetadata?.mediaFolderPath - const { mutateAsync: fetchMediaMetadata } = useFetchMediaMetadataMutation() - const [loading, setLoading] = useState(false) - const inFlightRef = useRef(false) + const rejectPlanMutation = useRejectPlanMutation() + const applyPlanMutation = useApplyPlanMutation() + const tryToRenameEpisodesMutation = useTryToRenameEpisodesMutation() + + const loading = + rejectPlanMutation.isPending || + applyPlanMutation.isPending || + tryToRenameEpisodesMutation.isPending + const renameFailedMessage = t("toast.renameFailed", { defaultValue: "Rename failed. Please try again.", @@ -67,91 +52,15 @@ export function useRuleBasedRenameFilesFlow({ namingRuleOptions[0]?.value ?? "plex", ) - const plan = useMemo( - () => - selectActiveAppPlan( - plans, - mediaFolderPath, - "rename-files", - ), - [plans, mediaFolderPath], - ) - - const open = plan !== undefined - - const plansKey = useMemo( - () => - mediaFolderPath - ? plansQueryKey(normalizeMediaFolderPathForQuery(mediaFolderPath)) - : null, - [mediaFolderPath], - ) - - const upsertPlanInCache = useCallback( - (next: UIRenameFilesPlan, removeId?: string) => { - if (!plansKey) return - queryClient.setQueryData(plansKey, (prev) => { - const list = (prev ?? []).filter( - (p) => p.id !== next.id && (removeId === undefined || p.id !== removeId), - ) - return [...list, next] - }) - }, - [plansKey, queryClient], - ) - - const removePlanFromCache = useCallback( - (planId: string) => { - if (!plansKey) return - console.log("[rename] removed rename plan from UI cache", { planId, mediaFolderPath }) - queryClient.setQueryData(plansKey, (prev) => - (prev ?? []).filter((p) => p.id !== planId), - ) - }, - [plansKey, mediaFolderPath, queryClient], - ) - - const requestTryToRename = useCallback( - async (rule: RenameRuleName, removePlanId?: string): Promise => { - if (!mediaFolderPath) { - throw new Error("No media folder path available") - } - console.log("[rename] POST /api/try-to-rename-episodes", { - mediaFolderPath, - namingRule: rule, - }) - const resp = await tryToRenameEpisodes({ mediaFolderPath, rule }) - if (resp.error || !resp.data?.plan) { - throw new Error(resp.error ?? "try-to-rename-episodes: empty response") - } - const next = resp.data.plan as UIRenameFilesPlan - upsertPlanInCache(next, removePlanId) - console.log("[rename] rename preview ready — user can review and confirm", { - planId: next.id, - namingRule: rule, - fileCount: next.files.length, - preview: next.files.map((f) => ({ - from: fileBaseName(f.from), - to: fileBaseName(f.to), - })), - }) - return next - }, - [mediaFolderPath, upsertPlanInCache], - ) - - const requestRejectPlan = useCallback( - async (planId: string): Promise => { - console.log("[rename] POST /api/reject-plan", { planId }) - const resp = await rejectPlan({ id: planId }) - if (resp.error) { - throw new Error(resp.error) - } - removePlanFromCache(planId) - console.log("[rename] rename plan rejected", { planId }) - }, - [removePlanFromCache], - ) + const reset = useCallback(() => { + rejectPlanMutation.reset() + applyPlanMutation.reset() + tryToRenameEpisodesMutation.reset() + }, [ + rejectPlanMutation, + applyPlanMutation, + tryToRenameEpisodesMutation + ]) /** * Generate or refresh the rename preview. @@ -159,7 +68,7 @@ export function useRuleBasedRenameFilesFlow({ * 1. Click Rename (default naming rule) * 2. User changing the naming rule dropdown (reject + try-to-rename) */ - const onNamingRuleSelected = useCallback( + const selectNamingRule = useCallback( async (rule: RenameRuleName) => { if (!mediaFolderPath) { console.warn("[rename] cannot generate preview — media folder path missing", { rule }) @@ -167,187 +76,117 @@ export function useRuleBasedRenameFilesFlow({ return } - if (inFlightRef.current) { - return - } - inFlightRef.current = true - setLoading(true) - - const previousPlanId = plan?.id - const isSwitch = previousPlanId !== undefined + setSelectedNamingRule(rule) - console.log( - isSwitch - ? "[rename] user selected naming rule — regenerating preview" - : "[rename] rename prompt opened — generating preview with default naming rule", - { - planId: previousPlanId, - namingRule: rule, - planStatus: plan?.status, - tvShow: mediaMetadata?.tvShow?.name, - mediaFolderPath, - }, - ) + if (plan && plan.status === "pending") { + try { + rejectPlanMutation.mutateAsync({ id: plan.id, mediaFolderPath }) // fire and forget + } catch (error) { + console.error("[rename] failed to generate rename preview", { mediaFolderPath, rule, error }) + } + } try { - if (previousPlanId) { - await requestRejectPlan(previousPlanId) - } - await requestTryToRename(rule, previousPlanId) + const resp = await tryToRenameEpisodesMutation.mutateAsync({ mediaFolderPath, rule }) + setPlan(resp as RenameFilesPlan) } catch (error) { - console.error("[rename] failed to build rename preview", { - planId: previousPlanId, - namingRule: rule, - error, - }) - const message = - error instanceof Error && error.message ? error.message : renameFailedMessage - toast.error(message) - if (previousPlanId) { - removePlanFromCache(previousPlanId) - } - } finally { - inFlightRef.current = false - setLoading(false) + console.error(`Unable to create rename episodes plan: rule=${rule}, folder=${mediaFolderPath}`, error) + toast.error(renameFailedMessage) } }, [ plan, + setPlan, mediaFolderPath, - mediaMetadata?.tvShow?.name, - requestRejectPlan, - requestTryToRename, - removePlanFromCache, renameFailedMessage, + rejectPlanMutation, + tryToRenameEpisodesMutation, ], ) - const onConfirm = useCallback( - async (planId: string) => { - const targetPlan = plans.find((p) => p.id === planId) as UIRenameFilesPlan | undefined + const confirm = useCallback( + async (selectedEpisodeFiles?: string[]) => { - if (!targetPlan) { - console.warn("[rename] user confirmed but rename plan not found", { planId }) - toast.error("Failed to find rename plan") - return + if (plan === undefined) { + console.error(`Plan was confirmed but the plan is undefined`) + return; } if (!mediaMetadata || !mediaFolderPath) { - console.warn("[rename] user confirmed but media metadata missing", { planId }) + console.warn("[rename] user confirmed but media metadata missing", { plan }) toast.error("No media metadata available") return } - // Selection filtering is applied client-side for UX; server apply uses the stored plan. - // Checkbox-limited applies still go through apply-plan (sequence diagram). - const preparedPlan = beforeConfirm(targetPlan) - console.log("[rename] user confirmed — applying plan", { - planId, - fileCount: preparedPlan.files.length, - files: preparedPlan.files.map((f) => ({ - from: fileBaseName(f.from), - to: fileBaseName(f.to), - })), - }) - try { - console.log("[rename] POST /api/apply-plan", { planId }) - const resp = await applyPlan({ id: planId }) - if (resp.error) { - throw new Error(resp.error) - } - removePlanFromCache(planId) - const pathPosix = normalizeMediaFolderPathForQuery(mediaFolderPath) - await queryClient.invalidateQueries({ queryKey: mediaMetadataQueryKey(pathPosix) }) - await fetchMediaMetadata({ path: mediaFolderPath }) - console.log("[rename] rename completed successfully", { planId }) + console.log("[rename] POST /api/apply-plan", { id: plan.id, selectedCount: selectedEpisodeFiles?.length }) + await applyPlanMutation.mutateAsync({ id: plan.id, mediaFolderPath, files: selectedEpisodeFiles }) + + setOpen(false) + setPlan(undefined) + console.log("[rename] rename completed successfully", { id: plan.id }) } catch (error) { - console.error("[rename] unexpected error while applying rename", { planId, error }) + console.error("[rename] unexpected error while applying rename", { id: plan.id, error }) toast.error(renameFailedMessage) } }, [ - plans, - mediaMetadata, mediaFolderPath, - beforeConfirm, - removePlanFromCache, - queryClient, - fetchMediaMetadata, + plan, + applyPlanMutation, renameFailedMessage, ], ) - const onCancel = useCallback( - async (planId: string) => { - console.log("[rename] user cancelled rename preview", { planId }) - try { - await requestRejectPlan(planId) - console.log("[rename] rename plan cancelled", { planId }) - } catch (error) { - console.error("[rename] failed to cancel rename plan, cleared from cache", { planId, error }) - removePlanFromCache(planId) - toast.error(renameFailedMessage) + + const cancel = useCallback( + async () => { + + if(mediaFolderPath === undefined) { + console.error(`Media folder path is undefined`) + return; + } + + setOpen(false) + + if (plan && plan.status === 'pending') { + rejectPlanMutation.mutateAsync({ id: plan.id, mediaFolderPath }) // fire and forget } + + setPlan(undefined) + reset() + }, - [requestRejectPlan, removePlanFromCache, renameFailedMessage], + [mediaFolderPath, rejectPlanMutation, renameFailedMessage, plan, reset], ) /** Opens RuleBasedRenameFilePrompt by calling try-to-rename-episodes with the default rule. */ - const startRenameFlow = useCallback(() => { + const start = useCallback(() => { + if (!mediaFolderPath) { console.warn("[rename] cannot start — media folder path missing") toast.error("No media folder path available") return } - - onFlowStart?.() - - console.log("[rename] user clicked rename — opening rename prompt", { - mediaFolderPath, - defaultNamingRule: selectedNamingRule, - tvShow: mediaMetadata?.tvShow?.name, - }) - - void onNamingRuleSelected(selectedNamingRule) + reset() + setOpen(true) + void selectNamingRule(selectedNamingRule) }, [ mediaFolderPath, - mediaMetadata?.tvShow?.name, - onFlowStart, selectedNamingRule, - onNamingRuleSelected, + selectNamingRule, + reset ]) - useEffect(() => { - if (plan) { - onFlowStart?.() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [plan?.id, onFlowStart]) - - const allRenamePlanFilesUnchanged = useMemo(() => { - if ( - !plan || - plan.status !== "pending" || - plan.task !== "rename-files" || - !mediaMetadata - ) { - return false - } - return plan.files.length === 0 && (mediaMetadata.mediaFiles?.length ?? 0) > 0 - }, [plan, mediaMetadata]) - return { plan, open, loading, selectedNamingRule, - setSelectedNamingRule, namingRuleOptions, - onNamingRuleSelected, - onConfirm, - onCancel, - startRenameFlow, - allRenamePlanFilesUnchanged, + selectNamingRule, + confirm, + cancel, + start, } } diff --git a/apps/ui/src/hooks/useTvShowPanel.ts b/apps/ui/src/hooks/useTvShowPanel.ts index e6ac4891..6dd85d5c 100644 --- a/apps/ui/src/hooks/useTvShowPanel.ts +++ b/apps/ui/src/hooks/useTvShowPanel.ts @@ -1,11 +1,14 @@ import type { MetadataFiles } from "@smm/types/MetadataFiles"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useMediaFolderFilesQuery } from "./useMediaFolderFilesQuery"; import { useMediaMetadataQuery } from "./mediaMetadata"; import { findFilesByExtensions } from "@/lib/music"; import { extensions, imageFileExtensions, subtitleFileExtensions } from "@smm/types/mediaFileExtensions"; import { basename, extname } from "@/lib/path"; import type { MediaMetadata } from "@smm/types/types"; +import { usePlansQuery } from "./plans"; +import { Path } from "@smm/utils/path"; +import type { Plan } from "@/api/getPlans"; const INIT_METADATA_FILES: MetadataFiles = { nfoPath: undefined, @@ -57,7 +60,7 @@ export function findNfos(files: string[], videoFile: string): string[] { return files.filter(file => file === nfoFilePath) } -export function useTvShowPanel(folderPath?: string) { +export function useTvShowPanel(folderPath: string | undefined, plan: Plan | undefined) { if (folderPath === undefined) { return { @@ -149,10 +152,51 @@ export function useTvShowPanel(folderPath?: string) { }, [metadataQuery.data, filesQuery.data]) + const newFilePaths: { season: number, episode: number, newFilePath: string }[] = useMemo(() => { + + if(plan === undefined) { + return []; + } + + console.log(`Detected plan: `, plan) + + if(plan.task === 'rename-files') { + return plan.files + .map(file => { + + // If rename-files plan is built wrongly + // The plan may try to rename the episode that does not exist + + const episode = metadataQuery.data?.mediaFiles?.find(mediaFile => mediaFile.absolutePath === file.from) + return { + season: episode?.seasonNumber ?? -1, + episode: episode?.episodeNumber ?? -1, + newFilePath: file.to + } + }) + .filter(file => file.season !== -1 && file.episode !== -1) + } + + if(plan.task === 'recognize-media-file') { + return plan.files.map(file => { + return { + season: file.season, + episode: file.episode, + newFilePath: file.path + } + }) + } + + console.warn(`Unsupported type of plan: ${plan.task}`) + return []; + + }, [plan, metadataQuery.data]) + return { metadataFiles, subtitleFiles, nfoFiles, - thumbnailFiles + thumbnailFiles, + newFilePaths, } } \ No newline at end of file diff --git a/docs/dev/rename-episodes.md b/docs/dev/rename-episodes.md index 87242092..4dd99a10 100644 --- a/docs/dev/rename-episodes.md +++ b/docs/dev/rename-episodes.md @@ -81,7 +81,6 @@ sequenceDiagram W->>W: invalidate useMediaMetadataQuery ``` - ### UC2: Switch naming rule When user click the rename button @@ -113,6 +112,61 @@ sequenceDiagram W->>W: invalidate useMediaMetadataQuery ``` + +### UC3: Rename selected episodes + +User want to rename only selected episodes + +```mermaid +sequenceDiagram + participant U as User + participant W as UI + participant S as Server + participant C as Core + + U->>W: click rename button + W->>S: POST /api/try-to-rename-episodes + S->>C: tryToRenameEpisodes() + C->>S: plan id + data + S->>W: plan id + data + W->>U: display plan + U->>W: select partial episodes in UI + U->>W: click confirm button + W->>S: 1* POST /api/apply-plan with selected episode list + S->>C: 2* applyPlan(..., data: any) + W->>W: invalidate useMediaMetadataQuery +``` + +1*: +``` +POST /api/apply-plan +{ + data: { + files: [ + '/path/to/file1', + '/path/to/file2', + '/path/to/file3', + ] + } +} +``` + +RenameFilesPlan maintains a list of `{from: string, to: string}` +The "files" represent the "from" file that needs to apply. + +Needs to add new validatin that files are in `from` list. If some files is not in "from" list. Throw the error in ProblemDetails format. + +2*: How to handle applyPlan() request with selected file list. +applyPlan() is general interface for all plan. the `data: any` argument carries selected files list. +Core module needs to reject the original plan(because there is no partial-approved status for a plan. We cannot apply some of rename intention and mark the plan is approved/done ). +And then create a new plan with only selected files. + +In disk, +If caller apply plan without selected file list, there is ONLY one plan file. +If caller apply plan with selected file list, there are 2 plan file, one is rejected, another one is approved. + + + ## MCP Tool and AI Tool AI and MCP clients use a single tool, **`create-rename-episode-plan`**, instead of the former begin/add/end rename task flow. From 96e646747017bb44358b364785a529a9e5edbcc6 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sat, 5 Sep 2026 01:57:49 +0800 Subject: [PATCH 22/83] docs: add rule-based recognize flow refactor design --- ...-09-05-rule-based-recognize-flow-design.md | 284 ++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-05-rule-based-recognize-flow-design.md diff --git a/docs/superpowers/specs/2026-09-05-rule-based-recognize-flow-design.md b/docs/superpowers/specs/2026-09-05-rule-based-recognize-flow-design.md new file mode 100644 index 00000000..2c01464f --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-rule-based-recognize-flow-design.md @@ -0,0 +1,284 @@ +# Rule-Based Recognize Flow Refactor (Mirror Rename Flow) + +**Status**: Proposed (2026-09-05) + +This design document describe the high level design of a feature. +The design document is golden source and reference by one or more features. + +## 1. Background + +Rule-based rename was refactored (commit 49fc489d) to the pattern described in +[rename-episodes.md](../../dev/rename-episodes.md): the flow hook owns local +`open`/`plan` state, the backend builds the pending plan (`try-to-rename-episodes`), +and `apply-plan` / `reject-plan` mutations close the loop. The prompt is rendered +directly by `TvShowPanel` instead of through `TvShowAppPlanPromptContext`. + +Rule-based recognize still uses the legacy pattern in +`useRuleBasedRecognizeFlow` (344 lines): + +- plan selection from the plans cache (`usePlansQuery` + `selectActiveAppPlan`) +- a `preparing` status with client-side computation (`buildTemporaryRecognitionPlanAsync`) + resumed by `resumeComputation` effects guarded by `computationRef` +- confirm runs in the browser (`applyRecognizeMediaFilePlan` + + `useUpdateMediaMetadataMutation`) instead of backend `apply-plan` + +**Already implemented (backend)** + +- `Core.tryToRecognizeEpisodes(path)` / `tryToRecognizeEpisodesPipeline` — rule-based + episode matching → pending `RecognizeMediaFilePlan` (persisted via `writePlan`); + empty result yields a plan with `files: []`, not an error. Used by CLI + (`smm try-to-recognize`). +- `Core.applyPlan(plan, data?)` / `applyPlanPipeline` — dispatches by task; + `recognize-media-file` merges all `plan.files` into metadata and deletes the plan. + `data.files` selection is honored for `rename-files` only + ([UC3](./2026-09-04-uc3-apply-plan-selected-files-design.md)). +- `Core.rejectPlan(id)` +- `POST /api/apply-plan`, `POST /api/reject-plan` (`apps/cli/src/route/RenameEpisodesPlan.ts`) +- `useApplyPlanMutation` / `useRejectPlanMutation` (`apps/ui`) + +**Still missing** + +- `POST /api/try-to-recognize-episodes` HTTP route +- selected-episodes support for `recognize-media-file` apply (UC3 explicitly left + `recognize-media-file` ignoring `data`; this design supersedes that decision) +- frontend: `api/tryToRecognizeEpisodes.ts`, `useTryToRecognizeEpisodesMutation`, + rewritten `useRuleBasedRecognizeFlow`, prompt wired in `TvShowPanel` + +**Agreed product decisions** + +- Full-stack refactor mirroring the rename flow (user decision). +- Selected-episodes UX is preserved: extend backend `apply-plan` to honor + `data.files` for `recognize-media-file` (user decision). +- `RuleBasedRecognizePrompt` moves out of `TvShowPanelPrompts` into `TvShowPanel`, + fed by `recognizeFlow.*` props — same as `RuleBasedRenameFilePrompt`. +- `TvShowAppPlanPromptContext` is slimmed to AI-only fields; the rename-refactor + stub fields (`onAppRename*`, `renameToolbarOptions`, `selectedNamingRule`, + `setSelectedNamingRule`, `appRenamePlan`, `appRecognizePlan`, + `onAppRecognize*`, `tvShowTitle`, `tvShowTmdbId`, `isRuleBasedRecognizeLoading`, + `notAllEpisodesRecognized`, `allPlanFilesUnchanged`, `allRenamePlanFilesUnchanged`) + are removed. This also fixes the current unused-variable type errors in + `TvShowPanelPrompts.tsx`. +- No naming-rule dropdown exists for recognize, so the flow hook has no + `selectNamingRule`; `start` always uses the backend default rule set. +- Empty recognition result (`plan.files.length === 0`) → frontend rejects the plan + server-side, toasts "Unable to recognize any episodes. Consider using AI to + recognize instead.", and does not open the prompt. +- AI-based recognize/rename flows (plans-cache driven) are out of scope and keep + working unchanged. + +## 2. Architecture + +### 2.1 Project Level Architecture + +```mermaid +sequenceDiagram + participant U as User + participant W as UI (TvShowPanel) + participant F as useRuleBasedRecognizeFlow + participant S as apps/cli + participant C as Core + participant Fs as FsPort + + U->>W: click Recognize button + W->>F: start() + F->>S: POST /api/try-to-recognize-episodes { mediaFolderPath } + S->>C: tryToRecognizeEpisodes(path) + C->>Fs: recognizeEpisodes + writePlan (pending) + S-->>F: { data: { plan } } + F-->>W: open prompt, show preview (plan.files) + U->>W: (un)check episodes, confirm + W->>F: confirm(selectedFiles?) + F->>S: POST /api/apply-plan { id, data?: { files } } + S->>C: applyPlan(plan, data) + C->>Fs: merge selected files into metadata, delete plan + S-->>W: { data: { id } } + broadcast mediaMetadataUpdated + U->>W: cancel (alt path) + W->>F: cancel() + F->>S: POST /api/reject-plan { id } +``` + +### 2.2 App Level Architecture + +| Piece | Location | Role | +|--------|----------|------| +| Selected recognize apply | `apps/core/src/pipeline/applySelectedRecognizeFilesPlan.ts` (new) | Validate `data.files` ⊆ `plan.files[].path`, merge selection, delete plan | +| Dispatch with data | `apps/core/src/pipeline/applyPlan.ts` | `recognize-media-file` + non-empty `data.files` → selected pipeline | +| Selection error | same new file | `RecognizedFilesNotInPlanError extends Error` | +| Core API | `apps/core/src/Core.ts` | no signature change (`applyPlan(plan, data?)` already) | +| HTTP surface | `apps/cli/src/route/TryToRecognizeEpisodes.ts` (new) | `POST /api/try-to-recognize-episodes` | +| UI api | `apps/ui/src/api/tryToRecognizeEpisodes.ts` (new) | mirrors `tryToRenameEpisodes.ts` | +| UI mutation | `apps/ui/src/hooks/plans/useTryToRecognizeEpisodesMutation.ts` (new) | mirrors `useTryToRenameEpisodesMutation.ts`, writes plans cache on success | +| UI flow hook | `apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.ts` (rewrite) | local `open`/`plan`, `start`/`confirm`/`cancel` | +| UI prompt wiring | `apps/ui/src/components/tv/TvShowPanel.tsx` | render `RuleBasedRecognizePrompt` with `recognizeFlow.*` | +| Context slim-down | `apps/ui/src/components/tv/plans/TvShowAppPlanPromptContext.tsx` | AI-only fields | +| Dead code removal | `apps/ui/src/components/tv/TvShowPanelUtils.ts` | drop `buildTemporaryRecognitionPlanAsync`, `applyRecognizeMediaFilePlan`, `rebuildPlanWithSelectedEpisodes` (if unreferenced) | + +### 2.3 Key Design + +#### Backend: selected recognize apply + +```ts +// applySelectedRecognizeFilesPlan.ts +class RecognizedFilesNotInPlanError extends Error { + readonly files: string[] // offending paths +} + +function applySelectedRecognizeFilesPlanPipeline( + plan: RecognizeMediaFilePlan, + selectedFiles: string[], + deps: ApplyPlanDeps, +): Promise +``` + +1. Empty `selectedFiles` → plain `Error` (caller bug). +2. Each selected path must match a `plan.files[].path` via `mediaFilePathEqual` + (POSIX normalization, Windows separators tolerated); any miss → + `RecognizedFilesNotInPlanError(offenders)` before any disk write. +3. `filtered` = `plan.files` entries whose `path` was selected (duplicates collapse). +4. Merge `filtered` into metadata (`updateMediaFileMetadatas`), persist, + `deletePlan` — identical to `applyRecognizeMediaFilePlanPipeline` but with + `filtered` instead of `plan.files`. +5. `applyPlanPipeline` dispatch: `recognize-media-file` + non-empty + `data.files` → selected pipeline; absent `data` → full merge (unchanged). + +`RecognizedFilesNotInPlanError` maps to 400 `application/problem+json`: +`POST /api/apply-plan`'s existing catch block (which already maps UC3's +`SelectedFilesNotInPlanError` to 400) additionally catches +`RecognizedFilesNotInPlanError` and builds the same ProblemDetails body with +`detail: "Files not in plan: "`. + +#### Backend: try-to-recognize-episodes route + +`POST /api/try-to-recognize-episodes` body `{ mediaFolderPath: string }` → +`getCore().tryToRecognizeEpisodes(mediaFolderPath)` → `{ data: { plan } }` with +HTTP 200; errors → `{ error: "Error Reason: ..." }` with HTTP 200 (same pattern +as `try-to-rename-episodes`). + +#### Frontend: flow hook + +```ts +export interface UseRuleBasedRecognizeFlowOptions { + mediaMetadata: MediaMetadata | undefined +} + +// returns { +// plan, open, loading, +// tvShowTitle, tvShowTmdbId, +// notAllEpisodesRecognized, allPlanFilesUnchanged, +// confirm, cancel, start, +// } +``` + +- `loading` = `tryToRecognize.isPending || applyPlan.isPending || rejectPlan.isPending` +- `start()`: guard `mediaFolderPath`, `reset()`, `setOpen(true)`, + `tryToRecognize.mutateAsync({ mediaFolderPath })` → `setPlan(resp)`; + empty `files` → `rejectPlan` (fire-and-forget), toast `noRecognizedFiles`, + `setOpen(false)`; failure → toast `recognizeFailedMessage`, `setOpen(false)` +- `confirm(selectedEpisodeFiles?)`: applyPlan `{ id: plan.id, mediaFolderPath, files: selectedEpisodeFiles }`; + backend `onSuccess` already invalidates mediaMetadata (rename parity); + success → `setOpen(false)`, `setPlan(undefined)`; failure → toast, prompt stays open +- `cancel()`: `setOpen(false)`; pending plan → `rejectPlan` (fire-and-forget); + `setPlan(undefined)` + `reset()` +- `tvShowTitle`/`tvShowTmdbId`/`notAllEpisodesRecognized`/`allPlanFilesUnchanged` + derived from `plan` + `mediaMetadata` exactly as today (guards on + `plan.status === "pending"` and `plan.files.length > 0` instead of the old + `loading` flag) +- `beforeConfirm` is removed: selection now travels via `data.files` + +#### Frontend: TvShowPanel wiring + +`RuleBasedRecognizePrompt` renders in `TvShowPanel` next to +`RuleBasedRenameFilePrompt`: + +- `isOpen={recognizeFlow.open}`, `isLoading={recognizeFlow.loading}` +- `tvShowTitle` / `tvShowTmdbId` / `notAllEpisodesRecognized` / `allPlanFilesUnchanged` + from `recognizeFlow` +- `isConfirmButtonDisabled={recognizeFlow.loading || recognizeFlow.allPlanFilesUnchanged}` +- `onConfirm`: map checked episodes to paths (same mapping as rename's + `ruleBasedRenameFilePromptProps`) → `recognizeFlow.confirm(selectedFiles)` +- `onCancel`: `recognizeFlow.cancel()` +- `onRecognizeButtonClick={recognizeFlow.start}` + +`TvShowAppPlanPromptContext` keeps only: `aiRenamePlan`, `aiRenamePromptStatus`, +`aiRecognizePlan`, `aiRecognizePromptStatus`, `onAiRenameConfirm/Cancel`, +`onAiRecognizeConfirm/Cancel`. + +## 3. User Stories + +### 3.1 Start recognize and review preview + +* **Given** a TV-show folder with metadata +* **When** the user clicks the Recognize button +* **Then** `POST /api/try-to-recognize-episodes` returns a pending plan, the + prompt opens with "Recognizing episodes…" while loading and shows the review + message once `plan.files` arrive + +### 3.2 Confirm with selected episodes + +* **Given** a pending recognize plan with 3 matched files and the user unchecked one +* **When** the user confirms +* **Then** `apply-plan` carries `data.files` with the 2 checked paths; only those + files are merged into metadata; the plan file is deleted; the prompt closes + +### 3.3 Confirm without selection + +* **Given** a pending recognize plan and no checkbox interaction +* **When** the user confirms +* **Then** `apply-plan` carries no `data`; all plan files are merged (today's behavior) + +### 3.4 Cancel + +* **Given** the recognize prompt is open with a pending plan +* **When** the user cancels +* **Then** `reject-plan` is called, the prompt closes, flow state resets + +### 3.5 Nothing recognized + +* **Given** a folder whose files match no episode rule +* **When** the user clicks Recognize +* **Then** the returned plan has `files: []`; the prompt never opens; the user + sees "Unable to recognize any episodes. Consider using AI to recognize instead." + +### 3.6 Selection errors + +* **Given** a pending recognize plan +* **When** `apply-plan` receives a `data.files` entry not in the plan +* **Then** HTTP 400 ProblemDetails lists the offender; nothing is merged + +## 4. Test Plan + +**Core unit** (`applySelectedRecognizeFilesPlan.test.ts`, in-memory `FsPort`) + +1. Subset apply: only selected entries merged into metadata; plan deleted +2. Unmatched file → `RecognizedFilesNotInPlanError` with offenders; zero disk writes +3. Empty selection → error +4. Windows-style separators in selection match POSIX `plan.files[].path` +5. `applyPlanPipeline` dispatch: recognize + `data.files` → selected pipeline; + recognize without `data` → full merge; `rename-files` behavior unchanged + +**CLI route tests** + +6. `POST /api/try-to-recognize-episodes` happy path → `{ data: { plan } }` +7. Missing `mediaFolderPath` → `{ error: "Error Reason: mediaFolderPath is required" }` +8. Pipeline throw → `{ error: "Error Reason: ..." }` HTTP 200 +9. `RecognizedFilesNotInPlanError` on apply-plan → 400 problem+json (extend existing test) + +**UI hook tests** (`useRuleBasedRecognizeFlow.test.tsx`, mutation-hook mocks — mirror rename tests) + +10. Start: `open=true`, `tryToRecognize.mutateAsync` called with `{ mediaFolderPath }`; + `isPending=true` → `loading=true` +11. Success: `loading=false`, `plan` assigned +12. Cancel: `open=false`, `rejectPlan.mutateAsync` called with `{ id, mediaFolderPath }`, + mutations reset, `plan=undefined` +13. Start → confirm without selection: applyPlan `{ id, mediaFolderPath, files: undefined }`, + closes on success +14. Start → confirm → apply fails: toast `recognizeFailedMessage`, prompt stays open, + plan kept +15. Start → empty `files` result: prompt closed, `noRecognizedFiles` toast, plan rejected +16. Start → confirm with selected files: applyPlan receives the selected paths + +**UI context/component** + +17. `TvShowAppPlanPromptContext` type shrinks to AI fields; `TvShowPanelPrompts` + renders only AI prompts + `UseNfoPrompt`; typecheck clean under + `tsc -p tsconfig.app.json` From 6f6baf100b130bdea6a944f6b9718009ad53d40d Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 00:03:37 +0800 Subject: [PATCH 23/83] refactor: enhance the useRuleBasedRecognizesFlow.ts --- apps/cli/server.ts | 2 + apps/cli/src/route/RenameEpisodesPlan.test.ts | 27 + apps/cli/src/route/RenameEpisodesPlan.ts | 6 +- .../src/route/TryToRecognizeEpisodes.test.ts | 69 + apps/cli/src/route/TryToRecognizeEpisodes.ts | 55 + apps/core/src/pipeline/applyPlan.ts | 4 + .../applySelectedRecognizeFilesPlan.test.ts | 154 ++ .../applySelectedRecognizeFilesPlan.ts | 61 + .../applySelectedRenameFilesPlan.test.ts | 45 +- apps/ui/src/api/tryToRecognizeEpisodes.ts | 30 + .../components/media/MediaFileTableBlocks.tsx | 7 +- apps/ui/src/components/movie/MoviePanel.tsx | 3 +- .../ui/src/components/tv/TvShowPanel.test.tsx | 305 ++++ apps/ui/src/components/tv/TvShowPanel.tsx | 153 +- .../src/components/tv/TvShowPanelPrompts.tsx | 62 - .../components/tv/TvShowPanelUtils.test.ts | 180 ++- apps/ui/src/components/tv/TvShowPanelUtils.ts | 86 +- .../tv/plans/TvShowAppPlanPromptContext.tsx | 19 - .../useTryToRecognizeEpisodesMutation.ts | 35 + .../tv/useRuleBasedRecognizeFlow.test.tsx | 281 ++-- .../src/hooks/tv/useRuleBasedRecognizeFlow.ts | 375 ++--- apps/ui/src/hooks/useTvShowPanel.ts | 6 +- docs/api/index.md | 4 + docs/dev/recognize-episodes.md | 3 + .../2026-09-05-rule-based-recognize-flow.md | 1264 +++++++++++++++++ 25 files changed, 2556 insertions(+), 680 deletions(-) create mode 100644 apps/cli/src/route/TryToRecognizeEpisodes.test.ts create mode 100644 apps/cli/src/route/TryToRecognizeEpisodes.ts create mode 100644 apps/core/src/pipeline/applySelectedRecognizeFilesPlan.test.ts create mode 100644 apps/core/src/pipeline/applySelectedRecognizeFilesPlan.ts create mode 100644 apps/ui/src/api/tryToRecognizeEpisodes.ts create mode 100644 apps/ui/src/components/tv/TvShowPanel.test.tsx create mode 100644 apps/ui/src/hooks/plans/useTryToRecognizeEpisodesMutation.ts create mode 100644 docs/superpowers/plans/2026-09-05-rule-based-recognize-flow.md diff --git a/apps/cli/server.ts b/apps/cli/server.ts index ca5e3486..03be8d18 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -45,6 +45,7 @@ import { handleDebugGetEpisodesToolRoute } from './src/route/debug/debugGetEpiso import { handleDebugIsFolderExistToolRoute } from './src/route/debug/debugIsFolderExistTool'; import { handlePlans } from './src/route/Plans'; import { handleRenameEpisodesPlan } from './src/route/RenameEpisodesPlan'; +import { handleTryToRecognizeEpisodes } from './src/route/TryToRecognizeEpisodes'; import { handleGetFolders } from './src/route/GetFolders'; import { handleUnimportFolder } from './src/route/UnimportFolder'; import { handleImportFolder } from './src/route/ImportFolder'; @@ -299,6 +300,7 @@ export class Server { handleDebugIsFolderExistToolRoute(this.app); handlePlans(this.app); handleRenameEpisodesPlan(this.app); + handleTryToRecognizeEpisodes(this.app); handleGetFolders(this.app); handleUnimportFolder(this.app); handleImportFolder(this.app); diff --git a/apps/cli/src/route/RenameEpisodesPlan.test.ts b/apps/cli/src/route/RenameEpisodesPlan.test.ts index 7d3b54e7..ea2bfb52 100644 --- a/apps/cli/src/route/RenameEpisodesPlan.test.ts +++ b/apps/cli/src/route/RenameEpisodesPlan.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { Hono } from 'hono' import { SelectedFilesNotInPlanError } from '@smm/core/pipeline/applySelectedRenameFilesPlan' +import { RecognizedFilesNotInPlanError } from '@smm/core/pipeline/applySelectedRecognizeFilesPlan' const mocks = vi.hoisted(() => ({ createRenameEpisodePlan: vi.fn(), @@ -210,6 +211,32 @@ describe('POST /api/apply-plan', () => { }) }) + it('returns 400 ProblemDetails for RecognizedFilesNotInPlanError', async () => { + mocks.getPlan.mockResolvedValue({ + ...plan, + task: 'recognize-media-file' as const, + files: [{ season: 1, episode: 1, path: '/media/Show/S01E01.mkv' }], + }) + mocks.applyPlan.mockRejectedValue( + new RecognizedFilesNotInPlanError(['/media/Show/ghost.mkv']), + ) + + const response = await post({ + id: 'plan-1', + data: { files: ['/media/Show/ghost.mkv'] }, + }) + + expect(response.status).toBe(400) + expect(response.headers.get('Content-Type')).toContain('application/problem+json') + await expect(response.json()).resolves.toEqual({ + type: 'about:blank', + title: 'Bad Request', + status: 400, + detail: 'Files not in plan: /media/Show/ghost.mkv', + instance: '/api/apply-plan', + }) + }) + it('keeps the legacy Error Reason body for other Core errors', async () => { mocks.getPlan.mockRejectedValue(new Error('Plan not found: plan-1')) diff --git a/apps/cli/src/route/RenameEpisodesPlan.ts b/apps/cli/src/route/RenameEpisodesPlan.ts index 44f0b519..b6c4e935 100644 --- a/apps/cli/src/route/RenameEpisodesPlan.ts +++ b/apps/cli/src/route/RenameEpisodesPlan.ts @@ -8,6 +8,7 @@ import { } from '@smm/types/event-types' import { formatToolError } from '@smm/core/ai-tool/toolResult' import { SelectedFilesNotInPlanError } from '@smm/core/pipeline/applySelectedRenameFilesPlan' +import { RecognizedFilesNotInPlanError } from '@smm/core/pipeline/applySelectedRecognizeFilesPlan' import type { ProblemDetails } from '@smm/types' import { getCore } from '../core/getCore' import { broadcast } from '@/utils/socketIO' @@ -239,7 +240,10 @@ export function handleRenameEpisodesPlan(app: Hono): void { const ok: ApplyPlanResponseBody = { data: { id: plan.id } } return c.json(ok, 200) } catch (error) { - if (error instanceof SelectedFilesNotInPlanError) { + if ( + error instanceof SelectedFilesNotInPlanError || + error instanceof RecognizedFilesNotInPlanError + ) { const problem = problemDetails(error.message) return c.json(problem, 400, { 'Content-Type': 'application/problem+json' }) } diff --git a/apps/cli/src/route/TryToRecognizeEpisodes.test.ts b/apps/cli/src/route/TryToRecognizeEpisodes.test.ts new file mode 100644 index 00000000..af298522 --- /dev/null +++ b/apps/cli/src/route/TryToRecognizeEpisodes.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Hono } from 'hono' + +const mocks = vi.hoisted(() => ({ + tryToRecognizeEpisodes: vi.fn(), +})) + +vi.mock('../core/getCore', () => ({ + getCore: () => mocks, +})) + +import { handleTryToRecognizeEpisodes } from './TryToRecognizeEpisodes' + +const plan = { + id: 'plan-r1', + task: 'recognize-media-file' as const, + status: 'pending' as const, + creator: 'app' as const, + mediaFolderPath: '/media/Show', + files: [{ season: 1, episode: 1, path: '/media/Show/S01E01.mkv' }], +} + +describe('POST /api/try-to-recognize-episodes', () => { + let app: Hono + + beforeEach(() => { + mocks.tryToRecognizeEpisodes.mockReset() + app = new Hono() + handleTryToRecognizeEpisodes(app) + }) + + async function post(body: unknown) { + return app.request('/api/try-to-recognize-episodes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + it('returns the pending plan', async () => { + mocks.tryToRecognizeEpisodes.mockResolvedValue(plan) + + const response = await post({ mediaFolderPath: '/media/Show' }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { plan } }) + expect(mocks.tryToRecognizeEpisodes).toHaveBeenCalledWith('/media/Show') + }) + + it('requires mediaFolderPath', async () => { + const response = await post({}) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + error: 'Error Reason: mediaFolderPath is required', + }) + expect(mocks.tryToRecognizeEpisodes).not.toHaveBeenCalled() + }) + + it('maps pipeline errors to Error Reason', async () => { + mocks.tryToRecognizeEpisodes.mockRejectedValue( + new Error('Media metadata not found: /media/Show'), + ) + + const response = await post({ mediaFolderPath: '/media/Show' }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + error: 'Error Reason: Media metadata not found: /media/Show', + }) + }) +}) diff --git a/apps/cli/src/route/TryToRecognizeEpisodes.ts b/apps/cli/src/route/TryToRecognizeEpisodes.ts new file mode 100644 index 00000000..5dbc6e5c --- /dev/null +++ b/apps/cli/src/route/TryToRecognizeEpisodes.ts @@ -0,0 +1,55 @@ +import type { Hono } from 'hono' +import type { RecognizeMediaFilePlan } from '@smm/types/RecognizeMediaFilePlan' +import { getCore } from '../core/getCore' +import { logger } from '../../lib/logger' + +export interface TryToRecognizeEpisodesRequestBody { + mediaFolderPath: string +} + +export interface TryToRecognizeEpisodesResponseBody { + data?: { plan: RecognizeMediaFilePlan } + error?: string +} + +function readStringField(body: unknown, key: string): string | undefined { + if (typeof body !== 'object' || body === null || !(key in body)) return undefined + const value = (body as Record)[key] + return typeof value === 'string' ? value : undefined +} + +/** + * Recognize-episodes plan HTTP surface matching docs/dev/recognize-episodes.md: + * - POST /api/try-to-recognize-episodes → Core.tryToRecognizeEpisodes + * (apply/reject reuse POST /api/apply-plan and /api/reject-plan in RenameEpisodesPlan.ts) + */ +export function handleTryToRecognizeEpisodes(app: Hono): void { + app.post('/api/try-to-recognize-episodes', async (c) => { + try { + let body: unknown = {} + try { + body = await c.req.json() + } catch { + /* empty */ + } + + const mediaFolderPath = readStringField(body, 'mediaFolderPath') + if (!mediaFolderPath?.trim()) { + const err: TryToRecognizeEpisodesResponseBody = { + error: 'Error Reason: mediaFolderPath is required', + } + return c.json(err, 200) + } + + const plan = await getCore().tryToRecognizeEpisodes(mediaFolderPath) + const ok: TryToRecognizeEpisodesResponseBody = { data: { plan } } + return c.json(ok, 200) + } catch (error) { + logger.error({ error }, '[POST /api/try-to-recognize-episodes] route error') + const err: TryToRecognizeEpisodesResponseBody = { + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(err, 200) + } + }) +} diff --git a/apps/core/src/pipeline/applyPlan.ts b/apps/core/src/pipeline/applyPlan.ts index 9bff610d..5752aa95 100644 --- a/apps/core/src/pipeline/applyPlan.ts +++ b/apps/core/src/pipeline/applyPlan.ts @@ -2,6 +2,7 @@ import type { MediaMetadata } from "@smm/types"; import type { FsPort } from "../ports/FsPort"; import { applyRenameFilesPlanPipeline } from "./applyRenameFilesPlan"; import { applySelectedRenameFilesPlanPipeline } from "./applySelectedRenameFilesPlan"; +import { applySelectedRecognizeFilesPlanPipeline } from "./applySelectedRecognizeFilesPlan"; import { deletePlan, type Plan } from "./plans"; import { updateMediaFileMetadatas } from "./updateMediaFileMetadatas"; @@ -25,6 +26,9 @@ export async function applyPlanPipeline( ): Promise { const task = plan.task; if (task === "recognize-media-file") { + if (Array.isArray(data?.files)) { + return applySelectedRecognizeFilesPlanPipeline(plan, data.files, deps); + } return applyRecognizeMediaFilePlanPipeline(plan, deps); } if (task === "rename-files") { diff --git a/apps/core/src/pipeline/applySelectedRecognizeFilesPlan.test.ts b/apps/core/src/pipeline/applySelectedRecognizeFilesPlan.test.ts new file mode 100644 index 00000000..c448f1e9 --- /dev/null +++ b/apps/core/src/pipeline/applySelectedRecognizeFilesPlan.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MediaMetadata } from "@smm/types"; +import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan"; +import type { FsPort } from "../ports/FsPort"; +import { metadataCachePath, planFilePath } from "./paths"; +import { + applySelectedRecognizeFilesPlanPipeline, + RecognizedFilesNotInPlanError, +} from "./applySelectedRecognizeFilesPlan"; +import { applyPlanPipeline } from "./applyPlan"; + +const appDataDir = "/data"; +const folder = "/m/Show"; + +function inMemoryFs(seed: Record = {}): FsPort & { raw: Map } { + const files = new Map(Object.entries(seed)); + return { + raw: files, + readTextFile: vi.fn(async (path: string) => { + const v = files.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }), + writeTextFile: vi.fn(async (path: string, content: string) => { + files.set(path, content); + }), + writeBinaryFile: vi.fn(async () => {}), + exists: vi.fn(async (path: string) => files.has(path)), + listFiles: vi.fn(async (dir: string) => { + const prefix = dir.endsWith("/") ? dir : `${dir}/`; + return [...files.keys()].filter((p) => p.startsWith(prefix)); + }), + deleteFile: vi.fn(async (path: string) => { + files.delete(path); + }), + rename: vi.fn(async (from: string, to: string) => { + const v = files.get(from); + if (v === undefined) throw new Error("ENOENT: " + from); + files.delete(from); + files.set(to, v); + }), + mkdir: vi.fn(async () => {}), + listSubdirectories: vi.fn(async () => []), + }; +} + +const plan: RecognizeMediaFilePlan = { + id: "plan-r1", + task: "recognize-media-file", + status: "pending", + creator: "app", + mediaFolderPath: folder, + files: [ + { season: 1, episode: 1, path: `${folder}/ep1.mkv` }, + { season: 1, episode: 2, path: `${folder}/ep2.mkv` }, + ], +}; + +function seedMetadata(mediaFiles: MediaMetadata["mediaFiles"]): Record { + return { + // Runtime cast: MediaMetadata has required fields the pipeline never reads. + [metadataCachePath(appDataDir, folder)]: JSON.stringify({ + mediaFolderPath: folder, + type: "tvshow-folder", + mediaFiles: mediaFiles ?? [], + } as unknown as MediaMetadata), + [planFilePath(appDataDir, plan.id)]: JSON.stringify(plan), + }; +} + +function makeDeps(fs: ReturnType) { + return { + fs, + appDataDir, + normalizePosix: (p: string) => p, + getMediaMetadata: async (f: string) => + JSON.parse(fs.raw.get(metadataCachePath(appDataDir, f))!) as MediaMetadata, + setMetadata: vi.fn(async (mm: MediaMetadata) => { + fs.raw.set(metadataCachePath(appDataDir, folder), JSON.stringify(mm)); + }), + }; +} + +describe("applySelectedRecognizeFilesPlanPipeline", () => { + it("applies only the selected entries and deletes the plan", async () => { + const fs = inMemoryFs( + seedMetadata([{ absolutePath: `${folder}/ep1.mkv`, seasonNumber: 1, episodeNumber: 1 }]), + ); + await applySelectedRecognizeFilesPlanPipeline(plan, [`${folder}/ep2.mkv`], makeDeps(fs)); + + const mm = JSON.parse(fs.raw.get(metadataCachePath(appDataDir, folder))!) as MediaMetadata; + const ep2 = mm.mediaFiles?.find((f) => f.absolutePath === `${folder}/ep2.mkv`); + expect(ep2?.seasonNumber).toBe(1); + expect(ep2?.episodeNumber).toBe(2); + expect(await fs.exists(planFilePath(appDataDir, plan.id))).toBe(false); + }); + + it("throws RecognizedFilesNotInPlanError with offenders and writes nothing", async () => { + const fs = inMemoryFs(seedMetadata([])); + await expect( + applySelectedRecognizeFilesPlanPipeline(plan, [`${folder}/other.mkv`], { + fs, + appDataDir, + normalizePosix: (p) => p, + getMediaMetadata: async () => null, + setMetadata: async () => {}, + }), + ).rejects.toMatchObject({ + name: "RecognizedFilesNotInPlanError", + files: [`${folder}/other.mkv`], + }); + expect(await fs.exists(planFilePath(appDataDir, plan.id))).toBe(true); + }); + + it("rejects an empty selection", async () => { + const fs = inMemoryFs(seedMetadata([])); + await expect( + applySelectedRecognizeFilesPlanPipeline(plan, [], { + fs, + appDataDir, + normalizePosix: (p) => p, + getMediaMetadata: async () => null, + setMetadata: async () => {}, + }), + ).rejects.toThrow("data.files must be a non-empty array"); + }); + + it("matches Windows-style separators in the selection", async () => { + const fs = inMemoryFs(seedMetadata([])); + await applySelectedRecognizeFilesPlanPipeline(plan, [`\\m\\Show\\ep1.mkv`], makeDeps(fs)); + const mm = JSON.parse(fs.raw.get(metadataCachePath(appDataDir, folder))!) as MediaMetadata; + expect(mm.mediaFiles?.find((f) => f.absolutePath === `${folder}/ep1.mkv`)?.episodeNumber).toBe(1); + expect(await fs.exists(planFilePath(appDataDir, plan.id))).toBe(false); + }); +}); + +describe("applyPlanPipeline dispatch (recognize-media-file)", () => { + it("routes data.files to the selected pipeline", async () => { + const fs = inMemoryFs(seedMetadata([])); + await applyPlanPipeline(plan, makeDeps(fs), { files: [`${folder}/ep1.mkv`] }); + const mm = JSON.parse(fs.raw.get(metadataCachePath(appDataDir, folder))!) as MediaMetadata; + expect( + mm.mediaFiles?.some((f) => f.absolutePath === `${folder}/ep1.mkv` && f.episodeNumber === 1), + ).toBe(true); + expect(mm.mediaFiles?.find((f) => f.absolutePath === `${folder}/ep2.mkv`)).toBeUndefined(); + }); + + it("keeps full merge when data is absent", async () => { + const fs = inMemoryFs(seedMetadata([])); + await applyPlanPipeline(plan, makeDeps(fs)); + const mm = JSON.parse(fs.raw.get(metadataCachePath(appDataDir, folder))!) as MediaMetadata; + expect(mm.mediaFiles?.length).toBe(2); + }); +}); diff --git a/apps/core/src/pipeline/applySelectedRecognizeFilesPlan.ts b/apps/core/src/pipeline/applySelectedRecognizeFilesPlan.ts new file mode 100644 index 00000000..03f107e4 --- /dev/null +++ b/apps/core/src/pipeline/applySelectedRecognizeFilesPlan.ts @@ -0,0 +1,61 @@ +import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan"; +import type { ApplyPlanDeps } from "./applyPlan"; +import { mediaFilePathEqual } from "./mediaFilePathEqual"; +import { updateMediaFileMetadatas } from "./updateMediaFileMetadatas"; +import { deletePlan } from "./plans"; + +export class RecognizedFilesNotInPlanError extends Error { + readonly files: string[]; + + constructor(files: string[]) { + super(`Files not in plan: ${files.join(", ")}`); + this.name = "RecognizedFilesNotInPlanError"; + this.files = files; + } +} + +/** + * Apply only the selected files of a pending recognize-media-file plan: + * validate membership, merge the filtered entries into metadata, delete the plan. + */ +export async function applySelectedRecognizeFilesPlanPipeline( + plan: RecognizeMediaFilePlan, + selectedFiles: string[], + deps: ApplyPlanDeps, +): Promise { + if (plan.task !== "recognize-media-file") { + throw new Error(`Unsupported plan task: ${plan.task}`); + } + if (selectedFiles.length === 0) { + throw new Error("data.files must be a non-empty array"); + } + + // Normalize Windows separators first: mediaFilePathEqual's Path.posix + // fallback can't parse paths like "\m\Show\ep1.mkv" (no drive letter). + const toPosix = (p: string) => p.replaceAll("\\", "/"); + + const offenders = selectedFiles.filter( + (file) => !plan.files.some((entry) => mediaFilePathEqual(entry.path, toPosix(file))), + ); + if (offenders.length > 0) { + throw new RecognizedFilesNotInPlanError(offenders); + } + + const filtered = plan.files.filter((entry) => + selectedFiles.some((file) => mediaFilePathEqual(entry.path, toPosix(file))), + ); + + const folder = deps.normalizePosix(plan.mediaFolderPath); + const mm = await deps.getMediaMetadata(folder); + if (!mm) { + throw new Error(`Media metadata not found: ${plan.mediaFolderPath}`); + } + + let mediaFiles = mm.mediaFiles ?? []; + for (const file of filtered) { + mediaFiles = updateMediaFileMetadatas(mediaFiles, file.path, file.season, file.episode); + } + + await deps.setMetadata({ ...mm, mediaFiles }); + await deletePlan(deps.fs, deps.appDataDir, plan.id); +} diff --git a/apps/core/src/pipeline/applySelectedRenameFilesPlan.test.ts b/apps/core/src/pipeline/applySelectedRenameFilesPlan.test.ts index 7c74bb72..eb217281 100644 --- a/apps/core/src/pipeline/applySelectedRenameFilesPlan.test.ts +++ b/apps/core/src/pipeline/applySelectedRenameFilesPlan.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { MediaMetadata } from "@smm/types"; import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan"; import type { FsPort } from "../ports/FsPort"; -import { planFilePath } from "./paths"; +import { metadataCachePath, planFilePath } from "./paths"; import { applySelectedRenameFilesPlanPipeline, SelectedFilesNotInPlanError, @@ -184,7 +184,7 @@ describe("applyPlanPipeline dispatch with data", () => { expect(planFiles).toEqual([]); }); - it("ignores data for recognize-media-file plans", async () => { + it("rejects unknown data.files for recognize-media-file plans", async () => { const plan = { id: "rec-1", task: "recognize-media-file" as const, @@ -199,9 +199,46 @@ describe("applyPlanPipeline dispatch with data", () => { }); const deps = baseDeps(fs); - await applyPlanPipeline(plan, deps, { files: [`${folder}/nope.mkv`] }); + await expect( + applyPlanPipeline(plan, deps, { files: [`${folder}/nope.mkv`] }), + ).rejects.toMatchObject({ name: "RecognizedFilesNotInPlanError" }); - expect(deps.setMetadata).toHaveBeenCalledTimes(1); + expect(deps.setMetadata).not.toHaveBeenCalled(); + expect(fs.raw.has(planFilePath(appDataDir, "rec-1"))).toBe(true); + }); + + it("applies selected data.files for recognize-media-file plans", async () => { + const plan = { + id: "rec-1", + task: "recognize-media-file" as const, + status: "pending" as const, + creator: "app" as const, + mediaFolderPath: folder, + files: [ + { season: 1, episode: 1, path: `${folder}/old1.mkv` }, + { season: 1, episode: 2, path: `${folder}/old2.mkv` }, + ], + }; + const fs = inMemoryFs({ + [metadataCachePath(appDataDir, folder)]: JSON.stringify(baseMetadata()), + [planFilePath(appDataDir, "rec-1")]: JSON.stringify(plan), + [`${folder}/old1.mkv`]: "v1", + }); + const deps = { + fs, + appDataDir, + normalizePosix: (p: string) => p, + getMediaMetadata: async () => + JSON.parse(fs.raw.get(metadataCachePath(appDataDir, folder))!) as MediaMetadata, + setMetadata: vi.fn(async (mm: MediaMetadata) => { + fs.raw.set(metadataCachePath(appDataDir, folder), JSON.stringify(mm)); + }), + }; + + await applyPlanPipeline(plan, deps, { files: [`${folder}/old1.mkv`] }); + + const mm = JSON.parse(fs.raw.get(metadataCachePath(appDataDir, folder))!) as MediaMetadata; + expect(mm.mediaFiles?.find((f) => f.absolutePath === `${folder}/old1.mkv`)?.episodeNumber).toBe(1); expect(fs.raw.has(planFilePath(appDataDir, "rec-1"))).toBe(false); }); }); diff --git a/apps/ui/src/api/tryToRecognizeEpisodes.ts b/apps/ui/src/api/tryToRecognizeEpisodes.ts new file mode 100644 index 00000000..11a46e37 --- /dev/null +++ b/apps/ui/src/api/tryToRecognizeEpisodes.ts @@ -0,0 +1,30 @@ +import type { RecognizeMediaFilePlan } from '@smm/types/RecognizeMediaFilePlan' +import { apiFetch } from '@/lib/apiFetch' + +export interface TryToRecognizeEpisodesRequest { + mediaFolderPath: string +} + +export interface TryToRecognizeEpisodesResponseBody { + data?: { plan: RecognizeMediaFilePlan } + error?: string +} + +/** POST /api/try-to-recognize-episodes — build a pending recognize-media-file plan. */ +export async function tryToRecognizeEpisodes( + request: TryToRecognizeEpisodesRequest, + signal?: AbortSignal, +): Promise { + const resp = await apiFetch('/api/try-to-recognize-episodes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal, + }) + + if (!resp.ok) { + throw new Error(`Failed to try-to-recognize-episodes: ${resp.statusText}`) + } + + return (await resp.json()) as TryToRecognizeEpisodesResponseBody +} diff --git a/apps/ui/src/components/media/MediaFileTableBlocks.tsx b/apps/ui/src/components/media/MediaFileTableBlocks.tsx index d3f9b76d..75b39916 100644 --- a/apps/ui/src/components/media/MediaFileTableBlocks.tsx +++ b/apps/ui/src/components/media/MediaFileTableBlocks.tsx @@ -167,6 +167,11 @@ export function UIMediaFileTableEpisodeBlock({ const newFilePath: string | undefined = newFilePaths?.find((newFilePath) => newFilePath.season === season.season && newFilePath.episode === episode.episode)?.newFilePath + const isCheckboxDisabled = disableCheckboxIfEpisodeVideoNotAvailable && ( + !newFilePath || + newFilePath === episode.path + ) + return ( onCheck?.(season.season, episode.episode, isChecked)} isChecked={selectedEpisodes?.some((selectedEpisode) => selectedEpisode.season === season.season && selectedEpisode.episode === episode.episode)} /> diff --git a/apps/ui/src/components/movie/MoviePanel.tsx b/apps/ui/src/components/movie/MoviePanel.tsx index fd3d8c2f..95afe218 100644 --- a/apps/ui/src/components/movie/MoviePanel.tsx +++ b/apps/ui/src/components/movie/MoviePanel.tsx @@ -98,7 +98,7 @@ function MoviePanel() { { value: "plex", label: "Plex" } as ToolbarOption, { value: "emby", label: "Emby" } as ToolbarOption, ] - const [selectedNamingRule, setSelectedNamingRule] = useState<"plex" | "emby">(toolbarOptions[0]?.value || "plex") + const [selectedNamingRule] = useState<"plex" | "emby">(toolbarOptions[0]?.value || "plex") const [, setIsRenaming] = useState(false) // Prompt states @@ -390,7 +390,6 @@ function MoviePanel() { isOpen={isRuleBasedRenameFilePromptOpen} namingRuleOptions={toolbarOptions} selectedNamingRule={selectedNamingRule} - onNamingRuleChange={(value) => setSelectedNamingRule(value as "plex" | "emby")} onConfirm={handleRuleBasedRenameConfirm} onCancel={() => setIsRuleBasedRenameFilePromptOpen(false)} /> diff --git a/apps/ui/src/components/tv/TvShowPanel.test.tsx b/apps/ui/src/components/tv/TvShowPanel.test.tsx new file mode 100644 index 00000000..4b309db8 --- /dev/null +++ b/apps/ui/src/components/tv/TvShowPanel.test.tsx @@ -0,0 +1,305 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { fireEvent, render, waitFor } from "@testing-library/react" +import type { MediaMetadata } from "@smm/types" +import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan" + +const FOLDER = "/storage/show" +const EP1_PATH = `${FOLDER}/S01E01.mkv` +const EP2_PATH = `${FOLDER}/S01E02.mkv` + +// Regression shape from the 2026-09-05 bug: the plan proposes S01E01 + S01E02, +// but metadata only links S01E01. S01E02's table path is undefined, so the +// confirm payload must come from the PLAN, not from metadata. +const makePlan = (): RecognizeMediaFilePlan => ({ + id: "plan-1", + task: "recognize-media-file", + status: "pending", + creator: "app", + mediaFolderPath: FOLDER, + files: [ + { season: 1, episode: 1, path: EP1_PATH }, + { season: 1, episode: 2, path: EP2_PATH }, + ], +}) + +const makeMetadata = (): MediaMetadata => + ({ + mediaFolderPath: FOLDER, + type: "tvshow-folder", + tvShow: { + id: "1", + name: "Show", + seasons: [ + { + season: 1, + name: "Season 1", + episodes: [ + { season: 1, episode: 1, name: "E1" }, + { season: 1, episode: 2, name: "E2" }, + ], + }, + ], + }, + mediaFiles: [{ absolutePath: EP1_PATH, seasonNumber: 1, episodeNumber: 1 }], + }) as unknown as MediaMetadata + +const h = vi.hoisted(() => ({ + metadata: undefined as unknown, + recognizePlan: undefined as unknown, + recognizeConfirm: vi.fn(), + recognizeCancel: vi.fn(), + recognizeStart: vi.fn(), + renameConfirm: vi.fn(), +})) + +vi.mock("@/stores/uiMediaFolderStore", () => ({ + useUIMediaFolderStoreState: () => ({ folders: [], selectedFolder: "/storage/show" }), + useUIMediaFolderStore: { getState: () => ({ applyFolderClick: vi.fn() }) }, +})) + +vi.mock("@/hooks/mediaMetadata", () => ({ + useMediaMetadataQuery: () => ({ + data: h.metadata, + isError: false, + isPending: false, + fetchStatus: "idle", + }), +})) + +vi.mock("@/hooks/useMediaFolderFilesQuery", () => ({ + useMediaFolderFilesQuery: () => ({ data: [] }), +})) + +vi.mock("@/hooks/plans", () => ({ + usePlansQuery: () => ({ data: [] }), +})) + +vi.mock("@/hooks/useSelectTvShowForFolderMutation", () => ({ + useSelectTvShowForFolderMutation: () => ({ + selectTvShowForFolderMutation: { mutate: vi.fn() }, + updateMediaMetadata: vi.fn(), + }), +})) + +vi.mock("@/hooks/mediaMetadata/useFetchMediaMetadataMutation", () => ({ + useFetchMediaMetadataMutation: () => ({ mutateAsync: vi.fn() }), +})) + +vi.mock("@/hooks/useResolvedLanguages", () => ({ + useResolvedLanguages: () => ({ mediaLanguage: "en-US" }), +})) + +vi.mock("@/hooks/useFeatures", () => ({ + useFeatures: () => ({ isVideoCompressionEnabled: false, isFormatConverterEnabled: false }), +})) + +vi.mock("@/hooks/tv/useTvShowEpisodeVideoCompress", () => ({ + useTvShowEpisodeVideoCompress: () => ({ handleVideoCompressForRow: vi.fn() }), +})) + +vi.mock("@/hooks/tv/useTvShowEpisodeFormatConvert", () => ({ + useTvShowEpisodeFormatConvert: () => ({ handleFormatConvertForRow: vi.fn() }), +})) + +vi.mock("@/hooks/useSubtitleFlow", () => ({ + useSubtitleFlow: () => ({ + showSubtitleMenu: false, + dialogs: { transcribe: {}, translate: {}, synthesize: {}, pipeline: {} }, + header: {}, + }), +})) + +vi.mock("@/hooks/useRenameVideoFileFlow", () => ({ + useRenameVideoFileFlow: () => ({ onRenameContextMenuClick: vi.fn() }), +})) + +vi.mock("@/hooks/tv/useSelectAndUnselectFileFlow", () => ({ + useSelectAndUnselectFileFlow: () => ({ + onSelectFileContextMenuClick: vi.fn(), + onUnlinkContextMenuClick: vi.fn(), + }), +})) + +vi.mock("@/hooks/tv/useTvShowPanelState", () => ({ + useTvShowPanelState: () => undefined, +})) + +vi.mock("@/lib/dialogRequestEvents", () => ({ + askForRenameFile: vi.fn(), + askForScrape: vi.fn(), +})) + +vi.mock("@/lib/i18n", () => ({ + useTranslation: () => ({ + t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key, + }), +})) + +vi.mock("@/hooks/tv/useRuleBasedRecognizeFlow", () => ({ + useRuleBasedRecognizeFlow: () => ({ + plan: h.recognizePlan, + open: h.recognizePlan !== undefined, + loading: false, + tvShowTitle: "Show", + tvShowTmdbId: 1, + notAllEpisodesRecognized: false, + allPlanFilesUnchanged: false, + confirm: h.recognizeConfirm, + cancel: h.recognizeCancel, + start: h.recognizeStart, + }), +})) + +vi.mock("@/hooks/tv/useRuleBasedRenameFilesFlow", () => ({ + useRuleBasedRenameFilesFlow: () => ({ + plan: undefined, + open: false, + loading: false, + selectedNamingRule: "plex", + namingRuleOptions: [ + { value: "plex", label: "Plex" }, + { value: "emby", label: "Emby" }, + ], + selectNamingRule: vi.fn(), + confirm: h.renameConfirm, + cancel: vi.fn(), + start: vi.fn(), + }), +})) + +vi.mock("@/hooks/tv/useAiBasedRenameFilesFlow", () => ({ + useAiBasedRenameFilesFlow: () => ({ + plan: undefined, + promptStatus: "generating", + onConfirm: vi.fn(), + onCancel: vi.fn(), + }), +})) + +vi.mock("@/hooks/tv/useAiBasedRecognizeFlow", () => ({ + useAiBasedRecognizeFlow: () => ({ + plan: undefined, + promptStatus: "generating", + onConfirm: vi.fn(), + onCancel: vi.fn(), + }), +})) + +vi.mock("./TvShowPanelHeader", () => ({ TvShowPanelHeader: () => null })) +vi.mock("./TvShowPanelPrompts", () => ({ TvShowPanelPrompts: () => null })) +vi.mock("../RuleBasedRenameFilePrompt", () => ({ + RuleBasedRenameFilePrompt: () => null, +})) + +vi.mock("@/components/dialogs", () => ({ + TranscribeDialog: () => null, + SubtitleTranslationDialog: () => null, + SynthesizeSubtitleDialog: () => null, + ProcessPipelineDialog: () => null, +})) + +// Checkbox stub: MediaFileTable is a heavy table component; the panel contract +// under test is the (season, episode) toggle -> onCheck -> confirm payload flow. +vi.mock("@/components/media/MediaFileTable", async () => { + const { createElement } = await import("react") + return { + MediaFileTable: (props: { + seasonData: { season: number; episodes: { season: number; episode: number }[] }[] + selectedEpisodes?: { season: number; episode: number }[] + onCheck?: (season: number, episode: number, checked: boolean) => void + }) => + createElement( + "div", + { "data-testid": "media-file-table" }, + props.seasonData + .flatMap((s) => s.episodes) + .map((e) => { + const checked = + props.selectedEpisodes?.some( + (sel) => sel.season === e.season && sel.episode === e.episode, + ) ?? false + return createElement( + "button", + { + key: `${e.season}-${e.episode}`, + "data-testid": `episode-check-${e.season}-${e.episode}`, + "data-checked": checked ? "true" : "false", + onClick: () => props.onCheck?.(e.season, e.episode, !checked), + }, + `S${e.season}E${e.episode}-${checked ? "checked" : "unchecked"}`, + ) + }), + ), + } +}) + +import TvShowPanel from "./TvShowPanel" + +const getEpisodeButton = (container: HTMLElement, season: number, episode: number) => + container.querySelector(`[data-testid="episode-check-${season}-${episode}"]`) as HTMLElement + +describe("TvShowPanel rule-based recognize confirm", () => { + beforeEach(() => { + vi.clearAllMocks() + h.metadata = makeMetadata() + h.recognizePlan = makePlan() + }) + + it("seeds both plan episodes as checked and passes all plan paths to recognizeFlow.confirm", async () => { + const { container, getByTestId } = render() + + await waitFor(() => { + expect(getEpisodeButton(container, 1, 1)).toHaveAttribute("data-checked", "true") + expect(getEpisodeButton(container, 1, 2)).toHaveAttribute("data-checked", "true") + }) + + fireEvent.click(getByTestId("floating-prompt-confirm-button")) + + expect(h.recognizeConfirm).toHaveBeenCalledTimes(1) + // Regression: S01E02 is not linked in metadata; it must still be applied + // via the plan path (the old table-based lookup dropped it). + expect(h.recognizeConfirm).toHaveBeenCalledWith([EP1_PATH, EP2_PATH]) + }) + + it("excludes an unchecked episode from the confirm payload", async () => { + const { container, getByTestId } = render() + + await waitFor(() => { + expect(getEpisodeButton(container, 1, 2)).toHaveAttribute("data-checked", "true") + }) + + fireEvent.click(getEpisodeButton(container, 1, 2)) + await waitFor(() => { + expect(getEpisodeButton(container, 1, 2)).toHaveAttribute("data-checked", "false") + }) + + fireEvent.click(getByTestId("floating-prompt-confirm-button")) + + expect(h.recognizeConfirm).toHaveBeenCalledTimes(1) + expect(h.recognizeConfirm).toHaveBeenCalledWith([EP1_PATH]) + }) + + it("passes an empty array when every episode is unchecked", async () => { + const { container, getByTestId } = render() + + await waitFor(() => { + expect(getEpisodeButton(container, 1, 1)).toHaveAttribute("data-checked", "true") + }) + + fireEvent.click(getEpisodeButton(container, 1, 1)) + fireEvent.click(getEpisodeButton(container, 1, 2)) + + fireEvent.click(getByTestId("floating-prompt-confirm-button")) + + expect(h.recognizeConfirm).toHaveBeenCalledTimes(1) + expect(h.recognizeConfirm).toHaveBeenCalledWith([]) + }) + + it("does not call confirm when the recognize prompt is not open", () => { + h.recognizePlan = undefined + const { queryByTestId } = render() + + expect(queryByTestId("floating-prompt-confirm-button")).toBeNull() + expect(h.recognizeConfirm).not.toHaveBeenCalled() + }) +}) diff --git a/apps/ui/src/components/tv/TvShowPanel.tsx b/apps/ui/src/components/tv/TvShowPanel.tsx index 424962ef..fb2eb9ae 100644 --- a/apps/ui/src/components/tv/TvShowPanel.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.tsx @@ -23,11 +23,9 @@ import { usePlansQuery } from "@/hooks/plans" import { MediaFileTable } from "@/components/media/MediaFileTable" import type { MediaFileTableContextMenuProps, - UIMediaFileDataRow, UIMediaFileTableRow, UIMediaEpisodeSelection, MediaFileTableSeasonData, - MediaFileTableEpisodeData, } from "@/components/media/UIMediaFileTable" import { useRenameVideoFileFlow } from "@/hooks/useRenameVideoFileFlow" import { TvShowPanelHeader } from "./TvShowPanelHeader" @@ -38,20 +36,20 @@ import { useSubtitleFlow } from "@/hooks/useSubtitleFlow" import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" import { rebuildPlanWithSelectedEpisodes, - rebuildRenamePlanWithSelectedEpisodes, + buildRenameApplySelectedFiles, + buildRecognizeApplySelectedFiles, } from "./TvShowPanelUtils" import { useLatest } from "react-use" import type { UIMediaFolderStatus } from "@/types/UIMediaFolder" import type { UIRecognizeMediaFilePlan } from "@/types/UIRecognizeMediaFilePlan" -import type { UIRenameFilesPlan } from "@/types/UIRenameFilesPlan" import { TvShowAppPlanPromptProvider, type TvShowAppPlanPromptContextValue, } from "./plans/TvShowAppPlanPromptContext" import { useTvShowPanel } from "@/hooks/useTvShowPanel" import { RuleBasedRenameFilePrompt } from "../RuleBasedRenameFilePrompt" -import type { RenameRuleName } from "@/lib/renameRules" -import type { string } from "zod" +import { RuleBasedRecognizePrompt } from "./RuleBasedRecognizePrompt" +import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan" export function buildMediaFileTableSeasonData(m: MediaMetadata): MediaFileTableSeasonData[] { @@ -133,26 +131,11 @@ function TvShowPanel() { }) const [tableData] = useState([]) - const latestTableData = useLatest(tableData) // Checkbox selection — separate UI state, kept apart from row data so that // user toggles survive the row rebuilds triggered by metadata / plan refetches. const [selectedEpisodes, setSelectedEpisodes] = useState([]) - const getSelectedEpisodePaths = useCallback( - () => - selectedEpisodes - .map(({ season, episode }) => { - const row = latestTableData.current.find( - (r): r is UIMediaFileDataRow => - r.type === "episode" && r.season === season && r.episode === episode, - ) - return row?.videoFile - }) - .filter((path): path is string => path !== undefined), - [selectedEpisodes, latestTableData], - ) - const getSelectedEpisodes = useCallback( () => selectedEpisodes, [selectedEpisodes], @@ -164,12 +147,6 @@ function TvShowPanel() { [getSelectedEpisodes], ) - const renameBeforeConfirm = useCallback( - (plan: UIRenameFilesPlan) => - rebuildRenamePlanWithSelectedEpisodes(plan, getSelectedEpisodePaths()), - [getSelectedEpisodePaths], - ) - const handleSelectResult = useCallback( (args: SearchResultSelectedArgs) => { const path = mediaMetadata?.mediaFolderPath @@ -246,26 +223,19 @@ function TvShowPanel() { }) const renameFlow = useRuleBasedRenameFilesFlow({ - plans, mediaMetadata, - uiStatus, - beforeConfirm: renameBeforeConfirm, - onFlowStart: () => setEpisodeTableLayout("simple"), }) const aiRenameFlow = useAiBasedRenameFilesFlow({ plans, mediaMetadata, - onAppRenameConfirm: async (planId: string) => {}, + onAppRenameConfirm: async () => {}, setSelectedMediaMetadataByMediaFolderPath: setSelectedByMediaFolderPath, onFlowStart: () => setEpisodeTableLayout("simple"), }) const recognizeFlow = useRuleBasedRecognizeFlow({ - plans, mediaMetadata, - uiStatus, - beforeConfirm: recognizeBeforeConfirm, }) const aiRecognizeFlow = useAiBasedRecognizeFlow({ @@ -283,6 +253,8 @@ function TvShowPanel() { const { metadataFiles, subtitleFiles, nfoFiles, thumbnailFiles, newFilePaths } = useTvShowPanel(selectedFolder, plan) + console.log(`>>> newFilePaths:`, newFilePaths) + const selectFileFlow = useSelectAndUnselectFileFlow({ mediaMetadata, folderFiles, @@ -316,45 +288,56 @@ function TvShowPanel() { const appPlanPromptValue = useMemo((): TvShowAppPlanPromptContextValue => { return { - appRenamePlan: renameFlow.plan, - appRecognizePlan: recognizeFlow.plan, aiRenamePlan: aiRenameFlow.plan, aiRenamePromptStatus: aiRenameFlow.promptStatus, aiRecognizePlan: aiRecognizeFlow.plan, aiRecognizePromptStatus: aiRecognizeFlow.promptStatus, - renameToolbarOptions: renameFlow.namingRuleOptions, - selectedNamingRule: renameFlow.selectedNamingRule, - setSelectedNamingRule: () => {}, - onAppRenameNamingRuleSelected: () => {}, - onAppRenameConfirm: () => {}, - onAppRenameCancel: () => {}, onAiRenameConfirm: aiRenameFlow.onConfirm, onAiRenameCancel: aiRenameFlow.onCancel, onAiRecognizeConfirm: aiRecognizeFlow.onConfirm, onAiRecognizeCancel: aiRecognizeFlow.onCancel, - onAppRecognizeConfirm: recognizeFlow.onConfirm, - onAppRecognizeCancel: recognizeFlow.onCancel, - tvShowTitle: recognizeFlow.tvShowTitle, - tvShowTmdbId: recognizeFlow.tvShowTmdbId, - isRuleBasedRecognizeLoading: recognizeFlow.loading, - notAllEpisodesRecognized: recognizeFlow.notAllEpisodesRecognized, - allPlanFilesUnchanged: recognizeFlow.allPlanFilesUnchanged, - allRenamePlanFilesUnchanged: false, } - }, [renameFlow, aiRenameFlow, aiRecognizeFlow, recognizeFlow]) + }, [aiRenameFlow, aiRecognizeFlow]) const latestMediaMetadata = useLatest(mediaMetadata) const planId = useMemo(() => { return plan?.id ?? '' }, [plan]) const selectedEpisodesByPlanId = useRef>(new Map()) + const latestPlan = useLatest(plan) + const latestMetadata = useLatest(mediaMetadata) + useEffect(() => { - const m = latestMediaMetadata.current - const selectedEpisodes = m?.mediaFiles - ?.filter(f => f.seasonNumber !== undefined && f.episodeNumber !== undefined) - ?.map(f => { return { season: f.seasonNumber!, episode: f.episodeNumber!} }) - const episodes = selectedEpisodes ?? [] - selectedEpisodesByPlanId.current.set(planId, episodes) - setSelectedEpisodes(episodes) + console.log(`>>> useEffect selectedEpisodes CALLED`); + const plan = latestPlan.current; + const metadata = latestMetadata.current; + + if(planId !== plan?.id || plan.status !== 'pending') { + return; + } + + if(plan.task === 'rename-files') { + const m = latestMediaMetadata.current; + const selectedEpisodes = m?.mediaFiles + ?.filter(f => f.seasonNumber !== undefined && f.episodeNumber !== undefined) + ?.map(f => { return { season: f.seasonNumber!, episode: f.episodeNumber!} }) + const episodes = selectedEpisodes ?? [] + selectedEpisodesByPlanId.current.set(planId, episodes) + setSelectedEpisodes(episodes) + } else if (plan.task === 'recognize-media-file') { + const recognizePlan = plan as RecognizeMediaFilePlan; + const episodes = recognizePlan.files.map(f => { + return { + season: f.season, + episode: f.episode, + } + }) + .filter(f => { + return metadata?.tvShow?.seasons?.find(s => s.season === f.season)?.episodes?.find(e => e.episode === f.episode) + }) + setSelectedEpisodes(episodes) + } + + }, [planId]) const ruleBasedRenameFilePromptProps = useMemo(() => { @@ -365,23 +348,47 @@ function TvShowPanel() { selectedNamingRule: renameFlow.selectedNamingRule, onNamingRulesSelected: renameFlow.selectNamingRule, onConfirm: async () => { - - const episodes = mediaFileTableSeasonData.flatMap(s => s.episodes) - - const selectedFiles = episodes - .filter((e) => { - return selectedEpisodes.some(s => s.season === e.season && s.episode === e.episode) - }) - .flatMap(e => e.path) - .filter((path): path is string => path !== undefined) + // RENAME applies to files already linked in metadata, so each checked + // episode's table path (metadata.mediaFiles[...].absolutePath) is the + // plan entry's `from`. RECOGNIZE must not use this table lookup — + // see buildRecognizeApplySelectedFiles. + const selectedFiles = buildRenameApplySelectedFiles( + mediaFileTableSeasonData, + selectedEpisodes, + ) renameFlow.confirm(selectedFiles) }, onCancel: () => { - renameFlow.cancel(plan?.id ?? '') + void renameFlow.cancel() }, } - }, [renameFlow]) + }, [renameFlow, mediaFileTableSeasonData, selectedEpisodes]) + + const ruleBasedRecognizePromptProps = useMemo(() => { + return { + isOpen: recognizeFlow.open, + isLoading: recognizeFlow.loading, + tvShowTitle: recognizeFlow.tvShowTitle, + tvShowTmdbId: recognizeFlow.tvShowTmdbId, + notAllEpisodesRecognized: recognizeFlow.notAllEpisodesRecognized, + allPlanFilesUnchanged: recognizeFlow.allPlanFilesUnchanged, + isConfirmButtonDisabled: recognizeFlow.loading || recognizeFlow.allPlanFilesUnchanged, + onConfirm: async () => { + // RECOGNIZE applies plan-proposed paths: the files are usually NOT yet + // linked in metadata, so the episode-table lookup used by RENAME would + // drop them. The selection must resolve through recognizeFlow.plan.files. + const selectedFiles = buildRecognizeApplySelectedFiles( + recognizeFlow.plan, + selectedEpisodes, + ) + await recognizeFlow.confirm(selectedFiles) + }, + onCancel: () => { + void recognizeFlow.cancel() + }, + } + }, [recognizeFlow, selectedEpisodes]) return ( @@ -391,6 +398,10 @@ function TvShowPanel() { { } + + { + + } @@ -400,7 +411,7 @@ function TvShowPanel() {
state.closeUseNfoPrompt) @@ -79,29 +60,6 @@ export function TvShowPanelPrompts() { }} /> - {/* { - setSelectedNamingRule(value as "plex" | "emby") - }} - onNamingRulesSelected={(value) => { - void onAppRenameNamingRuleSelected(value as "plex" | "emby") - }} - isConfirmButtonDisabled={allRenamePlanFilesUnchanged} - onConfirm={async () => { - if (appRenamePlan) { - await onAppRenameConfirm(appRenamePlan.id) - } - }} - onCancel={async () => { - if (appRenamePlan) { - await onAppRenameCancel(appRenamePlan.id) - } - }} - /> */} - - - { - if (appRecognizePlan) { - await onAppRecognizeConfirm(appRecognizePlan as UIRecognizeMediaFilePlan) - } - }} - onCancel={async () => { - if (appRecognizePlan) { - await onAppRecognizeCancel(appRecognizePlan.id) - } - }} - />
) } diff --git a/apps/ui/src/components/tv/TvShowPanelUtils.test.ts b/apps/ui/src/components/tv/TvShowPanelUtils.test.ts index 1558c7ce..68cab887 100644 --- a/apps/ui/src/components/tv/TvShowPanelUtils.test.ts +++ b/apps/ui/src/components/tv/TvShowPanelUtils.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { mapTagToFileType, newPath, buildFileProps, renameFiles, updateMediaFileMetadatas, buildTvShowMediaMetadataByNFO, buildTmdbEpisodeByNFO, buildTemporaryRecognitionPlanAsync, tryToRecognizeTvShowFolderByNFO, unlinkEpisode } from './TvShowPanelUtils' +import { mapTagToFileType, newPath, buildFileProps, renameFiles, updateMediaFileMetadatas, buildTvShowMediaMetadataByNFO, buildTmdbEpisodeByNFO, tryToRecognizeTvShowFolderByNFO, unlinkEpisode, buildRenameApplySelectedFiles, buildRecognizeApplySelectedFiles } from './TvShowPanelUtils' import type { FileProps } from '@/lib/types' import type { MediaMetadata, MediaFileMetadata } from '@smm/types' import type { UIMediaMetadata } from '@/types/UIMediaMetadata' +import type { RecognizeMediaFilePlan } from '@smm/types/RecognizeMediaFilePlan' import { readFile } from '@/api/readFile' import { parseEpisodeNfo } from '@/lib/nfo' import { toast } from 'sonner' @@ -1051,98 +1052,6 @@ describe('buildTmdbEpisodeByNFO', () => { }) }) -describe('buildTemporaryRecognitionPlanAsync', () => { - const tvShowWithS1E1 = { - id: '1', - name: 'Show', - database: 'TMDB' as const, - seasons: [ - { - season: 1, - name: '', - episodes: [{ season: 1, episode: 1, name: '' }], - }, - ], - } - - it('returns null when mediaFolderPath is missing', async () => { - const mm: MediaMetadata = { - mediaFolderPath: undefined, - files: ['/media/S01E01.mkv'], - tvShow: tvShowWithS1E1, - } - const result = await buildTemporaryRecognitionPlanAsync(mm, mm.files ?? []) - expect(result).toBeNull() - }) - - it('returns null when files is missing', async () => { - const mm: MediaMetadata = { - mediaFolderPath: '/media', - files: undefined, - tvShow: tvShowWithS1E1, - } - const result = await buildTemporaryRecognitionPlanAsync(mm, mm.files ?? []) - expect(result).toBeNull() - }) - - it('returns null when tvShow is missing', async () => { - const mm: MediaMetadata = { - mediaFolderPath: '/media', - files: ['/media/S01E01.mkv'], - tvShow: undefined, - } - const result = await buildTemporaryRecognitionPlanAsync(mm, mm.files ?? []) - expect(result).toBeNull() - }) - - it('returns plan with empty files when no files are recognized', async () => { - const mm: MediaMetadata = { - mediaFolderPath: '/media', - files: ['/media/other.mkv'], - tvShow: { - id: '1', - name: 'Show', - database: 'TMDB', - seasons: [{ season: 1, name: '', episodes: [{ season: 1, episode: 1, name: '' }] }], - }, - } - const result = await buildTemporaryRecognitionPlanAsync(mm, mm.files ?? []) - expect(result).not.toBeNull() - expect(result!.mediaFolderPath).toBe('/media') - expect(result!.files).toHaveLength(0) - }) - - it('returns plan with files and mediaFolderPath when recognizeEpisodes returns matches', async () => { - const mediaFolderPath = '/media/show' - const mm: MediaMetadata = { - mediaFolderPath, - files: ['/media/show/S01E01.mkv', '/media/show/S01E02.mkv'], - tvShow: { - id: '1', - name: 'Show', - database: 'TMDB', - seasons: [ - { - season: 1, - name: '', - episodes: [ - { season: 1, episode: 1, name: '' }, - { season: 1, episode: 2, name: '' }, - ], - }, - ], - }, - } - const result = await buildTemporaryRecognitionPlanAsync(mm, mm.files ?? []) - expect(result).not.toBeNull() - expect(result!.mediaFolderPath).toBe(mediaFolderPath) - expect(result!.files).toHaveLength(2) - expect(result!.files).toContainEqual({ season: 1, episode: 1, path: '/media/show/S01E01.mkv' }) - expect(result!.files).toContainEqual({ season: 1, episode: 2, path: '/media/show/S01E02.mkv' }) - }) -}) - - describe('tryToRecognizeTvShowFolderByNFO', () => { it('should handle parseEpisodeNfo error and return mediaMetadata with valid tvShow', async () => { const tvshowNfoXml = ` @@ -1321,3 +1230,88 @@ describe('unlinkEpisode', () => { expect(metadata.mediaFiles).toHaveLength(0) }) }) + +describe('buildRenameApplySelectedFiles', () => { + const seasonData = [ + { + season: 1, + title: 'Season 1', + episodes: [ + { season: 1, episode: 1, title: 'E1', path: '/show/S01E01.mkv' }, + { season: 1, episode: 2, title: 'E2', path: '/show/S01E02.mkv' }, + { season: 1, episode: 3, title: 'E3', path: undefined }, + ], + }, + ] + + it('returns table paths of checked episodes', () => { + const result = buildRenameApplySelectedFiles(seasonData, [ + { season: 1, episode: 1 }, + { season: 1, episode: 2 }, + ]) + expect(result).toEqual(['/show/S01E01.mkv', '/show/S01E02.mkv']) + }) + + it('excludes unchecked episodes', () => { + const result = buildRenameApplySelectedFiles(seasonData, [{ season: 1, episode: 2 }]) + expect(result).toEqual(['/show/S01E02.mkv']) + }) + + it('skips checked episodes whose table path is undefined', () => { + const result = buildRenameApplySelectedFiles(seasonData, [{ season: 1, episode: 3 }]) + expect(result).toEqual([]) + }) + + it('returns empty array for empty selection', () => { + expect(buildRenameApplySelectedFiles(seasonData, [])).toEqual([]) + }) +}) + +describe('buildRecognizeApplySelectedFiles', () => { + // Regression scenario from server.log/browser.log (2026-09-05): the plan + // proposes S01E01 + S01E02, but metadata only links S01E01. The checked + // episodes must map to PLAN paths — the old table-based lookup silently + // dropped S01E02, so its recognition was never applied. + const plan: RecognizeMediaFilePlan = { + id: 'plan-r1', + task: 'recognize-media-file', + status: 'pending', + creator: 'app', + mediaFolderPath: '/show', + files: [ + { season: 1, episode: 1, path: '/show/S01E01 - old name.mkv' }, + { season: 1, episode: 2, path: '/show/S01E02 - not yet linked.mkv' }, + ], + } + + it('maps checked episodes to plan file paths, including files not linked in metadata', () => { + const result = buildRecognizeApplySelectedFiles(plan, [ + { season: 1, episode: 1 }, + { season: 1, episode: 2 }, + ]) + expect(result).toEqual(['/show/S01E01 - old name.mkv', '/show/S01E02 - not yet linked.mkv']) + }) + + it('excludes unchecked episodes', () => { + const result = buildRecognizeApplySelectedFiles(plan, [{ season: 1, episode: 2 }]) + expect(result).toEqual(['/show/S01E02 - not yet linked.mkv']) + }) + + it('ignores checked episodes that are not part of the plan', () => { + const result = buildRecognizeApplySelectedFiles(plan, [ + { season: 1, episode: 1 }, + { season: 2, episode: 5 }, + ]) + expect(result).toEqual(['/show/S01E01 - old name.mkv']) + }) + + it('returns empty array for empty selection', () => { + expect(buildRecognizeApplySelectedFiles(plan, [])).toEqual([]) + }) + + it('returns empty array when plan is undefined', () => { + expect( + buildRecognizeApplySelectedFiles(undefined, [{ season: 1, episode: 1 }]), + ).toEqual([]) + }) +}) diff --git a/apps/ui/src/components/tv/TvShowPanelUtils.ts b/apps/ui/src/components/tv/TvShowPanelUtils.ts index ab78d24c..facf97ff 100644 --- a/apps/ui/src/components/tv/TvShowPanelUtils.ts +++ b/apps/ui/src/components/tv/TvShowPanelUtils.ts @@ -22,10 +22,9 @@ import type { FileProps } from "@/lib/types"; import { readFile } from "@/api/readFile"; import { parseEpisodeNfo } from "@/lib/nfo"; import { renameFiles as renameFilesApi } from "@/api/renameFiles"; -import type { RecognizeMediaFilePlan, RecognizedFile } from "@smm/types/RecognizeMediaFilePlan"; +import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan"; import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan"; import { toast } from "sonner"; -import { recognizeEpisodesAsync } from "@/lib/recognizeEpisodesUi"; import type { PersistUIMediaMetadataFn } from "@/types/persistUIMediaMetadata"; export function mapTagToFileType(tag: "VID" | "SUB" | "AUD" | "NFO" | "POSTER" | ""): "file" | "video" | "subtitle" | "audio" | "nfo" | "poster" { @@ -253,7 +252,7 @@ export function rebuildRenamePlanWithSelectedEpisodes( originalPlan: RenameFilesPlan, selectedEpisodePaths: string[] ): RenameFilesPlan { - + return { ...originalPlan, files: originalPlan.files.filter(file => { @@ -263,6 +262,49 @@ export function rebuildRenamePlanWithSelectedEpisodes( } +/** + * RENAME flow: build the apply-plan `data.files` payload from the episode table. + * A rename plan only touches files that are already linked in metadata, so the + * table's current episode path (metadata.mediaFiles[...].absolutePath) is + * exactly the plan entry's `from`. + * + * Do NOT reuse this for the RECOGNIZE flow: recognize targets files that are + * not yet linked, whose table paths are `undefined` — the selection would + * silently shrink to the already-recognized files. + * See buildRecognizeApplySelectedFiles. + */ +export function buildRenameApplySelectedFiles( + seasonData: { episodes: { season: number; episode: number; path?: string }[] }[], + selectedEpisodes: { season: number; episode: number }[], +): string[] { + const selectedSet = new Set(selectedEpisodes.map((e) => `${e.season}-${e.episode}`)) + return seasonData + .flatMap((s) => s.episodes) + .flatMap((e) => + e.path !== undefined && selectedSet.has(`${e.season}-${e.episode}`) ? [e.path] : [], + ) +} + +/** + * RECOGNIZE flow: build the apply-plan `data.files` payload from the PLAN itself. + * A recognize plan proposes season/episode for files that are usually NOT yet + * linked in metadata, so their episode-table paths are `undefined` and a + * table-based lookup would silently drop them from the selection. The checked + * (season, episode) pairs must therefore be mapped back to `plan.files[].path`, + * matching docs/dev/recognize-episodes.md: + * "data: { files } with the selected plan.files[].path entries". + */ +export function buildRecognizeApplySelectedFiles( + plan: RecognizeMediaFilePlan | undefined, + selectedEpisodes: { season: number; episode: number }[], +): string[] { + if (plan === undefined) return [] + const selectedSet = new Set(selectedEpisodes.map((e) => `${e.season}-${e.episode}`)) + return plan.files + .filter((f) => selectedSet.has(`${f.season}-${f.episode}`)) + .map((f) => f.path) +} + /** * Try to regcognize media folder by NFO. * @param mediaFolderPath @@ -947,44 +989,6 @@ export async function executeRenamePlan( * @returns A partial recognition plan with file mappings, or null if no files found * The caller (addTmpPlan) will add id, task, status, and tmp fields */ -export async function buildTemporaryRecognitionPlanAsync( - mediaMetadata: MediaMetadata, - folderFiles: string[], -): Promise<(Partial & { mediaFolderPath: string; files: RecognizedFile[] }) | null> { - console.log("[recognize] build temporary plan started", { - mediaFolderPath: mediaMetadata.mediaFolderPath, - fileCount: folderFiles.length, - tvShowId: mediaMetadata.tvShow?.id, - }) - - if (!mediaMetadata.mediaFolderPath || folderFiles.length === 0 || !mediaMetadata.tvShow) { - console.warn("[recognize] build temporary plan aborted: missing prerequisites", { - hasMediaFolderPath: !!mediaMetadata.mediaFolderPath, - hasFiles: folderFiles.length > 0, - hasTvShow: !!mediaMetadata.tvShow, - }) - return null - } - - const collected = await recognizeEpisodesAsync(mediaMetadata, folderFiles); - - console.log("[recognize] build temporary plan completed", { - mediaFolderPath: mediaMetadata.mediaFolderPath, - recognizedCount: collected.length, - }) - - return { - mediaFolderPath: mediaMetadata.mediaFolderPath, - files: collected.map(({ season, episode, file }) => ({ - season, - episode, - path: file, - })) - } -} - - - export interface OnMediaFolderSelectedParams { mediaMetadata: MediaMetadata diff --git a/apps/ui/src/components/tv/plans/TvShowAppPlanPromptContext.tsx b/apps/ui/src/components/tv/plans/TvShowAppPlanPromptContext.tsx index 792f23a5..0169791d 100644 --- a/apps/ui/src/components/tv/plans/TvShowAppPlanPromptContext.tsx +++ b/apps/ui/src/components/tv/plans/TvShowAppPlanPromptContext.tsx @@ -8,34 +8,15 @@ export interface RenameToolbarOption { } export interface TvShowAppPlanPromptContextValue { - appRenamePlan: UIRenameFilesPlan | undefined - appRecognizePlan: UIRecognizeMediaFilePlan | undefined aiRenamePlan: UIRenameFilesPlan | undefined aiRenamePromptStatus: "generating" | "wait-for-ack" aiRecognizePlan: UIRecognizeMediaFilePlan | undefined aiRecognizePromptStatus: "generating" | "wait-for-ack" - renameToolbarOptions: RenameToolbarOption[] - selectedNamingRule: "plex" | "emby" - setSelectedNamingRule: (rule: "plex" | "emby") => void - - onAppRenameNamingRuleSelected: (rule: "plex" | "emby") => void | Promise - onAppRenameConfirm: (planId: string) => void | Promise - onAppRenameCancel: (planId: string) => void | Promise onAiRenameConfirm: () => void | Promise onAiRenameCancel: () => void | Promise onAiRecognizeConfirm: () => void | Promise onAiRecognizeCancel: () => void | Promise - - onAppRecognizeConfirm: (plan: UIRecognizeMediaFilePlan) => void | Promise - onAppRecognizeCancel: (planId: string) => void | Promise - - tvShowTitle: string - tvShowTmdbId: number - isRuleBasedRecognizeLoading: boolean - notAllEpisodesRecognized: boolean - allPlanFilesUnchanged: boolean - allRenamePlanFilesUnchanged: boolean } const TvShowAppPlanPromptContext = createContext( diff --git a/apps/ui/src/hooks/plans/useTryToRecognizeEpisodesMutation.ts b/apps/ui/src/hooks/plans/useTryToRecognizeEpisodesMutation.ts new file mode 100644 index 00000000..c17943b7 --- /dev/null +++ b/apps/ui/src/hooks/plans/useTryToRecognizeEpisodesMutation.ts @@ -0,0 +1,35 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan" +import { tryToRecognizeEpisodes } from "@/api/tryToRecognizeEpisodes" +import type { Plan } from "@/api/getPlans" +import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" +import { plansQueryKey } from "./plansQueryKeys" + +export interface TryToRecognizeEpisodesVariables { + mediaFolderPath: string +} + +/** + * POST /api/try-to-recognize-episodes — build a pending recognize-media-file + * plan and add it to the plans cache. + */ +export function useTryToRecognizeEpisodesMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ mediaFolderPath }): Promise => { + const resp = await tryToRecognizeEpisodes({ mediaFolderPath }) + if (resp.error || !resp.data?.plan) { + throw new Error(resp.error ?? "Failed to create recognize plan") + } + return resp.data.plan as RecognizeMediaFilePlan + }, + onSuccess: (plan, { mediaFolderPath }) => { + const key = plansQueryKey(normalizeMediaFolderPathForQuery(mediaFolderPath)) + queryClient.setQueryData(key, (prev) => { + const rest = (prev ?? []).filter((p) => p.id !== plan.id) + return [...rest, plan] + }) + }, + }) +} diff --git a/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.test.tsx b/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.test.tsx index 3575b63d..52ef3754 100644 --- a/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.test.tsx +++ b/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.test.tsx @@ -1,50 +1,47 @@ import { describe, expect, it, vi, beforeEach } from "vitest" -import { renderHook, waitFor } from "@testing-library/react" +import { renderHook, waitFor, act } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import type { ReactNode } from "react" import { useRuleBasedRecognizeFlow } from "./useRuleBasedRecognizeFlow" -import { buildTemporaryRecognitionPlanAsync } from "@/components/tv/TvShowPanelUtils" -import { plansQueryKey } from "@/hooks/plans/plansQueryKeys" -import type { UIRecognizeMediaFilePlan } from "@/types/UIRecognizeMediaFilePlan" import type { MediaMetadata } from "@smm/types" +import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan" -const { toastErrorMock, createPlanOptimisticMock, updatePlanMutateAsyncMock } = vi.hoisted(() => ({ - toastErrorMock: vi.fn(), - createPlanOptimisticMock: vi.fn(), - updatePlanMutateAsyncMock: vi.fn(), -})) - -vi.mock("sonner", () => ({ - toast: { - error: toastErrorMock, - success: vi.fn(), - }, -})) - -vi.mock("@/components/tv/TvShowPanelUtils", async (importOriginal) => { - const mod = await importOriginal() +const { + toastErrorMock, + toastSuccessMock, + tryToRecognizeMutationMock, + rejectPlanMutationMock, + applyPlanMutationMock, +} = vi.hoisted(() => { + const makeMutation = () => ({ + mutateAsync: vi.fn(), + mutate: vi.fn(), + reset: vi.fn(), + isPending: false, + }) return { - ...mod, - buildTemporaryRecognitionPlanAsync: vi.fn(), + toastErrorMock: vi.fn(), + toastSuccessMock: vi.fn(), + tryToRecognizeMutationMock: makeMutation(), + rejectPlanMutationMock: makeMutation(), + applyPlanMutationMock: makeMutation(), } }) -vi.mock("@/lib/mediaFolderFiles", () => ({ - listMediaFolderFilePaths: vi.fn(async () => ["/media/folder/S01E01.mkv"]), +vi.mock("sonner", () => ({ + toast: { error: toastErrorMock, success: toastSuccessMock }, })) -vi.mock("@/hooks/plans", () => ({ - useCreatePlanMutation: () => ({ - createPlanOptimistic: createPlanOptimisticMock, - }), - useUpdatePlanMutation: () => ({ - mutateAsync: updatePlanMutateAsyncMock, - }), - toUpdatePlanPatch: (patch: unknown) => patch, +vi.mock("@/hooks/plans/useTryToRecognizeEpisodesMutation", () => ({ + useTryToRecognizeEpisodesMutation: () => tryToRecognizeMutationMock, })) -vi.mock("@/hooks/mediaMetadata/useUpdateMediaMetadataMutation", () => ({ - useUpdateMediaMetadataMutation: () => ({ persistMediaMetadata: vi.fn() }), +vi.mock("@/hooks/plans/useRejectPlanMutation", () => ({ + useRejectPlanMutation: () => rejectPlanMutationMock, +})) + +vi.mock("@/hooks/plans/useApplyPlanMutation", () => ({ + useApplyPlanMutation: () => applyPlanMutationMock, })) vi.mock("@/lib/i18n", () => ({ @@ -55,21 +52,37 @@ vi.mock("@/lib/i18n", () => ({ describe("useRuleBasedRecognizeFlow", () => { const mediaFolderPath = "/storage/Users/currentUser/Download/Anime/show" - const preparingPlan: UIRecognizeMediaFilePlan = { + const pendingPlan: RecognizeMediaFilePlan = { id: "plan-1", task: "recognize-media-file", - status: "preparing", + status: "pending", creator: "app", mediaFolderPath, - files: [], + files: [ + { season: 1, episode: 1, path: `${mediaFolderPath}/S01E01.mkv` }, + { season: 1, episode: 2, path: `${mediaFolderPath}/S01E02.mkv` }, + ], } const mediaMetadata = { mediaFolderPath, type: "tvshow-folder", - tvShow: { id: "123", name: "Test Show" }, - files: ["S01E01.mkv"], - } as MediaMetadata + tvShow: { + id: "123", + name: "Test Show", + seasons: [ + { + season: 1, + name: "Season 1", + episodes: [ + { episode: 1, name: "E1" }, + { episode: 2, name: "E2" }, + ], + }, + ], + }, + mediaFiles: [{ absolutePath: `${mediaFolderPath}/S01E01.mkv`, seasonNumber: 1, episodeNumber: 1 }], + } as unknown as MediaMetadata let queryClient: QueryClient @@ -77,117 +90,167 @@ describe("useRuleBasedRecognizeFlow", () => { {children} ) + const renderFlow = () => + renderHook(() => useRuleBasedRecognizeFlow({ mediaMetadata }), { wrapper }) + beforeEach(() => { queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) vi.clearAllMocks() - createPlanOptimisticMock.mockResolvedValue(preparingPlan) - updatePlanMutateAsyncMock.mockResolvedValue(null) + tryToRecognizeMutationMock.mutateAsync.mockResolvedValue(pendingPlan) + rejectPlanMutationMock.mutateAsync.mockResolvedValue(null) + applyPlanMutationMock.mutateAsync.mockResolvedValue(null) + tryToRecognizeMutationMock.isPending = false }) - it("shows failure toast and rejects plan when recognition computation throws", async () => { - queryClient.setQueryData(plansQueryKey(mediaFolderPath), [preparingPlan]) - vi.mocked(buildTemporaryRecognitionPlanAsync).mockRejectedValue( - new Error("lookup crashed"), - ) - - renderHook( + it("Start to recognize: open=true, mutation called, loading=true while pending", async () => { + let resolveTryToRecognize: (plan: RecognizeMediaFilePlan) => void = () => {} + tryToRecognizeMutationMock.mutateAsync.mockImplementation( () => - useRuleBasedRecognizeFlow({ - plans: [preparingPlan], - mediaMetadata, - uiStatus: "ok", - beforeConfirm: (plan) => plan, + new Promise((resolve) => { + resolveTryToRecognize = resolve }), - { wrapper }, ) + tryToRecognizeMutationMock.isPending = true - await waitFor(() => { - expect(toastErrorMock).toHaveBeenCalledWith("lookup crashed") + const { result } = renderFlow() + + act(() => { + result.current.start() }) - expect(updatePlanMutateAsyncMock).toHaveBeenCalledWith({ + expect(result.current.open).toBe(true) + expect(tryToRecognizeMutationMock.mutateAsync).toHaveBeenCalledWith({ mediaFolderPath }) + expect(result.current.loading).toBe(true) + + await act(async () => { + tryToRecognizeMutationMock.isPending = false + resolveTryToRecognize(pendingPlan) + }) + + expect(result.current.loading).toBe(false) + expect(result.current.plan).toEqual(pendingPlan) + }) + + it("Start to recognize and then cancel", async () => { + const { result } = renderFlow() + + await act(async () => { + await result.current.start() + }) + + await act(async () => { + await result.current.cancel() + }) + + expect(result.current.open).toBe(false) + expect(rejectPlanMutationMock.mutateAsync).toHaveBeenCalledWith({ id: "plan-1", mediaFolderPath, - patch: { status: "rejected" }, }) + expect(tryToRecognizeMutationMock.reset).toHaveBeenCalled() + expect(rejectPlanMutationMock.reset).toHaveBeenCalled() + expect(applyPlanMutationMock.reset).toHaveBeenCalled() + expect(result.current.plan).toBeUndefined() }) - it("shows failure toast and rejects plan when no episodes are recognized", async () => { - queryClient.setQueryData(plansQueryKey(mediaFolderPath), [preparingPlan]) - vi.mocked(buildTemporaryRecognitionPlanAsync).mockResolvedValue({ + it("Start to recognize and then confirm without selection", async () => { + const { result } = renderFlow() + + await act(async () => { + await result.current.start() + }) + + await act(async () => { + await result.current.confirm() + }) + + expect(applyPlanMutationMock.mutateAsync).toHaveBeenCalledWith({ + id: "plan-1", mediaFolderPath, - files: [], + files: undefined, }) + expect(result.current.open).toBe(false) + expect(result.current.plan).toBeUndefined() + expect(toastErrorMock).not.toHaveBeenCalled() + }) - renderHook( - () => - useRuleBasedRecognizeFlow({ - plans: [preparingPlan], - mediaMetadata, - uiStatus: "ok", - beforeConfirm: (plan) => plan, - }), - { wrapper }, - ) + it("Confirm with selected files passes them to apply-plan", async () => { + const { result } = renderFlow() - await waitFor(() => { - expect(toastErrorMock).toHaveBeenCalledWith( - "Unable to recognize any episodes. Consider using AI to recognize instead.", - ) + await act(async () => { + await result.current.start() + }) + + await act(async () => { + await result.current.confirm([`${mediaFolderPath}/S01E01.mkv`]) }) - expect(updatePlanMutateAsyncMock).toHaveBeenCalledWith({ + expect(applyPlanMutationMock.mutateAsync).toHaveBeenCalledWith({ id: "plan-1", mediaFolderPath, - patch: { status: "rejected" }, + files: [`${mediaFolderPath}/S01E01.mkv`], }) + expect(result.current.open).toBe(false) }) - it("shows failure toast when createPlan fails during startRecognizeFlow", async () => { - createPlanOptimisticMock.mockRejectedValue(new Error("create plan failed")) + it("Confirm failure keeps the prompt open and toasts", async () => { + applyPlanMutationMock.mutateAsync.mockRejectedValue(new Error("boom")) - const { result } = renderHook( - () => - useRuleBasedRecognizeFlow({ - plans: [], - mediaMetadata, - uiStatus: "ok", - beforeConfirm: (plan) => plan, - }), - { wrapper }, - ) + const { result } = renderFlow() - result.current.startRecognizeFlow() + await act(async () => { + await result.current.start() + }) - await waitFor(() => { - expect(toastErrorMock).toHaveBeenCalledWith("create plan failed") + await act(async () => { + await result.current.confirm() }) + + expect(toastErrorMock).toHaveBeenCalledWith("Recognition failed. Please try again.") + expect(result.current.open).toBe(true) + expect(result.current.plan).toEqual(pendingPlan) }) - it("fails preparing plan when metadata loading errors", async () => { - queryClient.setQueryData(plansQueryKey(mediaFolderPath), [preparingPlan]) + it("Empty recognition result: prompt closed, no-recognized-files toast, plan rejected", async () => { + tryToRecognizeMutationMock.mutateAsync.mockResolvedValue({ + ...pendingPlan, + files: [], + }) - renderHook( - () => - useRuleBasedRecognizeFlow({ - plans: [preparingPlan], - mediaMetadata, - uiStatus: "error_loading_metadata", - beforeConfirm: (plan) => plan, - }), - { wrapper }, - ) + const { result } = renderFlow() - await waitFor(() => { - expect(toastErrorMock).toHaveBeenCalledWith("Recognition failed. Please try again.") + act(() => { + result.current.start() }) - expect(updatePlanMutateAsyncMock).toHaveBeenCalledWith({ + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith( + "Unable to recognize any episodes. Consider using AI to recognize instead.", + ) + }) + expect(rejectPlanMutationMock.mutateAsync).toHaveBeenCalledWith({ id: "plan-1", mediaFolderPath, - patch: { status: "rejected" }, }) + expect(result.current.open).toBe(false) + expect(result.current.plan).toBeUndefined() + }) + + it("Start failure: toast and prompt closed", async () => { + tryToRecognizeMutationMock.mutateAsync.mockRejectedValue(new Error("boom")) + + const { result } = renderFlow() + + act(() => { + result.current.start() + }) + + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith("Recognition failed. Please try again.") + }) + expect(result.current.open).toBe(false) + expect(result.current.plan).toBeUndefined() }) }) diff --git a/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.ts b/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.ts index 6db61b86..508649d8 100644 --- a/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.ts +++ b/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.ts @@ -1,49 +1,41 @@ -import { useCallback, useEffect, useMemo, useRef } from "react" -import { useQueryClient } from "@tanstack/react-query" +import { useCallback, useMemo, useState } from "react" import { toast } from "sonner" -import { - applyRecognizeMediaFilePlan, - buildTemporaryRecognitionPlanAsync, -} from "@/components/tv/TvShowPanelUtils" -import { selectActiveAppPlan } from "@/components/tv/plans/selectActiveAppPlan" -import { useCreatePlanMutation, toUpdatePlanPatch, useUpdatePlanMutation } from "@/hooks/plans" -import { plansQueryKey } from "@/hooks/plans/plansQueryKeys" -import { useUpdateMediaMetadataMutation } from "@/hooks/mediaMetadata/useUpdateMediaMetadataMutation" +import { useApplyPlanMutation } from "@/hooks/plans/useApplyPlanMutation" +import { useRejectPlanMutation } from "@/hooks/plans/useRejectPlanMutation" +import { useTryToRecognizeEpisodesMutation } from "@/hooks/plans/useTryToRecognizeEpisodesMutation" import { isRuleBasedRecognizePlanComplete, isRuleBasedRecognizePlanFullyUnchanged, } from "@/lib/isRuleBasedRecognizePlanComplete" -import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" -import { listMediaFolderFilePaths } from "@/lib/mediaFolderFiles" -import { nextTraceId } from "@/lib/utils" import { useTranslation } from "@/lib/i18n" -import type { Plan } from "@/api/getPlans" import type { MediaMetadata } from "@smm/types" import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan" -import type { UIMediaFolderStatus } from "@/types/UIMediaFolder" -import type { UIPlan } from "@/types/UIPlan" -import type { UIRecognizeMediaFilePlan } from "@/types/UIRecognizeMediaFilePlan" export interface UseRuleBasedRecognizeFlowOptions { - plans: UIPlan[] mediaMetadata: MediaMetadata | undefined - uiStatus: UIMediaFolderStatus | undefined - beforeConfirm: (plan: UIRecognizeMediaFilePlan) => UIRecognizeMediaFilePlan } +/** + * Rule-based recognize flow aligned with docs/dev/recognize-episodes.md: + * try-to-recognize-episodes → apply-plan (data.files for selected episodes) / reject-plan. + */ export function useRuleBasedRecognizeFlow({ - plans, mediaMetadata, - uiStatus, - beforeConfirm, }: UseRuleBasedRecognizeFlowOptions) { const { t } = useTranslation(["components"]) - const queryClient = useQueryClient() + + const [open, setOpen] = useState(false) + const [plan, setPlan] = useState(undefined) + const mediaFolderPath = mediaMetadata?.mediaFolderPath - const createPlanMutation = useCreatePlanMutation() - const updatePlanMutation = useUpdatePlanMutation() - const { persistMediaMetadata } = useUpdateMediaMetadataMutation() - const computationRef = useRef(new Set()) + const rejectPlanMutation = useRejectPlanMutation() + const applyPlanMutation = useApplyPlanMutation() + const tryToRecognizeMutation = useTryToRecognizeEpisodesMutation() + + const loading = + rejectPlanMutation.isPending || + applyPlanMutation.isPending || + tryToRecognizeMutation.isPending const recognizeFailedMessage = t("toast.recognizeFailed", { defaultValue: "Recognition failed. Please try again.", @@ -53,281 +45,118 @@ export function useRuleBasedRecognizeFlow({ "Unable to recognize any episodes. Consider using AI to recognize instead.", }) - const plan = useMemo( - () => - selectActiveAppPlan( - plans, - mediaFolderPath, - "recognize-media-file", - ), - [plans, mediaFolderPath], - ) - - const open = plan !== undefined - const loading = plan?.status === "preparing" - - const tvShowTitle = mediaMetadata?.tvShow?.name ?? "" - const tvShowTmdbId = parseInt(mediaMetadata?.tvShow?.id ?? "0", 10) - const okMediaMetadata = - uiStatus === "ok" ? mediaMetadata : undefined - - const notAllEpisodesRecognized = useMemo(() => { - if ( - loading || - !plan || - plan.status !== "pending" || - plan.task !== "recognize-media-file" || - !okMediaMetadata - ) { - return false - } - return !isRuleBasedRecognizePlanComplete(plan.files, okMediaMetadata) - }, [loading, plan, okMediaMetadata]) - - const allPlanFilesUnchanged = useMemo(() => { - if ( - loading || - !plan || - plan.status !== "pending" || - plan.task !== "recognize-media-file" || - !okMediaMetadata - ) { - return false - } - return isRuleBasedRecognizePlanFullyUnchanged(plan.files, okMediaMetadata) - }, [loading, plan, okMediaMetadata]) - - const removePlanFromCache = useCallback( - (planId: string) => { - if (!mediaFolderPath) return - console.log("[recognize] remove plan from cache", { planId, mediaFolderPath }) - const key = plansQueryKey(normalizeMediaFolderPathForQuery(mediaFolderPath)) - queryClient.setQueryData(key, (prev) => - (prev ?? []).filter((p) => p.id !== planId), - ) - }, - [mediaFolderPath, queryClient], - ) - - const failRecognizePlan = useCallback( - async (planId: string, message: string, reason: string) => { - console.warn("[recognize] fail recognize plan", { planId, reason, message }) - toast.error(message) - if (!mediaFolderPath) { - removePlanFromCache(planId) - return - } - try { - await updatePlanMutation.mutateAsync({ - id: planId, - mediaFolderPath, - patch: toUpdatePlanPatch({ status: "rejected" }), - }) - console.log("[recognize] plan rejected", { planId }) - } catch (error) { - console.error("[recognize] failed to reject plan, removing from cache", { planId, error }) - removePlanFromCache(planId) - } - }, - [mediaFolderPath, updatePlanMutation, removePlanFromCache], - ) - - const resumeComputation = useCallback( - (planId: string) => { - if (!mediaFolderPath || !mediaMetadata) { - return - } + const reset = useCallback(() => { + rejectPlanMutation.reset() + applyPlanMutation.reset() + tryToRecognizeMutation.reset() + }, [rejectPlanMutation, applyPlanMutation, tryToRecognizeMutation]) - const current = plans.find((p) => p.id === planId) - if ( - !current || - current.task !== "recognize-media-file" || - current.status !== "preparing" || - current.files.length > 0 - ) { - return - } - if (computationRef.current.has(planId)) { + const confirm = useCallback( + async (selectedEpisodeFiles?: string[]) => { + if (plan === undefined) { + console.error("Plan was confirmed but the plan is undefined") return } - computationRef.current.add(planId) - console.log("[recognize] matching episode video files by naming rules", { - planId, - mediaFolderPath, - tvShow: mediaMetadata.tvShow?.name, - }) - void listMediaFolderFilePaths(mediaMetadata.mediaFolderPath!) - .then((folderFiles) => buildTemporaryRecognitionPlanAsync(mediaMetadata, folderFiles)) - .then(async (planData) => { - if (planData && planData.files.length > 0) { - await updatePlanMutation.mutateAsync({ - id: planId, - mediaFolderPath, - patch: toUpdatePlanPatch({ status: "pending", files: planData.files }), - }) - console.log("[recognize] recognize preview ready — user can review and confirm", { - planId, - tvShow: mediaMetadata.tvShow?.name, - matchedCount: planData.files.length, - matches: planData.files.map((f) => ({ - episode: `S${f.season}E${f.episode}`, - file: f.path.split(/[/\\]/).pop(), - })), - }) - return - } - await failRecognizePlan(planId, noRecognizedFilesMessage, "no recognized files") - }) - .catch(async (err) => { - console.error("[recognize] episode matching failed", { planId, error: err }) - const message = - err instanceof Error && err.message ? err.message : recognizeFailedMessage - await failRecognizePlan(planId, message, "computation error") - }) - .finally(() => { - computationRef.current.delete(planId) - }) - }, - [ - mediaFolderPath, - mediaMetadata, - plans, - updatePlanMutation, - failRecognizePlan, - noRecognizedFilesMessage, - recognizeFailedMessage, - ], - ) - - const onConfirm = useCallback( - async (recognizePlan: UIRecognizeMediaFilePlan) => { - console.log("[recognize] confirm started", { - planId: recognizePlan.id, - fileCount: recognizePlan.files.length, - }) - - if (!okMediaMetadata) { - console.warn("[recognize] confirm aborted: no media metadata", { planId: recognizePlan.id }) + if (!mediaMetadata || !mediaFolderPath) { + console.warn("[recognize] user confirmed but media metadata missing", { plan }) toast.error("No media metadata available") return } - if (!recognizePlan.mediaFolderPath) { - console.warn("[recognize] confirm aborted: invalid plan", { planId: recognizePlan.id }) - toast.error("Plan not found or invalid") - return - } - try { - const actualPlan = beforeConfirm(recognizePlan) as RecognizeMediaFilePlan - const traceId = `TvShowPanel-handleRuleBasedRecognizeConfirm-${nextTraceId()}` - console.log("[recognize] applying recognize plan", { - planId: recognizePlan.id, - traceId, - fileCount: actualPlan.files.length, - }) - await applyRecognizeMediaFilePlan(actualPlan, okMediaMetadata, persistMediaMetadata, { - traceId, + console.log("[recognize] POST /api/apply-plan", { + id: plan.id, + selectedCount: selectedEpisodeFiles?.length, }) - if (mediaFolderPath) { - await updatePlanMutation.mutateAsync({ - id: recognizePlan.id, - mediaFolderPath, - patch: toUpdatePlanPatch({ status: "completed" }), - }) - } - console.log("[recognize] confirm completed", { planId: recognizePlan.id, traceId }) - toast.success(t("toolbar.recognizeEpisodesSuccess")) - } catch (error) { - console.error("[recognize] confirm failed", { planId: recognizePlan.id, error }) - toast.error("Failed to apply recognition") - } - }, - [okMediaMetadata, mediaFolderPath, beforeConfirm, persistMediaMetadata, updatePlanMutation, t], - ) - - const onCancel = useCallback( - async (planId: string) => { - console.log("[recognize] cancel started", { planId }) - if (!mediaFolderPath) { - console.warn("[recognize] cancel aborted: no media folder path", { planId }) - return - } - try { - await updatePlanMutation.mutateAsync({ - id: planId, + await applyPlanMutation.mutateAsync({ + id: plan.id, mediaFolderPath, - patch: toUpdatePlanPatch({ status: "rejected" }), + files: selectedEpisodeFiles, }) - console.log("[recognize] cancel completed", { planId }) + + setOpen(false) + setPlan(undefined) + toast.success(t("toolbar.recognizeEpisodesSuccess")) + console.log("[recognize] recognize completed successfully", { id: plan.id }) } catch (error) { - console.error("[recognize] cancel failed, removing from cache", { planId, error }) - removePlanFromCache(planId) + console.error("[recognize] unexpected error while applying recognize", { id: plan.id, error }) toast.error(recognizeFailedMessage) } }, - [mediaFolderPath, updatePlanMutation, removePlanFromCache, recognizeFailedMessage], + [mediaFolderPath, plan, mediaMetadata, applyPlanMutation, recognizeFailedMessage, t], ) - const startRecognizeFlow = useCallback(() => { - if (!mediaFolderPath) { - console.warn("[recognize] start aborted: no media folder path") - toast.error("No media folder path available") + const cancel = useCallback(async () => { + if (mediaFolderPath === undefined) { + console.error("Media folder path is undefined") return } - const planId = crypto.randomUUID() - console.log("[recognize] user started rule-based recognize", { - planId, - mediaFolderPath, - tvShow: mediaMetadata?.tvShow?.name, - }) + setOpen(false) - void createPlanMutation - .createPlanOptimistic({ - id: planId, - task: "recognize-media-file", - mediaFolderPath, - creator: "app", - }) - .then(() => { - console.log("[recognize] recognize plan created (status=preparing), matching files next", { - planId, + if (plan && plan.status === "pending") { + rejectPlanMutation.mutateAsync({ id: plan.id, mediaFolderPath }) // fire and forget + } + + setPlan(undefined) + reset() + }, [mediaFolderPath, rejectPlanMutation, plan, reset]) + + /** Opens RuleBasedRecognizePrompt by calling try-to-recognize-episodes. */ + const start = useCallback(() => { + if (!mediaFolderPath) { + console.warn("[recognize] cannot start — media folder path missing") + toast.error("No media folder path available") + return + } + reset() + setOpen(true) + void tryToRecognizeMutation.mutateAsync({ mediaFolderPath }) + .then((resp) => { + if (!resp.files || resp.files.length === 0) { + console.log("[recognize] no files recognized", { mediaFolderPath }) + toast.error(noRecognizedFilesMessage) + rejectPlanMutation.mutateAsync({ id: resp.id, mediaFolderPath }) // fire and forget + setOpen(false) + return + } + console.log("[recognize] recognize preview ready", { + id: resp.id, + matchedCount: resp.files.length, }) + setPlan(resp) }) - .catch(async (err) => { - console.error("[recognize] failed to create plan", { planId, error: err }) - const message = - err instanceof Error && err.message ? err.message : recognizeFailedMessage - toast.error(message) - removePlanFromCache(planId) + .catch((error) => { + console.error("[recognize] failed to create recognize plan", { mediaFolderPath, error }) + toast.error(recognizeFailedMessage) + setOpen(false) }) }, [ mediaFolderPath, - mediaMetadata, - createPlanMutation, + reset, + tryToRecognizeMutation, + rejectPlanMutation, + noRecognizedFilesMessage, recognizeFailedMessage, - removePlanFromCache, ]) - useEffect(() => { - if ( - plan?.status === "preparing" && - plan.files.length === 0 && - mediaMetadata && - mediaFolderPath - ) { - resumeComputation(plan.id) + const tvShowTitle = mediaMetadata?.tvShow?.name ?? "" + const tvShowTmdbId = parseInt(mediaMetadata?.tvShow?.id ?? "0", 10) + + const notAllEpisodesRecognized = useMemo(() => { + if (!plan || plan.files.length === 0 || !mediaMetadata) { + return false } - }, [plan?.id, plan?.status, plan?.files.length, mediaMetadata, mediaFolderPath, resumeComputation]) + return !isRuleBasedRecognizePlanComplete(plan.files, mediaMetadata) + }, [plan, mediaMetadata]) - useEffect(() => { - if (plan?.status === "preparing" && uiStatus === "error_loading_metadata") { - console.warn("[recognize] metadata load error while preparing, failing plan", { planId: plan.id }) - void failRecognizePlan(plan.id, recognizeFailedMessage, "metadata load error") + const allPlanFilesUnchanged = useMemo(() => { + if (!plan || plan.files.length === 0 || !mediaMetadata) { + return false } - }, [plan?.id, plan?.status, uiStatus, failRecognizePlan, recognizeFailedMessage]) + return isRuleBasedRecognizePlanFullyUnchanged(plan.files, mediaMetadata) + }, [plan, mediaMetadata]) return { plan, @@ -337,8 +166,8 @@ export function useRuleBasedRecognizeFlow({ tvShowTmdbId, notAllEpisodesRecognized, allPlanFilesUnchanged, - onConfirm, - onCancel, - startRecognizeFlow, + confirm, + cancel, + start, } } diff --git a/apps/ui/src/hooks/useTvShowPanel.ts b/apps/ui/src/hooks/useTvShowPanel.ts index 6dd85d5c..c204717e 100644 --- a/apps/ui/src/hooks/useTvShowPanel.ts +++ b/apps/ui/src/hooks/useTvShowPanel.ts @@ -1,13 +1,11 @@ import type { MetadataFiles } from "@smm/types/MetadataFiles"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { useMediaFolderFilesQuery } from "./useMediaFolderFilesQuery"; import { useMediaMetadataQuery } from "./mediaMetadata"; import { findFilesByExtensions } from "@/lib/music"; import { extensions, imageFileExtensions, subtitleFileExtensions } from "@smm/types/mediaFileExtensions"; import { basename, extname } from "@/lib/path"; import type { MediaMetadata } from "@smm/types/types"; -import { usePlansQuery } from "./plans"; -import { Path } from "@smm/utils/path"; import type { Plan } from "@/api/getPlans"; const INIT_METADATA_FILES: MetadataFiles = { @@ -187,7 +185,7 @@ export function useTvShowPanel(folderPath: string | undefined, plan: Plan | unde }) } - console.warn(`Unsupported type of plan: ${plan.task}`) + console.warn(`Unsupported type of plan`) return []; }, [plan, metadataQuery.data]) diff --git a/docs/api/index.md b/docs/api/index.md index 7dae983c..3e1f4eb7 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -110,6 +110,10 @@ Source Code: apps/cli/src/cli/runCli.ts + apps/core Core.tryToRecognizeEpisodes `smm try-to-rename [--rule plex|emby]` — build a pending rename-files plan (default rule: plex). `smm apply ` — apply a pending recognize-media-file or rename-files plan (updates metadata cache, deletes plan file). +## RecognizeEpisodes (Web UI) +Source Code: apps/cli/src/route/TryToRecognizeEpisodes.ts + apps/core Core.tryToRecognizeEpisodes +HTTP: `POST /api/try-to-recognize-episodes` — rule-based episode recognition via Layer 2 `Core.tryToRecognizeEpisodes(path)` → pending `RecognizeMediaFilePlan` persisted under `{appDataDir}/plans/`. Request body: `{ mediaFolderPath: string }`. Response: `{ data: { plan } }` or `{ error }` (HTTP 200). Apply/reject reuse `POST /api/apply-plan` / `POST /api/reject-plan`; `apply-plan` honors `data.files` for `recognize-media-file` plans (applies only the selected `plan.files[].path` entries; unknown paths → 400 ProblemDetails). Product doc: [docs/dev/recognize-episodes.md](../dev/recognize-episodes.md). + ## CLI: scrape Source Code: apps/cli/src/cli/runCli.ts + apps/core Core.scrapeFolder `smm scrape [--language ]` — scrape TMDB TV poster, fanart, episode thumbnails, and NFO files for a managed TV show folder. Prints each task as `poster|fanart|thumbnails|nfo: completed|skipped|failed`. Requires TMDB metadata and linked episodes (for thumbnails / episode NFO). diff --git a/docs/dev/recognize-episodes.md b/docs/dev/recognize-episodes.md index 678fded3..0bab2334 100644 --- a/docs/dev/recognize-episodes.md +++ b/docs/dev/recognize-episodes.md @@ -45,11 +45,14 @@ sequenceDiagram CLI->>Browser: RecognizeMediaFilePlan Browser->>User: show RuleBasedRecognizePrompt User->>Browser: click confirm button + Browser->>Browser: (optionally uncheck episodes) Browser->>CLI: POST /api/apply-plan CLI->>Core: applyPlan() Core->>Core: update MediaMetadata ``` +The Web UI may uncheck episodes before confirming; `apply-plan` then carries `data: { files }` with the selected `plan.files[].path` entries (same selection semantics as rename UC3). + ## References [Import Folder](./import-folder.md) diff --git a/docs/superpowers/plans/2026-09-05-rule-based-recognize-flow.md b/docs/superpowers/plans/2026-09-05-rule-based-recognize-flow.md new file mode 100644 index 00000000..a4d2001f --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-rule-based-recognize-flow.md @@ -0,0 +1,1264 @@ +# Rule-Based Recognize Flow Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rewrite rule-based recognize to mirror the rename flow: backend builds the pending plan via a new `POST /api/try-to-recognize-episodes`, the UI flow hook owns local `open`/`plan` state, and `apply-plan` gains selected-episodes support for `recognize-media-file`. + +**Architecture:** 3 layers — `apps/core` (selected recognize apply pipeline), `apps/cli` (new route + 400 error mapping), `apps/ui` (api + mutation + flow hook rewrite + prompt wiring + context slim-down). Design doc: `docs/superpowers/specs/2026-09-05-rule-based-recognize-flow-design.md`. + +**Tech Stack:** TypeScript, Hono (cli), TanStack Query + Vitest + Testing Library (ui), in-memory `FsPort` (core tests). + +## Global Constraints + +- **DO NOT run `git commit` at any point.** The user reviews all changes locally; every "Commit" step from the standard template is replaced by a verification step. Leave all work uncommitted. +- Reference flow implementation: `apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts` and its test `useRuleBasedRenameFilesFlow.test.tsx` (hook-level mutation mocks via `vi.hoisted`). +- Error body pattern for non-validation route errors: `{ error: "Error Reason: ..." }` with HTTP 200; selection membership errors: RFC 9457 ProblemDetails with HTTP 400 (`Content-Type: application/problem+json`). +- Selection membership comparison: `mediaFilePathEqual` from `@smm/core/pipeline/mediaFilePathEqual`, with a pre-normalization `p.replaceAll("\\", "/")` (same as `applySelectedRenameFilesPlan.ts:36`). +- `apps/ui` typecheck MUST use `pnpm exec tsc -p tsconfig.app.json --noEmit` (the root `tsconfig.json` has `"files": []` and checks nothing). +- AI flows (`useAiBasedRecognizeFlow`, `useAiBasedRenameFilesFlow`, `handleAiRecognizeConfirm`) are out of scope and must keep working: do NOT delete `applyRecognizeMediaFilePlan` or `rebuildPlanWithSelectedEpisodes` from `TvShowPanelUtils.ts`. + +--- + +### Task 1: Core — selected recognize apply pipeline + +**Files:** +- Create: `apps/core/src/pipeline/applySelectedRecognizeFilesPlan.ts` +- Modify: `apps/core/src/pipeline/applyPlan.ts` +- Test: `apps/core/src/pipeline/applySelectedRecognizeFilesPlan.test.ts` (new) + +**Interfaces:** +- Consumes: `ApplyPlanDeps` from `./applyPlan`; `deletePlan` from `./plans`; `updateMediaFileMetadatas` from `./updateMediaFileMetadatas` (signature: `(files, path, season, episode) => files`); `mediaFilePathEqual` from `./mediaFilePathEqual`. +- Produces: `class RecognizedFilesNotInPlanError extends Error { readonly files: string[] }` and `applySelectedRecognizeFilesPlanPipeline(plan: RecognizeMediaFilePlan, selectedFiles: string[], deps: ApplyPlanDeps): Promise`. Task 2 imports the error class; `applyPlanPipeline` dispatch gains the `data.files` branch. + +- [ ] **Step 1: Write the failing test** + +Create `apps/core/src/pipeline/applySelectedRecognizeFilesPlan.test.ts`. Copy the `inMemoryFs` helper from `applySelectedRenameFilesPlan.test.ts:22-60` verbatim (it implements `FsPort` over a `Map` with `raw` exposure). Then add: + +```ts +import { describe, expect, it, vi } from "vitest"; +import type { MediaMetadata } from "@smm/types"; +import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan"; +import { metadataCachePath, planFilePath } from "./paths"; +import { + applySelectedRecognizeFilesPlanPipeline, + RecognizedFilesNotInPlanError, +} from "./applySelectedRecognizeFilesPlan"; +import { applyPlanPipeline } from "./applyPlan"; + +const appDataDir = "/data"; +const folder = "/m/Show"; + +// ... inMemoryFs helper copied from applySelectedRenameFilesPlan.test.ts ... + +const plan: RecognizeMediaFilePlan = { + id: "plan-r1", + task: "recognize-media-file", + status: "pending", + creator: "app", + mediaFolderPath: folder, + files: [ + { season: 1, episode: 1, path: `${folder}/ep1.mkv` }, + { season: 1, episode: 2, path: `${folder}/ep2.mkv` }, + ], +}; + +function seedMetadata(mediaFiles: MediaMetadata["mediaFiles"]): Record { + return { + // Runtime cast: MediaMetadata has required fields the pipeline never reads. + [metadataCachePath(appDataDir, folder)]: JSON.stringify({ + mediaFolderPath: folder, + type: "tvshow-folder", + mediaFiles: mediaFiles ?? [], + } as unknown as MediaMetadata), + [planFilePath(appDataDir, plan.id)]: JSON.stringify(plan), + }; +} + +describe("applySelectedRecognizeFilesPlanPipeline", () => { + it("applies only the selected entries and deletes the plan", async () => { + const fs = inMemoryFs( + seedMetadata([{ absolutePath: `${folder}/ep1.mkv` }]), + ); + await applySelectedRecognizeFilesPlanPipeline(plan, [`${folder}/ep2.mkv`], { + fs, + appDataDir, + normalizePosix: (p) => p, + getMediaMetadata: async (f) => + JSON.parse(fs.raw.get(metadataCachePath(appDataDir, f))!) as MediaMetadata, + setMetadata: async (mm) => { + fs.raw.set(metadataCachePath(appDataDir, folder), JSON.stringify(mm)); + }, + }); + + const mm = JSON.parse(fs.raw.get(metadataCachePath(appDataDir, folder))!) as MediaMetadata; + const ep2 = mm.mediaFiles?.find((f) => f.absolutePath === `${folder}/ep2.mkv`); + expect(ep2?.seasonNumber).toBe(1); + expect(ep2?.episodeNumber).toBe(2); + expect(await fs.exists(planFilePath(appDataDir, plan.id))).toBe(false); + }); + + it("throws RecognizedFilesNotInPlanError with offenders and writes nothing", async () => { + const fs = inMemoryFs(seedMetadata([])); + await expect( + applySelectedRecognizeFilesPlanPipeline( + plan, + [`${folder}/other.mkv`], + { + fs, + appDataDir, + normalizePosix: (p) => p, + getMediaMetadata: async () => null, + setMetadata: async () => {}, + }, + ), + ).rejects.toMatchObject({ + name: "RecognizedFilesNotInPlanError", + files: [`${folder}/other.mkv`], + }); + expect(await fs.exists(planFilePath(appDataDir, plan.id))).toBe(true); + }); + + it("rejects an empty selection", async () => { + const fs = inMemoryFs(seedMetadata([])); + await expect( + applySelectedRecognizeFilesPlanPipeline(plan, [], { + fs, + appDataDir, + normalizePosix: (p) => p, + getMediaMetadata: async () => null, + setMetadata: async () => {}, + }), + ).rejects.toThrow("data.files must be a non-empty array"); + }); + + it("matches Windows-style separators in the selection", async () => { + const fs = inMemoryFs(seedMetadata([])); + await applySelectedRecognizeFilesPlanPipeline(plan, [`\\m\\Show\\ep1.mkv`], { + fs, + appDataDir, + normalizePosix: (p) => p, + getMediaMetadata: async () => + JSON.parse(fs.raw.get(metadataCachePath(appDataDir, folder))!) as MediaMetadata, + setMetadata: async (mm) => { + fs.raw.set(metadataCachePath(appDataDir, folder), JSON.stringify(mm)); + }, + }); + const mm = JSON.parse(fs.raw.get(metadataCachePath(appDataDir, folder))!) as MediaMetadata; + expect(mm.mediaFiles?.find((f) => f.absolutePath === `${folder}/ep1.mkv`)?.episodeNumber).toBe(1); + expect(await fs.exists(planFilePath(appDataDir, plan.id))).toBe(false); + }); +}); + +describe("applyPlanPipeline dispatch (recognize-media-file)", () => { + it("routes data.files to the selected pipeline", async () => { + const fs = inMemoryFs(seedMetadata([])); + await applyPlanPipeline(plan, { + fs, + appDataDir, + normalizePosix: (p) => p, + getMediaMetadata: async () => + JSON.parse(fs.raw.get(metadataCachePath(appDataDir, folder))!) as MediaMetadata, + setMetadata: async (mm) => { + fs.raw.set(metadataCachePath(appDataDir, folder), JSON.stringify(mm)); + }, + }, { files: [`${folder}/ep1.mkv`] }); + const mm = JSON.parse(fs.raw.get(metadataCachePath(appDataDir, folder))!) as MediaMetadata; + expect(mm.mediaFiles?.some((f) => f.absolutePath === `${folder}/ep1.mkv` && f.episodeNumber === 1)).toBe(true); + expect(mm.mediaFiles?.find((f) => f.absolutePath === `${folder}/ep2.mkv`)).toBeUndefined(); + }); + + it("keeps full merge when data is absent", async () => { + const fs = inMemoryFs(seedMetadata([])); + await applyPlanPipeline(plan, { + fs, + appDataDir, + normalizePosix: (p) => p, + getMediaMetadata: async () => + JSON.parse(fs.raw.get(metadataCachePath(appDataDir, folder))!) as MediaMetadata, + setMetadata: async (mm) => { + fs.raw.set(metadataCachePath(appDataDir, folder), JSON.stringify(mm)); + }, + }); + const mm = JSON.parse(fs.raw.get(metadataCachePath(appDataDir, folder))!) as MediaMetadata; + expect(mm.mediaFiles?.length).toBe(2); + }); +}); +``` + +Adapt assertions if `updateMediaFileMetadatas` merges differently than `seasonNumber`/`episodeNumber` on the media file (read `apps/core/src/pipeline/updateMediaFileMetadatas.ts:5` first and align the seed `mediaFiles` shape with `MediaMetadata["mediaFiles"]`). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/core && pnpm exec vitest run src/pipeline/applySelectedRecognizeFilesPlan.test.ts` +Expected: FAIL — `Cannot find module './applySelectedRecognizeFilesPlan'` + +- [ ] **Step 3: Write the pipeline** + +Create `apps/core/src/pipeline/applySelectedRecognizeFilesPlan.ts`: + +```ts +import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan"; +import type { ApplyPlanDeps } from "./applyPlan"; +import { mediaFilePathEqual } from "./mediaFilePathEqual"; +import { updateMediaFileMetadatas } from "./updateMediaFileMetadatas"; +import { deletePlan } from "./plans"; + +export class RecognizedFilesNotInPlanError extends Error { + readonly files: string[]; + + constructor(files: string[]) { + super(`Files not in plan: ${files.join(", ")}`); + this.name = "RecognizedFilesNotInPlanError"; + this.files = files; + } +} + +/** + * Apply only the selected files of a pending recognize-media-file plan: + * validate membership, merge the filtered entries into metadata, delete the plan. + */ +export async function applySelectedRecognizeFilesPlanPipeline( + plan: RecognizeMediaFilePlan, + selectedFiles: string[], + deps: ApplyPlanDeps, +): Promise { + if (plan.task !== "recognize-media-file") { + throw new Error(`Unsupported plan task: ${plan.task}`); + } + if (selectedFiles.length === 0) { + throw new Error("data.files must be a non-empty array"); + } + + // Normalize Windows separators first: mediaFilePathEqual's Path.posix + // fallback can't parse paths like "\m\Show\ep1.mkv" (no drive letter). + const toPosix = (p: string) => p.replaceAll("\\", "/"); + + const offenders = selectedFiles.filter( + (file) => !plan.files.some((entry) => mediaFilePathEqual(entry.path, toPosix(file))), + ); + if (offenders.length > 0) { + throw new RecognizedFilesNotInPlanError(offenders); + } + + const filtered = plan.files.filter((entry) => + selectedFiles.some((file) => mediaFilePathEqual(entry.path, toPosix(file))), + ); + + const folder = deps.normalizePosix(plan.mediaFolderPath); + const mm = await deps.getMediaMetadata(folder); + if (!mm) { + throw new Error(`Media metadata not found: ${plan.mediaFolderPath}`); + } + + let mediaFiles = mm.mediaFiles ?? []; + for (const file of filtered) { + mediaFiles = updateMediaFileMetadatas(mediaFiles, file.path, file.season, file.episode); + } + + await deps.setMetadata({ ...mm, mediaFiles }); + await deletePlan(deps.fs, deps.appDataDir, plan.id); +} +``` + +- [ ] **Step 4: Add the dispatch branch** + +In `apps/core/src/pipeline/applyPlan.ts`, add the import and change the recognize branch (lines 27-29) to: + +```ts +import { applySelectedRecognizeFilesPlanPipeline } from "./applySelectedRecognizeFilesPlan"; +``` + +```ts + if (task === "recognize-media-file") { + if (Array.isArray(data?.files)) { + return applySelectedRecognizeFilesPlanPipeline(plan, data.files, deps); + } + return applyRecognizeMediaFilePlanPipeline(plan, deps); + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd apps/core && pnpm exec vitest run src/pipeline/applySelectedRecognizeFilesPlan.test.ts src/pipeline/applyPlan.test.ts src/pipeline/applySelectedRenameFilesPlan.test.ts` +Expected: PASS (including existing rename tests — rename dispatch unchanged) + +- [ ] **Step 6: Verify no regressions (no commit)** + +Run: `cd apps/core && pnpm test 2>&1 | tail -5 && pnpm typecheck` +Expected: all tests PASS, typecheck clean. **Do NOT commit** — leave changes local for user review. + +--- + +### Task 2: CLI — try-to-recognize-episodes route + apply-plan 400 mapping + +**Files:** +- Create: `apps/cli/src/route/TryToRecognizeEpisodes.ts` +- Modify: `apps/cli/src/route/RenameEpisodesPlan.ts:242-251` (apply-plan catch block) +- Modify: `apps/cli/server.ts:47,301` (import + mount) +- Test: `apps/cli/src/route/TryToRecognizeEpisodes.test.ts` (new), `apps/cli/src/route/RenameEpisodesPlan.test.ts` (extend) + +**Interfaces:** +- Consumes: `getCore()` from `../core/getCore` (`tryToRecognizeEpisodes(path): Promise`); `RecognizedFilesNotInPlanError` from Task 1. +- Produces: `handleTryToRecognizeEpisodes(app: Hono): void` serving `POST /api/try-to-recognize-episodes` with body `{ mediaFolderPath: string }` → `{ data: { plan } }` or `{ error }` (HTTP 200). + +- [ ] **Step 1: Write the failing route test** + +Create `apps/cli/src/route/TryToRecognizeEpisodes.test.ts`, mirroring `RenameEpisodesPlan.test.ts:1-46` mock setup: + +```ts +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Hono } from 'hono' + +const mocks = vi.hoisted(() => ({ + tryToRecognizeEpisodes: vi.fn(), +})) + +vi.mock('../core/getCore', () => ({ + getCore: () => mocks, +})) + +import { handleTryToRecognizeEpisodes } from './TryToRecognizeEpisodes' + +const plan = { + id: 'plan-r1', + task: 'recognize-media-file' as const, + status: 'pending' as const, + creator: 'app' as const, + mediaFolderPath: '/media/Show', + files: [{ season: 1, episode: 1, path: '/media/Show/S01E01.mkv' }], +} + +describe('POST /api/try-to-recognize-episodes', () => { + let app: Hono + + beforeEach(() => { + mocks.tryToRecognizeEpisodes.mockReset() + app = new Hono() + handleTryToRecognizeEpisodes(app) + }) + + async function post(body: unknown) { + return app.request('/api/try-to-recognize-episodes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + it('returns the pending plan', async () => { + mocks.tryToRecognizeEpisodes.mockResolvedValue(plan) + + const response = await post({ mediaFolderPath: '/media/Show' }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { plan } }) + expect(mocks.tryToRecognizeEpisodes).toHaveBeenCalledWith('/media/Show') + }) + + it('requires mediaFolderPath', async () => { + const response = await post({}) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + error: 'Error Reason: mediaFolderPath is required', + }) + expect(mocks.tryToRecognizeEpisodes).not.toHaveBeenCalled() + }) + + it('maps pipeline errors to Error Reason', async () => { + mocks.tryToRecognizeEpisodes.mockRejectedValue(new Error('Media metadata not found: /media/Show')) + + const response = await post({ mediaFolderPath: '/media/Show' }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + error: 'Error Reason: Media metadata not found: /media/Show', + }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/cli && pnpm exec vitest run src/route/TryToRecognizeEpisodes.test.ts` +Expected: FAIL — `Cannot find module './TryToRecognizeEpisodes'` + +- [ ] **Step 3: Implement the route** + +Create `apps/cli/src/route/TryToRecognizeEpisodes.ts`: + +```ts +import type { Hono } from 'hono' +import type { RecognizeMediaFilePlan } from '@smm/types/RecognizeMediaFilePlan' +import { getCore } from '../core/getCore' +import { logger } from '../../lib/logger' + +export interface TryToRecognizeEpisodesRequestBody { + mediaFolderPath: string +} + +export interface TryToRecognizeEpisodesResponseBody { + data?: { plan: RecognizeMediaFilePlan } + error?: string +} + +function readStringField(body: unknown, key: string): string | undefined { + if (typeof body !== 'object' || body === null || !(key in body)) return undefined + const value = (body as Record)[key] + return typeof value === 'string' ? value : undefined +} + +/** + * Recognize-episodes plan HTTP surface matching docs/dev/recognize-episodes.md: + * - POST /api/try-to-recognize-episodes → Core.tryToRecognizeEpisodes + * (apply/reject reuse POST /api/apply-plan and /api/reject-plan in RenameEpisodesPlan.ts) + */ +export function handleTryToRecognizeEpisodes(app: Hono): void { + app.post('/api/try-to-recognize-episodes', async (c) => { + try { + let body: unknown = {} + try { + body = await c.req.json() + } catch { + /* empty */ + } + + const mediaFolderPath = readStringField(body, 'mediaFolderPath') + if (!mediaFolderPath?.trim()) { + const err: TryToRecognizeEpisodesResponseBody = { + error: 'Error Reason: mediaFolderPath is required', + } + return c.json(err, 200) + } + + const plan = await getCore().tryToRecognizeEpisodes(mediaFolderPath) + const ok: TryToRecognizeEpisodesResponseBody = { data: { plan } } + return c.json(ok, 200) + } catch (error) { + logger.error({ error }, '[POST /api/try-to-recognize-episodes] route error') + const err: TryToRecognizeEpisodesResponseBody = { + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(err, 200) + } + }) +} +``` + +- [ ] **Step 4: Map the new error to 400 ProblemDetails in apply-plan** + +In `apps/cli/src/route/RenameEpisodesPlan.ts`, add the import next to line 10: + +```ts +import { RecognizedFilesNotInPlanError } from '@smm/core/pipeline/applySelectedRecognizeFilesPlan' +``` + +Change the apply-plan catch block (lines 242-245) from: + +```ts + if (error instanceof SelectedFilesNotInPlanError) { + const problem = problemDetails(error.message) + return c.json(problem, 400, { 'Content-Type': 'application/problem+json' }) + } +``` + +to: + +```ts + if ( + error instanceof SelectedFilesNotInPlanError || + error instanceof RecognizedFilesNotInPlanError + ) { + const problem = problemDetails(error.message) + return c.json(problem, 400, { 'Content-Type': 'application/problem+json' }) + } +``` + +- [ ] **Step 5: Extend the apply-plan route test** + +In `apps/cli/src/route/RenameEpisodesPlan.test.ts`, extend the `vi.mock('@smm/core/pipeline/applySelectedRenameFilesPlan')` style: the error classes are real imports (not mocked — see line 3), so simply add a test inside the apply-plan `describe` block: + +```ts + it('maps RecognizedFilesNotInPlanError to 400 problem+json', async () => { + const { RecognizedFilesNotInPlanError } = await import('@smm/core/pipeline/applySelectedRecognizeFilesPlan') + mocks.applyPlan.mockRejectedValue( + new RecognizedFilesNotInPlanError(['/media/Show/ghost.mkv']), + ) + mocks.getPlan.mockResolvedValue({ + ...plan, + task: 'recognize-media-file' as const, + files: [{ season: 1, episode: 1, path: '/media/Show/S01E01.mkv' }], + }) + + const response = await post({ id: 'plan-1', data: { files: ['/media/Show/ghost.mkv'] } }) + expect(response.status).toBe(400) + expect(response.headers.get('Content-Type')).toContain('application/problem+json') + const body = await response.json() + expect(body.detail).toBe('Files not in plan: /media/Show/ghost.mkv') + }) +``` + +Adjust `post`/`plan` names to the actual helpers used in the apply-plan `describe` block of that file (read the file section around the existing `SelectedFilesNotInPlanError` test and mirror it exactly). + +- [ ] **Step 6: Mount the route in server.ts** + +In `apps/cli/server.ts`, add after line 47: + +```ts +import { handleTryToRecognizeEpisodes } from './src/route/TryToRecognizeEpisodes'; +``` + +and after line 301 (`handleRenameEpisodesPlan(this.app);`): + +```ts + handleTryToRecognizeEpisodes(this.app); +``` + +- [ ] **Step 7: Run tests to verify they pass (no commit)** + +Run: `cd apps/cli && pnpm exec vitest run src/route/TryToRecognizeEpisodes.test.ts src/route/RenameEpisodesPlan.test.ts && pnpm typecheck` +Expected: PASS, typecheck clean. **Do NOT commit.** + +--- + +### Task 3: UI — api client + mutation hook + +**Files:** +- Create: `apps/ui/src/api/tryToRecognizeEpisodes.ts` +- Create: `apps/ui/src/hooks/plans/useTryToRecognizeEpisodesMutation.ts` + +**Interfaces:** +- Consumes: `apiFetch` from `@/lib/apiFetch`; `RecognizeMediaFilePlan` from `@smm/types/RecognizeMediaFilePlan`; `plansQueryKey` / `normalizeMediaFolderPathForQuery`. +- Produces: `tryToRecognizeEpisodes(request: TryToRecognizeEpisodesRequest, signal?): Promise` and `useTryToRecognizeEpisodesMutation(): useMutation`. Task 4 imports the hook. + +- [ ] **Step 1: Create the api client** + +Create `apps/ui/src/api/tryToRecognizeEpisodes.ts`, mirroring `apps/ui/src/api/tryToRenameEpisodes.ts`: + +```ts +import type { RecognizeMediaFilePlan } from '@smm/types/RecognizeMediaFilePlan' +import { apiFetch } from '@/lib/apiFetch' + +export interface TryToRecognizeEpisodesRequest { + mediaFolderPath: string +} + +export interface TryToRecognizeEpisodesResponseBody { + data?: { plan: RecognizeMediaFilePlan } + error?: string +} + +/** POST /api/try-to-recognize-episodes — build a pending recognize-media-file plan. */ +export async function tryToRecognizeEpisodes( + request: TryToRecognizeEpisodesRequest, + signal?: AbortSignal, +): Promise { + const resp = await apiFetch('/api/try-to-recognize-episodes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal, + }) + + if (!resp.ok) { + throw new Error(`Failed to try-to-recognize-episodes: ${resp.statusText}`) + } + + return (await resp.json()) as TryToRecognizeEpisodesResponseBody +} +``` + +- [ ] **Step 2: Create the mutation hook** + +Create `apps/ui/src/hooks/plans/useTryToRecognizeEpisodesMutation.ts`, mirroring `useTryToRenameEpisodesMutation.ts`: + +```ts +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { tryToRecognizeEpisodes } from "@/api/tryToRecognizeEpisodes" +import type { Plan } from "@/api/getPlans" +import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" +import { plansQueryKey } from "./plansQueryKeys" + +export interface TryToRecognizeEpisodesVariables { + mediaFolderPath: string +} + +/** + * POST /api/try-to-recognize-episodes — build a pending recognize-media-file + * plan and add it to the plans cache. + */ +export function useTryToRecognizeEpisodesMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ mediaFolderPath }): Promise => { + const resp = await tryToRecognizeEpisodes({ mediaFolderPath }) + if (resp.error || !resp.data?.plan) { + throw new Error(resp.error ?? "Failed to create recognize plan") + } + return resp.data.plan as RecognizeMediaFilePlan + }, + onSuccess: (plan, { mediaFolderPath }) => { + const key = plansQueryKey(normalizeMediaFolderPathForQuery(mediaFolderPath)) + queryClient.setQueryData(key, (prev) => { + const rest = (prev ?? []).filter((p) => p.id !== plan.id) + return [...rest, plan] + }) + }, + }) +} +``` + +Add `import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan"` at the top. + +- [ ] **Step 3: Verify (no commit)** + +Run: `cd apps/ui && pnpm exec tsc -p tsconfig.app.json --noEmit` +Expected: clean (pre-existing TvShowPanel errors unrelated to these two new files are acceptable at this point; the new files themselves must produce zero errors). **Do NOT commit.** + +--- + +### Task 4: UI — rewrite useRuleBasedRecognizeFlow (TDD) + +**Files:** +- Modify: `apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.ts` (full rewrite, 344 → ~150 lines) +- Test: `apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.test.tsx` (rewrite) + +**Interfaces:** +- Consumes: `useTryToRecognizeEpisodesMutation` (Task 3), `useApplyPlanMutation` / `useRejectPlanMutation` (`ApplyPlanVariables { id, mediaFolderPath, files? }`, `RejectPlanVariables { id, mediaFolderPath }`), `isRuleBasedRecognizePlanComplete` / `isRuleBasedRecognizePlanFullyUnchanged` from `@/lib/isRuleBasedRecognizePlanComplete` (signature: `(files: RecognizedFile[], mediaMetadata: MediaMetadata) => boolean`). +- Produces: `useRuleBasedRecognizeFlow({ mediaMetadata })` returning `{ plan: RecognizeMediaFilePlan | undefined, open: boolean, loading: boolean, tvShowTitle: string, tvShowTmdbId: number, notAllEpisodesRecognized: boolean, allPlanFilesUnchanged: boolean, confirm(selectedEpisodeFiles?: string[]): Promise, cancel(): Promise, start(): void }`. Task 5 consumes all of these. + +- [ ] **Step 1: Write the failing tests** + +Rewrite `apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.test.tsx`, mirroring `useRuleBasedRenameFilesFlow.test.tsx` exactly (same hoisted-mock setup, QueryClient wrapper, `mediaMetadata` fixture builder): + +```tsx +import { describe, expect, it, vi, beforeEach } from "vitest" +import { renderHook, waitFor, act } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import type { ReactNode } from "react" +import { useRuleBasedRecognizeFlow } from "./useRuleBasedRecognizeFlow" +import type { MediaMetadata } from "@smm/types" +import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan" + +const { + toastErrorMock, + toastSuccessMock, + tryToRecognizeMutationMock, + rejectPlanMutationMock, + applyPlanMutationMock, +} = vi.hoisted(() => { + const makeMutation = () => ({ + mutateAsync: vi.fn(), + mutate: vi.fn(), + reset: vi.fn(), + isPending: false, + }) + return { + toastErrorMock: vi.fn(), + toastSuccessMock: vi.fn(), + tryToRecognizeMutationMock: makeMutation(), + rejectPlanMutationMock: makeMutation(), + applyPlanMutationMock: makeMutation(), + } +}) + +vi.mock("sonner", () => ({ + toast: { error: toastErrorMock, success: toastSuccessMock }, +})) + +vi.mock("@/hooks/plans/useTryToRecognizeEpisodesMutation", () => ({ + useTryToRecognizeEpisodesMutation: () => tryToRecognizeMutationMock, +})) + +vi.mock("@/hooks/plans/useRejectPlanMutation", () => ({ + useRejectPlanMutation: () => rejectPlanMutationMock, +})) + +vi.mock("@/hooks/plans/useApplyPlanMutation", () => ({ + useApplyPlanMutation: () => applyPlanMutationMock, +})) + +vi.mock("@/lib/i18n", () => ({ + useTranslation: () => ({ + t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key, + }), +})) + +describe("useRuleBasedRecognizeFlow", () => { + const mediaFolderPath = "/storage/Users/currentUser/Download/Anime/show" + const pendingPlan: RecognizeMediaFilePlan = { + id: "plan-1", + task: "recognize-media-file", + status: "pending", + creator: "app", + mediaFolderPath, + files: [ + { season: 1, episode: 1, path: `${mediaFolderPath}/S01E01.mkv` }, + { season: 1, episode: 2, path: `${mediaFolderPath}/S01E02.mkv` }, + ], + } + + const mediaMetadata = { + mediaFolderPath, + type: "tvshow-folder", + tvShow: { + id: "123", + name: "Test Show", + seasons: [ + { + season: 1, + name: "Season 1", + episodes: [ + { episode: 1, name: "E1" }, + { episode: 2, name: "E2" }, + ], + }, + ], + }, + mediaFiles: [{ absolutePath: `${mediaFolderPath}/S01E01.mkv`, seasonNumber: 1, episodeNumber: 1 }], + } as unknown as MediaMetadata + + let queryClient: QueryClient + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + + const renderFlow = () => + renderHook(() => useRuleBasedRecognizeFlow({ mediaMetadata }), { wrapper }) + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + vi.clearAllMocks() + tryToRecognizeMutationMock.mutateAsync.mockResolvedValue(pendingPlan) + rejectPlanMutationMock.mutateAsync.mockResolvedValue(null) + applyPlanMutationMock.mutateAsync.mockResolvedValue(null) + tryToRecognizeMutationMock.isPending = false + }) + + it("Start to recognize: open=true, mutation called, loading=true while pending", async () => { + let resolveTryToRecognize: (plan: RecognizeMediaFilePlan) => void = () => {} + tryToRecognizeMutationMock.mutateAsync.mockImplementation( + () => new Promise((resolve) => { resolveTryToRecognize = resolve }), + ) + tryToRecognizeMutationMock.isPending = true + + const { result } = renderFlow() + + act(() => { + result.current.start() + }) + + expect(result.current.open).toBe(true) + expect(tryToRecognizeMutationMock.mutateAsync).toHaveBeenCalledWith({ mediaFolderPath }) + expect(result.current.loading).toBe(true) + + await act(async () => { + tryToRecognizeMutationMock.isPending = false + resolveTryToRecognize(pendingPlan) + }) + + expect(result.current.loading).toBe(false) + expect(result.current.plan).toEqual(pendingPlan) + }) + + it("Start to recognize and then cancel", async () => { + const { result } = renderFlow() + + await act(async () => { + await result.current.start() + }) + + await act(async () => { + await result.current.cancel() + }) + + expect(result.current.open).toBe(false) + expect(rejectPlanMutationMock.mutateAsync).toHaveBeenCalledWith({ + id: "plan-1", + mediaFolderPath, + }) + expect(tryToRecognizeMutationMock.reset).toHaveBeenCalled() + expect(rejectPlanMutationMock.reset).toHaveBeenCalled() + expect(applyPlanMutationMock.reset).toHaveBeenCalled() + expect(result.current.plan).toBeUndefined() + }) + + it("Start to recognize and then confirm without selection", async () => { + const { result } = renderFlow() + + await act(async () => { + await result.current.start() + }) + + await act(async () => { + await result.current.confirm() + }) + + expect(applyPlanMutationMock.mutateAsync).toHaveBeenCalledWith({ + id: "plan-1", + mediaFolderPath, + files: undefined, + }) + expect(result.current.open).toBe(false) + expect(result.current.plan).toBeUndefined() + expect(toastErrorMock).not.toHaveBeenCalled() + }) + + it("Confirm with selected files passes them to apply-plan", async () => { + const { result } = renderFlow() + + await act(async () => { + await result.current.start() + }) + + await act(async () => { + await result.current.confirm([`${mediaFolderPath}/S01E01.mkv`]) + }) + + expect(applyPlanMutationMock.mutateAsync).toHaveBeenCalledWith({ + id: "plan-1", + mediaFolderPath, + files: [`${mediaFolderPath}/S01E01.mkv`], + }) + expect(result.current.open).toBe(false) + }) + + it("Confirm failure keeps the prompt open and toasts", async () => { + applyPlanMutationMock.mutateAsync.mockRejectedValue(new Error("boom")) + + const { result } = renderFlow() + + await act(async () => { + await result.current.start() + }) + + await act(async () => { + await result.current.confirm() + }) + + expect(toastErrorMock).toHaveBeenCalledWith("Recognition failed. Please try again.") + expect(result.current.open).toBe(true) + expect(result.current.plan).toEqual(pendingPlan) + }) + + it("Empty recognition result: prompt closed, no-recognized-files toast, plan rejected", async () => { + tryToRecognizeMutationMock.mutateAsync.mockResolvedValue({ + ...pendingPlan, + files: [], + }) + + const { result } = renderFlow() + + act(() => { + result.current.start() + }) + + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith( + "Unable to recognize any episodes. Consider using AI to recognize instead.", + ) + }) + expect(rejectPlanMutationMock.mutateAsync).toHaveBeenCalledWith({ + id: "plan-1", + mediaFolderPath, + }) + expect(result.current.open).toBe(false) + expect(result.current.plan).toBeUndefined() + }) + + it("Start failure: toast and prompt closed", async () => { + tryToRecognizeMutationMock.mutateAsync.mockRejectedValue(new Error("boom")) + + const { result } = renderFlow() + + act(() => { + result.current.start() + }) + + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith("Recognition failed. Please try again.") + }) + expect(result.current.open).toBe(false) + expect(result.current.plan).toBeUndefined() + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/ui && pnpm exec vitest run src/hooks/tv/useRuleBasedRecognizeFlow.test.tsx` +Expected: FAIL — the old hook requires `plans`/`uiStatus`/`beforeConfirm` options and has no `start`/`confirm`/`cancel` shape matching the tests. + +- [ ] **Step 3: Rewrite the hook** + +Replace `apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.ts` entirely: + +```ts +import { useCallback, useMemo, useState } from "react" +import { toast } from "sonner" +import { useApplyPlanMutation } from "@/hooks/plans/useApplyPlanMutation" +import { useRejectPlanMutation } from "@/hooks/plans/useRejectPlanMutation" +import { useTryToRecognizeEpisodesMutation } from "@/hooks/plans/useTryToRecognizeEpisodesMutation" +import { + isRuleBasedRecognizePlanComplete, + isRuleBasedRecognizePlanFullyUnchanged, +} from "@/lib/isRuleBasedRecognizePlanComplete" +import { useTranslation } from "@/lib/i18n" +import type { MediaMetadata } from "@smm/types" +import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan" + +export interface UseRuleBasedRecognizeFlowOptions { + mediaMetadata: MediaMetadata | undefined +} + +/** + * Rule-based recognize flow aligned with docs/dev/recognize-episodes.md: + * try-to-recognize-episodes → apply-plan (data.files for selected episodes) / reject-plan. + */ +export function useRuleBasedRecognizeFlow({ + mediaMetadata, +}: UseRuleBasedRecognizeFlowOptions) { + const { t } = useTranslation(["components"]) + + const [open, setOpen] = useState(false) + const [plan, setPlan] = useState(undefined) + + const mediaFolderPath = mediaMetadata?.mediaFolderPath + const rejectPlanMutation = useRejectPlanMutation() + const applyPlanMutation = useApplyPlanMutation() + const tryToRecognizeMutation = useTryToRecognizeEpisodesMutation() + + const loading = + rejectPlanMutation.isPending || + applyPlanMutation.isPending || + tryToRecognizeMutation.isPending + + const recognizeFailedMessage = t("toast.recognizeFailed", { + defaultValue: "Recognition failed. Please try again.", + }) + const noRecognizedFilesMessage = t("toast.noRecognizedFiles", { + defaultValue: + "Unable to recognize any episodes. Consider using AI to recognize instead.", + }) + + const reset = useCallback(() => { + rejectPlanMutation.reset() + applyPlanMutation.reset() + tryToRecognizeMutation.reset() + }, [rejectPlanMutation, applyPlanMutation, tryToRecognizeMutation]) + + const confirm = useCallback( + async (selectedEpisodeFiles?: string[]) => { + if (plan === undefined) { + console.error("Plan was confirmed but the plan is undefined") + return + } + + if (!mediaMetadata || !mediaFolderPath) { + console.warn("[recognize] user confirmed but media metadata missing", { plan }) + toast.error("No media metadata available") + return + } + + try { + console.log("[recognize] POST /api/apply-plan", { + id: plan.id, + selectedCount: selectedEpisodeFiles?.length, + }) + await applyPlanMutation.mutateAsync({ + id: plan.id, + mediaFolderPath, + files: selectedEpisodeFiles, + }) + + setOpen(false) + setPlan(undefined) + toast.success(t("toolbar.recognizeEpisodesSuccess")) + console.log("[recognize] recognize completed successfully", { id: plan.id }) + } catch (error) { + console.error("[recognize] unexpected error while applying recognize", { id: plan.id, error }) + toast.error(recognizeFailedMessage) + } + }, + [mediaFolderPath, plan, mediaMetadata, applyPlanMutation, recognizeFailedMessage, t], + ) + + const cancel = useCallback(async () => { + if (mediaFolderPath === undefined) { + console.error("Media folder path is undefined") + return + } + + setOpen(false) + + if (plan && plan.status === "pending") { + rejectPlanMutation.mutateAsync({ id: plan.id, mediaFolderPath }) // fire and forget + } + + setPlan(undefined) + reset() + }, [mediaFolderPath, rejectPlanMutation, plan, reset]) + + /** Opens RuleBasedRecognizePrompt by calling try-to-recognize-episodes. */ + const start = useCallback(() => { + if (!mediaFolderPath) { + console.warn("[recognize] cannot start — media folder path missing") + toast.error("No media folder path available") + return + } + reset() + setOpen(true) + void tryToRecognizeMutation.mutateAsync({ mediaFolderPath }) + .then((resp) => { + if (!resp.files || resp.files.length === 0) { + console.log("[recognize] no files recognized", { mediaFolderPath }) + toast.error(noRecognizedFilesMessage) + rejectPlanMutation.mutateAsync({ id: resp.id, mediaFolderPath }) // fire and forget + setOpen(false) + return + } + console.log("[recognize] recognize preview ready", { + id: resp.id, + matchedCount: resp.files.length, + }) + setPlan(resp) + }) + .catch((error) => { + console.error("[recognize] failed to create recognize plan", { mediaFolderPath, error }) + toast.error(recognizeFailedMessage) + setOpen(false) + }) + }, [ + mediaFolderPath, + reset, + tryToRecognizeMutation, + rejectPlanMutation, + noRecognizedFilesMessage, + recognizeFailedMessage, + ]) + + const tvShowTitle = mediaMetadata?.tvShow?.name ?? "" + const tvShowTmdbId = parseInt(mediaMetadata?.tvShow?.id ?? "0", 10) + + const notAllEpisodesRecognized = useMemo(() => { + if (!plan || plan.files.length === 0 || !mediaMetadata) { + return false + } + return !isRuleBasedRecognizePlanComplete(plan.files, mediaMetadata) + }, [plan, mediaMetadata]) + + const allPlanFilesUnchanged = useMemo(() => { + if (!plan || plan.files.length === 0 || !mediaMetadata) { + return false + } + return isRuleBasedRecognizePlanFullyUnchanged(plan.files, mediaMetadata) + }, [plan, mediaMetadata]) + + return { + plan, + open, + loading, + tvShowTitle, + tvShowTmdbId, + notAllEpisodesRecognized, + allPlanFilesUnchanged, + confirm, + cancel, + start, + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/ui && pnpm exec vitest run src/hooks/tv/useRuleBasedRecognizeFlow.test.tsx` +Expected: 7 PASS + +- [ ] **Step 5: Verify (no commit)** + +Run: `cd apps/ui && pnpm exec vitest run src/hooks/tv/useRuleBasedRecognizeFlow.test.tsx src/hooks/tv/useRuleBasedRenameFilesFlow.test.tsx` +Expected: all PASS. **Do NOT commit.** (TvShowPanel will still fail typecheck until Task 5 — expected.) + +--- + +### Task 5: UI — TvShowPanel wiring, context slim-down, dead code + +**Files:** +- Modify: `apps/ui/src/components/tv/TvShowPanel.tsx` +- Modify: `apps/ui/src/components/tv/TvShowPanelPrompts.tsx` +- Modify: `apps/ui/src/components/tv/plans/TvShowAppPlanPromptContext.tsx` +- Modify: `apps/ui/src/components/tv/TvShowPanelUtils.ts` (remove `buildTemporaryRecognitionPlanAsync` only) + +**Interfaces:** +- Consumes: everything `useRuleBasedRecognizeFlow` returns (Task 4); `RuleBasedRecognizePrompt` props (`isOpen`, `isLoading`, `tvShowTitle`, `tvShowTmdbId`, `notAllEpisodesRecognized`, `allPlanFilesUnchanged`, `isConfirmButtonDisabled`, `onConfirm`, `onCancel`). +- Produces: `TvShowAppPlanPromptContextValue` with ONLY: `aiRenamePlan`, `aiRenamePromptStatus`, `aiRecognizePlan`, `aiRecognizePromptStatus`, `onAiRenameConfirm`, `onAiRenameCancel`, `onAiRecognizeConfirm`, `onAiRecognizeCancel`. The `RenameToolbarOption` export stays in the context file (still imported by `useRuleBasedRenameFilesFlow.ts:8`). + +- [ ] **Step 1: Slim the context interface** + +Replace the interface in `TvShowAppPlanPromptContext.tsx` with: + +```tsx +export interface TvShowAppPlanPromptContextValue { + aiRenamePlan: UIRenameFilesPlan | undefined + aiRenamePromptStatus: "generating" | "wait-for-ack" + aiRecognizePlan: UIRecognizeMediaFilePlan | undefined + aiRecognizePromptStatus: "generating" | "wait-for-ack" + + onAiRenameConfirm: () => void | Promise + onAiRenameCancel: () => void | Promise + onAiRecognizeConfirm: () => void | Promise + onAiRecognizeCancel: () => void | Promise +} +``` + +Keep `RenameToolbarOption` and the provider/hook unchanged. Remove the now-unused `UIRecognizeMediaFilePlan` import ONLY if `aiRecognizePlan` no longer references it (it does reference it — keep the import). + +- [ ] **Step 2: Clean up TvShowPanelPrompts.tsx** + +Delete: +- the commented-out `RuleBasedRenameFilePrompt` block (lines 82-103) and its import (line 2) +- the `RuleBasedRecognizePrompt` render block (lines 127-145) and its import (line 5) +- from the `useTvShowAppPlanPrompts()` destructure: `appRenamePlan`, `appRecognizePlan`, `renameToolbarOptions`, `selectedNamingRule`, `setSelectedNamingRule`, `onAppRenameNamingRuleSelected`, `onAppRenameConfirm`, `onAppRenameCancel`, `onAppRecognizeConfirm`, `onAppRecognizeCancel`, `tvShowTitle`, `tvShowTmdbId`, `isRuleBasedRecognizeLoading`, `notAllEpisodesRecognized`, `allPlanFilesUnchanged`, `allRenamePlanFilesUnchanged` +- the now-unused `UIRecognizeMediaFilePlan` import (line 8) + +Keep: `UseNfoPrompt` block, `AiBasedRenameFilePrompt`, `AiBasedRecognizePrompt`, `useTvShowPromptsStore` usage. + +- [ ] **Step 3: Wire the prompt into TvShowPanel.tsx** + +Make these changes: + +1. Fix the rename flow call (lines 248-254) to match the current hook signature: + +```tsx + const renameFlow = useRuleBasedRenameFilesFlow({ + mediaMetadata, + }) +``` + +2. Fix the recognize flow call (lines 264-269): + +```tsx + const recognizeFlow = useRuleBasedRecognizeFlow({ + mediaMetadata, + }) +``` + +3. Add the import (next to line 52's `RuleBasedRenameFilePrompt` import): + +```tsx +import { RuleBasedRecognizePrompt } from "./RuleBasedRecognizePrompt" +``` + +4. Add a props memo after `ruleBasedRenameFilePromptProps` (lines 360-384), same checked-episodes → paths mapping: + +```tsx + const ruleBasedRecognizePromptProps = useMemo(() => { + return { + isOpen: recognizeFlow.open, + isLoading: recognizeFlow.loading, + tvShowTitle: recognizeFlow.tvShowTitle, + tvShowTmdbId: recognizeFlow.tvShowTmdbId, + notAllEpisodesRecognized: recognizeFlow.notAllEpisodesRecognized, + allPlanFilesUnchanged: recognizeFlow.allPlanFilesUnchanged, + isConfirmButtonDisabled: recognizeFlow.loading || recognizeFlow.allPlanFilesUnchanged, + onConfirm: async () => { + const episodes = mediaFileTableSeasonData.flatMap((s) => s.episodes) + const selectedFiles = episodes + .filter((e) => + selectedEpisodes.some((s) => s.season === e.season && s.episode === e.episode), + ) + .flatMap((e) => e.path) + .filter((path): path is string => path !== undefined) + await recognizeFlow.confirm(selectedFiles) + }, + onCancel: () => { + void recognizeFlow.cancel() + }, + } + }, [recognizeFlow, mediaFileTableSeasonData, selectedEpisodes]) +``` + +5. Render it next to the rename prompt (after line 392's `{)`: + +```tsx + { + + } +``` + +6. Slim `appPlanPromptValue` (lines 317-344) to: + +```tsx + const appPlanPromptValue = useMemo((): TvShowAppPlanPromptContextValue => { + return { + aiRenamePlan: aiRenameFlow.plan, + aiRenamePromptStatus: aiRenameFlow.promptStatus, + aiRecognizePlan: aiRecognizeFlow.plan, + aiRecognizePromptStatus: aiRecognizeFlow.promptStatus, + onAiRenameConfirm: aiRenameFlow.onConfirm, + onAiRenameCancel: aiRenameFlow.onCancel, + onAiRecognizeConfirm: aiRecognizeFlow.onConfirm, + onAiRecognizeCancel: aiRecognizeFlow.onCancel, + } + }, [renameFlow, aiRenameFlow, aiRecognizeFlow, recognizeFlow]) +``` + +(`renameFlow`/`recognizeFlow` can be dropped from the dep array since no field references them anymore — remove them.) + +7. Remove the fix-ups that existed only for the old flows: `renameFlow.cancel(plan?.id ?? '')` inside `ruleBasedRenameFilePromptProps.onCancel` becomes `() => { void renameFlow.cancel() }`; delete the unused imports flagged by typecheck (expected: `MediaFileTableEpisodeData` line 30, `RenameRuleName` line 53, `import type { string } from "zod"` line 54). Keep `recognizeBeforeConfirm` (line 161-165) — `aiRecognizeFlow` still uses it. + +- [ ] **Step 4: Remove dead code from TvShowPanelUtils.ts** + +Verify first, then delete: + +Run: `cd apps/ui && grep -rn "buildTemporaryRecognitionPlanAsync" src/ --include="*.ts*" | grep -v TvShowPanelUtils.ts` +Expected: only the removed `useRuleBasedRecognizeFlow` reference (gone after Task 4). If any other reference remains, STOP and keep the function. + +Delete `buildTemporaryRecognitionPlanAsync` from `TvShowPanelUtils.ts` (lines ~950-984) and the `recognizeEpisodesAsync` import (line 28) if it becomes unused. Do NOT delete `applyRecognizeMediaFilePlan` or `rebuildPlanWithSelectedEpisodes` (`handleAiRecognizeConfirm.ts` and `aiRecognizeFlow` use them). + +- [ ] **Step 5: Verify (no commit)** + +Run: `cd apps/ui && pnpm exec tsc -p tsconfig.app.json --noEmit && pnpm exec vitest run src/hooks/tv/ src/components/tv/` +Expected: typecheck clean (MoviePanel pre-existing errors from the rename refactor may remain — list them in the final report if so); all tv hook/component tests PASS. **Do NOT commit.** + +--- + +### Task 6: Docs + full verification + +**Files:** +- Modify: `docs/api/index.md` +- Modify: `docs/dev/recognize-episodes.md` (only if its Web UI section needs the selected-files note) + +**Interfaces:** +- Consumes: everything from Tasks 1-5. +- Produces: documentation matching the implemented HTTP surface. + +- [ ] **Step 1: Update docs/api/index.md** + +Add a new section after `## CLI: try-to-recognize / try-to-rename / apply` (around line 112): + +```markdown +## RecognizeEpisodes (Web UI) +Source Code: apps/cli/src/route/TryToRecognizeEpisodes.ts + apps/core Core.tryToRecognizeEpisodes +HTTP: `POST /api/try-to-recognize-episodes` — rule-based episode recognition via Layer 2 `Core.tryToRecognizeEpisodes(path)` → pending `RecognizeMediaFilePlan` persisted under `{appDataDir}/plans/`. Request body: `{ mediaFolderPath: string }`. Response: `{ data: { plan } }` or `{ error }` (HTTP 200). Apply/reject reuse `POST /api/apply-plan` / `POST /api/reject-plan`; `apply-plan` honors `data.files` for `recognize-media-file` plans (applies only the selected `plan.files[].path` entries; unknown paths → 400 ProblemDetails). Product doc: [docs/dev/recognize-episodes.md](../dev/recognize-episodes.md). +``` + +- [ ] **Step 2: Update docs/dev/recognize-episodes.md** + +The Web UI sequence diagram already shows `POST /api/try-to-recognize-episodes` → `POST /api/apply-plan`. Add one sentence after the diagram: "The Web UI may uncheck episodes before confirming; `apply-plan` then carries `data: { files }` with the selected `plan.files[].path` entries (same selection semantics as rename UC3)." Also remove the `Browser->>User: show RuleBasedRecognizePrompt` wording only if it contradicts the final UI flow — otherwise leave the diagram untouched. + +- [ ] **Step 3: Full verification** + +```bash +cd apps/core && pnpm test && pnpm typecheck +cd apps/cli && pnpm test && pnpm typecheck +cd apps/ui && pnpm test +cd apps/ui && pnpm exec tsc -p tsconfig.app.json --noEmit +``` + +Expected: all tests PASS; core/cli typecheck clean; ui typecheck clean (or only pre-existing MoviePanel rename-refactor errors — report them, do not fix here). + +- [ ] **Step 4: Stop — hand off for review** + +Run `git status --short` and `git diff --stat`. Present the change summary to the user. **Do NOT commit — the user reviews and commits locally.** From a669199b7e170b4fe83791cc96fed973c12a749d Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 00:24:12 +0800 Subject: [PATCH 24/83] test: fixed unit tests error --- .../media/MediaFileTableSimpleLayout.test.tsx | 69 +++++++++++++++++-- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/apps/ui/src/components/media/MediaFileTableSimpleLayout.test.tsx b/apps/ui/src/components/media/MediaFileTableSimpleLayout.test.tsx index 29a05612..0330db50 100644 --- a/apps/ui/src/components/media/MediaFileTableSimpleLayout.test.tsx +++ b/apps/ui/src/components/media/MediaFileTableSimpleLayout.test.tsx @@ -229,7 +229,24 @@ describe("MediaFileTableSimpleLayout", () => { it("fires onCheck with (season, episode, checked) when a row checkbox is toggled", () => { const onCheck = vi.fn() - renderLayout({ checboxVisible: true, onCheck }) + renderLayout({ + checboxVisible: true, + onCheck, + // Checkboxes are only enabled for episodes with a plan target that + // differs from the current path. + newFilePaths: [ + { + season: 1, + episode: 2, + newFilePath: `${mediaFolderPath}/Breaking Bad - S01E02 - Cat's in the Bag (renamed).mkv`, + }, + { + season: 2, + episode: 1, + newFilePath: `${mediaFolderPath}/Breaking Bad - S02E01 - Grilled (renamed).mkv`, + }, + ], + }) const s1e2Row = getEpisodeRow("S01E02") fireEvent.click(within(s1e2Row).getByRole("checkbox")) @@ -245,7 +262,24 @@ describe("MediaFileTableSimpleLayout", () => { { season: 1, episode: 1 }, { season: 2, episode: 1 }, ] - renderLayout({ checboxVisible: true, selectedEpisodes }) + renderLayout({ + checboxVisible: true, + selectedEpisodes, + // Selected episodes only render as checked while their checkbox is + // enabled, i.e. while a differing plan target exists. + newFilePaths: [ + { + season: 1, + episode: 1, + newFilePath: `${mediaFolderPath}/Breaking Bad - S01E01 - Pilot (renamed).mkv`, + }, + { + season: 2, + episode: 1, + newFilePath: `${mediaFolderPath}/Breaking Bad - S02E01 - Grilled (renamed).mkv`, + }, + ], + }) expect( within(getEpisodeRow("S01E01")).getByRole("checkbox"), @@ -258,14 +292,39 @@ describe("MediaFileTableSimpleLayout", () => { ).not.toBeChecked() }) - it("disables checkboxes for episodes without a video file", () => { - renderLayout({ checboxVisible: true }) + it("renders selected episodes without a plan target as disabled and unchecked", () => { + const selectedEpisodes: UIMediaEpisodeSelection[] = [{ season: 1, episode: 1 }] + renderLayout({ checboxVisible: true, selectedEpisodes }) + + const checkbox = within(getEpisodeRow("S01E01")).getByRole("checkbox") + expect(checkbox).toBeDisabled() + expect(checkbox).not.toBeChecked() + }) + + it("disables checkboxes without a differing plan target and enables them otherwise", () => { + renderLayout({ + checboxVisible: true, + newFilePaths: [ + // Target equal to the current path → nothing to apply → disabled. + { season: 1, episode: 1, newFilePath: episodePath(1, "Pilot") }, + // Differing target → enabled, even for an episode without a video file. + { + season: 1, + episode: 3, + newFilePath: `${mediaFolderPath}/Breaking Bad - S01E03 - Cancer Man (renamed).mkv`, + }, + ], + }) + // No plan target → disabled, regardless of a linked video file. expect( - within(getEpisodeRow("S01E03")).getByRole("checkbox"), + within(getEpisodeRow("S01E02")).getByRole("checkbox"), ).toBeDisabled() expect( within(getEpisodeRow("S01E01")).getByRole("checkbox"), + ).toBeDisabled() + expect( + within(getEpisodeRow("S01E03")).getByRole("checkbox"), ).not.toBeDisabled() }) From ddcdba9a5bb2166457e0d148856572a4f5ae728e Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 17:20:27 +0800 Subject: [PATCH 25/83] docs: add AI Agent permissions config design Co-Authored-By: Claude Opus 4.7 --- .../2026-09-06-ai-agent-permissions-design.md | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-06-ai-agent-permissions-design.md diff --git a/docs/superpowers/specs/2026-09-06-ai-agent-permissions-design.md b/docs/superpowers/specs/2026-09-06-ai-agent-permissions-design.md new file mode 100644 index 00000000..9e39b296 --- /dev/null +++ b/docs/superpowers/specs/2026-09-06-ai-agent-permissions-design.md @@ -0,0 +1,106 @@ +# AI Agent Settings — Permissions Config + +This design document describes the high level design of a feature. +The design document is golden source and referenced by one or more features. + +> **Status:** Pending implementation. + +## 1. Background + +AI Assistant tools and MCP clients currently ask for user confirmation before every write operation (rename folder, rename episode file, batch rename, ...). Users who want hands-off automation have no way to pre-authorize these writes. + +This feature adds a new **"AI Agent"** settings category with a **`permissions`** config: an access control list that lets the user allow AI Assistant / MCP clients to bypass permission (confirmation) checks. At this stage only one permission is supported: **`metadata.write`**. + +**Scope for this feature (locked):** + +- **In scope:** new `AiAgentSettings` UI component; new "AI Agent" tab in the config panel; read/write support for `aiAgent.permissions` in user config (`smm.json`); i18n for all 4 locales. +- **Out of scope:** enforcement (skipping confirmation prompts in AI tools / MCP handlers); server-side (`core-routes` / CLI / `apps/core`) normalization of `aiAgent`; any permission beyond `metadata.write`. + +**Decisions (locked):** + +- Config shape: nested `aiAgent: { permissions: AiAgentPermission[] }` on `UserConfig` (user chose nested over flat `permissions`). +- UI form: a single checkbox toggling the one supported permission; a permission list table is deferred until a second permission exists. +- Approach: renderer-only plumbing (Approach A). Server-side defaults/normalization and a shared `hasAiAgentPermission()` helper are deferred to the enforcement feature. +- Default is `permissions: []` — no bypass unless the user explicitly grants it (safe default). + +## 2. Architecture + +### 2.1 Project Level Architecture + +Primary work is in `apps/ui` and `packages/types`. No new API routes; the existing renderer read/write path persists the field as JSON. + +| Package / App | Change | +|---------------|--------| +| `packages/types` | Add `AI_AGENT_PERMISSIONS` constant, `AiAgentPermission` type, `AiAgentConfig` interface, `aiAgent?: AiAgentConfig` on `UserConfig` | +| `apps/ui` | `AiAgentSettings` component, "AI Agent" tab in `config-panel.tsx`, `aiAgent` defaults in `normalizeUserConfig`, locales | + +### 2.2 App Level Architecture + +#### Config field + +```ts +// packages/types/types.ts +export const AI_AGENT_PERMISSIONS = { + metadataWrite: "metadata.write", +} as const; +export type AiAgentPermission = + (typeof AI_AGENT_PERMISSIONS)[keyof typeof AI_AGENT_PERMISSIONS]; + +export interface AiAgentConfig { + /** Permissions granted to AI Assistant / MCP clients (bypass confirmation). */ + permissions?: AiAgentPermission[]; +} + +// UserConfig gains: +aiAgent?: AiAgentConfig; +``` + +The `"metadata.write"` literal exists only in `AI_AGENT_PERMISSIONS`. + +#### Renderer read path (`apps/ui/src/api/readUserConfig.ts`) + +- `defaultUserConfig` gains `aiAgent: { permissions: [] }`. +- `normalizeUserConfig` adds a nested merge, same shape as `tmdb`/`tvdb`: + +```ts +aiAgent: { + ...defaultUserConfig.aiAgent, + ...(raw.aiAgent ?? {}), +}, +``` + +- Write path needs no changes — `setAndSaveUserConfig` persists the whole object to `smm.json`. + +#### UI — `AiAgentSettings` (new, `apps/ui/src/components/ui/settings/`) + +Follows the `GeneralSettings` pattern: + +- `useConfig()` for `userConfig` + `setAndSaveUserConfig`; initial-values memo synced via effect on config reload. +- Plain `` + `Label` + description paragraph (matches the existing telemetry / MCP-server checkboxes; the shadcn `Checkbox` component is not used by settings today). +- Checked state derives from `userConfig.aiAgent?.permissions?.includes(AI_AGENT_PERMISSIONS.metadataWrite) ?? false`. +- On save: `aiAgent.permissions = checked ? ["metadata.write"] : []` (whole-array replace — only one permission exists today). +- Save button appears only when `hasChanges`, same as `GeneralSettings`. +- `data-testid` conventions: `ai-agent-settings` (root), `setting-ai-agent-metadata-write` (checkbox), `settings-save-button` (save). + +#### Panel wiring (`apps/ui/src/components/ui/config-panel.tsx`) + +- `SettingsTab` union gains `"ai-agent"`. +- Menu item added between "ai" and "media-databases" with the `Sparkles` icon (`Bot` is taken by AI settings); label from `t('sidebar.aiAgent')`. +- `renderContent` gains a `"ai-agent"` case returning ``. + +#### i18n (`apps/ui/public/locales/{en,zh-CN,zh-HK,zh-TW}/settings.json`) + +| Key | en | zh-CN | zh-HK / zh-TW | +|-----|----|-----------------------|---------------| +| `sidebar.aiAgent` | `AI Agent` | `AI 智能体` | `AI 代理` | +| `aiAgent.title` | `AI Agent` | `AI 智能体` | `AI 代理` | +| `aiAgent.description` | `Configure permissions granted to AI Assistant and MCP clients` | `配置授予 AI 助手和 MCP 客户端的权限` | `設定授予 AI 助理與 MCP 用戶端的權限` | +| `aiAgent.metadataWrite` | `Allow metadata writes without confirmation` | `允许无需确认即写入元数据` | `允許無需確認即寫入元資料` | +| `aiAgent.metadataWriteDescription` | `AI Assistant and MCP clients can update media metadata (e.g. rename folders/files) without asking for confirmation each time.` | `AI 助手和 MCP 客户端将可以直接更新媒体元数据(例如重命名文件夹/文件),无需每次确认。` | `AI 助理與 MCP 用戶端將可以直接更新媒體元資料(例如重新命名資料夾/檔案),無需每次確認。` | + +Save button reuses the existing common-namespace key: `t('save', { ns: 'common' })` (same as `GeneralSettings`). + +#### Tests + +- `normalizeUserConfig`: config without `aiAgent` gets `{ permissions: [] }`; partial `aiAgent` merges over defaults. +- New `AiAgentSettings.test.tsx` (following `GeneralSettings.test.tsx`): checkbox reflects persisted config; toggling reveals Save; saving persists `aiAgent.permissions`; no Save button initially. From 81748d2d104a0dc5fb41f04537faf109705a87a3 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 17:32:57 +0800 Subject: [PATCH 26/83] docs: add ai-agent permissions implementation plan Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-09-06-ai-agent-permissions.md | 486 ++++++++++++++++++ 1 file changed, 486 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-06-ai-agent-permissions.md diff --git a/docs/superpowers/plans/2026-09-06-ai-agent-permissions.md b/docs/superpowers/plans/2026-09-06-ai-agent-permissions.md new file mode 100644 index 00000000..8f7a5722 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-ai-agent-permissions.md @@ -0,0 +1,486 @@ +# AI Agent Permissions Config Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an "AI Agent" settings category with an `aiAgent.permissions` config (`metadata.write` bypass ACL) persisted to `smm.json`. + +**Architecture:** Types live in `packages/types` (shared `UserConfig`); the renderer merges `aiAgent` defaults in `apps/ui/src/api/readUserConfig.ts`; a new `AiAgentSettings` component follows the `GeneralSettings` pattern (local state + Save button); the tab is wired in `config-panel.tsx` with keys in all 4 locales. Enforcement in AI tools / MCP handlers is explicitly out of scope (see spec). + +**Tech Stack:** React 18 + TypeScript, vitest + testing-library (jsdom), i18next JSON locales, pnpm workspace. + +**Spec:** `docs/superpowers/specs/2026-09-06-ai-agent-permissions-design.md` + +--- + +### Task 1: `aiAgent` config type + renderer defaults + +**Files:** +- Modify: `packages/types/types.ts` (insert before `export interface UserConfig` at line 51; add field before interface closing brace at line 156) +- Modify: `apps/ui/src/api/readUserConfig.ts` (defaults at lines 7-29; normalize at lines 32-45) +- Test: `apps/ui/src/api/readUserConfig.test.ts` + +- [ ] **Step 1: Write the failing tests** + +In `apps/ui/src/api/readUserConfig.test.ts`, add `AI_AGENT_PERMISSIONS` to the existing type import (line 2) and add two tests inside the existing `describe('normalizeUserConfig', ...)` block (after the test ending at line 50): + +```ts +import { AI_AGENT_PERMISSIONS, type UserConfig } from '@smm/types' +``` + +```ts + it('fills missing aiAgent with empty permissions', () => { + const raw: Partial = { folders: [] } + + const normalized = normalizeUserConfig(raw) + + expect(normalized.aiAgent).toEqual({ permissions: [] }) + }) + + it('merges partial aiAgent without dropping other defaults', () => { + const raw: Partial = { + aiAgent: { permissions: [AI_AGENT_PERMISSIONS.metadataWrite] }, + } + + const normalized = normalizeUserConfig(raw) + + expect(normalized.aiAgent).toEqual({ permissions: ['metadata.write'] }) + }) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/ui && pnpm test src/api/readUserConfig.test.ts` +Expected: FAIL — `AI_AGENT_PERMISSIONS` is undefined, so `AI_AGENT_PERMISSIONS.metadataWrite` throws; `normalized.aiAgent` is undefined instead of `{ permissions: [] }`. + +- [ ] **Step 3: Add types + defaults + normalize** + +In `packages/types/types.ts`, insert immediately **before** `export interface UserConfig {` (line 51): + +```ts +/** + * Permissions that let AI Assistant / MCP clients bypass the + * corresponding confirmation (permission) check. + */ +export const AI_AGENT_PERMISSIONS = { + /** Update media metadata (rename folders/files, metadata cache) without confirmation. */ + metadataWrite: "metadata.write", +} as const; + +export type AiAgentPermission = + (typeof AI_AGENT_PERMISSIONS)[keyof typeof AI_AGENT_PERMISSIONS]; + +export interface AiAgentConfig { + /** + * Permissions granted to AI Assistant / MCP clients. + * Empty or undefined means every write still asks for confirmation. + */ + permissions?: AiAgentPermission[]; +} +``` + +In the same file, inside `interface UserConfig`, after the `quickjsExecutablePath?: string` field (line 155), add: + +```ts + /** + * AI Agent settings. Currently only holds permissions that let + * AI Assistant / MCP clients bypass confirmation checks. + */ + aiAgent?: AiAgentConfig; +``` + +In `apps/ui/src/api/readUserConfig.ts`, add `aiAgent: { permissions: [] }` as the last property of `defaultUserConfig` (after `useBundledFfmpegForVideoCaptioner: true,` at line 28) and add a nested merge to `normalizeUserConfig` (same shape as `tmdb`/`tvdb`), before the closing brace of the returned object: + +```ts + aiAgent: { + ...defaultUserConfig.aiAgent, + ...(raw.aiAgent ?? {}), + }, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/ui && pnpm test src/api/readUserConfig.test.ts` +Expected: PASS (all tests in file). + +- [ ] **Step 5: Commit** + +```bash +git add packages/types/types.ts apps/ui/src/api/readUserConfig.ts apps/ui/src/api/readUserConfig.test.ts +git commit -m "feat: add aiAgent permissions config to user config" +``` + +--- + +### Task 2: `AiAgentSettings` component + +**Files:** +- Create: `apps/ui/src/components/ui/settings/AiAgentSettings.tsx` +- Test: `apps/ui/src/components/ui/settings/AiAgentSettings.test.tsx` + +- [ ] **Step 1: Write the failing tests** + +Create `apps/ui/src/components/ui/settings/AiAgentSettings.test.tsx`: + +```tsx +/** @vitest-environment jsdom */ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { AiAgentSettings } from "./AiAgentSettings"; +import { AI_AGENT_PERMISSIONS } from "@smm/types"; + +const defaultUserConfig = { + tmdb: {}, + tvdb: {}, + folders: [], + renameRules: [], + dryRun: false, + selectedRenameRule: "", + aiAgent: { permissions: [] as string[] }, +}; + +const mockSetAndSaveUserConfig = vi.fn(); + +const mockUseConfig = vi.fn(() => ({ + userConfig: defaultUserConfig, + setAndSaveUserConfig: mockSetAndSaveUserConfig, +})); + +vi.mock("@/hooks/userConfig", () => ({ + useConfig: () => mockUseConfig(), +})); + +vi.mock("@/lib/i18n", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("@/lib/utils", () => ({ + nextTraceId: () => "test-trace-id", +})); + +describe("AiAgentSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders the AI agent settings page", () => { + render(); + expect(screen.getByTestId("ai-agent-settings")).toBeInTheDocument(); + }); + + it("checkbox is unchecked when no permissions are granted", () => { + render(); + expect( + screen.getByTestId("setting-ai-agent-metadata-write"), + ).not.toBeChecked(); + }); + + it("checkbox is checked when metadata.write is granted", () => { + mockUseConfig.mockReturnValue({ + userConfig: { + ...defaultUserConfig, + aiAgent: { permissions: [AI_AGENT_PERMISSIONS.metadataWrite] }, + }, + setAndSaveUserConfig: mockSetAndSaveUserConfig, + }); + render(); + expect(screen.getByTestId("setting-ai-agent-metadata-write")).toBeChecked(); + }); + + it("hides save button until something changes", () => { + render(); + expect(screen.queryByTestId("settings-save-button")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("setting-ai-agent-metadata-write")); + + expect(screen.getByTestId("settings-save-button")).toBeInTheDocument(); + }); + + it("saves metadata.write permission when checked and saved", async () => { + render(); + fireEvent.click(screen.getByTestId("setting-ai-agent-metadata-write")); + fireEvent.click(screen.getByTestId("settings-save-button")); + + expect(mockSetAndSaveUserConfig).toHaveBeenCalledTimes(1); + const [traceId, savedConfig] = mockSetAndSaveUserConfig.mock.calls[0]; + expect(traceId).toContain("AiAgentSettings"); + expect(savedConfig.aiAgent.permissions).toEqual(["metadata.write"]); + }); + + it("saves empty permissions when unchecked and saved", async () => { + mockUseConfig.mockReturnValue({ + userConfig: { + ...defaultUserConfig, + aiAgent: { permissions: [AI_AGENT_PERMISSIONS.metadataWrite] }, + }, + setAndSaveUserConfig: mockSetAndSaveUserConfig, + }); + render(); + fireEvent.click(screen.getByTestId("setting-ai-agent-metadata-write")); + fireEvent.click(screen.getByTestId("settings-save-button")); + + const [, savedConfig] = mockSetAndSaveUserConfig.mock.calls[0]; + expect(savedConfig.aiAgent.permissions).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/ui && pnpm test src/components/ui/settings/AiAgentSettings.test.tsx` +Expected: FAIL — `./AiAgentSettings` does not exist (import error). + +- [ ] **Step 3: Implement the component** + +Create `apps/ui/src/components/ui/settings/AiAgentSettings.tsx`: + +```tsx +import { useEffect, useMemo, useState } from "react" +import { useConfig } from "@/hooks/userConfig" +import { Label } from "@/components/ui/label" +import { Button } from "@/components/ui/button" +import { useTranslation } from "@/lib/i18n" +import { nextTraceId } from "@/lib/utils" +import { AI_AGENT_PERMISSIONS } from "@smm/types" + +export function AiAgentSettings() { + const { userConfig, setAndSaveUserConfig } = useConfig() + const { t } = useTranslation(['settings', 'common']) + + const initialValues = useMemo( + () => ({ + metadataWrite: + userConfig.aiAgent?.permissions?.includes( + AI_AGENT_PERMISSIONS.metadataWrite, + ) ?? false, + }), + [userConfig], + ) + + const [metadataWrite, setMetadataWrite] = useState(initialValues.metadataWrite) + + // Reset form when userConfig changes + useEffect(() => { + /* eslint-disable react-hooks/set-state-in-effect */ + setMetadataWrite(initialValues.metadataWrite) + /* eslint-enable react-hooks/set-state-in-effect */ + }, [initialValues]) + + const hasChanges = metadataWrite !== initialValues.metadataWrite + + const handleSave = async () => { + const traceId = `AiAgentSettings-${nextTraceId()}` + console.log(`[${traceId}] AiAgentSettings: Saving AI agent settings`) + await setAndSaveUserConfig(traceId, { + ...userConfig, + aiAgent: { + ...userConfig.aiAgent, + permissions: metadataWrite ? [AI_AGENT_PERMISSIONS.metadataWrite] : [], + }, + }) + } + + return ( +
+
+

{t('aiAgent.title')}

+

{t('aiAgent.description')}

+
+ +
+
+ setMetadataWrite(e.target.checked)} + className="h-4 w-4 rounded border-input" + data-testid="setting-ai-agent-metadata-write" + /> + +
+

+ {t('aiAgent.metadataWriteDescription')} +

+
+ + {hasChanges && ( +
+ +
+ )} +
+ ) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/ui && pnpm test src/components/ui/settings/AiAgentSettings.test.tsx` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/ui/src/components/ui/settings/AiAgentSettings.tsx apps/ui/src/components/ui/settings/AiAgentSettings.test.tsx +git commit -m "feat(ui): add AiAgentSettings component" +``` + +--- + +### Task 3: Panel wiring + locales + +**Files:** +- Modify: `apps/ui/src/components/ui/config-panel.tsx` (lines 2-3, 11, 24-28, 66-79) +- Modify: `apps/ui/public/locales/en/settings.json` +- Modify: `apps/ui/public/locales/zh-CN/settings.json` +- Modify: `apps/ui/public/locales/zh-HK/settings.json` +- Modify: `apps/ui/public/locales/zh-TW/settings.json` + +No unit tests — no test infra exists for `config-panel.tsx` or locale JSON. Verified by typecheck + full suite (Task 4). + +- [ ] **Step 1: Wire the tab in `config-panel.tsx`** + +Add `Sparkles` to the lucide-react import (line 2) and the component import (line 4 area): + +```tsx +import { Settings, Bot, MessageSquare, Box, Database, Sparkles } from "lucide-react" +import { AiAgentSettings } from "./settings/AiAgentSettings" +``` + +Extend the `SettingsTab` union (line 11) — insert `"ai-agent"` after `"ai"`: + +```tsx +export type SettingsTab = "general" | "ai" | "ai-agent" | "external-apps" | "media-databases" | "rename-rules" | "feedback" +``` + +In `menuItems` (line 21-29), insert between the `"ai"` entry and the `"media-databases"` entry: + +```tsx + { id: "ai-agent", label: t('sidebar.aiAgent'), icon: }, +``` + +In `renderContent` (lines 65-80), add a case after `case "ai":`: + +```tsx + case "ai-agent": + return +``` + +- [ ] **Step 2: Add en locale keys** + +In `apps/ui/public/locales/en/settings.json`, insert an `"aiAgent"` section after the `"ai"` section (after line 92, including a comma), and `"aiAgent"` in `sidebar` after `"ai"`: + +```json + "aiAgent": { + "title": "AI Agent", + "description": "Configure permissions granted to AI Assistant and MCP clients", + "metadataWrite": "Allow metadata writes without confirmation", + "metadataWriteDescription": "AI Assistant and MCP clients can update media metadata (e.g. rename folders/files) without asking for confirmation each time." + }, +``` + +```json + "aiAgent": "AI Agent" +``` + +(In `sidebar`, add a comma after `"ai": "AI"` and place `"aiAgent"` before `"mediaDatabases"`.) + +- [ ] **Step 3: Add zh-CN locale keys** + +In `apps/ui/public/locales/zh-CN/settings.json`, same placement: + +```json + "aiAgent": { + "title": "AI 智能体", + "description": "配置授予 AI 助手和 MCP 客户端的权限", + "metadataWrite": "允许无需确认即写入元数据", + "metadataWriteDescription": "AI 助手和 MCP 客户端将可以直接更新媒体元数据(例如重命名文件夹/文件),无需每次确认。" + }, +``` + +```json + "aiAgent": "AI 智能体" +``` + +- [ ] **Step 4: Add zh-HK and zh-TW locale keys** + +In both `apps/ui/public/locales/zh-HK/settings.json` and `apps/ui/public/locales/zh-TW/settings.json`, same placement: + +```json + "aiAgent": { + "title": "AI 代理", + "description": "設定授予 AI 助理與 MCP 用戶端的權限", + "metadataWrite": "允許無需確認即寫入元資料", + "metadataWriteDescription": "AI 助理與 MCP 用戶端將可以直接更新媒體元資料(例如重新命名資料夾/檔案),無需每次確認。" + }, +``` + +```json + "aiAgent": "AI 代理" +``` + +- [ ] **Step 5: Validate JSON and typecheck** + +Run: `node -e "['en','zh-CN','zh-HK','zh-TW'].forEach(l => require('./apps/ui/public/locales/' + l + '/settings.json'))" && cd apps/ui && pnpm typecheck` +Expected: no output errors from node; `tsc --noEmit` exits 0. + +- [ ] **Step 6: Commit** + +```bash +git add apps/ui/src/components/ui/config-panel.tsx apps/ui/public/locales/en/settings.json apps/ui/public/locales/zh-CN/settings.json apps/ui/public/locales/zh-HK/settings.json apps/ui/public/locales/zh-TW/settings.json +git commit -m "feat(ui): add AI Agent tab and locales" +``` + +--- + +### Task 4: Full verification + spec status + +**Files:** +- Modify: `docs/superpowers/specs/2026-09-06-ai-agent-permissions-design.md` (status banner) + +- [ ] **Step 1: Run the full apps/ui test suite** + +Run: `cd apps/ui && pnpm test` +Expected: PASS — no regressions in `GeneralSettings.test.tsx`, `readUserConfig.test.ts`, or any other suite. + +- [ ] **Step 2: Lint** + +Run: `cd apps/ui && pnpm lint` +Expected: no new errors introduced by the changed/created files. + +- [ ] **Step 3: Manual browser check** + +Run `cd apps/ui && pnpm dev`, open the app, go to Settings: +1. Sidebar shows "AI Agent" between "AI" and "Media Databases" +2. AI Agent tab shows the checkbox, unchecked by default +3. Toggle it → Save button appears bottom-right → click Save +4. Verify `smm.json` in the user data dir now contains `"aiAgent": { "permissions": ["metadata.write"] }` +5. Reopen settings → checkbox still checked; uncheck + Save → smm.json has `"permissions": []` + +(If the standalone renderer dev server cannot reach a backend in this environment, note that explicitly in the task report instead of claiming the check passed.) + +- [ ] **Step 4: Flip the spec status banner** + +In `docs/superpowers/specs/2026-09-06-ai-agent-permissions-design.md`, replace: + +```markdown +> **Status:** Pending implementation. +``` + +with: + +```markdown +> **Status:** Implemented (2026-09-06). Commit range: ``..``. +``` + +- [ ] **Step 5: Commit** + +```bash +git add docs/superpowers/specs/2026-09-06-ai-agent-permissions-design.md +git commit -m "docs: mark ai-agent permissions design implemented" +``` From 078954e5fc5dc5303f9f8c97b3926b0d22fc95c3 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 17:43:01 +0800 Subject: [PATCH 27/83] feat: add aiAgent permissions config to user config --- apps/ui/src/api/readUserConfig.test.ts | 20 +++++++++++++++++++- apps/ui/src/api/readUserConfig.ts | 5 +++++ packages/types/types.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/apps/ui/src/api/readUserConfig.test.ts b/apps/ui/src/api/readUserConfig.test.ts index 04384478..2b423cef 100644 --- a/apps/ui/src/api/readUserConfig.test.ts +++ b/apps/ui/src/api/readUserConfig.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' -import type { UserConfig } from '@smm/types' +import { AI_AGENT_PERMISSIONS, type UserConfig } from '@smm/types' import { defaultUserConfig, normalizeUserConfig, readUserConfigFromUserDataDir } from './readUserConfig' import { readFile } from './readFile' @@ -48,6 +48,24 @@ describe('normalizeUserConfig', () => { }) expect(normalized.primaryDatabase).toBe('TMDB') }) + + it('fills missing aiAgent with empty permissions', () => { + const raw: Partial = { folders: [] } + + const normalized = normalizeUserConfig(raw) + + expect(normalized.aiAgent).toEqual({ permissions: [] }) + }) + + it('merges partial aiAgent without dropping other defaults', () => { + const raw: Partial = { + aiAgent: { permissions: [AI_AGENT_PERMISSIONS.metadataWrite] }, + } + + const normalized = normalizeUserConfig(raw) + + expect(normalized.aiAgent).toEqual({ permissions: ['metadata.write'] }) + }) }) describe('readUserConfigFromUserDataDir', () => { diff --git a/apps/ui/src/api/readUserConfig.ts b/apps/ui/src/api/readUserConfig.ts index 508b312a..0f46c597 100644 --- a/apps/ui/src/api/readUserConfig.ts +++ b/apps/ui/src/api/readUserConfig.ts @@ -26,6 +26,7 @@ export const defaultUserConfig: UserConfig = { mcpHost: '127.0.0.1', mcpPort: 30001, useBundledFfmpegForVideoCaptioner: true, + aiAgent: { permissions: [] }, }; /** Merge persisted config with defaults so required nested fields (tmdb, tvdb) always exist. */ @@ -41,6 +42,10 @@ export function normalizeUserConfig(raw: Partial): UserConfig { ...defaultUserConfig.tvdb, ...(raw.tvdb ?? {}), }, + aiAgent: { + ...defaultUserConfig.aiAgent, + ...(raw.aiAgent ?? {}), + }, } } diff --git a/packages/types/types.ts b/packages/types/types.ts index 167320c6..1d2aca60 100644 --- a/packages/types/types.ts +++ b/packages/types/types.ts @@ -45,6 +45,26 @@ export const DEFAULT_AI_PROVIDERS: OpenAICompatibleConfig[] = [ export const DEFAULT_SELECTED_AI_PROVIDER = 'DeepSeek' +/** + * Permissions that let AI Assistant / MCP clients bypass the + * corresponding confirmation (permission) check. + */ +export const AI_AGENT_PERMISSIONS = { + /** Update media metadata (rename folders/files, metadata cache) without confirmation. */ + metadataWrite: "metadata.write", +} as const; + +export type AiAgentPermission = + (typeof AI_AGENT_PERMISSIONS)[keyof typeof AI_AGENT_PERMISSIONS]; + +export interface AiAgentConfig { + /** + * Permissions granted to AI Assistant / MCP clients. + * Empty or undefined means every write still asks for confirmation. + */ + permissions?: AiAgentPermission[]; +} + /** * Represent the user configuration, which is editable to the user. */ @@ -153,6 +173,12 @@ export interface UserConfig { * Path to the QuickJS executable file. */ quickjsExecutablePath?: string + + /** + * AI Agent settings. Currently only holds permissions that let + * AI Assistant / MCP clients bypass confirmation checks. + */ + aiAgent?: AiAgentConfig; } /** From 545727233823009c6659af6c1555577a9b61e16f Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 17:49:11 +0800 Subject: [PATCH 28/83] feat(ui): add AiAgentSettings component --- .../ui/settings/AiAgentSettings.test.tsx | 108 ++++++++++++++++++ .../ui/settings/AiAgentSettings.tsx | 79 +++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 apps/ui/src/components/ui/settings/AiAgentSettings.test.tsx create mode 100644 apps/ui/src/components/ui/settings/AiAgentSettings.tsx diff --git a/apps/ui/src/components/ui/settings/AiAgentSettings.test.tsx b/apps/ui/src/components/ui/settings/AiAgentSettings.test.tsx new file mode 100644 index 00000000..e0ebbfc7 --- /dev/null +++ b/apps/ui/src/components/ui/settings/AiAgentSettings.test.tsx @@ -0,0 +1,108 @@ +/** @vitest-environment jsdom */ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { AiAgentSettings } from "./AiAgentSettings"; +import { AI_AGENT_PERMISSIONS } from "@smm/types"; + +const defaultUserConfig = { + tmdb: {}, + tvdb: {}, + folders: [], + renameRules: [], + dryRun: false, + selectedRenameRule: "", + aiAgent: { permissions: [] as string[] }, +}; + +const mockSetAndSaveUserConfig = vi.fn(); + +const mockUseConfig = vi.fn(() => ({ + userConfig: defaultUserConfig, + setAndSaveUserConfig: mockSetAndSaveUserConfig, +})); + +vi.mock("@/hooks/userConfig", () => ({ + useConfig: () => mockUseConfig(), +})); + +vi.mock("@/lib/i18n", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("@/lib/utils", () => ({ + nextTraceId: () => "test-trace-id", + cn: (...classes: unknown[]) => classes.filter(Boolean).join(" "), +})); + +describe("AiAgentSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseConfig.mockImplementation(() => ({ + userConfig: defaultUserConfig, + setAndSaveUserConfig: mockSetAndSaveUserConfig, + })); + }); + + it("renders the AI agent settings page", () => { + render(); + expect(screen.getByTestId("ai-agent-settings")).toBeInTheDocument(); + }); + + it("checkbox is unchecked when no permissions are granted", () => { + render(); + expect( + screen.getByTestId("setting-ai-agent-metadata-write"), + ).not.toBeChecked(); + }); + + it("checkbox is checked when metadata.write is granted", () => { + mockUseConfig.mockReturnValue({ + userConfig: { + ...defaultUserConfig, + aiAgent: { permissions: [AI_AGENT_PERMISSIONS.metadataWrite] }, + }, + setAndSaveUserConfig: mockSetAndSaveUserConfig, + }); + render(); + expect(screen.getByTestId("setting-ai-agent-metadata-write")).toBeChecked(); + }); + + it("hides save button until something changes", () => { + render(); + expect(screen.queryByTestId("settings-save-button")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("setting-ai-agent-metadata-write")); + + expect(screen.getByTestId("settings-save-button")).toBeInTheDocument(); + }); + + it("saves metadata.write permission when checked and saved", async () => { + render(); + fireEvent.click(screen.getByTestId("setting-ai-agent-metadata-write")); + fireEvent.click(screen.getByTestId("settings-save-button")); + + expect(mockSetAndSaveUserConfig).toHaveBeenCalledTimes(1); + const [traceId, savedConfig] = mockSetAndSaveUserConfig.mock.calls[0]; + expect(traceId).toContain("AiAgentSettings"); + expect(savedConfig.aiAgent.permissions).toEqual(["metadata.write"]); + }); + + it("saves empty permissions when unchecked and saved", async () => { + mockUseConfig.mockReturnValue({ + userConfig: { + ...defaultUserConfig, + aiAgent: { permissions: [AI_AGENT_PERMISSIONS.metadataWrite] }, + }, + setAndSaveUserConfig: mockSetAndSaveUserConfig, + }); + render(); + fireEvent.click(screen.getByTestId("setting-ai-agent-metadata-write")); + fireEvent.click(screen.getByTestId("settings-save-button")); + + const [, savedConfig] = mockSetAndSaveUserConfig.mock.calls[0]; + expect(savedConfig.aiAgent.permissions).toEqual([]); + }); +}); diff --git a/apps/ui/src/components/ui/settings/AiAgentSettings.tsx b/apps/ui/src/components/ui/settings/AiAgentSettings.tsx new file mode 100644 index 00000000..1b608682 --- /dev/null +++ b/apps/ui/src/components/ui/settings/AiAgentSettings.tsx @@ -0,0 +1,79 @@ +import { useEffect, useMemo, useState } from "react" +import { useConfig } from "@/hooks/userConfig" +import { Label } from "@/components/ui/label" +import { Button } from "@/components/ui/button" +import { useTranslation } from "@/lib/i18n" +import { nextTraceId } from "@/lib/utils" +import { AI_AGENT_PERMISSIONS } from "@smm/types" + +export function AiAgentSettings() { + const { userConfig, setAndSaveUserConfig } = useConfig() + const { t } = useTranslation(['settings', 'common']) + + const initialValues = useMemo( + () => ({ + metadataWrite: + userConfig.aiAgent?.permissions?.includes( + AI_AGENT_PERMISSIONS.metadataWrite, + ) ?? false, + }), + [userConfig], + ) + + const [metadataWrite, setMetadataWrite] = useState(initialValues.metadataWrite) + + // Reset form when userConfig changes + useEffect(() => { + /* eslint-disable react-hooks/set-state-in-effect */ + setMetadataWrite(initialValues.metadataWrite) + /* eslint-enable react-hooks/set-state-in-effect */ + }, [initialValues]) + + const hasChanges = metadataWrite !== initialValues.metadataWrite + + const handleSave = async () => { + const traceId = `AiAgentSettings-${nextTraceId()}` + console.log(`[${traceId}] AiAgentSettings: Saving AI agent settings`) + await setAndSaveUserConfig(traceId, { + ...userConfig, + aiAgent: { + ...userConfig.aiAgent, + permissions: metadataWrite ? [AI_AGENT_PERMISSIONS.metadataWrite] : [], + }, + }) + } + + return ( +
+
+

{t('aiAgent.title')}

+

{t('aiAgent.description')}

+
+ +
+
+ setMetadataWrite(e.target.checked)} + className="h-4 w-4 rounded border-input" + data-testid="setting-ai-agent-metadata-write" + /> + +
+

+ {t('aiAgent.metadataWriteDescription')} +

+
+ + {hasChanges && ( +
+ +
+ )} +
+ ) +} From 0c432e580fbc014d85d8f52503b19227a4feb7ce Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 17:53:03 +0800 Subject: [PATCH 29/83] feat(ui): add AI Agent tab and locales --- apps/ui/public/locales/en/settings.json | 7 +++++++ apps/ui/public/locales/zh-CN/settings.json | 7 +++++++ apps/ui/public/locales/zh-HK/settings.json | 7 +++++++ apps/ui/public/locales/zh-TW/settings.json | 7 +++++++ apps/ui/src/components/ui/config-panel.tsx | 8 ++++++-- 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/ui/public/locales/en/settings.json b/apps/ui/public/locales/en/settings.json index 626ff29b..c911b040 100644 --- a/apps/ui/public/locales/en/settings.json +++ b/apps/ui/public/locales/en/settings.json @@ -90,6 +90,12 @@ "checkError": "Connection failed", "checkChecking": "Checking..." }, + "aiAgent": { + "title": "AI Agent", + "description": "Configure permissions granted to AI Assistant and MCP clients", + "metadataWrite": "Allow metadata writes without confirmation", + "metadataWriteDescription": "AI Assistant and MCP clients can update media metadata (e.g. rename folders/files) without asking for confirmation each time." + }, "feedback": { "title": "Feedback", "description": "Share your feedback, report bugs, or suggest new features", @@ -136,6 +142,7 @@ "title": "Settings", "general": "General", "ai": "AI", + "aiAgent": "AI Agent", "mediaDatabases": "Media Databases", "renameRules": "Rename Rules", "externalApps": "External Apps", diff --git a/apps/ui/public/locales/zh-CN/settings.json b/apps/ui/public/locales/zh-CN/settings.json index 871bc2cf..43f41a46 100644 --- a/apps/ui/public/locales/zh-CN/settings.json +++ b/apps/ui/public/locales/zh-CN/settings.json @@ -90,6 +90,12 @@ "checkError": "连接失败", "checkChecking": "检查中..." }, + "aiAgent": { + "title": "AI 智能体", + "description": "配置授予 AI 助手和 MCP 客户端的权限", + "metadataWrite": "允许无需确认即写入元数据", + "metadataWriteDescription": "AI 助手和 MCP 客户端将可以直接更新媒体元数据(例如重命名文件夹/文件),无需每次确认。" + }, "feedback": { "title": "反馈", "description": "分享您的反馈、报告错误或建议新功能", @@ -136,6 +142,7 @@ "title": "设置", "general": "常规", "ai": "AI", + "aiAgent": "AI 智能体", "mediaDatabases": "媒体数据库", "renameRules": "重命名规则", "externalApps": "外部应用", diff --git a/apps/ui/public/locales/zh-HK/settings.json b/apps/ui/public/locales/zh-HK/settings.json index b882abaf..b7b5caf1 100644 --- a/apps/ui/public/locales/zh-HK/settings.json +++ b/apps/ui/public/locales/zh-HK/settings.json @@ -90,6 +90,12 @@ "checkError": "連接失敗", "checkChecking": "檢查中..." }, + "aiAgent": { + "title": "AI 代理", + "description": "設定授予 AI 助理與 MCP 用戶端的權限", + "metadataWrite": "允許無需確認即寫入元資料", + "metadataWriteDescription": "AI 助理與 MCP 用戶端將可以直接更新媒體元資料(例如重新命名資料夾/檔案),無需每次確認。" + }, "feedback": { "title": "意見回饋", "description": "分享您的意見、回報錯誤或建議新功能", @@ -136,6 +142,7 @@ "title": "設定", "general": "一般", "ai": "AI", + "aiAgent": "AI 代理", "mediaDatabases": "媒體資料庫", "renameRules": "重新命名規則", "externalApps": "外部應用", diff --git a/apps/ui/public/locales/zh-TW/settings.json b/apps/ui/public/locales/zh-TW/settings.json index df766f35..64fe5ae9 100644 --- a/apps/ui/public/locales/zh-TW/settings.json +++ b/apps/ui/public/locales/zh-TW/settings.json @@ -90,6 +90,12 @@ "checkError": "連接失敗", "checkChecking": "檢查中..." }, + "aiAgent": { + "title": "AI 代理", + "description": "設定授予 AI 助理與 MCP 用戶端的權限", + "metadataWrite": "允許無需確認即寫入元資料", + "metadataWriteDescription": "AI 助理與 MCP 用戶端將可以直接更新媒體元資料(例如重新命名資料夾/檔案),無需每次確認。" + }, "feedback": { "title": "意見回饋", "description": "分享您的意見、回報錯誤或建議新功能", @@ -136,6 +142,7 @@ "title": "設定", "general": "一般", "ai": "AI", + "aiAgent": "AI 代理", "mediaDatabases": "媒體資料庫", "renameRules": "重新命名規則", "externalApps": "外部應用", diff --git a/apps/ui/src/components/ui/config-panel.tsx b/apps/ui/src/components/ui/config-panel.tsx index 3bf64cc8..db307a9a 100644 --- a/apps/ui/src/components/ui/config-panel.tsx +++ b/apps/ui/src/components/ui/config-panel.tsx @@ -1,14 +1,15 @@ import { useState } from "react" -import { Settings, Bot, MessageSquare, Box, Database } from "lucide-react" +import { Settings, Bot, MessageSquare, Box, Database, Sparkles } from "lucide-react" import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, SidebarHeader, SidebarProvider, SidebarTrigger, SidebarMenu, SidebarMenuItem, SidebarMenuButton } from "./sidebar" import { GeneralSettings } from "./settings/GeneralSettings" import { AiSettings } from "./settings/AiSettings" +import { AiAgentSettings } from "./settings/AiAgentSettings" import { ExternalApplicationsSettings } from "./settings/ExternalApplicationsSettings" import { MediaDatabasesSettings } from "./settings/MediaDatabasesSettings" import { Feedback } from "./settings/Feedback" import { useTranslation } from "@/lib/i18n" -export type SettingsTab = "general" | "ai" | "external-apps" | "media-databases" | "rename-rules" | "feedback" +export type SettingsTab = "general" | "ai" | "ai-agent" | "external-apps" | "media-databases" | "rename-rules" | "feedback" interface ConfigPanelSidebarProps { activeTab: SettingsTab @@ -21,6 +22,7 @@ function ConfigPanelSidebar({ activeTab, onTabChange }: ConfigPanelSidebarProps) const menuItems: Array<{ id: SettingsTab; label: string; icon: React.ReactNode }> = [ { id: "general", label: t('sidebar.general'), icon: }, { id: "ai", label: t('sidebar.ai'), icon: }, + { id: "ai-agent", label: t('sidebar.aiAgent'), icon: }, { id: "media-databases", label: t('sidebar.mediaDatabases'), icon: }, { id: "external-apps", label: t('sidebar.externalApps'), icon: }, // Disable Rename Rules as this feature is going to deprecate @@ -68,6 +70,8 @@ function ConfigPanel({ initialTab = "general" }: ConfigPanelProps) { return case "ai": return + case "ai-agent": + return case "external-apps": return case "media-databases": From a52b75bbd88f94883d562f8e9d311889642aa23e Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 17:56:58 +0800 Subject: [PATCH 30/83] fix(ui): use zh-TW metadata terminology in ai agent locale Co-Authored-By: Claude Opus 4.7 --- apps/ui/public/locales/zh-TW/settings.json | 4 ++-- .../specs/2026-09-06-ai-agent-permissions-design.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ui/public/locales/zh-TW/settings.json b/apps/ui/public/locales/zh-TW/settings.json index 64fe5ae9..5a56df1b 100644 --- a/apps/ui/public/locales/zh-TW/settings.json +++ b/apps/ui/public/locales/zh-TW/settings.json @@ -93,8 +93,8 @@ "aiAgent": { "title": "AI 代理", "description": "設定授予 AI 助理與 MCP 用戶端的權限", - "metadataWrite": "允許無需確認即寫入元資料", - "metadataWriteDescription": "AI 助理與 MCP 用戶端將可以直接更新媒體元資料(例如重新命名資料夾/檔案),無需每次確認。" + "metadataWrite": "允許無需確認即寫入中繼資料", + "metadataWriteDescription": "AI 助理與 MCP 用戶端將可以直接更新媒體中繼資料(例如重新命名資料夾/檔案),無需每次確認。" }, "feedback": { "title": "意見回饋", diff --git a/docs/superpowers/specs/2026-09-06-ai-agent-permissions-design.md b/docs/superpowers/specs/2026-09-06-ai-agent-permissions-design.md index 9e39b296..4eb48b8e 100644 --- a/docs/superpowers/specs/2026-09-06-ai-agent-permissions-design.md +++ b/docs/superpowers/specs/2026-09-06-ai-agent-permissions-design.md @@ -95,8 +95,8 @@ Follows the `GeneralSettings` pattern: | `sidebar.aiAgent` | `AI Agent` | `AI 智能体` | `AI 代理` | | `aiAgent.title` | `AI Agent` | `AI 智能体` | `AI 代理` | | `aiAgent.description` | `Configure permissions granted to AI Assistant and MCP clients` | `配置授予 AI 助手和 MCP 客户端的权限` | `設定授予 AI 助理與 MCP 用戶端的權限` | -| `aiAgent.metadataWrite` | `Allow metadata writes without confirmation` | `允许无需确认即写入元数据` | `允許無需確認即寫入元資料` | -| `aiAgent.metadataWriteDescription` | `AI Assistant and MCP clients can update media metadata (e.g. rename folders/files) without asking for confirmation each time.` | `AI 助手和 MCP 客户端将可以直接更新媒体元数据(例如重命名文件夹/文件),无需每次确认。` | `AI 助理與 MCP 用戶端將可以直接更新媒體元資料(例如重新命名資料夾/檔案),無需每次確認。` | +| `aiAgent.metadataWrite` | `Allow metadata writes without confirmation` | `允许无需确认即写入元数据` | zh-HK:`允許無需確認即寫入元資料`;zh-TW:`允許無需確認即寫入中繼資料` | +| `aiAgent.metadataWriteDescription` | `AI Assistant and MCP clients can update media metadata (e.g. rename folders/files) without asking for confirmation each time.` | `AI 助手和 MCP 客户端将可以直接更新媒体元数据(例如重命名文件夹/文件),无需每次确认。` | zh-HK:`...更新媒體元資料...`;zh-TW:`AI 助理與 MCP 用戶端將可以直接更新媒體中繼資料(例如重新命名資料夾/檔案),無需每次確認。` | Save button reuses the existing common-namespace key: `t('save', { ns: 'common' })` (same as `GeneralSettings`). From 74fdfbc980f70c981778cb5705102bafc30548a3 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 17:59:39 +0800 Subject: [PATCH 31/83] fix(ui): remove unused eslint-disable directive in AiAgentSettings Co-Authored-By: Claude Opus 4.7 --- apps/ui/src/components/ui/settings/AiAgentSettings.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/ui/src/components/ui/settings/AiAgentSettings.tsx b/apps/ui/src/components/ui/settings/AiAgentSettings.tsx index 1b608682..a0498b12 100644 --- a/apps/ui/src/components/ui/settings/AiAgentSettings.tsx +++ b/apps/ui/src/components/ui/settings/AiAgentSettings.tsx @@ -24,9 +24,7 @@ export function AiAgentSettings() { // Reset form when userConfig changes useEffect(() => { - /* eslint-disable react-hooks/set-state-in-effect */ setMetadataWrite(initialValues.metadataWrite) - /* eslint-enable react-hooks/set-state-in-effect */ }, [initialValues]) const hasChanges = metadataWrite !== initialValues.metadataWrite From d2b73d12459cb2300ab98e4e81eb95e33a2662c6 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 17:59:48 +0800 Subject: [PATCH 32/83] docs: mark ai-agent permissions design implemented Co-Authored-By: Claude Opus 4.7 --- .../superpowers/specs/2026-09-06-ai-agent-permissions-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-09-06-ai-agent-permissions-design.md b/docs/superpowers/specs/2026-09-06-ai-agent-permissions-design.md index 4eb48b8e..74b83966 100644 --- a/docs/superpowers/specs/2026-09-06-ai-agent-permissions-design.md +++ b/docs/superpowers/specs/2026-09-06-ai-agent-permissions-design.md @@ -3,7 +3,7 @@ This design document describes the high level design of a feature. The design document is golden source and referenced by one or more features. -> **Status:** Pending implementation. +> **Status:** Implemented (2026-09-06). Commits: `078954e5`, `54572723`, `0c432e58`, `a52b75bb`, `74fdfbc9`. ## 1. Background From 0a85aa6bfab87f0e7d8defa88b00f3048ed2e0d7 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 18:08:21 +0800 Subject: [PATCH 33/83] fix(core): register aiAgent key in user config validation --- apps/core/src/pipeline/userConfigDefaults.ts | 1 + .../src/pipeline/userConfigValidation.test.ts | 40 +++++++++++++++++++ .../core/src/pipeline/userConfigValidation.ts | 16 ++++++++ 3 files changed, 57 insertions(+) create mode 100644 apps/core/src/pipeline/userConfigValidation.test.ts diff --git a/apps/core/src/pipeline/userConfigDefaults.ts b/apps/core/src/pipeline/userConfigDefaults.ts index de33978d..5b3f96f9 100644 --- a/apps/core/src/pipeline/userConfigDefaults.ts +++ b/apps/core/src/pipeline/userConfigDefaults.ts @@ -36,6 +36,7 @@ export const USER_CONFIG_KEY_FLAGS = { videoCaptionerExecutablePath: true, useBundledFfmpegForVideoCaptioner: true, quickjsExecutablePath: true, + aiAgent: true, } as const satisfies { [K in keyof UserConfig]: true }; export const USER_CONFIG_KEYS = Object.keys(USER_CONFIG_KEY_FLAGS) as (keyof UserConfig)[]; diff --git a/apps/core/src/pipeline/userConfigValidation.test.ts b/apps/core/src/pipeline/userConfigValidation.test.ts new file mode 100644 index 00000000..abf44500 --- /dev/null +++ b/apps/core/src/pipeline/userConfigValidation.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import type { UserConfig } from "@smm/types"; +import { validateUserConfig, validateUserConfigValue } from "./userConfigValidation"; + +describe("validateUserConfigValue aiAgent", () => { + it("returns undefined for missing aiAgent", () => { + expect(validateUserConfigValue("aiAgent", undefined)).toBeUndefined(); + }); + + it("validates and preserves granted permissions", () => { + expect(validateUserConfigValue("aiAgent", { permissions: ["metadata.write"] })).toEqual({ + permissions: ["metadata.write"], + }); + }); + + it("defaults permissions to empty array when absent", () => { + expect(validateUserConfigValue("aiAgent", {})).toEqual({ permissions: [] }); + }); + + it("rejects non-array permissions", () => { + expect(() => + validateUserConfigValue("aiAgent", { permissions: "metadata.write" }), + ).toThrow(); + }); +}); + +describe("validateUserConfig", () => { + it("preserves aiAgent instead of stripping it", () => { + const validated = validateUserConfig({ + folders: [], + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: "plex", + aiAgent: { permissions: ["metadata.write"] }, + } as UserConfig); + expect(validated.aiAgent).toEqual({ permissions: ["metadata.write"] }); + }); +}); diff --git a/apps/core/src/pipeline/userConfigValidation.ts b/apps/core/src/pipeline/userConfigValidation.ts index a54b5cc0..3c3d6d70 100644 --- a/apps/core/src/pipeline/userConfigValidation.ts +++ b/apps/core/src/pipeline/userConfigValidation.ts @@ -1,5 +1,7 @@ import { isPreferMediaLanguage } from "@smm/utils/locale"; import type { + AiAgentConfig, + AiAgentPermission, LanguageCode, OpenAICompatibleConfig, PrimaryDatabase, @@ -47,6 +49,16 @@ function validateTvdbConfig(value: unknown): TVDBConfig { }; } +function validateAiAgentConfig(value: unknown): AiAgentConfig { + const obj = assertObject(value, "aiAgent"); + return { + permissions: + obj.permissions === undefined + ? [] + : (validateStringArray(obj.permissions, "aiAgent.permissions") as AiAgentPermission[]), + }; +} + function validateAiProvider(value: unknown, index: number): OpenAICompatibleConfig { const obj = assertObject(value, `aiProviders[${index}]`); return { @@ -212,6 +224,10 @@ export function validateUserConfigValue( } return value as UserConfig[K]; } + case "aiAgent": { + if (value === undefined) return undefined as UserConfig[K]; + return validateAiAgentConfig(value) as UserConfig[K]; + } default: { const _exhaustive: never = key; throw new Error(`Unknown config key: ${String(_exhaustive)}`); From 8a616f7c07a440ef93bd89c7b991903a52e85ee7 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 18:08:21 +0800 Subject: [PATCH 34/83] test(ui): add aiAgent round-trip coverage --- apps/ui/src/api/readUserConfig.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/ui/src/api/readUserConfig.test.ts b/apps/ui/src/api/readUserConfig.test.ts index 2b423cef..27a801f4 100644 --- a/apps/ui/src/api/readUserConfig.test.ts +++ b/apps/ui/src/api/readUserConfig.test.ts @@ -66,6 +66,16 @@ describe('normalizeUserConfig', () => { expect(normalized.aiAgent).toEqual({ permissions: ['metadata.write'] }) }) + + it('keeps aiAgent through a serialize → normalize round trip', () => { + const normalized = normalizeUserConfig({ + aiAgent: { permissions: [AI_AGENT_PERMISSIONS.metadataWrite] }, + }) + const persisted = JSON.parse(JSON.stringify(normalized)) + expect(normalizeUserConfig(persisted).aiAgent).toEqual({ + permissions: ['metadata.write'], + }) + }) }) describe('readUserConfigFromUserDataDir', () => { From 3c333122298678c7e60f6ca953907d79a934df9a Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 18:10:43 +0800 Subject: [PATCH 35/83] docs: record core aiAgent key registration in design spec Co-Authored-By: Claude Opus 4.7 --- .../specs/2026-09-06-ai-agent-permissions-design.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-09-06-ai-agent-permissions-design.md b/docs/superpowers/specs/2026-09-06-ai-agent-permissions-design.md index 74b83966..571aba56 100644 --- a/docs/superpowers/specs/2026-09-06-ai-agent-permissions-design.md +++ b/docs/superpowers/specs/2026-09-06-ai-agent-permissions-design.md @@ -3,7 +3,7 @@ This design document describes the high level design of a feature. The design document is golden source and referenced by one or more features. -> **Status:** Implemented (2026-09-06). Commits: `078954e5`, `54572723`, `0c432e58`, `a52b75bb`, `74fdfbc9`. +> **Status:** Implemented (2026-09-06). Commits: `078954e5`, `54572723`, `0c432e58`, `a52b75bb`, `74fdfbc9`, `0a85aa6b`, `8a616f7c`. ## 1. Background @@ -14,13 +14,13 @@ This feature adds a new **"AI Agent"** settings category with a **`permissions`* **Scope for this feature (locked):** - **In scope:** new `AiAgentSettings` UI component; new "AI Agent" tab in the config panel; read/write support for `aiAgent.permissions` in user config (`smm.json`); i18n for all 4 locales. -- **Out of scope:** enforcement (skipping confirmation prompts in AI tools / MCP handlers); server-side (`core-routes` / CLI / `apps/core`) normalization of `aiAgent`; any permission beyond `metadata.write`. +- **Out of scope:** enforcement (skipping confirmation prompts in AI tools / MCP handlers); server-side (`core-routes` / CLI) normalization of `aiAgent`; any permission beyond `metadata.write`. (`apps/core` does register the `aiAgent` key in its config validation so core-side writes preserve it — see Decisions.) **Decisions (locked):** - Config shape: nested `aiAgent: { permissions: AiAgentPermission[] }` on `UserConfig` (user chose nested over flat `permissions`). - UI form: a single checkbox toggling the one supported permission; a permission list table is deferred until a second permission exists. -- Approach: renderer-only plumbing (Approach A). Server-side defaults/normalization and a shared `hasAiAgentPermission()` helper are deferred to the enforcement feature. +- Approach: renderer-only plumbing (Approach A). Server-side defaults/normalization and a shared `hasAiAgentPermission()` helper are deferred to the enforcement feature. Exception added during final review: `apps/core` registers `aiAgent` in its user-config validation allowlist — without it, core's whole-file smm.json writer would silently strip `aiAgent` on core-side writes (commits `0a85aa6b`, `8a616f7c`). - Default is `permissions: []` — no bypass unless the user explicitly grants it (safe default). ## 2. Architecture @@ -33,6 +33,7 @@ Primary work is in `apps/ui` and `packages/types`. No new API routes; the existi |---------------|--------| | `packages/types` | Add `AI_AGENT_PERMISSIONS` constant, `AiAgentPermission` type, `AiAgentConfig` interface, `aiAgent?: AiAgentConfig` on `UserConfig` | | `apps/ui` | `AiAgentSettings` component, "AI Agent" tab in `config-panel.tsx`, `aiAgent` defaults in `normalizeUserConfig`, locales | +| `apps/core` | Register `aiAgent` in `USER_CONFIG_KEY_FLAGS` + `validateUserConfig` switch so core writes preserve the field | ### 2.2 App Level Architecture From ed76cc5bef043d1ed47d2555da9ebe021491b991 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 19:21:54 +0800 Subject: [PATCH 36/83] docs: add ai rename plan metadata.write enforcement design --- ...-plan-metadata-write-enforcement-design.md | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-06-ai-plan-metadata-write-enforcement-design.md diff --git a/docs/superpowers/specs/2026-09-06-ai-plan-metadata-write-enforcement-design.md b/docs/superpowers/specs/2026-09-06-ai-plan-metadata-write-enforcement-design.md new file mode 100644 index 00000000..d2f3f113 --- /dev/null +++ b/docs/superpowers/specs/2026-09-06-ai-plan-metadata-write-enforcement-design.md @@ -0,0 +1,167 @@ +# AI Rename Plan — metadata.write Enforcement + Browser-side Plan Pulling + +This design document describes the high level design of a feature. +The design document is golden source and referenced by one or more features. + +> **Status:** Pending implementation. + +> **Source spec:** uncommitted diff in `docs/dev/rename-episodes.md` (MCP Tool and AI Tool section + Browser-side Pulling section). +> **Builds on:** [AI Agent Settings — Permissions Config](./2026-09-06-ai-agent-permissions-design.md) (Phase 1: `aiAgent.permissions` config + settings UI). This feature is the enforcement follow-up that Phase 1 deferred. + +## 1. Background + +Phase 1 added the `aiAgent.permissions` user config (`metadata.write`) and its settings UI, but nothing reads the permission yet. AI Assistant and MCP clients that create rename-episode plans always leave the plan pending and require the user to approve it in the SMM UI — even when the user has granted `metadata.write`. + +This feature implements two things: + +1. **Enforcement** — when `metadata.write` is granted, core-routes AI rename-plan tools apply the plan automatically; no UI approval. When not granted (or unknown), the plan stays pending and the UI shows the confirm prompt (existing behavior). +2. **Browser-side pulling** — a backgrounded browser may miss server-pushed events, so the UI refetches pending plans at two trigger points: sidebar folder select, and browser reactivation with a folder already selected. + +**Scope (locked):** + +- **In scope:** auto-apply gating in the core-routes tool builder (covers **MCP tool** + **backend AI chat tool**); fallback to pending on apply failure; browser-side pulling at the two trigger points. +- **Out of scope:** the frontend AI tool (`POST /api/create-rename-episode-plan`) and the debug route — they keep the manual-approval flow; ohos auto-apply wiring (ohos has no Core instance to apply with); any permission beyond `metadata.write`. + +**Decisions (locked):** + +- Gating seam: tool-builder dependency injection (Approach A) — optional `getUserConfig` + `applyRenameEpisodePlan` deps on `buildCreateRenameEpisodePlanTool`. Not pipeline-level (would leak auto-apply into the frontend HTTP route) and not per-server (duplicated, message logic splits). +- Auto-apply requires **both** deps present **and** permission granted. Any missing piece → pending flow (safe default, consistent with Phase 1's `permissions: []`). +- Apply failure → **fall back to pending** (plan stays on disk, `RenameFilesPlanReady` emitted, agent message asks user to review in UI). Never a hard error to the agent, never a rejected plan. +- One spec + one plan for gating and pulling. +- Permission is checked at plan-creation time. MCP path reads config fresh per tool call; chat path uses the per-request `UserConfig` snapshot (a permission granted mid-chat-request is not retroactive within that request). + +## 2. Architecture + +### 2.1 Project Level + +| Package / App | Change | +|---|---| +| `packages/types` | `hasAiAgentPermission()` helper; `RENAME_PLAN_AUTO_APPLIED_MESSAGE` in `planTaskMessages.ts` | +| `packages/core-routes` | `buildCreateRenameEpisodePlanTool` gains optional `extra` deps + auto-apply flow; `McpConfig.applyRenameEpisodePlan`; `ChatToolsExtraDeps.applyRenameEpisodePlan`; MCP handler wiring | +| `apps/cli` | Wire `applyRenameEpisodePlan: (plan) => getCore().applyPlan(plan)` in `buildMcpConfig()` and `handleChatRequest()` | +| `apps/ui` | `applyFolderClick` invalidates plans query; new `usePlansPullOnVisible` hook | + +### 2.2 Permission helper (`packages/types/types.ts`) + +```ts +export function hasAiAgentPermission( + userConfig: UserConfig | undefined, + permission: AiAgentPermission, +): boolean { + return userConfig?.aiAgent?.permissions?.includes(permission) ?? false; +} +``` + +Undefined config, missing `aiAgent`, or empty/missing permissions → `false`. + +### 2.3 Tool builder flow (`packages/core-routes/src/tools/createRenameEpisodePlan.ts`) + +`buildCreateRenameEpisodePlanTool` gains an optional 6th parameter: + +```ts +export interface CreateRenameEpisodePlanToolExtra { + /** Reads current user config; used for the metadata.write permission check. */ + getUserConfig?: () => Promise; + /** Applies (renames) a created plan. Hosts without a Core instance omit it. */ + applyRenameEpisodePlan?: (plan: RenameFilesPlan) => Promise; +} +``` + +`RenameFilesPlan` comes from `@smm/types/RenameFilesPlan`. + +After the plan is created by `createRenameEpisodePlanPipeline` (unchanged), `execute` becomes: + +1. If `getUserConfig` **and** `applyRenameEpisodePlan` are present → `hasAiAgentPermission(await getUserConfig(), AI_AGENT_PERMISSIONS.metadataWrite)`. If the config read rejects, treat as not granted. +2. Permission granted → `await applyRenameEpisodePlan(plan)`: + - **Success** → emit `mediaMetadataUpdated` with `data: { folderPath: plan.mediaFolderPath }` (same event the manual apply route broadcasts; `MediaMetadataUpdatedEventData.folderPath` is optional), log at info, return `toolOk({ message: RENAME_PLAN_AUTO_APPLIED_MESSAGE, planId: plan.id })`. **Do not** emit `RenameFilesPlanReady` — nothing is pending. + - **Failure** → log at warn, fall through to the pending flow. +3. Pending flow (unchanged): emit `RenameFilesPlanReady`, return `toolOk({ message: END_PLAN_TASK_SUCCESS_MESSAGE, planId: plan.id })`. + +**New message constant** (`packages/types/ai-tools/planTaskMessages.ts`): + +```ts +/** + * Returned to the AI when the rename plan was applied automatically + * because the user granted the `metadata.write` permission. + */ +export const RENAME_PLAN_AUTO_APPLIED_MESSAGE = + "Rename plan applied automatically (metadata.write permission granted). No user approval needed."; +``` + +**Why not invalidate plans on auto-apply success:** the UI never learned about the plan (no `RenameFilesPlanReady`), so there is normally nothing to refresh. The tiny race (a plans fetch landing between create and apply, milliseconds apart) leaves a stale pending entry that the pulling triggers clean up. + +### 2.4 Wiring + +Both surfaces follow the existing host-injection patterns: + +| Surface | `getUserConfig` | `applyRenameEpisodePlan` | +|---|---|---| +| MCP tool — `McpConfig` (packages/core-routes/src/mcp/types.ts) | already a required field | **new optional field** `applyRenameEpisodePlan?: (plan: RenameFilesPlan) => Promise` | +| MCP handler — `registerCreateRenameEpisodePlanTool` (packages/core-routes/src/mcp/toolHandlers/createRenameEpisodePlan.ts) | pass `config.getUserConfig` | pass `config.applyRenameEpisodePlan` | +| Backend AI tools — `ChatToolsExtraDeps` (packages/core-routes/src/tools/index.ts) | registry's per-request snapshot: `getUserConfig: () => Promise.resolve(userConfig)` | **new optional field** `applyRenameEpisodePlan?`, passed through from `extra` | +| CLI MCP — `buildMcpConfig()` (apps/cli/src/mcp/mcp.ts) | existing | add `applyRenameEpisodePlan: (plan) => getCore().applyPlan(plan)` — same pattern as `renameEpisodeFile` etc. | +| CLI chat — `handleChatRequest()` (apps/cli/src/route/chatRoute.ts) | existing (via `doChat` → `createChatTools`) | add `applyRenameEpisodePlan: (plan) => getCore().applyPlan(plan)` to the extras object | +| ohos MCP / ohos chat | existing | **omit** — no Core instance; pending flow always, even with permission granted (documented degradation) | + +`Core.applyPlan(plan)` signature (apps/core/src/Core.ts): `applyPlan(plan: Plan, data?: ApplyPlanData): Promise` — full plan apply is exactly the first argument. + +**Untouched surfaces:** `apps/ui/src/ai/tools/CreateRenameEpisodePlan.tsx` (frontend AI tool via `POST /api/create-rename-episode-plan`) and `POST /debug/createRenameEpisodePlan` — both call the pipeline directly, never the tool builder, so they keep the manual-approval flow by construction. + +### 2.5 Browser-side pulling (apps/ui) + +The AI rename prompt derives from the plans query (`usePlansQuery` → `AiBasedRenameFilePrompt` via `TvShowAppPlanPromptContext`), so pulling reduces to refetching that query at the two trigger points. + +**Trigger 1 — sidebar folder select.** `applyFolderClick` in `apps/ui/src/stores/uiMediaFolderStore.ts` calls: + +```ts +void queryClient.invalidateQueries({ queryKey: [PLANS_QUERY_ROOT] }) +``` + +`queryClient` is imported from `@/lib/queryClient` (module singleton, same pattern as `useTvShowWebSocketEvents.ts`). Folder *switches* already refetch via query-key change; doing it in the store action additionally covers re-selecting the same folder. + +**Trigger 2 — browser reactivates.** New hook `apps/ui/src/hooks/plans/usePlansPullOnVisible.ts`: + +```ts +export function usePlansPullOnVisible() { + useEffect(() => { + const onVisibilityChange = () => { + if (document.visibilityState !== "visible") return; + if (!useUIMediaFolderStore.getState().selectedFolder) return; + void queryClient.invalidateQueries({ queryKey: [PLANS_QUERY_ROOT] }); + }; + document.addEventListener("visibilitychange", onVisibilityChange); + return () => document.removeEventListener("visibilitychange", onVisibilityChange); + }, []); +} +``` + +Mounted next to the existing `useTvShowWebSocketEvents()` call in the TV panel. No new event types, no server changes. + +## 3. Error Handling + +| Failure | Behavior | +|---|---| +| `getUserConfig()` rejects | Treat as not granted → pending flow | +| `applyRenameEpisodePlan` rejects (file locked, target exists, ...) | Warn log → pending flow (plan already on disk, stays pending) | +| Deps absent (ohos) | Pending flow, unchanged behavior | +| Permission absent/empty | Pending flow (Phase 1 default) | + +The plan file written by the pipeline before the apply attempt is the pending-flow artifact; the fallback needs no cleanup — the plan is exactly what the pending flow would have produced. + +## 4. Testing + +| Area | Test | +|---|---| +| `hasAiAgentPermission` | packages/types: undefined config / missing aiAgent / empty / granted / other-permission-only → correct boolean | +| Tool builder (packages/core-routes, extend `createRenameEpisodePlan.test.ts`) | permission granted + applier → applies, emits `mediaMetadataUpdated`, returns `RENAME_PLAN_AUTO_APPLIED_MESSAGE`, no `RenameFilesPlanReady`; applier rejects → pending flow + `RenameFilesPlanReady`; permission missing → pending; deps absent → pending; `getUserConfig` rejects → pending | +| Pulling hook (apps/ui, jsdom) | visibilitychange to `visible` with folder selected → invalidates plans query; hidden or no folder → no invalidation | +| `applyFolderClick` (apps/ui) | invalidates plans query on folder click | +| E2E | extend `apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts` per `docs/dev/rename-episodes.md` testing table | + +## 5. References + +[AI Agent Settings — Permissions Config](./2026-09-06-ai-agent-permissions-design.md) + +[Rename Episodes](../../dev/rename-episodes.md) + +[Manage Plan](../../dev/manage-plan.md) From 98f526b701732b23b8bb7d2b7ee00d736fc81050 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 19:49:53 +0800 Subject: [PATCH 37/83] docs: add ai rename plan enforcement implementation plan --- ...9-06-ai-plan-metadata-write-enforcement.md | 857 ++++++++++++++++++ 1 file changed, 857 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-06-ai-plan-metadata-write-enforcement.md diff --git a/docs/superpowers/plans/2026-09-06-ai-plan-metadata-write-enforcement.md b/docs/superpowers/plans/2026-09-06-ai-plan-metadata-write-enforcement.md new file mode 100644 index 00000000..85b1fe31 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-ai-plan-metadata-write-enforcement.md @@ -0,0 +1,857 @@ +# AI Rename Plan metadata.write Enforcement + Plan Pulling Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When the user grants `aiAgent.permissions: ["metadata.write"]`, the core-routes AI rename-plan tools (MCP tool + backend AI chat tool) apply the plan automatically; otherwise — and on any failure — the plan stays pending for UI approval. The UI also refetches pending plans at two trigger points (sidebar folder select, browser reactivation). + +**Architecture:** Permission check + apply runner are injected as optional deps into `buildCreateRenameEpisodePlanTool` (packages/core-routes); hosts with a Core instance (CLI) wire `getCore().applyPlan`, ohos omits it and keeps the pending flow. Pulling reuses the plans TanStack query: invalidate `PLANS_QUERY_ROOT` in the sidebar store action and from a new `visibilitychange` hook mounted in `useAiBasedRenameFilesFlow`. + +**Tech Stack:** TypeScript, vitest (node + jsdom), React 18 + TanStack Query + zustand, pnpm workspaces. + +**Spec:** `docs/superpowers/specs/2026-09-06-ai-plan-metadata-write-enforcement-design.md` + +**Environment notes:** +- Windows + bash shell. Run tests per package: `cd packages/types && pnpm test`, `cd packages/core-routes && pnpm test`, `cd apps/ui && pnpm test `. +- **The working tree contains the user's unrelated uncommitted changes** (`apps/ui/src/components/tv/TvShowPanel.tsx` modified, `docs/dev/rename-episodes.md` modified, `docs/dev/user-config.md` untracked). NEVER stage, revert, or commit those files. Only `git add` the exact files listed in each commit step. +- Out of plan scope: extending `apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts` (needs a running CLI stack + AI provider; flagged to the user at the end). + +--- + +### Task 1: `hasAiAgentPermission` helper + +**Files:** +- Modify: `packages/types/types.ts` (insert after the `AiAgentConfig` interface, ~line 67, before the `UserConfig` interface) +- Test: `packages/types/hasAiAgentPermission.test.ts` (create) + +- [ ] **Step 1: Write the failing test** + +Create `packages/types/hasAiAgentPermission.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { + AI_AGENT_PERMISSIONS, + hasAiAgentPermission, + type UserConfig, +} from "./types"; + +function configWith(permissions: string[] | undefined): UserConfig { + return { aiAgent: { permissions } } as unknown as UserConfig; +} + +describe("hasAiAgentPermission", () => { + it("returns false for undefined config", () => { + expect( + hasAiAgentPermission(undefined, AI_AGENT_PERMISSIONS.metadataWrite), + ).toBe(false); + }); + + it("returns false when aiAgent is missing", () => { + expect( + hasAiAgentPermission({} as UserConfig, AI_AGENT_PERMISSIONS.metadataWrite), + ).toBe(false); + }); + + it("returns false when permissions are missing", () => { + expect( + hasAiAgentPermission( + configWith(undefined), + AI_AGENT_PERMISSIONS.metadataWrite, + ), + ).toBe(false); + }); + + it("returns false when permissions are empty", () => { + expect( + hasAiAgentPermission( + configWith([]), + AI_AGENT_PERMISSIONS.metadataWrite, + ), + ).toBe(false); + }); + + it("returns true when the permission is granted", () => { + expect( + hasAiAgentPermission( + configWith([AI_AGENT_PERMISSIONS.metadataWrite]), + AI_AGENT_PERMISSIONS.metadataWrite, + ), + ).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd packages/types && pnpm test hasAiAgentPermission.test.ts` +Expected: FAIL — `hasAiAgentPermission` is not exported by `./types`. + +- [ ] **Step 3: Implement the helper** + +In `packages/types/types.ts`, insert immediately after the closing brace of `export interface AiAgentConfig { ... }` (the block containing `permissions?: AiAgentPermission[]`, ending near line 67), before the `UserConfig` interface: + +```ts +/** + * Whether the user config grants the given AI Agent permission. + * Missing config or permissions means nothing is granted. + */ +export function hasAiAgentPermission( + userConfig: UserConfig | undefined, + permission: AiAgentPermission, +): boolean { + return userConfig?.aiAgent?.permissions?.includes(permission) ?? false; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/types && pnpm test hasAiAgentPermission.test.ts` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/types/types.ts packages/types/hasAiAgentPermission.test.ts +git commit -m "feat(types): add hasAiAgentPermission helper" +``` + +--- + +### Task 2: Auto-apply message constant + tool builder flow + +**Files:** +- Modify: `packages/types/ai-tools/planTaskMessages.ts` (append constant) +- Modify: `packages/core-routes/src/tools/createRenameEpisodePlan.ts` (imports, `CreateRenameEpisodePlanToolExtra`, 6th param, execute flow) +- Test: `packages/core-routes/src/tools/createRenameEpisodePlan.test.ts` (extend) + +- [ ] **Step 1: Write the failing tests** + +In `packages/core-routes/src/tools/createRenameEpisodePlan.test.ts`, extend the imports at the top and add a new `describe` block inside the top-level `describe` (after the existing `"prefers injected broadcast over defaultBroadcast"` test, before its closing `});`): + +```ts +import { AI_AGENT_PERMISSIONS, type UserConfig } from "@smm/types"; +import { + END_PLAN_TASK_SUCCESS_MESSAGE, + RENAME_PLAN_AUTO_APPLIED_MESSAGE, +} from "@smm/types/ai-tools/planTaskMessages"; +import { + MEDIA_METADATA_UPDATED_EVENT, + RenameFilesPlanReady, +} from "@smm/types/event-types"; +``` + +(`END_PLAN_TASK_SUCCESS_MESSAGE` and `RenameFilesPlanReady` are already imported — merge, don't duplicate.) + +```ts + describe("auto-apply (metadata.write)", () => { + const folder = "/media/show"; + const args = { + mediaFolderPath: folder, + files: [ + { + from: `${folder}/S01E01.mkv`, + to: `${folder}/Show - S01E01.mkv`, + }, + ], + }; + + function grantedConfig(): UserConfig { + return { + aiAgent: { permissions: [AI_AGENT_PERMISSIONS.metadataWrite] }, + } as unknown as UserConfig; + } + + function expectPendingPlanId(result: unknown): { planId: string } { + if (!("planId" in result)) { + throw new Error((result as { error: string }).error); + } + return result as { planId: string }; + } + + it("applies the plan and reports auto-apply when permission is granted", async () => { + const broadcast = vi.fn(); + const applyRenameEpisodePlan = vi.fn(async () => {}); + const tool = buildCreateRenameEpisodePlanTool( + "/app-data", + createMockFs(folder), + broadcast, + undefined, + undefined, + { + getUserConfig: async () => grantedConfig(), + applyRenameEpisodePlan, + }, + ); + + const result = expectPendingPlanId(await tool.execute(args)); + + expect(result.planId).toEqual(expect.any(String)); + expect(applyRenameEpisodePlan).toHaveBeenCalledTimes(1); + expect(broadcast).toHaveBeenCalledWith({ + event: MEDIA_METADATA_UPDATED_EVENT, + data: { folderPath: folder }, + }); + expect(broadcast).not.toHaveBeenCalledWith( + expect.objectContaining({ event: RenameFilesPlanReady.event }), + ); + }); + + it("falls back to a pending plan when the apply runner fails", async () => { + const broadcast = vi.fn(); + const tool = buildCreateRenameEpisodePlanTool( + "/app-data", + createMockFs(folder), + broadcast, + undefined, + undefined, + { + getUserConfig: async () => grantedConfig(), + applyRenameEpisodePlan: vi.fn(async () => { + throw new Error("EBUSY: resource busy"); + }), + }, + ); + + const result = expectPendingPlanId(await tool.execute(args)); + + expect(result.planId).toEqual(expect.any(String)); + expect(broadcast).toHaveBeenCalledWith({ + event: RenameFilesPlanReady.event, + data: { + taskId: result.planId, + planFilePath: `/app-data/plans/${result.planId}.plan.json`, + }, + }); + }); + + it("keeps the plan pending when the permission is not granted", async () => { + const broadcast = vi.fn(); + const applyRenameEpisodePlan = vi.fn(async () => {}); + const tool = buildCreateRenameEpisodePlanTool( + "/app-data", + createMockFs(folder), + broadcast, + undefined, + undefined, + { + getUserConfig: async () => + ({ aiAgent: { permissions: [] } }) as unknown as UserConfig, + applyRenameEpisodePlan, + }, + ); + + const result = expectPendingPlanId(await tool.execute(args)); + + expect(result.planId).toEqual(expect.any(String)); + expect(applyRenameEpisodePlan).not.toHaveBeenCalled(); + expect(broadcast).toHaveBeenCalledWith( + expect.objectContaining({ event: RenameFilesPlanReady.event }), + ); + }); + + it("keeps the plan pending when reading user config fails", async () => { + const broadcast = vi.fn(); + const applyRenameEpisodePlan = vi.fn(async () => {}); + const tool = buildCreateRenameEpisodePlanTool( + "/app-data", + createMockFs(folder), + broadcast, + undefined, + undefined, + { + getUserConfig: async () => { + throw new Error("config read failed"); + }, + applyRenameEpisodePlan, + }, + ); + + const result = expectPendingPlanId(await tool.execute(args)); + + expect(result.planId).toEqual(expect.any(String)); + expect(applyRenameEpisodePlan).not.toHaveBeenCalled(); + expect(broadcast).toHaveBeenCalledWith( + expect.objectContaining({ event: RenameFilesPlanReady.event }), + ); + }); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd packages/core-routes && pnpm test createRenameEpisodePlan.test.ts` +Expected: FAIL — `RENAME_PLAN_AUTO_APPLIED_MESSAGE` is not exported; the 6th argument is a TS error / auto-apply expectations fail. + +- [ ] **Step 3: Add the message constant** + +In `packages/types/ai-tools/planTaskMessages.ts`, append at the end: + +```ts +/** + * Returned to the AI when the rename plan was applied automatically + * because the user granted the `metadata.write` permission. + */ +export const RENAME_PLAN_AUTO_APPLIED_MESSAGE = + "Rename plan applied automatically (metadata.write permission granted). No user approval needed."; +``` + +- [ ] **Step 4: Implement the tool builder flow** + +In `packages/core-routes/src/tools/createRenameEpisodePlan.ts`: + +(a) Update imports — add `hasAiAgentPermission` / `AI_AGENT_PERMISSIONS` / `UserConfig` / `RenameFilesPlan` / `MEDIA_METADATA_UPDATED_EVENT` / `RENAME_PLAN_AUTO_APPLIED_MESSAGE` (merge with existing import statements): + +```ts +import { + AI_AGENT_PERMISSIONS, + hasAiAgentPermission, + type UserConfig, +} from "@smm/types"; +import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan"; +import { END_PLAN_TASK_SUCCESS_MESSAGE, RENAME_PLAN_AUTO_APPLIED_MESSAGE } from "@smm/types/ai-tools/planTaskMessages"; +import { + MEDIA_METADATA_UPDATED_EVENT, + RenameFilesPlanReady, + type RenameFilesPlanReadyRequestData, +} from "@smm/types/event-types"; +``` + +(b) Add the extra-deps interface (above `buildCreateRenameEpisodePlanTool`): + +```ts +/** + * Optional dependencies for the `metadata.write` auto-apply flow. + * Auto-apply requires BOTH deps: without `getUserConfig` the tool + * cannot verify the permission; without `applyRenameEpisodePlan` + * (hosts without a Core instance, e.g. ohos) it cannot apply. + */ +export interface CreateRenameEpisodePlanToolExtra { + /** Reads the current user config for the metadata.write permission check. */ + getUserConfig?: () => Promise; + /** Applies (renames) a created plan. Host Core runner, e.g. `Core.applyPlan`. */ + applyRenameEpisodePlan?: (plan: RenameFilesPlan) => Promise; +} +``` + +(c) Add the 6th parameter and restructure `execute`. The section after the pipeline call changes from emit-pending-return to: + +```ts +export function buildCreateRenameEpisodePlanTool( + appDataDir: string, + fs: ChatFs, + broadcast?: (message: WebSocketMessage) => void, + logger?: CoreRoutesLogger, + abortSignal?: AbortSignal, + extra?: CreateRenameEpisodePlanToolExtra, +) { +``` + +and inside `execute`, replace everything after the `const plan = await createRenameEpisodePlanPipeline(...)` statement with: + +```ts + if (extra?.getUserConfig && extra.applyRenameEpisodePlan) { + try { + const userConfig = await extra.getUserConfig(); + if ( + hasAiAgentPermission( + userConfig, + AI_AGENT_PERMISSIONS.metadataWrite, + ) + ) { + await extra.applyRenameEpisodePlan(plan); + emit({ + event: MEDIA_METADATA_UPDATED_EVENT, + data: { folderPath: plan.mediaFolderPath }, + }); + logger?.info( + { + planId: plan.id, + folderPath: plan.mediaFolderPath, + fileCount: plan.files.length, + }, + `[tool][${CREATE_RENAME_EPISODE_PLAN}] Plan applied automatically`, + ); + return toolOk({ + message: RENAME_PLAN_AUTO_APPLIED_MESSAGE, + planId: plan.id, + }); + } + } catch (error) { + logger?.warn( + { planId: plan.id, error }, + `[tool][${CREATE_RENAME_EPISODE_PLAN}] Auto-apply failed, plan stays pending`, + ); + } + } + + const data: RenameFilesPlanReadyRequestData = { + taskId: plan.id, + planFilePath: planPath(appDataDir, plan.id), + }; + emit({ event: RenameFilesPlanReady.event, data }); + logger?.info( + { + planId: plan.id, + folderPath: plan.mediaFolderPath, + fileCount: plan.files.length, + }, + `[tool][${CREATE_RENAME_EPISODE_PLAN}] Plan created`, + ); + + return toolOk({ + message: END_PLAN_TASK_SUCCESS_MESSAGE, + planId: plan.id, + }); +``` + +The pending flow (emit + log + return) keeps its existing code verbatim — only the auto-apply block is inserted above it. The outer `try`/`catch → formatToolError` stays as-is. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd packages/core-routes && pnpm test createRenameEpisodePlan.test.ts` +Expected: PASS (2 existing + 4 new tests). + +- [ ] **Step 6: Commit** + +```bash +git add packages/types/ai-tools/planTaskMessages.ts packages/core-routes/src/tools/createRenameEpisodePlan.ts packages/core-routes/src/tools/createRenameEpisodePlan.test.ts +git commit -m "feat(core-routes): auto-apply ai rename plans when metadata.write granted" +``` + +--- + +### Task 3: Wiring — MCP config, MCP handler, chat tools registry, CLI + +**Files:** +- Modify: `packages/core-routes/src/mcp/types.ts` (new optional `McpConfig` field) +- Modify: `packages/core-routes/src/mcp/toolHandlers/createRenameEpisodePlan.ts` (pass extra) +- Modify: `packages/core-routes/src/tools/index.ts` (`ChatToolsExtraDeps` field + registry pass-through) +- Modify: `apps/cli/src/mcp/mcp.ts` (`buildMcpConfig` applier) +- Modify: `apps/cli/src/route/chatRoute.ts` (`doChat` extras applier) + +No unit tests — pure wiring, verified by typecheck (Task 2's builder tests cover the behavior). + +- [ ] **Step 1: Add `applyRenameEpisodePlan` to `McpConfig`** + +In `packages/core-routes/src/mcp/types.ts`, add to the imports at the top: + +```ts +import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan"; +``` + +Then insert after the `renameEpisodeFile?` field (the one returning `{ succeeded; failed }`): + +```ts + /** + * Optional runner for `create-rename-episode-plan` auto-apply. + * Hosts that expose Core (e.g. Bun cli) inject `Core.applyPlan`. + * When omitted (e.g. ohos has no Core instance), AI rename plans + * always stay pending for user approval, even when the user granted + * the `metadata.write` permission. + */ + applyRenameEpisodePlan?: (plan: RenameFilesPlan) => Promise; +``` + +- [ ] **Step 2: Pass the extra deps from the MCP handler** + +In `packages/core-routes/src/mcp/toolHandlers/createRenameEpisodePlan.ts`, change the `buildCreateRenameEpisodePlanTool` call to: + +```ts + const tool = buildCreateRenameEpisodePlanTool( + config.appDataDir, + config.fs ?? defaultChatFs(), + config.broadcast, + config.logger, + undefined, + { + getUserConfig: config.getUserConfig, + applyRenameEpisodePlan: config.applyRenameEpisodePlan, + }, + ); +``` + +- [ ] **Step 3: Add the field to `ChatToolsExtraDeps` and pass it in the registry** + +In `packages/core-routes/src/tools/index.ts`: + +(a) Add to the imports: + +```ts +import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan"; +``` + +(b) In `export interface ChatToolsExtraDeps`, add: + +```ts + /** Host Core runner for applying AI rename plans (Bun cli / Electron). */ + applyRenameEpisodePlan?: (plan: RenameFilesPlan) => Promise; +``` + +(c) In `createChatTools`, change the `CREATE_RENAME_EPISODE_PLAN` entry to: + +```ts + [CREATE_RENAME_EPISODE_PLAN]: buildCreateRenameEpisodePlanTool( + config.appDataDir, + fs, + broadcast, + logger, + abortSignal, + { + getUserConfig: () => Promise.resolve(userConfig), + applyRenameEpisodePlan: extra?.applyRenameEpisodePlan, + }, + ), +``` + +- [ ] **Step 4: Wire the CLI MCP server** + +In `apps/cli/src/mcp/mcp.ts`, inside `buildMcpConfig()`'s returned object, add after the `renameEpisodeFile:` line: + +```ts + applyRenameEpisodePlan: (plan) => getCore().applyPlan(plan), +``` + +- [ ] **Step 5: Wire the CLI chat route** + +In `apps/cli/src/route/chatRoute.ts`, inside the `doChat(chatConfig, c.req.raw, { ... })` extras object, add after the `renameEpisodeFile:` line: + +```ts + applyRenameEpisodePlan: (plan) => getCore().applyPlan(plan), +``` + +- [ ] **Step 6: Typecheck** + +Run: `cd packages/core-routes && pnpm typecheck && cd ../../apps/cli && pnpm typecheck` +Expected: both exit 0. (`Plan = RecognizeMediaFilePlan | RenameFilesPlan` makes `getCore().applyPlan(plan)` accept the narrower `RenameFilesPlan` argument.) + +- [ ] **Step 7: Commit** + +```bash +git add packages/core-routes/src/mcp/types.ts packages/core-routes/src/mcp/toolHandlers/createRenameEpisodePlan.ts packages/core-routes/src/tools/index.ts apps/cli/src/mcp/mcp.ts apps/cli/src/route/chatRoute.ts +git commit -m "feat: wire applyRenameEpisodePlan into mcp and chat tools" +``` + +--- + +### Task 4: Pulling trigger 1 — sidebar folder select + +**Files:** +- Modify: `apps/ui/src/stores/uiMediaFolderStore.ts` (imports + one line in `applyFolderClick`) +- Test: `apps/ui/src/stores/uiMediaFolderStore.test.ts` (create) + +- [ ] **Step 1: Write the failing test** + +Create `apps/ui/src/stores/uiMediaFolderStore.test.ts`: + +```ts +/** @vitest-environment jsdom */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const invalidateQueries = vi.fn(); +vi.mock("@/lib/queryClient", () => ({ + queryClient: { + invalidateQueries: (...args: unknown[]) => invalidateQueries(...args), + }, +})); + +import { useUIMediaFolderStore } from "./uiMediaFolderStore"; +import { PLANS_QUERY_ROOT } from "@/hooks/plans/plansQueryKeys"; + +describe("uiMediaFolderStore.applyFolderClick pulls pending plans", () => { + beforeEach(() => { + invalidateQueries.mockClear(); + useUIMediaFolderStore.setState({ + folders: [], + selectedFolder: "", + selectedFolders: [], + }); + }); + + it("invalidates the plans query on single click", () => { + useUIMediaFolderStore.getState().applyFolderClick("/media/show", false); + + expect(useUIMediaFolderStore.getState().selectedFolder).toBe("/media/show"); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: [PLANS_QUERY_ROOT], + }); + }); + + it("invalidates the plans query when re-selecting the same folder", () => { + useUIMediaFolderStore.setState({ + selectedFolder: "/media/show", + selectedFolders: ["/media/show"], + }); + + useUIMediaFolderStore.getState().applyFolderClick("/media/show", false); + + expect(invalidateQueries).toHaveBeenCalledTimes(1); + }); + + it("invalidates the plans query on multi-select click too", () => { + useUIMediaFolderStore.getState().applyFolderClick("/media/show", true); + + expect(invalidateQueries).toHaveBeenCalledTimes(1); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/ui && pnpm test src/stores/uiMediaFolderStore.test.ts` +Expected: FAIL — `invalidateQueries` never called. + +- [ ] **Step 3: Implement** + +In `apps/ui/src/stores/uiMediaFolderStore.ts`, add to the imports: + +```ts +import { queryClient } from "@/lib/queryClient" +import { PLANS_QUERY_ROOT } from "@/hooks/plans/plansQueryKeys" +``` + +(Import from `plansQueryKeys` directly, not the `@/hooks/plans` barrel — the barrel pulls in query hooks and the API client, which would create a heavy import cycle from a zustand store module.) + +Then add one line as the first statement inside `applyFolderClick`'s `set((state) => { ... })` callback, right after the `console.log`: + +```ts + applyFolderClick: (rawPath, multi) => + set((state) => { + const path = rawPath + console.log(`[sidebar] folder click path=${path} multi=${multi}`) + // Browser-side pulling: folder select refetches pending plans + // (e.g. AI plans created while the browser was backgrounded). + void queryClient.invalidateQueries({ queryKey: [PLANS_QUERY_ROOT] }) + if (!multi) { + return { selectedFolder: path, selectedFolders: [path] } + } + const next = new Set(state.selectedFolders) + if (next.has(path)) next.delete(path) + else next.add(path) + return { + selectedFolder: path, + selectedFolders: [...next], + } + }), +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/ui && pnpm test src/stores/uiMediaFolderStore.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/ui/src/stores/uiMediaFolderStore.ts apps/ui/src/stores/uiMediaFolderStore.test.ts +git commit -m "feat(ui): pull pending plans on sidebar folder select" +``` + +--- + +### Task 5: Pulling trigger 2 — `usePlansPullOnVisible` hook + +**Files:** +- Create: `apps/ui/src/hooks/plans/usePlansPullOnVisible.ts` +- Modify: `apps/ui/src/hooks/plans/index.ts` (export) +- Modify: `apps/ui/src/hooks/tv/useAiBasedRenameFilesFlow.ts` (mount) +- Test: `apps/ui/src/hooks/plans/usePlansPullOnVisible.test.ts` (create) + +- [ ] **Step 1: Write the failing test** + +Create `apps/ui/src/hooks/plans/usePlansPullOnVisible.test.ts`: + +```ts +/** @vitest-environment jsdom */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook } from "@testing-library/react"; + +const invalidateQueries = vi.fn(); +vi.mock("@/lib/queryClient", () => ({ + queryClient: { + invalidateQueries: (...args: unknown[]) => invalidateQueries(...args), + }, +})); + +import { usePlansPullOnVisible } from "./usePlansPullOnVisible"; +import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore"; +import { PLANS_QUERY_ROOT } from "./plansQueryKeys"; + +function fireVisibilityChange(state: "visible" | "hidden") { + Object.defineProperty(document, "visibilityState", { + value: state, + configurable: true, + }); + document.dispatchEvent(new Event("visibilitychange")); +} + +describe("usePlansPullOnVisible", () => { + beforeEach(() => { + invalidateQueries.mockClear(); + useUIMediaFolderStore.setState({ + selectedFolder: "/media/show", + selectedFolders: ["/media/show"], + }); + }); + + afterEach(() => { + fireVisibilityChange("visible"); + }); + + it("invalidates the plans query when the browser becomes visible", () => { + renderHook(() => usePlansPullOnVisible()); + + fireVisibilityChange("visible"); + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: [PLANS_QUERY_ROOT], + }); + }); + + it("does not invalidate when the browser becomes hidden", () => { + renderHook(() => usePlansPullOnVisible()); + + fireVisibilityChange("hidden"); + + expect(invalidateQueries).not.toHaveBeenCalled(); + }); + + it("does not invalidate when no folder is selected", () => { + useUIMediaFolderStore.setState({ selectedFolder: "", selectedFolders: [] }); + + renderHook(() => usePlansPullOnVisible()); + + fireVisibilityChange("visible"); + + expect(invalidateQueries).not.toHaveBeenCalled(); + }); + + it("removes the listener on unmount", () => { + const { unmount } = renderHook(() => usePlansPullOnVisible()); + unmount(); + + fireVisibilityChange("visible"); + + expect(invalidateQueries).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/ui && pnpm test src/hooks/plans/usePlansPullOnVisible.test.ts` +Expected: FAIL — `./usePlansPullOnVisible` does not exist (import error). + +- [ ] **Step 3: Implement the hook** + +Create `apps/ui/src/hooks/plans/usePlansPullOnVisible.ts`: + +```ts +import { useEffect } from "react" +import { queryClient } from "@/lib/queryClient" +import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" +import { PLANS_QUERY_ROOT } from "./plansQueryKeys" + +/** + * Browser-side pulling: a backgrounded browser may pause its JS and + * miss server-pushed events, so refetch pending plans when the + * browser becomes visible and a media folder is already selected. + */ +export function usePlansPullOnVisible() { + useEffect(() => { + const onVisibilityChange = () => { + if (document.visibilityState !== "visible") return + if (!useUIMediaFolderStore.getState().selectedFolder) return + void queryClient.invalidateQueries({ queryKey: [PLANS_QUERY_ROOT] }) + } + document.addEventListener("visibilitychange", onVisibilityChange) + return () => + document.removeEventListener("visibilitychange", onVisibilityChange) + }, []) +} +``` + +Then in `apps/ui/src/hooks/plans/index.ts`, add: + +```ts +export { usePlansPullOnVisible } from "./usePlansPullOnVisible" +``` + +- [ ] **Step 4: Mount the hook** + +In `apps/ui/src/hooks/tv/useAiBasedRenameFilesFlow.ts`, extend the existing import (line 6): + +```ts +import { + toUpdatePlanPatch, + usePlansPullOnVisible, + useUpdatePlanMutation, +} from "@/hooks/plans" +``` + +And add the call right after `useTvShowWebSocketEvents({...})` (line 87-89), before the `return`: + +```ts + usePlansPullOnVisible() +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd apps/ui && pnpm test src/hooks/plans/usePlansPullOnVisible.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 6: Commit** + +```bash +git add apps/ui/src/hooks/plans/usePlansPullOnVisible.ts apps/ui/src/hooks/plans/usePlansPullOnVisible.test.ts apps/ui/src/hooks/plans/index.ts apps/ui/src/hooks/tv/useAiBasedRenameFilesFlow.ts +git commit -m "feat(ui): pull pending plans on browser reactivate" +``` + +--- + +### Task 6: Full verification + spec status + +**Files:** +- Modify: `docs/superpowers/specs/2026-09-06-ai-plan-metadata-write-enforcement-design.md` (status banner) + +- [ ] **Step 1: Run all affected test suites** + +Run: +```bash +cd packages/types && pnpm test && cd ../../packages/core-routes && pnpm test && cd ../../apps/ui && pnpm test +``` +Expected: PASS — types (5 new), core-routes (2 existing + 4 new), apps/ui (no regressions; 3 + 4 new). + +- [ ] **Step 2: Typecheck everything** + +Run: `pnpm -r typecheck` (from the repo root) +Expected: exit 0. + +- [ ] **Step 3: Lint changed UI files** + +Run: `cd apps/ui && pnpm lint` +Expected: no new errors introduced by the changed/created files. + +- [ ] **Step 4: Flip the spec status banner** + +In `docs/superpowers/specs/2026-09-06-ai-plan-metadata-write-enforcement-design.md`, replace: + +```markdown +> **Status:** Pending implementation. +``` + +with: + +```markdown +> **Status:** Implemented (2026-09-06). Commit range: ``..``. +``` + +- [ ] **Step 5: Commit** + +```bash +git add docs/superpowers/specs/2026-09-06-ai-plan-metadata-write-enforcement-design.md +git commit -m "docs: mark ai rename plan enforcement design implemented" +``` + +- [ ] **Step 6: Report e2e deferral** + +State in the task report that extending `apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts` (per the spec's testing table) was NOT done — it needs a running CLI stack + AI provider. The user decides whether to run it manually or schedule a follow-up. From 04917547ab23a816d28d16e98b869fef73492aa5 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 20:10:57 +0800 Subject: [PATCH 38/83] feat(types): add hasAiAgentPermission helper --- packages/types/hasAiAgentPermission.test.ts | 51 +++++++++++++++++++++ packages/types/types.ts | 11 +++++ 2 files changed, 62 insertions(+) create mode 100644 packages/types/hasAiAgentPermission.test.ts diff --git a/packages/types/hasAiAgentPermission.test.ts b/packages/types/hasAiAgentPermission.test.ts new file mode 100644 index 00000000..cb571e46 --- /dev/null +++ b/packages/types/hasAiAgentPermission.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + AI_AGENT_PERMISSIONS, + hasAiAgentPermission, + type UserConfig, +} from "./types"; + +function configWith(permissions: string[] | undefined): UserConfig { + return { aiAgent: { permissions } } as unknown as UserConfig; +} + +describe("hasAiAgentPermission", () => { + it("returns false for undefined config", () => { + expect( + hasAiAgentPermission(undefined, AI_AGENT_PERMISSIONS.metadataWrite), + ).toBe(false); + }); + + it("returns false when aiAgent is missing", () => { + expect( + hasAiAgentPermission({} as UserConfig, AI_AGENT_PERMISSIONS.metadataWrite), + ).toBe(false); + }); + + it("returns false when permissions are missing", () => { + expect( + hasAiAgentPermission( + configWith(undefined), + AI_AGENT_PERMISSIONS.metadataWrite, + ), + ).toBe(false); + }); + + it("returns false when permissions are empty", () => { + expect( + hasAiAgentPermission( + configWith([]), + AI_AGENT_PERMISSIONS.metadataWrite, + ), + ).toBe(false); + }); + + it("returns true when the permission is granted", () => { + expect( + hasAiAgentPermission( + configWith([AI_AGENT_PERMISSIONS.metadataWrite]), + AI_AGENT_PERMISSIONS.metadataWrite, + ), + ).toBe(true); + }); +}); diff --git a/packages/types/types.ts b/packages/types/types.ts index 1d2aca60..aab50200 100644 --- a/packages/types/types.ts +++ b/packages/types/types.ts @@ -65,6 +65,17 @@ export interface AiAgentConfig { permissions?: AiAgentPermission[]; } +/** + * Whether the user config grants the given AI Agent permission. + * Missing config or permissions means nothing is granted. + */ +export function hasAiAgentPermission( + userConfig: UserConfig | undefined, + permission: AiAgentPermission, +): boolean { + return userConfig?.aiAgent?.permissions?.includes(permission) ?? false; +} + /** * Represent the user configuration, which is editable to the user. */ From 0e16c295d53dc297580a5b1f04bcb0c6f3c6cf1b Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 20:14:18 +0800 Subject: [PATCH 39/83] feat(core-routes): auto-apply ai rename plans when metadata.write granted --- .../src/tools/createRenameEpisodePlan.test.ts | 144 +++++++++++++++++- .../src/tools/createRenameEpisodePlan.ts | 61 +++++++- packages/types/ai-tools/planTaskMessages.ts | 7 + 3 files changed, 209 insertions(+), 3 deletions(-) diff --git a/packages/core-routes/src/tools/createRenameEpisodePlan.test.ts b/packages/core-routes/src/tools/createRenameEpisodePlan.test.ts index bd005c00..c414754d 100644 --- a/packages/core-routes/src/tools/createRenameEpisodePlan.test.ts +++ b/packages/core-routes/src/tools/createRenameEpisodePlan.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it, vi } from "vitest"; import { CREATE_RENAME_EPISODE_PLAN } from "@smm/types/ai-tools/createRenameEpisodePlan"; -import { END_PLAN_TASK_SUCCESS_MESSAGE } from "@smm/types/ai-tools/planTaskMessages"; -import { RenameFilesPlanReady } from "@smm/types/event-types"; +import { AI_AGENT_PERMISSIONS, type UserConfig } from "@smm/types"; +import { + END_PLAN_TASK_SUCCESS_MESSAGE, + RENAME_PLAN_AUTO_APPLIED_MESSAGE, +} from "@smm/types/ai-tools/planTaskMessages"; +import { + MEDIA_METADATA_UPDATED_EVENT, + RenameFilesPlanReady, +} from "@smm/types/event-types"; import type { ChatFs } from "../chatTypes.ts"; import * as broadcastModule from "./broadcast.ts"; import { buildCreateRenameEpisodePlanTool } from "./createRenameEpisodePlan.ts"; @@ -102,4 +109,137 @@ describe(`buildCreateRenameEpisodePlanTool (${CREATE_RENAME_EPISODE_PLAN})`, () emitSpy.mockRestore(); }); + + describe("auto-apply (metadata.write)", () => { + const folder = "/media/show"; + const args = { + mediaFolderPath: folder, + files: [ + { + from: `${folder}/S01E01.mkv`, + to: `${folder}/Show - S01E01.mkv`, + }, + ], + }; + + function grantedConfig(): UserConfig { + return { + aiAgent: { permissions: [AI_AGENT_PERMISSIONS.metadataWrite] }, + } as unknown as UserConfig; + } + + function expectPendingPlanId(result: unknown): { planId: string } { + if (!("planId" in result)) { + throw new Error((result as { error: string }).error); + } + return result as { planId: string }; + } + + it("applies the plan and reports auto-apply when permission is granted", async () => { + const broadcast = vi.fn(); + const applyRenameEpisodePlan = vi.fn(async () => {}); + const tool = buildCreateRenameEpisodePlanTool( + "/app-data", + createMockFs(folder), + broadcast, + undefined, + undefined, + { + getUserConfig: async () => grantedConfig(), + applyRenameEpisodePlan, + }, + ); + + const result = expectPendingPlanId(await tool.execute(args)); + + expect(result.planId).toEqual(expect.any(String)); + expect(applyRenameEpisodePlan).toHaveBeenCalledTimes(1); + expect(broadcast).toHaveBeenCalledWith({ + event: MEDIA_METADATA_UPDATED_EVENT, + data: { folderPath: folder }, + }); + expect(broadcast).not.toHaveBeenCalledWith( + expect.objectContaining({ event: RenameFilesPlanReady.event }), + ); + }); + + it("falls back to a pending plan when the apply runner fails", async () => { + const broadcast = vi.fn(); + const tool = buildCreateRenameEpisodePlanTool( + "/app-data", + createMockFs(folder), + broadcast, + undefined, + undefined, + { + getUserConfig: async () => grantedConfig(), + applyRenameEpisodePlan: vi.fn(async () => { + throw new Error("EBUSY: resource busy"); + }), + }, + ); + + const result = expectPendingPlanId(await tool.execute(args)); + + expect(result.planId).toEqual(expect.any(String)); + expect(broadcast).toHaveBeenCalledWith({ + event: RenameFilesPlanReady.event, + data: { + taskId: result.planId, + planFilePath: `/app-data/plans/${result.planId}.plan.json`, + }, + }); + }); + + it("keeps the plan pending when the permission is not granted", async () => { + const broadcast = vi.fn(); + const applyRenameEpisodePlan = vi.fn(async () => {}); + const tool = buildCreateRenameEpisodePlanTool( + "/app-data", + createMockFs(folder), + broadcast, + undefined, + undefined, + { + getUserConfig: async () => + ({ aiAgent: { permissions: [] } }) as unknown as UserConfig, + applyRenameEpisodePlan, + }, + ); + + const result = expectPendingPlanId(await tool.execute(args)); + + expect(result.planId).toEqual(expect.any(String)); + expect(applyRenameEpisodePlan).not.toHaveBeenCalled(); + expect(broadcast).toHaveBeenCalledWith( + expect.objectContaining({ event: RenameFilesPlanReady.event }), + ); + }); + + it("keeps the plan pending when reading user config fails", async () => { + const broadcast = vi.fn(); + const applyRenameEpisodePlan = vi.fn(async () => {}); + const tool = buildCreateRenameEpisodePlanTool( + "/app-data", + createMockFs(folder), + broadcast, + undefined, + undefined, + { + getUserConfig: async () => { + throw new Error("config read failed"); + }, + applyRenameEpisodePlan, + }, + ); + + const result = expectPendingPlanId(await tool.execute(args)); + + expect(result.planId).toEqual(expect.any(String)); + expect(applyRenameEpisodePlan).not.toHaveBeenCalled(); + expect(broadcast).toHaveBeenCalledWith( + expect.objectContaining({ event: RenameFilesPlanReady.event }), + ); + }); + }); }); diff --git a/packages/core-routes/src/tools/createRenameEpisodePlan.ts b/packages/core-routes/src/tools/createRenameEpisodePlan.ts index 7b43f26b..46e16009 100644 --- a/packages/core-routes/src/tools/createRenameEpisodePlan.ts +++ b/packages/core-routes/src/tools/createRenameEpisodePlan.ts @@ -1,13 +1,23 @@ import { createRenameEpisodePlanPipeline } from "@smm/core/createRenameEpisodePlan"; import type { FsPort } from "@smm/core/FsPort"; import { Path } from "@smm/utils/path"; +import { + AI_AGENT_PERMISSIONS, + hasAiAgentPermission, + type UserConfig, +} from "@smm/types"; +import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan"; import { CREATE_RENAME_EPISODE_PLAN, CREATE_RENAME_EPISODE_PLAN_DESCRIPTION, createRenameEpisodePlanInputSchema, } from "@smm/types/ai-tools/createRenameEpisodePlan"; -import { END_PLAN_TASK_SUCCESS_MESSAGE } from "@smm/types/ai-tools/planTaskMessages"; import { + END_PLAN_TASK_SUCCESS_MESSAGE, + RENAME_PLAN_AUTO_APPLIED_MESSAGE, +} from "@smm/types/ai-tools/planTaskMessages"; +import { + MEDIA_METADATA_UPDATED_EVENT, RenameFilesPlanReady, type RenameFilesPlanReadyRequestData, } from "@smm/types/event-types"; @@ -65,12 +75,26 @@ function planPath(appDataDir: string, planId: string): string { return new Path(appDataDir, `plans/${planId}.plan.json`).abs("posix"); } +/** + * Optional dependencies for the `metadata.write` auto-apply flow. + * Auto-apply requires BOTH deps: without `getUserConfig` the tool + * cannot verify the permission; without `applyRenameEpisodePlan` + * (hosts without a Core instance, e.g. ohos) it cannot apply. + */ +export interface CreateRenameEpisodePlanToolExtra { + /** Reads the current user config for the metadata.write permission check. */ + getUserConfig?: () => Promise; + /** Applies (renames) a created plan. Host Core runner, e.g. `Core.applyPlan`. */ + applyRenameEpisodePlan?: (plan: RenameFilesPlan) => Promise; +} + export function buildCreateRenameEpisodePlanTool( appDataDir: string, fs: ChatFs, broadcast?: (message: WebSocketMessage) => void, logger?: CoreRoutesLogger, abortSignal?: AbortSignal, + extra?: CreateRenameEpisodePlanToolExtra, ) { const emit = broadcast ?? defaultBroadcast; return { @@ -100,6 +124,41 @@ export function buildCreateRenameEpisodePlanTool( }, ); + if (extra?.getUserConfig && extra.applyRenameEpisodePlan) { + try { + const userConfig = await extra.getUserConfig(); + if ( + hasAiAgentPermission( + userConfig, + AI_AGENT_PERMISSIONS.metadataWrite, + ) + ) { + await extra.applyRenameEpisodePlan(plan); + emit({ + event: MEDIA_METADATA_UPDATED_EVENT, + data: { folderPath: plan.mediaFolderPath }, + }); + logger?.info( + { + planId: plan.id, + folderPath: plan.mediaFolderPath, + fileCount: plan.files.length, + }, + `[tool][${CREATE_RENAME_EPISODE_PLAN}] Plan applied automatically`, + ); + return toolOk({ + message: RENAME_PLAN_AUTO_APPLIED_MESSAGE, + planId: plan.id, + }); + } + } catch (error) { + logger?.warn( + { planId: plan.id, error }, + `[tool][${CREATE_RENAME_EPISODE_PLAN}] Auto-apply failed, plan stays pending`, + ); + } + } + const data: RenameFilesPlanReadyRequestData = { taskId: plan.id, planFilePath: planPath(appDataDir, plan.id), diff --git a/packages/types/ai-tools/planTaskMessages.ts b/packages/types/ai-tools/planTaskMessages.ts index 9393c0a7..e1662402 100644 --- a/packages/types/ai-tools/planTaskMessages.ts +++ b/packages/types/ai-tools/planTaskMessages.ts @@ -12,3 +12,10 @@ export const END_PLAN_TASK_SUCCESS_MESSAGE = */ export const PLAN_CANCELLED_BY_USER_MESSAGE = "该任务已被用户取消, 请停止后续操作"; + +/** + * Returned to the AI when the rename plan was applied automatically + * because the user granted the `metadata.write` permission. + */ +export const RENAME_PLAN_AUTO_APPLIED_MESSAGE = + "Rename plan applied automatically (metadata.write permission granted). No user approval needed."; From 5def24d3ec2d9c3c253c8034e0b3093f3f8b04c2 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 20:16:12 +0800 Subject: [PATCH 40/83] fix(core-routes): narrow expectPendingPlanId param type for tsc --- packages/core-routes/src/tools/createRenameEpisodePlan.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-routes/src/tools/createRenameEpisodePlan.test.ts b/packages/core-routes/src/tools/createRenameEpisodePlan.test.ts index c414754d..937d9afd 100644 --- a/packages/core-routes/src/tools/createRenameEpisodePlan.test.ts +++ b/packages/core-routes/src/tools/createRenameEpisodePlan.test.ts @@ -128,7 +128,7 @@ describe(`buildCreateRenameEpisodePlanTool (${CREATE_RENAME_EPISODE_PLAN})`, () } as unknown as UserConfig; } - function expectPendingPlanId(result: unknown): { planId: string } { + function expectPendingPlanId(result: object): { planId: string } { if (!("planId" in result)) { throw new Error((result as { error: string }).error); } From 898b1df57975b12edf6f8cdaf47304e93559eb2d Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 20:19:07 +0800 Subject: [PATCH 41/83] feat: wire applyRenameEpisodePlan into mcp and chat tools --- apps/cli/src/mcp/mcp.ts | 1 + apps/cli/src/route/chatRoute.ts | 1 + .../src/mcp/toolHandlers/createRenameEpisodePlan.ts | 5 +++++ packages/core-routes/src/mcp/types.ts | 10 ++++++++++ packages/core-routes/src/tools/index.ts | 7 +++++++ 5 files changed, 24 insertions(+) diff --git a/apps/cli/src/mcp/mcp.ts b/apps/cli/src/mcp/mcp.ts index 4d584d5e..356c1e7f 100644 --- a/apps/cli/src/mcp/mcp.ts +++ b/apps/cli/src/mcp/mcp.ts @@ -148,6 +148,7 @@ async function buildMcpConfig(): Promise { broadcast(message as Parameters[0]), toolDescriptions: await loadLocalizedToolDescriptions(), renameEpisodeFile: (input) => getCore().renameEpisodeFile(input), + applyRenameEpisodePlan: (plan) => getCore().applyPlan(plan), scrapeFolder: (path, options) => getCore().scrapeFolder(path, options), getJob: (id) => getCore().getJob(id), searchInTmdb: (keyword, options) => getCore().searchInTmdb(keyword, options), diff --git a/apps/cli/src/route/chatRoute.ts b/apps/cli/src/route/chatRoute.ts index 6ce42d7b..bc15413d 100644 --- a/apps/cli/src/route/chatRoute.ts +++ b/apps/cli/src/route/chatRoute.ts @@ -17,6 +17,7 @@ export function handleChatRequest(app: Hono, chatConfig: ChatConfig) { try { const response = await doChat(chatConfig, c.req.raw, { renameEpisodeFile: (input) => getCore().renameEpisodeFile(input), + applyRenameEpisodePlan: (plan) => getCore().applyPlan(plan), scrapeFolder: (path, options) => getCore().scrapeFolder(path, options), getJob: (id) => getCore().getJob(id), tmdb: { diff --git a/packages/core-routes/src/mcp/toolHandlers/createRenameEpisodePlan.ts b/packages/core-routes/src/mcp/toolHandlers/createRenameEpisodePlan.ts index 52197427..b5e80c8d 100644 --- a/packages/core-routes/src/mcp/toolHandlers/createRenameEpisodePlan.ts +++ b/packages/core-routes/src/mcp/toolHandlers/createRenameEpisodePlan.ts @@ -22,6 +22,11 @@ export function registerCreateRenameEpisodePlanTool( config.fs ?? defaultChatFs(), config.broadcast, config.logger, + undefined, + { + getUserConfig: config.getUserConfig, + applyRenameEpisodePlan: config.applyRenameEpisodePlan, + }, ); const description = config.toolDescriptions?.[CREATE_RENAME_EPISODE_PLAN] ?? diff --git a/packages/core-routes/src/mcp/types.ts b/packages/core-routes/src/mcp/types.ts index 7812859e..3a47fa73 100644 --- a/packages/core-routes/src/mcp/types.ts +++ b/packages/core-routes/src/mcp/types.ts @@ -1,4 +1,5 @@ import type { UserConfig } from "@smm/types"; +import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan"; import type { ChatFs } from "../chatTypes.ts"; import type { CoreRoutesLogger } from "../types.ts"; import type { WebSocketMessage } from "../socketIO/types.ts"; @@ -94,6 +95,15 @@ export interface McpConfig { failed: Array<{ path: string; error: string }>; }>; + /** + * Optional runner for `create-rename-episode-plan` auto-apply. + * Hosts that expose Core (e.g. Bun cli) inject `Core.applyPlan`. + * When omitted (e.g. ohos has no Core instance), AI rename plans + * always stay pending for user approval, even when the user granted + * the `metadata.write` permission. + */ + applyRenameEpisodePlan?: (plan: RenameFilesPlan) => Promise; + /** * Optional runner for `scrape`. Hosts that expose Core inject * `Core.scrapeFolder`. When omitted, the tool reports unavailable. diff --git a/packages/core-routes/src/tools/index.ts b/packages/core-routes/src/tools/index.ts index 9495514f..61ad8e5e 100644 --- a/packages/core-routes/src/tools/index.ts +++ b/packages/core-routes/src/tools/index.ts @@ -1,4 +1,5 @@ import type { UserConfig } from "@smm/types"; +import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan"; import { resolveAppLanguage, detectOsLocale } from "@smm/utils/locale"; import { GET_APPLICATION_CONTEXT } from "@smm/types/ai-tools/getApplicationContext"; import { IS_FOLDER_EXIST } from "@smm/types/ai-tools/isFolderExist"; @@ -117,6 +118,8 @@ export interface ChatToolsExtraDeps { tmdb?: TmdbToolRunners; /** Host Core runners for TVDB query tools. */ tvdb?: TvdbToolRunners; + /** Host Core runner for applying AI rename plans (Bun cli / Electron). */ + applyRenameEpisodePlan?: (plan: RenameFilesPlan) => Promise; } export interface CreateChatToolsArgs { @@ -214,6 +217,10 @@ export function createChatTools(args: CreateChatToolsArgs): ChatTools { broadcast, logger, abortSignal, + { + getUserConfig: () => Promise.resolve(userConfig), + applyRenameEpisodePlan: extra?.applyRenameEpisodePlan, + }, ), [BEGIN_RECOGNIZE_TASK]: buildBeginRecognizeTaskTool( clientId, From 5514e55000e3c78950992176335c46e123e5998f Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 20:22:19 +0800 Subject: [PATCH 42/83] feat(ui): pull pending plans on sidebar folder select --- apps/ui/src/stores/uiMediaFolderStore.test.ts | 49 +++++++++++++++++++ apps/ui/src/stores/uiMediaFolderStore.ts | 5 ++ 2 files changed, 54 insertions(+) create mode 100644 apps/ui/src/stores/uiMediaFolderStore.test.ts diff --git a/apps/ui/src/stores/uiMediaFolderStore.test.ts b/apps/ui/src/stores/uiMediaFolderStore.test.ts new file mode 100644 index 00000000..eb65d68d --- /dev/null +++ b/apps/ui/src/stores/uiMediaFolderStore.test.ts @@ -0,0 +1,49 @@ +/** @vitest-environment jsdom */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const invalidateQueries = vi.fn(); +vi.mock("@/lib/queryClient", () => ({ + queryClient: { + invalidateQueries: (...args: unknown[]) => invalidateQueries(...args), + }, +})); + +import { useUIMediaFolderStore } from "./uiMediaFolderStore"; +import { PLANS_QUERY_ROOT } from "@/hooks/plans/plansQueryKeys"; + +describe("uiMediaFolderStore.applyFolderClick pulls pending plans", () => { + beforeEach(() => { + invalidateQueries.mockClear(); + useUIMediaFolderStore.setState({ + folders: [], + selectedFolder: "", + selectedFolders: [], + }); + }); + + it("invalidates the plans query on single click", () => { + useUIMediaFolderStore.getState().applyFolderClick("/media/show", false); + + expect(useUIMediaFolderStore.getState().selectedFolder).toBe("/media/show"); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: [PLANS_QUERY_ROOT], + }); + }); + + it("invalidates the plans query when re-selecting the same folder", () => { + useUIMediaFolderStore.setState({ + selectedFolder: "/media/show", + selectedFolders: ["/media/show"], + }); + + useUIMediaFolderStore.getState().applyFolderClick("/media/show", false); + + expect(invalidateQueries).toHaveBeenCalledTimes(1); + }); + + it("invalidates the plans query on multi-select click too", () => { + useUIMediaFolderStore.getState().applyFolderClick("/media/show", true); + + expect(invalidateQueries).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/ui/src/stores/uiMediaFolderStore.ts b/apps/ui/src/stores/uiMediaFolderStore.ts index b9e6f729..7e1e1363 100644 --- a/apps/ui/src/stores/uiMediaFolderStore.ts +++ b/apps/ui/src/stores/uiMediaFolderStore.ts @@ -4,6 +4,8 @@ import { useShallow } from "zustand/shallow" import { Path } from "@smm/utils/path" import type { UIMediaFolder, UIMediaFolderStatus } from "@/types/UIMediaFolder" import { installUIMediaFolderStoreBridge } from "./uiMediaFolderStoreBridge" +import { queryClient } from "@/lib/queryClient" +import { PLANS_QUERY_ROOT } from "@/hooks/plans/plansQueryKeys" interface UIMediaFolderStoreState { folders: UIMediaFolder[] @@ -85,6 +87,9 @@ const useUIMediaFolderStore = create((set) => ({ set((state) => { const path = rawPath console.log(`[sidebar] folder click path=${path} multi=${multi}`) + // Browser-side pulling: folder select refetches pending plans + // (e.g. AI plans created while the browser was backgrounded). + void queryClient.invalidateQueries({ queryKey: [PLANS_QUERY_ROOT] }) if (!multi) { return { selectedFolder: path, selectedFolders: [path] } } From 077891fae49e780761133617c148200601884b27 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 20:25:02 +0800 Subject: [PATCH 43/83] feat(ui): pull pending plans on browser reactivate --- apps/ui/src/hooks/plans/index.ts | 1 + .../hooks/plans/usePlansPullOnVisible.test.ts | 73 +++++++++++++++++++ .../src/hooks/plans/usePlansPullOnVisible.ts | 22 ++++++ .../src/hooks/tv/useAiBasedRenameFilesFlow.ts | 8 +- 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 apps/ui/src/hooks/plans/usePlansPullOnVisible.test.ts create mode 100644 apps/ui/src/hooks/plans/usePlansPullOnVisible.ts diff --git a/apps/ui/src/hooks/plans/index.ts b/apps/ui/src/hooks/plans/index.ts index 514adea3..287555c7 100644 --- a/apps/ui/src/hooks/plans/index.ts +++ b/apps/ui/src/hooks/plans/index.ts @@ -1,4 +1,5 @@ export { PLANS_QUERY_ROOT, plansQueryKey } from "./plansQueryKeys" +export { usePlansPullOnVisible } from "./usePlansPullOnVisible" export { usePlansQuery } from "./usePlansQuery" export { useCreatePlanMutation } from "./useCreatePlanMutation" export { diff --git a/apps/ui/src/hooks/plans/usePlansPullOnVisible.test.ts b/apps/ui/src/hooks/plans/usePlansPullOnVisible.test.ts new file mode 100644 index 00000000..6a12c855 --- /dev/null +++ b/apps/ui/src/hooks/plans/usePlansPullOnVisible.test.ts @@ -0,0 +1,73 @@ +/** @vitest-environment jsdom */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook } from "@testing-library/react"; + +const invalidateQueries = vi.fn(); +vi.mock("@/lib/queryClient", () => ({ + queryClient: { + invalidateQueries: (...args: unknown[]) => invalidateQueries(...args), + }, +})); + +import { usePlansPullOnVisible } from "./usePlansPullOnVisible"; +import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore"; +import { PLANS_QUERY_ROOT } from "./plansQueryKeys"; + +function fireVisibilityChange(state: "visible" | "hidden") { + Object.defineProperty(document, "visibilityState", { + value: state, + configurable: true, + }); + document.dispatchEvent(new Event("visibilitychange")); +} + +describe("usePlansPullOnVisible", () => { + beforeEach(() => { + invalidateQueries.mockClear(); + useUIMediaFolderStore.setState({ + selectedFolder: "/media/show", + selectedFolders: ["/media/show"], + }); + }); + + afterEach(() => { + fireVisibilityChange("visible"); + }); + + it("invalidates the plans query when the browser becomes visible", () => { + renderHook(() => usePlansPullOnVisible()); + + fireVisibilityChange("visible"); + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: [PLANS_QUERY_ROOT], + }); + }); + + it("does not invalidate when the browser becomes hidden", () => { + renderHook(() => usePlansPullOnVisible()); + + fireVisibilityChange("hidden"); + + expect(invalidateQueries).not.toHaveBeenCalled(); + }); + + it("does not invalidate when no folder is selected", () => { + useUIMediaFolderStore.setState({ selectedFolder: "", selectedFolders: [] }); + + renderHook(() => usePlansPullOnVisible()); + + fireVisibilityChange("visible"); + + expect(invalidateQueries).not.toHaveBeenCalled(); + }); + + it("removes the listener on unmount", () => { + const { unmount } = renderHook(() => usePlansPullOnVisible()); + unmount(); + + fireVisibilityChange("visible"); + + expect(invalidateQueries).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/ui/src/hooks/plans/usePlansPullOnVisible.ts b/apps/ui/src/hooks/plans/usePlansPullOnVisible.ts new file mode 100644 index 00000000..164e9fe9 --- /dev/null +++ b/apps/ui/src/hooks/plans/usePlansPullOnVisible.ts @@ -0,0 +1,22 @@ +import { useEffect } from "react" +import { queryClient } from "@/lib/queryClient" +import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" +import { PLANS_QUERY_ROOT } from "./plansQueryKeys" + +/** + * Browser-side pulling: a backgrounded browser may pause its JS and + * miss server-pushed events, so refetch pending plans when the + * browser becomes visible and a media folder is already selected. + */ +export function usePlansPullOnVisible() { + useEffect(() => { + const onVisibilityChange = () => { + if (document.visibilityState !== "visible") return + if (!useUIMediaFolderStore.getState().selectedFolder) return + void queryClient.invalidateQueries({ queryKey: [PLANS_QUERY_ROOT] }) + } + document.addEventListener("visibilitychange", onVisibilityChange) + return () => + document.removeEventListener("visibilitychange", onVisibilityChange) + }, []) +} diff --git a/apps/ui/src/hooks/tv/useAiBasedRenameFilesFlow.ts b/apps/ui/src/hooks/tv/useAiBasedRenameFilesFlow.ts index 640b1538..7952dc83 100644 --- a/apps/ui/src/hooks/tv/useAiBasedRenameFilesFlow.ts +++ b/apps/ui/src/hooks/tv/useAiBasedRenameFilesFlow.ts @@ -3,7 +3,11 @@ import { toast } from "sonner" import { cleanupRenamePlan } from "@/ai/plan/cleanupRenamePlan" import { selectActiveAiPlan } from "@/components/tv/plans/selectActiveAppPlan" import { useTvShowWebSocketEvents } from "./useTvShowWebSocketEvents" -import { toUpdatePlanPatch, useUpdatePlanMutation } from "@/hooks/plans" +import { + toUpdatePlanPatch, + usePlansPullOnVisible, + useUpdatePlanMutation, +} from "@/hooks/plans" import type { MediaMetadata } from "@smm/types" import type { UIPlan } from "@/types/UIPlan" import type { UIRenameFilesPlan } from "@/types/UIRenameFilesPlan" @@ -88,6 +92,8 @@ export function useAiBasedRenameFilesFlow({ setSelectedMediaMetadataByMediaFolderPath, }) + usePlansPullOnVisible() + return { plan, promptStatus, From 2a3d0209bc322312db13267bcefec4a646fb1c26 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 20:30:37 +0800 Subject: [PATCH 44/83] docs: mark ai rename plan enforcement design implemented --- .../2026-09-06-ai-plan-metadata-write-enforcement-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-09-06-ai-plan-metadata-write-enforcement-design.md b/docs/superpowers/specs/2026-09-06-ai-plan-metadata-write-enforcement-design.md index d2f3f113..f1e0e953 100644 --- a/docs/superpowers/specs/2026-09-06-ai-plan-metadata-write-enforcement-design.md +++ b/docs/superpowers/specs/2026-09-06-ai-plan-metadata-write-enforcement-design.md @@ -3,7 +3,7 @@ This design document describes the high level design of a feature. The design document is golden source and referenced by one or more features. -> **Status:** Pending implementation. +> **Status:** Implemented (2026-09-06). Commits: `04917547`, `0e16c295`, `5def24d3`, `898b1df5`, `5514e550`, `077891fa`. > **Source spec:** uncommitted diff in `docs/dev/rename-episodes.md` (MCP Tool and AI Tool section + Browser-side Pulling section). > **Builds on:** [AI Agent Settings — Permissions Config](./2026-09-06-ai-agent-permissions-design.md) (Phase 1: `aiAgent.permissions` config + settings UI). This feature is the enforcement follow-up that Phase 1 deferred. From a8c4e6850e4d1c619bfc803544a32b9578a86bde Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 21:33:38 +0800 Subject: [PATCH 45/83] chore(ui): remove debug console.log in TvShowPanel --- apps/ui/src/components/tv/TvShowPanel.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/ui/src/components/tv/TvShowPanel.tsx b/apps/ui/src/components/tv/TvShowPanel.tsx index fb2eb9ae..fac8a5cc 100644 --- a/apps/ui/src/components/tv/TvShowPanel.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.tsx @@ -253,8 +253,6 @@ function TvShowPanel() { const { metadataFiles, subtitleFiles, nfoFiles, thumbnailFiles, newFilePaths } = useTvShowPanel(selectedFolder, plan) - console.log(`>>> newFilePaths:`, newFilePaths) - const selectFileFlow = useSelectAndUnselectFileFlow({ mediaMetadata, folderFiles, @@ -307,7 +305,6 @@ function TvShowPanel() { const latestMetadata = useLatest(mediaMetadata) useEffect(() => { - console.log(`>>> useEffect selectedEpisodes CALLED`); const plan = latestPlan.current; const metadata = latestMetadata.current; From 17abcf5e4f67f2ea3fdf0d6230e11075d6310d96 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 21:40:35 +0800 Subject: [PATCH 46/83] refactor(ui): rename AiBased*Prompt components to *EpisodePrompt --- ...ePrompt.tsx => AiBasedRecognizeEpisodePrompt.tsx} | 10 +++++----- ...FilePrompt.tsx => AiBasedRenameEpisodePrompt.tsx} | 12 ++++++------ apps/ui/src/components/tv/TvShowPanelPrompts.tsx | 8 ++++---- apps/ui/src/lib/harmonyOSDisabledFeatures.ts | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) rename apps/ui/src/components/tv/{AiBasedRecognizePrompt.tsx => AiBasedRecognizeEpisodePrompt.tsx} (86%) rename apps/ui/src/components/tv/{AiBasedRenameFilePrompt.tsx => AiBasedRenameEpisodePrompt.tsx} (83%) diff --git a/apps/ui/src/components/tv/AiBasedRecognizePrompt.tsx b/apps/ui/src/components/tv/AiBasedRecognizeEpisodePrompt.tsx similarity index 86% rename from apps/ui/src/components/tv/AiBasedRecognizePrompt.tsx rename to apps/ui/src/components/tv/AiBasedRecognizeEpisodePrompt.tsx index b7622072..812e03fb 100644 --- a/apps/ui/src/components/tv/AiBasedRecognizePrompt.tsx +++ b/apps/ui/src/components/tv/AiBasedRecognizeEpisodePrompt.tsx @@ -3,7 +3,7 @@ import { Loader2 } from "lucide-react" import { cn } from "@/lib/utils" import { useTranslation } from "@/lib/i18n" -export interface AiBasedRecognizePromptProps extends Omit { +export interface AiBasedRecognizeEpisodePromptProps extends Omit { /** * Status of the AI recognition operation * - "generating": AI is generating output @@ -13,10 +13,10 @@ export interface AiBasedRecognizePromptProps extends Omit { +export interface AiBasedRenameEpisodePromptProps extends Omit { /** * Status of the AI renaming operation * - "generating": AI is generating output @@ -13,10 +13,10 @@ export interface AiBasedRenameFilePromptProps extends Omit - { @@ -71,7 +71,7 @@ export function TvShowPanelPrompts() { }} /> - { diff --git a/apps/ui/src/lib/harmonyOSDisabledFeatures.ts b/apps/ui/src/lib/harmonyOSDisabledFeatures.ts index a7187a95..1c47214e 100644 --- a/apps/ui/src/lib/harmonyOSDisabledFeatures.ts +++ b/apps/ui/src/lib/harmonyOSDisabledFeatures.ts @@ -5,7 +5,7 @@ * The AI Summary (MusicPanel right-click → Summarize) flow is gated via the * master `isAiFeatureEnabled` flag, which defaults to `false` on HarmonyOS * (see `apps/ui/src/hooks/useFeatures.ts` `readAiFeatureEnabled`). MCP/backend - * plan prompts (`AiBasedRecognizePrompt`, `AiBasedRenameFilePrompt`) are not + * plan prompts (`AiBasedRecognizeEpisodePrompt`, `AiBasedRenameEpisodePrompt`) are not * gated by that flag — pending `creator: "ai"` plans must always be confirmable. */ export const HARMONYOS_DISABLED_FEATURE_IDS = [ From f00f7e2eaab7dfb65a21a1a4b66ae50245b40866 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 21:50:40 +0800 Subject: [PATCH 47/83] refactor(ui): add cohesive useAiBasedRenameEpisodeFlow hook --- .../tv/useAiBasedRenameEpisodeFlow.test.ts | 94 ++++++++++++++ .../hooks/tv/useAiBasedRenameEpisodeFlow.ts | 119 ++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts create mode 100644 apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.ts diff --git a/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts b/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts new file mode 100644 index 00000000..58bd05a1 --- /dev/null +++ b/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from "vitest" +import { renderHook } from "@testing-library/react" +import { useAiBasedRenameEpisodeFlow } from "./useAiBasedRenameEpisodeFlow" +import type { UIRenameFilesPlan } from "@/types/UIRenameFilesPlan" +import type { MediaMetadata } from "@smm/types" + +const h = vi.hoisted(() => ({ + plans: [] as unknown[], + updatePlanMutateAsync: vi.fn(), + cleanupRenamePlan: vi.fn(), +})) + +vi.mock("@/hooks/plans", () => ({ + usePlansQuery: () => ({ data: h.plans }), + usePlansPullOnVisible: () => undefined, + useUpdatePlanMutation: () => ({ mutateAsync: h.updatePlanMutateAsync }), + toUpdatePlanPatch: (patch: unknown) => patch, +})) + +vi.mock("@/stores/uiMediaFolderStore", () => ({ + useUIMediaFolderStore: { getState: () => ({ applyFolderClick: vi.fn() }) }, +})) + +vi.mock("./useTvShowWebSocketEvents", () => ({ + useTvShowWebSocketEvents: () => undefined, +})) + +vi.mock("@/ai/plan/cleanupRenamePlan", () => ({ + cleanupRenamePlan: h.cleanupRenamePlan, +})) + +describe("useAiBasedRenameEpisodeFlow", () => { + const mediaFolderPath = "/storage/Users/currentUser/Download/Anime/show" + const mediaMetadata = { mediaFolderPath, type: "tvshow-folder" } as MediaMetadata + + const pendingAiPlan: UIRenameFilesPlan = { + id: "rename-plan-1", + task: "rename-files", + status: "pending", + creator: "ai", + mediaFolderPath, + files: [ + { from: `${mediaFolderPath}/old.mkv`, to: `${mediaFolderPath}/new.mkv` }, + ], + } as unknown as UIRenameFilesPlan + + it("surfaces a pending AI rename plan and opens the prompt", () => { + h.plans = [pendingAiPlan] + const { result } = renderHook(() => + useAiBasedRenameEpisodeFlow({ mediaMetadata }), + ) + + expect(result.current.plan?.id).toBe("rename-plan-1") + expect(result.current.promptStatus).toBe("wait-for-ack") + expect(result.current.promptProps.isOpen).toBe(true) + expect(result.current.promptProps.status).toBe("wait-for-ack") + }) + + it("maps a preparing plan to the generating prompt status", () => { + h.plans = [{ ...pendingAiPlan, status: "preparing" }] + const { result } = renderHook(() => + useAiBasedRenameEpisodeFlow({ mediaMetadata }), + ) + + expect(result.current.promptStatus).toBe("generating") + expect(result.current.promptProps.status).toBe("generating") + }) + + it("ignores plans of other media folders", () => { + h.plans = [{ ...pendingAiPlan, mediaFolderPath: "/other/show" }] + const { result } = renderHook(() => + useAiBasedRenameEpisodeFlow({ mediaMetadata }), + ) + + expect(result.current.plan).toBeUndefined() + expect(result.current.promptProps.isOpen).toBe(false) + }) + + it("rejects and cleans up the plan on cancel", async () => { + h.plans = [pendingAiPlan] + const { result } = renderHook(() => + useAiBasedRenameEpisodeFlow({ mediaMetadata }), + ) + + await result.current.onCancel() + + expect(h.updatePlanMutateAsync).toHaveBeenCalledWith({ + id: "rename-plan-1", + mediaFolderPath, + patch: { status: "rejected" }, + }) + expect(h.cleanupRenamePlan).toHaveBeenCalledWith("rename-plan-1") + }) +}) diff --git a/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.ts b/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.ts new file mode 100644 index 00000000..417fb6b0 --- /dev/null +++ b/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.ts @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useMemo } from "react" +import { toast } from "sonner" +import { cleanupRenamePlan } from "@/ai/plan/cleanupRenamePlan" +import { selectActiveAiPlan } from "@/components/tv/plans/selectActiveAppPlan" +import { useTvShowWebSocketEvents } from "./useTvShowWebSocketEvents" +import { + toUpdatePlanPatch, + usePlansPullOnVisible, + usePlansQuery, + useUpdatePlanMutation, +} from "@/hooks/plans" +import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" +import type { MediaMetadata } from "@smm/types" +import type { UIRenameFilesPlan } from "@/types/UIRenameFilesPlan" +import type { AiBasedRenameEpisodePromptProps } from "@/components/tv/AiBasedRenameEpisodePrompt" + +export interface UseAiBasedRenameEpisodeFlowOptions { + mediaMetadata: MediaMetadata | undefined + /** Called when an AI rename plan is detected (e.g. switch episode table to simple layout). */ + onFlowStart?: () => void +} + +/** + * Cohesive AI-based rename episode flow: surfaces AI/MCP-created rename plans + * for the selected folder and drives AiBasedRenameEpisodePrompt. Plans query, + * folder selection and confirm/cancel side effects live in this hook — the + * panel only renders promptProps. Rule-based (creator: 'app') plans are + * handled exclusively by useRuleBasedRenameFilesFlow. + * + * Not gated by `isAiFeatureEnabled` — see useAiBasedRecognizeEpisodeFlow. + */ +export function useAiBasedRenameEpisodeFlow({ + mediaMetadata, + onFlowStart, +}: UseAiBasedRenameEpisodeFlowOptions) { + const { data: plans = [] } = usePlansQuery(mediaMetadata?.mediaFolderPath) + const updatePlanMutation = useUpdatePlanMutation() + const mediaFolderPath = mediaMetadata?.mediaFolderPath + + const plan = useMemo( + () => + selectActiveAiPlan( + plans, + mediaFolderPath, + "rename-files", + ), + [plans, mediaFolderPath], + ) + + const promptStatus: "generating" | "wait-for-ack" = + plan?.status === "preparing" ? "generating" : "wait-for-ack" + + useEffect(() => { + console.log( + `[rename] useAiBasedRenameEpisodeFlow: plan=${plan ? `id=${plan.id} status=${plan.status}` : "undefined"}, ` + + `mediaFolderPath=${mediaFolderPath}, plansCount=${plans.length}`, + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [plan?.id, plan?.status, plans.length, mediaFolderPath]) + + // The actual rename is performed by the backend that created the plan; + // confirming from the app only acknowledges the prompt (pre-existing no-op). + const onConfirm = useCallback(async () => { + if (!plan) return + }, [plan]) + + const onCancel = useCallback(async () => { + if (!plan || !mediaFolderPath) return + try { + await updatePlanMutation.mutateAsync({ + id: plan.id, + mediaFolderPath, + patch: toUpdatePlanPatch({ status: "rejected" }), + }) + await cleanupRenamePlan(plan.id) + } catch (error) { + console.error("[useAiBasedRenameEpisodeFlow] Error rejecting rename plan:", error) + toast.error( + `Failed to reject rename plan: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } + }, [plan, mediaFolderPath, updatePlanMutation]) + + useEffect(() => { + if (plan) { + onFlowStart?.() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [plan?.id, onFlowStart]) + + const setSelectedMediaMetadataByMediaFolderPath = useCallback((path: string) => { + useUIMediaFolderStore.getState().applyFolderClick(path, false) + }, []) + + useTvShowWebSocketEvents({ + setSelectedMediaMetadataByMediaFolderPath, + }) + + usePlansPullOnVisible() + + const promptProps = useMemo((): AiBasedRenameEpisodePromptProps => ({ + isOpen: plan !== undefined, + status: promptStatus, + onConfirm: () => { + void onConfirm() + }, + onCancel: () => { + void onCancel() + }, + }), [plan, promptStatus, onConfirm, onCancel]) + + return { + plan, + promptStatus, + onConfirm, + onCancel, + promptProps, + } +} From 99a797f8e817b5c289494c86df6aa8ddb71ce92f Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 21:58:10 +0800 Subject: [PATCH 48/83] refactor(ui): add cohesive useAiBasedRecognizeEpisodeFlow hook --- .../tv/useAiBasedRecognizeEpisodeFlow.test.ts | 116 +++++++++++++++++ .../tv/useAiBasedRecognizeEpisodeFlow.ts | 121 ++++++++++++++++++ .../hooks/tv/useAiBasedRecognizeFlow.test.ts | 52 -------- 3 files changed, 237 insertions(+), 52 deletions(-) create mode 100644 apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts create mode 100644 apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.ts delete mode 100644 apps/ui/src/hooks/tv/useAiBasedRecognizeFlow.test.ts diff --git a/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts b/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts new file mode 100644 index 00000000..7dee0636 --- /dev/null +++ b/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from "vitest" +import { renderHook } from "@testing-library/react" +import { useAiBasedRecognizeEpisodeFlow } from "./useAiBasedRecognizeEpisodeFlow" +import { handleAiRecognizeConfirm } from "@/actions/handleAiRecognizeConfirm" +import { cleanupRecognizePlan } from "@/ai/tools/EndRecognizeTask" +import type { UIRecognizeMediaFilePlan } from "@/types/UIRecognizeMediaFilePlan" +import type { MediaMetadata } from "@smm/types" + +const h = vi.hoisted(() => ({ + plans: [] as unknown[], + updatePlanMutateAsync: vi.fn(), + cleanupRecognizePlan: vi.fn(), + handleAiRecognizeConfirm: vi.fn(), +})) + +vi.mock("@/hooks/plans", () => ({ + usePlansQuery: () => ({ data: h.plans }), + useUpdatePlanMutation: () => ({ mutateAsync: h.updatePlanMutateAsync }), + toUpdatePlanPatch: (patch: unknown) => patch, +})) + +vi.mock("@/hooks/mediaMetadata/useUpdateMediaMetadataMutation", () => ({ + useUpdateMediaMetadataMutation: () => ({ persistMediaMetadata: vi.fn() }), +})) + +vi.mock("@/actions/handleAiRecognizeConfirm", () => ({ + handleAiRecognizeConfirm: h.handleAiRecognizeConfirm, +})) + +vi.mock("@/ai/tools/EndRecognizeTask", () => ({ + cleanupRecognizePlan: h.cleanupRecognizePlan, +})) + +describe("useAiBasedRecognizeEpisodeFlow", () => { + const mediaFolderPath = "/storage/Users/currentUser/Download/Anime/show" + const mediaMetadata = { mediaFolderPath, type: "tvshow-folder" } as MediaMetadata + + const pendingAiPlan: UIRecognizeMediaFilePlan = { + id: "plan-1", + task: "recognize-media-file", + status: "pending", + creator: "ai", + mediaFolderPath, + files: [ + { season: 1, episode: 1, path: `${mediaFolderPath}/S01E01.mkv` }, + { season: 1, episode: 2, path: `${mediaFolderPath}/S01E02.mkv` }, + ], + } + + it("surfaces pending MCP recognize plans regardless of isAiFeatureEnabled", () => { + h.plans = [pendingAiPlan] + const { result } = renderHook(() => + useAiBasedRecognizeEpisodeFlow({ + mediaMetadata, + beforeConfirm: (plan) => plan, + }), + ) + + expect(result.current.plan?.id).toBe("plan-1") + expect(result.current.promptStatus).toBe("wait-for-ack") + expect(result.current.promptProps.isOpen).toBe(true) + expect(result.current.promptProps.status).toBe("wait-for-ack") + }) + + it("ignores plans of other media folders", () => { + h.plans = [{ ...pendingAiPlan, mediaFolderPath: "/other/show" }] + const { result } = renderHook(() => + useAiBasedRecognizeEpisodeFlow({ + mediaMetadata, + beforeConfirm: (plan) => plan, + }), + ) + + expect(result.current.plan).toBeUndefined() + expect(result.current.promptProps.isOpen).toBe(false) + }) + + it("passes the beforeConfirm-prepared plan to handleAiRecognizeConfirm", async () => { + h.plans = [pendingAiPlan] + const { result } = renderHook(() => + useAiBasedRecognizeEpisodeFlow({ + mediaMetadata, + beforeConfirm: (plan) => ({ ...plan, files: plan.files.slice(0, 1) }), + }), + ) + + await result.current.onConfirm() + + expect(h.handleAiRecognizeConfirm).toHaveBeenCalledWith( + expect.objectContaining({ files: [pendingAiPlan.files[0]] }), + mediaMetadata, + expect.any(Function), + expect.any(Function), + ) + expect(h.cleanupRecognizePlan).toHaveBeenCalledWith("plan-1") + }) + + it("rejects and cleans up the plan on cancel", async () => { + h.plans = [pendingAiPlan] + const { result } = renderHook(() => + useAiBasedRecognizeEpisodeFlow({ + mediaMetadata, + beforeConfirm: (plan) => plan, + }), + ) + + await result.current.onCancel() + + expect(h.updatePlanMutateAsync).toHaveBeenCalledWith({ + id: "plan-1", + mediaFolderPath, + patch: { status: "rejected" }, + }) + expect(h.cleanupRecognizePlan).toHaveBeenCalledWith("plan-1") + }) +}) diff --git a/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.ts b/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.ts new file mode 100644 index 00000000..fa0d1192 --- /dev/null +++ b/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.ts @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useMemo } from "react" +import { toast } from "sonner" +import { handleAiRecognizeConfirm } from "@/actions/handleAiRecognizeConfirm" +import { cleanupRecognizePlan } from "@/ai/tools/EndRecognizeTask" +import { selectActiveAiPlan } from "@/components/tv/plans/selectActiveAppPlan" +import { toUpdatePlanPatch, usePlansQuery, useUpdatePlanMutation } from "@/hooks/plans" +import { useUpdateMediaMetadataMutation } from "@/hooks/mediaMetadata/useUpdateMediaMetadataMutation" +import type { MediaMetadata } from "@smm/types" +import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan" +import type { UIRecognizeMediaFilePlan } from "@/types/UIRecognizeMediaFilePlan" +import type { AiBasedRecognizeEpisodePromptProps } from "@/components/tv/AiBasedRecognizeEpisodePrompt" + +export interface UseAiBasedRecognizeEpisodeFlowOptions { + mediaMetadata: MediaMetadata | undefined + beforeConfirm: (plan: UIRecognizeMediaFilePlan) => UIRecognizeMediaFilePlan + /** Called when an AI recognize plan is detected (e.g. switch episode table to simple layout). */ + onFlowStart?: () => void +} + +/** + * Cohesive AI-based recognize episode flow: surfaces AI/MCP-created recognize + * plans for the selected folder and drives AiBasedRecognizeEpisodePrompt. The + * plans query and confirm/cancel side effects live in this hook; only + * `beforeConfirm` (episode checkbox selection) is supplied by the panel. + * Rule-based (creator: 'app') plans are handled exclusively by + * useRuleBasedRecognizeFlow. + * + * Not gated by `isAiFeatureEnabled`: pending MCP/backend plans must always + * surface so the user can confirm or reject them (especially on HarmonyOS + * where in-app AI chat defaults off but external MCP is supported). + */ +export function useAiBasedRecognizeEpisodeFlow({ + mediaMetadata, + beforeConfirm, + onFlowStart, +}: UseAiBasedRecognizeEpisodeFlowOptions) { + const { data: plans = [] } = usePlansQuery(mediaMetadata?.mediaFolderPath) + const updatePlanMutation = useUpdatePlanMutation() + const { persistMediaMetadata } = useUpdateMediaMetadataMutation() + const mediaFolderPath = mediaMetadata?.mediaFolderPath + + const plan = useMemo( + () => + selectActiveAiPlan( + plans, + mediaFolderPath, + "recognize-media-file", + ), + [plans, mediaFolderPath], + ) + + const promptStatus: "generating" | "wait-for-ack" = + plan?.status === "preparing" ? "generating" : "wait-for-ack" + + const onConfirm = useCallback(async () => { + if (!plan || !mediaMetadata?.mediaFolderPath) return + const preparedPlan = beforeConfirm(plan) as RecognizeMediaFilePlan + await handleAiRecognizeConfirm( + preparedPlan, + mediaMetadata, + persistMediaMetadata, + async (id, patch) => { + await updatePlanMutation.mutateAsync({ + id, + mediaFolderPath: mediaMetadata.mediaFolderPath!, + patch: toUpdatePlanPatch(patch), + }) + }, + ) + await cleanupRecognizePlan(plan.id) + }, [ + plan, + mediaMetadata, + beforeConfirm, + persistMediaMetadata, + updatePlanMutation, + ]) + + const onCancel = useCallback(async () => { + if (!plan || !mediaFolderPath) return + try { + await updatePlanMutation.mutateAsync({ + id: plan.id, + mediaFolderPath, + patch: toUpdatePlanPatch({ status: "rejected" }), + }) + await cleanupRecognizePlan(plan.id) + } catch (error) { + console.error("[useAiBasedRecognizeEpisodeFlow] Error rejecting recognize plan:", error) + toast.error( + `Failed to reject recognize plan: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } + }, [plan, mediaFolderPath, updatePlanMutation]) + + useEffect(() => { + if (plan) { + onFlowStart?.() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [plan?.id, onFlowStart]) + + const promptProps = useMemo((): AiBasedRecognizeEpisodePromptProps => ({ + isOpen: plan !== undefined, + status: promptStatus, + onConfirm: () => { + void onConfirm() + }, + onCancel: () => { + void onCancel() + }, + }), [plan, promptStatus, onConfirm, onCancel]) + + return { + plan, + promptStatus, + onConfirm, + onCancel, + promptProps, + } +} diff --git a/apps/ui/src/hooks/tv/useAiBasedRecognizeFlow.test.ts b/apps/ui/src/hooks/tv/useAiBasedRecognizeFlow.test.ts deleted file mode 100644 index 8400dbfe..00000000 --- a/apps/ui/src/hooks/tv/useAiBasedRecognizeFlow.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it, vi } from "vitest" -import { renderHook } from "@testing-library/react" -import { useAiBasedRecognizeFlow } from "./useAiBasedRecognizeFlow" -import type { UIRecognizeMediaFilePlan } from "@/types/UIRecognizeMediaFilePlan" -import type { MediaMetadata } from "@smm/types" - -vi.mock("@/hooks/plans", () => ({ - useUpdatePlanMutation: () => ({ mutateAsync: vi.fn() }), - toUpdatePlanPatch: (patch: unknown) => patch, -})) - -vi.mock("@/hooks/mediaMetadata/useUpdateMediaMetadataMutation", () => ({ - useUpdateMediaMetadataMutation: () => ({ persistMediaMetadata: vi.fn() }), -})) - -vi.mock("@/actions/handleAiRecognizeConfirm", () => ({ - handleAiRecognizeConfirm: vi.fn(), -})) - -vi.mock("@/ai/tools/EndRecognizeTask", () => ({ - cleanupRecognizePlan: vi.fn(), -})) - -describe("useAiBasedRecognizeFlow", () => { - const mediaFolderPath = "/storage/Users/currentUser/Download/Anime/show" - const pendingAiPlan: UIRecognizeMediaFilePlan = { - id: "plan-1", - task: "recognize-media-file", - status: "pending", - creator: "ai", - mediaFolderPath, - files: [{ season: 1, episode: 1, path: `${mediaFolderPath}/S01E01.mkv` }], - } - - const mediaMetadata = { - mediaFolderPath, - type: "tvshow-folder", - } as MediaMetadata - - it("surfaces pending MCP recognize plans regardless of isAiFeatureEnabled", () => { - const { result } = renderHook(() => - useAiBasedRecognizeFlow({ - plans: [pendingAiPlan], - mediaMetadata, - beforeConfirm: (plan) => plan, - }), - ) - - expect(result.current.plan?.id).toBe("plan-1") - expect(result.current.promptStatus).toBe("wait-for-ack") - }) -}) From 8a389cc719a0c73ffa004b68e08f58260dd65bca Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 22:08:09 +0800 Subject: [PATCH 49/83] refactor(ui): decouple AI rename/recognize flows from TvShowPanel --- .../ui/src/components/tv/TvShowPanel.test.tsx | 10 +- apps/ui/src/components/tv/TvShowPanel.tsx | 48 ++------ .../src/components/tv/TvShowPanelPrompts.tsx | 36 ------ .../tv/plans/TvShowAppPlanPromptContext.tsx | 49 -------- .../src/hooks/tv/useAiBasedRecognizeFlow.ts | 107 ------------------ .../src/hooks/tv/useAiBasedRenameFilesFlow.ts | 103 ----------------- .../hooks/tv/useRuleBasedRenameFilesFlow.ts | 6 +- 7 files changed, 23 insertions(+), 336 deletions(-) delete mode 100644 apps/ui/src/components/tv/plans/TvShowAppPlanPromptContext.tsx delete mode 100644 apps/ui/src/hooks/tv/useAiBasedRecognizeFlow.ts delete mode 100644 apps/ui/src/hooks/tv/useAiBasedRenameFilesFlow.ts diff --git a/apps/ui/src/components/tv/TvShowPanel.test.tsx b/apps/ui/src/components/tv/TvShowPanel.test.tsx index 4b309db8..023d4672 100644 --- a/apps/ui/src/components/tv/TvShowPanel.test.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.test.tsx @@ -167,21 +167,23 @@ vi.mock("@/hooks/tv/useRuleBasedRenameFilesFlow", () => ({ }), })) -vi.mock("@/hooks/tv/useAiBasedRenameFilesFlow", () => ({ - useAiBasedRenameFilesFlow: () => ({ +vi.mock("@/hooks/tv/useAiBasedRenameEpisodeFlow", () => ({ + useAiBasedRenameEpisodeFlow: () => ({ plan: undefined, promptStatus: "generating", onConfirm: vi.fn(), onCancel: vi.fn(), + promptProps: { isOpen: false, status: "generating", onConfirm: vi.fn(), onCancel: vi.fn() }, }), })) -vi.mock("@/hooks/tv/useAiBasedRecognizeFlow", () => ({ - useAiBasedRecognizeFlow: () => ({ +vi.mock("@/hooks/tv/useAiBasedRecognizeEpisodeFlow", () => ({ + useAiBasedRecognizeEpisodeFlow: () => ({ plan: undefined, promptStatus: "generating", onConfirm: vi.fn(), onCancel: vi.fn(), + promptProps: { isOpen: false, status: "generating", onConfirm: vi.fn(), onCancel: vi.fn() }, }), })) diff --git a/apps/ui/src/components/tv/TvShowPanel.tsx b/apps/ui/src/components/tv/TvShowPanel.tsx index fac8a5cc..6f8526c3 100644 --- a/apps/ui/src/components/tv/TvShowPanel.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.tsx @@ -1,4 +1,4 @@ -import { useUIMediaFolderStore, useUIMediaFolderStoreState } from "@/stores/uiMediaFolderStore" +import { useUIMediaFolderStoreState } from "@/stores/uiMediaFolderStore" import { useMediaMetadataQuery } from "@/hooks/mediaMetadata" import { useSelectTvShowForFolderMutation } from "@/hooks/useSelectTvShowForFolderMutation" import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" @@ -14,12 +14,11 @@ import { useTvShowEpisodeVideoCompress } from "@/hooks/tv/useTvShowEpisodeVideoC import { useTvShowEpisodeFormatConvert } from "@/hooks/tv/useTvShowEpisodeFormatConvert" import { useRuleBasedRenameFilesFlow } from "@/hooks/tv/useRuleBasedRenameFilesFlow" import { useRuleBasedRecognizeFlow } from "@/hooks/tv/useRuleBasedRecognizeFlow" -import { useAiBasedRenameFilesFlow } from "@/hooks/tv/useAiBasedRenameFilesFlow" -import { useAiBasedRecognizeFlow } from "@/hooks/tv/useAiBasedRecognizeFlow" +import { useAiBasedRenameEpisodeFlow } from "@/hooks/tv/useAiBasedRenameEpisodeFlow" +import { useAiBasedRecognizeEpisodeFlow } from "@/hooks/tv/useAiBasedRecognizeEpisodeFlow" import { useSelectAndUnselectFileFlow } from "@/hooks/tv/useSelectAndUnselectFileFlow" import { useResolvedLanguages } from "@/hooks/useResolvedLanguages" import { askForRenameFile, askForScrape } from "@/lib/dialogRequestEvents" -import { usePlansQuery } from "@/hooks/plans" import { MediaFileTable } from "@/components/media/MediaFileTable" import type { MediaFileTableContextMenuProps, @@ -42,13 +41,11 @@ import { import { useLatest } from "react-use" import type { UIMediaFolderStatus } from "@/types/UIMediaFolder" import type { UIRecognizeMediaFilePlan } from "@/types/UIRecognizeMediaFilePlan" -import { - TvShowAppPlanPromptProvider, - type TvShowAppPlanPromptContextValue, -} from "./plans/TvShowAppPlanPromptContext" import { useTvShowPanel } from "@/hooks/useTvShowPanel" import { RuleBasedRenameFilePrompt } from "../RuleBasedRenameFilePrompt" import { RuleBasedRecognizePrompt } from "./RuleBasedRecognizePrompt" +import { AiBasedRenameEpisodePrompt } from "./AiBasedRenameEpisodePrompt" +import { AiBasedRecognizeEpisodePrompt } from "./AiBasedRecognizeEpisodePrompt" import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan" @@ -116,12 +113,6 @@ function TvShowPanel() { uiFolderRow?.status, ]) - // Plans for the current folder, backed by TanStack Query. - const { data: plans = [] } = usePlansQuery(mediaMetadata?.mediaFolderPath) - - const setSelectedByMediaFolderPath = useCallback((path: string) => { - useUIMediaFolderStore.getState().applyFolderClick(path, false) - }, []) const { selectTvShowForFolderMutation, updateMediaMetadata } = useSelectTvShowForFolderMutation() const { mutateAsync: fetchMediaMetadata } = useFetchMediaMetadataMutation() @@ -226,11 +217,8 @@ function TvShowPanel() { mediaMetadata, }) - const aiRenameFlow = useAiBasedRenameFilesFlow({ - plans, + const aiRenameFlow = useAiBasedRenameEpisodeFlow({ mediaMetadata, - onAppRenameConfirm: async () => {}, - setSelectedMediaMetadataByMediaFolderPath: setSelectedByMediaFolderPath, onFlowStart: () => setEpisodeTableLayout("simple"), }) @@ -238,8 +226,7 @@ function TvShowPanel() { mediaMetadata, }) - const aiRecognizeFlow = useAiBasedRecognizeFlow({ - plans, + const aiRecognizeFlow = useAiBasedRecognizeEpisodeFlow({ mediaMetadata, beforeConfirm: recognizeBeforeConfirm, onFlowStart: () => setEpisodeTableLayout("simple"), @@ -284,19 +271,6 @@ function TvShowPanel() { ], ) - const appPlanPromptValue = useMemo((): TvShowAppPlanPromptContextValue => { - return { - aiRenamePlan: aiRenameFlow.plan, - aiRenamePromptStatus: aiRenameFlow.promptStatus, - aiRecognizePlan: aiRecognizeFlow.plan, - aiRecognizePromptStatus: aiRecognizeFlow.promptStatus, - onAiRenameConfirm: aiRenameFlow.onConfirm, - onAiRenameCancel: aiRenameFlow.onCancel, - onAiRecognizeConfirm: aiRecognizeFlow.onConfirm, - onAiRecognizeCancel: aiRecognizeFlow.onCancel, - } - }, [aiRenameFlow, aiRecognizeFlow]) - const latestMediaMetadata = useLatest(mediaMetadata) const planId = useMemo(() => { return plan?.id ?? '' }, [plan]) const selectedEpisodesByPlanId = useRef>(new Map()) @@ -388,7 +362,6 @@ function TvShowPanel() { }, [recognizeFlow, selectedEpisodes]) return ( -
@@ -399,7 +372,11 @@ function TvShowPanel() { { } - + + + + + @@ -454,7 +431,6 @@ function TvShowPanel() { )}
-
) } diff --git a/apps/ui/src/components/tv/TvShowPanelPrompts.tsx b/apps/ui/src/components/tv/TvShowPanelPrompts.tsx index c189916d..e6c71c9b 100644 --- a/apps/ui/src/components/tv/TvShowPanelPrompts.tsx +++ b/apps/ui/src/components/tv/TvShowPanelPrompts.tsx @@ -1,22 +1,8 @@ import { UseNfoPrompt } from "./UseNfoPrompt" -import { AiBasedRenameEpisodePrompt } from "./AiBasedRenameEpisodePrompt" -import { AiBasedRecognizeEpisodePrompt } from "./AiBasedRecognizeEpisodePrompt" import type { TMDBTVShow } from "@smm/types" import { useTvShowPromptsStore } from "@/stores/tvShowPromptsStore" -import { useTvShowAppPlanPrompts } from "./plans/TvShowAppPlanPromptContext" export function TvShowPanelPrompts() { - const { - aiRenamePlan, - aiRenamePromptStatus, - aiRecognizePlan, - aiRecognizePromptStatus, - onAiRenameConfirm, - onAiRenameCancel, - onAiRecognizeConfirm, - onAiRecognizeCancel, - } = useTvShowAppPlanPrompts() - const closeUseNfoPrompt = useTvShowPromptsStore((state) => state.closeUseNfoPrompt) const useNfoPrompt = useTvShowPromptsStore((state) => state.useNfoPrompt) @@ -59,28 +45,6 @@ export function TvShowPanelPrompts() { } }} /> - - { - await onAiRenameConfirm() - }} - onCancel={() => { - void onAiRenameCancel() - }} - /> - - { - void onAiRecognizeConfirm() - }} - onCancel={() => { - void onAiRecognizeCancel() - }} - />
) } diff --git a/apps/ui/src/components/tv/plans/TvShowAppPlanPromptContext.tsx b/apps/ui/src/components/tv/plans/TvShowAppPlanPromptContext.tsx deleted file mode 100644 index 0169791d..00000000 --- a/apps/ui/src/components/tv/plans/TvShowAppPlanPromptContext.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { createContext, useContext, type ReactNode } from "react" -import type { UIRecognizeMediaFilePlan } from "@/types/UIRecognizeMediaFilePlan" -import type { UIRenameFilesPlan } from "@/types/UIRenameFilesPlan" - -export interface RenameToolbarOption { - value: "plex" | "emby" - label: string -} - -export interface TvShowAppPlanPromptContextValue { - aiRenamePlan: UIRenameFilesPlan | undefined - aiRenamePromptStatus: "generating" | "wait-for-ack" - aiRecognizePlan: UIRecognizeMediaFilePlan | undefined - aiRecognizePromptStatus: "generating" | "wait-for-ack" - - onAiRenameConfirm: () => void | Promise - onAiRenameCancel: () => void | Promise - onAiRecognizeConfirm: () => void | Promise - onAiRecognizeCancel: () => void | Promise -} - -const TvShowAppPlanPromptContext = createContext( - null, -) - -export function TvShowAppPlanPromptProvider({ - value, - children, -}: { - value: TvShowAppPlanPromptContextValue - children: ReactNode -}) { - return ( - - {children} - - ) -} - -// eslint-disable-next-line react-refresh/only-export-components -export function useTvShowAppPlanPrompts(): TvShowAppPlanPromptContextValue { - const ctx = useContext(TvShowAppPlanPromptContext) - if (!ctx) { - throw new Error( - "useTvShowAppPlanPrompts must be used within TvShowAppPlanPromptProvider", - ) - } - return ctx -} diff --git a/apps/ui/src/hooks/tv/useAiBasedRecognizeFlow.ts b/apps/ui/src/hooks/tv/useAiBasedRecognizeFlow.ts deleted file mode 100644 index 837721cd..00000000 --- a/apps/ui/src/hooks/tv/useAiBasedRecognizeFlow.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { useCallback, useEffect, useMemo } from "react" -import { toast } from "sonner" -import { handleAiRecognizeConfirm } from "@/actions/handleAiRecognizeConfirm" -import { cleanupRecognizePlan } from "@/ai/tools/EndRecognizeTask" -import { selectActiveAiPlan } from "@/components/tv/plans/selectActiveAppPlan" -import { toUpdatePlanPatch, useUpdatePlanMutation } from "@/hooks/plans" -import { useUpdateMediaMetadataMutation } from "@/hooks/mediaMetadata/useUpdateMediaMetadataMutation" -import type { MediaMetadata } from "@smm/types" -import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan" -import type { UIPlan } from "@/types/UIPlan" -import type { UIRecognizeMediaFilePlan } from "@/types/UIRecognizeMediaFilePlan" - -export interface UseAiBasedRecognizeFlowOptions { - plans: UIPlan[] - mediaMetadata: MediaMetadata | undefined - beforeConfirm: (plan: UIRecognizeMediaFilePlan) => UIRecognizeMediaFilePlan - /** Called when an AI recognize plan is detected (e.g. switch episode table to simple layout). */ - onFlowStart?: () => void -} - -/** - * Surfaces AI/MCP-created recognize plans for preview mode and - * AiBasedRecognizePrompt. Rule-based (creator: 'app') plans are handled - * exclusively by useRuleBasedRecognizeFlow. - * - * Not gated by `isAiFeatureEnabled`: pending MCP/backend plans must always - * surface so the user can confirm or reject them (especially on HarmonyOS - * where in-app AI chat defaults off but external MCP is supported). - */ -export function useAiBasedRecognizeFlow({ - plans, - mediaMetadata, - beforeConfirm, - onFlowStart, -}: UseAiBasedRecognizeFlowOptions) { - const updatePlanMutation = useUpdatePlanMutation() - const { persistMediaMetadata } = useUpdateMediaMetadataMutation() - const mediaFolderPath = mediaMetadata?.mediaFolderPath - - const plan = useMemo( - () => - selectActiveAiPlan( - plans, - mediaFolderPath, - "recognize-media-file", - ), - [plans, mediaFolderPath], - ) - - const promptStatus: "generating" | "wait-for-ack" = - plan?.status === "preparing" ? "generating" : "wait-for-ack" - - const onConfirm = useCallback(async () => { - if (!plan || !mediaMetadata?.mediaFolderPath) return - const preparedPlan = beforeConfirm(plan) as RecognizeMediaFilePlan - await handleAiRecognizeConfirm( - preparedPlan, - mediaMetadata, - persistMediaMetadata, - async (id, patch) => { - await updatePlanMutation.mutateAsync({ - id, - mediaFolderPath: mediaMetadata.mediaFolderPath!, - patch: toUpdatePlanPatch(patch), - }) - }, - ) - await cleanupRecognizePlan(plan.id) - }, [ - plan, - mediaMetadata, - beforeConfirm, - persistMediaMetadata, - updatePlanMutation, - ]) - - const onCancel = useCallback(async () => { - if (!plan || !mediaFolderPath) return - try { - await updatePlanMutation.mutateAsync({ - id: plan.id, - mediaFolderPath, - patch: toUpdatePlanPatch({ status: "rejected" }), - }) - await cleanupRecognizePlan(plan.id) - } catch (error) { - console.error("[useAiBasedRecognizeFlow] Error rejecting recognize plan:", error) - toast.error( - `Failed to reject recognize plan: ${error instanceof Error ? error.message : "Unknown error"}`, - ) - } - }, [plan, mediaFolderPath, updatePlanMutation]) - - useEffect(() => { - if (plan) { - onFlowStart?.() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [plan?.id, onFlowStart]) - - return { - plan, - promptStatus, - onConfirm, - onCancel, - } -} diff --git a/apps/ui/src/hooks/tv/useAiBasedRenameFilesFlow.ts b/apps/ui/src/hooks/tv/useAiBasedRenameFilesFlow.ts deleted file mode 100644 index 7952dc83..00000000 --- a/apps/ui/src/hooks/tv/useAiBasedRenameFilesFlow.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { useCallback, useEffect, useMemo } from "react" -import { toast } from "sonner" -import { cleanupRenamePlan } from "@/ai/plan/cleanupRenamePlan" -import { selectActiveAiPlan } from "@/components/tv/plans/selectActiveAppPlan" -import { useTvShowWebSocketEvents } from "./useTvShowWebSocketEvents" -import { - toUpdatePlanPatch, - usePlansPullOnVisible, - useUpdatePlanMutation, -} from "@/hooks/plans" -import type { MediaMetadata } from "@smm/types" -import type { UIPlan } from "@/types/UIPlan" -import type { UIRenameFilesPlan } from "@/types/UIRenameFilesPlan" - -export interface UseAiBasedRenameFilesFlowOptions { - plans: UIPlan[] - mediaMetadata: MediaMetadata | undefined - onAppRenameConfirm: (planId: string) => Promise - setSelectedMediaMetadataByMediaFolderPath: (path: string) => void - /** Called when an AI rename plan is detected (e.g. switch episode table to simple layout). */ - onFlowStart?: () => void -} - -/** - * Surfaces AI/MCP-created rename plans for preview mode and - * AiBasedRenameFilePrompt. Rule-based (creator: 'app') plans are handled - * exclusively by useRuleBasedRenameFilesFlow. - * - * Not gated by `isAiFeatureEnabled` — see useAiBasedRecognizeFlow. - */ -export function useAiBasedRenameFilesFlow({ - plans, - mediaMetadata, - onAppRenameConfirm, - setSelectedMediaMetadataByMediaFolderPath, - onFlowStart, -}: UseAiBasedRenameFilesFlowOptions) { - const updatePlanMutation = useUpdatePlanMutation() - const mediaFolderPath = mediaMetadata?.mediaFolderPath - - const plan = useMemo( - () => - selectActiveAiPlan( - plans, - mediaFolderPath, - "rename-files", - ), - [plans, mediaFolderPath], - ) - - const promptStatus: "generating" | "wait-for-ack" = - plan?.status === "preparing" ? "generating" : "wait-for-ack" - - useEffect(() => { - console.log( - `[rename] useAiBasedRenameFilesFlow: plan=${plan ? `id=${plan.id} status=${plan.status}` : "undefined"}, ` + - `mediaFolderPath=${mediaFolderPath}, plansCount=${plans.length}`, - ) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [plan?.id, plan?.status, plans.length, mediaFolderPath]) - - const onConfirm = useCallback(async () => { - if (!plan) return - await onAppRenameConfirm(plan.id) - }, [plan, onAppRenameConfirm]) - - const onCancel = useCallback(async () => { - if (!plan || !mediaFolderPath) return - try { - await updatePlanMutation.mutateAsync({ - id: plan.id, - mediaFolderPath, - patch: toUpdatePlanPatch({ status: "rejected" }), - }) - await cleanupRenamePlan(plan.id) - } catch (error) { - console.error("[useAiBasedRenameFilesFlow] Error rejecting rename plan:", error) - toast.error( - `Failed to reject rename plan: ${error instanceof Error ? error.message : "Unknown error"}`, - ) - } - }, [plan, mediaFolderPath, updatePlanMutation]) - - useEffect(() => { - if (plan) { - onFlowStart?.() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [plan?.id, onFlowStart]) - - useTvShowWebSocketEvents({ - setSelectedMediaMetadataByMediaFolderPath, - }) - - usePlansPullOnVisible() - - return { - plan, - promptStatus, - onConfirm, - onCancel, - } -} diff --git a/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts b/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts index e2cd43f8..ce3316ef 100644 --- a/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts +++ b/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts @@ -5,10 +5,14 @@ import { useApplyPlanMutation } from "@/hooks/plans/useApplyPlanMutation" import { useRejectPlanMutation } from "@/hooks/plans/useRejectPlanMutation" import { useTryToRenameEpisodesMutation } from "@/hooks/plans/useTryToRenameEpisodesMutation" import { useTranslation } from "@/lib/i18n" -import type { RenameToolbarOption } from "@/components/tv/plans/TvShowAppPlanPromptContext" import type { MediaMetadata } from "@smm/types" import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan" +export interface RenameToolbarOption { + value: "plex" | "emby" + label: string +} + export interface UseRuleBasedRenameFilesFlowOptions { mediaMetadata: MediaMetadata | undefined } From 0066c8fa81581162f37f805e640b53159ca833e1 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 22:35:06 +0800 Subject: [PATCH 50/83] chore(ui): drop unused imports in recognize episode flow test --- apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts b/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts index 7dee0636..65871240 100644 --- a/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts +++ b/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it, vi } from "vitest" import { renderHook } from "@testing-library/react" import { useAiBasedRecognizeEpisodeFlow } from "./useAiBasedRecognizeEpisodeFlow" -import { handleAiRecognizeConfirm } from "@/actions/handleAiRecognizeConfirm" -import { cleanupRecognizePlan } from "@/ai/tools/EndRecognizeTask" import type { UIRecognizeMediaFilePlan } from "@/types/UIRecognizeMediaFilePlan" import type { MediaMetadata } from "@smm/types" From a5c2ec3f8078aaea5cfb9c859d4eea7cd4c2ddbb Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 22:50:34 +0800 Subject: [PATCH 51/83] fix(ui): sync i18next.d.ts with aiAgent settings locale keys --- apps/ui/src/types/i18next.d.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/ui/src/types/i18next.d.ts b/apps/ui/src/types/i18next.d.ts index 63a9a584..62e2dc1f 100644 --- a/apps/ui/src/types/i18next.d.ts +++ b/apps/ui/src/types/i18next.d.ts @@ -1106,6 +1106,12 @@ interface SettingsResources { checkSuccess: string checkError: string } + aiAgent: { + title: string + description: string + metadataWrite: string + metadataWriteDescription: string + } feedback: { title: string description: string @@ -1153,6 +1159,7 @@ interface SettingsResources { title: string general: string ai: string + aiAgent: string mediaDatabases: string renameRules: string externalApps: string From f2bd4cd1c1b4c1e846e215edcbfa2577c5833235 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 6 Sep 2026 23:23:02 +0800 Subject: [PATCH 52/83] fix(ui): apply AI rename plan on prompt confirm --- .../tv/useAiBasedRenameEpisodeFlow.test.ts | 43 ++++++++++++++++++- .../hooks/tv/useAiBasedRenameEpisodeFlow.ts | 20 +++++++-- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts b/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts index 58bd05a1..7e8e5a54 100644 --- a/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts +++ b/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest" +import { beforeEach, describe, expect, it, vi } from "vitest" import { renderHook } from "@testing-library/react" import { useAiBasedRenameEpisodeFlow } from "./useAiBasedRenameEpisodeFlow" import type { UIRenameFilesPlan } from "@/types/UIRenameFilesPlan" @@ -7,13 +7,20 @@ import type { MediaMetadata } from "@smm/types" const h = vi.hoisted(() => ({ plans: [] as unknown[], updatePlanMutateAsync: vi.fn(), + applyPlanMutateAsync: vi.fn(), cleanupRenamePlan: vi.fn(), + toastError: vi.fn(), +})) + +vi.mock("sonner", () => ({ + toast: { error: h.toastError, success: vi.fn() }, })) vi.mock("@/hooks/plans", () => ({ usePlansQuery: () => ({ data: h.plans }), usePlansPullOnVisible: () => undefined, useUpdatePlanMutation: () => ({ mutateAsync: h.updatePlanMutateAsync }), + useApplyPlanMutation: () => ({ mutateAsync: h.applyPlanMutateAsync }), toUpdatePlanPatch: (patch: unknown) => patch, })) @@ -33,6 +40,10 @@ describe("useAiBasedRenameEpisodeFlow", () => { const mediaFolderPath = "/storage/Users/currentUser/Download/Anime/show" const mediaMetadata = { mediaFolderPath, type: "tvshow-folder" } as MediaMetadata + beforeEach(() => { + vi.clearAllMocks() + }) + const pendingAiPlan: UIRenameFilesPlan = { id: "rename-plan-1", task: "rename-files", @@ -91,4 +102,34 @@ describe("useAiBasedRenameEpisodeFlow", () => { }) expect(h.cleanupRenamePlan).toHaveBeenCalledWith("rename-plan-1") }) + + it("applies the full plan on confirm and cleans up the draft", async () => { + h.plans = [pendingAiPlan] + const { result } = renderHook(() => + useAiBasedRenameEpisodeFlow({ mediaMetadata }), + ) + + await result.current.onConfirm() + + expect(h.applyPlanMutateAsync).toHaveBeenCalledWith({ + id: "rename-plan-1", + mediaFolderPath, + }) + expect(h.cleanupRenamePlan).toHaveBeenCalledWith("rename-plan-1") + }) + + it("shows a toast and keeps the plan when apply fails", async () => { + h.plans = [pendingAiPlan] + h.applyPlanMutateAsync.mockRejectedValueOnce(new Error("disk locked")) + const { result } = renderHook(() => + useAiBasedRenameEpisodeFlow({ mediaMetadata }), + ) + + await result.current.onConfirm() + + expect(h.toastError).toHaveBeenCalledWith( + expect.stringContaining("Failed to apply rename plan"), + ) + expect(h.cleanupRenamePlan).not.toHaveBeenCalled() + }) }) diff --git a/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.ts b/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.ts index 417fb6b0..ed1185ad 100644 --- a/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.ts +++ b/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.ts @@ -5,6 +5,7 @@ import { selectActiveAiPlan } from "@/components/tv/plans/selectActiveAppPlan" import { useTvShowWebSocketEvents } from "./useTvShowWebSocketEvents" import { toUpdatePlanPatch, + useApplyPlanMutation, usePlansPullOnVisible, usePlansQuery, useUpdatePlanMutation, @@ -35,6 +36,7 @@ export function useAiBasedRenameEpisodeFlow({ }: UseAiBasedRenameEpisodeFlowOptions) { const { data: plans = [] } = usePlansQuery(mediaMetadata?.mediaFolderPath) const updatePlanMutation = useUpdatePlanMutation() + const applyPlanMutation = useApplyPlanMutation() const mediaFolderPath = mediaMetadata?.mediaFolderPath const plan = useMemo( @@ -58,11 +60,21 @@ export function useAiBasedRenameEpisodeFlow({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [plan?.id, plan?.status, plans.length, mediaFolderPath]) - // The actual rename is performed by the backend that created the plan; - // confirming from the app only acknowledges the prompt (pre-existing no-op). + // Applies the pending plan via POST /api/apply-plan (AI rename approval in + // docs/dev/rename-episodes.md). useApplyPlanMutation removes the plan from + // the plans cache on success, which closes the prompt. const onConfirm = useCallback(async () => { - if (!plan) return - }, [plan]) + if (!plan || !mediaFolderPath) return + try { + await applyPlanMutation.mutateAsync({ id: plan.id, mediaFolderPath }) + await cleanupRenamePlan(plan.id) + } catch (error) { + console.error("[useAiBasedRenameEpisodeFlow] Error applying rename plan:", error) + toast.error( + `Failed to apply rename plan: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } + }, [plan, mediaFolderPath, applyPlanMutation]) const onCancel = useCallback(async () => { if (!plan || !mediaFolderPath) return From de452683c0e197675f98bac6bc28fafe0b4e7b1d Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 7 Sep 2026 00:00:24 +0800 Subject: [PATCH 53/83] refactor: remove legacy rename begin/add/end dead code --- apps/core/src/plan/renamePlan.test.ts | 38 +----- apps/core/src/plan/renamePlan.ts | 81 +---------- .../applyRenameFilesPlanForTvShow.test.ts | 129 ------------------ .../actions/applyRenameFilesPlanForTvShow.ts | 44 ------ ...handleRenamePromptConfirmForTvShow.test.ts | 126 ----------------- .../handleRenamePromptConfirmForTvShow.ts | 91 ------------ apps/ui/src/ai/plan/renamePlanService.ts | 92 ------------- .../applyRenamePairsToUIMediaMetadata.test.ts | 28 ---- .../lib/applyRenamePairsToUIMediaMetadata.ts | 39 ------ .../src/lib/buildTvShowRenameListForPlan.ts | 65 --------- packages/core-routes/src/tools/plans.test.ts | 40 +----- packages/core-routes/src/tools/plans.ts | 66 --------- 12 files changed, 4 insertions(+), 835 deletions(-) delete mode 100644 apps/ui/src/actions/applyRenameFilesPlanForTvShow.test.ts delete mode 100644 apps/ui/src/actions/applyRenameFilesPlanForTvShow.ts delete mode 100644 apps/ui/src/actions/handleRenamePromptConfirmForTvShow.test.ts delete mode 100644 apps/ui/src/actions/handleRenamePromptConfirmForTvShow.ts delete mode 100644 apps/ui/src/ai/plan/renamePlanService.ts delete mode 100644 apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.test.ts delete mode 100644 apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.ts delete mode 100644 apps/ui/src/lib/buildTvShowRenameListForPlan.ts diff --git a/apps/core/src/plan/renamePlan.test.ts b/apps/core/src/plan/renamePlan.test.ts index a7571275..c76a01c7 100644 --- a/apps/core/src/plan/renamePlan.test.ts +++ b/apps/core/src/plan/renamePlan.test.ts @@ -1,46 +1,12 @@ -import { describe, it, expect, vi } from 'vitest' -import { - createEmptyRenamePlan, - assertEpisodeVideoFile, - prepareAppendRenameEntry, -} from './renamePlan' +import { describe, it, expect } from 'vitest' +import { assertEpisodeVideoFile } from './renamePlan' import type { MediaMetadata } from '@smm/types' describe('renamePlan', () => { - it('createEmptyRenamePlan normalizes folder path', () => { - const plan = createEmptyRenamePlan('C:\\media\\show') - expect(plan.task).toBe('rename-files') - expect(plan.status).toBe('pending') - expect(plan.files).toEqual([]) - expect(plan.id).toBeTruthy() - expect(plan.mediaFolderPath).not.toContain('\\') - }) - it('assertEpisodeVideoFile fails when file is not in metadata', () => { const metadata = { mediaFiles: [{ absolutePath: '/media/show/S01E01.mp4' }], } as MediaMetadata expect(assertEpisodeVideoFile(metadata, '/media/show/other.mp4')).toBeDefined() }) - - it('prepareAppendRenameEntry delegates validation to deps', async () => { - const plan = createEmptyRenamePlan('/media/show') - const validateOperations = vi.fn(async () => ({ - isValid: false, - errors: ['bad'], - validatedRenames: [], - })) - const result = await prepareAppendRenameEntry( - plan, - { from: '/media/show/a.mp4', to: '/media/show/b.mp4' }, - { - validateOperations, - getMediaMetadata: async () => null, - }, - ) - expect('error' in result).toBe(true) - if ('error' in result) { - expect(result.error).toContain('bad') - } - }) }) diff --git a/apps/core/src/plan/renamePlan.ts b/apps/core/src/plan/renamePlan.ts index a4452115..6515887d 100644 --- a/apps/core/src/plan/renamePlan.ts +++ b/apps/core/src/plan/renamePlan.ts @@ -1,29 +1,6 @@ -import type { MediaMetadata, RenameValidationResult } from '@smm/types' -import type { RenameFilesPlan } from '@smm/types/RenameFilesPlan' -import type { PlanCreator, PlanStatus } from '@smm/types/planCommon' +import type { MediaMetadata } from '@smm/types' import { Path } from '@smm/utils/path' -export function createEmptyRenamePlan( - mediaFolderPath: string, - id?: string, - options?: { creator?: PlanCreator; status?: PlanStatus }, -): RenameFilesPlan { - const planId = - id ?? - (typeof crypto !== 'undefined' && 'randomUUID' in crypto - ? crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(16).slice(2)}`) - - return { - id: planId, - task: 'rename-files', - status: options?.status ?? 'pending', - creator: options?.creator ?? 'app', - mediaFolderPath: Path.posix(mediaFolderPath), - files: [], - } -} - export function assertMediaFolderHasMetadata( exists: boolean, folderPath: string, @@ -47,59 +24,3 @@ export function assertEpisodeVideoFile( } return undefined } - -export interface PrepareAppendRenameEntryDeps { - validateOperations: ( - files: Array<{ from: string; to: string }>, - folderPathInPosix: string, - ) => Promise - getMediaMetadata: ( - folderPathInPosix: string, - ) => Promise -} - -export async function prepareAppendRenameEntry( - plan: RenameFilesPlan, - entry: { from: string; to: string }, - deps: PrepareAppendRenameEntryDeps, -): Promise { - const fromPosix = Path.posix(entry.from) - const toPosix = Path.posix(entry.to) - const candidateFiles = [...plan.files, { from: fromPosix, to: toPosix }] - - const validationResult = await deps.validateOperations( - candidateFiles, - plan.mediaFolderPath, - ) - if (!validationResult.isValid) { - return { error: `Error Reason: ${validationResult.errors.join('\n')}` } - } - - const mm = await deps.getMediaMetadata(plan.mediaFolderPath) - if (!mm) { - return { - error: `Error Reason: Media metadata not found for media folder: ${plan.mediaFolderPath}`, - } - } - - const episodeError = assertEpisodeVideoFile(mm, fromPosix) - if (episodeError) { - return { error: episodeError } - } - - return { - ...plan, - files: [...plan.files, { from: fromPosix, to: toPosix }], - } -} - -export function toUIRenameFilesPlanPaths(plan: RenameFilesPlan): RenameFilesPlan { - return { - ...plan, - mediaFolderPath: Path.toPlatformPath(plan.mediaFolderPath), - files: plan.files.map((f) => ({ - from: Path.toPlatformPath(f.from), - to: Path.toPlatformPath(f.to), - })), - } -} diff --git a/apps/ui/src/actions/applyRenameFilesPlanForTvShow.test.ts b/apps/ui/src/actions/applyRenameFilesPlanForTvShow.test.ts deleted file mode 100644 index 204b45a1..00000000 --- a/apps/ui/src/actions/applyRenameFilesPlanForTvShow.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { Path } from '@smm/utils/path' -import type { RenameFilesRequestBody, RenameFilesResponseBody } from '@smm/types' -import { applyRenameFilesPlanForTvShow } from './applyRenameFilesPlanForTvShow' -import type { UIRenameFilesPlan } from '@/types/UIRenameFilesPlan' - -type RenameFilesApi = (params: RenameFilesRequestBody) => Promise - -describe.skipIf(Path.isWindows())('applyRenameFilesPlanForTvShow', () => { - - let mockRenameFilesApi: ReturnType - - beforeEach(() => { - mockRenameFilesApi = vi.fn().mockResolvedValue({}) - }) - - it('Can rename video file and all supported associated files', async () => { - - const mediaFolderPath = '/media/show' - const plan: UIRenameFilesPlan = { - id: 'plan-1', - task: 'rename-files', - status: 'pending', - mediaFolderPath, - tmp: false, - files: [ - { from: '/media/show/1.mkv', to: '/media/show/S01E01.mkv' }, - ], - } - const localFiles = [ - '/media/show/1.mkv', - '/media/show/1.jpg', - '/media/show/1.srt', - '/media/show/1.mka', - '/media/show/1.nfo', - '/media/show/fanart.jpg', - ] - - await applyRenameFilesPlanForTvShow( - { mediaFolderPath, localFiles, plan }, - { renameFilesApi: mockRenameFilesApi as RenameFilesApi } - ) - - expect(mockRenameFilesApi).toHaveBeenCalledTimes(1) - const [req] = mockRenameFilesApi.mock.calls[0] - expect(req.mediaFolder).toBe(mediaFolderPath) - expect(req.files).toHaveLength(5) - expect(req.files[0].from).toBe(Path.toPlatformPath('/media/show/1.mkv')) - expect(req.files[0].to).toBe(Path.toPlatformPath('/media/show/S01E01.mkv')) - expect(req.files).toContainEqual({ from: Path.toPlatformPath('/media/show/1.jpg'), to: Path.toPlatformPath('/media/show/S01E01.jpg') }) - expect(req.files).toContainEqual({ from: Path.toPlatformPath('/media/show/1.srt'), to: Path.toPlatformPath('/media/show/S01E01.srt') }) - expect(req.files).toContainEqual({ from: Path.toPlatformPath('/media/show/1.mka'), to: Path.toPlatformPath('/media/show/S01E01.mka') }) - expect(req.files).toContainEqual({ from: Path.toPlatformPath('/media/show/1.nfo'), to: Path.toPlatformPath('/media/show/S01E01.nfo') }) - expect(req.files.some((f: { from: string }) => f.from === Path.toPlatformPath('/media/show/fanart.jpg'))).toBe(false) - }) - - it('Can rename video file and subtitle files with language code', async () => { - - const mediaFolderPath = '/media/show' - const plan: UIRenameFilesPlan = { - id: 'plan-1', - task: 'rename-files', - status: 'pending', - mediaFolderPath, - tmp: false, - files: [ - { from: '/media/show/1.mkv', to: '/media/show/S01E01.mkv' }, - ], - } - const localFiles = [ - '/media/show/1.mkv', - '/media/show/1.sc.srt', - '/media/show/1.tc.srt', - ] - - await applyRenameFilesPlanForTvShow( - { mediaFolderPath, localFiles, plan }, - { renameFilesApi: mockRenameFilesApi as RenameFilesApi } - ) - - expect(mockRenameFilesApi).toHaveBeenCalledTimes(1) - const [req] = mockRenameFilesApi.mock.calls[0] - expect(req.mediaFolder).toBe(mediaFolderPath) - expect(req.files).toHaveLength(3) - expect(req.files[0].from).toContain('1.mkv') - expect(req.files[0].to).toContain('S01E01.mkv') - expect(req.files).toContainEqual(expect.objectContaining({ from: expect.stringContaining('1.sc.srt'), to: expect.stringContaining('S01E01.sc.srt') })) - expect(req.files).toContainEqual(expect.objectContaining({ from: expect.stringContaining('1.tc.srt'), to: expect.stringContaining('S01E01.tc.srt') })) - }) - - it('Can rename video file and subtitle files to new path in season folder', async () => { - const mediaFolderPath = '/media/show' - const plan: UIRenameFilesPlan = { - id: 'plan-1', - task: 'rename-files', - status: 'pending', - mediaFolderPath, - tmp: false, - files: [ - { from: '/media/show/1.mkv', to: '/media/show/Season 01/S01E01.mkv' }, - ], - } - const localFiles = [ - '/media/show/1.mkv', - '/media/show/1.jpg', - '/media/show/1.srt', - '/media/show/1.mka', - '/media/show/1.nfo', - '/media/show/fanart.jpg', - ] - - await applyRenameFilesPlanForTvShow( - { mediaFolderPath, localFiles, plan }, - { renameFilesApi: mockRenameFilesApi as RenameFilesApi } - ) - - expect(mockRenameFilesApi).toHaveBeenCalledTimes(1) - const [req] = mockRenameFilesApi.mock.calls[0] - expect(req.mediaFolder).toBe(mediaFolderPath) - expect(req.files).toHaveLength(5) - expect(req.files[0].from).toBe(Path.toPlatformPath('/media/show/1.mkv')) - expect(req.files[0].to).toBe(Path.toPlatformPath('/media/show/Season 01/S01E01.mkv')) - expect(req.files).toContainEqual({ from: Path.toPlatformPath('/media/show/1.jpg'), to: Path.toPlatformPath('/media/show/Season 01/S01E01.jpg') }) - expect(req.files).toContainEqual({ from: Path.toPlatformPath('/media/show/1.srt'), to: Path.toPlatformPath('/media/show/Season 01/S01E01.srt') }) - expect(req.files).toContainEqual({ from: Path.toPlatformPath('/media/show/1.mka'), to: Path.toPlatformPath('/media/show/Season 01/S01E01.mka') }) - expect(req.files).toContainEqual({ from: Path.toPlatformPath('/media/show/1.nfo'), to: Path.toPlatformPath('/media/show/Season 01/S01E01.nfo') }) - }) - -}) diff --git a/apps/ui/src/actions/applyRenameFilesPlanForTvShow.ts b/apps/ui/src/actions/applyRenameFilesPlanForTvShow.ts deleted file mode 100644 index 3bbe0c23..00000000 --- a/apps/ui/src/actions/applyRenameFilesPlanForTvShow.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Path } from "@smm/utils/path"; -import type { UIRenameFilesPlan } from "@/types/UIRenameFilesPlan"; -import type { RenameFilesRequestBody, RenameFilesResponseBody } from "@smm/types"; -import { buildTvShowRenameListForPlan } from "@/lib/buildTvShowRenameListForPlan"; - -export async function applyRenameFilesPlanForTvShow( - options: { - mediaFolderPath: string, - localFiles: string[], - plan: UIRenameFilesPlan, - traceId?: string, - }, - deps: { - renameFilesApi: (params: RenameFilesRequestBody) => Promise, - } -): Promise<{ renameList: Array<{ from: string; to: string }> }> { - - const { mediaFolderPath, traceId } = options; - const renameList = buildTvShowRenameListForPlan(options); - - console.log("[rename] calling /renameFiles API", { - traceId, - mediaFolderPath, - renameCount: renameList.length, - }); - - const filesParam = renameList.map(({ from, to }) => { return { from: Path.toPlatformPath(from), to: Path.toPlatformPath(to) } }); - const req: RenameFilesRequestBody = { - files: filesParam, - traceId: options.traceId, - mediaFolder: mediaFolderPath, - clientId: undefined, - } - - const resp = await deps.renameFilesApi(req); - if(resp.error) { - console.error("[rename] /renameFiles API returned error", { traceId, error: resp.error }); - throw new Error(`/api/renameFiles API error: ${resp.error}`); - } - - console.log("[rename] /renameFiles API succeeded", { traceId, renamedCount: renameList.length }); - - return { renameList }; -} diff --git a/apps/ui/src/actions/handleRenamePromptConfirmForTvShow.test.ts b/apps/ui/src/actions/handleRenamePromptConfirmForTvShow.test.ts deleted file mode 100644 index e5e567a5..00000000 --- a/apps/ui/src/actions/handleRenamePromptConfirmForTvShow.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" -import { toast } from "sonner" -import { - handleRenamePromptConfirmForTvShow, - type SetPlanByIdFn, -} from "./handleRenamePromptConfirmForTvShow" -import { applyRenameFilesPlanForTvShow } from "@/actions/applyRenameFilesPlanForTvShow" -import type { UIMediaMetadata } from "@/types/UIMediaMetadata" -import type { UIRenameFilesPlan } from "@/types/UIRenameFilesPlan" -import type { PersistUIMediaMetadataFn } from "@/types/persistUIMediaMetadata" - -vi.mock("@/actions/applyRenameFilesPlanForTvShow", () => ({ - applyRenameFilesPlanForTvShow: vi.fn(), -})) - -vi.mock("sonner", () => ({ - toast: { - success: vi.fn(), - error: vi.fn(), - }, -})) - -vi.mock("@/lib/mediaFolderFiles", () => ({ - listMediaFolderFilePaths: vi.fn(async () => ["/media/show/1.mkv"]), -})) - -describe("handleRenamePromptConfirmForTvShow", () => { - const mediaFolderPath = "/media/show" - const planId = "plan-1" - - const plan: UIRenameFilesPlan = { - id: planId, - task: "rename-files", - status: "pending", - creator: "app", - mediaFolderPath, - files: [{ from: "/media/show/1.mkv", to: "/media/show/S01E01.mkv" }], - } - - const mediaMetadata: UIMediaMetadata = { - mediaFolderPath, - type: "tvshow-folder", - status: "ok", - files: ["/media/show/1.mkv"], - mediaFiles: [], - } as UIMediaMetadata - - let setPlanById: ReturnType - let persistUiMediaMetadata: ReturnType - let renameFilesApi: ReturnType - - const renameFailedLabel = "Failed to rename file" - const noMediaPathErrorLabel = "No media folder path available" - - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(applyRenameFilesPlanForTvShow).mockResolvedValue({ - renameList: [{ from: "/media/show/1.mkv", to: "/media/show/S01E01.mkv" }], - }) - setPlanById = vi.fn().mockResolvedValue(undefined) - persistUiMediaMetadata = vi.fn().mockResolvedValue(undefined) - renameFilesApi = vi.fn() - }) - - function runHandler( - overrides: { - plan?: UIRenameFilesPlan - mediaMetadata?: UIMediaMetadata - selectedEpisodePaths?: string[] - } = {}, - ) { - return handleRenamePromptConfirmForTvShow( - { - planId, - plan: overrides.plan ?? plan, - mediaMetadata: overrides.mediaMetadata ?? mediaMetadata, - selectedEpisodePaths: overrides.selectedEpisodePaths ?? ["/media/show/1.mkv"], - renameFailedLabel, - noMediaPathErrorLabel, - }, - { - setPlanById: setPlanById as SetPlanByIdFn, - persistUiMediaMetadata: persistUiMediaMetadata as PersistUIMediaMetadataFn, - renameFilesApi, - }, - ) - } - - it("marks plan preparing then completed on success", async () => { - await runHandler() - - expect(setPlanById).toHaveBeenCalledWith(planId, { status: "preparing" }) - expect(applyRenameFilesPlanForTvShow).toHaveBeenCalledTimes(1) - expect(persistUiMediaMetadata).toHaveBeenCalledTimes(1) - expect(setPlanById).toHaveBeenCalledWith(planId, { status: "completed" }) - expect(toast.error).not.toHaveBeenCalled() - }) - - it("shows toast.error and restores plan status when rename API fails", async () => { - vi.mocked(applyRenameFilesPlanForTvShow).mockRejectedValue( - new Error("/api/renameFiles API error: target file already exists"), - ) - - await runHandler() - - expect(persistUiMediaMetadata).not.toHaveBeenCalled() - expect(toast.error).toHaveBeenCalledWith( - `${renameFailedLabel}: /api/renameFiles API error: target file already exists`, - ) - expect(setPlanById).toHaveBeenCalledWith(planId, { status: "pending" }) - expect(setPlanById).not.toHaveBeenCalledWith(planId, { status: "completed" }) - }) - - it("shows toast.error when media folder path is missing", async () => { - const metadataWithoutPath = { - ...mediaMetadata, - mediaFolderPath: undefined, - } as UIMediaMetadata - - await runHandler({ mediaMetadata: metadataWithoutPath }) - - expect(applyRenameFilesPlanForTvShow).not.toHaveBeenCalled() - expect(toast.error).toHaveBeenCalledWith(noMediaPathErrorLabel) - expect(setPlanById).not.toHaveBeenCalled() - }) -}) diff --git a/apps/ui/src/actions/handleRenamePromptConfirmForTvShow.ts b/apps/ui/src/actions/handleRenamePromptConfirmForTvShow.ts deleted file mode 100644 index 6f86fc06..00000000 --- a/apps/ui/src/actions/handleRenamePromptConfirmForTvShow.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan" -import { toast } from "sonner" -import type { MediaMetadata } from "@/lib/mediaFolderFiles" -import { listMediaFolderFilePaths } from "@/lib/mediaFolderFiles" -import type { UIPlan } from "@/types/UIPlan" -import type { UIRenameFilesPlan } from "@/types/UIRenameFilesPlan" -import type { PersistUIMediaMetadataFn } from "@/types/persistUIMediaMetadata" -import { applyRenameFilesPlanForTvShow } from "@/actions/applyRenameFilesPlanForTvShow" -import { applyRenamePairsToUIMediaMetadata } from "@/lib/applyRenamePairsToUIMediaMetadata" -import { rebuildRenamePlanWithSelectedEpisodes } from "@/components/tv/TvShowPanelUtils" -export type SetPlanByIdFn = (id: string, payload: Partial) => void | Promise - -export type RenameFilesApi = Parameters[1]["renameFilesApi"] - -export async function handleRenamePromptConfirmForTvShow( - options: { - planId: string - plan: UIRenameFilesPlan - mediaMetadata: MediaMetadata - selectedEpisodePaths: string[] - renameFailedLabel: string - noMediaPathErrorLabel: string - }, - deps: { - setPlanById: SetPlanByIdFn - persistUiMediaMetadata: PersistUIMediaMetadataFn - renameFilesApi: RenameFilesApi - }, -): Promise { - const { - planId, - plan, - mediaMetadata, - selectedEpisodePaths, - renameFailedLabel, - noMediaPathErrorLabel, - } = options - const { setPlanById, persistUiMediaMetadata, renameFilesApi } = deps - - const folderFiles = await listMediaFolderFilePaths(mediaMetadata.mediaFolderPath!) - if (!mediaMetadata.mediaFolderPath || folderFiles.length === 0) { - console.warn("[rename] cannot apply rename — folder path or file list missing", { planId }) - toast.error(noMediaPathErrorLabel) - return - } - - const actualPlan: UIRenameFilesPlan = rebuildRenamePlanWithSelectedEpisodes( - plan as RenameFilesPlan, - selectedEpisodePaths, - ) - await setPlanById(planId, { status: "preparing" }) - const renameTraceId = `RuleBasedRenameConfirm-${planId}` - - try { - const { renameList } = await applyRenameFilesPlanForTvShow( - { - mediaFolderPath: mediaMetadata.mediaFolderPath, - localFiles: folderFiles, - plan: actualPlan, - traceId: renameTraceId, - }, - { renameFilesApi }, - ) - console.log("[rename] disk rename succeeded, updating local metadata", { - planId, - traceId: renameTraceId, - renamedCount: renameList.length, - }) - const updatedMetadata = applyRenamePairsToUIMediaMetadata(mediaMetadata, renameList) - await persistUiMediaMetadata(mediaMetadata.mediaFolderPath, updatedMetadata, { - traceId: renameTraceId, - }) - await setPlanById(planId, { status: "completed" }) - console.log("[rename] rename plan completed and closed", { planId, traceId: renameTraceId }) - } catch (error) { - console.error("[rename] disk rename or metadata update failed", { - planId, - traceId: renameTraceId, - error, - }) - const errorMessage = error instanceof Error ? error.message : "Unknown error" - toast.error(`${renameFailedLabel}: ${errorMessage}`) - try { - await setPlanById(planId, { status: "pending" }) - console.log("[rename] rename plan restored to pending so user can retry", { planId }) - } catch (revertError) { - console.error("[rename] failed to restore rename plan after error", { planId, error: revertError }) - toast.error(renameFailedLabel) - } - } -} diff --git a/apps/ui/src/ai/plan/renamePlanService.ts b/apps/ui/src/ai/plan/renamePlanService.ts deleted file mode 100644 index 2ca49fc9..00000000 --- a/apps/ui/src/ai/plan/renamePlanService.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { RenameFilesPlan } from '@smm/types/RenameFilesPlan' -import type { RenameValidationResult } from '@smm/types' -import { - assertMediaFolderHasMetadata, - prepareAppendRenameEntry, -} from '@smm/core/plan/renamePlan' -import { validateRenameOperationsSync } from '@smm/core/validations/rename/validateRenameOperationsSync' -import { validateRenameOperationsApi } from '@/api/validateRenameOperations' -import { resolveMediaMetadataForFolderPath } from '@/ai/mediaMetadataToolBridge' -import { getPlanById } from '@/api/getPlanById' -import { updatePlan } from '@/api/updatePlan' -import { getPlanDraft, setPlanDraft } from './aiPlanDrafts' - -async function validateRenameOperationsForFrontend( - files: Array<{ from: string; to: string }>, - folderPathInPosix: string, -): Promise { - const syncResult = validateRenameOperationsSync(files, folderPathInPosix) - if (!syncResult.isValid) { - return syncResult - } - - const apiResult = await validateRenameOperationsApi({ - mediaFolderPath: folderPathInPosix, - files, - filesystemCheck: true, - }) - - if (apiResult.error) { - return { - isValid: false, - errors: [apiResult.error], - validatedRenames: [], - } - } - - return apiResult.data ?? syncResult -} - -export async function assertRenameMediaFolderOpened( - mediaFolderPath: string, -): Promise { - const metadata = await resolveMediaMetadataForFolderPath(mediaFolderPath) - return assertMediaFolderHasMetadata(!!metadata, mediaFolderPath) -} - -export async function resolveRenamePlanDraft( - planId: string, -): Promise { - const normalizedId = planId.trim() - const draft = getPlanDraft(normalizedId) - if (draft?.task === 'rename-files') { - return draft - } - - const resp = await getPlanById(normalizedId) - if (resp.error || !resp.data?.plan || resp.data.plan.task !== 'rename-files') { - return null - } - - const plan = resp.data.plan as RenameFilesPlan - setPlanDraft(plan) - return plan -} - -export async function appendRenameEntryWithValidation( - planId: string, - entry: { from: string; to: string }, -): Promise { - const plan = await resolveRenamePlanDraft(planId) - if (!plan) { - return { error: `Error Reason: Task with id "${planId.trim()}" not found` } - } - - const result = await prepareAppendRenameEntry(plan, entry, { - validateOperations: validateRenameOperationsForFrontend, - getMediaMetadata: async (folderPathInPosix) => { - return (await resolveMediaMetadataForFolderPath(folderPathInPosix)) ?? null - }, - }) - - if ('error' in result) { - return result - } - - const resp = await updatePlan(planId.trim(), { files: result.files }) - if (resp.error || !resp.data) { - return { error: resp.error ?? 'updatePlan failed' } - } - setPlanDraft(resp.data.plan as RenameFilesPlan) - return result -} diff --git a/apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.test.ts b/apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.test.ts deleted file mode 100644 index 16a05386..00000000 --- a/apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { applyRenamePairsToUIMediaMetadata } from "./applyRenamePairsToUIMediaMetadata"; -import type { MediaMetadata } from "@smm/types"; - -describe("applyRenamePairsToUIMediaMetadata", () => { - it("remaps mediaFiles paths", () => { - const meta = { - mediaFolderPath: "/show", - type: "tvshow-folder" as const, - mediaFiles: [ - { - absolutePath: "/show/old.mkv", - seasonNumber: 1, - episodeNumber: 1, - subtitleFilePaths: ["/show/old.srt"], - }, - ], - } satisfies MediaMetadata; - - const next = applyRenamePairsToUIMediaMetadata(meta, [ - { from: "/show/old.mkv", to: "/show/new.mkv" }, - { from: "/show/old.srt", to: "/show/new.srt" }, - ]); - - expect(next.mediaFiles?.[0]?.absolutePath).toBe("/show/new.mkv"); - expect(next.mediaFiles?.[0]?.subtitleFilePaths).toEqual(["/show/new.srt"]); - }); -}); diff --git a/apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.ts b/apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.ts deleted file mode 100644 index 216df44d..00000000 --- a/apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { MediaFileMetadata, MediaMetadata } from "@smm/types" -import { Path } from "@smm/utils/path" - -function pathKey(p: string): string { - try { - return Path.posix(p) - } catch { - return p - } -} - -/** - * Apply completed on-disk renames to in-memory metadata (mediaFiles). - * Pairs must match what was passed to `/api/renameFiles` (POSIX paths as stored in metadata). - */ -export function applyRenamePairsToUIMediaMetadata( - metadata: MediaMetadata, - pairs: Array<{ from: string; to: string }>, -): MediaMetadata { - const map = new Map() - for (const { from, to } of pairs) { - map.set(pathKey(from), to) - } - const remap = (p: string) => map.get(pathKey(p)) ?? p - - const next: MediaMetadata = { ...metadata } - if (next.mediaFiles?.length) { - next.mediaFiles = next.mediaFiles.map( - (mf): MediaFileMetadata => ({ - seasonNumber: mf.seasonNumber, - episodeNumber: mf.episodeNumber, - absolutePath: remap(mf.absolutePath), - subtitleFilePaths: mf.subtitleFilePaths?.map(remap), - audioFilePaths: mf.audioFilePaths?.map(remap), - }), - ) - } - return next -} diff --git a/apps/ui/src/lib/buildTvShowRenameListForPlan.ts b/apps/ui/src/lib/buildTvShowRenameListForPlan.ts deleted file mode 100644 index c15f93ae..00000000 --- a/apps/ui/src/lib/buildTvShowRenameListForPlan.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { basename, extname, join } from "@/lib/path"; -import { findAssociatedFiles } from "@/lib/utils"; -import type { UIRenameFilesPlan } from "@/types/UIRenameFilesPlan"; -import { ext } from "@smm/utils/path"; -import { subtitleFileExtensions } from "@smm/types/mediaFileExtensions"; -import Debug from "debug"; - -const debug = Debug("buildTvShowRenameListForPlan"); - -/** - * Full rename list for a TV show rename plan (video rows + associated subtitles/nfo/etc.), - * same ordering as {@link applyRenameFilesPlanForTvShow} sends to the API. - */ -export function buildTvShowRenameListForPlan(options: { - mediaFolderPath: string; - localFiles: string[]; - plan: UIRenameFilesPlan; - traceId?: string; -}): Array<{ from: string; to: string }> { - const { mediaFolderPath, localFiles, plan, traceId } = options; - const logPrefix = traceId ? `[${traceId}] ` : ""; - - const renameList: Array<{ from: string; to: string }> = []; - renameList.push(...plan.files); - - for (const file of plan.files) { - const { from, to } = file; - - const newFileRelativePath = to.replace(mediaFolderPath, ""); - const newFileRelativePathWithExt = newFileRelativePath.replace( - extname(newFileRelativePath), - "" - ); - - const associatedFiles = findAssociatedFiles(mediaFolderPath, localFiles, from) - .map((f) => f.path) - .map((relativePath) => join(mediaFolderPath, relativePath)); - - const renameListForAssoFiles: Array<{ from: string; to: string }> = []; - for (const associatedFile of associatedFiles) { - let _ext = extname(associatedFile); - const fromA = associatedFile; - - if (subtitleFileExtensions.includes(_ext)) { - const filename = basename(associatedFile); - if (filename === undefined) { - throw new Error(`basename of ${associatedFile} is undefined`); - } - const parts = filename.split("."); - if (parts.length > 2) { - _ext = ext(filename, 2); - } - } - - const toA = join(mediaFolderPath, newFileRelativePathWithExt + _ext); - - renameListForAssoFiles.push({ from: fromA, to: toA }); - } - - debug(`${logPrefix}rename list for associated files: ${JSON.stringify(renameListForAssoFiles)}`); - renameList.push(...renameListForAssoFiles); - } - - return renameList; -} diff --git a/packages/core-routes/src/tools/plans.test.ts b/packages/core-routes/src/tools/plans.test.ts index 20068f7d..38915795 100644 --- a/packages/core-routes/src/tools/plans.test.ts +++ b/packages/core-routes/src/tools/plans.test.ts @@ -10,7 +10,6 @@ import { } from "./plans.ts"; import { defaultChatFs } from "../chatFs.ts"; import type { ChatFs } from "../chatTypes.ts"; -import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan"; import type { AnyPlan } from "./plans.ts"; import type { RecognizeMediaFilePlan, @@ -218,44 +217,8 @@ describe("plan cancellation (rejected status)", () => { ).rejects.toThrow("该任务已被用户取消, 请停止后续操作"); }); - it("appendRenamePlanEntry throws the cancellation message when the plan is rejected", async () => { - const { - appendRenamePlanEntry, - beginRenamePlan, - readRenamePlan, - } = await import("./plans.ts"); - const { PLAN_CANCELLED_BY_USER_MESSAGE } = await import( - "@smm/types/ai-tools/planTaskMessages" - ); - const taskId = await beginRenamePlan(appDataDir, "/media/show", fs); - const existing = fs.plans.get(taskId) as RenameFilesPlan | undefined; - expect(existing).toBeDefined(); - fs.plans.set(taskId, { ...existing!, status: "rejected" }); - - await expect( - appendRenamePlanEntry( - appDataDir, - taskId, - "/media/show/a.mp4", - "/media/show/b.mp4", - fs, - { - validateOperations: async () => ({ - isValid: true, - errors: [], - validatedRenames: [], - }), - getMediaMetadata: async () => null, - }, - ), - ).rejects.toThrow(PLAN_CANCELLED_BY_USER_MESSAGE); - void readRenamePlan; - }); - it("updatePlanContent keeps the plan file when status is 'rejected' (no delete)", async () => { - const { readRenamePlan, updatePlanContent } = await import( - "./plans.ts" - ); + const { updatePlanContent } = await import("./plans.ts"); const taskId = await beginRecognizePlan(appDataDir, "/media/show", fs); const updated = await updatePlanContent( @@ -270,7 +233,6 @@ describe("plan cancellation (rejected status)", () => { // calls (add-*-file / end-*-task) can detect the cancellation. const planAfter = await readRecognizePlan(appDataDir, taskId, fs); expect(planAfter?.status).toBe("rejected"); - void readRenamePlan; }); it("updatePlanContent still deletes the plan file when status is 'completed' (regression)", async () => { diff --git a/packages/core-routes/src/tools/plans.ts b/packages/core-routes/src/tools/plans.ts index cbbf11a1..5acfe10e 100644 --- a/packages/core-routes/src/tools/plans.ts +++ b/packages/core-routes/src/tools/plans.ts @@ -10,11 +10,6 @@ import type { RenameFileEntry, RenameFilesPlan } from "@smm/types/RenameFilesPla import type { PlanCreator, PlanStatus } from "@smm/types/planCommon"; import { isActivePlanStatus } from "@smm/types/planCommon"; import { PLAN_CANCELLED_BY_USER_MESSAGE } from "@smm/types/ai-tools/planTaskMessages"; -import { - createEmptyRenamePlan, - prepareAppendRenameEntry, - type PrepareAppendRenameEntryDeps, -} from "@smm/core/plan/renamePlan"; import type { ChatFs } from "../chatTypes.ts"; import type { CoreRoutesLogger } from "../types.ts"; @@ -56,67 +51,6 @@ async function ensurePlansDirExists( // ─── Rename-files plan ─────────────────────────────────────────── -export interface RenamePlanAppendDeps { - validateOperations: PrepareAppendRenameEntryDeps["validateOperations"]; - getMediaMetadata: PrepareAppendRenameEntryDeps["getMediaMetadata"]; -} - -/** - * Begin a rename-files task: create an empty plan file and return - * the new plan id. - * - * AI/MCP-created plans start as `preparing` with `creator: "ai"`; the - * end-task tool flips them to `pending` once entries are added. - */ -export async function beginRenamePlan( - appDataDir: string, - mediaFolderPath: string, - fs: ChatFs, -): Promise { - await ensurePlansDirExists(appDataDir, fs); - const plan = createEmptyRenamePlan(Path.posix(mediaFolderPath), undefined, { - creator: "ai", - status: "preparing", - }); - await fs.writeJson(planFilePath(appDataDir, plan.id), plan); - return plan.id; -} - -/** - * Append a rename entry to an existing plan. Throws if the plan is - * missing or validation fails. - */ -export async function appendRenamePlanEntry( - appDataDir: string, - planId: string, - from: string, - to: string, - fs: ChatFs, - deps: RenamePlanAppendDeps, -): Promise { - const filePath = planFilePath(appDataDir, planId); - const plan = (await fs.readJson(filePath)) ?? null; - if (!plan) { - throw new Error(`Task with id ${planId} not found`); - } - - if (plan.status === "rejected") { - throw new Error(PLAN_CANCELLED_BY_USER_MESSAGE); - } - - const result = await prepareAppendRenameEntry( - plan, - { from, to }, - deps, - ); - - if ("error" in result) { - throw new Error(result.error.replace(/^Error Reason: /, "")); - } - - await fs.writeJson(filePath, result); -} - /** * Read a rename plan by id. Returns `null` if the file does not * exist. From 508ef18c925220f4956917012a645f7480724156 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 7 Sep 2026 01:30:49 +0800 Subject: [PATCH 54/83] docs: add recognize single-call migration design --- ...-recognize-single-call-migration-design.md | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-07-recognize-single-call-migration-design.md diff --git a/docs/superpowers/specs/2026-09-07-recognize-single-call-migration-design.md b/docs/superpowers/specs/2026-09-07-recognize-single-call-migration-design.md new file mode 100644 index 00000000..bcb2ab6c --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-recognize-single-call-migration-design.md @@ -0,0 +1,203 @@ +# Recognize Single-Call Migration (createRecognizeEpisodePlan) + +This design document describes the high level design of a feature. +The design document is golden source and referenced by one or more features. + +> **Status:** Design approved by user (2026-09-07). Pending implementation. + +> **Builds on:** +> [Create Rename Episode Plan](./2026-08-29-create-rename-episode-plan-design.md) — the rename single-call precedent this migration mirrors. +> [AI Rename Plan — metadata.write Enforcement](./2026-09-06-ai-plan-metadata-write-enforcement-design.md) — the auto-apply gating pattern reused here. + +## 1. Background + +AI/MCP episode recognition still uses the legacy three-step tool flow +(`begin-recognize-task` → `add-recognized-media-file` ×N → `end-recognize-task`), +while rename already migrated to a single `create-rename-episode-plan` call +(2026-08-29; the recognize tools were explicitly out of scope there). + +The three-step flow costs N+2 agent round-trips, keeps a `preparing` plan +status alive (surfaced by the UI as a "generating" prompt state), and maintains +three tool implementations plus an in-memory draft store across the frontend +chat, MCP (cli), and MCP (ohos) surfaces. + +This migration replaces the three-step flow with one +`create-recognize-episode-plan` call per surface, mirrors the rename +enforcement (`metadata.write` auto-apply), and — once no producer of +`preparing` AI plans remains — deletes the UI `promptStatus` machinery that +only existed to service the `preparing` window. + +**Goals (all confirmed by user):** + +1. One tool call per recognition task (agent passes all `file → season/episode` + mappings at once). +2. Architecture cleanup: delete the three-step tools and the draft store. +3. `metadata.write` auto-apply, same gating semantics as rename. +4. UI simplification: remove `promptStatus` from both AI episode flow hooks and + the generating branches from the AI prompt components. + +## 2. Architecture + +### 2.1 Project Level + +| Package / App | Change | +|---|---| +| `apps/core` | New `pipeline/createRecognizeEpisodePlan.ts` (pure pipeline, mirrors `createRenameEpisodePlan`) | +| `packages/types` | New `ai-tools/createRecognizeEpisodePlan.ts` schema; delete `ai-tools/recognizeMediaFileTask.ts`; add `RECOGNIZE_PLAN_AUTO_APPLIED_MESSAGE` in `ai-tools/planTaskMessages.ts` | +| `packages/core-routes` | New tool builder `tools/createRecognizeEpisodePlan.ts` + MCP handler; HTTP route `POST /api/create-recognize-episode-plan`; delete `tools/recognizeMediaFilesTask.ts`, the three MCP handlers, and the recognize-specific helpers in `tools/plans.ts`; `McpConfig` gains optional `applyRecognizeEpisodePlan` | +| `apps/cli` | Wire `applyRecognizeEpisodePlan: (plan) => getCore().applyPlan(plan)` in `buildMcpConfig()` and the chat extras (same two places as rename) | +| `apps/ui` | New `ai/tools/CreateRecognizeEpisodePlan.tsx`; delete the three step tools and the plan-draft store; remove `promptStatus` / generating branches / `cleanup*Plan` draft calls | + +### 2.2 App Level + +#### Core pipeline + +```ts +// apps/core/src/pipeline/createRecognizeEpisodePlan.ts +export interface CreateRecognizeEpisodePlanOptions { + creator?: "app" | "ai"; + id?: string; +} + +export interface CreateRecognizeEpisodePlanDeps { + fs: FsPort; + appDataDir: string; + normalizePosix: (path: string) => string; + createId?: () => string; +} + +export async function createRecognizeEpisodePlanPipeline( + mediaFolderPath: string, + files: Array<{ season: number; episode: number; path: string }>, + options: CreateRecognizeEpisodePlanOptions | undefined, + deps: CreateRecognizeEpisodePlanDeps, +): Promise +``` + +Validation order: + +1. `files` non-empty (else throw `No recognize entries in task`). +2. Normalize every `path` via `deps.normalizePosix`. +3. Batch dedup: the same normalized `path` twice, or the same + `(season, episode)` pair twice → throw. +4. Per-entry existence via `deps.fs.exists(...)` → throw with the existing + message shape (`File "…" (S#E#) does not exist in the media folder`). +5. Persist via `writePlan(deps.fs, deps.appDataDir, plan)` with + `status: "pending"`, `creator: options.creator ?? "app"`. + +Deliberately NOT validated: whether `season/episode` exists in media metadata. +Recognize exists to let the AI build that mapping; the user gives the final +verdict in the confirm prompt (same semantics as today's `add` step, which +only checks file existence). + +#### Tool builder (core-routes) + +```ts +// packages/core-routes/src/tools/createRecognizeEpisodePlan.ts +export interface CreateRecognizeEpisodePlanToolExtra { + getUserConfig?: () => Promise; + applyRecognizeEpisodePlan?: (plan: RecognizeMediaFilePlan) => Promise; +} +``` + +`execute` (mirrors the rename tool): + +1. Call the pipeline → pending plan on disk. +2. If **both** extra deps are present **and** + `hasAiAgentPermission(await getUserConfig(), AI_AGENT_PERMISSIONS.metadataWrite)` + (config read failure counts as not granted): call + `applyRecognizeEpisodePlan(plan)`. + - Success → emit `mediaMetadataUpdated` with `{ folderPath: plan.mediaFolderPath }`, + return `toolOk({ message: RECOGNIZE_PLAN_AUTO_APPLIED_MESSAGE, planId })`. + **No** `RecognizeMediaFilePlanReady` — nothing is pending. + - Failure → warn log, fall through to the pending flow. +3. Pending flow → emit `RecognizeMediaFilePlanReady { taskId, planFilePath }`, + return `toolOk({ message: END_PLAN_TASK_SUCCESS_MESSAGE, taskId })`. + +#### HTTP route + +`POST /api/create-recognize-episode-plan` (core-routes routes + cli mount), +calling the pipeline directly — the manual-approval flow by construction, no +gating (same rule as the rename frontend/debug surfaces). Documented in +`docs/api/index.md`. + +#### MCP + chat wiring + +| Surface | `applyRecognizeEpisodePlan` | +|---|---| +| `McpConfig` (core-routes `mcp/types.ts`) | new optional field | +| cli `buildMcpConfig()` | `(plan) => getCore().applyPlan(plan)` | +| cli chat extras | `(plan) => getCore().applyPlan(plan)` | +| ohos MCP | omit — no Core instance; always pending flow (documented degradation, identical to rename) | + +`Core.applyPlan(plan)` already handles `recognize-media-file` +(merge `mediaFiles` → `setMetadata` → delete plan file). + +#### Frontend chat tool + +`apps/ui/src/ai/tools/CreateRecognizeEpisodePlan.tsx` — single call to +`POST /api/create-recognize-episode-plan`, then invalidate the plans query and +return `END_PLAN_TASK_SUCCESS_MESSAGE` + `taskId` (byte-for-byte the pattern of +`CreateRenameEpisodePlan.tsx`). + +### 2.3 Key Design + +* **`preparing` loses its last producer.** After this migration no code path + creates an `ai` recognize plan in `preparing`. The status value itself stays + in `PlanStatus` (plan files from older sessions may still carry it); + `cleanPreparingPlans` remains the startup janitor for those leftovers. +* **`selectActiveAiPlan` tightens to `pending` only.** With no producer, + matching `preparing` would only resurface stale files that the janitor is + about to delete. `selectActiveAppPlan` is untouched (rule-based plans are + born `pending` anyway). +* **`promptStatus` is deleted, not hardcoded.** Both AI episode flow hooks stop + returning it; `promptProps` carries only `isOpen/onConfirm/onCancel`. The AI + prompt components lose the `status` prop, the generating branch, the spinner, + and the confirm-disable-during-generating logic. Locale keys + `toolbar.aiGenerating` / `toolbar.aiRecognizing` are removed from all four + languages (`aiReview` / `aiReviewEpisodes` stay). +* **Draft store dies with the flow.** `aiPlanDrafts` / `recognizePlanService` / + `cleanupRenamePlan` / `cleanupRecognizePlan` exist only to service + accumulate-between-steps semantics. The flow hooks' `confirm`/`cancel` drop + their `cleanup*Plan` calls; `useCreatePlanMutation` (dead optimistic-create + export from the same era) is removed as well. +* **Single write, single validation pass.** Unlike the three-step flow (N + `updatePlan` writes + per-entry checks), the whole batch is validated once + and the plan is written once, already `pending`. + +## 3. Deletion List + +| Location | Item | +|---|---| +| `packages/types` | `ai-tools/recognizeMediaFileTask.ts` | +| `packages/core-routes` | `tools/recognizeMediaFilesTask.ts`; MCP handlers `beginRecognizeTask.ts` / `addRecognizedFile.ts` / `endRecognizeTask.ts`; `tools/plans.ts`: `beginRecognizePlan`, `appendRecognizedFile`, `defaultValidateRecognizedFiles` (+ `RecognizePlanAppendDeps`) | +| `packages/core-routes` (kept) | `updatePlanContent`, `cancelPlan`, `cleanPreparingPlans`, `readPlanById`, `readRenamePlan`, `plansApi` | +| `apps/ui` | `ai/tools/BeginRecognizeTask.tsx`, `AddRecognizedMediaFile.tsx`, `EndRecognizeTask.tsx`; `ai/plan/aiPlanDrafts.ts`, `recognizePlanService.ts`, `cleanupRenamePlan.ts`; `hooks/plans/useCreatePlanMutation.ts` | + +## 4. Error Handling + +| Failure | Behavior | +|---|---| +| Pipeline validation fails (empty / dup / missing file) | Tool returns error to the agent; no plan is written; agent can correct and retry | +| `getUserConfig()` rejects | Treated as not granted → pending flow | +| `applyRecognizeEpisodePlan` rejects (locked file, etc.) | Warn log → pending flow (plan already on disk); never a hard error to the agent | +| Deps absent (ohos) | Always pending flow | +| Permission absent/empty | Pending flow | + +## 5. Testing + +| Area | Test | +|---|---| +| `apps/core` pipeline | empty files → throw; duplicate path / duplicate (season, episode) → throw; missing file → throw; happy path writes `pending` plan with normalized paths | +| core-routes tool builder | granted + applier → applies, emits `mediaMetadataUpdated`, returns auto-applied message, no PlanReady; applier rejects → pending flow + PlanReady; permission missing → pending; deps absent → pending; `getUserConfig` rejects → pending | +| core-routes `plans.test.ts` | rework: fixtures that used `beginRecognizePlan` build `preparing` plans via `writePlan` directly; delete `appendRecognizedFile` tests | +| `apps/cli` | `applyRecognizeEpisodePlan` wired in both MCP config and chat extras | +| `apps/ui` | flow hook tests: drop `promptStatus`/cleanup assertions; `selectActiveAppPlan.test`: `preparing` no longer surfaces for AI; locale files: key parity after `aiGenerating`/`aiRecognizing` removal | +| e2e (follow-up round) | rewrite `MCP RecognizeTaskFlow` and `AiTool-RecognizeTool` specs to the single-call tool | + +## 6. References + +* [Create Rename Episode Plan](./2026-08-29-create-rename-episode-plan-design.md) +* [AI Rename Plan — metadata.write Enforcement + Browser-side Plan Pulling](./2026-09-06-ai-plan-metadata-write-enforcement-design.md) +* [Recognize Episodes](../../dev/recognize-episodes.md) +* [Manage Plan](../../dev/manage-plan.md) From 90ebe1942e52cd01a54df4041d49ebc9ae783cc7 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 7 Sep 2026 02:02:05 +0800 Subject: [PATCH 55/83] docs: add recognize single-call migration implementation plan --- ...6-09-07-recognize-single-call-migration.md | 1442 +++++++++++++++++ 1 file changed, 1442 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-07-recognize-single-call-migration.md diff --git a/docs/superpowers/plans/2026-09-07-recognize-single-call-migration.md b/docs/superpowers/plans/2026-09-07-recognize-single-call-migration.md new file mode 100644 index 00000000..369d5e87 --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-recognize-single-call-migration.md @@ -0,0 +1,1442 @@ +# Recognize Single-Call Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 用单次 `create-recognize-episode-plan` 调用替换 recognize 三步式工具(前端聊天 / MCP),同步引入 `metadata.write` 自动应用,随后删除 `promptStatus` 与 preparing 相关 UI 复杂度。 + +**Architecture:** 完全镜像 rename 先例 —— `apps/core` 纯管线(FsPort + writePlan,直写 pending)→ `core-routes` 工具构建器(`CreateRecognizeEpisodePlanToolExtra` gating,与 rename enforcement 同构)→ MCP handler / HTTP 路由 / 前端聊天工具三表面。迁移后 `preparing` 无生产者,UI 侧收紧。 + +**Tech Stack:** TypeScript + zod + Hono + @modelcontextprotocol/sdk + Vitest + React 19 + TanStack Query + +**Design doc:** `docs/superpowers/specs/2026-09-07-recognize-single-call-migration-design.md` + +## Global Constraints + +- 行为约束(spec §4):校验失败 → 工具返回 error、不产生 plan;`getUserConfig` reject → 视为未授予;applier reject → warn + 落回 pending 流(不硬报错);ohos 依赖缺失 → 永远 pending。 +- 校验语义(spec §2.2):仅 文件存在性 + 批内去重(同 path / 同 (season,episode)),**不**校验 S,E 对 metadata。 +- 新 plan 一律 `status: "pending"`、`creator: "ai"`(工具)/可选 `"app"`(管线与 HTTP)。 +- 自动应用成功:广播 `mediaMetadataUpdated { folderPath }`、返回 `RECOGNIZE_PLAN_AUTO_APPLIED_MESSAGE`、**不**发 `RecognizeMediaFilePlanReady`。 +- pending 流:广播 `RecognizeMediaFilePlanReady { taskId, planFilePath }`、返回 `END_PLAN_TASK_SUCCESS_MESSAGE`。 +- 包管理器 pnpm;每个任务后跑对应包 `pnpm typecheck` + 测试;提交信息 conventional commits。 +- e2e 规格改写(`MCP RecognizeTaskFlow` / `AiTool-RecognizeTool`)为**后续轮次**,不在本计划(spec §5)。 +- 工作树中 `apps/ui/src/components/tv/TvShowPanel.tsx` 与 `docs/dev/*` 有用户未提交改动:**不要 stage 这些文件**(除非任务明确说明)。 + +--- + +### Task 1: types — 新 schema 与消息常量 + +**Files:** +- Create: `packages/types/ai-tools/createRecognizeEpisodePlan.ts` +- Modify: `packages/types/ai-tools/planTaskMessages.ts` + +**Interfaces:** +- Produces: `CREATE_RECOGNIZE_EPISODE_PLAN = 'create-recognize-episode-plan'`、`CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION`、`createRecognizeEpisodePlanInputSchema`(`{ mediaFolderPath: string; files: Array<{season:number; episode:number; path:string}> }` min(1))、`CreateRecognizeEpisodePlanInput`;`RECOGNIZE_PLAN_AUTO_APPLIED_MESSAGE`。Task 2/3/4/5/6 消费这些名字。 + +- [ ] **Step 1: 创建 schema 文件** + +```ts +// packages/types/ai-tools/createRecognizeEpisodePlan.ts +import { z } from 'zod' + +export const CREATE_RECOGNIZE_EPISODE_PLAN = 'create-recognize-episode-plan' as const + +export const CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION = + 'Create a recognize-media-file plan that maps episode video files to season/episode numbers. ' + + 'Provide every mapping (season, episode, absolute file path) in one call. ' + + 'After success, tell the user to open SMM, review, and approve the plan.' + +export const createRecognizeEpisodePlanInputSchema = z.object({ + mediaFolderPath: z + .string() + .describe('Absolute media folder path (POSIX or Windows)'), + files: z + .array( + z.object({ + season: z.number().describe('The season number of the episode.'), + episode: z.number().describe('The episode number.'), + path: z + .string() + .describe('The absolute path of the media file (POSIX or Windows format).'), + }), + ) + .min(1), +}) + +export type CreateRecognizeEpisodePlanInput = z.infer< + typeof createRecognizeEpisodePlanInputSchema +> +``` + +- [ ] **Step 2: planTaskMessages.ts 追加常量(文件末尾)** + +```ts +/** + * Returned to the AI when the recognize plan was applied automatically + * because the user granted the `metadata.write` permission. + */ +export const RECOGNIZE_PLAN_AUTO_APPLIED_MESSAGE = + "Recognize plan applied automatically (metadata.write permission granted). No user approval needed."; +``` + +- [ ] **Step 3: 验证** + +Run: `pnpm typecheck:types` +Expected: 通过 + +- [ ] **Step 4: Commit** + +```bash +git add packages/types/ai-tools/createRecognizeEpisodePlan.ts packages/types/ai-tools/planTaskMessages.ts +git commit -m "feat(types): add createRecognizeEpisodePlan schema and auto-applied message" +``` + +--- + +### Task 2: apps/core — createRecognizeEpisodePlanPipeline + Core 方法(TDD) + +**Files:** +- Create: `apps/core/src/pipeline/createRecognizeEpisodePlan.ts` +- Test: `apps/core/src/pipeline/createRecognizeEpisodePlan.test.ts` +- Modify: `apps/core/src/Core.ts`(import + `createRecognizeEpisodePlan` 方法,镜像 `createRenameEpisodePlan` 于 :534) + +**Interfaces:** +- Consumes: `writePlan`(`apps/core/src/pipeline/plans.ts:16`,签名 `writePlan(fs: FsPort, appDataDir: string, plan: Plan): Promise`)、`planFilePath`(`apps/core/src/pipeline/paths.ts`)、`FsPort`(`../ports/FsPort`)。 +- Produces: `createRecognizeEpisodePlanPipeline(mediaFolderPath, files, options, deps): Promise`,`CreateRecognizeEpisodePlanOptions { creator?: "app"|"ai"; id?: string }`,`CreateRecognizeEpisodePlanDeps { fs: FsPort; appDataDir: string; normalizePosix: (p:string)=>string; createId?: () => string }`。Task 5 的 Core 方法与 Task 3 的工具 builder 依赖。 + +- [ ] **Step 1: 写失败测试** + +```ts +// apps/core/src/pipeline/createRecognizeEpisodePlan.test.ts +import { describe, expect, it, vi } from "vitest"; +import type { FsPort } from "../ports/FsPort"; +import { createRecognizeEpisodePlanPipeline } from "./createRecognizeEpisodePlan"; +import { planFilePath } from "./paths"; + +function inMemoryFs(seed: Record = {}): FsPort { + const files = new Map(Object.entries(seed)); + return { + readTextFile: vi.fn(async (path: string) => { + const v = files.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }), + writeTextFile: vi.fn(async (path: string, content: string) => { + files.set(path, content); + }), + writeBinaryFile: vi.fn(async () => {}), + exists: vi.fn(async (path: string) => files.has(path)), + listFiles: vi.fn(async () => []), + deleteFile: vi.fn(async () => {}), + rename: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), + listSubdirectories: vi.fn(async () => []), + }; +} + +describe("createRecognizeEpisodePlanPipeline", () => { + const appDataDir = "/data"; + const folder = "/m/Show"; + + it("writes a pending ai plan with posix paths", async () => { + const fs = inMemoryFs({ "/m/Show/S01E01.mkv": "" }); + const plan = await createRecognizeEpisodePlanPipeline( + folder, + [{ season: 1, episode: 1, path: "/m/Show/S01E01.mkv" }], + { creator: "ai", id: "fixed-id" }, + { fs, appDataDir, normalizePosix: (p) => p, createId: () => "fixed-id" }, + ); + expect(plan.status).toBe("pending"); + expect(plan.creator).toBe("ai"); + expect(plan.task).toBe("recognize-media-file"); + expect(plan.files[0]).toEqual({ season: 1, episode: 1, path: "/m/Show/S01E01.mkv" }); + expect(await fs.exists(planFilePath(appDataDir, "fixed-id"))).toBe(true); + }); + + it("rejects empty files", async () => { + const fs = inMemoryFs(); + await expect( + createRecognizeEpisodePlanPipeline(folder, [], undefined, { + fs, appDataDir, normalizePosix: (p) => p, + }), + ).rejects.toThrow("No recognize entries in task"); + }); + + it("rejects duplicate paths", async () => { + const fs = inMemoryFs({ "/m/Show/S01E01.mkv": "" }); + await expect( + createRecognizeEpisodePlanPipeline( + folder, + [ + { season: 1, episode: 1, path: "/m/Show/S01E01.mkv" }, + { season: 1, episode: 2, path: "/m/Show/S01E01.mkv" }, + ], + undefined, + { fs, appDataDir, normalizePosix: (p) => p }, + ), + ).rejects.toThrow("Duplicate file path"); + }); + + it("rejects duplicate season/episode pairs", async () => { + const fs = inMemoryFs({ + "/m/Show/a.mkv": "", + "/m/Show/b.mkv": "", + }); + await expect( + createRecognizeEpisodePlanPipeline( + folder, + [ + { season: 1, episode: 1, path: "/m/Show/a.mkv" }, + { season: 1, episode: 1, path: "/m/Show/b.mkv" }, + ], + undefined, + { fs, appDataDir, normalizePosix: (p) => p }, + ), + ).rejects.toThrow("Duplicate season/episode"); + }); + + it("rejects files that do not exist", async () => { + const fs = inMemoryFs(); + await expect( + createRecognizeEpisodePlanPipeline( + folder, + [{ season: 1, episode: 1, path: "/m/Show/missing.mkv" }], + undefined, + { fs, appDataDir, normalizePosix: (p) => p }, + ), + ).rejects.toThrow('does not exist in the media folder'); + }); +}); +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cd apps/core && pnpm vitest run src/pipeline/createRecognizeEpisodePlan.test.ts` +Expected: FAIL — `Cannot find module './createRecognizeEpisodePlan'` + +- [ ] **Step 3: 实现管线** + +```ts +// apps/core/src/pipeline/createRecognizeEpisodePlan.ts +import { randomUUID } from "node:crypto"; +import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan"; +import type { FsPort } from "../ports/FsPort"; +import { writePlan } from "./plans"; + +export interface CreateRecognizeEpisodePlanOptions { + creator?: "app" | "ai"; + id?: string; +} + +export interface CreateRecognizeEpisodePlanDeps { + fs: FsPort; + appDataDir: string; + normalizePosix: (path: string) => string; + createId?: () => string; +} + +export async function createRecognizeEpisodePlanPipeline( + mediaFolderPath: string, + files: Array<{ season: number; episode: number; path: string }>, + options: CreateRecognizeEpisodePlanOptions | undefined, + deps: CreateRecognizeEpisodePlanDeps, +): Promise { + const posixFolder = deps.normalizePosix(mediaFolderPath); + + if (files.length === 0) { + throw new Error("No recognize entries in task"); + } + + const normalizedFiles = files.map((file) => ({ + season: file.season, + episode: file.episode, + path: deps.normalizePosix(file.path), + })); + + const seenPaths = new Set(); + const seenEpisodes = new Set(); + for (const file of normalizedFiles) { + if (seenPaths.has(file.path)) { + throw new Error(`Duplicate file path in task: ${file.path}`); + } + seenPaths.add(file.path); + + const episodeKey = `${file.season}-${file.episode}`; + if (seenEpisodes.has(episodeKey)) { + throw new Error( + `Duplicate season/episode in task: S${file.season}E${file.episode}`, + ); + } + seenEpisodes.add(episodeKey); + + if (!(await deps.fs.exists(file.path))) { + throw new Error( + `File "${file.path}" (S${file.season}E${file.episode}) does not exist in the media folder`, + ); + } + } + + const createId = deps.createId ?? randomUUID; + const id = options?.id ?? createId(); + + const plan: RecognizeMediaFilePlan = { + id, + task: "recognize-media-file", + status: "pending", + creator: options?.creator ?? "app", + mediaFolderPath: posixFolder, + files: normalizedFiles, + }; + + await writePlan(deps.fs, deps.appDataDir, plan); + return plan; +} +``` + +- [ ] **Step 4: 运行确认通过** + +Run: `cd apps/core && pnpm vitest run src/pipeline/createRecognizeEpisodePlan.test.ts` +Expected: PASS(5 用例) + +- [ ] **Step 5: Core 方法** + +`apps/core/src/Core.ts` —— 在 import 区(:77-79 附近,已有 `createRenameEpisodePlanPipeline`)加入: + +```ts + createRecognizeEpisodePlanPipeline, + type CreateRecognizeEpisodePlanOptions as CreateRecognizeEpisodePlanCoreOptions, +} from "./pipeline/createRecognizeEpisodePlan"; +``` + +(与现有 rename import 合并整理为两个 import 语句即可。)在 `createRenameEpisodePlan` 方法(:534)后新增: + +```ts + async createRecognizeEpisodePlan( + mediaFolderPath: string, + files: Array<{ season: number; episode: number; path: string }>, + options?: CreateRecognizeEpisodePlanCoreOptions, + ): Promise { + return createRecognizeEpisodePlanPipeline(mediaFolderPath, files, options, { + fs: this.fs, + appDataDir: this.getMetadataRoot(), + normalizePosix: (path) => this.normalizePosix(path), + }); + } +``` + +(`RecognizeMediaFilePlan` 若 Core.ts 未导入则从 `@smm/types/RecognizeMediaFilePlan` 补充 type import;`createId` 不传,管线默认 randomUUID。) + +- [ ] **Step 6: 验证并提交** + +Run: `cd apps/core && pnpm typecheck && pnpm test` +Expected: 通过(含既有 477+ 测试) + +```bash +git add apps/core/src/pipeline/createRecognizeEpisodePlan.ts apps/core/src/pipeline/createRecognizeEpisodePlan.test.ts apps/core/src/Core.ts +git commit -m "feat(core): add createRecognizeEpisodePlanPipeline and Core method" +``` + +--- + +### Task 3: core-routes — 工具构建器(TDD) + +**Files:** +- Create: `packages/core-routes/src/tools/chatFsPort.ts` +- Create: `packages/core-routes/src/tools/createRecognizeEpisodePlan.ts` +- Test: `packages/core-routes/src/tools/createRecognizeEpisodePlan.test.ts` +- Modify: `packages/core-routes/src/tools/createRenameEpisodePlan.ts`(本地 `createFsPort`/`planPath` 改为从 `./chatFsPort` 导入;行为不变) + +**Interfaces:** +- Consumes: Task 1 的 schema 与 `RECOGNIZE_PLAN_AUTO_APPLIED_MESSAGE`、Task 2 的管线(经 `@smm/core/createRecognizeEpisodePlan` 导出 —— 需在 `apps/core` 对外出口追加;见 Step 4)、`hasAiAgentPermission`/`AI_AGENT_PERMISSIONS`(`@smm/types`)、`RecognizeMediaFilePlanReady`/`MEDIA_METADATA_UPDATED_EVENT`(`@smm/types/event-types`)。 +- Produces: `buildCreateRecognizeEpisodePlanTool(appDataDir, fs, broadcast?, logger?, abortSignal?, extra?: CreateRecognizeEpisodePlanToolExtra)`、`CreateRecognizeEpisodePlanToolExtra { getUserConfig?; applyRecognizeEpisodePlan?: (plan: RecognizeMediaFilePlan) => Promise }`、`CREATE_RECOGNIZE_EPISODE_PLAN_TOOL_NAME`。Task 4/5 消费。 + +- [ ] **Step 1: 抽取共享 ChatFs→FsPort 适配器** + +```ts +// packages/core-routes/src/tools/chatFsPort.ts +import { Path } from "@smm/utils/path"; +import type { FsPort } from "@smm/core/FsPort"; +import type { ChatFs } from "../chatTypes.ts"; + +function unsupportedFsOperation(name: string): never { + throw new Error(`${name} is not supported by the plan filesystem adapter`); +} + +export function createFsPort(fs: ChatFs): FsPort { + return { + async readTextFile(path: string): Promise { + const value = await fs.readJson(path); + if (value === null) { + throw new Error(`File not found: ${path}`); + } + return JSON.stringify(value); + }, + async writeTextFile(path: string, content: string): Promise { + await fs.writeJson(path, JSON.parse(content) as unknown); + }, + async writeBinaryFile(): Promise { + unsupportedFsOperation("writeBinaryFile"); + }, + exists: (path: string) => fs.exists(path), + isFile: (path: string) => fs.exists(path), + async listFiles(): Promise { + return unsupportedFsOperation("listFiles"); + }, + async listSubdirectories(): Promise { + return unsupportedFsOperation("listSubdirectories"); + }, + async deleteFile(): Promise { + unsupportedFsOperation("deleteFile"); + }, + async rename(): Promise { + unsupportedFsOperation("rename"); + }, + async mkdir(): Promise { + unsupportedFsOperation("mkdir"); + }, + }; +} + +export function planPath(appDataDir: string, planId: string): string { + return new Path(appDataDir, `plans/${planId}.plan.json`).abs("posix"); +} +``` + +然后修改 `createRenameEpisodePlan.ts`:删除本地 `createFsPort`(:30-67)与 `planPath`(:74-76),改为: + +```ts +import { createFsPort, planPath } from "./chatFsPort.ts"; +``` + +(`metadataPath` 保留在 rename 工具内。) + +- [ ] **Step 2: 写失败测试** + +```ts +// packages/core-routes/src/tools/createRecognizeEpisodePlan.test.ts +import { describe, expect, it, vi } from "vitest"; +import { CREATE_RECOGNIZE_EPISODE_PLAN } from "@smm/types/ai-tools/createRecognizeEpisodePlan"; +import { AI_AGENT_PERMISSIONS, type UserConfig } from "@smm/types"; +import { + END_PLAN_TASK_SUCCESS_MESSAGE, + RECOGNIZE_PLAN_AUTO_APPLIED_MESSAGE, +} from "@smm/types/ai-tools/planTaskMessages"; +import { + MEDIA_METADATA_UPDATED_EVENT, + RecognizeMediaFilePlanReady, +} from "@smm/types/event-types"; +import type { ChatFs } from "../chatTypes.ts"; +import { buildCreateRecognizeEpisodePlanTool } from "./createRecognizeEpisodePlan.ts"; + +function createMockFs(folder: string): ChatFs { + const values = new Map(); + return { + async readJson(path: string): Promise { + return (values.get(path) ?? null) as T | null; + }, + writeJson: vi.fn(async (path: string, value: unknown) => { + values.set(path, value); + }), + exists: vi.fn(async (path: string) => path === `${folder}/S01E01.mkv`), + }; +} + +const FILES = [{ season: 1, episode: 1, path: "/media/show/S01E01.mkv" }]; + +function grantedConfig(): UserConfig { + return { + aiAgent: { permissions: [AI_AGENT_PERMISSIONS.metadataWrite] }, + } as unknown as UserConfig; +} + +describe(`buildCreateRecognizeEpisodePlanTool (${CREATE_RECOGNIZE_EPISODE_PLAN})`, () => { + it("pending flow: emits RecognizeMediaFilePlanReady and returns the success message", async () => { + const broadcast = vi.fn(); + const tool = buildCreateRecognizeEpisodePlanTool( + "/app-data", + createMockFs("/media/show"), + broadcast, + ); + + const result = await tool.execute({ + mediaFolderPath: "/media/show", + files: FILES, + }); + + if (!("planId" in result)) { + throw new Error(result.error); + } + expect(result.message).toBe(END_PLAN_TASK_SUCCESS_MESSAGE); + expect(result.planId).toEqual(expect.any(String)); + expect(broadcast).toHaveBeenCalledWith({ + event: RecognizeMediaFilePlanReady.event, + data: { + taskId: result.planId, + planFilePath: `/app-data/plans/${result.planId}.plan.json`, + }, + }); + }); + + it("auto-apply: granted permission + applier applies, emits mediaMetadataUpdated, no PlanReady", async () => { + const broadcast = vi.fn(); + const applyRecognizeEpisodePlan = vi.fn(async () => {}); + const tool = buildCreateRecognizeEpisodePlanTool( + "/app-data", + createMockFs("/media/show"), + broadcast, + undefined, + undefined, + { + getUserConfig: async () => grantedConfig(), + applyRecognizeEpisodePlan, + }, + ); + + const result = await tool.execute({ + mediaFolderPath: "/media/show", + files: FILES, + }); + + if (!("planId" in result)) { + throw new Error(result.error); + } + expect(result.message).toBe(RECOGNIZE_PLAN_AUTO_APPLIED_MESSAGE); + expect(applyRecognizeEpisodePlan).toHaveBeenCalledTimes(1); + expect(broadcast).toHaveBeenCalledWith({ + event: MEDIA_METADATA_UPDATED_EVENT, + data: { folderPath: "/media/show" }, + }); + expect( + broadcast.mock.calls.some( + (call) => call[0].event === RecognizeMediaFilePlanReady.event, + ), + ).toBe(false); + }); + + it("applier failure falls back to the pending flow", async () => { + const broadcast = vi.fn(); + const tool = buildCreateRecognizeEpisodePlanTool( + "/app-data", + createMockFs("/media/show"), + broadcast, + undefined, + undefined, + { + getUserConfig: async () => grantedConfig(), + applyRecognizeEpisodePlan: vi.fn(async () => { + throw new Error("disk locked"); + }), + }, + ); + + const result = await tool.execute({ + mediaFolderPath: "/media/show", + files: FILES, + }); + + if (!("planId" in result)) { + throw new Error(result.error); + } + expect(result.message).toBe(END_PLAN_TASK_SUCCESS_MESSAGE); + expect( + broadcast.mock.calls.some( + (call) => call[0].event === RecognizeMediaFilePlanReady.event, + ), + ).toBe(true); + }); + + it("no permission → pending flow", async () => { + const broadcast = vi.fn(); + const applyRecognizeEpisodePlan = vi.fn(async () => {}); + const tool = buildCreateRecognizeEpisodePlanTool( + "/app-data", + createMockFs("/media/show"), + broadcast, + undefined, + undefined, + { + getUserConfig: async () => ({}) as UserConfig, + applyRecognizeEpisodePlan, + }, + ); + + const result = await tool.execute({ + mediaFolderPath: "/media/show", + files: FILES, + }); + + if (!("planId" in result)) { + throw new Error(result.error); + } + expect(result.message).toBe(END_PLAN_TASK_SUCCESS_MESSAGE); + expect(applyRecognizeEpisodePlan).not.toHaveBeenCalled(); + }); + + it("absent deps → pending flow", async () => { + const broadcast = vi.fn(); + const tool = buildCreateRecognizeEpisodePlanTool( + "/app-data", + createMockFs("/media/show"), + broadcast, + ); + const result = await tool.execute({ + mediaFolderPath: "/media/show", + files: FILES, + }); + if (!("planId" in result)) { + throw new Error(result.error); + } + expect(result.message).toBe(END_PLAN_TASK_SUCCESS_MESSAGE); + }); + + it("getUserConfig rejection → pending flow", async () => { + const broadcast = vi.fn(); + const applyRecognizeEpisodePlan = vi.fn(async () => {}); + const tool = buildCreateRecognizeEpisodePlanTool( + "/app-data", + createMockFs("/media/show"), + broadcast, + undefined, + undefined, + { + getUserConfig: async () => { + throw new Error("config unavailable"); + }, + applyRecognizeEpisodePlan, + }, + ); + + const result = await tool.execute({ + mediaFolderPath: "/media/show", + files: FILES, + }); + + if (!("planId" in result)) { + throw new Error(result.error); + } + expect(result.message).toBe(END_PLAN_TASK_SUCCESS_MESSAGE); + expect(applyRecognizeEpisodePlan).not.toHaveBeenCalled(); + }); + + it("validation failure returns an error payload and writes nothing", async () => { + const broadcast = vi.fn(); + const tool = buildCreateRecognizeEpisodePlanTool( + "/app-data", + createMockFs("/media/show"), + broadcast, + ); + + const result = await tool.execute({ + mediaFolderPath: "/media/show", + files: [ + { season: 1, episode: 1, path: "/media/show/S01E01.mkv" }, + { season: 1, episode: 1, path: "/media/show/S01E02.mkv" }, + ], + }); + + expect(result.error).toContain("Duplicate season/episode"); + }); +}); +``` + +- [ ] **Step 3: 运行确认失败** + +Run: `cd packages/core-routes && pnpm vitest run src/tools/createRecognizeEpisodePlan.test.ts` +Expected: FAIL — `Cannot find module './createRecognizeEpisodePlan.ts'` + +- [ ] **Step 4: 实现 tool builder** + +前置:`apps/core` 的对外出口需要暴露管线 —— 在 `apps/core/src/index.ts`(或 rename 管线的同款导出位置;rename 经 `@smm/core/createRenameEpisodePlan` 导出,见 `packages/core-routes/src/tools/createRenameEpisodePlan.ts:1`)找到 `createRenameEpisodePlan` 的 subpath export 配置(`apps/core/package.json` exports 字段 + 对应导出文件),按同样方式增加 `./createRecognizeEpisodePlan` 导出。以实际 exports 结构为准,镜像 rename 的每一处登记。 + +```ts +// packages/core-routes/src/tools/createRecognizeEpisodePlan.ts +import { createRecognizeEpisodePlanPipeline } from "@smm/core/createRecognizeEpisodePlan"; +import type { FsPort } from "@smm/core/FsPort"; +import { Path } from "@smm/utils/path"; +import { + AI_AGENT_PERMISSIONS, + hasAiAgentPermission, + type UserConfig, +} from "@smm/types"; +import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan"; +import { + CREATE_RECOGNIZE_EPISODE_PLAN, + CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION, + createRecognizeEpisodePlanInputSchema, +} from "@smm/types/ai-tools/createRecognizeEpisodePlan"; +import { + END_PLAN_TASK_SUCCESS_MESSAGE, + RECOGNIZE_PLAN_AUTO_APPLIED_MESSAGE, +} from "@smm/types/ai-tools/planTaskMessages"; +import { + MEDIA_METADATA_UPDATED_EVENT, + RecognizeMediaFilePlanReady, + type RecognizeMediaFilePlanReadyRequestData, +} from "@smm/types/event-types"; +import { formatToolError, toolOk } from "@smm/core/ai-tool/toolResult"; +import type { ChatFs } from "../chatTypes.ts"; +import type { CoreRoutesLogger } from "../types.ts"; +import type { WebSocketMessage } from "../socketIO/types.ts"; +import { defaultBroadcast } from "./broadcast.ts"; +import { createFsPort, planPath } from "./chatFsPort.ts"; + +/** + * Optional dependencies for the `metadata.write` auto-apply flow. + * Auto-apply requires BOTH deps: without `getUserConfig` the tool + * cannot verify the permission; without `applyRecognizeEpisodePlan` + * (hosts without a Core instance, e.g. ohos) it cannot apply. + */ +export interface CreateRecognizeEpisodePlanToolExtra { + /** Reads the current user config for the metadata.write permission check. */ + getUserConfig?: () => Promise; + /** Applies (merges metadata of) a created plan. Host Core runner, e.g. `Core.applyPlan`. */ + applyRecognizeEpisodePlan?: (plan: RecognizeMediaFilePlan) => Promise; +} + +export function buildCreateRecognizeEpisodePlanTool( + appDataDir: string, + fs: ChatFs, + broadcast?: (message: WebSocketMessage) => void, + logger?: CoreRoutesLogger, + abortSignal?: AbortSignal, + extra?: CreateRecognizeEpisodePlanToolExtra, +) { + const emit = broadcast ?? defaultBroadcast; + return { + description: CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION, + inputSchema: createRecognizeEpisodePlanInputSchema, + execute: async (args: unknown) => { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); + } + + const parsed = createRecognizeEpisodePlanInputSchema.safeParse(args); + if (!parsed.success) { + return formatToolError(parsed.error); + } + + try { + const plan = await createRecognizeEpisodePlanPipeline( + parsed.data.mediaFolderPath, + parsed.data.files, + { creator: "ai" }, + { + fs: createFsPort(fs), + appDataDir, + normalizePosix: Path.posix, + }, + ); + + if (extra?.getUserConfig && extra.applyRecognizeEpisodePlan) { + try { + const userConfig = await extra.getUserConfig(); + if ( + hasAiAgentPermission( + userConfig, + AI_AGENT_PERMISSIONS.metadataWrite, + ) + ) { + await extra.applyRecognizeEpisodePlan(plan); + emit({ + event: MEDIA_METADATA_UPDATED_EVENT, + data: { folderPath: plan.mediaFolderPath }, + }); + logger?.info( + { + planId: plan.id, + folderPath: plan.mediaFolderPath, + fileCount: plan.files.length, + }, + `[tool][${CREATE_RECOGNIZE_EPISODE_PLAN}] Plan applied automatically`, + ); + return toolOk({ + message: RECOGNIZE_PLAN_AUTO_APPLIED_MESSAGE, + planId: plan.id, + }); + } + } catch (error) { + logger?.warn( + { planId: plan.id, error }, + `[tool][${CREATE_RECOGNIZE_EPISODE_PLAN}] Auto-apply failed, plan stays pending`, + ); + } + } + + const data: RecognizeMediaFilePlanReadyRequestData = { + taskId: plan.id, + planFilePath: planPath(appDataDir, plan.id), + }; + emit({ event: RecognizeMediaFilePlanReady.event, data }); + logger?.info( + { + planId: plan.id, + folderPath: plan.mediaFolderPath, + fileCount: plan.files.length, + }, + `[tool][${CREATE_RECOGNIZE_EPISODE_PLAN}] Plan created`, + ); + + return toolOk({ + message: END_PLAN_TASK_SUCCESS_MESSAGE, + planId: plan.id, + }); + } catch (error) { + return formatToolError(error); + } + }, + }; +} + +export const CREATE_RECOGNIZE_EPISODE_PLAN_TOOL_NAME = + CREATE_RECOGNIZE_EPISODE_PLAN; +``` + +注意:若 `@smm/types/event-types` 中 `RecognizeMediaFilePlanReadyRequestData` 的实际导出名不同,以 `packages/types/event-types.ts` 中的真实命名为准(`recognizeMediaFilesTask.ts:24` 现有 import 可对照)。 + +- [ ] **Step 5: 运行确认通过** + +Run: `cd packages/core-routes && pnpm vitest run src/tools/createRecognizeEpisodePlan.test.ts src/tools/createRenameEpisodePlan.test.ts` +Expected: PASS(新 7 用例 + rename 既有用例全绿 —— 证明共享适配器重构无回归) + +- [ ] **Step 6: 类型检查并提交** + +Run: `pnpm typecheck:core && pnpm typecheck:core-routes` +Expected: 通过 + +```bash +git add packages/core-routes/src/tools/chatFsPort.ts packages/core-routes/src/tools/createRecognizeEpisodePlan.ts packages/core-routes/src/tools/createRecognizeEpisodePlan.test.ts packages/core-routes/src/tools/createRenameEpisodePlan.ts apps/core +git commit -m "feat(core-routes): add createRecognizeEpisodePlan tool builder with metadata.write gating" +``` + +--- + +### Task 4: core-routes — MCP 接线 + 三步式移除 + +**Files:** +- Create: `packages/core-routes/src/mcp/toolHandlers/createRecognizeEpisodePlan.ts` +- Modify: `packages/core-routes/src/mcp/types.ts`(`applyRenameEpisodePlan` 字段 :105 后新增 recognize 字段) +- Modify: `packages/core-routes/src/mcp/createServer.ts`(:6-8 imports、:116-122 注册块) +- Modify: `packages/core-routes/src/tools/index.ts`(imports :38-40/:68-71、`ChatTools` :100-102、`ChatToolsExtraDeps` :110-123、`createChatTools` :225-24x) +- Modify: `packages/core-routes/src/chat.ts`(:24-26 imports、:153-155 registry) +- Modify: `packages/core-routes/src/tools/plans.ts`(删除 recognize 三步式 helper) +- Modify: `packages/core-routes/src/tools/plans.test.ts`(fixture 重构) +- Delete: `packages/core-routes/src/mcp/toolHandlers/beginRecognizeTask.ts`、`addRecognizedFile.ts`、`endRecognizeTask.ts`、`packages/core-routes/src/tools/recognizeMediaFilesTask.ts` + +**Interfaces:** +- Consumes: Task 3 的 `buildCreateRecognizeEpisodePlanTool` / `CreateRecognizeEpisodePlanToolExtra`。 +- Produces: `registerCreateRecognizeEpisodePlanTool(server, config)`;`McpConfig.applyRecognizeEpisodePlan?: (plan: RecognizeMediaFilePlan) => Promise`;`ChatToolsExtraDeps.applyRecognizeEpisodePlan?`。Task 5 消费。 + +- [ ] **Step 1: McpConfig 字段** + +`packages/core-routes/src/mcp/types.ts`,在 `applyRenameEpisodePlan?: (plan: RenameFilesPlan) => Promise;`(:105)后新增: + +```ts + /** Host Core runner for applying AI recognize plans (Bun cli / Electron). */ + applyRecognizeEpisodePlan?: (plan: RecognizeMediaFilePlan) => Promise; +``` + +(`RecognizeMediaFilePlan` type import 若缺则从 `@smm/types/RecognizeMediaFilePlan` 补。) + +- [ ] **Step 2: 新 MCP handler** + +```ts +// packages/core-routes/src/mcp/toolHandlers/createRecognizeEpisodePlan.ts +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + CREATE_RECOGNIZE_EPISODE_PLAN, + CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION, + createRecognizeEpisodePlanInputSchema, +} from "@smm/types/ai-tools/createRecognizeEpisodePlan"; +import { defaultChatFs } from "../../chatFs.ts"; +import { buildCreateRecognizeEpisodePlanTool } from "../../tools/createRecognizeEpisodePlan.ts"; +import { + createErrorResponse, + createSuccessResponse, + type McpToolResponse, +} from "../index.ts"; +import type { McpConfig } from "../types.ts"; + +export function registerCreateRecognizeEpisodePlanTool( + server: McpServer, + config: McpConfig, +): void { + const tool = buildCreateRecognizeEpisodePlanTool( + config.appDataDir, + config.fs ?? defaultChatFs(), + config.broadcast, + config.logger, + undefined, + { + getUserConfig: config.getUserConfig, + applyRecognizeEpisodePlan: config.applyRecognizeEpisodePlan, + }, + ); + const description = + config.toolDescriptions?.[CREATE_RECOGNIZE_EPISODE_PLAN] ?? + CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION; + + server.registerTool( + CREATE_RECOGNIZE_EPISODE_PLAN, + { + description, + inputSchema: createRecognizeEpisodePlanInputSchema, + }, + async (args: unknown): Promise => { + const result = await tool.execute(args); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + }, + ); +} +``` + +- [ ] **Step 3: createServer.ts 注册替换** + +imports(:6-8): + +```ts +import { registerCreateRecognizeEpisodePlanTool } from "./toolHandlers/createRecognizeEpisodePlan.ts"; +``` + +(删除 `registerBeginRecognizeTaskTool` / `registerAddRecognizedFileTool` / `registerEndRecognizeTaskTool` 三行 import。) + +注册块(:116-122): + +```ts + // Episode-level rename plan. + registerCreateRenameEpisodePlanTool(server, config); + + // Episode recognition plan (single call). + registerCreateRecognizeEpisodePlanTool(server, config); +``` + +- [ ] **Step 4: git rm 三步式 handler 与工具实现** + +```bash +git rm packages/core-routes/src/mcp/toolHandlers/beginRecognizeTask.ts packages/core-routes/src/mcp/toolHandlers/addRecognizedFile.ts packages/core-routes/src/mcp/toolHandlers/endRecognizeTask.ts packages/core-routes/src/tools/recognizeMediaFilesTask.ts +``` + +- [ ] **Step 5: tools/index.ts 与 chat.ts 注册表替换** + +`tools/index.ts`: +1. imports:删除 `BEGIN_RECOGNIZE_TASK/ADD_RECOGNIZED_MEDIA_FILE/END_RECOGNIZE_TASK` 及 `recognizeMediaFilesTask.ts` 的 4 个 builder import(:38-40、:68-71),新增: +```ts +import { + CREATE_RECOGNIZE_EPISODE_PLAN, +} from "@smm/types/ai-tools/createRecognizeEpisodePlan"; +import { buildCreateRecognizeEpisodePlanTool } from "./createRecognizeEpisodePlan.ts"; +``` +(`CREATE_RENAME_EPISODE_PLAN` 及 rename builder import 已存在,保留;`RecognizeMediaFilePlan` type import 若缺则补。) +2. `ChatTools` 接口(:100-102)三行替换为: +```ts + [CREATE_RECOGNIZE_EPISODE_PLAN]: ReturnType< + typeof buildCreateRecognizeEpisodePlanTool + >; +``` +3. `ChatToolsExtraDeps`(:121-122 后)新增: +```ts + /** Host Core runner for applying AI recognize plans (Bun cli / Electron). */ + applyRecognizeEpisodePlan?: (plan: RecognizeMediaFilePlan) => Promise; +``` +4. `createChatTools` 返回表(:225-24x,三个 recognize builder 调用)替换为: +```ts + [CREATE_RECOGNIZE_EPISODE_PLAN]: buildCreateRecognizeEpisodePlanTool( + config.appDataDir, + fs, + broadcast, + logger, + abortSignal, + { + getUserConfig: () => Promise.resolve(userConfig), + applyRecognizeEpisodePlan: extra?.applyRecognizeEpisodePlan, + }, + ), +``` + +`chat.ts`:imports(:24-26)三行替换为 `CREATE_RECOGNIZE_EPISODE_PLAN`;registry(:153-155)三行替换为: +```ts + [CREATE_RECOGNIZE_EPISODE_PLAN]: tools[CREATE_RECOGNIZE_EPISODE_PLAN], +``` + +- [ ] **Step 6: tools/plans.ts 删除 recognize 三步式 helper** + +删除:`beginRecognizePlan`(:166-181)、`appendRecognizedFile`(:152 起的完整函数)、`RecognizePlanAppendDeps` 接口、`defaultValidateRecognizedFiles`(:131-150)。逐项 grep 确认仅剩死引用: + +Run: `grep -n "beginRecognizePlan\|appendRecognizedFile\|defaultValidateRecognizedFiles\|RecognizePlanAppendDeps\|ensurePlansDirExists\|readRecognizePlan" packages/core-routes/src/tools/plans.ts` + +保留规则:`readRecognizePlan`(plans.test.ts 仍用)、`updatePlanContent`/`cancelPlan`/`cleanPreparingPlans`/`readPlanById`/`createPlan`/`plansApi` 相关全部保留。`ensurePlansDirExists`/`PLAN_CANCELLED_BY_USER_MESSAGE`/`RecognizedFile`/`PlanStatus` 等 import 若仅剩死引用则一并清理;`readRecognizePlan` 如引用了被删符号则改写为直读(`fs.readJson` + task 断言)。 + +- [ ] **Step 7: plans.test.ts fixture 重构** + +原 `beginRecognizePlan(appDataDir, "/media/show", fs)` 夹具(`appendRecognizedFile` 两个 describe 与 cancellation 测试中共 4 处)替换为直写 preparing plan: + +```ts +async function seedPreparingPlan( + appDataDir: string, + planId: string, + fs: ChatFs, +): Promise { + await fs.writeJson( + `${appDataDir}/plans/${planId}.plan.json`, + { + id: planId, + task: "recognize-media-file", + status: "preparing", + creator: "ai", + mediaFolderPath: "/media/show", + files: [], + }, + ); +} +``` + +删除 `appendRecognizedFile` 的两个 describe(:74-190 附近的 in-memory / real-fs 用例)与 "appendRecognizedFile throws the cancellation message" 用例;保留并改造 "plan cancellation" / `updatePlanContent` / `cleanPreparingPlans` 用例(fixture 换 `seedPreparingPlan`)。若 `cleanPreparingPlans` 用例依赖 begin 创建 preparing —— 同样换 `seedPreparingPlan`。 + +- [ ] **Step 8: 验证** + +Run: `cd packages/core-routes && pnpm typecheck && pnpm test` +Expected: 通过(无 recognizeMediaFilesTask 残留引用;`grep -rn "recognizeMediaFilesTask\|BEGIN_RECOGNIZE_TASK\|ADD_RECOGNIZED_MEDIA_FILE\|END_RECOGNIZE_TASK" packages/core-routes/src --include="*.ts" | grep -v test` 零匹配) + +- [ ] **Step 9: Commit** + +```bash +git add -A packages/core-routes +git commit -m "refactor(core-routes): replace recognize begin/add/end tools with single-call createRecognizeEpisodePlan" +``` + +--- + +### Task 5: apps/cli — HTTP 路由 + wiring + +**Files:** +- Create: `apps/cli/src/route/RecognizeEpisodesPlan.ts` +- Test: `apps/cli/src/route/RecognizeEpisodesPlan.test.ts` +- Modify: `apps/cli/server.ts`(:47-48/:301-303 区域) +- Modify: `apps/cli/src/mcp/mcp.ts`(:151 附近)、`apps/cli/src/route/chatRoute.ts`(:20 附近) +- Modify: `docs/api/index.md` + +**Interfaces:** +- Consumes: `getCore().createRecognizeEpisodePlan`(Task 2)、`broadcast`(`@/utils/socketIO`)、`RecognizeMediaFilePlanReady`(`@smm/types/event-types`)。 +- Produces: `POST /api/create-recognize-episode-plan`;MCP/chat 的 `applyRecognizeEpisodePlan` 注入。 + +- [ ] **Step 1: 新路由** + +```ts +// apps/cli/src/route/RecognizeEpisodesPlan.ts +import type { Hono } from 'hono' +import { Path } from '@smm/utils/path' +import type { RecognizeMediaFilePlan } from '@smm/types/RecognizeMediaFilePlan' +import { + RecognizeMediaFilePlanReady, + type RecognizeMediaFilePlanReadyRequestData, +} from '@smm/types/event-types' +import { formatToolError } from '@smm/core/ai-tool/toolResult' +import { getCore } from '../core/getCore' +import { broadcast } from '@/utils/socketIO' +import { getAppDataDir } from '@/utils/config' +import { logger } from '../../lib/logger' + +export interface CreateRecognizeEpisodePlanRequestBody { + mediaFolderPath: string + files: Array<{ season: number; episode: number; path: string }> + creator?: 'ai' | 'app' +} + +export interface CreateRecognizeEpisodePlanResponseBody { + data?: { plan: RecognizeMediaFilePlan } + error?: string +} + +function readStringField(body: unknown, key: string): string | undefined { + if (typeof body !== 'object' || body === null || !(key in body)) return undefined + const value = (body as Record)[key] + return typeof value === 'string' ? value : undefined +} + +function readRecognizeFiles( + body: unknown, +): Array<{ season: number; episode: number; path: string }> | undefined { + if (typeof body !== 'object' || body === null || !('files' in body)) return undefined + const files = (body as Record).files + if (!Array.isArray(files)) return undefined + if ( + !files.every( + (file) => + typeof file === 'object' && + file !== null && + typeof (file as Record).season === 'number' && + typeof (file as Record).episode === 'number' && + typeof (file as Record).path === 'string', + ) + ) { + return undefined + } + return files as Array<{ season: number; episode: number; path: string }> +} + +export async function createRecognizeEpisodePlanFromBody( + body: unknown, +): Promise { + const mediaFolderPath = readStringField(body, 'mediaFolderPath') + if (!mediaFolderPath?.trim()) { + return { error: 'Error Reason: mediaFolderPath is required' } + } + + const files = readRecognizeFiles(body) + if (!files) { + return { error: 'Error Reason: files must be an array' } + } + + const creator = readStringField(body, 'creator') === 'app' ? 'app' : 'ai' + const plan = await getCore().createRecognizeEpisodePlan(mediaFolderPath, files, { creator }) + + if (creator === 'ai') { + const planFilePath = Path.posix(`${getAppDataDir()}/plans/${plan.id}.plan.json`) + const data: RecognizeMediaFilePlanReadyRequestData = { + taskId: plan.id, + planFilePath, + } + broadcast({ event: RecognizeMediaFilePlanReady.event, data }) + } + + return { data: { plan } } +} + +/** + * Recognize-episodes plan HTTP surface (single call): + * - POST /api/create-recognize-episode-plan → Core.createRecognizeEpisodePlan + * (apply/reject reuse POST /api/apply-plan and /api/reject-plan in RenameEpisodesPlan.ts) + */ +export function handleRecognizeEpisodesPlan(app: Hono): void { + app.post('/api/create-recognize-episode-plan', async (c) => { + try { + let body: unknown = {} + try { + body = await c.req.json() + } catch { + /* empty */ + } + return c.json(await createRecognizeEpisodePlanFromBody(body), 200) + } catch (error) { + logger.error({ error }, '[POST /api/create-recognize-episode-plan] route error') + const err: CreateRecognizeEpisodePlanResponseBody = formatToolError(error) + return c.json(err, 200) + } + }) +} +``` + +- [ ] **Step 2: 路由测试(镜像 RenameEpisodesPlan.test.ts 的 create 用例)** + +先读 `apps/cli/src/route/RenameEpisodesPlan.test.ts:27-60` 的 app/依赖组织方式,按同款写 `RecognizeEpisodesPlan.test.ts`:mock `../core/getCore`(`getCore: () => ({ createRecognizeEpisodePlan: vi.fn(async () => plan) })`)与 `@/utils/socketIO`(`broadcast: vi.fn()`),断言:ai creator → 200 + `data.plan` + broadcast `RecognizeMediaFilePlanReady`;缺 `mediaFolderPath`/`files` → error 文案;creator=app → 不 broadcast。 + +- [ ] **Step 3: server.ts 挂载** + +:47-48 附近加: + +```ts +import { handleRecognizeEpisodesPlan } from './src/route/RecognizeEpisodesPlan'; +``` + +:303 后加: + +```ts + handleRecognizeEpisodesPlan(this.app); +``` + +- [ ] **Step 4: MCP / chat 自动应用注入** + +`apps/cli/src/mcp/mcp.ts` :151 `applyRenameEpisodePlan` 行后: + +```ts + applyRecognizeEpisodePlan: (plan) => getCore().applyPlan(plan), +``` + +`apps/cli/src/route/chatRoute.ts` :20 同样追加一行。 + +- [ ] **Step 5: docs/api/index.md** + +在 rename 的 `POST /api/create-rename-episode-plan` 条目旁按同格式新增 `POST /api/create-recognize-episode-plan`(请求体 `{ mediaFolderPath, files: [{season,episode,path}], creator? }`,响应 `{ data: { plan } }`)。 + +- [ ] **Step 6: 检查 ai-tool-registry 测试** + +Run: `grep -n "Recognize" apps/cli/src/test/ai-tool-registry.test.ts` +若该测试枚举 chat/MCP 工具名,将 `begin-recognize-task`/`add-recognized-media-file`/`end-recognize-task` 三项替换为 `create-recognize-episode-plan` 一项。 + +- [ ] **Step 7: 验证并提交** + +Run: `cd apps/cli && pnpm typecheck && pnpm test` +Expected: 通过 + +```bash +git add apps/cli/src/route/RecognizeEpisodesPlan.ts apps/cli/src/route/RecognizeEpisodesPlan.test.ts apps/cli/server.ts apps/cli/src/mcp/mcp.ts apps/cli/src/route/chatRoute.ts docs/api/index.md apps/cli/src/test/ai-tool-registry.test.ts +git commit -m "feat(cli): add create-recognize-episode-plan route and metadata.write auto-apply wiring" +``` + +--- + +### Task 6: apps/ui — 前端工具替换 + 三步式/draft 删除 + +**Files:** +- Create: `apps/ui/src/api/createRecognizeEpisodePlan.ts` +- Create: `apps/ui/src/ai/tools/CreateRecognizeEpisodePlan.tsx` +- Modify: `apps/ui/src/ai/tools/index.ts`、`apps/ui/src/ai/Assistant.tsx`(:28-30 imports + 使用处)、`apps/ui/src/ai/Assistant.registry.test.ts`(:33-35) +- Modify: `apps/ui/src/hooks/plans/index.ts`(删 `useCreatePlanMutation` 导出) +- Modify: `apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.ts` + `.test.ts`、`useAiBasedRenameEpisodeFlow.ts` + `.test.ts`(去 `cleanup*Plan`) +- Delete: `apps/ui/src/ai/tools/{BeginRecognizeTask,AddRecognizedMediaFile,EndRecognizeTask}.tsx`、`apps/ui/src/ai/plan/{aiPlanDrafts,recognizePlanService,cleanupRenamePlan}.ts`、`apps/ui/src/hooks/plans/useCreatePlanMutation.ts` + +**Interfaces:** +- Consumes: Task 5 的 HTTP 路由。 +- Produces: `createRecognizeEpisodePlanApi(request, signal?)`;`CreateRecognizeEpisodePlanTool`。 + +- [ ] **Step 1: api wrapper** + +```ts +// apps/ui/src/api/createRecognizeEpisodePlan.ts +import type { PlanCreator } from '@smm/types/planCommon' +import type { RecognizeMediaFilePlan } from '@smm/types/RecognizeMediaFilePlan' +import { apiFetch } from '@/lib/apiFetch' + +export interface CreateRecognizeEpisodePlanRequest { + mediaFolderPath: string + files: Array<{ season: number; episode: number; path: string }> + creator: PlanCreator +} + +export interface CreateRecognizeEpisodePlanResponseBody { + data?: { plan: RecognizeMediaFilePlan } + error?: string +} + +export async function createRecognizeEpisodePlanApi( + request: CreateRecognizeEpisodePlanRequest, + signal?: AbortSignal, +): Promise { + const resp = await apiFetch('/api/create-recognize-episode-plan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal, + }) + + if (!resp.ok) { + throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`) + } + + return (await resp.json()) as CreateRecognizeEpisodePlanResponseBody +} +``` + +- [ ] **Step 2: 前端工具** + +```tsx +// apps/ui/src/ai/tools/CreateRecognizeEpisodePlan.tsx +import { makeAssistantTool, tool } from '@assistant-ui/react' +import { + CREATE_RECOGNIZE_EPISODE_PLAN, + CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION, + createRecognizeEpisodePlanInputSchema, +} from '@smm/types/ai-tools/createRecognizeEpisodePlan' +import { END_PLAN_TASK_SUCCESS_MESSAGE } from '@smm/types/ai-tools/planTaskMessages' +import { formatToolError, toolOk } from '@smm/core/ai-tool/toolResult' +import { createRecognizeEpisodePlanApi } from '@/api/createRecognizeEpisodePlan' +import { PLANS_QUERY_ROOT } from '@/hooks/plans' +import { queryClient } from '@/lib/queryClient' + +const createRecognizeEpisodePlan = tool({ + description: CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION, + parameters: createRecognizeEpisodePlanInputSchema, + execute: async ({ mediaFolderPath, files }) => { + try { + const resp = await createRecognizeEpisodePlanApi({ + mediaFolderPath, + files, + creator: 'ai', + }) + if (resp.error || !resp.data) { + return { error: resp.error ?? 'Error Reason: Plan creation returned no data' } + } + + await queryClient.invalidateQueries({ queryKey: [PLANS_QUERY_ROOT] }) + return toolOk({ + message: END_PLAN_TASK_SUCCESS_MESSAGE, + taskId: resp.data.plan.id, + }) + } catch (error) { + return formatToolError(error) + } + }, +}) + +export const CreateRecognizeEpisodePlanTool = makeAssistantTool({ + ...createRecognizeEpisodePlan, + toolName: CREATE_RECOGNIZE_EPISODE_PLAN, +}) +``` + +- [ ] **Step 3: 删除与注册表更新** + +```bash +git rm apps/ui/src/ai/tools/BeginRecognizeTask.tsx apps/ui/src/ai/tools/AddRecognizedMediaFile.tsx apps/ui/src/ai/tools/EndRecognizeTask.tsx apps/ui/src/ai/plan/aiPlanDrafts.ts apps/ui/src/ai/plan/recognizePlanService.ts apps/ui/src/ai/plan/cleanupRenamePlan.ts apps/ui/src/hooks/plans/useCreatePlanMutation.ts +``` + +`ai/tools/index.ts`:三行 recognize 导出与 `cleanupRecognizePlan` 导出替换为 `export { CreateRecognizeEpisodePlanTool } from './CreateRecognizeEpisodePlan'`。`hooks/plans/index.ts`:删除 `useCreatePlanMutation` 导出行。`Assistant.tsx`:import 区三行换 `CreateRecognizeEpisodePlanTool`,并在该文件内工具使用处(grep `BeginRecognizeTaskTool` 找到全部出现点)同步替换。`Assistant.registry.test.ts`:`:33-35` 三项换 `'CreateRecognizeEpisodePlanTool'`。 + +- [ ] **Step 4: flow hooks 去 cleanup** + +`useAiBasedRenameEpisodeFlow.ts`:删 `import { cleanupRenamePlan } from "@/ai/plan/cleanupRenamePlan"`;confirm 中 `await cleanupRenamePlan(plan.id)` 删除;cancel 中同删;错误文案保留。`useAiBasedRenameEpisodeFlow.test.ts`:删 `cleanupRenamePlan` mock/hoisted/断言(confirm 用例断言只保留 `applyPlanMutateAsync`;cancel 用例只保留 `updatePlanMutateAsync`)。`useAiBasedRecognizeEpisodeFlow.ts`/`.test.ts` 同法(删 `cleanupRecognizePlan`)。 + +- [ ] **Step 5: 验证** + +Run: `cd apps/ui && pnpm typecheck && pnpm vitest run` +Expected: 通过;`grep -rn "BeginRecognizeTask\|AddRecognizedMediaFile\|EndRecognizeTask\|aiPlanDrafts\|recognizePlanService\|cleanupRenamePlan\|cleanupRecognizePlan\|useCreatePlanMutation" apps/ui/src --include="*.ts" --include="*.tsx" | grep -v test` 零匹配 + +- [ ] **Step 6: Commit** + +```bash +git add -A apps/ui +git commit -m "refactor(ui): replace recognize 3-step chat tools with single-call createRecognizeEpisodePlan" +``` + +--- + +### Task 7: apps/ui — promptStatus 删除与 UI 收紧 + +**Files:** +- Modify: `apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.ts`、`useAiBasedRecognizeEpisodeFlow.ts` 及两个 `.test.ts` +- Modify: `apps/ui/src/components/tv/AiBasedRenameEpisodePrompt.tsx`、`AiBasedRecognizeEpisodePrompt.tsx` +- Modify: `apps/ui/src/components/tv/plans/selectActiveAppPlan.ts` + `selectActiveAppPlan.test.ts` +- Modify: `apps/ui/public/locales/{en,zh-CN,zh-HK,zh-TW}/components.json`(删 `toolbar.aiGenerating`、`toolbar.aiRecognizing`) + +**Interfaces:** +- Produces: flow hook 返回值去掉 `promptStatus`;`promptProps = { isOpen, onConfirm, onCancel }`;prompt 组件 props 删 `status`。Task 8 验证依赖。 + +- [ ] **Step 1: 先改测试(RED)** + +两个 hook 测试:删除 `"maps a preparing plan to the generating prompt status"` 用例;将 promptStatus 相关断言改为 `expect(result.current.promptStatus).toBeUndefined()`(rename 用例)/删除(recognize 用例的 `promptStatus` 断言删掉);`promptProps` 断言去掉 `status`。运行: + +Run: `cd apps/ui && pnpm vitest run src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts` +Expected: FAIL(promptStatus 仍存在 / promptProps 仍含 status) + +- [ ] **Step 2: hook 实现(GREEN)** + +两个 hook:删 `const promptStatus: ... = plan?.status === "preparing" ? "generating" : "wait-for-ack"`;`promptProps` memo 去掉 `status` 字段与对应类型字段;返回对象删 `promptStatus`。 + +- [ ] **Step 3: prompt 组件** + +`AiBasedRenameEpisodePrompt.tsx` 全量替换为: + +```tsx +import { FloatingPrompt, type FloatingPromptProps } from "../FloatingPrompt" +import { cn } from "@/lib/utils" +import { useTranslation } from "@/lib/i18n" + +export interface AiBasedRenameEpisodePromptProps extends Omit { +} + +/** + * AiBasedRenameEpisodePrompt component built on top of FloatingPrompt. + * Used to confirm AI episode renaming operations. + */ +export function AiBasedRenameEpisodePrompt({ + onConfirm, + onCancel, + isOpen = false, + className, + confirmLabel, + cancelLabel, + isConfirmButtonDisabled, + isConfirmDisabled, + ...promptProps +}: AiBasedRenameEpisodePromptProps) { + const { t } = useTranslation('components') + + return ( + +
+ + {t('toolbar.aiReview', { defaultValue: 'Review AI-generated file names' })} + +
+
+ ) +} +``` + +(`AiBasedRecognizeEpisodePrompt.tsx` 同法:删 status prop/generating 分支/`Loader2` import/`isConfirmButtonDisabledFinal` 的 generating 项,文案用 `toolbar.aiReviewEpisodes`,testid 保持 `ai-based-recognize-status`。) + +- [ ] **Step 4: selectActiveAiPlan 收紧** + +`selectActiveAppPlan.ts`:`selectActiveAiPlan` 改为仅匹配 `pending`(`selectActiveAppPlan` 保持不变): + +```ts +export function selectActiveAiPlan( + plans: Plan[], + mediaFolderPath: string | undefined, + task: PlanTask, +): T | undefined { + if (!mediaFolderPath) return undefined + + return plans.find( + (p) => + p.task === task && + p.creator === "ai" && + p.status === "pending" && + mediaFolderPathEqual(p.mediaFolderPath, mediaFolderPath), + ) as T | undefined +} +``` + +同步更新 `selectActiveAppPlan.test.ts` 中 AI 相关 preparing 期望(改为不 surfaced)。 + +- [ ] **Step 5: locale** + +四个 `apps/ui/public/locales/*/components.json` 删除 `toolbar.aiGenerating` 与 `toolbar.aiRecognizing` 两个键(保留 `aiReview`/`aiReviewEpisodes`)。若 `TvShowPanel.locale.test.ts` 校验键集合,按其断言方式同步。 + +- [ ] **Step 6: 验证** + +Run: `cd apps/ui && pnpm typecheck && pnpm vitest run` +Expected: 全绿 + +- [ ] **Step 7: Commit** + +```bash +git add apps/ui/src/hooks/tv apps/ui/src/components/tv apps/ui/public/locales +git commit -m "refactor(ui): remove promptStatus and preparing UI after single-call recognize migration" +``` + +**注意**:`apps/ui/src/components/tv/TvShowPanel.tsx` 有用户本地改动,本任务**不触碰**该文件。 + +--- + +### Task 8: 收尾验证 + +**Files:** 无新增(修复并入本任务提交) + +- [ ] **Step 1: 残留引用 grep** + +Run: `grep -rn "recognizeMediaFilesTask\|BEGIN_RECOGNIZE_TASK\|ADD_RECOGNIZED_MEDIA_FILE\|END_RECOGNIZE_TASK\|begin-recognize-task\|add-recognized-media-file\|end-recognize-task\|promptStatus" apps packages --include="*.ts" --include="*.tsx" | grep -v "\.superpowers\|docs/\|node_modules"` +Expected: 零匹配(e2e 规格文件除外 —— 属后续轮次) + +- [ ] **Step 2: 全量门禁** + +Run: `pnpm build && pnpm typecheck && pnpm test` +Expected: 全部通过 + +- [ ] **Step 3:(如有修复)Commit** + +```bash +git add -A && git commit -m "chore: fixups for recognize single-call migration" +``` From ec485ee0f876538de7cb01679b702d520218ae78 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 7 Sep 2026 02:03:10 +0800 Subject: [PATCH 56/83] feat(types): add createRecognizeEpisodePlan schema and auto-applied message --- .../ai-tools/createRecognizeEpisodePlan.ts | 29 +++++++++++++++++++ packages/types/ai-tools/planTaskMessages.ts | 7 +++++ 2 files changed, 36 insertions(+) create mode 100644 packages/types/ai-tools/createRecognizeEpisodePlan.ts diff --git a/packages/types/ai-tools/createRecognizeEpisodePlan.ts b/packages/types/ai-tools/createRecognizeEpisodePlan.ts new file mode 100644 index 00000000..5eabda7d --- /dev/null +++ b/packages/types/ai-tools/createRecognizeEpisodePlan.ts @@ -0,0 +1,29 @@ +import { z } from 'zod' + +export const CREATE_RECOGNIZE_EPISODE_PLAN = 'create-recognize-episode-plan' as const + +export const CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION = + 'Create a recognize-media-file plan that maps episode video files to season/episode numbers. ' + + 'Provide every mapping (season, episode, absolute file path) in one call. ' + + 'After success, tell the user to open SMM, review, and approve the plan.' + +export const createRecognizeEpisodePlanInputSchema = z.object({ + mediaFolderPath: z + .string() + .describe('Absolute media folder path (POSIX or Windows)'), + files: z + .array( + z.object({ + season: z.number().describe('The season number of the episode.'), + episode: z.number().describe('The episode number.'), + path: z + .string() + .describe('The absolute path of the media file (POSIX or Windows format).'), + }), + ) + .min(1), +}) + +export type CreateRecognizeEpisodePlanInput = z.infer< + typeof createRecognizeEpisodePlanInputSchema +> diff --git a/packages/types/ai-tools/planTaskMessages.ts b/packages/types/ai-tools/planTaskMessages.ts index e1662402..6a19c101 100644 --- a/packages/types/ai-tools/planTaskMessages.ts +++ b/packages/types/ai-tools/planTaskMessages.ts @@ -19,3 +19,10 @@ export const PLAN_CANCELLED_BY_USER_MESSAGE = */ export const RENAME_PLAN_AUTO_APPLIED_MESSAGE = "Rename plan applied automatically (metadata.write permission granted). No user approval needed."; + +/** + * Returned to the AI when the recognize plan was applied automatically + * because the user granted the `metadata.write` permission. + */ +export const RECOGNIZE_PLAN_AUTO_APPLIED_MESSAGE = + "Recognize plan applied automatically (metadata.write permission granted). No user approval needed."; From 019b24928f39f69efa11c9f4b383eb237e8a3bbf Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 7 Sep 2026 02:07:57 +0800 Subject: [PATCH 57/83] feat(core): add createRecognizeEpisodePlanPipeline and Core method --- apps/core/src/Core.ts | 16 +++ .../createRecognizeEpisodePlan.test.ts | 99 +++++++++++++++++++ .../pipeline/createRecognizeEpisodePlan.ts | 73 ++++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 apps/core/src/pipeline/createRecognizeEpisodePlan.test.ts create mode 100644 apps/core/src/pipeline/createRecognizeEpisodePlan.ts diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index e056cb1c..d900ec94 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -77,6 +77,10 @@ import { createRenameEpisodePlanPipeline, type CreateRenameEpisodePlanOptions, } from "./pipeline/createRenameEpisodePlan"; +import { + createRecognizeEpisodePlanPipeline, + type CreateRecognizeEpisodePlanOptions as CreateRecognizeEpisodePlanCoreOptions, +} from "./pipeline/createRecognizeEpisodePlan"; import { tryToRenameFolderPipeline } from "./pipeline/tryToRenameFolder"; import { prepareScrapeFolder, @@ -544,6 +548,18 @@ export class Core { }); } + async createRecognizeEpisodePlan( + mediaFolderPath: string, + files: Array<{ season: number; episode: number; path: string }>, + options?: CreateRecognizeEpisodePlanCoreOptions, + ): Promise { + return createRecognizeEpisodePlanPipeline(mediaFolderPath, files, options, { + fs: this.fs, + appDataDir: this.getMetadataRoot(), + normalizePosix: (path) => this.normalizePosix(path), + }); + } + async getPlan(id: string): Promise { const plan = await readPlan(this.fs, this.getMetadataRoot(), id); if (!plan) throw new Error(`Plan not found: ${id}`); diff --git a/apps/core/src/pipeline/createRecognizeEpisodePlan.test.ts b/apps/core/src/pipeline/createRecognizeEpisodePlan.test.ts new file mode 100644 index 00000000..51d0f39d --- /dev/null +++ b/apps/core/src/pipeline/createRecognizeEpisodePlan.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; +import type { FsPort } from "../ports/FsPort"; +import { createRecognizeEpisodePlanPipeline } from "./createRecognizeEpisodePlan"; +import { planFilePath } from "./paths"; + +function inMemoryFs(seed: Record = {}): FsPort { + const files = new Map(Object.entries(seed)); + return { + readTextFile: vi.fn(async (path: string) => { + const v = files.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }), + writeTextFile: vi.fn(async (path: string, content: string) => { + files.set(path, content); + }), + writeBinaryFile: vi.fn(async () => {}), + exists: vi.fn(async (path: string) => files.has(path)), + listFiles: vi.fn(async () => []), + deleteFile: vi.fn(async () => {}), + rename: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), + listSubdirectories: vi.fn(async () => []), + }; +} + +describe("createRecognizeEpisodePlanPipeline", () => { + const appDataDir = "/data"; + const folder = "/m/Show"; + + it("writes a pending ai plan with posix paths", async () => { + const fs = inMemoryFs({ "/m/Show/S01E01.mkv": "" }); + const plan = await createRecognizeEpisodePlanPipeline( + folder, + [{ season: 1, episode: 1, path: "/m/Show/S01E01.mkv" }], + { creator: "ai", id: "fixed-id" }, + { fs, appDataDir, normalizePosix: (p) => p, createId: () => "fixed-id" }, + ); + expect(plan.status).toBe("pending"); + expect(plan.creator).toBe("ai"); + expect(plan.task).toBe("recognize-media-file"); + expect(plan.files[0]).toEqual({ season: 1, episode: 1, path: "/m/Show/S01E01.mkv" }); + expect(await fs.exists(planFilePath(appDataDir, "fixed-id"))).toBe(true); + }); + + it("rejects empty files", async () => { + const fs = inMemoryFs(); + await expect( + createRecognizeEpisodePlanPipeline(folder, [], undefined, { + fs, appDataDir, normalizePosix: (p) => p, + }), + ).rejects.toThrow("No recognize entries in task"); + }); + + it("rejects duplicate paths", async () => { + const fs = inMemoryFs({ "/m/Show/S01E01.mkv": "" }); + await expect( + createRecognizeEpisodePlanPipeline( + folder, + [ + { season: 1, episode: 1, path: "/m/Show/S01E01.mkv" }, + { season: 1, episode: 2, path: "/m/Show/S01E01.mkv" }, + ], + undefined, + { fs, appDataDir, normalizePosix: (p) => p }, + ), + ).rejects.toThrow("Duplicate file path"); + }); + + it("rejects duplicate season/episode pairs", async () => { + const fs = inMemoryFs({ + "/m/Show/a.mkv": "", + "/m/Show/b.mkv": "", + }); + await expect( + createRecognizeEpisodePlanPipeline( + folder, + [ + { season: 1, episode: 1, path: "/m/Show/a.mkv" }, + { season: 1, episode: 1, path: "/m/Show/b.mkv" }, + ], + undefined, + { fs, appDataDir, normalizePosix: (p) => p }, + ), + ).rejects.toThrow("Duplicate season/episode"); + }); + + it("rejects files that do not exist", async () => { + const fs = inMemoryFs(); + await expect( + createRecognizeEpisodePlanPipeline( + folder, + [{ season: 1, episode: 1, path: "/m/Show/missing.mkv" }], + undefined, + { fs, appDataDir, normalizePosix: (p) => p }, + ), + ).rejects.toThrow('does not exist in the media folder'); + }); +}); diff --git a/apps/core/src/pipeline/createRecognizeEpisodePlan.ts b/apps/core/src/pipeline/createRecognizeEpisodePlan.ts new file mode 100644 index 00000000..25542a67 --- /dev/null +++ b/apps/core/src/pipeline/createRecognizeEpisodePlan.ts @@ -0,0 +1,73 @@ +import { randomUUID } from "node:crypto"; +import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan"; +import type { FsPort } from "../ports/FsPort"; +import { writePlan } from "./plans"; + +export interface CreateRecognizeEpisodePlanOptions { + creator?: "app" | "ai"; + id?: string; +} + +export interface CreateRecognizeEpisodePlanDeps { + fs: FsPort; + appDataDir: string; + normalizePosix: (path: string) => string; + createId?: () => string; +} + +export async function createRecognizeEpisodePlanPipeline( + mediaFolderPath: string, + files: Array<{ season: number; episode: number; path: string }>, + options: CreateRecognizeEpisodePlanOptions | undefined, + deps: CreateRecognizeEpisodePlanDeps, +): Promise { + const posixFolder = deps.normalizePosix(mediaFolderPath); + + if (files.length === 0) { + throw new Error("No recognize entries in task"); + } + + const normalizedFiles = files.map((file) => ({ + season: file.season, + episode: file.episode, + path: deps.normalizePosix(file.path), + })); + + const seenPaths = new Set(); + const seenEpisodes = new Set(); + for (const file of normalizedFiles) { + if (seenPaths.has(file.path)) { + throw new Error(`Duplicate file path in task: ${file.path}`); + } + seenPaths.add(file.path); + + const episodeKey = `${file.season}-${file.episode}`; + if (seenEpisodes.has(episodeKey)) { + throw new Error( + `Duplicate season/episode in task: S${file.season}E${file.episode}`, + ); + } + seenEpisodes.add(episodeKey); + + if (!(await deps.fs.exists(file.path))) { + throw new Error( + `File "${file.path}" (S${file.season}E${file.episode}) does not exist in the media folder`, + ); + } + } + + const createId = deps.createId ?? randomUUID; + const id = options?.id ?? createId(); + + const plan: RecognizeMediaFilePlan = { + id, + task: "recognize-media-file", + status: "pending", + creator: options?.creator ?? "app", + mediaFolderPath: posixFolder, + files: normalizedFiles, + }; + + await writePlan(deps.fs, deps.appDataDir, plan); + return plan; +} From bbbd685386cc85b6b4a08ce9e7af9f72c95087d3 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 7 Sep 2026 02:12:55 +0800 Subject: [PATCH 58/83] feat(core-routes): add createRecognizeEpisodePlan tool builder with metadata.write gating --- apps/core/package.json | 1 + packages/core-routes/src/tools/chatFsPort.ts | 46 ++++ .../tools/createRecognizeEpisodePlan.test.ts | 223 ++++++++++++++++++ .../src/tools/createRecognizeEpisodePlan.ts | 139 +++++++++++ .../src/tools/createRenameEpisodePlan.ts | 44 +--- 5 files changed, 410 insertions(+), 43 deletions(-) create mode 100644 packages/core-routes/src/tools/chatFsPort.ts create mode 100644 packages/core-routes/src/tools/createRecognizeEpisodePlan.test.ts create mode 100644 packages/core-routes/src/tools/createRecognizeEpisodePlan.ts diff --git a/apps/core/package.json b/apps/core/package.json index 52f56208..765274b6 100644 --- a/apps/core/package.json +++ b/apps/core/package.json @@ -6,6 +6,7 @@ "exports": { ".": "./src/index.ts", "./createRenameEpisodePlan": "./src/pipeline/createRenameEpisodePlan.ts", + "./createRecognizeEpisodePlan": "./src/pipeline/createRecognizeEpisodePlan.ts", "./FsPort": "./src/ports/FsPort.ts", "./ai-tool/*": "./src/ai-tool/*", "./pipeline/*": "./src/pipeline/*", diff --git a/packages/core-routes/src/tools/chatFsPort.ts b/packages/core-routes/src/tools/chatFsPort.ts new file mode 100644 index 00000000..ef66966a --- /dev/null +++ b/packages/core-routes/src/tools/chatFsPort.ts @@ -0,0 +1,46 @@ +import { Path } from "@smm/utils/path"; +import type { FsPort } from "@smm/core/FsPort"; +import type { ChatFs } from "../chatTypes.ts"; + +function unsupportedFsOperation(name: string): never { + throw new Error(`${name} is not supported by the plan filesystem adapter`); +} + +export function createFsPort(fs: ChatFs): FsPort { + return { + async readTextFile(path: string): Promise { + const value = await fs.readJson(path); + if (value === null) { + throw new Error(`File not found: ${path}`); + } + return JSON.stringify(value); + }, + async writeTextFile(path: string, content: string): Promise { + await fs.writeJson(path, JSON.parse(content) as unknown); + }, + async writeBinaryFile(): Promise { + unsupportedFsOperation("writeBinaryFile"); + }, + exists: (path: string) => fs.exists(path), + isFile: (path: string) => fs.exists(path), + async listFiles(): Promise { + return unsupportedFsOperation("listFiles"); + }, + async listSubdirectories(): Promise { + return unsupportedFsOperation("listSubdirectories"); + }, + async deleteFile(): Promise { + unsupportedFsOperation("deleteFile"); + }, + async rename(): Promise { + unsupportedFsOperation("rename"); + }, + async mkdir(): Promise { + unsupportedFsOperation("mkdir"); + }, + }; +} + +export function planPath(appDataDir: string, planId: string): string { + return new Path(appDataDir, `plans/${planId}.plan.json`).abs("posix"); +} diff --git a/packages/core-routes/src/tools/createRecognizeEpisodePlan.test.ts b/packages/core-routes/src/tools/createRecognizeEpisodePlan.test.ts new file mode 100644 index 00000000..01582d01 --- /dev/null +++ b/packages/core-routes/src/tools/createRecognizeEpisodePlan.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it, vi } from "vitest"; +import { CREATE_RECOGNIZE_EPISODE_PLAN } from "@smm/types/ai-tools/createRecognizeEpisodePlan"; +import { AI_AGENT_PERMISSIONS, type UserConfig } from "@smm/types"; +import { + END_PLAN_TASK_SUCCESS_MESSAGE, + RECOGNIZE_PLAN_AUTO_APPLIED_MESSAGE, +} from "@smm/types/ai-tools/planTaskMessages"; +import { + MEDIA_METADATA_UPDATED_EVENT, + RecognizeMediaFilePlanReady, +} from "@smm/types/event-types"; +import type { ChatFs } from "../chatTypes.ts"; +import { buildCreateRecognizeEpisodePlanTool } from "./createRecognizeEpisodePlan.ts"; + +function createMockFs(folder: string): ChatFs { + const values = new Map(); + return { + async readJson(path: string): Promise { + return (values.get(path) ?? null) as T | null; + }, + writeJson: vi.fn(async (path: string, value: unknown) => { + values.set(path, value); + }), + exists: vi.fn(async (path: string) => path === `${folder}/S01E01.mkv`), + }; +} + +const FILES = [{ season: 1, episode: 1, path: "/media/show/S01E01.mkv" }]; + +function grantedConfig(): UserConfig { + return { + aiAgent: { permissions: [AI_AGENT_PERMISSIONS.metadataWrite] }, + } as unknown as UserConfig; +} + +describe(`buildCreateRecognizeEpisodePlanTool (${CREATE_RECOGNIZE_EPISODE_PLAN})`, () => { + it("pending flow: emits RecognizeMediaFilePlanReady and returns the success message", async () => { + const broadcast = vi.fn(); + const tool = buildCreateRecognizeEpisodePlanTool( + "/app-data", + createMockFs("/media/show"), + broadcast, + ); + + const result = await tool.execute({ + mediaFolderPath: "/media/show", + files: FILES, + }); + + if (!("planId" in result)) { + throw new Error(result.error); + } + expect(result.message).toBe(END_PLAN_TASK_SUCCESS_MESSAGE); + expect(result.planId).toEqual(expect.any(String)); + expect(broadcast).toHaveBeenCalledWith({ + event: RecognizeMediaFilePlanReady.event, + data: { + taskId: result.planId, + planFilePath: `/app-data/plans/${result.planId}.plan.json`, + }, + }); + }); + + it("auto-apply: granted permission + applier applies, emits mediaMetadataUpdated, no PlanReady", async () => { + const broadcast = vi.fn(); + const applyRecognizeEpisodePlan = vi.fn(async () => {}); + const tool = buildCreateRecognizeEpisodePlanTool( + "/app-data", + createMockFs("/media/show"), + broadcast, + undefined, + undefined, + { + getUserConfig: async () => grantedConfig(), + applyRecognizeEpisodePlan, + }, + ); + + const result = await tool.execute({ + mediaFolderPath: "/media/show", + files: FILES, + }); + + if (!("planId" in result)) { + throw new Error(result.error); + } + expect(result.message).toBe(RECOGNIZE_PLAN_AUTO_APPLIED_MESSAGE); + expect(applyRecognizeEpisodePlan).toHaveBeenCalledTimes(1); + expect(broadcast).toHaveBeenCalledWith({ + event: MEDIA_METADATA_UPDATED_EVENT, + data: { folderPath: "/media/show" }, + }); + expect( + broadcast.mock.calls.some( + (call) => call[0].event === RecognizeMediaFilePlanReady.event, + ), + ).toBe(false); + }); + + it("applier failure falls back to the pending flow", async () => { + const broadcast = vi.fn(); + const tool = buildCreateRecognizeEpisodePlanTool( + "/app-data", + createMockFs("/media/show"), + broadcast, + undefined, + undefined, + { + getUserConfig: async () => grantedConfig(), + applyRecognizeEpisodePlan: vi.fn(async () => { + throw new Error("disk locked"); + }), + }, + ); + + const result = await tool.execute({ + mediaFolderPath: "/media/show", + files: FILES, + }); + + if (!("planId" in result)) { + throw new Error(result.error); + } + expect(result.message).toBe(END_PLAN_TASK_SUCCESS_MESSAGE); + expect( + broadcast.mock.calls.some( + (call) => call[0].event === RecognizeMediaFilePlanReady.event, + ), + ).toBe(true); + }); + + it("no permission → pending flow", async () => { + const broadcast = vi.fn(); + const applyRecognizeEpisodePlan = vi.fn(async () => {}); + const tool = buildCreateRecognizeEpisodePlanTool( + "/app-data", + createMockFs("/media/show"), + broadcast, + undefined, + undefined, + { + getUserConfig: async () => ({}) as UserConfig, + applyRecognizeEpisodePlan, + }, + ); + + const result = await tool.execute({ + mediaFolderPath: "/media/show", + files: FILES, + }); + + if (!("planId" in result)) { + throw new Error(result.error); + } + expect(result.message).toBe(END_PLAN_TASK_SUCCESS_MESSAGE); + expect(applyRecognizeEpisodePlan).not.toHaveBeenCalled(); + }); + + it("absent deps → pending flow", async () => { + const broadcast = vi.fn(); + const tool = buildCreateRecognizeEpisodePlanTool( + "/app-data", + createMockFs("/media/show"), + broadcast, + ); + const result = await tool.execute({ + mediaFolderPath: "/media/show", + files: FILES, + }); + if (!("planId" in result)) { + throw new Error(result.error); + } + expect(result.message).toBe(END_PLAN_TASK_SUCCESS_MESSAGE); + }); + + it("getUserConfig rejection → pending flow", async () => { + const broadcast = vi.fn(); + const applyRecognizeEpisodePlan = vi.fn(async () => {}); + const tool = buildCreateRecognizeEpisodePlanTool( + "/app-data", + createMockFs("/media/show"), + broadcast, + undefined, + undefined, + { + getUserConfig: async () => { + throw new Error("config unavailable"); + }, + applyRecognizeEpisodePlan, + }, + ); + + const result = await tool.execute({ + mediaFolderPath: "/media/show", + files: FILES, + }); + + if (!("planId" in result)) { + throw new Error(result.error); + } + expect(result.message).toBe(END_PLAN_TASK_SUCCESS_MESSAGE); + expect(applyRecognizeEpisodePlan).not.toHaveBeenCalled(); + }); + + it("validation failure returns an error payload and writes nothing", async () => { + const broadcast = vi.fn(); + const tool = buildCreateRecognizeEpisodePlanTool( + "/app-data", + createMockFs("/media/show"), + broadcast, + ); + + const result = await tool.execute({ + mediaFolderPath: "/media/show", + files: [ + { season: 1, episode: 1, path: "/media/show/S01E01.mkv" }, + { season: 1, episode: 1, path: "/media/show/S01E02.mkv" }, + ], + }); + + expect(result.error).toContain("Duplicate season/episode"); + }); +}); diff --git a/packages/core-routes/src/tools/createRecognizeEpisodePlan.ts b/packages/core-routes/src/tools/createRecognizeEpisodePlan.ts new file mode 100644 index 00000000..a98fb08f --- /dev/null +++ b/packages/core-routes/src/tools/createRecognizeEpisodePlan.ts @@ -0,0 +1,139 @@ +import { createRecognizeEpisodePlanPipeline } from "@smm/core/createRecognizeEpisodePlan"; +import type { FsPort } from "@smm/core/FsPort"; +import { Path } from "@smm/utils/path"; +import { + AI_AGENT_PERMISSIONS, + hasAiAgentPermission, + type UserConfig, +} from "@smm/types"; +import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan"; +import { + CREATE_RECOGNIZE_EPISODE_PLAN, + CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION, + createRecognizeEpisodePlanInputSchema, +} from "@smm/types/ai-tools/createRecognizeEpisodePlan"; +import { + END_PLAN_TASK_SUCCESS_MESSAGE, + RECOGNIZE_PLAN_AUTO_APPLIED_MESSAGE, +} from "@smm/types/ai-tools/planTaskMessages"; +import { + MEDIA_METADATA_UPDATED_EVENT, + RecognizeMediaFilePlanReady, + type RecognizeMediaFilePlanReadyRequestData, +} from "@smm/types/event-types"; +import { formatToolError, toolOk } from "@smm/core/ai-tool/toolResult"; +import type { ChatFs } from "../chatTypes.ts"; +import type { CoreRoutesLogger } from "../types.ts"; +import type { WebSocketMessage } from "../socketIO/types.ts"; +import { defaultBroadcast } from "./broadcast.ts"; +import { createFsPort, planPath } from "./chatFsPort.ts"; + +/** + * Optional dependencies for the `metadata.write` auto-apply flow. + * Auto-apply requires BOTH deps: without `getUserConfig` the tool + * cannot verify the permission; without `applyRecognizeEpisodePlan` + * (hosts without a Core instance, e.g. ohos) it cannot apply. + */ +export interface CreateRecognizeEpisodePlanToolExtra { + /** Reads the current user config for the metadata.write permission check. */ + getUserConfig?: () => Promise; + /** Applies (merges metadata of) a created plan. Host Core runner, e.g. `Core.applyPlan`. */ + applyRecognizeEpisodePlan?: (plan: RecognizeMediaFilePlan) => Promise; +} + +export function buildCreateRecognizeEpisodePlanTool( + appDataDir: string, + fs: ChatFs, + broadcast?: (message: WebSocketMessage) => void, + logger?: CoreRoutesLogger, + abortSignal?: AbortSignal, + extra?: CreateRecognizeEpisodePlanToolExtra, +) { + const emit = broadcast ?? defaultBroadcast; + return { + description: CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION, + inputSchema: createRecognizeEpisodePlanInputSchema, + execute: async (args: unknown) => { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); + } + + const parsed = createRecognizeEpisodePlanInputSchema.safeParse(args); + if (!parsed.success) { + return formatToolError(parsed.error); + } + + try { + const plan = await createRecognizeEpisodePlanPipeline( + parsed.data.mediaFolderPath, + parsed.data.files, + { creator: "ai" }, + { + fs: createFsPort(fs), + appDataDir, + normalizePosix: Path.posix, + }, + ); + + if (extra?.getUserConfig && extra.applyRecognizeEpisodePlan) { + try { + const userConfig = await extra.getUserConfig(); + if ( + hasAiAgentPermission( + userConfig, + AI_AGENT_PERMISSIONS.metadataWrite, + ) + ) { + await extra.applyRecognizeEpisodePlan(plan); + emit({ + event: MEDIA_METADATA_UPDATED_EVENT, + data: { folderPath: plan.mediaFolderPath }, + }); + logger?.info( + { + planId: plan.id, + folderPath: plan.mediaFolderPath, + fileCount: plan.files.length, + }, + `[tool][${CREATE_RECOGNIZE_EPISODE_PLAN}] Plan applied automatically`, + ); + return toolOk({ + message: RECOGNIZE_PLAN_AUTO_APPLIED_MESSAGE, + planId: plan.id, + }); + } + } catch (error) { + logger?.warn( + { planId: plan.id, error }, + `[tool][${CREATE_RECOGNIZE_EPISODE_PLAN}] Auto-apply failed, plan stays pending`, + ); + } + } + + const data: RecognizeMediaFilePlanReadyRequestData = { + taskId: plan.id, + planFilePath: planPath(appDataDir, plan.id), + }; + emit({ event: RecognizeMediaFilePlanReady.event, data }); + logger?.info( + { + planId: plan.id, + folderPath: plan.mediaFolderPath, + fileCount: plan.files.length, + }, + `[tool][${CREATE_RECOGNIZE_EPISODE_PLAN}] Plan created`, + ); + + return toolOk({ + message: END_PLAN_TASK_SUCCESS_MESSAGE, + planId: plan.id, + }); + } catch (error) { + return formatToolError(error); + } + }, + }; +} + +export const CREATE_RECOGNIZE_EPISODE_PLAN_TOOL_NAME = + CREATE_RECOGNIZE_EPISODE_PLAN; diff --git a/packages/core-routes/src/tools/createRenameEpisodePlan.ts b/packages/core-routes/src/tools/createRenameEpisodePlan.ts index 46e16009..f6a0c236 100644 --- a/packages/core-routes/src/tools/createRenameEpisodePlan.ts +++ b/packages/core-routes/src/tools/createRenameEpisodePlan.ts @@ -26,55 +26,13 @@ import type { ChatFs } from "../chatTypes.ts"; import type { CoreRoutesLogger } from "../types.ts"; import type { WebSocketMessage } from "../socketIO/types.ts"; import { defaultBroadcast } from "./broadcast.ts"; - -function unsupportedFsOperation(name: string): never { - throw new Error(`${name} is not supported by the rename-plan filesystem adapter`); -} - -function createFsPort(fs: ChatFs): FsPort { - return { - async readTextFile(path: string): Promise { - const value = await fs.readJson(path); - if (value === null) { - throw new Error(`File not found: ${path}`); - } - return JSON.stringify(value); - }, - async writeTextFile(path: string, content: string): Promise { - await fs.writeJson(path, JSON.parse(content) as unknown); - }, - async writeBinaryFile(): Promise { - unsupportedFsOperation("writeBinaryFile"); - }, - exists: (path: string) => fs.exists(path), - isFile: (path: string) => fs.exists(path), - async listFiles(): Promise { - return unsupportedFsOperation("listFiles"); - }, - async listSubdirectories(): Promise { - return unsupportedFsOperation("listSubdirectories"); - }, - async deleteFile(): Promise { - unsupportedFsOperation("deleteFile"); - }, - async rename(): Promise { - unsupportedFsOperation("rename"); - }, - async mkdir(): Promise { - unsupportedFsOperation("mkdir"); - }, - }; -} +import { createFsPort, planPath } from "./chatFsPort.ts"; function metadataPath(appDataDir: string, mediaFolderPath: string): string { const filename = Path.posix(mediaFolderPath).replace(/[/\\:?*|<>"]/g, "_"); return new Path(appDataDir, `metadata/${filename}.json`).abs("posix"); } -function planPath(appDataDir: string, planId: string): string { - return new Path(appDataDir, `plans/${planId}.plan.json`).abs("posix"); -} - /** * Optional dependencies for the `metadata.write` auto-apply flow. * Auto-apply requires BOTH deps: without `getUserConfig` the tool From 14902b7688f71d16d359a535bd67390cb94bbfad Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 7 Sep 2026 02:25:20 +0800 Subject: [PATCH 59/83] refactor(core-routes): replace recognize begin/add/end tools with single-call createRecognizeEpisodePlan --- packages/core-routes/dist/core-routes.js | 13376 +++++++++++----- packages/core-routes/src/chat.ts | 10 +- packages/core-routes/src/cleanup.test.ts | 30 +- packages/core-routes/src/mcp/createServer.ts | 10 +- .../src/mcp/toolHandlers/addRecognizedFile.ts | 126 - .../mcp/toolHandlers/beginRecognizeTask.ts | 86 - .../createRecognizeEpisodePlan.ts | 49 + .../src/mcp/toolHandlers/endRecognizeTask.ts | 81 - packages/core-routes/src/mcp/types.ts | 4 + packages/core-routes/src/tools/index.ts | 43 +- packages/core-routes/src/tools/plans.test.ts | 205 +- packages/core-routes/src/tools/plans.ts | 101 +- .../src/tools/recognizeMediaFilesTask.ts | 297 - 13 files changed, 9353 insertions(+), 5065 deletions(-) delete mode 100644 packages/core-routes/src/mcp/toolHandlers/addRecognizedFile.ts delete mode 100644 packages/core-routes/src/mcp/toolHandlers/beginRecognizeTask.ts create mode 100644 packages/core-routes/src/mcp/toolHandlers/createRecognizeEpisodePlan.ts delete mode 100644 packages/core-routes/src/mcp/toolHandlers/endRecognizeTask.ts delete mode 100644 packages/core-routes/src/tools/recognizeMediaFilesTask.ts diff --git a/packages/core-routes/dist/core-routes.js b/packages/core-routes/dist/core-routes.js index 24b2b817..9a3af8c5 100644 --- a/packages/core-routes/dist/core-routes.js +++ b/packages/core-routes/dist/core-routes.js @@ -145,19 +145,19 @@ var require_token_io = __commonJS((exports, module) => { getUserDataDir: () => getUserDataDir }); module.exports = __toCommonJS(token_io_exports); - var import_path = __toESM2(__require("path")); + var import_path2 = __toESM2(__require("path")); var import_fs = __toESM2(__require("fs")); var import_os = __toESM2(__require("os")); var import_token_error = require_token_error(); function findRootDir() { try { let dir = process.cwd(); - while (dir !== import_path.default.dirname(dir)) { - const pkgPath = import_path.default.join(dir, ".vercel"); + while (dir !== import_path2.default.dirname(dir)) { + const pkgPath = import_path2.default.join(dir, ".vercel"); if (import_fs.default.existsSync(pkgPath)) { return dir; } - dir = import_path.default.dirname(dir); + dir = import_path2.default.dirname(dir); } } catch (e) { throw new import_token_error.VercelOidcTokenError("Token refresh only supported in node server environments"); @@ -170,9 +170,9 @@ var require_token_io = __commonJS((exports, module) => { } switch (import_os.default.platform()) { case "darwin": - return import_path.default.join(import_os.default.homedir(), "Library/Application Support"); + return import_path2.default.join(import_os.default.homedir(), "Library/Application Support"); case "linux": - return import_path.default.join(import_os.default.homedir(), ".local/share"); + return import_path2.default.join(import_os.default.homedir(), ".local/share"); case "win32": if (process.env.LOCALAPPDATA) { return process.env.LOCALAPPDATA; @@ -2048,576 +2048,4651 @@ var require_src = __commonJS((exports) => { }; }); -// ../../node_modules/.pnpm/negotiator@0.6.3/node_modules/negotiator/lib/charset.js -var require_charset = __commonJS((exports, module) => { - module.exports = preferredCharsets; - module.exports.preferredCharsets = preferredCharsets; - var simpleCharsetRegExp = /^\s*([^\s;]+)\s*(?:;(.*))?$/; - function parseAcceptCharset(accept) { - var accepts = accept.split(","); - for (var i = 0, j = 0;i < accepts.length; i++) { - var charset = parseCharset(accepts[i].trim(), i); - if (charset) { - accepts[j++] = charset; - } +// ../../node_modules/.pnpm/ms@2.1.3/node_modules/ms/index.js +var require_ms = __commonJS((exports, module) => { + var s = 1000; + var m = s * 60; + var h = m * 60; + var d = h * 24; + var w = d * 7; + var y = d * 365.25; + module.exports = function(val, options) { + options = options || {}; + var type = typeof val; + if (type === "string" && val.length > 0) { + return parse5(val); + } else if (type === "number" && isFinite(val)) { + return options.long ? fmtLong(val) : fmtShort(val); } - accepts.length = j; - return accepts; - } - function parseCharset(str, i) { - var match = simpleCharsetRegExp.exec(str); - if (!match) - return null; - var charset = match[1]; - var q = 1; - if (match[2]) { - var params = match[2].split(";"); - for (var j = 0;j < params.length; j++) { - var p = params[j].trim().split("="); - if (p[0] === "q") { - q = parseFloat(p[1]); - break; - } - } + throw new Error("val is not a non-empty string or a valid number. val=" + JSON.stringify(val)); + }; + function parse5(str) { + str = String(str); + if (str.length > 100) { + return; } - return { - charset, - q, - i - }; - } - function getCharsetPriority(charset, accepted, index) { - var priority = { o: -1, q: 0, s: 0 }; - for (var i = 0;i < accepted.length; i++) { - var spec = specify(charset, accepted[i], index); - if (spec && (priority.s - spec.s || priority.q - spec.q || priority.o - spec.o) < 0) { - priority = spec; - } + var match = /^(-?(?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i.exec(str); + if (!match) { + return; } - return priority; - } - function specify(charset, spec, index) { - var s = 0; - if (spec.charset.toLowerCase() === charset.toLowerCase()) { - s |= 1; - } else if (spec.charset !== "*") { - return null; + var n = parseFloat(match[1]); + var type = (match[2] || "ms").toLowerCase(); + switch (type) { + case "years": + case "year": + case "yrs": + case "yr": + case "y": + return n * y; + case "weeks": + case "week": + case "w": + return n * w; + case "days": + case "day": + case "d": + return n * d; + case "hours": + case "hour": + case "hrs": + case "hr": + case "h": + return n * h; + case "minutes": + case "minute": + case "mins": + case "min": + case "m": + return n * m; + case "seconds": + case "second": + case "secs": + case "sec": + case "s": + return n * s; + case "milliseconds": + case "millisecond": + case "msecs": + case "msec": + case "ms": + return n; + default: + return; } - return { - i: index, - o: spec.i, - q: spec.q, - s - }; } - function preferredCharsets(accept, provided) { - var accepts = parseAcceptCharset(accept === undefined ? "*" : accept || ""); - if (!provided) { - return accepts.filter(isQuality).sort(compareSpecs).map(getFullCharset); + function fmtShort(ms) { + var msAbs = Math.abs(ms); + if (msAbs >= d) { + return Math.round(ms / d) + "d"; } - var priorities = provided.map(function getPriority(type, index) { - return getCharsetPriority(type, accepts, index); - }); - return priorities.filter(isQuality).sort(compareSpecs).map(function getCharset(priority) { - return provided[priorities.indexOf(priority)]; - }); - } - function compareSpecs(a, b) { - return b.q - a.q || b.s - a.s || a.o - b.o || a.i - b.i || 0; + if (msAbs >= h) { + return Math.round(ms / h) + "h"; + } + if (msAbs >= m) { + return Math.round(ms / m) + "m"; + } + if (msAbs >= s) { + return Math.round(ms / s) + "s"; + } + return ms + "ms"; } - function getFullCharset(spec) { - return spec.charset; + function fmtLong(ms) { + var msAbs = Math.abs(ms); + if (msAbs >= d) { + return plural(ms, msAbs, d, "day"); + } + if (msAbs >= h) { + return plural(ms, msAbs, h, "hour"); + } + if (msAbs >= m) { + return plural(ms, msAbs, m, "minute"); + } + if (msAbs >= s) { + return plural(ms, msAbs, s, "second"); + } + return ms + " ms"; } - function isQuality(spec) { - return spec.q > 0; + function plural(ms, msAbs, n, name21) { + var isPlural = msAbs >= n * 1.5; + return Math.round(ms / n) + " " + name21 + (isPlural ? "s" : ""); } }); -// ../../node_modules/.pnpm/negotiator@0.6.3/node_modules/negotiator/lib/encoding.js -var require_encoding = __commonJS((exports, module) => { - module.exports = preferredEncodings; - module.exports.preferredEncodings = preferredEncodings; - var simpleEncodingRegExp = /^\s*([^\s;]+)\s*(?:;(.*))?$/; - function parseAcceptEncoding(accept) { - var accepts = accept.split(","); - var hasIdentity = false; - var minQuality = 1; - for (var i = 0, j = 0;i < accepts.length; i++) { - var encoding = parseEncoding(accepts[i].trim(), i); - if (encoding) { - accepts[j++] = encoding; - hasIdentity = hasIdentity || specify("identity", encoding); - minQuality = Math.min(minQuality, encoding.q || 1); +// ../../node_modules/.pnpm/debug@4.4.3_supports-color@8.1.1/node_modules/debug/src/common.js +var require_common = __commonJS((exports, module) => { + function setup(env) { + createDebug.debug = createDebug; + createDebug.default = createDebug; + createDebug.coerce = coerce2; + createDebug.disable = disable; + createDebug.enable = enable; + createDebug.enabled = enabled; + createDebug.humanize = require_ms(); + createDebug.destroy = destroy; + Object.keys(env).forEach((key) => { + createDebug[key] = env[key]; + }); + createDebug.names = []; + createDebug.skips = []; + createDebug.formatters = {}; + function selectColor(namespace) { + let hash2 = 0; + for (let i = 0;i < namespace.length; i++) { + hash2 = (hash2 << 5) - hash2 + namespace.charCodeAt(i); + hash2 |= 0; } + return createDebug.colors[Math.abs(hash2) % createDebug.colors.length]; } - if (!hasIdentity) { - accepts[j++] = { - encoding: "identity", - q: minQuality, - i - }; - } - accepts.length = j; - return accepts; - } - function parseEncoding(str, i) { - var match = simpleEncodingRegExp.exec(str); - if (!match) - return null; - var encoding = match[1]; - var q = 1; - if (match[2]) { - var params = match[2].split(";"); - for (var j = 0;j < params.length; j++) { - var p = params[j].trim().split("="); - if (p[0] === "q") { - q = parseFloat(p[1]); - break; + createDebug.selectColor = selectColor; + function createDebug(namespace) { + let prevTime; + let enableOverride = null; + let namespacesCache; + let enabledCache; + function debug(...args) { + if (!debug.enabled) { + return; + } + const self = debug; + const curr = Number(new Date); + const ms = curr - (prevTime || curr); + self.diff = ms; + self.prev = prevTime; + self.curr = curr; + prevTime = curr; + args[0] = createDebug.coerce(args[0]); + if (typeof args[0] !== "string") { + args.unshift("%O"); + } + let index = 0; + args[0] = args[0].replace(/%([a-zA-Z%])/g, (match, format) => { + if (match === "%%") { + return "%"; + } + index++; + const formatter = createDebug.formatters[format]; + if (typeof formatter === "function") { + const val = args[index]; + match = formatter.call(self, val); + args.splice(index, 1); + index--; + } + return match; + }); + createDebug.formatArgs.call(self, args); + const logFn = self.log || createDebug.log; + logFn.apply(self, args); + } + debug.namespace = namespace; + debug.useColors = createDebug.useColors(); + debug.color = createDebug.selectColor(namespace); + debug.extend = extend2; + debug.destroy = createDebug.destroy; + Object.defineProperty(debug, "enabled", { + enumerable: true, + configurable: false, + get: () => { + if (enableOverride !== null) { + return enableOverride; + } + if (namespacesCache !== createDebug.namespaces) { + namespacesCache = createDebug.namespaces; + enabledCache = createDebug.enabled(namespace); + } + return enabledCache; + }, + set: (v) => { + enableOverride = v; } + }); + if (typeof createDebug.init === "function") { + createDebug.init(debug); } + return debug; } - return { - encoding, - q, - i - }; - } - function getEncodingPriority(encoding, accepted, index) { - var priority = { o: -1, q: 0, s: 0 }; - for (var i = 0;i < accepted.length; i++) { - var spec = specify(encoding, accepted[i], index); - if (spec && (priority.s - spec.s || priority.q - spec.q || priority.o - spec.o) < 0) { - priority = spec; + function extend2(namespace, delimiter) { + const newDebug = createDebug(this.namespace + (typeof delimiter === "undefined" ? ":" : delimiter) + namespace); + newDebug.log = this.log; + return newDebug; + } + function enable(namespaces) { + createDebug.save(namespaces); + createDebug.namespaces = namespaces; + createDebug.names = []; + createDebug.skips = []; + const split2 = (typeof namespaces === "string" ? namespaces : "").trim().replace(/\s+/g, ",").split(",").filter(Boolean); + for (const ns of split2) { + if (ns[0] === "-") { + createDebug.skips.push(ns.slice(1)); + } else { + createDebug.names.push(ns); + } } } - return priority; - } - function specify(encoding, spec, index) { - var s = 0; - if (spec.encoding.toLowerCase() === encoding.toLowerCase()) { - s |= 1; - } else if (spec.encoding !== "*") { - return null; + function matchesTemplate(search, template) { + let searchIndex = 0; + let templateIndex = 0; + let starIndex = -1; + let matchIndex = 0; + while (searchIndex < search.length) { + if (templateIndex < template.length && (template[templateIndex] === search[searchIndex] || template[templateIndex] === "*")) { + if (template[templateIndex] === "*") { + starIndex = templateIndex; + matchIndex = searchIndex; + templateIndex++; + } else { + searchIndex++; + templateIndex++; + } + } else if (starIndex !== -1) { + templateIndex = starIndex + 1; + matchIndex++; + searchIndex = matchIndex; + } else { + return false; + } + } + while (templateIndex < template.length && template[templateIndex] === "*") { + templateIndex++; + } + return templateIndex === template.length; } - return { - i: index, - o: spec.i, - q: spec.q, - s - }; - } - function preferredEncodings(accept, provided) { - var accepts = parseAcceptEncoding(accept || ""); - if (!provided) { - return accepts.filter(isQuality).sort(compareSpecs).map(getFullEncoding); + function disable() { + const namespaces = [ + ...createDebug.names, + ...createDebug.skips.map((namespace) => "-" + namespace) + ].join(","); + createDebug.enable(""); + return namespaces; } - var priorities = provided.map(function getPriority(type, index) { - return getEncodingPriority(type, accepts, index); - }); - return priorities.filter(isQuality).sort(compareSpecs).map(function getEncoding(priority) { - return provided[priorities.indexOf(priority)]; - }); - } - function compareSpecs(a, b) { - return b.q - a.q || b.s - a.s || a.o - b.o || a.i - b.i || 0; - } - function getFullEncoding(spec) { - return spec.encoding; - } - function isQuality(spec) { - return spec.q > 0; - } -}); - -// ../../node_modules/.pnpm/negotiator@0.6.3/node_modules/negotiator/lib/language.js -var require_language = __commonJS((exports, module) => { - module.exports = preferredLanguages; - module.exports.preferredLanguages = preferredLanguages; - var simpleLanguageRegExp = /^\s*([^\s\-;]+)(?:-([^\s;]+))?\s*(?:;(.*))?$/; - function parseAcceptLanguage(accept) { - var accepts = accept.split(","); - for (var i = 0, j = 0;i < accepts.length; i++) { - var language = parseLanguage(accepts[i].trim(), i); - if (language) { - accepts[j++] = language; + function enabled(name21) { + for (const skip of createDebug.skips) { + if (matchesTemplate(name21, skip)) { + return false; + } } - } - accepts.length = j; - return accepts; - } - function parseLanguage(str, i) { - var match = simpleLanguageRegExp.exec(str); - if (!match) - return null; - var prefix = match[1]; - var suffix = match[2]; - var full = prefix; - if (suffix) - full += "-" + suffix; - var q = 1; - if (match[3]) { - var params = match[3].split(";"); - for (var j = 0;j < params.length; j++) { - var p = params[j].split("="); - if (p[0] === "q") - q = parseFloat(p[1]); + for (const ns of createDebug.names) { + if (matchesTemplate(name21, ns)) { + return true; + } } + return false; } - return { - prefix, - suffix, - q, - i, - full - }; - } - function getLanguagePriority(language, accepted, index) { - var priority = { o: -1, q: 0, s: 0 }; - for (var i = 0;i < accepted.length; i++) { - var spec = specify(language, accepted[i], index); - if (spec && (priority.s - spec.s || priority.q - spec.q || priority.o - spec.o) < 0) { - priority = spec; + function coerce2(val) { + if (val instanceof Error) { + return val.stack || val.message; } + return val; } - return priority; - } - function specify(language, spec, index) { - var p = parseLanguage(language); - if (!p) - return null; - var s = 0; - if (spec.full.toLowerCase() === p.full.toLowerCase()) { - s |= 4; - } else if (spec.prefix.toLowerCase() === p.full.toLowerCase()) { - s |= 2; - } else if (spec.full.toLowerCase() === p.prefix.toLowerCase()) { - s |= 1; - } else if (spec.full !== "*") { - return null; - } - return { - i: index, - o: spec.i, - q: spec.q, - s - }; - } - function preferredLanguages(accept, provided) { - var accepts = parseAcceptLanguage(accept === undefined ? "*" : accept || ""); - if (!provided) { - return accepts.filter(isQuality).sort(compareSpecs).map(getFullLanguage); + function destroy() { + console.warn("Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`."); } - var priorities = provided.map(function getPriority(type, index) { - return getLanguagePriority(type, accepts, index); - }); - return priorities.filter(isQuality).sort(compareSpecs).map(function getLanguage(priority) { - return provided[priorities.indexOf(priority)]; - }); - } - function compareSpecs(a, b) { - return b.q - a.q || b.s - a.s || a.o - b.o || a.i - b.i || 0; - } - function getFullLanguage(spec) { - return spec.full; - } - function isQuality(spec) { - return spec.q > 0; + createDebug.enable(createDebug.load()); + return createDebug; } + module.exports = setup; }); -// ../../node_modules/.pnpm/negotiator@0.6.3/node_modules/negotiator/lib/mediaType.js -var require_mediaType = __commonJS((exports, module) => { - module.exports = preferredMediaTypes; - module.exports.preferredMediaTypes = preferredMediaTypes; - var simpleMediaTypeRegExp = /^\s*([^\s\/;]+)\/([^;\s]+)\s*(?:;(.*))?$/; - function parseAccept(accept) { - var accepts = splitMediaTypes(accept); - for (var i = 0, j = 0;i < accepts.length; i++) { - var mediaType = parseMediaType(accepts[i].trim(), i); - if (mediaType) { - accepts[j++] = mediaType; +// ../../node_modules/.pnpm/debug@4.4.3_supports-color@8.1.1/node_modules/debug/src/browser.js +var require_browser = __commonJS((exports, module) => { + exports.formatArgs = formatArgs; + exports.save = save; + exports.load = load; + exports.useColors = useColors; + exports.storage = localstorage(); + exports.destroy = (() => { + let warned = false; + return () => { + if (!warned) { + warned = true; + console.warn("Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`."); } + }; + })(); + exports.colors = [ + "#0000CC", + "#0000FF", + "#0033CC", + "#0033FF", + "#0066CC", + "#0066FF", + "#0099CC", + "#0099FF", + "#00CC00", + "#00CC33", + "#00CC66", + "#00CC99", + "#00CCCC", + "#00CCFF", + "#3300CC", + "#3300FF", + "#3333CC", + "#3333FF", + "#3366CC", + "#3366FF", + "#3399CC", + "#3399FF", + "#33CC00", + "#33CC33", + "#33CC66", + "#33CC99", + "#33CCCC", + "#33CCFF", + "#6600CC", + "#6600FF", + "#6633CC", + "#6633FF", + "#66CC00", + "#66CC33", + "#9900CC", + "#9900FF", + "#9933CC", + "#9933FF", + "#99CC00", + "#99CC33", + "#CC0000", + "#CC0033", + "#CC0066", + "#CC0099", + "#CC00CC", + "#CC00FF", + "#CC3300", + "#CC3333", + "#CC3366", + "#CC3399", + "#CC33CC", + "#CC33FF", + "#CC6600", + "#CC6633", + "#CC9900", + "#CC9933", + "#CCCC00", + "#CCCC33", + "#FF0000", + "#FF0033", + "#FF0066", + "#FF0099", + "#FF00CC", + "#FF00FF", + "#FF3300", + "#FF3333", + "#FF3366", + "#FF3399", + "#FF33CC", + "#FF33FF", + "#FF6600", + "#FF6633", + "#FF9900", + "#FF9933", + "#FFCC00", + "#FFCC33" + ]; + function useColors() { + if (typeof window !== "undefined" && window.process && (window.process.type === "renderer" || window.process.__nwjs)) { + return true; } - accepts.length = j; - return accepts; - } - function parseMediaType(str, i) { - var match = simpleMediaTypeRegExp.exec(str); - if (!match) - return null; - var params = Object.create(null); - var q = 1; - var subtype = match[2]; - var type = match[1]; - if (match[3]) { - var kvps = splitParameters(match[3]).map(splitKeyValuePair); - for (var j = 0;j < kvps.length; j++) { - var pair = kvps[j]; - var key = pair[0].toLowerCase(); - var val = pair[1]; - var value = val && val[0] === '"' && val[val.length - 1] === '"' ? val.substr(1, val.length - 2) : val; - if (key === "q") { - q = parseFloat(value); - break; - } - params[key] = value; - } + if (typeof navigator !== "undefined" && navigator.userAgent && navigator.userAgent.toLowerCase().match(/(edge|trident)\/(\d+)/)) { + return false; } - return { - type, - subtype, - params, - q, - i - }; + let m; + return typeof document !== "undefined" && document.documentElement && document.documentElement.style && document.documentElement.style.WebkitAppearance || typeof window !== "undefined" && window.console && (window.console.firebug || window.console.exception && window.console.table) || typeof navigator !== "undefined" && navigator.userAgent && (m = navigator.userAgent.toLowerCase().match(/firefox\/(\d+)/)) && parseInt(m[1], 10) >= 31 || typeof navigator !== "undefined" && navigator.userAgent && navigator.userAgent.toLowerCase().match(/applewebkit\/(\d+)/); } - function getMediaTypePriority(type, accepted, index) { - var priority = { o: -1, q: 0, s: 0 }; - for (var i = 0;i < accepted.length; i++) { - var spec = specify(type, accepted[i], index); - if (spec && (priority.s - spec.s || priority.q - spec.q || priority.o - spec.o) < 0) { - priority = spec; - } + function formatArgs(args) { + args[0] = (this.useColors ? "%c" : "") + this.namespace + (this.useColors ? " %c" : " ") + args[0] + (this.useColors ? "%c " : " ") + "+" + module.exports.humanize(this.diff); + if (!this.useColors) { + return; } - return priority; + const c = "color: " + this.color; + args.splice(1, 0, c, "color: inherit"); + let index = 0; + let lastC = 0; + args[0].replace(/%[a-zA-Z%]/g, (match) => { + if (match === "%%") { + return; + } + index++; + if (match === "%c") { + lastC = index; + } + }); + args.splice(lastC, 0, c); } - function specify(type, spec, index) { - var p = parseMediaType(type); - var s = 0; - if (!p) { - return null; - } - if (spec.type.toLowerCase() == p.type.toLowerCase()) { - s |= 4; - } else if (spec.type != "*") { - return null; - } - if (spec.subtype.toLowerCase() == p.subtype.toLowerCase()) { - s |= 2; - } else if (spec.subtype != "*") { - return null; - } - var keys = Object.keys(spec.params); - if (keys.length > 0) { - if (keys.every(function(k) { - return spec.params[k] == "*" || (spec.params[k] || "").toLowerCase() == (p.params[k] || "").toLowerCase(); - })) { - s |= 1; + exports.log = console.debug || console.log || (() => {}); + function save(namespaces) { + try { + if (namespaces) { + exports.storage.setItem("debug", namespaces); } else { - return null; + exports.storage.removeItem("debug"); } - } - return { - i: index, - o: spec.i, - q: spec.q, - s - }; + } catch (error48) {} } - function preferredMediaTypes(accept, provided) { - var accepts = parseAccept(accept === undefined ? "*/*" : accept || ""); - if (!provided) { - return accepts.filter(isQuality).sort(compareSpecs).map(getFullType); + function load() { + let r; + try { + r = exports.storage.getItem("debug") || exports.storage.getItem("DEBUG"); + } catch (error48) {} + if (!r && typeof process !== "undefined" && "env" in process) { + r = process.env.DEBUG; } - var priorities = provided.map(function getPriority(type, index) { - return getMediaTypePriority(type, accepts, index); - }); - return priorities.filter(isQuality).sort(compareSpecs).map(function getType(priority) { - return provided[priorities.indexOf(priority)]; - }); - } - function compareSpecs(a, b) { - return b.q - a.q || b.s - a.s || a.o - b.o || a.i - b.i || 0; + return r; } - function getFullType(spec) { - return spec.type + "/" + spec.subtype; + function localstorage() { + try { + return localStorage; + } catch (error48) {} } - function isQuality(spec) { - return spec.q > 0; + module.exports = require_common()(exports); + var { formatters } = module.exports; + formatters.j = function(v) { + try { + return JSON.stringify(v); + } catch (error48) { + return "[UnexpectedJSONParseError]: " + error48.message; + } + }; +}); + +// ../../node_modules/.pnpm/has-flag@4.0.0/node_modules/has-flag/index.js +var require_has_flag = __commonJS((exports, module) => { + module.exports = (flag, argv = process.argv) => { + const prefix = flag.startsWith("-") ? "" : flag.length === 1 ? "-" : "--"; + const position = argv.indexOf(prefix + flag); + const terminatorPosition = argv.indexOf("--"); + return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition); + }; +}); + +// ../../node_modules/.pnpm/supports-color@8.1.1/node_modules/supports-color/index.js +var require_supports_color = __commonJS((exports, module) => { + var os2 = __require("os"); + var tty = __require("tty"); + var hasFlag = require_has_flag(); + var { env } = process; + var flagForceColor; + if (hasFlag("no-color") || hasFlag("no-colors") || hasFlag("color=false") || hasFlag("color=never")) { + flagForceColor = 0; + } else if (hasFlag("color") || hasFlag("colors") || hasFlag("color=true") || hasFlag("color=always")) { + flagForceColor = 1; } - function quoteCount(string4) { - var count = 0; - var index = 0; - while ((index = string4.indexOf('"', index)) !== -1) { - count++; - index++; + function envForceColor() { + if ("FORCE_COLOR" in env) { + if (env.FORCE_COLOR === "true") { + return 1; + } + if (env.FORCE_COLOR === "false") { + return 0; + } + return env.FORCE_COLOR.length === 0 ? 1 : Math.min(Number.parseInt(env.FORCE_COLOR, 10), 3); } - return count; } - function splitKeyValuePair(str) { - var index = str.indexOf("="); - var key; - var val; - if (index === -1) { - key = str; - } else { - key = str.substr(0, index); - val = str.substr(index + 1); + function translateLevel(level) { + if (level === 0) { + return false; } - return [key, val]; + return { + level, + hasBasic: true, + has256: level >= 2, + has16m: level >= 3 + }; } - function splitMediaTypes(accept) { - var accepts = accept.split(","); - for (var i = 1, j = 0;i < accepts.length; i++) { - if (quoteCount(accepts[j]) % 2 == 0) { - accepts[++j] = accepts[i]; - } else { - accepts[j] += "," + accepts[i]; + function supportsColor(haveStream, { streamIsTTY, sniffFlags = true } = {}) { + const noFlagForceColor = envForceColor(); + if (noFlagForceColor !== undefined) { + flagForceColor = noFlagForceColor; + } + const forceColor = sniffFlags ? flagForceColor : noFlagForceColor; + if (forceColor === 0) { + return 0; + } + if (sniffFlags) { + if (hasFlag("color=16m") || hasFlag("color=full") || hasFlag("color=truecolor")) { + return 3; + } + if (hasFlag("color=256")) { + return 2; } } - accepts.length = j + 1; - return accepts; - } - function splitParameters(str) { - var parameters = str.split(";"); - for (var i = 1, j = 0;i < parameters.length; i++) { - if (quoteCount(parameters[j]) % 2 == 0) { - parameters[++j] = parameters[i]; - } else { - parameters[j] += ";" + parameters[i]; + if (haveStream && !streamIsTTY && forceColor === undefined) { + return 0; + } + const min = forceColor || 0; + if (env.TERM === "dumb") { + return min; + } + if (process.platform === "win32") { + const osRelease = os2.release().split("."); + if (Number(osRelease[0]) >= 10 && Number(osRelease[2]) >= 10586) { + return Number(osRelease[2]) >= 14931 ? 3 : 2; } + return 1; } - parameters.length = j + 1; - for (var i = 0;i < parameters.length; i++) { - parameters[i] = parameters[i].trim(); + if ("CI" in env) { + if (["TRAVIS", "CIRCLECI", "APPVEYOR", "GITLAB_CI", "GITHUB_ACTIONS", "BUILDKITE", "DRONE"].some((sign) => (sign in env)) || env.CI_NAME === "codeship") { + return 1; + } + return min; } - return parameters; - } -}); - -// ../../node_modules/.pnpm/negotiator@0.6.3/node_modules/negotiator/index.js -var require_negotiator = __commonJS((exports, module) => { - /*! - * negotiator - * Copyright(c) 2012 Federico Romero - * Copyright(c) 2012-2014 Isaac Z. Schlueter - * Copyright(c) 2015 Douglas Christopher Wilson - * MIT Licensed - */ - var preferredCharsets = require_charset(); - var preferredEncodings = require_encoding(); - var preferredLanguages = require_language(); - var preferredMediaTypes = require_mediaType(); - module.exports = Negotiator; - module.exports.Negotiator = Negotiator; - function Negotiator(request) { - if (!(this instanceof Negotiator)) { - return new Negotiator(request); + if ("TEAMCITY_VERSION" in env) { + return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(env.TEAMCITY_VERSION) ? 1 : 0; } - this.request = request; + if (env.COLORTERM === "truecolor") { + return 3; + } + if ("TERM_PROGRAM" in env) { + const version2 = Number.parseInt((env.TERM_PROGRAM_VERSION || "").split(".")[0], 10); + switch (env.TERM_PROGRAM) { + case "iTerm.app": + return version2 >= 3 ? 3 : 2; + case "Apple_Terminal": + return 2; + } + } + if (/-256(color)?$/i.test(env.TERM)) { + return 2; + } + if (/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(env.TERM)) { + return 1; + } + if ("COLORTERM" in env) { + return 1; + } + return min; } - Negotiator.prototype.charset = function charset(available) { - var set2 = this.charsets(available); - return set2 && set2[0]; - }; - Negotiator.prototype.charsets = function charsets(available) { - return preferredCharsets(this.request.headers["accept-charset"], available); - }; - Negotiator.prototype.encoding = function encoding(available) { - var set2 = this.encodings(available); - return set2 && set2[0]; - }; - Negotiator.prototype.encodings = function encodings(available) { - return preferredEncodings(this.request.headers["accept-encoding"], available); - }; - Negotiator.prototype.language = function language(available) { - var set2 = this.languages(available); - return set2 && set2[0]; - }; - Negotiator.prototype.languages = function languages(available) { - return preferredLanguages(this.request.headers["accept-language"], available); - }; - Negotiator.prototype.mediaType = function mediaType(available) { - var set2 = this.mediaTypes(available); - return set2 && set2[0]; - }; - Negotiator.prototype.mediaTypes = function mediaTypes(available) { - return preferredMediaTypes(this.request.headers.accept, available); + function getSupportLevel(stream, options = {}) { + const level = supportsColor(stream, { + streamIsTTY: stream && stream.isTTY, + ...options + }); + return translateLevel(level); + } + module.exports = { + supportsColor: getSupportLevel, + stdout: getSupportLevel({ isTTY: tty.isatty(1) }), + stderr: getSupportLevel({ isTTY: tty.isatty(2) }) }; - Negotiator.prototype.preferredCharset = Negotiator.prototype.charset; - Negotiator.prototype.preferredCharsets = Negotiator.prototype.charsets; - Negotiator.prototype.preferredEncoding = Negotiator.prototype.encoding; - Negotiator.prototype.preferredEncodings = Negotiator.prototype.encodings; - Negotiator.prototype.preferredLanguage = Negotiator.prototype.language; - Negotiator.prototype.preferredLanguages = Negotiator.prototype.languages; - Negotiator.prototype.preferredMediaType = Negotiator.prototype.mediaType; - Negotiator.prototype.preferredMediaTypes = Negotiator.prototype.mediaTypes; }); -// ../../node_modules/.pnpm/mime-db@1.52.0/node_modules/mime-db/db.json -var require_db = __commonJS((exports, module) => { - module.exports = { - "application/1d-interleaved-parityfec": { - source: "iana" - }, - "application/3gpdash-qoe-report+xml": { - source: "iana", - charset: "UTF-8", - compressible: true - }, - "application/3gpp-ims+xml": { - source: "iana", - compressible: true - }, - "application/3gpphal+json": { - source: "iana", - compressible: true - }, - "application/3gpphalforms+json": { - source: "iana", - compressible: true - }, - "application/a2l": { - source: "iana" - }, - "application/ace+cbor": { - source: "iana" - }, - "application/activemessage": { - source: "iana" - }, - "application/activity+json": { - source: "iana", - compressible: true - }, - "application/alto-costmap+json": { - source: "iana", - compressible: true - }, - "application/alto-costmapfilter+json": { - source: "iana", - compressible: true - }, - "application/alto-directory+json": { - source: "iana", - compressible: true - }, - "application/alto-endpointcost+json": { - source: "iana", - compressible: true - }, - "application/alto-endpointcostparams+json": { - source: "iana", - compressible: true - }, - "application/alto-endpointprop+json": { - source: "iana", - compressible: true - }, - "application/alto-endpointpropparams+json": { - source: "iana", - compressible: true - }, - "application/alto-error+json": { - source: "iana", - compressible: true - }, - "application/alto-networkmap+json": { - source: "iana", - compressible: true - }, - "application/alto-networkmapfilter+json": { - source: "iana", +// ../../node_modules/.pnpm/debug@4.4.3_supports-color@8.1.1/node_modules/debug/src/node.js +var require_node2 = __commonJS((exports, module) => { + var tty = __require("tty"); + var util3 = __require("util"); + exports.init = init; + exports.log = log; + exports.formatArgs = formatArgs; + exports.save = save; + exports.load = load; + exports.useColors = useColors; + exports.destroy = util3.deprecate(() => {}, "Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`."); + exports.colors = [6, 2, 3, 4, 5, 1]; + try { + const supportsColor = require_supports_color(); + if (supportsColor && (supportsColor.stderr || supportsColor).level >= 2) { + exports.colors = [ + 20, + 21, + 26, + 27, + 32, + 33, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 56, + 57, + 62, + 63, + 68, + 69, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 92, + 93, + 98, + 99, + 112, + 113, + 128, + 129, + 134, + 135, + 148, + 149, + 160, + 161, + 162, + 163, + 164, + 165, + 166, + 167, + 168, + 169, + 170, + 171, + 172, + 173, + 178, + 179, + 184, + 185, + 196, + 197, + 198, + 199, + 200, + 201, + 202, + 203, + 204, + 205, + 206, + 207, + 208, + 209, + 214, + 215, + 220, + 221 + ]; + } + } catch (error48) {} + exports.inspectOpts = Object.keys(process.env).filter((key) => { + return /^debug_/i.test(key); + }).reduce((obj, key) => { + const prop = key.substring(6).toLowerCase().replace(/_([a-z])/g, (_, k) => { + return k.toUpperCase(); + }); + let val = process.env[key]; + if (/^(yes|on|true|enabled)$/i.test(val)) { + val = true; + } else if (/^(no|off|false|disabled)$/i.test(val)) { + val = false; + } else if (val === "null") { + val = null; + } else { + val = Number(val); + } + obj[prop] = val; + return obj; + }, {}); + function useColors() { + return "colors" in exports.inspectOpts ? Boolean(exports.inspectOpts.colors) : tty.isatty(process.stderr.fd); + } + function formatArgs(args) { + const { namespace: name21, useColors: useColors2 } = this; + if (useColors2) { + const c = this.color; + const colorCode = "\x1B[3" + (c < 8 ? c : "8;5;" + c); + const prefix = ` ${colorCode};1m${name21} \x1B[0m`; + args[0] = prefix + args[0].split(` +`).join(` +` + prefix); + args.push(colorCode + "m+" + module.exports.humanize(this.diff) + "\x1B[0m"); + } else { + args[0] = getDate() + name21 + " " + args[0]; + } + } + function getDate() { + if (exports.inspectOpts.hideDate) { + return ""; + } + return new Date().toISOString() + " "; + } + function log(...args) { + return process.stderr.write(util3.formatWithOptions(exports.inspectOpts, ...args) + ` +`); + } + function save(namespaces) { + if (namespaces) { + process.env.DEBUG = namespaces; + } else { + delete process.env.DEBUG; + } + } + function load() { + return process.env.DEBUG; + } + function init(debug) { + debug.inspectOpts = {}; + const keys = Object.keys(exports.inspectOpts); + for (let i = 0;i < keys.length; i++) { + debug.inspectOpts[keys[i]] = exports.inspectOpts[keys[i]]; + } + } + module.exports = require_common()(exports); + var { formatters } = module.exports; + formatters.o = function(v) { + this.inspectOpts.colors = this.useColors; + return util3.inspect(v, this.inspectOpts).split(` +`).map((str) => str.trim()).join(" "); + }; + formatters.O = function(v) { + this.inspectOpts.colors = this.useColors; + return util3.inspect(v, this.inspectOpts); + }; +}); + +// ../../node_modules/.pnpm/debug@4.4.3_supports-color@8.1.1/node_modules/debug/src/index.js +var require_src2 = __commonJS((exports, module) => { + if (typeof process === "undefined" || process.type === "renderer" || false || process.__nwjs) { + module.exports = require_browser(); + } else { + module.exports = require_node2(); + } +}); + +// ../../node_modules/.pnpm/agent-base@7.1.4/node_modules/agent-base/dist/helpers.js +var require_helpers = __commonJS((exports) => { + var __createBinding = exports && exports.__createBinding || (Object.create ? function(o, m, k, k2) { + if (k2 === undefined) + k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { + return m[k]; + } }; + } + Object.defineProperty(o, k2, desc); + } : function(o, m, k, k2) { + if (k2 === undefined) + k2 = k; + o[k2] = m[k]; + }); + var __setModuleDefault = exports && exports.__setModuleDefault || (Object.create ? function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); + } : function(o, v) { + o["default"] = v; + }); + var __importStar = exports && exports.__importStar || function(mod) { + if (mod && mod.__esModule) + return mod; + var result = {}; + if (mod != null) { + for (var k in mod) + if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) + __createBinding(result, mod, k); + } + __setModuleDefault(result, mod); + return result; + }; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.req = exports.json = exports.toBuffer = undefined; + var http = __importStar(__require("http")); + var https = __importStar(__require("https")); + async function toBuffer(stream) { + let length = 0; + const chunks = []; + for await (const chunk2 of stream) { + length += chunk2.length; + chunks.push(chunk2); + } + return Buffer.concat(chunks, length); + } + exports.toBuffer = toBuffer; + async function json3(stream) { + const buf = await toBuffer(stream); + const str = buf.toString("utf8"); + try { + return JSON.parse(str); + } catch (_err) { + const err = _err; + err.message += ` (input: ${str})`; + throw err; + } + } + exports.json = json3; + function req(url2, opts = {}) { + const href = typeof url2 === "string" ? url2 : url2.href; + const req2 = (href.startsWith("https:") ? https : http).request(url2, opts); + const promise2 = new Promise((resolve2, reject) => { + req2.once("response", resolve2).once("error", reject).end(); + }); + req2.then = promise2.then.bind(promise2); + return req2; + } + exports.req = req; +}); + +// ../../node_modules/.pnpm/agent-base@7.1.4/node_modules/agent-base/dist/index.js +var require_dist2 = __commonJS((exports) => { + var __createBinding = exports && exports.__createBinding || (Object.create ? function(o, m, k, k2) { + if (k2 === undefined) + k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { + return m[k]; + } }; + } + Object.defineProperty(o, k2, desc); + } : function(o, m, k, k2) { + if (k2 === undefined) + k2 = k; + o[k2] = m[k]; + }); + var __setModuleDefault = exports && exports.__setModuleDefault || (Object.create ? function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); + } : function(o, v) { + o["default"] = v; + }); + var __importStar = exports && exports.__importStar || function(mod) { + if (mod && mod.__esModule) + return mod; + var result = {}; + if (mod != null) { + for (var k in mod) + if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) + __createBinding(result, mod, k); + } + __setModuleDefault(result, mod); + return result; + }; + var __exportStar = exports && exports.__exportStar || function(m, exports2) { + for (var p in m) + if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports2, p)) + __createBinding(exports2, m, p); + }; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.Agent = undefined; + var net = __importStar(__require("net")); + var http = __importStar(__require("http")); + var https_1 = __require("https"); + __exportStar(require_helpers(), exports); + var INTERNAL = Symbol("AgentBaseInternalState"); + + class Agent extends http.Agent { + constructor(opts) { + super(opts); + this[INTERNAL] = {}; + } + isSecureEndpoint(options) { + if (options) { + if (typeof options.secureEndpoint === "boolean") { + return options.secureEndpoint; + } + if (typeof options.protocol === "string") { + return options.protocol === "https:"; + } + } + const { stack } = new Error; + if (typeof stack !== "string") + return false; + return stack.split(` +`).some((l) => l.indexOf("(https.js:") !== -1 || l.indexOf("node:https:") !== -1); + } + incrementSockets(name21) { + if (this.maxSockets === Infinity && this.maxTotalSockets === Infinity) { + return null; + } + if (!this.sockets[name21]) { + this.sockets[name21] = []; + } + const fakeSocket = new net.Socket({ writable: false }); + this.sockets[name21].push(fakeSocket); + this.totalSocketCount++; + return fakeSocket; + } + decrementSockets(name21, socket) { + if (!this.sockets[name21] || socket === null) { + return; + } + const sockets = this.sockets[name21]; + const index = sockets.indexOf(socket); + if (index !== -1) { + sockets.splice(index, 1); + this.totalSocketCount--; + if (sockets.length === 0) { + delete this.sockets[name21]; + } + } + } + getName(options) { + const secureEndpoint = this.isSecureEndpoint(options); + if (secureEndpoint) { + return https_1.Agent.prototype.getName.call(this, options); + } + return super.getName(options); + } + createSocket(req, options, cb) { + const connectOpts = { + ...options, + secureEndpoint: this.isSecureEndpoint(options) + }; + const name21 = this.getName(connectOpts); + const fakeSocket = this.incrementSockets(name21); + Promise.resolve().then(() => this.connect(req, connectOpts)).then((socket) => { + this.decrementSockets(name21, fakeSocket); + if (socket instanceof http.Agent) { + try { + return socket.addRequest(req, connectOpts); + } catch (err) { + return cb(err); + } + } + this[INTERNAL].currentSocket = socket; + super.createSocket(req, options, cb); + }, (err) => { + this.decrementSockets(name21, fakeSocket); + cb(err); + }); + } + createConnection() { + const socket = this[INTERNAL].currentSocket; + this[INTERNAL].currentSocket = undefined; + if (!socket) { + throw new Error("No socket was returned in the `connect()` function"); + } + return socket; + } + get defaultPort() { + return this[INTERNAL].defaultPort ?? (this.protocol === "https:" ? 443 : 80); + } + set defaultPort(v) { + if (this[INTERNAL]) { + this[INTERNAL].defaultPort = v; + } + } + get protocol() { + return this[INTERNAL].protocol ?? (this.isSecureEndpoint() ? "https:" : "http:"); + } + set protocol(v) { + if (this[INTERNAL]) { + this[INTERNAL].protocol = v; + } + } + } + exports.Agent = Agent; +}); + +// ../../node_modules/.pnpm/http-proxy-agent@7.0.2/node_modules/http-proxy-agent/dist/index.js +var require_dist3 = __commonJS((exports) => { + var __createBinding = exports && exports.__createBinding || (Object.create ? function(o, m, k, k2) { + if (k2 === undefined) + k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { + return m[k]; + } }; + } + Object.defineProperty(o, k2, desc); + } : function(o, m, k, k2) { + if (k2 === undefined) + k2 = k; + o[k2] = m[k]; + }); + var __setModuleDefault = exports && exports.__setModuleDefault || (Object.create ? function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); + } : function(o, v) { + o["default"] = v; + }); + var __importStar = exports && exports.__importStar || function(mod) { + if (mod && mod.__esModule) + return mod; + var result = {}; + if (mod != null) { + for (var k in mod) + if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) + __createBinding(result, mod, k); + } + __setModuleDefault(result, mod); + return result; + }; + var __importDefault = exports && exports.__importDefault || function(mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.HttpProxyAgent = undefined; + var net = __importStar(__require("net")); + var tls = __importStar(__require("tls")); + var debug_1 = __importDefault(require_src2()); + var events_1 = __require("events"); + var agent_base_1 = require_dist2(); + var url_1 = __require("url"); + var debug = (0, debug_1.default)("http-proxy-agent"); + + class HttpProxyAgent extends agent_base_1.Agent { + constructor(proxy, opts) { + super(opts); + this.proxy = typeof proxy === "string" ? new url_1.URL(proxy) : proxy; + this.proxyHeaders = opts?.headers ?? {}; + debug("Creating new HttpProxyAgent instance: %o", this.proxy.href); + const host = (this.proxy.hostname || this.proxy.host).replace(/^\[|\]$/g, ""); + const port = this.proxy.port ? parseInt(this.proxy.port, 10) : this.proxy.protocol === "https:" ? 443 : 80; + this.connectOpts = { + ...opts ? omit3(opts, "headers") : null, + host, + port + }; + } + addRequest(req, opts) { + req._header = null; + this.setRequestProps(req, opts); + super.addRequest(req, opts); + } + setRequestProps(req, opts) { + const { proxy } = this; + const protocol = opts.secureEndpoint ? "https:" : "http:"; + const hostname3 = req.getHeader("host") || "localhost"; + const base = `${protocol}//${hostname3}`; + const url2 = new url_1.URL(req.path, base); + if (opts.port !== 80) { + url2.port = String(opts.port); + } + req.path = String(url2); + const headers = typeof this.proxyHeaders === "function" ? this.proxyHeaders() : { ...this.proxyHeaders }; + if (proxy.username || proxy.password) { + const auth = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`; + headers["Proxy-Authorization"] = `Basic ${Buffer.from(auth).toString("base64")}`; + } + if (!headers["Proxy-Connection"]) { + headers["Proxy-Connection"] = this.keepAlive ? "Keep-Alive" : "close"; + } + for (const name21 of Object.keys(headers)) { + const value = headers[name21]; + if (value) { + req.setHeader(name21, value); + } + } + } + async connect(req, opts) { + req._header = null; + if (!req.path.includes("://")) { + this.setRequestProps(req, opts); + } + let first; + let endOfHeaders; + debug("Regenerating stored HTTP header string for request"); + req._implicitHeader(); + if (req.outputData && req.outputData.length > 0) { + debug("Patching connection write() output buffer with updated header"); + first = req.outputData[0].data; + endOfHeaders = first.indexOf(`\r +\r +`) + 4; + req.outputData[0].data = req._header + first.substring(endOfHeaders); + debug("Output buffer: %o", req.outputData[0].data); + } + let socket; + if (this.proxy.protocol === "https:") { + debug("Creating `tls.Socket`: %o", this.connectOpts); + socket = tls.connect(this.connectOpts); + } else { + debug("Creating `net.Socket`: %o", this.connectOpts); + socket = net.connect(this.connectOpts); + } + await (0, events_1.once)(socket, "connect"); + return socket; + } + } + HttpProxyAgent.protocols = ["http", "https"]; + exports.HttpProxyAgent = HttpProxyAgent; + function omit3(obj, ...keys) { + const ret = {}; + let key; + for (key in obj) { + if (!keys.includes(key)) { + ret[key] = obj[key]; + } + } + return ret; + } +}); + +// ../../node_modules/.pnpm/https-proxy-agent@7.0.6/node_modules/https-proxy-agent/dist/parse-proxy-response.js +var require_parse_proxy_response = __commonJS((exports) => { + var __importDefault = exports && exports.__importDefault || function(mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.parseProxyResponse = undefined; + var debug_1 = __importDefault(require_src2()); + var debug = (0, debug_1.default)("https-proxy-agent:parse-proxy-response"); + function parseProxyResponse(socket) { + return new Promise((resolve2, reject) => { + let buffersLength = 0; + const buffers = []; + function read() { + const b = socket.read(); + if (b) + ondata(b); + else + socket.once("readable", read); + } + function cleanup() { + socket.removeListener("end", onend); + socket.removeListener("error", onerror); + socket.removeListener("readable", read); + } + function onend() { + cleanup(); + debug("onend"); + reject(new Error("Proxy connection ended before receiving CONNECT response")); + } + function onerror(err) { + cleanup(); + debug("onerror %o", err); + reject(err); + } + function ondata(b) { + buffers.push(b); + buffersLength += b.length; + const buffered = Buffer.concat(buffers, buffersLength); + const endOfHeaders = buffered.indexOf(`\r +\r +`); + if (endOfHeaders === -1) { + debug("have not received end of HTTP headers yet..."); + read(); + return; + } + const headerParts = buffered.slice(0, endOfHeaders).toString("ascii").split(`\r +`); + const firstLine = headerParts.shift(); + if (!firstLine) { + socket.destroy(); + return reject(new Error("No header received from proxy CONNECT response")); + } + const firstLineParts = firstLine.split(" "); + const statusCode = +firstLineParts[1]; + const statusText = firstLineParts.slice(2).join(" "); + const headers = {}; + for (const header of headerParts) { + if (!header) + continue; + const firstColon = header.indexOf(":"); + if (firstColon === -1) { + socket.destroy(); + return reject(new Error(`Invalid header from proxy CONNECT response: "${header}"`)); + } + const key = header.slice(0, firstColon).toLowerCase(); + const value = header.slice(firstColon + 1).trimStart(); + const current = headers[key]; + if (typeof current === "string") { + headers[key] = [current, value]; + } else if (Array.isArray(current)) { + current.push(value); + } else { + headers[key] = value; + } + } + debug("got proxy server response: %o %o", firstLine, headers); + cleanup(); + resolve2({ + connect: { + statusCode, + statusText, + headers + }, + buffered + }); + } + socket.on("error", onerror); + socket.on("end", onend); + read(); + }); + } + exports.parseProxyResponse = parseProxyResponse; +}); + +// ../../node_modules/.pnpm/https-proxy-agent@7.0.6/node_modules/https-proxy-agent/dist/index.js +var require_dist4 = __commonJS((exports) => { + var __createBinding = exports && exports.__createBinding || (Object.create ? function(o, m, k, k2) { + if (k2 === undefined) + k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { + return m[k]; + } }; + } + Object.defineProperty(o, k2, desc); + } : function(o, m, k, k2) { + if (k2 === undefined) + k2 = k; + o[k2] = m[k]; + }); + var __setModuleDefault = exports && exports.__setModuleDefault || (Object.create ? function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); + } : function(o, v) { + o["default"] = v; + }); + var __importStar = exports && exports.__importStar || function(mod) { + if (mod && mod.__esModule) + return mod; + var result = {}; + if (mod != null) { + for (var k in mod) + if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) + __createBinding(result, mod, k); + } + __setModuleDefault(result, mod); + return result; + }; + var __importDefault = exports && exports.__importDefault || function(mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.HttpsProxyAgent = undefined; + var net = __importStar(__require("net")); + var tls = __importStar(__require("tls")); + var assert_1 = __importDefault(__require("assert")); + var debug_1 = __importDefault(require_src2()); + var agent_base_1 = require_dist2(); + var url_1 = __require("url"); + var parse_proxy_response_1 = require_parse_proxy_response(); + var debug = (0, debug_1.default)("https-proxy-agent"); + var setServernameFromNonIpHost = (options) => { + if (options.servername === undefined && options.host && !net.isIP(options.host)) { + return { + ...options, + servername: options.host + }; + } + return options; + }; + + class HttpsProxyAgent extends agent_base_1.Agent { + constructor(proxy, opts) { + super(opts); + this.options = { path: undefined }; + this.proxy = typeof proxy === "string" ? new url_1.URL(proxy) : proxy; + this.proxyHeaders = opts?.headers ?? {}; + debug("Creating new HttpsProxyAgent instance: %o", this.proxy.href); + const host = (this.proxy.hostname || this.proxy.host).replace(/^\[|\]$/g, ""); + const port = this.proxy.port ? parseInt(this.proxy.port, 10) : this.proxy.protocol === "https:" ? 443 : 80; + this.connectOpts = { + ALPNProtocols: ["http/1.1"], + ...opts ? omit3(opts, "headers") : null, + host, + port + }; + } + async connect(req, opts) { + const { proxy } = this; + if (!opts.host) { + throw new TypeError('No "host" provided'); + } + let socket; + if (proxy.protocol === "https:") { + debug("Creating `tls.Socket`: %o", this.connectOpts); + socket = tls.connect(setServernameFromNonIpHost(this.connectOpts)); + } else { + debug("Creating `net.Socket`: %o", this.connectOpts); + socket = net.connect(this.connectOpts); + } + const headers = typeof this.proxyHeaders === "function" ? this.proxyHeaders() : { ...this.proxyHeaders }; + const host = net.isIPv6(opts.host) ? `[${opts.host}]` : opts.host; + let payload = `CONNECT ${host}:${opts.port} HTTP/1.1\r +`; + if (proxy.username || proxy.password) { + const auth = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`; + headers["Proxy-Authorization"] = `Basic ${Buffer.from(auth).toString("base64")}`; + } + headers.Host = `${host}:${opts.port}`; + if (!headers["Proxy-Connection"]) { + headers["Proxy-Connection"] = this.keepAlive ? "Keep-Alive" : "close"; + } + for (const name21 of Object.keys(headers)) { + payload += `${name21}: ${headers[name21]}\r +`; + } + const proxyResponsePromise = (0, parse_proxy_response_1.parseProxyResponse)(socket); + socket.write(`${payload}\r +`); + const { connect, buffered } = await proxyResponsePromise; + req.emit("proxyConnect", connect); + this.emit("proxyConnect", connect, req); + if (connect.statusCode === 200) { + req.once("socket", resume); + if (opts.secureEndpoint) { + debug("Upgrading socket connection to TLS"); + return tls.connect({ + ...omit3(setServernameFromNonIpHost(opts), "host", "path", "port"), + socket + }); + } + return socket; + } + socket.destroy(); + const fakeSocket = new net.Socket({ writable: false }); + fakeSocket.readable = true; + req.once("socket", (s) => { + debug("Replaying proxy buffer for failed request"); + (0, assert_1.default)(s.listenerCount("data") > 0); + s.push(buffered); + s.push(null); + }); + return fakeSocket; + } + } + HttpsProxyAgent.protocols = ["http", "https"]; + exports.HttpsProxyAgent = HttpsProxyAgent; + function resume(socket) { + socket.resume(); + } + function omit3(obj, ...keys) { + const ret = {}; + let key; + for (key in obj) { + if (!keys.includes(key)) { + ret[key] = obj[key]; + } + } + return ret; + } +}); + +// ../../node_modules/.pnpm/smart-buffer@4.2.0/node_modules/smart-buffer/build/utils.js +var require_utils3 = __commonJS((exports) => { + Object.defineProperty(exports, "__esModule", { value: true }); + var buffer_1 = __require("buffer"); + var ERRORS = { + INVALID_ENCODING: "Invalid encoding provided. Please specify a valid encoding the internal Node.js Buffer supports.", + INVALID_SMARTBUFFER_SIZE: "Invalid size provided. Size must be a valid integer greater than zero.", + INVALID_SMARTBUFFER_BUFFER: "Invalid Buffer provided in SmartBufferOptions.", + INVALID_SMARTBUFFER_OBJECT: "Invalid SmartBufferOptions object supplied to SmartBuffer constructor or factory methods.", + INVALID_OFFSET: "An invalid offset value was provided.", + INVALID_OFFSET_NON_NUMBER: "An invalid offset value was provided. A numeric value is required.", + INVALID_LENGTH: "An invalid length value was provided.", + INVALID_LENGTH_NON_NUMBER: "An invalid length value was provived. A numeric value is required.", + INVALID_TARGET_OFFSET: "Target offset is beyond the bounds of the internal SmartBuffer data.", + INVALID_TARGET_LENGTH: "Specified length value moves cursor beyong the bounds of the internal SmartBuffer data.", + INVALID_READ_BEYOND_BOUNDS: "Attempted to read beyond the bounds of the managed data.", + INVALID_WRITE_BEYOND_BOUNDS: "Attempted to write beyond the bounds of the managed data." + }; + exports.ERRORS = ERRORS; + function checkEncoding(encoding) { + if (!buffer_1.Buffer.isEncoding(encoding)) { + throw new Error(ERRORS.INVALID_ENCODING); + } + } + exports.checkEncoding = checkEncoding; + function isFiniteInteger(value) { + return typeof value === "number" && isFinite(value) && isInteger(value); + } + exports.isFiniteInteger = isFiniteInteger; + function checkOffsetOrLengthValue(value, offset) { + if (typeof value === "number") { + if (!isFiniteInteger(value) || value < 0) { + throw new Error(offset ? ERRORS.INVALID_OFFSET : ERRORS.INVALID_LENGTH); + } + } else { + throw new Error(offset ? ERRORS.INVALID_OFFSET_NON_NUMBER : ERRORS.INVALID_LENGTH_NON_NUMBER); + } + } + function checkLengthValue(length) { + checkOffsetOrLengthValue(length, false); + } + exports.checkLengthValue = checkLengthValue; + function checkOffsetValue(offset) { + checkOffsetOrLengthValue(offset, true); + } + exports.checkOffsetValue = checkOffsetValue; + function checkTargetOffset(offset, buff) { + if (offset < 0 || offset > buff.length) { + throw new Error(ERRORS.INVALID_TARGET_OFFSET); + } + } + exports.checkTargetOffset = checkTargetOffset; + function isInteger(value) { + return typeof value === "number" && isFinite(value) && Math.floor(value) === value; + } + function bigIntAndBufferInt64Check(bufferMethod) { + if (typeof BigInt === "undefined") { + throw new Error("Platform does not support JS BigInt type."); + } + if (typeof buffer_1.Buffer.prototype[bufferMethod] === "undefined") { + throw new Error(`Platform does not support Buffer.prototype.${bufferMethod}.`); + } + } + exports.bigIntAndBufferInt64Check = bigIntAndBufferInt64Check; +}); + +// ../../node_modules/.pnpm/smart-buffer@4.2.0/node_modules/smart-buffer/build/smartbuffer.js +var require_smartbuffer = __commonJS((exports) => { + Object.defineProperty(exports, "__esModule", { value: true }); + var utils_1 = require_utils3(); + var DEFAULT_SMARTBUFFER_SIZE = 4096; + var DEFAULT_SMARTBUFFER_ENCODING = "utf8"; + + class SmartBuffer { + constructor(options) { + this.length = 0; + this._encoding = DEFAULT_SMARTBUFFER_ENCODING; + this._writeOffset = 0; + this._readOffset = 0; + if (SmartBuffer.isSmartBufferOptions(options)) { + if (options.encoding) { + utils_1.checkEncoding(options.encoding); + this._encoding = options.encoding; + } + if (options.size) { + if (utils_1.isFiniteInteger(options.size) && options.size > 0) { + this._buff = Buffer.allocUnsafe(options.size); + } else { + throw new Error(utils_1.ERRORS.INVALID_SMARTBUFFER_SIZE); + } + } else if (options.buff) { + if (Buffer.isBuffer(options.buff)) { + this._buff = options.buff; + this.length = options.buff.length; + } else { + throw new Error(utils_1.ERRORS.INVALID_SMARTBUFFER_BUFFER); + } + } else { + this._buff = Buffer.allocUnsafe(DEFAULT_SMARTBUFFER_SIZE); + } + } else { + if (typeof options !== "undefined") { + throw new Error(utils_1.ERRORS.INVALID_SMARTBUFFER_OBJECT); + } + this._buff = Buffer.allocUnsafe(DEFAULT_SMARTBUFFER_SIZE); + } + } + static fromSize(size, encoding) { + return new this({ + size, + encoding + }); + } + static fromBuffer(buff, encoding) { + return new this({ + buff, + encoding + }); + } + static fromOptions(options) { + return new this(options); + } + static isSmartBufferOptions(options) { + const castOptions = options; + return castOptions && (castOptions.encoding !== undefined || castOptions.size !== undefined || castOptions.buff !== undefined); + } + readInt8(offset) { + return this._readNumberValue(Buffer.prototype.readInt8, 1, offset); + } + readInt16BE(offset) { + return this._readNumberValue(Buffer.prototype.readInt16BE, 2, offset); + } + readInt16LE(offset) { + return this._readNumberValue(Buffer.prototype.readInt16LE, 2, offset); + } + readInt32BE(offset) { + return this._readNumberValue(Buffer.prototype.readInt32BE, 4, offset); + } + readInt32LE(offset) { + return this._readNumberValue(Buffer.prototype.readInt32LE, 4, offset); + } + readBigInt64BE(offset) { + utils_1.bigIntAndBufferInt64Check("readBigInt64BE"); + return this._readNumberValue(Buffer.prototype.readBigInt64BE, 8, offset); + } + readBigInt64LE(offset) { + utils_1.bigIntAndBufferInt64Check("readBigInt64LE"); + return this._readNumberValue(Buffer.prototype.readBigInt64LE, 8, offset); + } + writeInt8(value, offset) { + this._writeNumberValue(Buffer.prototype.writeInt8, 1, value, offset); + return this; + } + insertInt8(value, offset) { + return this._insertNumberValue(Buffer.prototype.writeInt8, 1, value, offset); + } + writeInt16BE(value, offset) { + return this._writeNumberValue(Buffer.prototype.writeInt16BE, 2, value, offset); + } + insertInt16BE(value, offset) { + return this._insertNumberValue(Buffer.prototype.writeInt16BE, 2, value, offset); + } + writeInt16LE(value, offset) { + return this._writeNumberValue(Buffer.prototype.writeInt16LE, 2, value, offset); + } + insertInt16LE(value, offset) { + return this._insertNumberValue(Buffer.prototype.writeInt16LE, 2, value, offset); + } + writeInt32BE(value, offset) { + return this._writeNumberValue(Buffer.prototype.writeInt32BE, 4, value, offset); + } + insertInt32BE(value, offset) { + return this._insertNumberValue(Buffer.prototype.writeInt32BE, 4, value, offset); + } + writeInt32LE(value, offset) { + return this._writeNumberValue(Buffer.prototype.writeInt32LE, 4, value, offset); + } + insertInt32LE(value, offset) { + return this._insertNumberValue(Buffer.prototype.writeInt32LE, 4, value, offset); + } + writeBigInt64BE(value, offset) { + utils_1.bigIntAndBufferInt64Check("writeBigInt64BE"); + return this._writeNumberValue(Buffer.prototype.writeBigInt64BE, 8, value, offset); + } + insertBigInt64BE(value, offset) { + utils_1.bigIntAndBufferInt64Check("writeBigInt64BE"); + return this._insertNumberValue(Buffer.prototype.writeBigInt64BE, 8, value, offset); + } + writeBigInt64LE(value, offset) { + utils_1.bigIntAndBufferInt64Check("writeBigInt64LE"); + return this._writeNumberValue(Buffer.prototype.writeBigInt64LE, 8, value, offset); + } + insertBigInt64LE(value, offset) { + utils_1.bigIntAndBufferInt64Check("writeBigInt64LE"); + return this._insertNumberValue(Buffer.prototype.writeBigInt64LE, 8, value, offset); + } + readUInt8(offset) { + return this._readNumberValue(Buffer.prototype.readUInt8, 1, offset); + } + readUInt16BE(offset) { + return this._readNumberValue(Buffer.prototype.readUInt16BE, 2, offset); + } + readUInt16LE(offset) { + return this._readNumberValue(Buffer.prototype.readUInt16LE, 2, offset); + } + readUInt32BE(offset) { + return this._readNumberValue(Buffer.prototype.readUInt32BE, 4, offset); + } + readUInt32LE(offset) { + return this._readNumberValue(Buffer.prototype.readUInt32LE, 4, offset); + } + readBigUInt64BE(offset) { + utils_1.bigIntAndBufferInt64Check("readBigUInt64BE"); + return this._readNumberValue(Buffer.prototype.readBigUInt64BE, 8, offset); + } + readBigUInt64LE(offset) { + utils_1.bigIntAndBufferInt64Check("readBigUInt64LE"); + return this._readNumberValue(Buffer.prototype.readBigUInt64LE, 8, offset); + } + writeUInt8(value, offset) { + return this._writeNumberValue(Buffer.prototype.writeUInt8, 1, value, offset); + } + insertUInt8(value, offset) { + return this._insertNumberValue(Buffer.prototype.writeUInt8, 1, value, offset); + } + writeUInt16BE(value, offset) { + return this._writeNumberValue(Buffer.prototype.writeUInt16BE, 2, value, offset); + } + insertUInt16BE(value, offset) { + return this._insertNumberValue(Buffer.prototype.writeUInt16BE, 2, value, offset); + } + writeUInt16LE(value, offset) { + return this._writeNumberValue(Buffer.prototype.writeUInt16LE, 2, value, offset); + } + insertUInt16LE(value, offset) { + return this._insertNumberValue(Buffer.prototype.writeUInt16LE, 2, value, offset); + } + writeUInt32BE(value, offset) { + return this._writeNumberValue(Buffer.prototype.writeUInt32BE, 4, value, offset); + } + insertUInt32BE(value, offset) { + return this._insertNumberValue(Buffer.prototype.writeUInt32BE, 4, value, offset); + } + writeUInt32LE(value, offset) { + return this._writeNumberValue(Buffer.prototype.writeUInt32LE, 4, value, offset); + } + insertUInt32LE(value, offset) { + return this._insertNumberValue(Buffer.prototype.writeUInt32LE, 4, value, offset); + } + writeBigUInt64BE(value, offset) { + utils_1.bigIntAndBufferInt64Check("writeBigUInt64BE"); + return this._writeNumberValue(Buffer.prototype.writeBigUInt64BE, 8, value, offset); + } + insertBigUInt64BE(value, offset) { + utils_1.bigIntAndBufferInt64Check("writeBigUInt64BE"); + return this._insertNumberValue(Buffer.prototype.writeBigUInt64BE, 8, value, offset); + } + writeBigUInt64LE(value, offset) { + utils_1.bigIntAndBufferInt64Check("writeBigUInt64LE"); + return this._writeNumberValue(Buffer.prototype.writeBigUInt64LE, 8, value, offset); + } + insertBigUInt64LE(value, offset) { + utils_1.bigIntAndBufferInt64Check("writeBigUInt64LE"); + return this._insertNumberValue(Buffer.prototype.writeBigUInt64LE, 8, value, offset); + } + readFloatBE(offset) { + return this._readNumberValue(Buffer.prototype.readFloatBE, 4, offset); + } + readFloatLE(offset) { + return this._readNumberValue(Buffer.prototype.readFloatLE, 4, offset); + } + writeFloatBE(value, offset) { + return this._writeNumberValue(Buffer.prototype.writeFloatBE, 4, value, offset); + } + insertFloatBE(value, offset) { + return this._insertNumberValue(Buffer.prototype.writeFloatBE, 4, value, offset); + } + writeFloatLE(value, offset) { + return this._writeNumberValue(Buffer.prototype.writeFloatLE, 4, value, offset); + } + insertFloatLE(value, offset) { + return this._insertNumberValue(Buffer.prototype.writeFloatLE, 4, value, offset); + } + readDoubleBE(offset) { + return this._readNumberValue(Buffer.prototype.readDoubleBE, 8, offset); + } + readDoubleLE(offset) { + return this._readNumberValue(Buffer.prototype.readDoubleLE, 8, offset); + } + writeDoubleBE(value, offset) { + return this._writeNumberValue(Buffer.prototype.writeDoubleBE, 8, value, offset); + } + insertDoubleBE(value, offset) { + return this._insertNumberValue(Buffer.prototype.writeDoubleBE, 8, value, offset); + } + writeDoubleLE(value, offset) { + return this._writeNumberValue(Buffer.prototype.writeDoubleLE, 8, value, offset); + } + insertDoubleLE(value, offset) { + return this._insertNumberValue(Buffer.prototype.writeDoubleLE, 8, value, offset); + } + readString(arg1, encoding) { + let lengthVal; + if (typeof arg1 === "number") { + utils_1.checkLengthValue(arg1); + lengthVal = Math.min(arg1, this.length - this._readOffset); + } else { + encoding = arg1; + lengthVal = this.length - this._readOffset; + } + if (typeof encoding !== "undefined") { + utils_1.checkEncoding(encoding); + } + const value = this._buff.slice(this._readOffset, this._readOffset + lengthVal).toString(encoding || this._encoding); + this._readOffset += lengthVal; + return value; + } + insertString(value, offset, encoding) { + utils_1.checkOffsetValue(offset); + return this._handleString(value, true, offset, encoding); + } + writeString(value, arg2, encoding) { + return this._handleString(value, false, arg2, encoding); + } + readStringNT(encoding) { + if (typeof encoding !== "undefined") { + utils_1.checkEncoding(encoding); + } + let nullPos = this.length; + for (let i = this._readOffset;i < this.length; i++) { + if (this._buff[i] === 0) { + nullPos = i; + break; + } + } + const value = this._buff.slice(this._readOffset, nullPos); + this._readOffset = nullPos + 1; + return value.toString(encoding || this._encoding); + } + insertStringNT(value, offset, encoding) { + utils_1.checkOffsetValue(offset); + this.insertString(value, offset, encoding); + this.insertUInt8(0, offset + value.length); + return this; + } + writeStringNT(value, arg2, encoding) { + this.writeString(value, arg2, encoding); + this.writeUInt8(0, typeof arg2 === "number" ? arg2 + value.length : this.writeOffset); + return this; + } + readBuffer(length) { + if (typeof length !== "undefined") { + utils_1.checkLengthValue(length); + } + const lengthVal = typeof length === "number" ? length : this.length; + const endPoint = Math.min(this.length, this._readOffset + lengthVal); + const value = this._buff.slice(this._readOffset, endPoint); + this._readOffset = endPoint; + return value; + } + insertBuffer(value, offset) { + utils_1.checkOffsetValue(offset); + return this._handleBuffer(value, true, offset); + } + writeBuffer(value, offset) { + return this._handleBuffer(value, false, offset); + } + readBufferNT() { + let nullPos = this.length; + for (let i = this._readOffset;i < this.length; i++) { + if (this._buff[i] === 0) { + nullPos = i; + break; + } + } + const value = this._buff.slice(this._readOffset, nullPos); + this._readOffset = nullPos + 1; + return value; + } + insertBufferNT(value, offset) { + utils_1.checkOffsetValue(offset); + this.insertBuffer(value, offset); + this.insertUInt8(0, offset + value.length); + return this; + } + writeBufferNT(value, offset) { + if (typeof offset !== "undefined") { + utils_1.checkOffsetValue(offset); + } + this.writeBuffer(value, offset); + this.writeUInt8(0, typeof offset === "number" ? offset + value.length : this._writeOffset); + return this; + } + clear() { + this._writeOffset = 0; + this._readOffset = 0; + this.length = 0; + return this; + } + remaining() { + return this.length - this._readOffset; + } + get readOffset() { + return this._readOffset; + } + set readOffset(offset) { + utils_1.checkOffsetValue(offset); + utils_1.checkTargetOffset(offset, this); + this._readOffset = offset; + } + get writeOffset() { + return this._writeOffset; + } + set writeOffset(offset) { + utils_1.checkOffsetValue(offset); + utils_1.checkTargetOffset(offset, this); + this._writeOffset = offset; + } + get encoding() { + return this._encoding; + } + set encoding(encoding) { + utils_1.checkEncoding(encoding); + this._encoding = encoding; + } + get internalBuffer() { + return this._buff; + } + toBuffer() { + return this._buff.slice(0, this.length); + } + toString(encoding) { + const encodingVal = typeof encoding === "string" ? encoding : this._encoding; + utils_1.checkEncoding(encodingVal); + return this._buff.toString(encodingVal, 0, this.length); + } + destroy() { + this.clear(); + return this; + } + _handleString(value, isInsert, arg3, encoding) { + let offsetVal = this._writeOffset; + let encodingVal = this._encoding; + if (typeof arg3 === "number") { + offsetVal = arg3; + } else if (typeof arg3 === "string") { + utils_1.checkEncoding(arg3); + encodingVal = arg3; + } + if (typeof encoding === "string") { + utils_1.checkEncoding(encoding); + encodingVal = encoding; + } + const byteLength = Buffer.byteLength(value, encodingVal); + if (isInsert) { + this.ensureInsertable(byteLength, offsetVal); + } else { + this._ensureWriteable(byteLength, offsetVal); + } + this._buff.write(value, offsetVal, byteLength, encodingVal); + if (isInsert) { + this._writeOffset += byteLength; + } else { + if (typeof arg3 === "number") { + this._writeOffset = Math.max(this._writeOffset, offsetVal + byteLength); + } else { + this._writeOffset += byteLength; + } + } + return this; + } + _handleBuffer(value, isInsert, offset) { + const offsetVal = typeof offset === "number" ? offset : this._writeOffset; + if (isInsert) { + this.ensureInsertable(value.length, offsetVal); + } else { + this._ensureWriteable(value.length, offsetVal); + } + value.copy(this._buff, offsetVal); + if (isInsert) { + this._writeOffset += value.length; + } else { + if (typeof offset === "number") { + this._writeOffset = Math.max(this._writeOffset, offsetVal + value.length); + } else { + this._writeOffset += value.length; + } + } + return this; + } + ensureReadable(length, offset) { + let offsetVal = this._readOffset; + if (typeof offset !== "undefined") { + utils_1.checkOffsetValue(offset); + offsetVal = offset; + } + if (offsetVal < 0 || offsetVal + length > this.length) { + throw new Error(utils_1.ERRORS.INVALID_READ_BEYOND_BOUNDS); + } + } + ensureInsertable(dataLength, offset) { + utils_1.checkOffsetValue(offset); + this._ensureCapacity(this.length + dataLength); + if (offset < this.length) { + this._buff.copy(this._buff, offset + dataLength, offset, this._buff.length); + } + if (offset + dataLength > this.length) { + this.length = offset + dataLength; + } else { + this.length += dataLength; + } + } + _ensureWriteable(dataLength, offset) { + const offsetVal = typeof offset === "number" ? offset : this._writeOffset; + this._ensureCapacity(offsetVal + dataLength); + if (offsetVal + dataLength > this.length) { + this.length = offsetVal + dataLength; + } + } + _ensureCapacity(minLength) { + const oldLength = this._buff.length; + if (minLength > oldLength) { + let data = this._buff; + let newLength = oldLength * 3 / 2 + 1; + if (newLength < minLength) { + newLength = minLength; + } + this._buff = Buffer.allocUnsafe(newLength); + data.copy(this._buff, 0, 0, oldLength); + } + } + _readNumberValue(func, byteSize, offset) { + this.ensureReadable(byteSize, offset); + const value = func.call(this._buff, typeof offset === "number" ? offset : this._readOffset); + if (typeof offset === "undefined") { + this._readOffset += byteSize; + } + return value; + } + _insertNumberValue(func, byteSize, value, offset) { + utils_1.checkOffsetValue(offset); + this.ensureInsertable(byteSize, offset); + func.call(this._buff, value, offset); + this._writeOffset += byteSize; + return this; + } + _writeNumberValue(func, byteSize, value, offset) { + if (typeof offset === "number") { + if (offset < 0) { + throw new Error(utils_1.ERRORS.INVALID_WRITE_BEYOND_BOUNDS); + } + utils_1.checkOffsetValue(offset); + } + const offsetVal = typeof offset === "number" ? offset : this._writeOffset; + this._ensureWriteable(byteSize, offsetVal); + func.call(this._buff, value, offsetVal); + if (typeof offset === "number") { + this._writeOffset = Math.max(this._writeOffset, offsetVal + byteSize); + } else { + this._writeOffset += byteSize; + } + return this; + } + } + exports.SmartBuffer = SmartBuffer; +}); + +// ../../node_modules/.pnpm/socks@2.8.7/node_modules/socks/build/common/constants.js +var require_constants = __commonJS((exports) => { + Object.defineProperty(exports, "__esModule", { value: true }); + exports.SOCKS5_NO_ACCEPTABLE_AUTH = exports.SOCKS5_CUSTOM_AUTH_END = exports.SOCKS5_CUSTOM_AUTH_START = exports.SOCKS_INCOMING_PACKET_SIZES = exports.SocksClientState = exports.Socks5Response = exports.Socks5HostType = exports.Socks5Auth = exports.Socks4Response = exports.SocksCommand = exports.ERRORS = exports.DEFAULT_TIMEOUT = undefined; + var DEFAULT_TIMEOUT = 30000; + exports.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT; + var ERRORS = { + InvalidSocksCommand: "An invalid SOCKS command was provided. Valid options are connect, bind, and associate.", + InvalidSocksCommandForOperation: "An invalid SOCKS command was provided. Only a subset of commands are supported for this operation.", + InvalidSocksCommandChain: "An invalid SOCKS command was provided. Chaining currently only supports the connect command.", + InvalidSocksClientOptionsDestination: "An invalid destination host was provided.", + InvalidSocksClientOptionsExistingSocket: "An invalid existing socket was provided. This should be an instance of stream.Duplex.", + InvalidSocksClientOptionsProxy: "Invalid SOCKS proxy details were provided.", + InvalidSocksClientOptionsTimeout: "An invalid timeout value was provided. Please enter a value above 0 (in ms).", + InvalidSocksClientOptionsProxiesLength: "At least two socks proxies must be provided for chaining.", + InvalidSocksClientOptionsCustomAuthRange: "Custom auth must be a value between 0x80 and 0xFE.", + InvalidSocksClientOptionsCustomAuthOptions: "When a custom_auth_method is provided, custom_auth_request_handler, custom_auth_response_size, and custom_auth_response_handler must also be provided and valid.", + NegotiationError: "Negotiation error", + SocketClosed: "Socket closed", + ProxyConnectionTimedOut: "Proxy connection timed out", + InternalError: "SocksClient internal error (this should not happen)", + InvalidSocks4HandshakeResponse: "Received invalid Socks4 handshake response", + Socks4ProxyRejectedConnection: "Socks4 Proxy rejected connection", + InvalidSocks4IncomingConnectionResponse: "Socks4 invalid incoming connection response", + Socks4ProxyRejectedIncomingBoundConnection: "Socks4 Proxy rejected incoming bound connection", + InvalidSocks5InitialHandshakeResponse: "Received invalid Socks5 initial handshake response", + InvalidSocks5IntiailHandshakeSocksVersion: "Received invalid Socks5 initial handshake (invalid socks version)", + InvalidSocks5InitialHandshakeNoAcceptedAuthType: "Received invalid Socks5 initial handshake (no accepted authentication type)", + InvalidSocks5InitialHandshakeUnknownAuthType: "Received invalid Socks5 initial handshake (unknown authentication type)", + Socks5AuthenticationFailed: "Socks5 Authentication failed", + InvalidSocks5FinalHandshake: "Received invalid Socks5 final handshake response", + InvalidSocks5FinalHandshakeRejected: "Socks5 proxy rejected connection", + InvalidSocks5IncomingConnectionResponse: "Received invalid Socks5 incoming connection response", + Socks5ProxyRejectedIncomingBoundConnection: "Socks5 Proxy rejected incoming bound connection" + }; + exports.ERRORS = ERRORS; + var SOCKS_INCOMING_PACKET_SIZES = { + Socks5InitialHandshakeResponse: 2, + Socks5UserPassAuthenticationResponse: 2, + Socks5ResponseHeader: 5, + Socks5ResponseIPv4: 10, + Socks5ResponseIPv6: 22, + Socks5ResponseHostname: (hostNameLength) => hostNameLength + 7, + Socks4Response: 8 + }; + exports.SOCKS_INCOMING_PACKET_SIZES = SOCKS_INCOMING_PACKET_SIZES; + var SocksCommand; + (function(SocksCommand2) { + SocksCommand2[SocksCommand2["connect"] = 1] = "connect"; + SocksCommand2[SocksCommand2["bind"] = 2] = "bind"; + SocksCommand2[SocksCommand2["associate"] = 3] = "associate"; + })(SocksCommand || (exports.SocksCommand = SocksCommand = {})); + var Socks4Response; + (function(Socks4Response2) { + Socks4Response2[Socks4Response2["Granted"] = 90] = "Granted"; + Socks4Response2[Socks4Response2["Failed"] = 91] = "Failed"; + Socks4Response2[Socks4Response2["Rejected"] = 92] = "Rejected"; + Socks4Response2[Socks4Response2["RejectedIdent"] = 93] = "RejectedIdent"; + })(Socks4Response || (exports.Socks4Response = Socks4Response = {})); + var Socks5Auth; + (function(Socks5Auth2) { + Socks5Auth2[Socks5Auth2["NoAuth"] = 0] = "NoAuth"; + Socks5Auth2[Socks5Auth2["GSSApi"] = 1] = "GSSApi"; + Socks5Auth2[Socks5Auth2["UserPass"] = 2] = "UserPass"; + })(Socks5Auth || (exports.Socks5Auth = Socks5Auth = {})); + var SOCKS5_CUSTOM_AUTH_START = 128; + exports.SOCKS5_CUSTOM_AUTH_START = SOCKS5_CUSTOM_AUTH_START; + var SOCKS5_CUSTOM_AUTH_END = 254; + exports.SOCKS5_CUSTOM_AUTH_END = SOCKS5_CUSTOM_AUTH_END; + var SOCKS5_NO_ACCEPTABLE_AUTH = 255; + exports.SOCKS5_NO_ACCEPTABLE_AUTH = SOCKS5_NO_ACCEPTABLE_AUTH; + var Socks5Response; + (function(Socks5Response2) { + Socks5Response2[Socks5Response2["Granted"] = 0] = "Granted"; + Socks5Response2[Socks5Response2["Failure"] = 1] = "Failure"; + Socks5Response2[Socks5Response2["NotAllowed"] = 2] = "NotAllowed"; + Socks5Response2[Socks5Response2["NetworkUnreachable"] = 3] = "NetworkUnreachable"; + Socks5Response2[Socks5Response2["HostUnreachable"] = 4] = "HostUnreachable"; + Socks5Response2[Socks5Response2["ConnectionRefused"] = 5] = "ConnectionRefused"; + Socks5Response2[Socks5Response2["TTLExpired"] = 6] = "TTLExpired"; + Socks5Response2[Socks5Response2["CommandNotSupported"] = 7] = "CommandNotSupported"; + Socks5Response2[Socks5Response2["AddressNotSupported"] = 8] = "AddressNotSupported"; + })(Socks5Response || (exports.Socks5Response = Socks5Response = {})); + var Socks5HostType; + (function(Socks5HostType2) { + Socks5HostType2[Socks5HostType2["IPv4"] = 1] = "IPv4"; + Socks5HostType2[Socks5HostType2["Hostname"] = 3] = "Hostname"; + Socks5HostType2[Socks5HostType2["IPv6"] = 4] = "IPv6"; + })(Socks5HostType || (exports.Socks5HostType = Socks5HostType = {})); + var SocksClientState; + (function(SocksClientState2) { + SocksClientState2[SocksClientState2["Created"] = 0] = "Created"; + SocksClientState2[SocksClientState2["Connecting"] = 1] = "Connecting"; + SocksClientState2[SocksClientState2["Connected"] = 2] = "Connected"; + SocksClientState2[SocksClientState2["SentInitialHandshake"] = 3] = "SentInitialHandshake"; + SocksClientState2[SocksClientState2["ReceivedInitialHandshakeResponse"] = 4] = "ReceivedInitialHandshakeResponse"; + SocksClientState2[SocksClientState2["SentAuthentication"] = 5] = "SentAuthentication"; + SocksClientState2[SocksClientState2["ReceivedAuthenticationResponse"] = 6] = "ReceivedAuthenticationResponse"; + SocksClientState2[SocksClientState2["SentFinalHandshake"] = 7] = "SentFinalHandshake"; + SocksClientState2[SocksClientState2["ReceivedFinalResponse"] = 8] = "ReceivedFinalResponse"; + SocksClientState2[SocksClientState2["BoundWaitingForConnection"] = 9] = "BoundWaitingForConnection"; + SocksClientState2[SocksClientState2["Established"] = 10] = "Established"; + SocksClientState2[SocksClientState2["Disconnected"] = 11] = "Disconnected"; + SocksClientState2[SocksClientState2["Error"] = 99] = "Error"; + })(SocksClientState || (exports.SocksClientState = SocksClientState = {})); +}); + +// ../../node_modules/.pnpm/socks@2.8.7/node_modules/socks/build/common/util.js +var require_util = __commonJS((exports) => { + Object.defineProperty(exports, "__esModule", { value: true }); + exports.shuffleArray = exports.SocksClientError = undefined; + + class SocksClientError extends Error { + constructor(message, options) { + super(message); + this.options = options; + } + } + exports.SocksClientError = SocksClientError; + function shuffleArray(array3) { + for (let i = array3.length - 1;i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array3[i], array3[j]] = [array3[j], array3[i]]; + } + } + exports.shuffleArray = shuffleArray; +}); + +// ../../node_modules/.pnpm/ip-address@10.1.0/node_modules/ip-address/dist/common.js +var require_common2 = __commonJS((exports) => { + Object.defineProperty(exports, "__esModule", { value: true }); + exports.isInSubnet = isInSubnet; + exports.isCorrect = isCorrect; + exports.numberToPaddedHex = numberToPaddedHex; + exports.stringToPaddedHex = stringToPaddedHex; + exports.testBit = testBit; + function isInSubnet(address) { + if (this.subnetMask < address.subnetMask) { + return false; + } + if (this.mask(address.subnetMask) === address.mask()) { + return true; + } + return false; + } + function isCorrect(defaultBits) { + return function() { + if (this.addressMinusSuffix !== this.correctForm()) { + return false; + } + if (this.subnetMask === defaultBits && !this.parsedSubnet) { + return true; + } + return this.parsedSubnet === String(this.subnetMask); + }; + } + function numberToPaddedHex(number4) { + return number4.toString(16).padStart(2, "0"); + } + function stringToPaddedHex(numberString) { + return numberToPaddedHex(parseInt(numberString, 10)); + } + function testBit(binaryValue, position) { + const { length } = binaryValue; + if (position > length) { + return false; + } + const positionInString = length - position; + return binaryValue.substring(positionInString, positionInString + 1) === "1"; + } +}); + +// ../../node_modules/.pnpm/ip-address@10.1.0/node_modules/ip-address/dist/v4/constants.js +var require_constants2 = __commonJS((exports) => { + Object.defineProperty(exports, "__esModule", { value: true }); + exports.RE_SUBNET_STRING = exports.RE_ADDRESS = exports.GROUPS = exports.BITS = undefined; + exports.BITS = 32; + exports.GROUPS = 4; + exports.RE_ADDRESS = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/g; + exports.RE_SUBNET_STRING = /\/\d{1,2}$/; +}); + +// ../../node_modules/.pnpm/ip-address@10.1.0/node_modules/ip-address/dist/address-error.js +var require_address_error = __commonJS((exports) => { + Object.defineProperty(exports, "__esModule", { value: true }); + exports.AddressError = undefined; + + class AddressError extends Error { + constructor(message, parseMessage) { + super(message); + this.name = "AddressError"; + this.parseMessage = parseMessage; + } + } + exports.AddressError = AddressError; +}); + +// ../../node_modules/.pnpm/ip-address@10.1.0/node_modules/ip-address/dist/ipv4.js +var require_ipv4 = __commonJS((exports) => { + var __createBinding = exports && exports.__createBinding || (Object.create ? function(o, m, k, k2) { + if (k2 === undefined) + k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { + return m[k]; + } }; + } + Object.defineProperty(o, k2, desc); + } : function(o, m, k, k2) { + if (k2 === undefined) + k2 = k; + o[k2] = m[k]; + }); + var __setModuleDefault = exports && exports.__setModuleDefault || (Object.create ? function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); + } : function(o, v) { + o["default"] = v; + }); + var __importStar = exports && exports.__importStar || function(mod) { + if (mod && mod.__esModule) + return mod; + var result = {}; + if (mod != null) { + for (var k in mod) + if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) + __createBinding(result, mod, k); + } + __setModuleDefault(result, mod); + return result; + }; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.Address4 = undefined; + var common = __importStar(require_common2()); + var constants = __importStar(require_constants2()); + var address_error_1 = require_address_error(); + + class Address4 { + constructor(address) { + this.groups = constants.GROUPS; + this.parsedAddress = []; + this.parsedSubnet = ""; + this.subnet = "/32"; + this.subnetMask = 32; + this.v4 = true; + this.isCorrect = common.isCorrect(constants.BITS); + this.isInSubnet = common.isInSubnet; + this.address = address; + const subnet = constants.RE_SUBNET_STRING.exec(address); + if (subnet) { + this.parsedSubnet = subnet[0].replace("/", ""); + this.subnetMask = parseInt(this.parsedSubnet, 10); + this.subnet = `/${this.subnetMask}`; + if (this.subnetMask < 0 || this.subnetMask > constants.BITS) { + throw new address_error_1.AddressError("Invalid subnet mask."); + } + address = address.replace(constants.RE_SUBNET_STRING, ""); + } + this.addressMinusSuffix = address; + this.parsedAddress = this.parse(address); + } + static isValid(address) { + try { + new Address4(address); + return true; + } catch (e) { + return false; + } + } + parse(address) { + const groups = address.split("."); + if (!address.match(constants.RE_ADDRESS)) { + throw new address_error_1.AddressError("Invalid IPv4 address."); + } + return groups; + } + correctForm() { + return this.parsedAddress.map((part) => parseInt(part, 10)).join("."); + } + static fromHex(hex3) { + const padded = hex3.replace(/:/g, "").padStart(8, "0"); + const groups = []; + let i; + for (i = 0;i < 8; i += 2) { + const h = padded.slice(i, i + 2); + groups.push(parseInt(h, 16)); + } + return new Address4(groups.join(".")); + } + static fromInteger(integer2) { + return Address4.fromHex(integer2.toString(16)); + } + static fromArpa(arpaFormAddress) { + const leader = arpaFormAddress.replace(/(\.in-addr\.arpa)?\.$/, ""); + const address = leader.split(".").reverse().join("."); + return new Address4(address); + } + toHex() { + return this.parsedAddress.map((part) => common.stringToPaddedHex(part)).join(":"); + } + toArray() { + return this.parsedAddress.map((part) => parseInt(part, 10)); + } + toGroup6() { + const output = []; + let i; + for (i = 0;i < constants.GROUPS; i += 2) { + output.push(`${common.stringToPaddedHex(this.parsedAddress[i])}${common.stringToPaddedHex(this.parsedAddress[i + 1])}`); + } + return output.join(":"); + } + bigInt() { + return BigInt(`0x${this.parsedAddress.map((n) => common.stringToPaddedHex(n)).join("")}`); + } + _startAddress() { + return BigInt(`0b${this.mask() + "0".repeat(constants.BITS - this.subnetMask)}`); + } + startAddress() { + return Address4.fromBigInt(this._startAddress()); + } + startAddressExclusive() { + const adjust = BigInt("1"); + return Address4.fromBigInt(this._startAddress() + adjust); + } + _endAddress() { + return BigInt(`0b${this.mask() + "1".repeat(constants.BITS - this.subnetMask)}`); + } + endAddress() { + return Address4.fromBigInt(this._endAddress()); + } + endAddressExclusive() { + const adjust = BigInt("1"); + return Address4.fromBigInt(this._endAddress() - adjust); + } + static fromBigInt(bigInt) { + return Address4.fromHex(bigInt.toString(16)); + } + static fromByteArray(bytes) { + if (bytes.length !== 4) { + throw new address_error_1.AddressError("IPv4 addresses require exactly 4 bytes"); + } + for (let i = 0;i < bytes.length; i++) { + if (!Number.isInteger(bytes[i]) || bytes[i] < 0 || bytes[i] > 255) { + throw new address_error_1.AddressError("All bytes must be integers between 0 and 255"); + } + } + return this.fromUnsignedByteArray(bytes); + } + static fromUnsignedByteArray(bytes) { + if (bytes.length !== 4) { + throw new address_error_1.AddressError("IPv4 addresses require exactly 4 bytes"); + } + const address = bytes.join("."); + return new Address4(address); + } + mask(mask) { + if (mask === undefined) { + mask = this.subnetMask; + } + return this.getBitsBase2(0, mask); + } + getBitsBase2(start, end) { + return this.binaryZeroPad().slice(start, end); + } + reverseForm(options) { + if (!options) { + options = {}; + } + const reversed = this.correctForm().split(".").reverse().join("."); + if (options.omitSuffix) { + return reversed; + } + return `${reversed}.in-addr.arpa.`; + } + isMulticast() { + return this.isInSubnet(new Address4("224.0.0.0/4")); + } + binaryZeroPad() { + return this.bigInt().toString(2).padStart(constants.BITS, "0"); + } + groupForV6() { + const segments = this.parsedAddress; + return this.address.replace(constants.RE_ADDRESS, `${segments.slice(0, 2).join(".")}.${segments.slice(2, 4).join(".")}`); + } + } + exports.Address4 = Address4; +}); + +// ../../node_modules/.pnpm/ip-address@10.1.0/node_modules/ip-address/dist/v6/constants.js +var require_constants3 = __commonJS((exports) => { + Object.defineProperty(exports, "__esModule", { value: true }); + exports.RE_URL_WITH_PORT = exports.RE_URL = exports.RE_ZONE_STRING = exports.RE_SUBNET_STRING = exports.RE_BAD_ADDRESS = exports.RE_BAD_CHARACTERS = exports.TYPES = exports.SCOPES = exports.GROUPS = exports.BITS = undefined; + exports.BITS = 128; + exports.GROUPS = 8; + exports.SCOPES = { + 0: "Reserved", + 1: "Interface local", + 2: "Link local", + 4: "Admin local", + 5: "Site local", + 8: "Organization local", + 14: "Global", + 15: "Reserved" + }; + exports.TYPES = { + "ff01::1/128": "Multicast (All nodes on this interface)", + "ff01::2/128": "Multicast (All routers on this interface)", + "ff02::1/128": "Multicast (All nodes on this link)", + "ff02::2/128": "Multicast (All routers on this link)", + "ff05::2/128": "Multicast (All routers in this site)", + "ff02::5/128": "Multicast (OSPFv3 AllSPF routers)", + "ff02::6/128": "Multicast (OSPFv3 AllDR routers)", + "ff02::9/128": "Multicast (RIP routers)", + "ff02::a/128": "Multicast (EIGRP routers)", + "ff02::d/128": "Multicast (PIM routers)", + "ff02::16/128": "Multicast (MLDv2 reports)", + "ff01::fb/128": "Multicast (mDNSv6)", + "ff02::fb/128": "Multicast (mDNSv6)", + "ff05::fb/128": "Multicast (mDNSv6)", + "ff02::1:2/128": "Multicast (All DHCP servers and relay agents on this link)", + "ff05::1:2/128": "Multicast (All DHCP servers and relay agents in this site)", + "ff02::1:3/128": "Multicast (All DHCP servers on this link)", + "ff05::1:3/128": "Multicast (All DHCP servers in this site)", + "::/128": "Unspecified", + "::1/128": "Loopback", + "ff00::/8": "Multicast", + "fe80::/10": "Link-local unicast" + }; + exports.RE_BAD_CHARACTERS = /([^0-9a-f:/%])/gi; + exports.RE_BAD_ADDRESS = /([0-9a-f]{5,}|:{3,}|[^:]:$|^:[^:]|\/$)/gi; + exports.RE_SUBNET_STRING = /\/\d{1,3}(?=%|$)/; + exports.RE_ZONE_STRING = /%.*$/; + exports.RE_URL = /^\[{0,1}([0-9a-f:]+)\]{0,1}/; + exports.RE_URL_WITH_PORT = /\[([0-9a-f:]+)\]:([0-9]{1,5})/; +}); + +// ../../node_modules/.pnpm/ip-address@10.1.0/node_modules/ip-address/dist/v6/helpers.js +var require_helpers2 = __commonJS((exports) => { + Object.defineProperty(exports, "__esModule", { value: true }); + exports.spanAllZeroes = spanAllZeroes; + exports.spanAll = spanAll; + exports.spanLeadingZeroes = spanLeadingZeroes; + exports.simpleGroup = simpleGroup; + function spanAllZeroes(s) { + return s.replace(/(0+)/g, '$1'); + } + function spanAll(s, offset = 0) { + const letters = s.split(""); + return letters.map((n, i) => `${spanAllZeroes(n)}`).join(""); + } + function spanLeadingZeroesSimple(group) { + return group.replace(/^(0+)/, '$1'); + } + function spanLeadingZeroes(address) { + const groups = address.split(":"); + return groups.map((g) => spanLeadingZeroesSimple(g)).join(":"); + } + function simpleGroup(addressString, offset = 0) { + const groups = addressString.split(":"); + return groups.map((g, i) => { + if (/group-v4/.test(g)) { + return g; + } + return `${spanLeadingZeroesSimple(g)}`; + }); + } +}); + +// ../../node_modules/.pnpm/ip-address@10.1.0/node_modules/ip-address/dist/v6/regular-expressions.js +var require_regular_expressions = __commonJS((exports) => { + var __createBinding = exports && exports.__createBinding || (Object.create ? function(o, m, k, k2) { + if (k2 === undefined) + k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { + return m[k]; + } }; + } + Object.defineProperty(o, k2, desc); + } : function(o, m, k, k2) { + if (k2 === undefined) + k2 = k; + o[k2] = m[k]; + }); + var __setModuleDefault = exports && exports.__setModuleDefault || (Object.create ? function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); + } : function(o, v) { + o["default"] = v; + }); + var __importStar = exports && exports.__importStar || function(mod) { + if (mod && mod.__esModule) + return mod; + var result = {}; + if (mod != null) { + for (var k in mod) + if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) + __createBinding(result, mod, k); + } + __setModuleDefault(result, mod); + return result; + }; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.ADDRESS_BOUNDARY = undefined; + exports.groupPossibilities = groupPossibilities; + exports.padGroup = padGroup; + exports.simpleRegularExpression = simpleRegularExpression; + exports.possibleElisions = possibleElisions; + var v6 = __importStar(require_constants3()); + function groupPossibilities(possibilities) { + return `(${possibilities.join("|")})`; + } + function padGroup(group) { + if (group.length < 4) { + return `0{0,${4 - group.length}}${group}`; + } + return group; + } + exports.ADDRESS_BOUNDARY = "[^A-Fa-f0-9:]"; + function simpleRegularExpression(groups) { + const zeroIndexes = []; + groups.forEach((group, i) => { + const groupInteger = parseInt(group, 16); + if (groupInteger === 0) { + zeroIndexes.push(i); + } + }); + const possibilities = zeroIndexes.map((zeroIndex) => groups.map((group, i) => { + if (i === zeroIndex) { + const elision = i === 0 || i === v6.GROUPS - 1 ? ":" : ""; + return groupPossibilities([padGroup(group), elision]); + } + return padGroup(group); + }).join(":")); + possibilities.push(groups.map(padGroup).join(":")); + return groupPossibilities(possibilities); + } + function possibleElisions(elidedGroups, moreLeft, moreRight) { + const left = moreLeft ? "" : ":"; + const right = moreRight ? "" : ":"; + const possibilities = []; + if (!moreLeft && !moreRight) { + possibilities.push("::"); + } + if (moreLeft && moreRight) { + possibilities.push(""); + } + if (moreRight && !moreLeft || !moreRight && moreLeft) { + possibilities.push(":"); + } + possibilities.push(`${left}(:0{1,4}){1,${elidedGroups - 1}}`); + possibilities.push(`(0{1,4}:){1,${elidedGroups - 1}}${right}`); + possibilities.push(`(0{1,4}:){${elidedGroups - 1}}0{1,4}`); + for (let groups = 1;groups < elidedGroups - 1; groups++) { + for (let position = 1;position < elidedGroups - groups; position++) { + possibilities.push(`(0{1,4}:){${position}}:(0{1,4}:){${elidedGroups - position - groups - 1}}0{1,4}`); + } + } + return groupPossibilities(possibilities); + } +}); + +// ../../node_modules/.pnpm/ip-address@10.1.0/node_modules/ip-address/dist/ipv6.js +var require_ipv6 = __commonJS((exports) => { + var __createBinding = exports && exports.__createBinding || (Object.create ? function(o, m, k, k2) { + if (k2 === undefined) + k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { + return m[k]; + } }; + } + Object.defineProperty(o, k2, desc); + } : function(o, m, k, k2) { + if (k2 === undefined) + k2 = k; + o[k2] = m[k]; + }); + var __setModuleDefault = exports && exports.__setModuleDefault || (Object.create ? function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); + } : function(o, v) { + o["default"] = v; + }); + var __importStar = exports && exports.__importStar || function(mod) { + if (mod && mod.__esModule) + return mod; + var result = {}; + if (mod != null) { + for (var k in mod) + if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) + __createBinding(result, mod, k); + } + __setModuleDefault(result, mod); + return result; + }; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.Address6 = undefined; + var common = __importStar(require_common2()); + var constants4 = __importStar(require_constants2()); + var constants6 = __importStar(require_constants3()); + var helpers = __importStar(require_helpers2()); + var ipv4_1 = require_ipv4(); + var regular_expressions_1 = require_regular_expressions(); + var address_error_1 = require_address_error(); + var common_1 = require_common2(); + function assert2(condition) { + if (!condition) { + throw new Error("Assertion failed."); + } + } + function addCommas(number4) { + const r = /(\d+)(\d{3})/; + while (r.test(number4)) { + number4 = number4.replace(r, "$1,$2"); + } + return number4; + } + function spanLeadingZeroes4(n) { + n = n.replace(/^(0{1,})([1-9]+)$/, '$1$2'); + n = n.replace(/^(0{1,})(0)$/, '$1$2'); + return n; + } + function compact2(address, slice) { + const s1 = []; + const s2 = []; + let i; + for (i = 0;i < address.length; i++) { + if (i < slice[0]) { + s1.push(address[i]); + } else if (i > slice[1]) { + s2.push(address[i]); + } + } + return s1.concat(["compact"]).concat(s2); + } + function paddedHex(octet) { + return parseInt(octet, 16).toString(16).padStart(4, "0"); + } + function unsignByte(b) { + return b & 255; + } + + class Address6 { + constructor(address, optionalGroups) { + this.addressMinusSuffix = ""; + this.parsedSubnet = ""; + this.subnet = "/128"; + this.subnetMask = 128; + this.v4 = false; + this.zone = ""; + this.isInSubnet = common.isInSubnet; + this.isCorrect = common.isCorrect(constants6.BITS); + if (optionalGroups === undefined) { + this.groups = constants6.GROUPS; + } else { + this.groups = optionalGroups; + } + this.address = address; + const subnet = constants6.RE_SUBNET_STRING.exec(address); + if (subnet) { + this.parsedSubnet = subnet[0].replace("/", ""); + this.subnetMask = parseInt(this.parsedSubnet, 10); + this.subnet = `/${this.subnetMask}`; + if (Number.isNaN(this.subnetMask) || this.subnetMask < 0 || this.subnetMask > constants6.BITS) { + throw new address_error_1.AddressError("Invalid subnet mask."); + } + address = address.replace(constants6.RE_SUBNET_STRING, ""); + } else if (/\//.test(address)) { + throw new address_error_1.AddressError("Invalid subnet mask."); + } + const zone = constants6.RE_ZONE_STRING.exec(address); + if (zone) { + this.zone = zone[0]; + address = address.replace(constants6.RE_ZONE_STRING, ""); + } + this.addressMinusSuffix = address; + this.parsedAddress = this.parse(this.addressMinusSuffix); + } + static isValid(address) { + try { + new Address6(address); + return true; + } catch (e) { + return false; + } + } + static fromBigInt(bigInt) { + const hex3 = bigInt.toString(16).padStart(32, "0"); + const groups = []; + let i; + for (i = 0;i < constants6.GROUPS; i++) { + groups.push(hex3.slice(i * 4, (i + 1) * 4)); + } + return new Address6(groups.join(":")); + } + static fromURL(url2) { + let host; + let port = null; + let result; + if (url2.indexOf("[") !== -1 && url2.indexOf("]:") !== -1) { + result = constants6.RE_URL_WITH_PORT.exec(url2); + if (result === null) { + return { + error: "failed to parse address with port", + address: null, + port: null + }; + } + host = result[1]; + port = result[2]; + } else if (url2.indexOf("/") !== -1) { + url2 = url2.replace(/^[a-z0-9]+:\/\//, ""); + result = constants6.RE_URL.exec(url2); + if (result === null) { + return { + error: "failed to parse address from URL", + address: null, + port: null + }; + } + host = result[1]; + } else { + host = url2; + } + if (port) { + port = parseInt(port, 10); + if (port < 0 || port > 65536) { + port = null; + } + } else { + port = null; + } + return { + address: new Address6(host), + port + }; + } + static fromAddress4(address) { + const address4 = new ipv4_1.Address4(address); + const mask6 = constants6.BITS - (constants4.BITS - address4.subnetMask); + return new Address6(`::ffff:${address4.correctForm()}/${mask6}`); + } + static fromArpa(arpaFormAddress) { + let address = arpaFormAddress.replace(/(\.ip6\.arpa)?\.$/, ""); + const semicolonAmount = 7; + if (address.length !== 63) { + throw new address_error_1.AddressError("Invalid 'ip6.arpa' form."); + } + const parts = address.split(".").reverse(); + for (let i = semicolonAmount;i > 0; i--) { + const insertIndex = i * 4; + parts.splice(insertIndex, 0, ":"); + } + address = parts.join(""); + return new Address6(address); + } + microsoftTranscription() { + return `${this.correctForm().replace(/:/g, "-")}.ipv6-literal.net`; + } + mask(mask = this.subnetMask) { + return this.getBitsBase2(0, mask); + } + possibleSubnets(subnetSize = 128) { + const availableBits = constants6.BITS - this.subnetMask; + const subnetBits = Math.abs(subnetSize - constants6.BITS); + const subnetPowers = availableBits - subnetBits; + if (subnetPowers < 0) { + return "0"; + } + return addCommas((BigInt("2") ** BigInt(subnetPowers)).toString(10)); + } + _startAddress() { + return BigInt(`0b${this.mask() + "0".repeat(constants6.BITS - this.subnetMask)}`); + } + startAddress() { + return Address6.fromBigInt(this._startAddress()); + } + startAddressExclusive() { + const adjust = BigInt("1"); + return Address6.fromBigInt(this._startAddress() + adjust); + } + _endAddress() { + return BigInt(`0b${this.mask() + "1".repeat(constants6.BITS - this.subnetMask)}`); + } + endAddress() { + return Address6.fromBigInt(this._endAddress()); + } + endAddressExclusive() { + const adjust = BigInt("1"); + return Address6.fromBigInt(this._endAddress() - adjust); + } + getScope() { + let scope = constants6.SCOPES[parseInt(this.getBits(12, 16).toString(10), 10)]; + if (this.getType() === "Global unicast" && scope !== "Link local") { + scope = "Global"; + } + return scope || "Unknown"; + } + getType() { + for (const subnet of Object.keys(constants6.TYPES)) { + if (this.isInSubnet(new Address6(subnet))) { + return constants6.TYPES[subnet]; + } + } + return "Global unicast"; + } + getBits(start, end) { + return BigInt(`0b${this.getBitsBase2(start, end)}`); + } + getBitsBase2(start, end) { + return this.binaryZeroPad().slice(start, end); + } + getBitsBase16(start, end) { + const length = end - start; + if (length % 4 !== 0) { + throw new Error("Length of bits to retrieve must be divisible by four"); + } + return this.getBits(start, end).toString(16).padStart(length / 4, "0"); + } + getBitsPastSubnet() { + return this.getBitsBase2(this.subnetMask, constants6.BITS); + } + reverseForm(options) { + if (!options) { + options = {}; + } + const characters = Math.floor(this.subnetMask / 4); + const reversed = this.canonicalForm().replace(/:/g, "").split("").slice(0, characters).reverse().join("."); + if (characters > 0) { + if (options.omitSuffix) { + return reversed; + } + return `${reversed}.ip6.arpa.`; + } + if (options.omitSuffix) { + return ""; + } + return "ip6.arpa."; + } + correctForm() { + let i; + let groups = []; + let zeroCounter = 0; + const zeroes = []; + for (i = 0;i < this.parsedAddress.length; i++) { + const value = parseInt(this.parsedAddress[i], 16); + if (value === 0) { + zeroCounter++; + } + if (value !== 0 && zeroCounter > 0) { + if (zeroCounter > 1) { + zeroes.push([i - zeroCounter, i - 1]); + } + zeroCounter = 0; + } + } + if (zeroCounter > 1) { + zeroes.push([this.parsedAddress.length - zeroCounter, this.parsedAddress.length - 1]); + } + const zeroLengths = zeroes.map((n) => n[1] - n[0] + 1); + if (zeroes.length > 0) { + const index = zeroLengths.indexOf(Math.max(...zeroLengths)); + groups = compact2(this.parsedAddress, zeroes[index]); + } else { + groups = this.parsedAddress; + } + for (i = 0;i < groups.length; i++) { + if (groups[i] !== "compact") { + groups[i] = parseInt(groups[i], 16).toString(16); + } + } + let correct = groups.join(":"); + correct = correct.replace(/^compact$/, "::"); + correct = correct.replace(/(^compact)|(compact$)/, ":"); + correct = correct.replace(/compact/, ""); + return correct; + } + binaryZeroPad() { + return this.bigInt().toString(2).padStart(constants6.BITS, "0"); + } + parse4in6(address) { + const groups = address.split(":"); + const lastGroup = groups.slice(-1)[0]; + const address4 = lastGroup.match(constants4.RE_ADDRESS); + if (address4) { + this.parsedAddress4 = address4[0]; + this.address4 = new ipv4_1.Address4(this.parsedAddress4); + for (let i = 0;i < this.address4.groups; i++) { + if (/^0[0-9]+/.test(this.address4.parsedAddress[i])) { + throw new address_error_1.AddressError("IPv4 addresses can't have leading zeroes.", address.replace(constants4.RE_ADDRESS, this.address4.parsedAddress.map(spanLeadingZeroes4).join("."))); + } + } + this.v4 = true; + groups[groups.length - 1] = this.address4.toGroup6(); + address = groups.join(":"); + } + return address; + } + parse(address) { + address = this.parse4in6(address); + const badCharacters = address.match(constants6.RE_BAD_CHARACTERS); + if (badCharacters) { + throw new address_error_1.AddressError(`Bad character${badCharacters.length > 1 ? "s" : ""} detected in address: ${badCharacters.join("")}`, address.replace(constants6.RE_BAD_CHARACTERS, '$1')); + } + const badAddress = address.match(constants6.RE_BAD_ADDRESS); + if (badAddress) { + throw new address_error_1.AddressError(`Address failed regex: ${badAddress.join("")}`, address.replace(constants6.RE_BAD_ADDRESS, '$1')); + } + let groups = []; + const halves = address.split("::"); + if (halves.length === 2) { + let first = halves[0].split(":"); + let last2 = halves[1].split(":"); + if (first.length === 1 && first[0] === "") { + first = []; + } + if (last2.length === 1 && last2[0] === "") { + last2 = []; + } + const remaining = this.groups - (first.length + last2.length); + if (!remaining) { + throw new address_error_1.AddressError("Error parsing groups"); + } + this.elidedGroups = remaining; + this.elisionBegin = first.length; + this.elisionEnd = first.length + this.elidedGroups; + groups = groups.concat(first); + for (let i = 0;i < remaining; i++) { + groups.push("0"); + } + groups = groups.concat(last2); + } else if (halves.length === 1) { + groups = address.split(":"); + this.elidedGroups = 0; + } else { + throw new address_error_1.AddressError("Too many :: groups found"); + } + groups = groups.map((group) => parseInt(group, 16).toString(16)); + if (groups.length !== this.groups) { + throw new address_error_1.AddressError("Incorrect number of groups found"); + } + return groups; + } + canonicalForm() { + return this.parsedAddress.map(paddedHex).join(":"); + } + decimal() { + return this.parsedAddress.map((n) => parseInt(n, 16).toString(10).padStart(5, "0")).join(":"); + } + bigInt() { + return BigInt(`0x${this.parsedAddress.map(paddedHex).join("")}`); + } + to4() { + const binary = this.binaryZeroPad().split(""); + return ipv4_1.Address4.fromHex(BigInt(`0b${binary.slice(96, 128).join("")}`).toString(16)); + } + to4in6() { + const address4 = this.to4(); + const address6 = new Address6(this.parsedAddress.slice(0, 6).join(":"), 6); + const correct = address6.correctForm(); + let infix = ""; + if (!/:$/.test(correct)) { + infix = ":"; + } + return correct + infix + address4.address; + } + inspectTeredo() { + const prefix = this.getBitsBase16(0, 32); + const bitsForUdpPort = this.getBits(80, 96); + const udpPort = (bitsForUdpPort ^ BigInt("0xffff")).toString(); + const server4 = ipv4_1.Address4.fromHex(this.getBitsBase16(32, 64)); + const bitsForClient4 = this.getBits(96, 128); + const client4 = ipv4_1.Address4.fromHex((bitsForClient4 ^ BigInt("0xffffffff")).toString(16)); + const flagsBase2 = this.getBitsBase2(64, 80); + const coneNat = (0, common_1.testBit)(flagsBase2, 15); + const reserved = (0, common_1.testBit)(flagsBase2, 14); + const groupIndividual = (0, common_1.testBit)(flagsBase2, 8); + const universalLocal = (0, common_1.testBit)(flagsBase2, 9); + const nonce = BigInt(`0b${flagsBase2.slice(2, 6) + flagsBase2.slice(8, 16)}`).toString(10); + return { + prefix: `${prefix.slice(0, 4)}:${prefix.slice(4, 8)}`, + server4: server4.address, + client4: client4.address, + flags: flagsBase2, + coneNat, + microsoft: { + reserved, + universalLocal, + groupIndividual, + nonce + }, + udpPort + }; + } + inspect6to4() { + const prefix = this.getBitsBase16(0, 16); + const gateway2 = ipv4_1.Address4.fromHex(this.getBitsBase16(16, 48)); + return { + prefix: prefix.slice(0, 4), + gateway: gateway2.address + }; + } + to6to4() { + if (!this.is4()) { + return null; + } + const addr6to4 = [ + "2002", + this.getBitsBase16(96, 112), + this.getBitsBase16(112, 128), + "", + "/16" + ].join(":"); + return new Address6(addr6to4); + } + toByteArray() { + const valueWithoutPadding = this.bigInt().toString(16); + const leadingPad = "0".repeat(valueWithoutPadding.length % 2); + const value = `${leadingPad}${valueWithoutPadding}`; + const bytes = []; + for (let i = 0, length = value.length;i < length; i += 2) { + bytes.push(parseInt(value.substring(i, i + 2), 16)); + } + return bytes; + } + toUnsignedByteArray() { + return this.toByteArray().map(unsignByte); + } + static fromByteArray(bytes) { + return this.fromUnsignedByteArray(bytes.map(unsignByte)); + } + static fromUnsignedByteArray(bytes) { + const BYTE_MAX = BigInt("256"); + let result = BigInt("0"); + let multiplier = BigInt("1"); + for (let i = bytes.length - 1;i >= 0; i--) { + result += multiplier * BigInt(bytes[i].toString(10)); + multiplier *= BYTE_MAX; + } + return Address6.fromBigInt(result); + } + isCanonical() { + return this.addressMinusSuffix === this.canonicalForm(); + } + isLinkLocal() { + if (this.getBitsBase2(0, 64) === "1111111010000000000000000000000000000000000000000000000000000000") { + return true; + } + return false; + } + isMulticast() { + return this.getType() === "Multicast"; + } + is4() { + return this.v4; + } + isTeredo() { + return this.isInSubnet(new Address6("2001::/32")); + } + is6to4() { + return this.isInSubnet(new Address6("2002::/16")); + } + isLoopback() { + return this.getType() === "Loopback"; + } + href(optionalPort) { + if (optionalPort === undefined) { + optionalPort = ""; + } else { + optionalPort = `:${optionalPort}`; + } + return `http://[${this.correctForm()}]${optionalPort}/`; + } + link(options) { + if (!options) { + options = {}; + } + if (options.className === undefined) { + options.className = ""; + } + if (options.prefix === undefined) { + options.prefix = "/#address="; + } + if (options.v4 === undefined) { + options.v4 = false; + } + let formFunction = this.correctForm; + if (options.v4) { + formFunction = this.to4in6; + } + const form = formFunction.call(this); + if (options.className) { + return `${form}`; + } + return `${form}`; + } + group() { + if (this.elidedGroups === 0) { + return helpers.simpleGroup(this.address).join(":"); + } + assert2(typeof this.elidedGroups === "number"); + assert2(typeof this.elisionBegin === "number"); + const output = []; + const [left, right] = this.address.split("::"); + if (left.length) { + output.push(...helpers.simpleGroup(left)); + } else { + output.push(""); + } + const classes = ["hover-group"]; + for (let i = this.elisionBegin;i < this.elisionBegin + this.elidedGroups; i++) { + classes.push(`group-${i}`); + } + output.push(``); + if (right.length) { + output.push(...helpers.simpleGroup(right, this.elisionEnd)); + } else { + output.push(""); + } + if (this.is4()) { + assert2(this.address4 instanceof ipv4_1.Address4); + output.pop(); + output.push(this.address4.groupForV6()); + } + return output.join(":"); + } + regularExpressionString(substringSearch = false) { + let output = []; + const address6 = new Address6(this.correctForm()); + if (address6.elidedGroups === 0) { + output.push((0, regular_expressions_1.simpleRegularExpression)(address6.parsedAddress)); + } else if (address6.elidedGroups === constants6.GROUPS) { + output.push((0, regular_expressions_1.possibleElisions)(constants6.GROUPS)); + } else { + const halves = address6.address.split("::"); + if (halves[0].length) { + output.push((0, regular_expressions_1.simpleRegularExpression)(halves[0].split(":"))); + } + assert2(typeof address6.elidedGroups === "number"); + output.push((0, regular_expressions_1.possibleElisions)(address6.elidedGroups, halves[0].length !== 0, halves[1].length !== 0)); + if (halves[1].length) { + output.push((0, regular_expressions_1.simpleRegularExpression)(halves[1].split(":"))); + } + output = [output.join(":")]; + } + if (!substringSearch) { + output = [ + "(?=^|", + regular_expressions_1.ADDRESS_BOUNDARY, + "|[^\\w\\:])(", + ...output, + ")(?=[^\\w\\:]|", + regular_expressions_1.ADDRESS_BOUNDARY, + "|$)" + ]; + } + return output.join(""); + } + regularExpression(substringSearch = false) { + return new RegExp(this.regularExpressionString(substringSearch), "i"); + } + } + exports.Address6 = Address6; +}); + +// ../../node_modules/.pnpm/ip-address@10.1.0/node_modules/ip-address/dist/ip-address.js +var require_ip_address = __commonJS((exports) => { + var __createBinding = exports && exports.__createBinding || (Object.create ? function(o, m, k, k2) { + if (k2 === undefined) + k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { + return m[k]; + } }; + } + Object.defineProperty(o, k2, desc); + } : function(o, m, k, k2) { + if (k2 === undefined) + k2 = k; + o[k2] = m[k]; + }); + var __setModuleDefault = exports && exports.__setModuleDefault || (Object.create ? function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); + } : function(o, v) { + o["default"] = v; + }); + var __importStar = exports && exports.__importStar || function(mod) { + if (mod && mod.__esModule) + return mod; + var result = {}; + if (mod != null) { + for (var k in mod) + if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) + __createBinding(result, mod, k); + } + __setModuleDefault(result, mod); + return result; + }; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.v6 = exports.AddressError = exports.Address6 = exports.Address4 = undefined; + var ipv4_1 = require_ipv4(); + Object.defineProperty(exports, "Address4", { enumerable: true, get: function() { + return ipv4_1.Address4; + } }); + var ipv6_1 = require_ipv6(); + Object.defineProperty(exports, "Address6", { enumerable: true, get: function() { + return ipv6_1.Address6; + } }); + var address_error_1 = require_address_error(); + Object.defineProperty(exports, "AddressError", { enumerable: true, get: function() { + return address_error_1.AddressError; + } }); + var helpers = __importStar(require_helpers2()); + exports.v6 = { helpers }; +}); + +// ../../node_modules/.pnpm/socks@2.8.7/node_modules/socks/build/common/helpers.js +var require_helpers3 = __commonJS((exports) => { + Object.defineProperty(exports, "__esModule", { value: true }); + exports.ipToBuffer = exports.int32ToIpv4 = exports.ipv4ToInt32 = exports.validateSocksClientChainOptions = exports.validateSocksClientOptions = undefined; + var util_1 = require_util(); + var constants_1 = require_constants(); + var stream = __require("stream"); + var ip_address_1 = require_ip_address(); + var net = __require("net"); + function validateSocksClientOptions(options, acceptedCommands = ["connect", "bind", "associate"]) { + if (!constants_1.SocksCommand[options.command]) { + throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksCommand, options); + } + if (acceptedCommands.indexOf(options.command) === -1) { + throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksCommandForOperation, options); + } + if (!isValidSocksRemoteHost(options.destination)) { + throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsDestination, options); + } + if (!isValidSocksProxy(options.proxy)) { + throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsProxy, options); + } + validateCustomProxyAuth(options.proxy, options); + if (options.timeout && !isValidTimeoutValue(options.timeout)) { + throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsTimeout, options); + } + if (options.existing_socket && !(options.existing_socket instanceof stream.Duplex)) { + throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsExistingSocket, options); + } + } + exports.validateSocksClientOptions = validateSocksClientOptions; + function validateSocksClientChainOptions(options) { + if (options.command !== "connect") { + throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksCommandChain, options); + } + if (!isValidSocksRemoteHost(options.destination)) { + throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsDestination, options); + } + if (!(options.proxies && Array.isArray(options.proxies) && options.proxies.length >= 2)) { + throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsProxiesLength, options); + } + options.proxies.forEach((proxy) => { + if (!isValidSocksProxy(proxy)) { + throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsProxy, options); + } + validateCustomProxyAuth(proxy, options); + }); + if (options.timeout && !isValidTimeoutValue(options.timeout)) { + throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsTimeout, options); + } + } + exports.validateSocksClientChainOptions = validateSocksClientChainOptions; + function validateCustomProxyAuth(proxy, options) { + if (proxy.custom_auth_method !== undefined) { + if (proxy.custom_auth_method < constants_1.SOCKS5_CUSTOM_AUTH_START || proxy.custom_auth_method > constants_1.SOCKS5_CUSTOM_AUTH_END) { + throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsCustomAuthRange, options); + } + if (proxy.custom_auth_request_handler === undefined || typeof proxy.custom_auth_request_handler !== "function") { + throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsCustomAuthOptions, options); + } + if (proxy.custom_auth_response_size === undefined) { + throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsCustomAuthOptions, options); + } + if (proxy.custom_auth_response_handler === undefined || typeof proxy.custom_auth_response_handler !== "function") { + throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsCustomAuthOptions, options); + } + } + } + function isValidSocksRemoteHost(remoteHost) { + return remoteHost && typeof remoteHost.host === "string" && Buffer.byteLength(remoteHost.host) < 256 && typeof remoteHost.port === "number" && remoteHost.port >= 0 && remoteHost.port <= 65535; + } + function isValidSocksProxy(proxy) { + return proxy && (typeof proxy.host === "string" || typeof proxy.ipaddress === "string") && typeof proxy.port === "number" && proxy.port >= 0 && proxy.port <= 65535 && (proxy.type === 4 || proxy.type === 5); + } + function isValidTimeoutValue(value) { + return typeof value === "number" && value > 0; + } + function ipv4ToInt32(ip) { + const address = new ip_address_1.Address4(ip); + return address.toArray().reduce((acc, part) => (acc << 8) + part, 0) >>> 0; + } + exports.ipv4ToInt32 = ipv4ToInt32; + function int32ToIpv4(int322) { + const octet1 = int322 >>> 24 & 255; + const octet2 = int322 >>> 16 & 255; + const octet3 = int322 >>> 8 & 255; + const octet4 = int322 & 255; + return [octet1, octet2, octet3, octet4].join("."); + } + exports.int32ToIpv4 = int32ToIpv4; + function ipToBuffer(ip) { + if (net.isIPv4(ip)) { + const address = new ip_address_1.Address4(ip); + return Buffer.from(address.toArray()); + } else if (net.isIPv6(ip)) { + const address = new ip_address_1.Address6(ip); + return Buffer.from(address.canonicalForm().split(":").map((segment) => segment.padStart(4, "0")).join(""), "hex"); + } else { + throw new Error("Invalid IP address format"); + } + } + exports.ipToBuffer = ipToBuffer; +}); + +// ../../node_modules/.pnpm/socks@2.8.7/node_modules/socks/build/common/receivebuffer.js +var require_receivebuffer = __commonJS((exports) => { + Object.defineProperty(exports, "__esModule", { value: true }); + exports.ReceiveBuffer = undefined; + + class ReceiveBuffer { + constructor(size = 4096) { + this.buffer = Buffer.allocUnsafe(size); + this.offset = 0; + this.originalSize = size; + } + get length() { + return this.offset; + } + append(data) { + if (!Buffer.isBuffer(data)) { + throw new Error("Attempted to append a non-buffer instance to ReceiveBuffer."); + } + if (this.offset + data.length >= this.buffer.length) { + const tmp = this.buffer; + this.buffer = Buffer.allocUnsafe(Math.max(this.buffer.length + this.originalSize, this.buffer.length + data.length)); + tmp.copy(this.buffer); + } + data.copy(this.buffer, this.offset); + return this.offset += data.length; + } + peek(length) { + if (length > this.offset) { + throw new Error("Attempted to read beyond the bounds of the managed internal data."); + } + return this.buffer.slice(0, length); + } + get(length) { + if (length > this.offset) { + throw new Error("Attempted to read beyond the bounds of the managed internal data."); + } + const value = Buffer.allocUnsafe(length); + this.buffer.slice(0, length).copy(value); + this.buffer.copyWithin(0, length, length + this.offset - length); + this.offset -= length; + return value; + } + } + exports.ReceiveBuffer = ReceiveBuffer; +}); + +// ../../node_modules/.pnpm/socks@2.8.7/node_modules/socks/build/client/socksclient.js +var require_socksclient = __commonJS((exports) => { + var __awaiter = exports && exports.__awaiter || function(thisArg, _arguments, P, generator) { + function adopt(value) { + return value instanceof P ? value : new P(function(resolve2) { + resolve2(value); + }); + } + return new (P || (P = Promise))(function(resolve2, reject) { + function fulfilled(value) { + try { + step(generator.next(value)); + } catch (e) { + reject(e); + } + } + function rejected(value) { + try { + step(generator["throw"](value)); + } catch (e) { + reject(e); + } + } + function step(result) { + result.done ? resolve2(result.value) : adopt(result.value).then(fulfilled, rejected); + } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); + }; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.SocksClientError = exports.SocksClient = undefined; + var events_1 = __require("events"); + var net = __require("net"); + var smart_buffer_1 = require_smartbuffer(); + var constants_1 = require_constants(); + var helpers_1 = require_helpers3(); + var receivebuffer_1 = require_receivebuffer(); + var util_1 = require_util(); + Object.defineProperty(exports, "SocksClientError", { enumerable: true, get: function() { + return util_1.SocksClientError; + } }); + var ip_address_1 = require_ip_address(); + + class SocksClient extends events_1.EventEmitter { + constructor(options) { + super(); + this.options = Object.assign({}, options); + (0, helpers_1.validateSocksClientOptions)(options); + this.setState(constants_1.SocksClientState.Created); + } + static createConnection(options, callback) { + return new Promise((resolve2, reject) => { + try { + (0, helpers_1.validateSocksClientOptions)(options, ["connect"]); + } catch (err) { + if (typeof callback === "function") { + callback(err); + return resolve2(err); + } else { + return reject(err); + } + } + const client = new SocksClient(options); + client.connect(options.existing_socket); + client.once("established", (info) => { + client.removeAllListeners(); + if (typeof callback === "function") { + callback(null, info); + resolve2(info); + } else { + resolve2(info); + } + }); + client.once("error", (err) => { + client.removeAllListeners(); + if (typeof callback === "function") { + callback(err); + resolve2(err); + } else { + reject(err); + } + }); + }); + } + static createConnectionChain(options, callback) { + return new Promise((resolve2, reject) => __awaiter(this, undefined, undefined, function* () { + try { + (0, helpers_1.validateSocksClientChainOptions)(options); + } catch (err) { + if (typeof callback === "function") { + callback(err); + return resolve2(err); + } else { + return reject(err); + } + } + if (options.randomizeChain) { + (0, util_1.shuffleArray)(options.proxies); + } + try { + let sock; + for (let i = 0;i < options.proxies.length; i++) { + const nextProxy = options.proxies[i]; + const nextDestination = i === options.proxies.length - 1 ? options.destination : { + host: options.proxies[i + 1].host || options.proxies[i + 1].ipaddress, + port: options.proxies[i + 1].port + }; + const result = yield SocksClient.createConnection({ + command: "connect", + proxy: nextProxy, + destination: nextDestination, + existing_socket: sock + }); + sock = sock || result.socket; + } + if (typeof callback === "function") { + callback(null, { socket: sock }); + resolve2({ socket: sock }); + } else { + resolve2({ socket: sock }); + } + } catch (err) { + if (typeof callback === "function") { + callback(err); + resolve2(err); + } else { + reject(err); + } + } + })); + } + static createUDPFrame(options) { + const buff = new smart_buffer_1.SmartBuffer; + buff.writeUInt16BE(0); + buff.writeUInt8(options.frameNumber || 0); + if (net.isIPv4(options.remoteHost.host)) { + buff.writeUInt8(constants_1.Socks5HostType.IPv4); + buff.writeUInt32BE((0, helpers_1.ipv4ToInt32)(options.remoteHost.host)); + } else if (net.isIPv6(options.remoteHost.host)) { + buff.writeUInt8(constants_1.Socks5HostType.IPv6); + buff.writeBuffer((0, helpers_1.ipToBuffer)(options.remoteHost.host)); + } else { + buff.writeUInt8(constants_1.Socks5HostType.Hostname); + buff.writeUInt8(Buffer.byteLength(options.remoteHost.host)); + buff.writeString(options.remoteHost.host); + } + buff.writeUInt16BE(options.remoteHost.port); + buff.writeBuffer(options.data); + return buff.toBuffer(); + } + static parseUDPFrame(data) { + const buff = smart_buffer_1.SmartBuffer.fromBuffer(data); + buff.readOffset = 2; + const frameNumber = buff.readUInt8(); + const hostType = buff.readUInt8(); + let remoteHost; + if (hostType === constants_1.Socks5HostType.IPv4) { + remoteHost = (0, helpers_1.int32ToIpv4)(buff.readUInt32BE()); + } else if (hostType === constants_1.Socks5HostType.IPv6) { + remoteHost = ip_address_1.Address6.fromByteArray(Array.from(buff.readBuffer(16))).canonicalForm(); + } else { + remoteHost = buff.readString(buff.readUInt8()); + } + const remotePort = buff.readUInt16BE(); + return { + frameNumber, + remoteHost: { + host: remoteHost, + port: remotePort + }, + data: buff.readBuffer() + }; + } + setState(newState) { + if (this.state !== constants_1.SocksClientState.Error) { + this.state = newState; + } + } + connect(existingSocket) { + this.onDataReceived = (data) => this.onDataReceivedHandler(data); + this.onClose = () => this.onCloseHandler(); + this.onError = (err) => this.onErrorHandler(err); + this.onConnect = () => this.onConnectHandler(); + const timer = setTimeout(() => this.onEstablishedTimeout(), this.options.timeout || constants_1.DEFAULT_TIMEOUT); + if (timer.unref && typeof timer.unref === "function") { + timer.unref(); + } + if (existingSocket) { + this.socket = existingSocket; + } else { + this.socket = new net.Socket; + } + this.socket.once("close", this.onClose); + this.socket.once("error", this.onError); + this.socket.once("connect", this.onConnect); + this.socket.on("data", this.onDataReceived); + this.setState(constants_1.SocksClientState.Connecting); + this.receiveBuffer = new receivebuffer_1.ReceiveBuffer; + if (existingSocket) { + this.socket.emit("connect"); + } else { + this.socket.connect(this.getSocketOptions()); + if (this.options.set_tcp_nodelay !== undefined && this.options.set_tcp_nodelay !== null) { + this.socket.setNoDelay(!!this.options.set_tcp_nodelay); + } + } + this.prependOnceListener("established", (info) => { + setImmediate(() => { + if (this.receiveBuffer.length > 0) { + const excessData = this.receiveBuffer.get(this.receiveBuffer.length); + info.socket.emit("data", excessData); + } + info.socket.resume(); + }); + }); + } + getSocketOptions() { + return Object.assign(Object.assign({}, this.options.socket_options), { host: this.options.proxy.host || this.options.proxy.ipaddress, port: this.options.proxy.port }); + } + onEstablishedTimeout() { + if (this.state !== constants_1.SocksClientState.Established && this.state !== constants_1.SocksClientState.BoundWaitingForConnection) { + this.closeSocket(constants_1.ERRORS.ProxyConnectionTimedOut); + } + } + onConnectHandler() { + this.setState(constants_1.SocksClientState.Connected); + if (this.options.proxy.type === 4) { + this.sendSocks4InitialHandshake(); + } else { + this.sendSocks5InitialHandshake(); + } + this.setState(constants_1.SocksClientState.SentInitialHandshake); + } + onDataReceivedHandler(data) { + this.receiveBuffer.append(data); + this.processData(); + } + processData() { + while (this.state !== constants_1.SocksClientState.Established && this.state !== constants_1.SocksClientState.Error && this.receiveBuffer.length >= this.nextRequiredPacketBufferSize) { + if (this.state === constants_1.SocksClientState.SentInitialHandshake) { + if (this.options.proxy.type === 4) { + this.handleSocks4FinalHandshakeResponse(); + } else { + this.handleInitialSocks5HandshakeResponse(); + } + } else if (this.state === constants_1.SocksClientState.SentAuthentication) { + this.handleInitialSocks5AuthenticationHandshakeResponse(); + } else if (this.state === constants_1.SocksClientState.SentFinalHandshake) { + this.handleSocks5FinalHandshakeResponse(); + } else if (this.state === constants_1.SocksClientState.BoundWaitingForConnection) { + if (this.options.proxy.type === 4) { + this.handleSocks4IncomingConnectionResponse(); + } else { + this.handleSocks5IncomingConnectionResponse(); + } + } else { + this.closeSocket(constants_1.ERRORS.InternalError); + break; + } + } + } + onCloseHandler() { + this.closeSocket(constants_1.ERRORS.SocketClosed); + } + onErrorHandler(err) { + this.closeSocket(err.message); + } + removeInternalSocketHandlers() { + this.socket.pause(); + this.socket.removeListener("data", this.onDataReceived); + this.socket.removeListener("close", this.onClose); + this.socket.removeListener("error", this.onError); + this.socket.removeListener("connect", this.onConnect); + } + closeSocket(err) { + if (this.state !== constants_1.SocksClientState.Error) { + this.setState(constants_1.SocksClientState.Error); + this.socket.destroy(); + this.removeInternalSocketHandlers(); + this.emit("error", new util_1.SocksClientError(err, this.options)); + } + } + sendSocks4InitialHandshake() { + const userId = this.options.proxy.userId || ""; + const buff = new smart_buffer_1.SmartBuffer; + buff.writeUInt8(4); + buff.writeUInt8(constants_1.SocksCommand[this.options.command]); + buff.writeUInt16BE(this.options.destination.port); + if (net.isIPv4(this.options.destination.host)) { + buff.writeBuffer((0, helpers_1.ipToBuffer)(this.options.destination.host)); + buff.writeStringNT(userId); + } else { + buff.writeUInt8(0); + buff.writeUInt8(0); + buff.writeUInt8(0); + buff.writeUInt8(1); + buff.writeStringNT(userId); + buff.writeStringNT(this.options.destination.host); + } + this.nextRequiredPacketBufferSize = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks4Response; + this.socket.write(buff.toBuffer()); + } + handleSocks4FinalHandshakeResponse() { + const data = this.receiveBuffer.get(8); + if (data[1] !== constants_1.Socks4Response.Granted) { + this.closeSocket(`${constants_1.ERRORS.Socks4ProxyRejectedConnection} - (${constants_1.Socks4Response[data[1]]})`); + } else { + if (constants_1.SocksCommand[this.options.command] === constants_1.SocksCommand.bind) { + const buff = smart_buffer_1.SmartBuffer.fromBuffer(data); + buff.readOffset = 2; + const remoteHost = { + port: buff.readUInt16BE(), + host: (0, helpers_1.int32ToIpv4)(buff.readUInt32BE()) + }; + if (remoteHost.host === "0.0.0.0") { + remoteHost.host = this.options.proxy.ipaddress; + } + this.setState(constants_1.SocksClientState.BoundWaitingForConnection); + this.emit("bound", { remoteHost, socket: this.socket }); + } else { + this.setState(constants_1.SocksClientState.Established); + this.removeInternalSocketHandlers(); + this.emit("established", { socket: this.socket }); + } + } + } + handleSocks4IncomingConnectionResponse() { + const data = this.receiveBuffer.get(8); + if (data[1] !== constants_1.Socks4Response.Granted) { + this.closeSocket(`${constants_1.ERRORS.Socks4ProxyRejectedIncomingBoundConnection} - (${constants_1.Socks4Response[data[1]]})`); + } else { + const buff = smart_buffer_1.SmartBuffer.fromBuffer(data); + buff.readOffset = 2; + const remoteHost = { + port: buff.readUInt16BE(), + host: (0, helpers_1.int32ToIpv4)(buff.readUInt32BE()) + }; + this.setState(constants_1.SocksClientState.Established); + this.removeInternalSocketHandlers(); + this.emit("established", { remoteHost, socket: this.socket }); + } + } + sendSocks5InitialHandshake() { + const buff = new smart_buffer_1.SmartBuffer; + const supportedAuthMethods = [constants_1.Socks5Auth.NoAuth]; + if (this.options.proxy.userId || this.options.proxy.password) { + supportedAuthMethods.push(constants_1.Socks5Auth.UserPass); + } + if (this.options.proxy.custom_auth_method !== undefined) { + supportedAuthMethods.push(this.options.proxy.custom_auth_method); + } + buff.writeUInt8(5); + buff.writeUInt8(supportedAuthMethods.length); + for (const authMethod of supportedAuthMethods) { + buff.writeUInt8(authMethod); + } + this.nextRequiredPacketBufferSize = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks5InitialHandshakeResponse; + this.socket.write(buff.toBuffer()); + this.setState(constants_1.SocksClientState.SentInitialHandshake); + } + handleInitialSocks5HandshakeResponse() { + const data = this.receiveBuffer.get(2); + if (data[0] !== 5) { + this.closeSocket(constants_1.ERRORS.InvalidSocks5IntiailHandshakeSocksVersion); + } else if (data[1] === constants_1.SOCKS5_NO_ACCEPTABLE_AUTH) { + this.closeSocket(constants_1.ERRORS.InvalidSocks5InitialHandshakeNoAcceptedAuthType); + } else { + if (data[1] === constants_1.Socks5Auth.NoAuth) { + this.socks5ChosenAuthType = constants_1.Socks5Auth.NoAuth; + this.sendSocks5CommandRequest(); + } else if (data[1] === constants_1.Socks5Auth.UserPass) { + this.socks5ChosenAuthType = constants_1.Socks5Auth.UserPass; + this.sendSocks5UserPassAuthentication(); + } else if (data[1] === this.options.proxy.custom_auth_method) { + this.socks5ChosenAuthType = this.options.proxy.custom_auth_method; + this.sendSocks5CustomAuthentication(); + } else { + this.closeSocket(constants_1.ERRORS.InvalidSocks5InitialHandshakeUnknownAuthType); + } + } + } + sendSocks5UserPassAuthentication() { + const userId = this.options.proxy.userId || ""; + const password = this.options.proxy.password || ""; + const buff = new smart_buffer_1.SmartBuffer; + buff.writeUInt8(1); + buff.writeUInt8(Buffer.byteLength(userId)); + buff.writeString(userId); + buff.writeUInt8(Buffer.byteLength(password)); + buff.writeString(password); + this.nextRequiredPacketBufferSize = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks5UserPassAuthenticationResponse; + this.socket.write(buff.toBuffer()); + this.setState(constants_1.SocksClientState.SentAuthentication); + } + sendSocks5CustomAuthentication() { + return __awaiter(this, undefined, undefined, function* () { + this.nextRequiredPacketBufferSize = this.options.proxy.custom_auth_response_size; + this.socket.write(yield this.options.proxy.custom_auth_request_handler()); + this.setState(constants_1.SocksClientState.SentAuthentication); + }); + } + handleSocks5CustomAuthHandshakeResponse(data) { + return __awaiter(this, undefined, undefined, function* () { + return yield this.options.proxy.custom_auth_response_handler(data); + }); + } + handleSocks5AuthenticationNoAuthHandshakeResponse(data) { + return __awaiter(this, undefined, undefined, function* () { + return data[1] === 0; + }); + } + handleSocks5AuthenticationUserPassHandshakeResponse(data) { + return __awaiter(this, undefined, undefined, function* () { + return data[1] === 0; + }); + } + handleInitialSocks5AuthenticationHandshakeResponse() { + return __awaiter(this, undefined, undefined, function* () { + this.setState(constants_1.SocksClientState.ReceivedAuthenticationResponse); + let authResult = false; + if (this.socks5ChosenAuthType === constants_1.Socks5Auth.NoAuth) { + authResult = yield this.handleSocks5AuthenticationNoAuthHandshakeResponse(this.receiveBuffer.get(2)); + } else if (this.socks5ChosenAuthType === constants_1.Socks5Auth.UserPass) { + authResult = yield this.handleSocks5AuthenticationUserPassHandshakeResponse(this.receiveBuffer.get(2)); + } else if (this.socks5ChosenAuthType === this.options.proxy.custom_auth_method) { + authResult = yield this.handleSocks5CustomAuthHandshakeResponse(this.receiveBuffer.get(this.options.proxy.custom_auth_response_size)); + } + if (!authResult) { + this.closeSocket(constants_1.ERRORS.Socks5AuthenticationFailed); + } else { + this.sendSocks5CommandRequest(); + } + }); + } + sendSocks5CommandRequest() { + const buff = new smart_buffer_1.SmartBuffer; + buff.writeUInt8(5); + buff.writeUInt8(constants_1.SocksCommand[this.options.command]); + buff.writeUInt8(0); + if (net.isIPv4(this.options.destination.host)) { + buff.writeUInt8(constants_1.Socks5HostType.IPv4); + buff.writeBuffer((0, helpers_1.ipToBuffer)(this.options.destination.host)); + } else if (net.isIPv6(this.options.destination.host)) { + buff.writeUInt8(constants_1.Socks5HostType.IPv6); + buff.writeBuffer((0, helpers_1.ipToBuffer)(this.options.destination.host)); + } else { + buff.writeUInt8(constants_1.Socks5HostType.Hostname); + buff.writeUInt8(this.options.destination.host.length); + buff.writeString(this.options.destination.host); + } + buff.writeUInt16BE(this.options.destination.port); + this.nextRequiredPacketBufferSize = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks5ResponseHeader; + this.socket.write(buff.toBuffer()); + this.setState(constants_1.SocksClientState.SentFinalHandshake); + } + handleSocks5FinalHandshakeResponse() { + const header = this.receiveBuffer.peek(5); + if (header[0] !== 5 || header[1] !== constants_1.Socks5Response.Granted) { + this.closeSocket(`${constants_1.ERRORS.InvalidSocks5FinalHandshakeRejected} - ${constants_1.Socks5Response[header[1]]}`); + } else { + const addressType = header[3]; + let remoteHost; + let buff; + if (addressType === constants_1.Socks5HostType.IPv4) { + const dataNeeded = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks5ResponseIPv4; + if (this.receiveBuffer.length < dataNeeded) { + this.nextRequiredPacketBufferSize = dataNeeded; + return; + } + buff = smart_buffer_1.SmartBuffer.fromBuffer(this.receiveBuffer.get(dataNeeded).slice(4)); + remoteHost = { + host: (0, helpers_1.int32ToIpv4)(buff.readUInt32BE()), + port: buff.readUInt16BE() + }; + if (remoteHost.host === "0.0.0.0") { + remoteHost.host = this.options.proxy.ipaddress; + } + } else if (addressType === constants_1.Socks5HostType.Hostname) { + const hostLength = header[4]; + const dataNeeded = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks5ResponseHostname(hostLength); + if (this.receiveBuffer.length < dataNeeded) { + this.nextRequiredPacketBufferSize = dataNeeded; + return; + } + buff = smart_buffer_1.SmartBuffer.fromBuffer(this.receiveBuffer.get(dataNeeded).slice(5)); + remoteHost = { + host: buff.readString(hostLength), + port: buff.readUInt16BE() + }; + } else if (addressType === constants_1.Socks5HostType.IPv6) { + const dataNeeded = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks5ResponseIPv6; + if (this.receiveBuffer.length < dataNeeded) { + this.nextRequiredPacketBufferSize = dataNeeded; + return; + } + buff = smart_buffer_1.SmartBuffer.fromBuffer(this.receiveBuffer.get(dataNeeded).slice(4)); + remoteHost = { + host: ip_address_1.Address6.fromByteArray(Array.from(buff.readBuffer(16))).canonicalForm(), + port: buff.readUInt16BE() + }; + } + this.setState(constants_1.SocksClientState.ReceivedFinalResponse); + if (constants_1.SocksCommand[this.options.command] === constants_1.SocksCommand.connect) { + this.setState(constants_1.SocksClientState.Established); + this.removeInternalSocketHandlers(); + this.emit("established", { remoteHost, socket: this.socket }); + } else if (constants_1.SocksCommand[this.options.command] === constants_1.SocksCommand.bind) { + this.setState(constants_1.SocksClientState.BoundWaitingForConnection); + this.nextRequiredPacketBufferSize = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks5ResponseHeader; + this.emit("bound", { remoteHost, socket: this.socket }); + } else if (constants_1.SocksCommand[this.options.command] === constants_1.SocksCommand.associate) { + this.setState(constants_1.SocksClientState.Established); + this.removeInternalSocketHandlers(); + this.emit("established", { + remoteHost, + socket: this.socket + }); + } + } + } + handleSocks5IncomingConnectionResponse() { + const header = this.receiveBuffer.peek(5); + if (header[0] !== 5 || header[1] !== constants_1.Socks5Response.Granted) { + this.closeSocket(`${constants_1.ERRORS.Socks5ProxyRejectedIncomingBoundConnection} - ${constants_1.Socks5Response[header[1]]}`); + } else { + const addressType = header[3]; + let remoteHost; + let buff; + if (addressType === constants_1.Socks5HostType.IPv4) { + const dataNeeded = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks5ResponseIPv4; + if (this.receiveBuffer.length < dataNeeded) { + this.nextRequiredPacketBufferSize = dataNeeded; + return; + } + buff = smart_buffer_1.SmartBuffer.fromBuffer(this.receiveBuffer.get(dataNeeded).slice(4)); + remoteHost = { + host: (0, helpers_1.int32ToIpv4)(buff.readUInt32BE()), + port: buff.readUInt16BE() + }; + if (remoteHost.host === "0.0.0.0") { + remoteHost.host = this.options.proxy.ipaddress; + } + } else if (addressType === constants_1.Socks5HostType.Hostname) { + const hostLength = header[4]; + const dataNeeded = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks5ResponseHostname(hostLength); + if (this.receiveBuffer.length < dataNeeded) { + this.nextRequiredPacketBufferSize = dataNeeded; + return; + } + buff = smart_buffer_1.SmartBuffer.fromBuffer(this.receiveBuffer.get(dataNeeded).slice(5)); + remoteHost = { + host: buff.readString(hostLength), + port: buff.readUInt16BE() + }; + } else if (addressType === constants_1.Socks5HostType.IPv6) { + const dataNeeded = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks5ResponseIPv6; + if (this.receiveBuffer.length < dataNeeded) { + this.nextRequiredPacketBufferSize = dataNeeded; + return; + } + buff = smart_buffer_1.SmartBuffer.fromBuffer(this.receiveBuffer.get(dataNeeded).slice(4)); + remoteHost = { + host: ip_address_1.Address6.fromByteArray(Array.from(buff.readBuffer(16))).canonicalForm(), + port: buff.readUInt16BE() + }; + } + this.setState(constants_1.SocksClientState.Established); + this.removeInternalSocketHandlers(); + this.emit("established", { remoteHost, socket: this.socket }); + } + } + get socksClientOptions() { + return Object.assign({}, this.options); + } + } + exports.SocksClient = SocksClient; +}); + +// ../../node_modules/.pnpm/socks@2.8.7/node_modules/socks/build/index.js +var require_build = __commonJS((exports) => { + var __createBinding = exports && exports.__createBinding || (Object.create ? function(o, m, k, k2) { + if (k2 === undefined) + k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { + return m[k]; + } }; + } + Object.defineProperty(o, k2, desc); + } : function(o, m, k, k2) { + if (k2 === undefined) + k2 = k; + o[k2] = m[k]; + }); + var __exportStar = exports && exports.__exportStar || function(m, exports2) { + for (var p in m) + if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports2, p)) + __createBinding(exports2, m, p); + }; + Object.defineProperty(exports, "__esModule", { value: true }); + __exportStar(require_socksclient(), exports); +}); + +// ../../node_modules/.pnpm/socks-proxy-agent@8.0.5/node_modules/socks-proxy-agent/dist/index.js +var require_dist5 = __commonJS((exports) => { + var __createBinding = exports && exports.__createBinding || (Object.create ? function(o, m, k, k2) { + if (k2 === undefined) + k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { + return m[k]; + } }; + } + Object.defineProperty(o, k2, desc); + } : function(o, m, k, k2) { + if (k2 === undefined) + k2 = k; + o[k2] = m[k]; + }); + var __setModuleDefault = exports && exports.__setModuleDefault || (Object.create ? function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); + } : function(o, v) { + o["default"] = v; + }); + var __importStar = exports && exports.__importStar || function(mod) { + if (mod && mod.__esModule) + return mod; + var result = {}; + if (mod != null) { + for (var k in mod) + if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) + __createBinding(result, mod, k); + } + __setModuleDefault(result, mod); + return result; + }; + var __importDefault = exports && exports.__importDefault || function(mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.SocksProxyAgent = undefined; + var socks_1 = require_build(); + var agent_base_1 = require_dist2(); + var debug_1 = __importDefault(require_src2()); + var dns = __importStar(__require("dns")); + var net = __importStar(__require("net")); + var tls = __importStar(__require("tls")); + var url_1 = __require("url"); + var debug = (0, debug_1.default)("socks-proxy-agent"); + var setServernameFromNonIpHost = (options) => { + if (options.servername === undefined && options.host && !net.isIP(options.host)) { + return { + ...options, + servername: options.host + }; + } + return options; + }; + function parseSocksURL(url2) { + let lookup = false; + let type = 5; + const host = url2.hostname; + const port = parseInt(url2.port, 10) || 1080; + switch (url2.protocol.replace(":", "")) { + case "socks4": + lookup = true; + type = 4; + break; + case "socks4a": + type = 4; + break; + case "socks5": + lookup = true; + type = 5; + break; + case "socks": + type = 5; + break; + case "socks5h": + type = 5; + break; + default: + throw new TypeError(`A "socks" protocol must be specified! Got: ${String(url2.protocol)}`); + } + const proxy = { + host, + port, + type + }; + if (url2.username) { + Object.defineProperty(proxy, "userId", { + value: decodeURIComponent(url2.username), + enumerable: false + }); + } + if (url2.password != null) { + Object.defineProperty(proxy, "password", { + value: decodeURIComponent(url2.password), + enumerable: false + }); + } + return { lookup, proxy }; + } + + class SocksProxyAgent extends agent_base_1.Agent { + constructor(uri, opts) { + super(opts); + const url2 = typeof uri === "string" ? new url_1.URL(uri) : uri; + const { proxy, lookup } = parseSocksURL(url2); + this.shouldLookup = lookup; + this.proxy = proxy; + this.timeout = opts?.timeout ?? null; + this.socketOptions = opts?.socketOptions ?? null; + } + async connect(req, opts) { + const { shouldLookup, proxy, timeout: timeout2 } = this; + if (!opts.host) { + throw new Error("No `host` defined!"); + } + let { host } = opts; + const { port, lookup: lookupFn = dns.lookup } = opts; + if (shouldLookup) { + host = await new Promise((resolve2, reject) => { + lookupFn(host, {}, (err, res) => { + if (err) { + reject(err); + } else { + resolve2(res); + } + }); + }); + } + const socksOpts = { + proxy, + destination: { + host, + port: typeof port === "number" ? port : parseInt(port, 10) + }, + command: "connect", + timeout: timeout2 ?? undefined, + socket_options: this.socketOptions ?? undefined + }; + const cleanup = (tlsSocket) => { + req.destroy(); + socket.destroy(); + if (tlsSocket) + tlsSocket.destroy(); + }; + debug("Creating socks proxy connection: %o", socksOpts); + const { socket } = await socks_1.SocksClient.createConnection(socksOpts); + debug("Successfully created socks proxy connection"); + if (timeout2 !== null) { + socket.setTimeout(timeout2); + socket.on("timeout", () => cleanup()); + } + if (opts.secureEndpoint) { + debug("Upgrading socket connection to TLS"); + const tlsSocket = tls.connect({ + ...omit3(setServernameFromNonIpHost(opts), "host", "path", "port"), + socket + }); + tlsSocket.once("error", (error48) => { + debug("Socket TLS error", error48.message); + cleanup(tlsSocket); + }); + return tlsSocket; + } + return socket; + } + } + SocksProxyAgent.protocols = [ + "socks", + "socks4", + "socks4a", + "socks5", + "socks5h" + ]; + exports.SocksProxyAgent = SocksProxyAgent; + function omit3(obj, ...keys) { + const ret = {}; + let key; + for (key in obj) { + if (!keys.includes(key)) { + ret[key] = obj[key]; + } + } + return ret; + } +}); + +// ../../node_modules/.pnpm/negotiator@0.6.3/node_modules/negotiator/lib/charset.js +var require_charset = __commonJS((exports, module) => { + module.exports = preferredCharsets; + module.exports.preferredCharsets = preferredCharsets; + var simpleCharsetRegExp = /^\s*([^\s;]+)\s*(?:;(.*))?$/; + function parseAcceptCharset(accept) { + var accepts = accept.split(","); + for (var i = 0, j = 0;i < accepts.length; i++) { + var charset = parseCharset(accepts[i].trim(), i); + if (charset) { + accepts[j++] = charset; + } + } + accepts.length = j; + return accepts; + } + function parseCharset(str, i) { + var match = simpleCharsetRegExp.exec(str); + if (!match) + return null; + var charset = match[1]; + var q = 1; + if (match[2]) { + var params = match[2].split(";"); + for (var j = 0;j < params.length; j++) { + var p = params[j].trim().split("="); + if (p[0] === "q") { + q = parseFloat(p[1]); + break; + } + } + } + return { + charset, + q, + i + }; + } + function getCharsetPriority(charset, accepted, index) { + var priority = { o: -1, q: 0, s: 0 }; + for (var i = 0;i < accepted.length; i++) { + var spec = specify(charset, accepted[i], index); + if (spec && (priority.s - spec.s || priority.q - spec.q || priority.o - spec.o) < 0) { + priority = spec; + } + } + return priority; + } + function specify(charset, spec, index) { + var s = 0; + if (spec.charset.toLowerCase() === charset.toLowerCase()) { + s |= 1; + } else if (spec.charset !== "*") { + return null; + } + return { + i: index, + o: spec.i, + q: spec.q, + s + }; + } + function preferredCharsets(accept, provided) { + var accepts = parseAcceptCharset(accept === undefined ? "*" : accept || ""); + if (!provided) { + return accepts.filter(isQuality).sort(compareSpecs).map(getFullCharset); + } + var priorities = provided.map(function getPriority(type, index) { + return getCharsetPriority(type, accepts, index); + }); + return priorities.filter(isQuality).sort(compareSpecs).map(function getCharset(priority) { + return provided[priorities.indexOf(priority)]; + }); + } + function compareSpecs(a, b) { + return b.q - a.q || b.s - a.s || a.o - b.o || a.i - b.i || 0; + } + function getFullCharset(spec) { + return spec.charset; + } + function isQuality(spec) { + return spec.q > 0; + } +}); + +// ../../node_modules/.pnpm/negotiator@0.6.3/node_modules/negotiator/lib/encoding.js +var require_encoding = __commonJS((exports, module) => { + module.exports = preferredEncodings; + module.exports.preferredEncodings = preferredEncodings; + var simpleEncodingRegExp = /^\s*([^\s;]+)\s*(?:;(.*))?$/; + function parseAcceptEncoding(accept) { + var accepts = accept.split(","); + var hasIdentity = false; + var minQuality = 1; + for (var i = 0, j = 0;i < accepts.length; i++) { + var encoding = parseEncoding(accepts[i].trim(), i); + if (encoding) { + accepts[j++] = encoding; + hasIdentity = hasIdentity || specify("identity", encoding); + minQuality = Math.min(minQuality, encoding.q || 1); + } + } + if (!hasIdentity) { + accepts[j++] = { + encoding: "identity", + q: minQuality, + i + }; + } + accepts.length = j; + return accepts; + } + function parseEncoding(str, i) { + var match = simpleEncodingRegExp.exec(str); + if (!match) + return null; + var encoding = match[1]; + var q = 1; + if (match[2]) { + var params = match[2].split(";"); + for (var j = 0;j < params.length; j++) { + var p = params[j].trim().split("="); + if (p[0] === "q") { + q = parseFloat(p[1]); + break; + } + } + } + return { + encoding, + q, + i + }; + } + function getEncodingPriority(encoding, accepted, index) { + var priority = { o: -1, q: 0, s: 0 }; + for (var i = 0;i < accepted.length; i++) { + var spec = specify(encoding, accepted[i], index); + if (spec && (priority.s - spec.s || priority.q - spec.q || priority.o - spec.o) < 0) { + priority = spec; + } + } + return priority; + } + function specify(encoding, spec, index) { + var s = 0; + if (spec.encoding.toLowerCase() === encoding.toLowerCase()) { + s |= 1; + } else if (spec.encoding !== "*") { + return null; + } + return { + i: index, + o: spec.i, + q: spec.q, + s + }; + } + function preferredEncodings(accept, provided) { + var accepts = parseAcceptEncoding(accept || ""); + if (!provided) { + return accepts.filter(isQuality).sort(compareSpecs).map(getFullEncoding); + } + var priorities = provided.map(function getPriority(type, index) { + return getEncodingPriority(type, accepts, index); + }); + return priorities.filter(isQuality).sort(compareSpecs).map(function getEncoding(priority) { + return provided[priorities.indexOf(priority)]; + }); + } + function compareSpecs(a, b) { + return b.q - a.q || b.s - a.s || a.o - b.o || a.i - b.i || 0; + } + function getFullEncoding(spec) { + return spec.encoding; + } + function isQuality(spec) { + return spec.q > 0; + } +}); + +// ../../node_modules/.pnpm/negotiator@0.6.3/node_modules/negotiator/lib/language.js +var require_language = __commonJS((exports, module) => { + module.exports = preferredLanguages; + module.exports.preferredLanguages = preferredLanguages; + var simpleLanguageRegExp = /^\s*([^\s\-;]+)(?:-([^\s;]+))?\s*(?:;(.*))?$/; + function parseAcceptLanguage(accept) { + var accepts = accept.split(","); + for (var i = 0, j = 0;i < accepts.length; i++) { + var language = parseLanguage(accepts[i].trim(), i); + if (language) { + accepts[j++] = language; + } + } + accepts.length = j; + return accepts; + } + function parseLanguage(str, i) { + var match = simpleLanguageRegExp.exec(str); + if (!match) + return null; + var prefix = match[1]; + var suffix = match[2]; + var full = prefix; + if (suffix) + full += "-" + suffix; + var q = 1; + if (match[3]) { + var params = match[3].split(";"); + for (var j = 0;j < params.length; j++) { + var p = params[j].split("="); + if (p[0] === "q") + q = parseFloat(p[1]); + } + } + return { + prefix, + suffix, + q, + i, + full + }; + } + function getLanguagePriority(language, accepted, index) { + var priority = { o: -1, q: 0, s: 0 }; + for (var i = 0;i < accepted.length; i++) { + var spec = specify(language, accepted[i], index); + if (spec && (priority.s - spec.s || priority.q - spec.q || priority.o - spec.o) < 0) { + priority = spec; + } + } + return priority; + } + function specify(language, spec, index) { + var p = parseLanguage(language); + if (!p) + return null; + var s = 0; + if (spec.full.toLowerCase() === p.full.toLowerCase()) { + s |= 4; + } else if (spec.prefix.toLowerCase() === p.full.toLowerCase()) { + s |= 2; + } else if (spec.full.toLowerCase() === p.prefix.toLowerCase()) { + s |= 1; + } else if (spec.full !== "*") { + return null; + } + return { + i: index, + o: spec.i, + q: spec.q, + s + }; + } + function preferredLanguages(accept, provided) { + var accepts = parseAcceptLanguage(accept === undefined ? "*" : accept || ""); + if (!provided) { + return accepts.filter(isQuality).sort(compareSpecs).map(getFullLanguage); + } + var priorities = provided.map(function getPriority(type, index) { + return getLanguagePriority(type, accepts, index); + }); + return priorities.filter(isQuality).sort(compareSpecs).map(function getLanguage(priority) { + return provided[priorities.indexOf(priority)]; + }); + } + function compareSpecs(a, b) { + return b.q - a.q || b.s - a.s || a.o - b.o || a.i - b.i || 0; + } + function getFullLanguage(spec) { + return spec.full; + } + function isQuality(spec) { + return spec.q > 0; + } +}); + +// ../../node_modules/.pnpm/negotiator@0.6.3/node_modules/negotiator/lib/mediaType.js +var require_mediaType = __commonJS((exports, module) => { + module.exports = preferredMediaTypes; + module.exports.preferredMediaTypes = preferredMediaTypes; + var simpleMediaTypeRegExp = /^\s*([^\s\/;]+)\/([^;\s]+)\s*(?:;(.*))?$/; + function parseAccept(accept) { + var accepts = splitMediaTypes(accept); + for (var i = 0, j = 0;i < accepts.length; i++) { + var mediaType = parseMediaType(accepts[i].trim(), i); + if (mediaType) { + accepts[j++] = mediaType; + } + } + accepts.length = j; + return accepts; + } + function parseMediaType(str, i) { + var match = simpleMediaTypeRegExp.exec(str); + if (!match) + return null; + var params = Object.create(null); + var q = 1; + var subtype = match[2]; + var type = match[1]; + if (match[3]) { + var kvps = splitParameters(match[3]).map(splitKeyValuePair); + for (var j = 0;j < kvps.length; j++) { + var pair = kvps[j]; + var key = pair[0].toLowerCase(); + var val = pair[1]; + var value = val && val[0] === '"' && val[val.length - 1] === '"' ? val.substr(1, val.length - 2) : val; + if (key === "q") { + q = parseFloat(value); + break; + } + params[key] = value; + } + } + return { + type, + subtype, + params, + q, + i + }; + } + function getMediaTypePriority(type, accepted, index) { + var priority = { o: -1, q: 0, s: 0 }; + for (var i = 0;i < accepted.length; i++) { + var spec = specify(type, accepted[i], index); + if (spec && (priority.s - spec.s || priority.q - spec.q || priority.o - spec.o) < 0) { + priority = spec; + } + } + return priority; + } + function specify(type, spec, index) { + var p = parseMediaType(type); + var s = 0; + if (!p) { + return null; + } + if (spec.type.toLowerCase() == p.type.toLowerCase()) { + s |= 4; + } else if (spec.type != "*") { + return null; + } + if (spec.subtype.toLowerCase() == p.subtype.toLowerCase()) { + s |= 2; + } else if (spec.subtype != "*") { + return null; + } + var keys = Object.keys(spec.params); + if (keys.length > 0) { + if (keys.every(function(k) { + return spec.params[k] == "*" || (spec.params[k] || "").toLowerCase() == (p.params[k] || "").toLowerCase(); + })) { + s |= 1; + } else { + return null; + } + } + return { + i: index, + o: spec.i, + q: spec.q, + s + }; + } + function preferredMediaTypes(accept, provided) { + var accepts = parseAccept(accept === undefined ? "*/*" : accept || ""); + if (!provided) { + return accepts.filter(isQuality).sort(compareSpecs).map(getFullType); + } + var priorities = provided.map(function getPriority(type, index) { + return getMediaTypePriority(type, accepts, index); + }); + return priorities.filter(isQuality).sort(compareSpecs).map(function getType(priority) { + return provided[priorities.indexOf(priority)]; + }); + } + function compareSpecs(a, b) { + return b.q - a.q || b.s - a.s || a.o - b.o || a.i - b.i || 0; + } + function getFullType(spec) { + return spec.type + "/" + spec.subtype; + } + function isQuality(spec) { + return spec.q > 0; + } + function quoteCount(string4) { + var count = 0; + var index = 0; + while ((index = string4.indexOf('"', index)) !== -1) { + count++; + index++; + } + return count; + } + function splitKeyValuePair(str) { + var index = str.indexOf("="); + var key; + var val; + if (index === -1) { + key = str; + } else { + key = str.substr(0, index); + val = str.substr(index + 1); + } + return [key, val]; + } + function splitMediaTypes(accept) { + var accepts = accept.split(","); + for (var i = 1, j = 0;i < accepts.length; i++) { + if (quoteCount(accepts[j]) % 2 == 0) { + accepts[++j] = accepts[i]; + } else { + accepts[j] += "," + accepts[i]; + } + } + accepts.length = j + 1; + return accepts; + } + function splitParameters(str) { + var parameters = str.split(";"); + for (var i = 1, j = 0;i < parameters.length; i++) { + if (quoteCount(parameters[j]) % 2 == 0) { + parameters[++j] = parameters[i]; + } else { + parameters[j] += ";" + parameters[i]; + } + } + parameters.length = j + 1; + for (var i = 0;i < parameters.length; i++) { + parameters[i] = parameters[i].trim(); + } + return parameters; + } +}); + +// ../../node_modules/.pnpm/negotiator@0.6.3/node_modules/negotiator/index.js +var require_negotiator = __commonJS((exports, module) => { + /*! + * negotiator + * Copyright(c) 2012 Federico Romero + * Copyright(c) 2012-2014 Isaac Z. Schlueter + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + var preferredCharsets = require_charset(); + var preferredEncodings = require_encoding(); + var preferredLanguages = require_language(); + var preferredMediaTypes = require_mediaType(); + module.exports = Negotiator; + module.exports.Negotiator = Negotiator; + function Negotiator(request) { + if (!(this instanceof Negotiator)) { + return new Negotiator(request); + } + this.request = request; + } + Negotiator.prototype.charset = function charset(available) { + var set2 = this.charsets(available); + return set2 && set2[0]; + }; + Negotiator.prototype.charsets = function charsets(available) { + return preferredCharsets(this.request.headers["accept-charset"], available); + }; + Negotiator.prototype.encoding = function encoding(available) { + var set2 = this.encodings(available); + return set2 && set2[0]; + }; + Negotiator.prototype.encodings = function encodings(available) { + return preferredEncodings(this.request.headers["accept-encoding"], available); + }; + Negotiator.prototype.language = function language(available) { + var set2 = this.languages(available); + return set2 && set2[0]; + }; + Negotiator.prototype.languages = function languages(available) { + return preferredLanguages(this.request.headers["accept-language"], available); + }; + Negotiator.prototype.mediaType = function mediaType(available) { + var set2 = this.mediaTypes(available); + return set2 && set2[0]; + }; + Negotiator.prototype.mediaTypes = function mediaTypes(available) { + return preferredMediaTypes(this.request.headers.accept, available); + }; + Negotiator.prototype.preferredCharset = Negotiator.prototype.charset; + Negotiator.prototype.preferredCharsets = Negotiator.prototype.charsets; + Negotiator.prototype.preferredEncoding = Negotiator.prototype.encoding; + Negotiator.prototype.preferredEncodings = Negotiator.prototype.encodings; + Negotiator.prototype.preferredLanguage = Negotiator.prototype.language; + Negotiator.prototype.preferredLanguages = Negotiator.prototype.languages; + Negotiator.prototype.preferredMediaType = Negotiator.prototype.mediaType; + Negotiator.prototype.preferredMediaTypes = Negotiator.prototype.mediaTypes; +}); + +// ../../node_modules/.pnpm/mime-db@1.52.0/node_modules/mime-db/db.json +var require_db = __commonJS((exports, module) => { + module.exports = { + "application/1d-interleaved-parityfec": { + source: "iana" + }, + "application/3gpdash-qoe-report+xml": { + source: "iana", + charset: "UTF-8", + compressible: true + }, + "application/3gpp-ims+xml": { + source: "iana", + compressible: true + }, + "application/3gpphal+json": { + source: "iana", + compressible: true + }, + "application/3gpphalforms+json": { + source: "iana", + compressible: true + }, + "application/a2l": { + source: "iana" + }, + "application/ace+cbor": { + source: "iana" + }, + "application/activemessage": { + source: "iana" + }, + "application/activity+json": { + source: "iana", + compressible: true + }, + "application/alto-costmap+json": { + source: "iana", + compressible: true + }, + "application/alto-costmapfilter+json": { + source: "iana", + compressible: true + }, + "application/alto-directory+json": { + source: "iana", + compressible: true + }, + "application/alto-endpointcost+json": { + source: "iana", + compressible: true + }, + "application/alto-endpointcostparams+json": { + source: "iana", + compressible: true + }, + "application/alto-endpointprop+json": { + source: "iana", + compressible: true + }, + "application/alto-endpointpropparams+json": { + source: "iana", + compressible: true + }, + "application/alto-error+json": { + source: "iana", + compressible: true + }, + "application/alto-networkmap+json": { + source: "iana", + compressible: true + }, + "application/alto-networkmapfilter+json": { + source: "iana", compressible: true }, "application/alto-updatestreamcontrol+json": { @@ -11032,1750 +15107,1004 @@ var require_db = __commonJS((exports, module) => { "video/x-ms-wmv": { source: "apache", compressible: false, - extensions: ["wmv"] - }, - "video/x-ms-wmx": { - source: "apache", - extensions: ["wmx"] - }, - "video/x-ms-wvx": { - source: "apache", - extensions: ["wvx"] - }, - "video/x-msvideo": { - source: "apache", - extensions: ["avi"] - }, - "video/x-sgi-movie": { - source: "apache", - extensions: ["movie"] - }, - "video/x-smv": { - source: "apache", - extensions: ["smv"] - }, - "x-conference/x-cooltalk": { - source: "apache", - extensions: ["ice"] - }, - "x-shader/x-fragment": { - compressible: true - }, - "x-shader/x-vertex": { - compressible: true - } - }; -}); - -// ../../node_modules/.pnpm/mime-types@2.1.35/node_modules/mime-types/index.js -var require_mime_types = __commonJS((exports) => { - /*! - * mime-types - * Copyright(c) 2014 Jonathan Ong - * Copyright(c) 2015 Douglas Christopher Wilson - * MIT Licensed - */ - var db = require_db(); - var extname2 = __require("path").extname; - var EXTRACT_TYPE_REGEXP = /^\s*([^;\s]*)(?:;|\s|$)/; - var TEXT_TYPE_REGEXP = /^text\//i; - exports.charset = charset; - exports.charsets = { lookup: charset }; - exports.contentType = contentType; - exports.extension = extension; - exports.extensions = Object.create(null); - exports.lookup = lookup; - exports.types = Object.create(null); - populateMaps(exports.extensions, exports.types); - function charset(type) { - if (!type || typeof type !== "string") { - return false; - } - var match = EXTRACT_TYPE_REGEXP.exec(type); - var mime = match && db[match[1].toLowerCase()]; - if (mime && mime.charset) { - return mime.charset; - } - if (match && TEXT_TYPE_REGEXP.test(match[1])) { - return "UTF-8"; - } - return false; - } - function contentType(str) { - if (!str || typeof str !== "string") { - return false; - } - var mime = str.indexOf("/") === -1 ? exports.lookup(str) : str; - if (!mime) { - return false; - } - if (mime.indexOf("charset") === -1) { - var charset2 = exports.charset(mime); - if (charset2) - mime += "; charset=" + charset2.toLowerCase(); - } - return mime; - } - function extension(type) { - if (!type || typeof type !== "string") { - return false; - } - var match = EXTRACT_TYPE_REGEXP.exec(type); - var exts = match && exports.extensions[match[1].toLowerCase()]; - if (!exts || !exts.length) { - return false; - } - return exts[0]; - } - function lookup(path12) { - if (!path12 || typeof path12 !== "string") { - return false; - } - var extension2 = extname2("x." + path12).toLowerCase().substr(1); - if (!extension2) { - return false; - } - return exports.types[extension2] || false; - } - function populateMaps(extensions2, types2) { - var preference = ["nginx", "apache", undefined, "iana"]; - Object.keys(db).forEach(function forEachMimeType(type) { - var mime = db[type]; - var exts = mime.extensions; - if (!exts || !exts.length) { - return; - } - extensions2[type] = exts; - for (var i = 0;i < exts.length; i++) { - var extension2 = exts[i]; - if (types2[extension2]) { - var from = preference.indexOf(db[types2[extension2]].source); - var to = preference.indexOf(mime.source); - if (types2[extension2] !== "application/octet-stream" && (from > to || from === to && types2[extension2].substr(0, 12) === "application/")) { - continue; - } - } - types2[extension2] = type; - } - }); - } -}); - -// ../../node_modules/.pnpm/accepts@1.3.8/node_modules/accepts/index.js -var require_accepts = __commonJS((exports, module) => { - /*! - * accepts - * Copyright(c) 2014 Jonathan Ong - * Copyright(c) 2015 Douglas Christopher Wilson - * MIT Licensed - */ - var Negotiator = require_negotiator(); - var mime = require_mime_types(); - module.exports = Accepts; - function Accepts(req) { - if (!(this instanceof Accepts)) { - return new Accepts(req); - } - this.headers = req.headers; - this.negotiator = new Negotiator(req); - } - Accepts.prototype.type = Accepts.prototype.types = function(types_) { - var types2 = types_; - if (types2 && !Array.isArray(types2)) { - types2 = new Array(arguments.length); - for (var i = 0;i < types2.length; i++) { - types2[i] = arguments[i]; - } - } - if (!types2 || types2.length === 0) { - return this.negotiator.mediaTypes(); - } - if (!this.headers.accept) { - return types2[0]; - } - var mimes = types2.map(extToMime); - var accepts = this.negotiator.mediaTypes(mimes.filter(validMime)); - var first = accepts[0]; - return first ? types2[mimes.indexOf(first)] : false; - }; - Accepts.prototype.encoding = Accepts.prototype.encodings = function(encodings_) { - var encodings = encodings_; - if (encodings && !Array.isArray(encodings)) { - encodings = new Array(arguments.length); - for (var i = 0;i < encodings.length; i++) { - encodings[i] = arguments[i]; - } - } - if (!encodings || encodings.length === 0) { - return this.negotiator.encodings(); - } - return this.negotiator.encodings(encodings)[0] || false; - }; - Accepts.prototype.charset = Accepts.prototype.charsets = function(charsets_) { - var charsets = charsets_; - if (charsets && !Array.isArray(charsets)) { - charsets = new Array(arguments.length); - for (var i = 0;i < charsets.length; i++) { - charsets[i] = arguments[i]; - } - } - if (!charsets || charsets.length === 0) { - return this.negotiator.charsets(); - } - return this.negotiator.charsets(charsets)[0] || false; - }; - Accepts.prototype.lang = Accepts.prototype.langs = Accepts.prototype.language = Accepts.prototype.languages = function(languages_) { - var languages = languages_; - if (languages && !Array.isArray(languages)) { - languages = new Array(arguments.length); - for (var i = 0;i < languages.length; i++) { - languages[i] = arguments[i]; - } - } - if (!languages || languages.length === 0) { - return this.negotiator.languages(); - } - return this.negotiator.languages(languages)[0] || false; - }; - function extToMime(type) { - return type.indexOf("/") === -1 ? mime.lookup(type) : type; - } - function validMime(type) { - return typeof type === "string"; - } -}); - -// ../../node_modules/.pnpm/base64id@2.0.0/node_modules/base64id/lib/base64id.js -var require_base64id = __commonJS((exports, module) => { - /*! - * base64id v0.1.0 - */ - var crypto2 = __require("crypto"); - var Base64Id = function() {}; - Base64Id.prototype.getRandomBytes = function(bytes) { - var BUFFER_SIZE = 4096; - var self = this; - bytes = bytes || 12; - if (bytes > BUFFER_SIZE) { - return crypto2.randomBytes(bytes); - } - var bytesInBuffer = parseInt(BUFFER_SIZE / bytes); - var threshold = parseInt(bytesInBuffer * 0.85); - if (!threshold) { - return crypto2.randomBytes(bytes); - } - if (this.bytesBufferIndex == null) { - this.bytesBufferIndex = -1; - } - if (this.bytesBufferIndex == bytesInBuffer) { - this.bytesBuffer = null; - this.bytesBufferIndex = -1; - } - if (this.bytesBufferIndex == -1 || this.bytesBufferIndex > threshold) { - if (!this.isGeneratingBytes) { - this.isGeneratingBytes = true; - crypto2.randomBytes(BUFFER_SIZE, function(err, bytes2) { - self.bytesBuffer = bytes2; - self.bytesBufferIndex = 0; - self.isGeneratingBytes = false; - }); - } - if (this.bytesBufferIndex == -1) { - return crypto2.randomBytes(bytes); - } - } - var result = this.bytesBuffer.slice(bytes * this.bytesBufferIndex, bytes * (this.bytesBufferIndex + 1)); - this.bytesBufferIndex++; - return result; - }; - Base64Id.prototype.generateId = function() { - var rand = Buffer.alloc(15); - if (!rand.writeInt32BE) { - return Math.abs(Math.random() * Math.random() * Date.now() | 0).toString() + Math.abs(Math.random() * Math.random() * Date.now() | 0).toString(); - } - this.sequenceNumber = this.sequenceNumber + 1 | 0; - rand.writeInt32BE(this.sequenceNumber, 11); - if (crypto2.randomBytes) { - this.getRandomBytes(12).copy(rand); - } else { - [0, 4, 8].forEach(function(i) { - rand.writeInt32BE(Math.random() * Math.pow(2, 32) | 0, i); - }); - } - return rand.toString("base64").replace(/\//g, "_").replace(/\+/g, "-"); - }; - exports = module.exports = new Base64Id; -}); - -// ../../node_modules/.pnpm/engine.io-parser@5.2.3/node_modules/engine.io-parser/build/cjs/commons.js -var require_commons = __commonJS((exports) => { - Object.defineProperty(exports, "__esModule", { value: true }); - exports.ERROR_PACKET = exports.PACKET_TYPES_REVERSE = exports.PACKET_TYPES = undefined; - var PACKET_TYPES = Object.create(null); - exports.PACKET_TYPES = PACKET_TYPES; - PACKET_TYPES["open"] = "0"; - PACKET_TYPES["close"] = "1"; - PACKET_TYPES["ping"] = "2"; - PACKET_TYPES["pong"] = "3"; - PACKET_TYPES["message"] = "4"; - PACKET_TYPES["upgrade"] = "5"; - PACKET_TYPES["noop"] = "6"; - var PACKET_TYPES_REVERSE = Object.create(null); - exports.PACKET_TYPES_REVERSE = PACKET_TYPES_REVERSE; - Object.keys(PACKET_TYPES).forEach((key) => { - PACKET_TYPES_REVERSE[PACKET_TYPES[key]] = key; - }); - var ERROR_PACKET = { type: "error", data: "parser error" }; - exports.ERROR_PACKET = ERROR_PACKET; -}); - -// ../../node_modules/.pnpm/engine.io-parser@5.2.3/node_modules/engine.io-parser/build/cjs/encodePacket.js -var require_encodePacket = __commonJS((exports) => { - Object.defineProperty(exports, "__esModule", { value: true }); - exports.encodePacket = undefined; - exports.encodePacketToBinary = encodePacketToBinary; - var commons_js_1 = require_commons(); - var encodePacket = ({ type, data }, supportsBinary, callback) => { - if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { - return callback(supportsBinary ? data : "b" + toBuffer(data, true).toString("base64")); - } - return callback(commons_js_1.PACKET_TYPES[type] + (data || "")); - }; - exports.encodePacket = encodePacket; - var toBuffer = (data, forceBufferConversion) => { - if (Buffer.isBuffer(data) || data instanceof Uint8Array && !forceBufferConversion) { - return data; - } else if (data instanceof ArrayBuffer) { - return Buffer.from(data); - } else { - return Buffer.from(data.buffer, data.byteOffset, data.byteLength); - } - }; - var TEXT_ENCODER; - function encodePacketToBinary(packet, callback) { - if (packet.data instanceof ArrayBuffer || ArrayBuffer.isView(packet.data)) { - return callback(toBuffer(packet.data, false)); - } - (0, exports.encodePacket)(packet, true, (encoded) => { - if (!TEXT_ENCODER) { - TEXT_ENCODER = new TextEncoder; - } - callback(TEXT_ENCODER.encode(encoded)); - }); - } -}); - -// ../../node_modules/.pnpm/engine.io-parser@5.2.3/node_modules/engine.io-parser/build/cjs/decodePacket.js -var require_decodePacket = __commonJS((exports) => { - Object.defineProperty(exports, "__esModule", { value: true }); - exports.decodePacket = undefined; - var commons_js_1 = require_commons(); - var decodePacket = (encodedPacket, binaryType) => { - if (typeof encodedPacket !== "string") { - return { - type: "message", - data: mapBinary(encodedPacket, binaryType) - }; - } - const type = encodedPacket.charAt(0); - if (type === "b") { - const buffer = Buffer.from(encodedPacket.substring(1), "base64"); - return { - type: "message", - data: mapBinary(buffer, binaryType) - }; - } - if (!commons_js_1.PACKET_TYPES_REVERSE[type]) { - return commons_js_1.ERROR_PACKET; - } - return encodedPacket.length > 1 ? { - type: commons_js_1.PACKET_TYPES_REVERSE[type], - data: encodedPacket.substring(1) - } : { - type: commons_js_1.PACKET_TYPES_REVERSE[type] - }; - }; - exports.decodePacket = decodePacket; - var mapBinary = (data, binaryType) => { - switch (binaryType) { - case "arraybuffer": - if (data instanceof ArrayBuffer) { - return data; - } else if (Buffer.isBuffer(data)) { - return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); - } else { - return data.buffer; - } - case "nodebuffer": - default: - if (Buffer.isBuffer(data)) { - return data; - } else { - return Buffer.from(data); - } - } - }; -}); - -// ../../node_modules/.pnpm/engine.io-parser@5.2.3/node_modules/engine.io-parser/build/cjs/index.js -var require_cjs = __commonJS((exports) => { - Object.defineProperty(exports, "__esModule", { value: true }); - exports.decodePayload = exports.decodePacket = exports.encodePayload = exports.encodePacket = exports.protocol = undefined; - exports.createPacketEncoderStream = createPacketEncoderStream; - exports.createPacketDecoderStream = createPacketDecoderStream; - var encodePacket_js_1 = require_encodePacket(); - Object.defineProperty(exports, "encodePacket", { enumerable: true, get: function() { - return encodePacket_js_1.encodePacket; - } }); - var decodePacket_js_1 = require_decodePacket(); - Object.defineProperty(exports, "decodePacket", { enumerable: true, get: function() { - return decodePacket_js_1.decodePacket; - } }); - var commons_js_1 = require_commons(); - var SEPARATOR = String.fromCharCode(30); - var encodePayload = (packets, callback) => { - const length = packets.length; - const encodedPackets = new Array(length); - let count = 0; - packets.forEach((packet, i) => { - (0, encodePacket_js_1.encodePacket)(packet, false, (encodedPacket) => { - encodedPackets[i] = encodedPacket; - if (++count === length) { - callback(encodedPackets.join(SEPARATOR)); - } - }); - }); - }; - exports.encodePayload = encodePayload; - var decodePayload = (encodedPayload, binaryType) => { - const encodedPackets = encodedPayload.split(SEPARATOR); - const packets = []; - for (let i = 0;i < encodedPackets.length; i++) { - const decodedPacket = (0, decodePacket_js_1.decodePacket)(encodedPackets[i], binaryType); - packets.push(decodedPacket); - if (decodedPacket.type === "error") { - break; - } - } - return packets; - }; - exports.decodePayload = decodePayload; - function createPacketEncoderStream() { - return new TransformStream({ - transform(packet, controller) { - (0, encodePacket_js_1.encodePacketToBinary)(packet, (encodedPacket) => { - const payloadLength = encodedPacket.length; - let header; - if (payloadLength < 126) { - header = new Uint8Array(1); - new DataView(header.buffer).setUint8(0, payloadLength); - } else if (payloadLength < 65536) { - header = new Uint8Array(3); - const view = new DataView(header.buffer); - view.setUint8(0, 126); - view.setUint16(1, payloadLength); - } else { - header = new Uint8Array(9); - const view = new DataView(header.buffer); - view.setUint8(0, 127); - view.setBigUint64(1, BigInt(payloadLength)); - } - if (packet.data && typeof packet.data !== "string") { - header[0] |= 128; - } - controller.enqueue(header); - controller.enqueue(encodedPacket); - }); - } - }); - } - var TEXT_DECODER; - function totalLength(chunks) { - return chunks.reduce((acc, chunk2) => acc + chunk2.length, 0); - } - function concatChunks(chunks, size) { - if (chunks[0].length === size) { - return chunks.shift(); - } - const buffer = new Uint8Array(size); - let j = 0; - for (let i = 0;i < size; i++) { - buffer[i] = chunks[0][j++]; - if (j === chunks[0].length) { - chunks.shift(); - j = 0; - } - } - if (chunks.length && j < chunks[0].length) { - chunks[0] = chunks[0].slice(j); - } - return buffer; - } - function createPacketDecoderStream(maxPayload, binaryType) { - if (!TEXT_DECODER) { - TEXT_DECODER = new TextDecoder; + extensions: ["wmv"] + }, + "video/x-ms-wmx": { + source: "apache", + extensions: ["wmx"] + }, + "video/x-ms-wvx": { + source: "apache", + extensions: ["wvx"] + }, + "video/x-msvideo": { + source: "apache", + extensions: ["avi"] + }, + "video/x-sgi-movie": { + source: "apache", + extensions: ["movie"] + }, + "video/x-smv": { + source: "apache", + extensions: ["smv"] + }, + "x-conference/x-cooltalk": { + source: "apache", + extensions: ["ice"] + }, + "x-shader/x-fragment": { + compressible: true + }, + "x-shader/x-vertex": { + compressible: true } - const chunks = []; - let state = 0; - let expectedLength = -1; - let isBinary = false; - return new TransformStream({ - transform(chunk2, controller) { - chunks.push(chunk2); - while (true) { - if (state === 0) { - if (totalLength(chunks) < 1) { - break; - } - const header = concatChunks(chunks, 1); - isBinary = (header[0] & 128) === 128; - expectedLength = header[0] & 127; - if (expectedLength < 126) { - state = 3; - } else if (expectedLength === 126) { - state = 1; - } else { - state = 2; - } - } else if (state === 1) { - if (totalLength(chunks) < 2) { - break; - } - const headerArray = concatChunks(chunks, 2); - expectedLength = new DataView(headerArray.buffer, headerArray.byteOffset, headerArray.length).getUint16(0); - state = 3; - } else if (state === 2) { - if (totalLength(chunks) < 8) { - break; - } - const headerArray = concatChunks(chunks, 8); - const view = new DataView(headerArray.buffer, headerArray.byteOffset, headerArray.length); - const n = view.getUint32(0); - if (n > Math.pow(2, 53 - 32) - 1) { - controller.enqueue(commons_js_1.ERROR_PACKET); - break; - } - expectedLength = n * Math.pow(2, 32) + view.getUint32(4); - state = 3; - } else { - if (totalLength(chunks) < expectedLength) { - break; - } - const data = concatChunks(chunks, expectedLength); - controller.enqueue((0, decodePacket_js_1.decodePacket)(isBinary ? data : TEXT_DECODER.decode(data), binaryType)); - state = 0; - } - if (expectedLength === 0 || expectedLength > maxPayload) { - controller.enqueue(commons_js_1.ERROR_PACKET); - break; - } - } - } - }); - } - exports.protocol = 4; + }; }); -// ../../node_modules/.pnpm/engine.io@6.6.5/node_modules/engine.io/build/parser-v3/utf8.js -var require_utf8 = __commonJS((exports, module) => { - /*! https://mths.be/utf8js v2.1.2 by @mathias */ - var stringFromCharCode = String.fromCharCode; - function ucs2decode(string4) { - var output = []; - var counter = 0; - var length = string4.length; - var value; - var extra; - while (counter < length) { - value = string4.charCodeAt(counter++); - if (value >= 55296 && value <= 56319 && counter < length) { - extra = string4.charCodeAt(counter++); - if ((extra & 64512) == 56320) { - output.push(((value & 1023) << 10) + (extra & 1023) + 65536); - } else { - output.push(value); - counter--; - } - } else { - output.push(value); - } - } - return output; - } - function ucs2encode(array3) { - var length = array3.length; - var index = -1; - var value; - var output = ""; - while (++index < length) { - value = array3[index]; - if (value > 65535) { - value -= 65536; - output += stringFromCharCode(value >>> 10 & 1023 | 55296); - value = 56320 | value & 1023; - } - output += stringFromCharCode(value); - } - return output; - } - function checkScalarValue(codePoint, strict) { - if (codePoint >= 55296 && codePoint <= 57343) { - if (strict) { - throw Error("Lone surrogate U+" + codePoint.toString(16).toUpperCase() + " is not a scalar value"); - } +// ../../node_modules/.pnpm/mime-types@2.1.35/node_modules/mime-types/index.js +var require_mime_types = __commonJS((exports) => { + /*! + * mime-types + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + var db = require_db(); + var extname2 = __require("path").extname; + var EXTRACT_TYPE_REGEXP = /^\s*([^;\s]*)(?:;|\s|$)/; + var TEXT_TYPE_REGEXP = /^text\//i; + exports.charset = charset; + exports.charsets = { lookup: charset }; + exports.contentType = contentType; + exports.extension = extension; + exports.extensions = Object.create(null); + exports.lookup = lookup; + exports.types = Object.create(null); + populateMaps(exports.extensions, exports.types); + function charset(type) { + if (!type || typeof type !== "string") { return false; } - return true; - } - function createByte(codePoint, shift) { - return stringFromCharCode(codePoint >> shift & 63 | 128); - } - function encodeCodePoint(codePoint, strict) { - if ((codePoint & 4294967168) == 0) { - return stringFromCharCode(codePoint); + var match = EXTRACT_TYPE_REGEXP.exec(type); + var mime = match && db[match[1].toLowerCase()]; + if (mime && mime.charset) { + return mime.charset; } - var symbol21 = ""; - if ((codePoint & 4294965248) == 0) { - symbol21 = stringFromCharCode(codePoint >> 6 & 31 | 192); - } else if ((codePoint & 4294901760) == 0) { - if (!checkScalarValue(codePoint, strict)) { - codePoint = 65533; - } - symbol21 = stringFromCharCode(codePoint >> 12 & 15 | 224); - symbol21 += createByte(codePoint, 6); - } else if ((codePoint & 4292870144) == 0) { - symbol21 = stringFromCharCode(codePoint >> 18 & 7 | 240); - symbol21 += createByte(codePoint, 12); - symbol21 += createByte(codePoint, 6); + if (match && TEXT_TYPE_REGEXP.test(match[1])) { + return "UTF-8"; } - symbol21 += stringFromCharCode(codePoint & 63 | 128); - return symbol21; + return false; } - function utf8encode(string4, opts) { - opts = opts || {}; - var strict = opts.strict !== false; - var codePoints = ucs2decode(string4); - var length = codePoints.length; - var index = -1; - var codePoint; - var byteString = ""; - while (++index < length) { - codePoint = codePoints[index]; - byteString += encodeCodePoint(codePoint, strict); + function contentType(str) { + if (!str || typeof str !== "string") { + return false; } - return byteString; - } - function readContinuationByte() { - if (byteIndex >= byteCount) { - throw Error("Invalid byte index"); + var mime = str.indexOf("/") === -1 ? exports.lookup(str) : str; + if (!mime) { + return false; } - var continuationByte = byteArray[byteIndex] & 255; - byteIndex++; - if ((continuationByte & 192) == 128) { - return continuationByte & 63; + if (mime.indexOf("charset") === -1) { + var charset2 = exports.charset(mime); + if (charset2) + mime += "; charset=" + charset2.toLowerCase(); } - throw Error("Invalid continuation byte"); + return mime; } - function decodeSymbol(strict) { - var byte1; - var byte2; - var byte3; - var byte4; - var codePoint; - if (byteIndex > byteCount) { - throw Error("Invalid byte index"); - } - if (byteIndex == byteCount) { + function extension(type) { + if (!type || typeof type !== "string") { return false; } - byte1 = byteArray[byteIndex] & 255; - byteIndex++; - if ((byte1 & 128) == 0) { - return byte1; - } - if ((byte1 & 224) == 192) { - byte2 = readContinuationByte(); - codePoint = (byte1 & 31) << 6 | byte2; - if (codePoint >= 128) { - return codePoint; - } else { - throw Error("Invalid continuation byte"); - } - } - if ((byte1 & 240) == 224) { - byte2 = readContinuationByte(); - byte3 = readContinuationByte(); - codePoint = (byte1 & 15) << 12 | byte2 << 6 | byte3; - if (codePoint >= 2048) { - return checkScalarValue(codePoint, strict) ? codePoint : 65533; - } else { - throw Error("Invalid continuation byte"); - } - } - if ((byte1 & 248) == 240) { - byte2 = readContinuationByte(); - byte3 = readContinuationByte(); - byte4 = readContinuationByte(); - codePoint = (byte1 & 7) << 18 | byte2 << 12 | byte3 << 6 | byte4; - if (codePoint >= 65536 && codePoint <= 1114111) { - return codePoint; - } - } - throw Error("Invalid UTF-8 detected"); - } - var byteArray; - var byteCount; - var byteIndex; - function utf8decode(byteString, opts) { - opts = opts || {}; - var strict = opts.strict !== false; - byteArray = ucs2decode(byteString); - byteCount = byteArray.length; - byteIndex = 0; - var codePoints = []; - var tmp; - while ((tmp = decodeSymbol(strict)) !== false) { - codePoints.push(tmp); + var match = EXTRACT_TYPE_REGEXP.exec(type); + var exts = match && exports.extensions[match[1].toLowerCase()]; + if (!exts || !exts.length) { + return false; } - return ucs2encode(codePoints); + return exts[0]; } - module.exports = { - version: "2.1.2", - encode: utf8encode, - decode: utf8decode - }; -}); - -// ../../node_modules/.pnpm/engine.io@6.6.5/node_modules/engine.io/build/parser-v3/index.js -var require_parser_v3 = __commonJS((exports) => { - Object.defineProperty(exports, "__esModule", { value: true }); - exports.packets = exports.protocol = undefined; - exports.encodePacket = encodePacket; - exports.encodeBase64Packet = encodeBase64Packet; - exports.decodePacket = decodePacket; - exports.decodeBase64Packet = decodeBase64Packet; - exports.encodePayload = encodePayload; - exports.decodePayload = decodePayload; - exports.encodePayloadAsBinary = encodePayloadAsBinary; - exports.decodePayloadAsBinary = decodePayloadAsBinary; - var utf8 = require_utf8(); - exports.protocol = 3; - var hasBinary = (packets) => { - for (const packet of packets) { - if (packet.data instanceof ArrayBuffer || ArrayBuffer.isView(packet.data)) { - return true; - } - } - return false; - }; - exports.packets = { - open: 0, - close: 1, - ping: 2, - pong: 3, - message: 4, - upgrade: 5, - noop: 6 - }; - var packetslist = Object.keys(exports.packets); - var err = { type: "error", data: "parser error" }; - var EMPTY_BUFFER = Buffer.concat([]); - function encodePacket(packet, supportsBinary, utf8encode, callback) { - if (typeof supportsBinary === "function") { - callback = supportsBinary; - supportsBinary = null; - } - if (typeof utf8encode === "function") { - callback = utf8encode; - utf8encode = null; - } - if (Buffer.isBuffer(packet.data)) { - return encodeBuffer(packet, supportsBinary, callback); - } else if (packet.data && (packet.data.buffer || packet.data) instanceof ArrayBuffer) { - return encodeBuffer({ type: packet.type, data: arrayBufferToBuffer(packet.data) }, supportsBinary, callback); - } - var encoded = exports.packets[packet.type]; - if (packet.data !== undefined) { - encoded += utf8encode ? utf8.encode(String(packet.data), { strict: false }) : String(packet.data); + function lookup(path13) { + if (!path13 || typeof path13 !== "string") { + return false; } - return callback("" + encoded); - } - function encodeBuffer(packet, supportsBinary, callback) { - if (!supportsBinary) { - return encodeBase64Packet(packet, callback); + var extension2 = extname2("x." + path13).toLowerCase().substr(1); + if (!extension2) { + return false; } - var data = packet.data; - var typeBuffer = Buffer.allocUnsafe(1); - typeBuffer[0] = exports.packets[packet.type]; - return callback(Buffer.concat([typeBuffer, data])); - } - function encodeBase64Packet(packet, callback) { - var data = Buffer.isBuffer(packet.data) ? packet.data : arrayBufferToBuffer(packet.data); - var message = "b" + exports.packets[packet.type]; - message += data.toString("base64"); - return callback(message); + return exports.types[extension2] || false; } - function decodePacket(data, binaryType, utf8decode) { - if (data === undefined) { - return err; - } - let type; - if (typeof data === "string") { - type = data.charAt(0); - if (type === "b") { - return decodeBase64Packet(data.slice(1), binaryType); + function populateMaps(extensions2, types2) { + var preference = ["nginx", "apache", undefined, "iana"]; + Object.keys(db).forEach(function forEachMimeType(type) { + var mime = db[type]; + var exts = mime.extensions; + if (!exts || !exts.length) { + return; } - if (utf8decode) { - data = tryDecode(data); - if (data === false) { - return err; + extensions2[type] = exts; + for (var i = 0;i < exts.length; i++) { + var extension2 = exts[i]; + if (types2[extension2]) { + var from = preference.indexOf(db[types2[extension2]].source); + var to = preference.indexOf(mime.source); + if (types2[extension2] !== "application/octet-stream" && (from > to || from === to && types2[extension2].substr(0, 12) === "application/")) { + continue; + } } + types2[extension2] = type; } - if (Number(type) != type || !packetslist[type]) { - return err; - } - if (data.length > 1) { - return { type: packetslist[type], data: data.slice(1) }; - } else { - return { type: packetslist[type] }; - } - } - if (binaryType === "arraybuffer") { - var intArray = new Uint8Array(data); - type = intArray[0]; - return { type: packetslist[type], data: intArray.buffer.slice(1) }; - } - if (data instanceof ArrayBuffer) { - data = arrayBufferToBuffer(data); - } - type = data[0]; - return { type: packetslist[type], data: data.slice(1) }; - } - function tryDecode(data) { - try { - data = utf8.decode(data, { strict: false }); - } catch (e) { - return false; - } - return data; - } - function decodeBase64Packet(msg, binaryType) { - var type = packetslist[msg.charAt(0)]; - var data = Buffer.from(msg.slice(1), "base64"); - if (binaryType === "arraybuffer") { - var abv = new Uint8Array(data.length); - for (var i = 0;i < abv.length; i++) { - abv[i] = data[i]; - } - data = abv.buffer; - } - return { type, data }; - } - function encodePayload(packets, supportsBinary, callback) { - if (typeof supportsBinary === "function") { - callback = supportsBinary; - supportsBinary = null; - } - if (supportsBinary && hasBinary(packets)) { - return encodePayloadAsBinary(packets, callback); - } - if (!packets.length) { - return callback("0:"); - } - function encodeOne(packet, doneCallback) { - encodePacket(packet, supportsBinary, false, function(message) { - doneCallback(null, setLengthHeader(message)); - }); - } - map2(packets, encodeOne, function(err2, results) { - return callback(results.join("")); }); } - function setLengthHeader(message) { - return message.length + ":" + message; - } - function map2(ary2, each, done) { - const results = new Array(ary2.length); - let count = 0; - for (let i = 0;i < ary2.length; i++) { - each(ary2[i], (error48, msg) => { - results[i] = msg; - if (++count === ary2.length) { - done(null, results); - } - }); - } - } - function decodePayload(data, binaryType, callback) { - if (typeof data !== "string") { - return decodePayloadAsBinary(data, binaryType, callback); - } - if (typeof binaryType === "function") { - callback = binaryType; - binaryType = null; - } - if (data === "") { - return callback(err, 0, 1); - } - var length = "", n, msg, packet; - for (var i = 0, l = data.length;i < l; i++) { - var chr = data.charAt(i); - if (chr !== ":") { - length += chr; - continue; - } - if (length === "" || length != (n = Number(length))) { - return callback(err, 0, 1); - } - msg = data.slice(i + 1, i + 1 + n); - if (length != msg.length) { - return callback(err, 0, 1); - } - if (msg.length) { - packet = decodePacket(msg, binaryType, false); - if (err.type === packet.type && err.data === packet.data) { - return callback(err, 0, 1); - } - var more = callback(packet, i + n, l); - if (more === false) - return; - } - i += n; - length = ""; - } - if (length !== "") { - return callback(err, 0, 1); +}); + +// ../../node_modules/.pnpm/accepts@1.3.8/node_modules/accepts/index.js +var require_accepts = __commonJS((exports, module) => { + /*! + * accepts + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + var Negotiator = require_negotiator(); + var mime = require_mime_types(); + module.exports = Accepts; + function Accepts(req) { + if (!(this instanceof Accepts)) { + return new Accepts(req); } + this.headers = req.headers; + this.negotiator = new Negotiator(req); } - function bufferToString(buffer) { - var str = ""; - for (var i = 0, l = buffer.length;i < l; i++) { - str += String.fromCharCode(buffer[i]); + Accepts.prototype.type = Accepts.prototype.types = function(types_) { + var types2 = types_; + if (types2 && !Array.isArray(types2)) { + types2 = new Array(arguments.length); + for (var i = 0;i < types2.length; i++) { + types2[i] = arguments[i]; + } } - return str; - } - function stringToBuffer(string4) { - var buf = Buffer.allocUnsafe(string4.length); - for (var i = 0, l = string4.length;i < l; i++) { - buf.writeUInt8(string4.charCodeAt(i), i); + if (!types2 || types2.length === 0) { + return this.negotiator.mediaTypes(); } - return buf; - } - function arrayBufferToBuffer(data) { - var length = data.byteLength || data.length; - var offset = data.byteOffset || 0; - return Buffer.from(data.buffer || data, offset, length); - } - function encodePayloadAsBinary(packets, callback) { - if (!packets.length) { - return callback(EMPTY_BUFFER); + if (!this.headers.accept) { + return types2[0]; } - map2(packets, encodeOneBinaryPacket, function(err2, results) { - return callback(Buffer.concat(results)); - }); - } - function encodeOneBinaryPacket(p, doneCallback) { - function onBinaryPacketEncode(packet) { - var encodingLength = "" + packet.length; - var sizeBuffer; - if (typeof packet === "string") { - sizeBuffer = Buffer.allocUnsafe(encodingLength.length + 2); - sizeBuffer[0] = 0; - for (var i = 0;i < encodingLength.length; i++) { - sizeBuffer[i + 1] = parseInt(encodingLength[i], 10); - } - sizeBuffer[sizeBuffer.length - 1] = 255; - return doneCallback(null, Buffer.concat([sizeBuffer, stringToBuffer(packet)])); + var mimes = types2.map(extToMime); + var accepts = this.negotiator.mediaTypes(mimes.filter(validMime)); + var first = accepts[0]; + return first ? types2[mimes.indexOf(first)] : false; + }; + Accepts.prototype.encoding = Accepts.prototype.encodings = function(encodings_) { + var encodings = encodings_; + if (encodings && !Array.isArray(encodings)) { + encodings = new Array(arguments.length); + for (var i = 0;i < encodings.length; i++) { + encodings[i] = arguments[i]; } - sizeBuffer = Buffer.allocUnsafe(encodingLength.length + 2); - sizeBuffer[0] = 1; - for (var i = 0;i < encodingLength.length; i++) { - sizeBuffer[i + 1] = parseInt(encodingLength[i], 10); + } + if (!encodings || encodings.length === 0) { + return this.negotiator.encodings(); + } + return this.negotiator.encodings(encodings)[0] || false; + }; + Accepts.prototype.charset = Accepts.prototype.charsets = function(charsets_) { + var charsets = charsets_; + if (charsets && !Array.isArray(charsets)) { + charsets = new Array(arguments.length); + for (var i = 0;i < charsets.length; i++) { + charsets[i] = arguments[i]; } - sizeBuffer[sizeBuffer.length - 1] = 255; - doneCallback(null, Buffer.concat([sizeBuffer, packet])); } - encodePacket(p, true, true, onBinaryPacketEncode); - } - function decodePayloadAsBinary(data, binaryType, callback) { - if (typeof binaryType === "function") { - callback = binaryType; - binaryType = null; + if (!charsets || charsets.length === 0) { + return this.negotiator.charsets(); } - var bufferTail = data; - var buffers = []; - var i; - while (bufferTail.length > 0) { - var strLen = ""; - var isString2 = bufferTail[0] === 0; - for (i = 1;; i++) { - if (bufferTail[i] === 255) - break; - if (strLen.length > 310) { - return callback(err, 0, 1); - } - strLen += "" + bufferTail[i]; + return this.negotiator.charsets(charsets)[0] || false; + }; + Accepts.prototype.lang = Accepts.prototype.langs = Accepts.prototype.language = Accepts.prototype.languages = function(languages_) { + var languages = languages_; + if (languages && !Array.isArray(languages)) { + languages = new Array(arguments.length); + for (var i = 0;i < languages.length; i++) { + languages[i] = arguments[i]; } - bufferTail = bufferTail.slice(strLen.length + 1); - var msgLength = parseInt(strLen, 10); - var msg = bufferTail.slice(1, msgLength + 1); - if (isString2) - msg = bufferToString(msg); - buffers.push(msg); - bufferTail = bufferTail.slice(msgLength + 1); } - var total = buffers.length; - for (i = 0;i < total; i++) { - var buffer = buffers[i]; - callback(decodePacket(buffer, binaryType, true), i, total); + if (!languages || languages.length === 0) { + return this.negotiator.languages(); } + return this.negotiator.languages(languages)[0] || false; + }; + function extToMime(type) { + return type.indexOf("/") === -1 ? mime.lookup(type) : type; + } + function validMime(type) { + return typeof type === "string"; } }); -// ../../node_modules/.pnpm/ms@2.1.3/node_modules/ms/index.js -var require_ms = __commonJS((exports, module) => { - var s = 1000; - var m = s * 60; - var h = m * 60; - var d = h * 24; - var w = d * 7; - var y = d * 365.25; - module.exports = function(val, options) { - options = options || {}; - var type = typeof val; - if (type === "string" && val.length > 0) { - return parse5(val); - } else if (type === "number" && isFinite(val)) { - return options.long ? fmtLong(val) : fmtShort(val); +// ../../node_modules/.pnpm/base64id@2.0.0/node_modules/base64id/lib/base64id.js +var require_base64id = __commonJS((exports, module) => { + /*! + * base64id v0.1.0 + */ + var crypto2 = __require("crypto"); + var Base64Id = function() {}; + Base64Id.prototype.getRandomBytes = function(bytes) { + var BUFFER_SIZE = 4096; + var self = this; + bytes = bytes || 12; + if (bytes > BUFFER_SIZE) { + return crypto2.randomBytes(bytes); } - throw new Error("val is not a non-empty string or a valid number. val=" + JSON.stringify(val)); - }; - function parse5(str) { - str = String(str); - if (str.length > 100) { - return; + var bytesInBuffer = parseInt(BUFFER_SIZE / bytes); + var threshold = parseInt(bytesInBuffer * 0.85); + if (!threshold) { + return crypto2.randomBytes(bytes); } - var match = /^(-?(?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i.exec(str); - if (!match) { - return; + if (this.bytesBufferIndex == null) { + this.bytesBufferIndex = -1; } - var n = parseFloat(match[1]); - var type = (match[2] || "ms").toLowerCase(); - switch (type) { - case "years": - case "year": - case "yrs": - case "yr": - case "y": - return n * y; - case "weeks": - case "week": - case "w": - return n * w; - case "days": - case "day": - case "d": - return n * d; - case "hours": - case "hour": - case "hrs": - case "hr": - case "h": - return n * h; - case "minutes": - case "minute": - case "mins": - case "min": - case "m": - return n * m; - case "seconds": - case "second": - case "secs": - case "sec": - case "s": - return n * s; - case "milliseconds": - case "millisecond": - case "msecs": - case "msec": - case "ms": - return n; - default: - return; + if (this.bytesBufferIndex == bytesInBuffer) { + this.bytesBuffer = null; + this.bytesBufferIndex = -1; } - } - function fmtShort(ms) { - var msAbs = Math.abs(ms); - if (msAbs >= d) { - return Math.round(ms / d) + "d"; + if (this.bytesBufferIndex == -1 || this.bytesBufferIndex > threshold) { + if (!this.isGeneratingBytes) { + this.isGeneratingBytes = true; + crypto2.randomBytes(BUFFER_SIZE, function(err, bytes2) { + self.bytesBuffer = bytes2; + self.bytesBufferIndex = 0; + self.isGeneratingBytes = false; + }); + } + if (this.bytesBufferIndex == -1) { + return crypto2.randomBytes(bytes); + } } - if (msAbs >= h) { - return Math.round(ms / h) + "h"; + var result = this.bytesBuffer.slice(bytes * this.bytesBufferIndex, bytes * (this.bytesBufferIndex + 1)); + this.bytesBufferIndex++; + return result; + }; + Base64Id.prototype.generateId = function() { + var rand = Buffer.alloc(15); + if (!rand.writeInt32BE) { + return Math.abs(Math.random() * Math.random() * Date.now() | 0).toString() + Math.abs(Math.random() * Math.random() * Date.now() | 0).toString(); } - if (msAbs >= m) { - return Math.round(ms / m) + "m"; + this.sequenceNumber = this.sequenceNumber + 1 | 0; + rand.writeInt32BE(this.sequenceNumber, 11); + if (crypto2.randomBytes) { + this.getRandomBytes(12).copy(rand); + } else { + [0, 4, 8].forEach(function(i) { + rand.writeInt32BE(Math.random() * Math.pow(2, 32) | 0, i); + }); } - if (msAbs >= s) { - return Math.round(ms / s) + "s"; + return rand.toString("base64").replace(/\//g, "_").replace(/\+/g, "-"); + }; + exports = module.exports = new Base64Id; +}); + +// ../../node_modules/.pnpm/engine.io-parser@5.2.3/node_modules/engine.io-parser/build/cjs/commons.js +var require_commons = __commonJS((exports) => { + Object.defineProperty(exports, "__esModule", { value: true }); + exports.ERROR_PACKET = exports.PACKET_TYPES_REVERSE = exports.PACKET_TYPES = undefined; + var PACKET_TYPES = Object.create(null); + exports.PACKET_TYPES = PACKET_TYPES; + PACKET_TYPES["open"] = "0"; + PACKET_TYPES["close"] = "1"; + PACKET_TYPES["ping"] = "2"; + PACKET_TYPES["pong"] = "3"; + PACKET_TYPES["message"] = "4"; + PACKET_TYPES["upgrade"] = "5"; + PACKET_TYPES["noop"] = "6"; + var PACKET_TYPES_REVERSE = Object.create(null); + exports.PACKET_TYPES_REVERSE = PACKET_TYPES_REVERSE; + Object.keys(PACKET_TYPES).forEach((key) => { + PACKET_TYPES_REVERSE[PACKET_TYPES[key]] = key; + }); + var ERROR_PACKET = { type: "error", data: "parser error" }; + exports.ERROR_PACKET = ERROR_PACKET; +}); + +// ../../node_modules/.pnpm/engine.io-parser@5.2.3/node_modules/engine.io-parser/build/cjs/encodePacket.js +var require_encodePacket = __commonJS((exports) => { + Object.defineProperty(exports, "__esModule", { value: true }); + exports.encodePacket = undefined; + exports.encodePacketToBinary = encodePacketToBinary; + var commons_js_1 = require_commons(); + var encodePacket = ({ type, data }, supportsBinary, callback) => { + if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { + return callback(supportsBinary ? data : "b" + toBuffer(data, true).toString("base64")); } - return ms + "ms"; + return callback(commons_js_1.PACKET_TYPES[type] + (data || "")); + }; + exports.encodePacket = encodePacket; + var toBuffer = (data, forceBufferConversion) => { + if (Buffer.isBuffer(data) || data instanceof Uint8Array && !forceBufferConversion) { + return data; + } else if (data instanceof ArrayBuffer) { + return Buffer.from(data); + } else { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } + }; + var TEXT_ENCODER; + function encodePacketToBinary(packet, callback) { + if (packet.data instanceof ArrayBuffer || ArrayBuffer.isView(packet.data)) { + return callback(toBuffer(packet.data, false)); + } + (0, exports.encodePacket)(packet, true, (encoded) => { + if (!TEXT_ENCODER) { + TEXT_ENCODER = new TextEncoder; + } + callback(TEXT_ENCODER.encode(encoded)); + }); } - function fmtLong(ms) { - var msAbs = Math.abs(ms); - if (msAbs >= d) { - return plural(ms, msAbs, d, "day"); +}); + +// ../../node_modules/.pnpm/engine.io-parser@5.2.3/node_modules/engine.io-parser/build/cjs/decodePacket.js +var require_decodePacket = __commonJS((exports) => { + Object.defineProperty(exports, "__esModule", { value: true }); + exports.decodePacket = undefined; + var commons_js_1 = require_commons(); + var decodePacket = (encodedPacket, binaryType) => { + if (typeof encodedPacket !== "string") { + return { + type: "message", + data: mapBinary(encodedPacket, binaryType) + }; } - if (msAbs >= h) { - return plural(ms, msAbs, h, "hour"); + const type = encodedPacket.charAt(0); + if (type === "b") { + const buffer = Buffer.from(encodedPacket.substring(1), "base64"); + return { + type: "message", + data: mapBinary(buffer, binaryType) + }; } - if (msAbs >= m) { - return plural(ms, msAbs, m, "minute"); + if (!commons_js_1.PACKET_TYPES_REVERSE[type]) { + return commons_js_1.ERROR_PACKET; } - if (msAbs >= s) { - return plural(ms, msAbs, s, "second"); + return encodedPacket.length > 1 ? { + type: commons_js_1.PACKET_TYPES_REVERSE[type], + data: encodedPacket.substring(1) + } : { + type: commons_js_1.PACKET_TYPES_REVERSE[type] + }; + }; + exports.decodePacket = decodePacket; + var mapBinary = (data, binaryType) => { + switch (binaryType) { + case "arraybuffer": + if (data instanceof ArrayBuffer) { + return data; + } else if (Buffer.isBuffer(data)) { + return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); + } else { + return data.buffer; + } + case "nodebuffer": + default: + if (Buffer.isBuffer(data)) { + return data; + } else { + return Buffer.from(data); + } } - return ms + " ms"; - } - function plural(ms, msAbs, n, name21) { - var isPlural = msAbs >= n * 1.5; - return Math.round(ms / n) + " " + name21 + (isPlural ? "s" : ""); - } + }; }); -// ../../node_modules/.pnpm/debug@4.4.3_supports-color@8.1.1/node_modules/debug/src/common.js -var require_common = __commonJS((exports, module) => { - function setup(env) { - createDebug.debug = createDebug; - createDebug.default = createDebug; - createDebug.coerce = coerce2; - createDebug.disable = disable; - createDebug.enable = enable; - createDebug.enabled = enabled; - createDebug.humanize = require_ms(); - createDebug.destroy = destroy; - Object.keys(env).forEach((key) => { - createDebug[key] = env[key]; +// ../../node_modules/.pnpm/engine.io-parser@5.2.3/node_modules/engine.io-parser/build/cjs/index.js +var require_cjs = __commonJS((exports) => { + Object.defineProperty(exports, "__esModule", { value: true }); + exports.decodePayload = exports.decodePacket = exports.encodePayload = exports.encodePacket = exports.protocol = undefined; + exports.createPacketEncoderStream = createPacketEncoderStream; + exports.createPacketDecoderStream = createPacketDecoderStream; + var encodePacket_js_1 = require_encodePacket(); + Object.defineProperty(exports, "encodePacket", { enumerable: true, get: function() { + return encodePacket_js_1.encodePacket; + } }); + var decodePacket_js_1 = require_decodePacket(); + Object.defineProperty(exports, "decodePacket", { enumerable: true, get: function() { + return decodePacket_js_1.decodePacket; + } }); + var commons_js_1 = require_commons(); + var SEPARATOR = String.fromCharCode(30); + var encodePayload = (packets, callback) => { + const length = packets.length; + const encodedPackets = new Array(length); + let count = 0; + packets.forEach((packet, i) => { + (0, encodePacket_js_1.encodePacket)(packet, false, (encodedPacket) => { + encodedPackets[i] = encodedPacket; + if (++count === length) { + callback(encodedPackets.join(SEPARATOR)); + } + }); }); - createDebug.names = []; - createDebug.skips = []; - createDebug.formatters = {}; - function selectColor(namespace) { - let hash2 = 0; - for (let i = 0;i < namespace.length; i++) { - hash2 = (hash2 << 5) - hash2 + namespace.charCodeAt(i); - hash2 |= 0; + }; + exports.encodePayload = encodePayload; + var decodePayload = (encodedPayload, binaryType) => { + const encodedPackets = encodedPayload.split(SEPARATOR); + const packets = []; + for (let i = 0;i < encodedPackets.length; i++) { + const decodedPacket = (0, decodePacket_js_1.decodePacket)(encodedPackets[i], binaryType); + packets.push(decodedPacket); + if (decodedPacket.type === "error") { + break; } - return createDebug.colors[Math.abs(hash2) % createDebug.colors.length]; } - createDebug.selectColor = selectColor; - function createDebug(namespace) { - let prevTime; - let enableOverride = null; - let namespacesCache; - let enabledCache; - function debug(...args) { - if (!debug.enabled) { - return; - } - const self = debug; - const curr = Number(new Date); - const ms = curr - (prevTime || curr); - self.diff = ms; - self.prev = prevTime; - self.curr = curr; - prevTime = curr; - args[0] = createDebug.coerce(args[0]); - if (typeof args[0] !== "string") { - args.unshift("%O"); - } - let index = 0; - args[0] = args[0].replace(/%([a-zA-Z%])/g, (match, format) => { - if (match === "%%") { - return "%"; - } - index++; - const formatter = createDebug.formatters[format]; - if (typeof formatter === "function") { - const val = args[index]; - match = formatter.call(self, val); - args.splice(index, 1); - index--; - } - return match; - }); - createDebug.formatArgs.call(self, args); - const logFn = self.log || createDebug.log; - logFn.apply(self, args); - } - debug.namespace = namespace; - debug.useColors = createDebug.useColors(); - debug.color = createDebug.selectColor(namespace); - debug.extend = extend2; - debug.destroy = createDebug.destroy; - Object.defineProperty(debug, "enabled", { - enumerable: true, - configurable: false, - get: () => { - if (enableOverride !== null) { - return enableOverride; + return packets; + }; + exports.decodePayload = decodePayload; + function createPacketEncoderStream() { + return new TransformStream({ + transform(packet, controller) { + (0, encodePacket_js_1.encodePacketToBinary)(packet, (encodedPacket) => { + const payloadLength = encodedPacket.length; + let header; + if (payloadLength < 126) { + header = new Uint8Array(1); + new DataView(header.buffer).setUint8(0, payloadLength); + } else if (payloadLength < 65536) { + header = new Uint8Array(3); + const view = new DataView(header.buffer); + view.setUint8(0, 126); + view.setUint16(1, payloadLength); + } else { + header = new Uint8Array(9); + const view = new DataView(header.buffer); + view.setUint8(0, 127); + view.setBigUint64(1, BigInt(payloadLength)); } - if (namespacesCache !== createDebug.namespaces) { - namespacesCache = createDebug.namespaces; - enabledCache = createDebug.enabled(namespace); + if (packet.data && typeof packet.data !== "string") { + header[0] |= 128; } - return enabledCache; - }, - set: (v) => { - enableOverride = v; - } - }); - if (typeof createDebug.init === "function") { - createDebug.init(debug); + controller.enqueue(header); + controller.enqueue(encodedPacket); + }); } - return debug; - } - function extend2(namespace, delimiter) { - const newDebug = createDebug(this.namespace + (typeof delimiter === "undefined" ? ":" : delimiter) + namespace); - newDebug.log = this.log; - return newDebug; + }); + } + var TEXT_DECODER; + function totalLength(chunks) { + return chunks.reduce((acc, chunk2) => acc + chunk2.length, 0); + } + function concatChunks(chunks, size) { + if (chunks[0].length === size) { + return chunks.shift(); } - function enable(namespaces) { - createDebug.save(namespaces); - createDebug.namespaces = namespaces; - createDebug.names = []; - createDebug.skips = []; - const split2 = (typeof namespaces === "string" ? namespaces : "").trim().replace(/\s+/g, ",").split(",").filter(Boolean); - for (const ns of split2) { - if (ns[0] === "-") { - createDebug.skips.push(ns.slice(1)); - } else { - createDebug.names.push(ns); - } + const buffer = new Uint8Array(size); + let j = 0; + for (let i = 0;i < size; i++) { + buffer[i] = chunks[0][j++]; + if (j === chunks[0].length) { + chunks.shift(); + j = 0; } } - function matchesTemplate(search, template) { - let searchIndex = 0; - let templateIndex = 0; - let starIndex = -1; - let matchIndex = 0; - while (searchIndex < search.length) { - if (templateIndex < template.length && (template[templateIndex] === search[searchIndex] || template[templateIndex] === "*")) { - if (template[templateIndex] === "*") { - starIndex = templateIndex; - matchIndex = searchIndex; - templateIndex++; + if (chunks.length && j < chunks[0].length) { + chunks[0] = chunks[0].slice(j); + } + return buffer; + } + function createPacketDecoderStream(maxPayload, binaryType) { + if (!TEXT_DECODER) { + TEXT_DECODER = new TextDecoder; + } + const chunks = []; + let state = 0; + let expectedLength = -1; + let isBinary = false; + return new TransformStream({ + transform(chunk2, controller) { + chunks.push(chunk2); + while (true) { + if (state === 0) { + if (totalLength(chunks) < 1) { + break; + } + const header = concatChunks(chunks, 1); + isBinary = (header[0] & 128) === 128; + expectedLength = header[0] & 127; + if (expectedLength < 126) { + state = 3; + } else if (expectedLength === 126) { + state = 1; + } else { + state = 2; + } + } else if (state === 1) { + if (totalLength(chunks) < 2) { + break; + } + const headerArray = concatChunks(chunks, 2); + expectedLength = new DataView(headerArray.buffer, headerArray.byteOffset, headerArray.length).getUint16(0); + state = 3; + } else if (state === 2) { + if (totalLength(chunks) < 8) { + break; + } + const headerArray = concatChunks(chunks, 8); + const view = new DataView(headerArray.buffer, headerArray.byteOffset, headerArray.length); + const n = view.getUint32(0); + if (n > Math.pow(2, 53 - 32) - 1) { + controller.enqueue(commons_js_1.ERROR_PACKET); + break; + } + expectedLength = n * Math.pow(2, 32) + view.getUint32(4); + state = 3; } else { - searchIndex++; - templateIndex++; + if (totalLength(chunks) < expectedLength) { + break; + } + const data = concatChunks(chunks, expectedLength); + controller.enqueue((0, decodePacket_js_1.decodePacket)(isBinary ? data : TEXT_DECODER.decode(data), binaryType)); + state = 0; + } + if (expectedLength === 0 || expectedLength > maxPayload) { + controller.enqueue(commons_js_1.ERROR_PACKET); + break; } - } else if (starIndex !== -1) { - templateIndex = starIndex + 1; - matchIndex++; - searchIndex = matchIndex; - } else { - return false; } } - while (templateIndex < template.length && template[templateIndex] === "*") { - templateIndex++; + }); + } + exports.protocol = 4; +}); + +// ../../node_modules/.pnpm/engine.io@6.6.5/node_modules/engine.io/build/parser-v3/utf8.js +var require_utf8 = __commonJS((exports, module) => { + /*! https://mths.be/utf8js v2.1.2 by @mathias */ + var stringFromCharCode = String.fromCharCode; + function ucs2decode(string4) { + var output = []; + var counter = 0; + var length = string4.length; + var value; + var extra; + while (counter < length) { + value = string4.charCodeAt(counter++); + if (value >= 55296 && value <= 56319 && counter < length) { + extra = string4.charCodeAt(counter++); + if ((extra & 64512) == 56320) { + output.push(((value & 1023) << 10) + (extra & 1023) + 65536); + } else { + output.push(value); + counter--; + } + } else { + output.push(value); } - return templateIndex === template.length; - } - function disable() { - const namespaces = [ - ...createDebug.names, - ...createDebug.skips.map((namespace) => "-" + namespace) - ].join(","); - createDebug.enable(""); - return namespaces; } - function enabled(name21) { - for (const skip of createDebug.skips) { - if (matchesTemplate(name21, skip)) { - return false; - } + return output; + } + function ucs2encode(array3) { + var length = array3.length; + var index = -1; + var value; + var output = ""; + while (++index < length) { + value = array3[index]; + if (value > 65535) { + value -= 65536; + output += stringFromCharCode(value >>> 10 & 1023 | 55296); + value = 56320 | value & 1023; } - for (const ns of createDebug.names) { - if (matchesTemplate(name21, ns)) { - return true; - } + output += stringFromCharCode(value); + } + return output; + } + function checkScalarValue(codePoint, strict) { + if (codePoint >= 55296 && codePoint <= 57343) { + if (strict) { + throw Error("Lone surrogate U+" + codePoint.toString(16).toUpperCase() + " is not a scalar value"); } return false; } - function coerce2(val) { - if (val instanceof Error) { - return val.stack || val.message; + return true; + } + function createByte(codePoint, shift) { + return stringFromCharCode(codePoint >> shift & 63 | 128); + } + function encodeCodePoint(codePoint, strict) { + if ((codePoint & 4294967168) == 0) { + return stringFromCharCode(codePoint); + } + var symbol21 = ""; + if ((codePoint & 4294965248) == 0) { + symbol21 = stringFromCharCode(codePoint >> 6 & 31 | 192); + } else if ((codePoint & 4294901760) == 0) { + if (!checkScalarValue(codePoint, strict)) { + codePoint = 65533; } - return val; + symbol21 = stringFromCharCode(codePoint >> 12 & 15 | 224); + symbol21 += createByte(codePoint, 6); + } else if ((codePoint & 4292870144) == 0) { + symbol21 = stringFromCharCode(codePoint >> 18 & 7 | 240); + symbol21 += createByte(codePoint, 12); + symbol21 += createByte(codePoint, 6); } - function destroy() { - console.warn("Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`."); + symbol21 += stringFromCharCode(codePoint & 63 | 128); + return symbol21; + } + function utf8encode(string4, opts) { + opts = opts || {}; + var strict = opts.strict !== false; + var codePoints = ucs2decode(string4); + var length = codePoints.length; + var index = -1; + var codePoint; + var byteString = ""; + while (++index < length) { + codePoint = codePoints[index]; + byteString += encodeCodePoint(codePoint, strict); } - createDebug.enable(createDebug.load()); - return createDebug; + return byteString; } - module.exports = setup; -}); - -// ../../node_modules/.pnpm/debug@4.4.3_supports-color@8.1.1/node_modules/debug/src/browser.js -var require_browser = __commonJS((exports, module) => { - exports.formatArgs = formatArgs; - exports.save = save; - exports.load = load; - exports.useColors = useColors; - exports.storage = localstorage(); - exports.destroy = (() => { - let warned = false; - return () => { - if (!warned) { - warned = true; - console.warn("Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`."); - } - }; - })(); - exports.colors = [ - "#0000CC", - "#0000FF", - "#0033CC", - "#0033FF", - "#0066CC", - "#0066FF", - "#0099CC", - "#0099FF", - "#00CC00", - "#00CC33", - "#00CC66", - "#00CC99", - "#00CCCC", - "#00CCFF", - "#3300CC", - "#3300FF", - "#3333CC", - "#3333FF", - "#3366CC", - "#3366FF", - "#3399CC", - "#3399FF", - "#33CC00", - "#33CC33", - "#33CC66", - "#33CC99", - "#33CCCC", - "#33CCFF", - "#6600CC", - "#6600FF", - "#6633CC", - "#6633FF", - "#66CC00", - "#66CC33", - "#9900CC", - "#9900FF", - "#9933CC", - "#9933FF", - "#99CC00", - "#99CC33", - "#CC0000", - "#CC0033", - "#CC0066", - "#CC0099", - "#CC00CC", - "#CC00FF", - "#CC3300", - "#CC3333", - "#CC3366", - "#CC3399", - "#CC33CC", - "#CC33FF", - "#CC6600", - "#CC6633", - "#CC9900", - "#CC9933", - "#CCCC00", - "#CCCC33", - "#FF0000", - "#FF0033", - "#FF0066", - "#FF0099", - "#FF00CC", - "#FF00FF", - "#FF3300", - "#FF3333", - "#FF3366", - "#FF3399", - "#FF33CC", - "#FF33FF", - "#FF6600", - "#FF6633", - "#FF9900", - "#FF9933", - "#FFCC00", - "#FFCC33" - ]; - function useColors() { - if (typeof window !== "undefined" && window.process && (window.process.type === "renderer" || window.process.__nwjs)) { - return true; + function readContinuationByte() { + if (byteIndex >= byteCount) { + throw Error("Invalid byte index"); } - if (typeof navigator !== "undefined" && navigator.userAgent && navigator.userAgent.toLowerCase().match(/(edge|trident)\/(\d+)/)) { - return false; + var continuationByte = byteArray[byteIndex] & 255; + byteIndex++; + if ((continuationByte & 192) == 128) { + return continuationByte & 63; } - let m; - return typeof document !== "undefined" && document.documentElement && document.documentElement.style && document.documentElement.style.WebkitAppearance || typeof window !== "undefined" && window.console && (window.console.firebug || window.console.exception && window.console.table) || typeof navigator !== "undefined" && navigator.userAgent && (m = navigator.userAgent.toLowerCase().match(/firefox\/(\d+)/)) && parseInt(m[1], 10) >= 31 || typeof navigator !== "undefined" && navigator.userAgent && navigator.userAgent.toLowerCase().match(/applewebkit\/(\d+)/); + throw Error("Invalid continuation byte"); } - function formatArgs(args) { - args[0] = (this.useColors ? "%c" : "") + this.namespace + (this.useColors ? " %c" : " ") + args[0] + (this.useColors ? "%c " : " ") + "+" + module.exports.humanize(this.diff); - if (!this.useColors) { - return; + function decodeSymbol(strict) { + var byte1; + var byte2; + var byte3; + var byte4; + var codePoint; + if (byteIndex > byteCount) { + throw Error("Invalid byte index"); } - const c = "color: " + this.color; - args.splice(1, 0, c, "color: inherit"); - let index = 0; - let lastC = 0; - args[0].replace(/%[a-zA-Z%]/g, (match) => { - if (match === "%%") { - return; - } - index++; - if (match === "%c") { - lastC = index; + if (byteIndex == byteCount) { + return false; + } + byte1 = byteArray[byteIndex] & 255; + byteIndex++; + if ((byte1 & 128) == 0) { + return byte1; + } + if ((byte1 & 224) == 192) { + byte2 = readContinuationByte(); + codePoint = (byte1 & 31) << 6 | byte2; + if (codePoint >= 128) { + return codePoint; + } else { + throw Error("Invalid continuation byte"); } - }); - args.splice(lastC, 0, c); - } - exports.log = console.debug || console.log || (() => {}); - function save(namespaces) { - try { - if (namespaces) { - exports.storage.setItem("debug", namespaces); + } + if ((byte1 & 240) == 224) { + byte2 = readContinuationByte(); + byte3 = readContinuationByte(); + codePoint = (byte1 & 15) << 12 | byte2 << 6 | byte3; + if (codePoint >= 2048) { + return checkScalarValue(codePoint, strict) ? codePoint : 65533; } else { - exports.storage.removeItem("debug"); + throw Error("Invalid continuation byte"); } - } catch (error48) {} + } + if ((byte1 & 248) == 240) { + byte2 = readContinuationByte(); + byte3 = readContinuationByte(); + byte4 = readContinuationByte(); + codePoint = (byte1 & 7) << 18 | byte2 << 12 | byte3 << 6 | byte4; + if (codePoint >= 65536 && codePoint <= 1114111) { + return codePoint; + } + } + throw Error("Invalid UTF-8 detected"); } - function load() { - let r; - try { - r = exports.storage.getItem("debug") || exports.storage.getItem("DEBUG"); - } catch (error48) {} - if (!r && typeof process !== "undefined" && "env" in process) { - r = process.env.DEBUG; + var byteArray; + var byteCount; + var byteIndex; + function utf8decode(byteString, opts) { + opts = opts || {}; + var strict = opts.strict !== false; + byteArray = ucs2decode(byteString); + byteCount = byteArray.length; + byteIndex = 0; + var codePoints = []; + var tmp; + while ((tmp = decodeSymbol(strict)) !== false) { + codePoints.push(tmp); + } + return ucs2encode(codePoints); + } + module.exports = { + version: "2.1.2", + encode: utf8encode, + decode: utf8decode + }; +}); + +// ../../node_modules/.pnpm/engine.io@6.6.5/node_modules/engine.io/build/parser-v3/index.js +var require_parser_v3 = __commonJS((exports) => { + Object.defineProperty(exports, "__esModule", { value: true }); + exports.packets = exports.protocol = undefined; + exports.encodePacket = encodePacket; + exports.encodeBase64Packet = encodeBase64Packet; + exports.decodePacket = decodePacket; + exports.decodeBase64Packet = decodeBase64Packet; + exports.encodePayload = encodePayload; + exports.decodePayload = decodePayload; + exports.encodePayloadAsBinary = encodePayloadAsBinary; + exports.decodePayloadAsBinary = decodePayloadAsBinary; + var utf8 = require_utf8(); + exports.protocol = 3; + var hasBinary = (packets) => { + for (const packet of packets) { + if (packet.data instanceof ArrayBuffer || ArrayBuffer.isView(packet.data)) { + return true; + } + } + return false; + }; + exports.packets = { + open: 0, + close: 1, + ping: 2, + pong: 3, + message: 4, + upgrade: 5, + noop: 6 + }; + var packetslist = Object.keys(exports.packets); + var err = { type: "error", data: "parser error" }; + var EMPTY_BUFFER = Buffer.concat([]); + function encodePacket(packet, supportsBinary, utf8encode, callback) { + if (typeof supportsBinary === "function") { + callback = supportsBinary; + supportsBinary = null; + } + if (typeof utf8encode === "function") { + callback = utf8encode; + utf8encode = null; + } + if (Buffer.isBuffer(packet.data)) { + return encodeBuffer(packet, supportsBinary, callback); + } else if (packet.data && (packet.data.buffer || packet.data) instanceof ArrayBuffer) { + return encodeBuffer({ type: packet.type, data: arrayBufferToBuffer(packet.data) }, supportsBinary, callback); + } + var encoded = exports.packets[packet.type]; + if (packet.data !== undefined) { + encoded += utf8encode ? utf8.encode(String(packet.data), { strict: false }) : String(packet.data); } - return r; - } - function localstorage() { - try { - return localStorage; - } catch (error48) {} + return callback("" + encoded); } - module.exports = require_common()(exports); - var { formatters } = module.exports; - formatters.j = function(v) { - try { - return JSON.stringify(v); - } catch (error48) { - return "[UnexpectedJSONParseError]: " + error48.message; + function encodeBuffer(packet, supportsBinary, callback) { + if (!supportsBinary) { + return encodeBase64Packet(packet, callback); } - }; -}); - -// ../../node_modules/.pnpm/has-flag@4.0.0/node_modules/has-flag/index.js -var require_has_flag = __commonJS((exports, module) => { - module.exports = (flag, argv = process.argv) => { - const prefix = flag.startsWith("-") ? "" : flag.length === 1 ? "-" : "--"; - const position = argv.indexOf(prefix + flag); - const terminatorPosition = argv.indexOf("--"); - return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition); - }; -}); - -// ../../node_modules/.pnpm/supports-color@8.1.1/node_modules/supports-color/index.js -var require_supports_color = __commonJS((exports, module) => { - var os2 = __require("os"); - var tty = __require("tty"); - var hasFlag = require_has_flag(); - var { env } = process; - var flagForceColor; - if (hasFlag("no-color") || hasFlag("no-colors") || hasFlag("color=false") || hasFlag("color=never")) { - flagForceColor = 0; - } else if (hasFlag("color") || hasFlag("colors") || hasFlag("color=true") || hasFlag("color=always")) { - flagForceColor = 1; + var data = packet.data; + var typeBuffer = Buffer.allocUnsafe(1); + typeBuffer[0] = exports.packets[packet.type]; + return callback(Buffer.concat([typeBuffer, data])); } - function envForceColor() { - if ("FORCE_COLOR" in env) { - if (env.FORCE_COLOR === "true") { - return 1; + function encodeBase64Packet(packet, callback) { + var data = Buffer.isBuffer(packet.data) ? packet.data : arrayBufferToBuffer(packet.data); + var message = "b" + exports.packets[packet.type]; + message += data.toString("base64"); + return callback(message); + } + function decodePacket(data, binaryType, utf8decode) { + if (data === undefined) { + return err; + } + let type; + if (typeof data === "string") { + type = data.charAt(0); + if (type === "b") { + return decodeBase64Packet(data.slice(1), binaryType); } - if (env.FORCE_COLOR === "false") { - return 0; + if (utf8decode) { + data = tryDecode(data); + if (data === false) { + return err; + } + } + if (Number(type) != type || !packetslist[type]) { + return err; + } + if (data.length > 1) { + return { type: packetslist[type], data: data.slice(1) }; + } else { + return { type: packetslist[type] }; } - return env.FORCE_COLOR.length === 0 ? 1 : Math.min(Number.parseInt(env.FORCE_COLOR, 10), 3); } + if (binaryType === "arraybuffer") { + var intArray = new Uint8Array(data); + type = intArray[0]; + return { type: packetslist[type], data: intArray.buffer.slice(1) }; + } + if (data instanceof ArrayBuffer) { + data = arrayBufferToBuffer(data); + } + type = data[0]; + return { type: packetslist[type], data: data.slice(1) }; } - function translateLevel(level) { - if (level === 0) { + function tryDecode(data) { + try { + data = utf8.decode(data, { strict: false }); + } catch (e) { return false; } - return { - level, - hasBasic: true, - has256: level >= 2, - has16m: level >= 3 - }; + return data; } - function supportsColor(haveStream, { streamIsTTY, sniffFlags = true } = {}) { - const noFlagForceColor = envForceColor(); - if (noFlagForceColor !== undefined) { - flagForceColor = noFlagForceColor; + function decodeBase64Packet(msg, binaryType) { + var type = packetslist[msg.charAt(0)]; + var data = Buffer.from(msg.slice(1), "base64"); + if (binaryType === "arraybuffer") { + var abv = new Uint8Array(data.length); + for (var i = 0;i < abv.length; i++) { + abv[i] = data[i]; + } + data = abv.buffer; } - const forceColor = sniffFlags ? flagForceColor : noFlagForceColor; - if (forceColor === 0) { - return 0; + return { type, data }; + } + function encodePayload(packets, supportsBinary, callback) { + if (typeof supportsBinary === "function") { + callback = supportsBinary; + supportsBinary = null; } - if (sniffFlags) { - if (hasFlag("color=16m") || hasFlag("color=full") || hasFlag("color=truecolor")) { - return 3; - } - if (hasFlag("color=256")) { - return 2; - } + if (supportsBinary && hasBinary(packets)) { + return encodePayloadAsBinary(packets, callback); } - if (haveStream && !streamIsTTY && forceColor === undefined) { - return 0; + if (!packets.length) { + return callback("0:"); } - const min = forceColor || 0; - if (env.TERM === "dumb") { - return min; + function encodeOne(packet, doneCallback) { + encodePacket(packet, supportsBinary, false, function(message) { + doneCallback(null, setLengthHeader(message)); + }); } - if (process.platform === "win32") { - const osRelease = os2.release().split("."); - if (Number(osRelease[0]) >= 10 && Number(osRelease[2]) >= 10586) { - return Number(osRelease[2]) >= 14931 ? 3 : 2; - } - return 1; + map2(packets, encodeOne, function(err2, results) { + return callback(results.join("")); + }); + } + function setLengthHeader(message) { + return message.length + ":" + message; + } + function map2(ary2, each, done) { + const results = new Array(ary2.length); + let count = 0; + for (let i = 0;i < ary2.length; i++) { + each(ary2[i], (error48, msg) => { + results[i] = msg; + if (++count === ary2.length) { + done(null, results); + } + }); } - if ("CI" in env) { - if (["TRAVIS", "CIRCLECI", "APPVEYOR", "GITLAB_CI", "GITHUB_ACTIONS", "BUILDKITE", "DRONE"].some((sign) => (sign in env)) || env.CI_NAME === "codeship") { - return 1; - } - return min; + } + function decodePayload(data, binaryType, callback) { + if (typeof data !== "string") { + return decodePayloadAsBinary(data, binaryType, callback); } - if ("TEAMCITY_VERSION" in env) { - return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(env.TEAMCITY_VERSION) ? 1 : 0; + if (typeof binaryType === "function") { + callback = binaryType; + binaryType = null; } - if (env.COLORTERM === "truecolor") { - return 3; + if (data === "") { + return callback(err, 0, 1); } - if ("TERM_PROGRAM" in env) { - const version2 = Number.parseInt((env.TERM_PROGRAM_VERSION || "").split(".")[0], 10); - switch (env.TERM_PROGRAM) { - case "iTerm.app": - return version2 >= 3 ? 3 : 2; - case "Apple_Terminal": - return 2; + var length = "", n, msg, packet; + for (var i = 0, l = data.length;i < l; i++) { + var chr = data.charAt(i); + if (chr !== ":") { + length += chr; + continue; + } + if (length === "" || length != (n = Number(length))) { + return callback(err, 0, 1); + } + msg = data.slice(i + 1, i + 1 + n); + if (length != msg.length) { + return callback(err, 0, 1); + } + if (msg.length) { + packet = decodePacket(msg, binaryType, false); + if (err.type === packet.type && err.data === packet.data) { + return callback(err, 0, 1); + } + var more = callback(packet, i + n, l); + if (more === false) + return; } + i += n; + length = ""; } - if (/-256(color)?$/i.test(env.TERM)) { - return 2; + if (length !== "") { + return callback(err, 0, 1); } - if (/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(env.TERM)) { - return 1; + } + function bufferToString(buffer) { + var str = ""; + for (var i = 0, l = buffer.length;i < l; i++) { + str += String.fromCharCode(buffer[i]); } - if ("COLORTERM" in env) { - return 1; + return str; + } + function stringToBuffer(string4) { + var buf = Buffer.allocUnsafe(string4.length); + for (var i = 0, l = string4.length;i < l; i++) { + buf.writeUInt8(string4.charCodeAt(i), i); } - return min; + return buf; } - function getSupportLevel(stream, options = {}) { - const level = supportsColor(stream, { - streamIsTTY: stream && stream.isTTY, - ...options - }); - return translateLevel(level); + function arrayBufferToBuffer(data) { + var length = data.byteLength || data.length; + var offset = data.byteOffset || 0; + return Buffer.from(data.buffer || data, offset, length); } - module.exports = { - supportsColor: getSupportLevel, - stdout: getSupportLevel({ isTTY: tty.isatty(1) }), - stderr: getSupportLevel({ isTTY: tty.isatty(2) }) - }; -}); - -// ../../node_modules/.pnpm/debug@4.4.3_supports-color@8.1.1/node_modules/debug/src/node.js -var require_node2 = __commonJS((exports, module) => { - var tty = __require("tty"); - var util3 = __require("util"); - exports.init = init; - exports.log = log; - exports.formatArgs = formatArgs; - exports.save = save; - exports.load = load; - exports.useColors = useColors; - exports.destroy = util3.deprecate(() => {}, "Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`."); - exports.colors = [6, 2, 3, 4, 5, 1]; - try { - const supportsColor = require_supports_color(); - if (supportsColor && (supportsColor.stderr || supportsColor).level >= 2) { - exports.colors = [ - 20, - 21, - 26, - 27, - 32, - 33, - 38, - 39, - 40, - 41, - 42, - 43, - 44, - 45, - 56, - 57, - 62, - 63, - 68, - 69, - 74, - 75, - 76, - 77, - 78, - 79, - 80, - 81, - 92, - 93, - 98, - 99, - 112, - 113, - 128, - 129, - 134, - 135, - 148, - 149, - 160, - 161, - 162, - 163, - 164, - 165, - 166, - 167, - 168, - 169, - 170, - 171, - 172, - 173, - 178, - 179, - 184, - 185, - 196, - 197, - 198, - 199, - 200, - 201, - 202, - 203, - 204, - 205, - 206, - 207, - 208, - 209, - 214, - 215, - 220, - 221 - ]; + function encodePayloadAsBinary(packets, callback) { + if (!packets.length) { + return callback(EMPTY_BUFFER); } - } catch (error48) {} - exports.inspectOpts = Object.keys(process.env).filter((key) => { - return /^debug_/i.test(key); - }).reduce((obj, key) => { - const prop = key.substring(6).toLowerCase().replace(/_([a-z])/g, (_, k) => { - return k.toUpperCase(); + map2(packets, encodeOneBinaryPacket, function(err2, results) { + return callback(Buffer.concat(results)); }); - let val = process.env[key]; - if (/^(yes|on|true|enabled)$/i.test(val)) { - val = true; - } else if (/^(no|off|false|disabled)$/i.test(val)) { - val = false; - } else if (val === "null") { - val = null; - } else { - val = Number(val); - } - obj[prop] = val; - return obj; - }, {}); - function useColors() { - return "colors" in exports.inspectOpts ? Boolean(exports.inspectOpts.colors) : tty.isatty(process.stderr.fd); } - function formatArgs(args) { - const { namespace: name21, useColors: useColors2 } = this; - if (useColors2) { - const c = this.color; - const colorCode = "\x1B[3" + (c < 8 ? c : "8;5;" + c); - const prefix = ` ${colorCode};1m${name21} \x1B[0m`; - args[0] = prefix + args[0].split(` -`).join(` -` + prefix); - args.push(colorCode + "m+" + module.exports.humanize(this.diff) + "\x1B[0m"); - } else { - args[0] = getDate() + name21 + " " + args[0]; + function encodeOneBinaryPacket(p, doneCallback) { + function onBinaryPacketEncode(packet) { + var encodingLength = "" + packet.length; + var sizeBuffer; + if (typeof packet === "string") { + sizeBuffer = Buffer.allocUnsafe(encodingLength.length + 2); + sizeBuffer[0] = 0; + for (var i = 0;i < encodingLength.length; i++) { + sizeBuffer[i + 1] = parseInt(encodingLength[i], 10); + } + sizeBuffer[sizeBuffer.length - 1] = 255; + return doneCallback(null, Buffer.concat([sizeBuffer, stringToBuffer(packet)])); + } + sizeBuffer = Buffer.allocUnsafe(encodingLength.length + 2); + sizeBuffer[0] = 1; + for (var i = 0;i < encodingLength.length; i++) { + sizeBuffer[i + 1] = parseInt(encodingLength[i], 10); + } + sizeBuffer[sizeBuffer.length - 1] = 255; + doneCallback(null, Buffer.concat([sizeBuffer, packet])); } + encodePacket(p, true, true, onBinaryPacketEncode); } - function getDate() { - if (exports.inspectOpts.hideDate) { - return ""; + function decodePayloadAsBinary(data, binaryType, callback) { + if (typeof binaryType === "function") { + callback = binaryType; + binaryType = null; } - return new Date().toISOString() + " "; - } - function log(...args) { - return process.stderr.write(util3.formatWithOptions(exports.inspectOpts, ...args) + ` -`); - } - function save(namespaces) { - if (namespaces) { - process.env.DEBUG = namespaces; - } else { - delete process.env.DEBUG; + var bufferTail = data; + var buffers = []; + var i; + while (bufferTail.length > 0) { + var strLen = ""; + var isString2 = bufferTail[0] === 0; + for (i = 1;; i++) { + if (bufferTail[i] === 255) + break; + if (strLen.length > 310) { + return callback(err, 0, 1); + } + strLen += "" + bufferTail[i]; + } + bufferTail = bufferTail.slice(strLen.length + 1); + var msgLength = parseInt(strLen, 10); + var msg = bufferTail.slice(1, msgLength + 1); + if (isString2) + msg = bufferToString(msg); + buffers.push(msg); + bufferTail = bufferTail.slice(msgLength + 1); } - } - function load() { - return process.env.DEBUG; - } - function init(debug) { - debug.inspectOpts = {}; - const keys = Object.keys(exports.inspectOpts); - for (let i = 0;i < keys.length; i++) { - debug.inspectOpts[keys[i]] = exports.inspectOpts[keys[i]]; + var total = buffers.length; + for (i = 0;i < total; i++) { + var buffer = buffers[i]; + callback(decodePacket(buffer, binaryType, true), i, total); } } - module.exports = require_common()(exports); - var { formatters } = module.exports; - formatters.o = function(v) { - this.inspectOpts.colors = this.useColors; - return util3.inspect(v, this.inspectOpts).split(` -`).map((str) => str.trim()).join(" "); - }; - formatters.O = function(v) { - this.inspectOpts.colors = this.useColors; - return util3.inspect(v, this.inspectOpts); - }; -}); - -// ../../node_modules/.pnpm/debug@4.4.3_supports-color@8.1.1/node_modules/debug/src/index.js -var require_src2 = __commonJS((exports, module) => { - if (typeof process === "undefined" || process.type === "renderer" || false || process.__nwjs) { - module.exports = require_browser(); - } else { - module.exports = require_node2(); - } }); // ../../node_modules/.pnpm/engine.io@6.6.5/node_modules/engine.io/build/transport.js @@ -13787,7 +17116,7 @@ var require_cookie = __commonJS((exports) => { }); // ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/lib/constants.js -var require_constants = __commonJS((exports, module) => { +var require_constants4 = __commonJS((exports, module) => { var BINARY_TYPES = ["nodebuffer", "arraybuffer", "fragments"]; var hasBlob = typeof Blob !== "undefined"; if (hasBlob) @@ -13807,7 +17136,7 @@ var require_constants = __commonJS((exports, module) => { // ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/lib/buffer-util.js var require_buffer_util = __commonJS((exports, module) => { - var { EMPTY_BUFFER } = require_constants(); + var { EMPTY_BUFFER } = require_constants4(); var FastBuffer = Buffer[Symbol.species]; function concat(list, totalLength) { if (list.length === 0) @@ -13917,10 +17246,10 @@ var require_limiter = __commonJS((exports, module) => { // ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/lib/permessage-deflate.js var require_permessage_deflate = __commonJS((exports, module) => { - var zlib2 = __require("zlib"); + var zlib3 = __require("zlib"); var bufferUtil = require_buffer_util(); var Limiter = require_limiter(); - var { kStatusCode } = require_constants(); + var { kStatusCode } = require_constants4(); var FastBuffer = Buffer[Symbol.species]; var TRAILER = Buffer.from([0, 0, 255, 255]); var kPerMessageDeflate = Symbol("permessage-deflate"); @@ -14081,8 +17410,8 @@ var require_permessage_deflate = __commonJS((exports, module) => { const endpoint = this._isServer ? "client" : "server"; if (!this._inflate) { const key = `${endpoint}_max_window_bits`; - const windowBits = typeof this.params[key] !== "number" ? zlib2.Z_DEFAULT_WINDOWBITS : this.params[key]; - this._inflate = zlib2.createInflateRaw({ + const windowBits = typeof this.params[key] !== "number" ? zlib3.Z_DEFAULT_WINDOWBITS : this.params[key]; + this._inflate = zlib3.createInflateRaw({ ...this._options.zlibInflateOptions, windowBits }); @@ -14122,8 +17451,8 @@ var require_permessage_deflate = __commonJS((exports, module) => { const endpoint = this._isServer ? "server" : "client"; if (!this._deflate) { const key = `${endpoint}_max_window_bits`; - const windowBits = typeof this.params[key] !== "number" ? zlib2.Z_DEFAULT_WINDOWBITS : this.params[key]; - this._deflate = zlib2.createDeflateRaw({ + const windowBits = typeof this.params[key] !== "number" ? zlib3.Z_DEFAULT_WINDOWBITS : this.params[key]; + this._deflate = zlib3.createDeflateRaw({ ...this._options.zlibDeflateOptions, windowBits }); @@ -14133,7 +17462,7 @@ var require_permessage_deflate = __commonJS((exports, module) => { } this._deflate[kCallback] = callback; this._deflate.write(data); - this._deflate.flush(zlib2.Z_SYNC_FLUSH, () => { + this._deflate.flush(zlib3.Z_SYNC_FLUSH, () => { if (!this._deflate) { return; } @@ -14182,7 +17511,7 @@ var require_permessage_deflate = __commonJS((exports, module) => { // ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/lib/validation.js var require_validation = __commonJS((exports, module) => { var { isUtf8 } = __require("buffer"); - var { hasBlob } = require_constants(); + var { hasBlob } = require_constants4(); var tokenChars = [ 0, 0, @@ -14375,7 +17704,7 @@ var require_receiver = __commonJS((exports, module) => { EMPTY_BUFFER, kStatusCode, kWebSocket - } = require_constants(); + } = require_constants4(); var { concat, toArrayBuffer, unmask } = require_buffer_util(); var { isValidStatusCode, isValidUTF8 } = require_validation(); var FastBuffer = Buffer[Symbol.species]; @@ -14752,7 +18081,7 @@ var require_sender = __commonJS((exports, module) => { var { Duplex } = __require("stream"); var { randomFillSync } = __require("crypto"); var PerMessageDeflate = require_permessage_deflate(); - var { EMPTY_BUFFER, kWebSocket, NOOP } = require_constants(); + var { EMPTY_BUFFER, kWebSocket, NOOP } = require_constants4(); var { isBlob: isBlob2, isValidStatusCode } = require_validation(); var { mask: applyMask, toBuffer } = require_buffer_util(); var kByteLength = Symbol("kByteLength"); @@ -15103,7 +18432,7 @@ var require_sender = __commonJS((exports, module) => { // ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/lib/event-target.js var require_event_target = __commonJS((exports, module) => { - var { kForOnEventAttribute, kListener } = require_constants(); + var { kForOnEventAttribute, kListener } = require_constants4(); var kCode = Symbol("kCode"); var kData = Symbol("kData"); var kError = Symbol("kError"); @@ -15420,8 +18749,8 @@ var require_extension = __commonJS((exports, module) => { // ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/lib/websocket.js var require_websocket2 = __commonJS((exports, module) => { var EventEmitter = __require("events"); - var https2 = __require("https"); - var http3 = __require("http"); + var https3 = __require("https"); + var http4 = __require("http"); var net2 = __require("net"); var tls = __require("tls"); var { randomBytes, createHash } = __require("crypto"); @@ -15440,7 +18769,7 @@ var require_websocket2 = __commonJS((exports, module) => { kStatusCode, kWebSocket, NOOP - } = require_constants(); + } = require_constants4(); var { EventTarget: { addEventListener, removeEventListener } } = require_event_target(); @@ -15834,7 +19163,7 @@ var require_websocket2 = __commonJS((exports, module) => { } const defaultPort = isSecure ? 443 : 80; const key = randomBytes(16).toString("base64"); - const request = isSecure ? https2.request : http3.request; + const request = isSecure ? https3.request : http4.request; const protocolSet = new Set; let perMessageDeflate; opts.createConnection = opts.createConnection || (isSecure ? tlsConnect : netConnect); @@ -16327,14 +19656,14 @@ var require_subprotocol = __commonJS((exports, module) => { // ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/lib/websocket-server.js var require_websocket_server = __commonJS((exports, module) => { var EventEmitter = __require("events"); - var http3 = __require("http"); + var http4 = __require("http"); var { Duplex } = __require("stream"); var { createHash } = __require("crypto"); var extension = require_extension(); var PerMessageDeflate = require_permessage_deflate(); var subprotocol = require_subprotocol(); var WebSocket = require_websocket2(); - var { GUID, kWebSocket } = require_constants(); + var { GUID, kWebSocket } = require_constants4(); var keyRegex = /^[+/0-9A-Za-z]{22}==$/; var RUNNING = 0; var CLOSING = 1; @@ -16365,8 +19694,8 @@ var require_websocket_server = __commonJS((exports, module) => { throw new TypeError('One and only one of the "port", "server", or "noServer" options ' + "must be specified"); } if (options.port != null) { - this._server = http3.createServer((req, res) => { - const body = http3.STATUS_CODES[426]; + this._server = http4.createServer((req, res) => { + const body = http4.STATUS_CODES[426]; res.writeHead(426, { "Content-Length": body.length, "Content-Type": "text/plain" @@ -16600,7 +19929,7 @@ var require_websocket_server = __commonJS((exports, module) => { this.destroy(); } function abortHandshake(socket, code, message, headers) { - message = message || http3.STATUS_CODES[code]; + message = message || http4.STATUS_CODES[code]; headers = { Connection: "close", "Content-Type": "text/html", @@ -16608,7 +19937,7 @@ var require_websocket_server = __commonJS((exports, module) => { ...headers }; socket.once("finish", socket.destroy); - socket.end(`HTTP/1.1 ${code} ${http3.STATUS_CODES[code]}\r + socket.end(`HTTP/1.1 ${code} ${http4.STATUS_CODES[code]}\r ` + Object.keys(headers).map((h) => `${h}: ${headers[h]}`).join(`\r `) + `\r \r @@ -17047,11 +20376,11 @@ var require_server = __commonJS((exports) => { this.init(); } _computePath(options) { - let path12 = (options.path || "/engine.io").replace(/\/$/, ""); + let path13 = (options.path || "/engine.io").replace(/\/$/, ""); if (options.addTrailingSlash !== false) { - path12 += "/"; + path13 += "/"; } - return path12; + return path13; } upgrades(transport) { if (!this.opts.allowUpgrades) @@ -17469,10 +20798,10 @@ var require_server = __commonJS((exports) => { } } attach(server, options = {}) { - const path12 = this._computePath(options); + const path13 = this._computePath(options); const destroyUpgradeTimeout = options.destroyUpgradeTimeout || 1000; function check2(req) { - return path12 === req.url.slice(0, path12.length); + return path13 === req.url.slice(0, path13.length); } const listeners = server.listeners("request").slice(0); server.removeAllListeners("request"); @@ -17480,7 +20809,7 @@ var require_server = __commonJS((exports) => { server.on("listening", this.init.bind(this)); server.on("request", (req, res) => { if (check2(req)) { - debug('intercepting request for path "%s"', path12); + debug('intercepting request for path "%s"', path13); this.handleRequest(req, res); } else { let i = 0; @@ -18195,8 +21524,8 @@ var require_userver = __commonJS((exports) => { return new transports_uws_1.default[transportName](req); } attach(app, options = {}) { - const path12 = this._computePath(options); - app.any(path12, this.handleRequest.bind(this)).ws(path12, { + const path13 = this._computePath(options); + app.any(path13, this.handleRequest.bind(this)).ws(path13, { compression: options.compression, idleTimeout: options.idleTimeout, maxBackpressure: options.maxBackpressure, @@ -20869,7 +24198,7 @@ var require_cluster_adapter = __commonJS((exports) => { }); // ../../node_modules/.pnpm/socket.io-adapter@2.5.6/node_modules/socket.io-adapter/dist/index.js -var require_dist2 = __commonJS((exports) => { +var require_dist6 = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.MessageType = exports.ClusterAdapterWithHeartbeat = exports.ClusterAdapter = exports.SessionAwareAdapter = exports.Adapter = undefined; var in_memory_adapter_1 = require_in_memory_adapter(); @@ -20899,7 +24228,7 @@ var require_parent_namespace = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.ParentNamespace = undefined; var namespace_1 = require_namespace(); - var socket_io_adapter_1 = require_dist2(); + var socket_io_adapter_1 = require_dist6(); var debug_1 = __importDefault(require_src2()); var debug = (0, debug_1.default)("socket.io:parent-namespace"); @@ -20965,7 +24294,7 @@ var require_uws = __commonJS((exports) => { exports.patchAdapter = patchAdapter; exports.restoreAdapter = restoreAdapter; exports.serveFile = serveFile; - var socket_io_adapter_1 = require_dist2(); + var socket_io_adapter_1 = require_dist6(); var fs_1 = __require("fs"); var debug_1 = __importDefault(require_src2()); var debug = (0, debug_1.default)("socket.io:adapter-uws"); @@ -21175,7 +24504,7 @@ var require_package = __commonJS((exports, module) => { }); // ../../node_modules/.pnpm/socket.io@4.8.3/node_modules/socket.io/dist/index.js -var require_dist3 = __commonJS((exports, module) => { +var require_dist7 = __commonJS((exports, module) => { var __dirname = "C:\\Users\\lawrence\\workspace\\smm_github\\node_modules\\.pnpm\\socket.io@4.8.3\\node_modules\\socket.io\\dist"; var __createBinding = exports && exports.__createBinding || (Object.create ? function(o, m, k, k2) { if (k2 === undefined) @@ -21219,7 +24548,7 @@ var require_dist3 = __commonJS((exports, module) => { var zlib_1 = __require("zlib"); var accepts = require_accepts(); var stream_1 = __require("stream"); - var path12 = __require("path"); + var path13 = __require("path"); var engine_io_1 = require_engine_io(); var client_1 = require_client(); var events_1 = __require("events"); @@ -21228,7 +24557,7 @@ var require_dist3 = __commonJS((exports, module) => { return namespace_1.Namespace; } }); var parent_namespace_1 = require_parent_namespace(); - var socket_io_adapter_1 = require_dist2(); + var socket_io_adapter_1 = require_dist6(); var parser = __importStar(require_cjs3()); var debug_1 = __importDefault(require_src2()); var socket_1 = require_socket2(); @@ -21387,7 +24716,7 @@ var require_dist3 = __commonJS((exports, module) => { res.writeHeader("cache-control", "public, max-age=0"); res.writeHeader("content-type", "application/" + (isMap2 ? "json" : "javascript") + "; charset=utf-8"); res.writeHeader("etag", expectedEtag); - const filepath = path12.join(__dirname, "../client-dist/", filename); + const filepath = path13.join(__dirname, "../client-dist/", filename); (0, uws_1.serveFile)(res, filepath); }); } @@ -21443,7 +24772,7 @@ var require_dist3 = __commonJS((exports, module) => { Server.sendFile(filename, req, res); } static sendFile(filename, req, res) { - const readStream = (0, fs_1.createReadStream)(path12.join(__dirname, "../client-dist/", filename)); + const readStream = (0, fs_1.createReadStream)(path13.join(__dirname, "../client-dist/", filename)); const encoding = accepts(req).encodings(["br", "gzip", "deflate"]); const onError = (err) => { if (err) { @@ -22621,7 +25950,7 @@ var require_codegen = __commonJS((exports) => { }); // ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/util.js -var require_util = __commonJS((exports) => { +var require_util2 = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.checkStrictMode = exports.getErrorPath = exports.Type = exports.useFunc = exports.setEvaluated = exports.evaluatedPropsToName = exports.mergeEvaluated = exports.eachItem = exports.unescapeJsonPointer = exports.escapeJsonPointer = exports.escapeFragment = exports.unescapeFragment = exports.schemaRefOrVal = exports.schemaHasRulesButRef = exports.schemaHasRules = exports.checkUnknownRules = exports.alwaysValidSchema = exports.toHash = undefined; var codegen_1 = require_codegen(); @@ -22814,7 +26143,7 @@ var require_errors = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.extendErrors = exports.resetErrorsCount = exports.reportExtraError = exports.reportError = exports.keyword$DataError = exports.keywordError = undefined; var codegen_1 = require_codegen(); - var util_1 = require_util(); + var util_1 = require_util2(); var names_1 = require_names(); exports.keywordError = { message: ({ keyword }) => (0, codegen_1.str)`must pass "${keyword}" keyword validation` @@ -23031,7 +26360,7 @@ var require_dataType = __commonJS((exports) => { var applicability_1 = require_applicability(); var errors_1 = require_errors(); var codegen_1 = require_codegen(); - var util_1 = require_util(); + var util_1 = require_util2(); var DataType; (function(DataType2) { DataType2[DataType2["Correct"] = 0] = "Correct"; @@ -23209,7 +26538,7 @@ var require_defaults = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.assignDefaults = undefined; var codegen_1 = require_codegen(); - var util_1 = require_util(); + var util_1 = require_util2(); function assignDefaults(it, ty) { const { properties, items } = it.schema; if (ty === "object" && properties) { @@ -23243,9 +26572,9 @@ var require_code2 = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.validateUnion = exports.validateArray = exports.usePattern = exports.callValidateCode = exports.schemaProperties = exports.allSchemaProperties = exports.noPropertyInData = exports.propertyInData = exports.isOwnProperty = exports.hasPropFunc = exports.reportMissingProp = exports.checkMissingProp = exports.checkReportMissingProp = undefined; var codegen_1 = require_codegen(); - var util_1 = require_util(); + var util_1 = require_util2(); var names_1 = require_names(); - var util_2 = require_util(); + var util_2 = require_util2(); function checkReportMissingProp(cxt, prop) { const { gen, data, it } = cxt; gen.if(noPropertyInData(gen, data, prop, it.opts.ownProperties), () => { @@ -23487,7 +26816,7 @@ var require_subschema = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.extendSubschemaMode = exports.extendSubschemaData = exports.getSubschema = undefined; var codegen_1 = require_codegen(); - var util_1 = require_util(); + var util_1 = require_util2(); function getSubschema(it, { keyword, schemaProp, schema, schemaPath, errSchemaPath, topSchemaRef }) { if (keyword !== undefined && schema !== undefined) { throw new Error('both "keyword" and "schema" passed, only one allowed'); @@ -23691,7 +27020,7 @@ var require_json_schema_traverse = __commonJS((exports, module) => { var require_resolve = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.getSchemaRefs = exports.resolveUrl = exports.normalizeId = exports._getFullPath = exports.getFullPath = exports.inlineRef = undefined; - var util_1 = require_util(); + var util_1 = require_util2(); var equal = require_fast_deep_equal(); var traverse = require_json_schema_traverse(); var SIMPLE_INLINED = new Set([ @@ -23854,7 +27183,7 @@ var require_validate = __commonJS((exports) => { var codegen_1 = require_codegen(); var names_1 = require_names(); var resolve_1 = require_resolve(); - var util_1 = require_util(); + var util_1 = require_util2(); var errors_1 = require_errors(); function validateFunctionCode(it) { if (isSchemaObj(it)) { @@ -24382,7 +27711,7 @@ var require_compile = __commonJS((exports) => { var validation_error_1 = require_validation_error(); var names_1 = require_names(); var resolve_1 = require_resolve(); - var util_1 = require_util(); + var util_1 = require_util2(); var validate_1 = require_validate(); class SchemaEnv { @@ -24613,7 +27942,7 @@ var require_data = __commonJS((exports, module) => { }); // ../../node_modules/.pnpm/fast-uri@3.1.0/node_modules/fast-uri/lib/utils.js -var require_utils3 = __commonJS((exports, module) => { +var require_utils4 = __commonJS((exports, module) => { var isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iu); var isIPv4 = RegExp.prototype.test.bind(/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/u); function stringArrayToHexStripped(input) { @@ -24734,8 +28063,8 @@ var require_utils3 = __commonJS((exports, module) => { } return ind; } - function removeDotSegments(path12) { - let input = path12; + function removeDotSegments(path13) { + let input = path13; const output = []; let nextSlash = -1; let len = 0; @@ -24869,7 +28198,7 @@ var require_utils3 = __commonJS((exports, module) => { // ../../node_modules/.pnpm/fast-uri@3.1.0/node_modules/fast-uri/lib/schemes.js var require_schemes = __commonJS((exports, module) => { - var { isUUID } = require_utils3(); + var { isUUID } = require_utils4(); var URN_REG = /([\da-z][\d\-a-z]{0,31}):((?:[\w!$'()*+,\-.:;=@]|%[\da-f]{2})+)/iu; var supportedSchemeNames = [ "http", @@ -24925,8 +28254,8 @@ var require_schemes = __commonJS((exports, module) => { wsComponent.secure = undefined; } if (wsComponent.resourceName) { - const [path12, query] = wsComponent.resourceName.split("?"); - wsComponent.path = path12 && path12 !== "/" ? path12 : undefined; + const [path13, query] = wsComponent.resourceName.split("?"); + wsComponent.path = path13 && path13 !== "/" ? path13 : undefined; wsComponent.query = query; wsComponent.resourceName = undefined; } @@ -24985,15 +28314,15 @@ var require_schemes = __commonJS((exports, module) => { urnComponent.nss = (uuidComponent.uuid || "").toLowerCase(); return urnComponent; } - var http3 = { + var http4 = { scheme: "http", domainHost: true, parse: httpParse, serialize: httpSerialize }; - var https2 = { + var https3 = { scheme: "https", - domainHost: http3.domainHost, + domainHost: http4.domainHost, parse: httpParse, serialize: httpSerialize }; @@ -25022,8 +28351,8 @@ var require_schemes = __commonJS((exports, module) => { skipNormalize: true }; var SCHEMES = { - http: http3, - https: https2, + http: http4, + https: https3, ws, wss, urn, @@ -25043,7 +28372,7 @@ var require_schemes = __commonJS((exports, module) => { // ../../node_modules/.pnpm/fast-uri@3.1.0/node_modules/fast-uri/index.js var require_fast_uri = __commonJS((exports, module) => { - var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizeComponentEncoding, isIPv4, nonSimpleDomain } = require_utils3(); + var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizeComponentEncoding, isIPv4, nonSimpleDomain } = require_utils4(); var { SCHEMES, getSchemeHandler } = require_schemes(); function normalize(uri, options) { if (typeof uri === "string") { @@ -25334,7 +28663,7 @@ var require_core = __commonJS((exports) => { var codegen_2 = require_codegen(); var resolve_1 = require_resolve(); var dataType_1 = require_dataType(); - var util_1 = require_util(); + var util_1 = require_util2(); var $dataRefSchema = require_data(); var uri_1 = require_uri(); var defaultRegExp = (str, flags) => new RegExp(str, flags); @@ -25914,7 +29243,7 @@ var require_ref = __commonJS((exports) => { var codegen_1 = require_codegen(); var names_1 = require_names(); var compile_1 = require_compile(); - var util_1 = require_util(); + var util_1 = require_util2(); var def = { keyword: "$ref", schemaType: "string", @@ -26123,7 +29452,7 @@ var require_ucs2length = __commonJS((exports) => { var require_limitLength = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); - var util_1 = require_util(); + var util_1 = require_util2(); var ucs2length_1 = require_ucs2length(); var error48 = { message({ keyword, schemaCode }) { @@ -26152,7 +29481,7 @@ var require_limitLength = __commonJS((exports) => { var require_pattern = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var code_1 = require_code2(); - var util_1 = require_util(); + var util_1 = require_util2(); var codegen_1 = require_codegen(); var error48 = { message: ({ schemaCode }) => (0, codegen_1.str)`must match pattern "${schemaCode}"`, @@ -26213,7 +29542,7 @@ var require_required = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var code_1 = require_code2(); var codegen_1 = require_codegen(); - var util_1 = require_util(); + var util_1 = require_util2(); var error48 = { message: ({ params: { missingProperty } }) => (0, codegen_1.str)`must have required property '${missingProperty}'`, params: ({ params: { missingProperty } }) => (0, codegen_1._)`{missingProperty: ${missingProperty}}` @@ -26326,7 +29655,7 @@ var require_uniqueItems = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var dataType_1 = require_dataType(); var codegen_1 = require_codegen(); - var util_1 = require_util(); + var util_1 = require_util2(); var equal_1 = require_equal(); var error48 = { message: ({ params: { i, j } }) => (0, codegen_1.str)`must NOT have duplicate items (items ## ${j} and ${i} are identical)`, @@ -26389,7 +29718,7 @@ var require_uniqueItems = __commonJS((exports) => { var require_const = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); - var util_1 = require_util(); + var util_1 = require_util2(); var equal_1 = require_equal(); var error48 = { message: "must be equal to constant", @@ -26415,7 +29744,7 @@ var require_const = __commonJS((exports) => { var require_enum = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); - var util_1 = require_util(); + var util_1 = require_util2(); var equal_1 = require_equal(); var error48 = { message: "must be equal to one of the allowed values", @@ -26492,7 +29821,7 @@ var require_additionalItems = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.validateAdditionalItems = undefined; var codegen_1 = require_codegen(); - var util_1 = require_util(); + var util_1 = require_util2(); var error48 = { message: ({ params: { len } }) => (0, codegen_1.str)`must NOT have more than ${len} items`, params: ({ params: { len } }) => (0, codegen_1._)`{limit: ${len}}` @@ -26542,7 +29871,7 @@ var require_items = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.validateTuple = undefined; var codegen_1 = require_codegen(); - var util_1 = require_util(); + var util_1 = require_util2(); var code_1 = require_code2(); var def = { keyword: "items", @@ -26609,7 +29938,7 @@ var require_prefixItems = __commonJS((exports) => { var require_items2020 = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); - var util_1 = require_util(); + var util_1 = require_util2(); var code_1 = require_code2(); var additionalItems_1 = require_additionalItems(); var error48 = { @@ -26641,7 +29970,7 @@ var require_items2020 = __commonJS((exports) => { var require_contains = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); - var util_1 = require_util(); + var util_1 = require_util2(); var error48 = { message: ({ params: { min, max } }) => max === undefined ? (0, codegen_1.str)`must contain at least ${min} valid item(s)` : (0, codegen_1.str)`must contain at least ${min} and no more than ${max} valid item(s)`, params: ({ params: { min, max } }) => max === undefined ? (0, codegen_1._)`{minContains: ${min}}` : (0, codegen_1._)`{minContains: ${min}, maxContains: ${max}}` @@ -26733,7 +30062,7 @@ var require_dependencies = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.validateSchemaDeps = exports.validatePropertyDeps = exports.error = undefined; var codegen_1 = require_codegen(); - var util_1 = require_util(); + var util_1 = require_util2(); var code_1 = require_code2(); exports.error = { message: ({ params: { property, depsCount, deps } }) => { @@ -26817,7 +30146,7 @@ var require_dependencies = __commonJS((exports) => { var require_propertyNames = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); - var util_1 = require_util(); + var util_1 = require_util2(); var error48 = { message: "property name must be valid", params: ({ params }) => (0, codegen_1._)`{propertyName: ${params.propertyName}}` @@ -26859,7 +30188,7 @@ var require_additionalProperties = __commonJS((exports) => { var code_1 = require_code2(); var codegen_1 = require_codegen(); var names_1 = require_names(); - var util_1 = require_util(); + var util_1 = require_util2(); var error48 = { message: "must NOT have additional properties", params: ({ params }) => (0, codegen_1._)`{additionalProperty: ${params.additionalProperty}}` @@ -26961,7 +30290,7 @@ var require_properties = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var validate_1 = require_validate(); var code_1 = require_code2(); - var util_1 = require_util(); + var util_1 = require_util2(); var additionalProperties_1 = require_additionalProperties(); var def = { keyword: "properties", @@ -27016,8 +30345,8 @@ var require_patternProperties = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var code_1 = require_code2(); var codegen_1 = require_codegen(); - var util_1 = require_util(); - var util_2 = require_util(); + var util_1 = require_util2(); + var util_2 = require_util2(); var def = { keyword: "patternProperties", type: "object", @@ -27085,7 +30414,7 @@ var require_patternProperties = __commonJS((exports) => { // ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/not.js var require_not = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); - var util_1 = require_util(); + var util_1 = require_util2(); var def = { keyword: "not", schemaType: ["object", "boolean"], @@ -27128,7 +30457,7 @@ var require_anyOf = __commonJS((exports) => { var require_oneOf = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); - var util_1 = require_util(); + var util_1 = require_util2(); var error48 = { message: "must match exactly one schema in oneOf", params: ({ params }) => (0, codegen_1._)`{passingSchemas: ${params.passing}}` @@ -27182,7 +30511,7 @@ var require_oneOf = __commonJS((exports) => { // ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/allOf.js var require_allOf = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); - var util_1 = require_util(); + var util_1 = require_util2(); var def = { keyword: "allOf", schemaType: "array", @@ -27207,7 +30536,7 @@ var require_allOf = __commonJS((exports) => { var require_if = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); - var util_1 = require_util(); + var util_1 = require_util2(); var error48 = { message: ({ params }) => (0, codegen_1.str)`must match "${params.ifClause}" schema`, params: ({ params }) => (0, codegen_1._)`{failingKeyword: ${params.ifClause}}` @@ -27272,7 +30601,7 @@ var require_if = __commonJS((exports) => { // ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/thenElse.js var require_thenElse = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); - var util_1 = require_util(); + var util_1 = require_util2(); var def = { keyword: ["then", "else"], schemaType: ["object", "boolean"], @@ -27479,7 +30808,7 @@ var require_discriminator = __commonJS((exports) => { var types_1 = require_types2(); var compile_1 = require_compile(); var ref_error_1 = require_ref_error(); - var util_1 = require_util(); + var util_1 = require_util2(); var error48 = { message: ({ params: { discrError, tagName } }) => discrError === types_1.DiscrError.Tag ? `tag "${tagName}" must be string` : `value of tag "${tagName}" must be in oneOf`, params: ({ params: { discrError, tag, tagName } }) => (0, codegen_1._)`{error: ${discrError}, tag: ${tagName}, tagValue: ${tag}}` @@ -28044,7 +31373,7 @@ var require_limit = __commonJS((exports) => { }); // ../../node_modules/.pnpm/ajv-formats@3.0.1_ajv@8.18.0/node_modules/ajv-formats/dist/index.js -var require_dist4 = __commonJS((exports, module) => { +var require_dist8 = __commonJS((exports, module) => { Object.defineProperty(exports, "__esModule", { value: true }); var formats_1 = require_formats(); var limit_1 = require_limit(); @@ -28094,7 +31423,8 @@ function sendJson(res, status, body) { const payload = JSON.stringify(body); res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", - "Content-Length": Buffer.byteLength(payload, "utf8") + "Content-Length": Buffer.byteLength(payload, "utf8"), + "Cache-Control": "no-store" }); res.end(payload); } @@ -28165,11 +31495,349 @@ function isRequestAuthorized(authorizationHeader, auth) { function validatePathIsInAllowlist(filePath, allowlist) { return allowlist.some((allowlistItem) => filePath.startsWith(allowlistItem)); } +// src/nodeRenameFileExistenceProbe.ts +import { stat } from "node:fs/promises"; +// ../../node_modules/.pnpm/es-toolkit@1.44.0/node_modules/es-toolkit/dist/array/flatten.mjs +function flatten(arr, depth = 1) { + const result = []; + const flooredDepth = Math.floor(depth); + const recursive = (arr2, currentDepth) => { + for (let i = 0;i < arr2.length; i++) { + const item = arr2[i]; + if (Array.isArray(item) && currentDepth < flooredDepth) { + recursive(item, currentDepth + 1); + } else { + result.push(item); + } + } + }; + recursive(arr, 0); + return result; +} + +// ../../node_modules/.pnpm/es-toolkit@1.44.0/node_modules/es-toolkit/dist/array/flattenDeep.mjs +function flattenDeep(arr) { + return flatten(arr, Infinity); +} +// ../../node_modules/.pnpm/es-toolkit@1.44.0/node_modules/es-toolkit/dist/array/last.mjs +function last(arr) { + return arr[arr.length - 1]; +} +// ../../node_modules/.pnpm/slash@5.1.0/node_modules/slash/index.js +function slash(path) { + const isExtendedLengthPath = path.startsWith("\\\\?\\"); + if (isExtendedLengthPath) { + return path; + } + return path.replace(/\\/g, "/"); +} + +// ../../node_modules/.pnpm/filename-reserved-regex@4.0.0/node_modules/filename-reserved-regex/index.js +function filenameReservedRegex() { + return /[<>:"/\\|?*\u0000-\u001F]|[. ]$/g; +} +function windowsReservedNameRegex() { + return /^(con|prn|aux|nul|com\d|lpt\d)$/i; +} + +// ../../node_modules/.pnpm/filenamify@7.0.1/node_modules/filenamify/filenamify.js +var MAX_FILENAME_LENGTH = 100; +var reRelativePath = /^\.+(\\|\/)|^\.+$/; +var reTrailingDotsAndSpaces = /[. ]+$/; +var reControlChars = /[\p{Control}\p{Format}\p{Zl}\p{Zp}\uFFF0-\uFFFF]/gu; +var reControlCharsTest = /[\p{Control}\p{Format}\p{Zl}\p{Zp}\uFFF0-\uFFFF]/u; +var isZeroWidthJoiner = (char) => char === "‍"; +var reRepeatedReservedCharacters = /([<>:"/\\|?*\u0000-\u001F]){2,}/g; +var reReplacementReservedCharacters = /[<>:"/\\|?*\u0000-\u001F]/; +var reUnicodeWhitespace = /[\t\n\r\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]+/g; +var segmenter; +function getSegmenter() { + segmenter ??= new Intl.Segmenter(undefined, { granularity: "grapheme" }); + return segmenter; +} +function truncateFilename(filename, maxLength) { + if (filename.length <= maxLength) { + return filename; + } + const extensionIndex = filename.lastIndexOf("."); + if (extensionIndex === -1) { + return truncateByGraphemeBudget(filename, maxLength); + } + const base = filename.slice(0, extensionIndex); + const extension = filename.slice(extensionIndex); + const baseBudget = Math.max(0, maxLength - extension.length); + const truncatedBase = truncateByGraphemeBudget(base, baseBudget); + return truncatedBase.replace(/ +$/, "") + extension; +} +function filenamify(string, options = {}) { + if (typeof string !== "string") { + throw new TypeError("Expected a string"); + } + const replacement = options.replacement ?? "!"; + const hasReservedChars = reReplacementReservedCharacters.test(replacement); + const hasControlChars = [...replacement].some((char) => reControlCharsTest.test(char) && !isZeroWidthJoiner(char)); + if (hasReservedChars || hasControlChars) { + throw new Error("Replacement string cannot contain reserved filename characters"); + } + string = string.normalize("NFC"); + string = string.replaceAll(reUnicodeWhitespace, " "); + if (replacement.length > 0) { + string = string.replaceAll(reRepeatedReservedCharacters, "$1"); + } + string = string.replace(reTrailingDotsAndSpaces, ""); + string = string.replace(reRelativePath, replacement); + string = string.replace(filenameReservedRegex(), replacement); + string = string.replaceAll(reControlChars, (char) => isZeroWidthJoiner(char) ? char : replacement); + string = string.replace(reTrailingDotsAndSpaces, ""); + if (string.length === 0) { + string = replacement.replace(reTrailingDotsAndSpaces, ""); + if (string.length === 0 && replacement.length > 0) { + string = "!"; + } + } + const allowedLength = typeof options.maxLength === "number" ? options.maxLength : MAX_FILENAME_LENGTH; + string = truncateFilename(string, allowedLength); + string = string.replace(reTrailingDotsAndSpaces, ""); + if (windowsReservedNameRegex().test(string)) { + string += replacement; + } + return string; +} +function truncateByGraphemeBudget(input, budget) { + if (input.length <= budget) { + return input; + } + let count = 0; + let output = ""; + for (const { segment } of getSegmenter().segment(input)) { + const next = count + segment.length; + if (next > budget) { + break; + } + output += segment; + count = next; + } + return output; +} +// ../utils/src/path.ts +var WIN_PATH_SEPARATOR = "\\"; +var POSIX_PATH_SEPARATOR = "/"; +function isNotEmpty(part) { + return part.trim() !== ""; +} +function split(path) { + let parts = path.split(":\\").filter(isNotEmpty); + parts = flattenDeep(parts.map((part) => part.split("\\").filter(isNotEmpty))); + parts = flattenDeep(parts.map((part) => part.split("/").filter(isNotEmpty))); + return parts; +} + +class Path { + static serverPlatform = null; + root; + sub; + unc; + constructor(root, sub) { + if (root.trim() === "") { + throw new Error("InvalidArgumentError: root path cannot be empty"); + } + if (sub !== undefined) { + if (split(sub).length === 0) { + if (sub.length === 0) { + throw new Error("InvalidArgumentError: sub path cannot be empty"); + } else { + throw new Error("InvalidArgumentError: invalid sub path"); + } + } + } + if (sub?.trim() === "") { + throw new Error("InvalidArgumentError: sub path cannot be empty"); + } + this.unc = root.startsWith("\\\\"); + if (!(root.startsWith("/") || /^[A-Za-z]:/.test(root) || root.startsWith("\\\\"))) { + throw new Error(`InvalidArgumentError: root=${root}. root path must start with "/" for POSIX format, "C:" for Windows format, or "\\\\" for Windows UNC format`); + } + this.root = split(root); + this.sub = sub === undefined ? [] : split(sub); + if (this.root.length === 0) { + throw new Error("InvalidArgumentError: invalid root path"); + } + } + _uncPath() { + const serverName = this.root[0]; + const parentPath = this.root.slice(1).join(WIN_PATH_SEPARATOR); + const subPath = this.sub.length === 0 ? "" : WIN_PATH_SEPARATOR + this.sub.join(WIN_PATH_SEPARATOR); + return `\\\\${serverName}\\${parentPath}${subPath}`; + } + abs(type = "posix") { + if (type === "win") { + if (this.unc) { + return this._uncPath(); + } else { + if (this.root[0]?.length !== 1) { + return this._uncPath(); + } + const rootFolderPaths = this.root.slice(1).join(WIN_PATH_SEPARATOR); + const subpath = this.sub.length === 0 ? "" : WIN_PATH_SEPARATOR + this.sub.join(WIN_PATH_SEPARATOR); + return `${this.root[0]}:${WIN_PATH_SEPARATOR}${rootFolderPaths}${subpath}`; + } + } else { + const subpath = this.sub.length === 0 ? "" : POSIX_PATH_SEPARATOR + this.sub.join(POSIX_PATH_SEPARATOR); + return `${POSIX_PATH_SEPARATOR}${this.root.join(POSIX_PATH_SEPARATOR)}${subpath}`; + } + } + rel(type = "posix") { + if (type === "win") { + return this.sub.join(WIN_PATH_SEPARATOR); + } else { + return this.sub.join(POSIX_PATH_SEPARATOR); + } + } + name() { + return last(this.sub) || last(this.root) || ""; + } + dir() { + return "/" + this.root.join(POSIX_PATH_SEPARATOR); + } + cd(subpath) { + return new Path(this.dir(), subpath); + } + platformAbsPath() { + return Path.isWindows() ? this.abs("win") : this.abs("posix"); + } + platformRelPath() { + return Path.isWindows() ? this.rel("win") : this.rel("posix"); + } + join(subpath) { + const parts = split(subpath); + return new Path(POSIX_PATH_SEPARATOR + this.root.join(POSIX_PATH_SEPARATOR), [...this.sub, ...parts].join(POSIX_PATH_SEPARATOR)); + } + filename(newFileName) { + if (this.sub.length === 0) { + throw new Error("InvalidArgumentError: sub path cannot be empty"); + } else { + const validName = filenamify(newFileName); + return new Path(POSIX_PATH_SEPARATOR + this.root.join(POSIX_PATH_SEPARATOR), [...this.sub.slice(0, -1), validName].join(POSIX_PATH_SEPARATOR)); + } + } + parent() { + if (this.sub.length === 0) { + throw new Error("reaching parent folder is not allowed"); + } else { + const parentSub = this.sub.slice(0, -1); + if (parentSub.length === 0) { + return new Path(POSIX_PATH_SEPARATOR + this.root.join(POSIX_PATH_SEPARATOR)); + } else { + return new Path(POSIX_PATH_SEPARATOR + this.root.join(POSIX_PATH_SEPARATOR), parentSub.join(POSIX_PATH_SEPARATOR)); + } + } + } + static fromAbsolutePath(absolutePath, root) { + return new Path(root, absolutePath.replace(root, "")); + } + static posix(windowsPath) { + const p = new Path(windowsPath); + return p.abs("posix"); + } + static win(posixPath) { + const p = new Path(posixPath); + return p.abs("win"); + } + static slash(windowsPath) { + return slash(windowsPath); + } + static backslash(posixPath) { + return posixPath.replace(POSIX_PATH_SEPARATOR, WIN_PATH_SEPARATOR); + } + static setServerPlatform(platform) { + Path.serverPlatform = platform; + } + static resetServerPlatformForTests() { + Path.serverPlatform = null; + } + static getServerPlatform() { + return Path.serverPlatform; + } + static isWindows() { + if (Path.serverPlatform !== null) { + return Path.serverPlatform === "win32"; + } + const proc = typeof globalThis !== "undefined" ? globalThis.process : undefined; + if (proc?.platform) { + return proc.platform === "win32"; + } + const win = typeof globalThis !== "undefined" ? globalThis.window : undefined; + if (win) { + const electron = win.electron; + if (electron?.process?.platform) { + return electron.process.platform === "win32"; + } + } + return false; + } + static pathSeparator() { + return Path.isWindows() ? WIN_PATH_SEPARATOR : POSIX_PATH_SEPARATOR; + } + static toPlatformPath(path) { + return Path.isWindows() ? Path.win(path) : Path.posix(path); + } + toString() { + return this.abs(); + } +} + +// src/nodeRenameFileExistenceProbe.ts +function statWithTimeout(filePath, timeoutMs) { + return Promise.race([ + stat(filePath), + new Promise((_, reject) => setTimeout(() => reject(new Error(`stat timeout for path: ${filePath}`)), timeoutMs)) + ]); +} +function createNodeRenameFileExistenceProbe(timeoutMs = 1000) { + return { + async isFile(posixPath) { + try { + const stats = await statWithTimeout(Path.toPlatformPath(posixPath), timeoutMs); + return stats?.isFile() ?? false; + } catch { + return false; + } + } + }; +} +// src/bindAddresses.ts +var DEFAULT_BIND_ADDRESS = "127.0.0.1"; +function resolveWebUiBindAddress() { + const fromEnv = process.env.WEBUI_ADDRESS?.trim(); + return fromEnv || DEFAULT_BIND_ADDRESS; +} +function resolveReverseProxyBindAddress() { + const fromEnv = process.env.REVERSE_PROXY_ADDRESS?.trim(); + return fromEnv || DEFAULT_BIND_ADDRESS; +} +function resolveMcpBindAddress(fallback) { + const fromEnv = process.env.MCP_ADDRESS?.trim(); + if (fromEnv) { + return fromEnv; + } + const fb = fallback?.trim(); + return fb || DEFAULT_BIND_ADDRESS; +} +function resolveMcpAdvertisedHost(bindAddress) { + return resolveReverseProxyAdvertisedHost(bindAddress); +} +function resolveReverseProxyAdvertisedHost(bindAddress) { + if (bindAddress === "0.0.0.0" || bindAddress === "::") { + return DEFAULT_BIND_ADDRESS; + } + return bindAddress; +} // src/hello.ts function doHello(options) { return { uptime: process.uptime(), - ...options + ...options, + platform: options.platform ?? process.platform }; } // ../../node_modules/.pnpm/@ai-sdk+provider@3.0.8/node_modules/@ai-sdk/provider/dist/index.mjs @@ -28489,7 +32157,7 @@ var UnsupportedFunctionalityError = class extends (_b14 = AISDKError, _a14 = sym // ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/external.js var exports_external = {}; __export(exports_external, { - xor: () => xor, + xor: () => xor2, xid: () => xid2, void: () => _void2, uuidv7: () => uuidv7, @@ -28500,7 +32168,7 @@ __export(exports_external, { url: () => url, uppercase: () => _uppercase, unknown: () => unknown, - union: () => union, + union: () => union2, undefined: () => _undefined3, ulid: () => ulid2, uint64: () => uint64, @@ -28588,7 +32256,7 @@ __export(exports_external, { iso: () => exports_iso, ipv6: () => ipv62, ipv4: () => ipv42, - intersection: () => intersection, + intersection: () => intersection2, int64: () => int64, int32: () => int32, int: () => int, @@ -28630,7 +32298,7 @@ __export(exports_external, { config: () => config, coerce: () => exports_coerce, codec: () => codec, - clone: () => clone, + clone: () => clone2, cidrv6: () => cidrv62, cidrv4: () => cidrv42, check: () => check, @@ -28767,7 +32435,7 @@ __export(exports_core2, { createToJSONSchemaMethod: () => createToJSONSchemaMethod, createStandardJSONSchemaMethod: () => createStandardJSONSchemaMethod, config: () => config, - clone: () => clone, + clone: () => clone2, _xor: () => _xor, _xid: () => _xid, _void: () => _void, @@ -29098,21 +32766,21 @@ __export(exports_util, { promiseAllObject: () => promiseAllObject, primitiveTypes: () => primitiveTypes, prefixIssues: () => prefixIssues, - pick: () => pick, - partial: () => partial, + pick: () => pick2, + partial: () => partial2, parsedType: () => parsedType, optionalKeys: () => optionalKeys, - omit: () => omit, + omit: () => omit2, objectClone: () => objectClone, numKeys: () => numKeys, nullish: () => nullish, normalizeParams: () => normalizeParams, mergeDefs: () => mergeDefs, - merge: () => merge, + merge: () => merge2, jsonStringifyReplacer: () => jsonStringifyReplacer, joinValues: () => joinValues, issue: () => issue, - isPlainObject: () => isPlainObject, + isPlainObject: () => isPlainObject2, isObject: () => isObject, hexToUint8Array: () => hexToUint8Array, getSizableOrigin: () => getSizableOrigin, @@ -29128,7 +32796,7 @@ __export(exports_util, { defineLazy: () => defineLazy, createTransparentProxy: () => createTransparentProxy, cloneDef: () => cloneDef, - clone: () => clone, + clone: () => clone2, cleanRegex: () => cleanRegex, cleanEnum: () => cleanEnum, captureStackTrace: () => captureStackTrace, @@ -29297,7 +32965,7 @@ var allowsEval = cached(() => { return false; } }); -function isPlainObject(o) { +function isPlainObject2(o) { if (isObject(o) === false) return false; const ctor = o.constructor; @@ -29314,7 +32982,7 @@ function isPlainObject(o) { return true; } function shallowClone(o) { - if (isPlainObject(o)) + if (isPlainObject2(o)) return { ...o }; if (Array.isArray(o)) return [...o]; @@ -29378,7 +33046,7 @@ var primitiveTypes = new Set(["string", "number", "bigint", "boolean", "symbol", function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -function clone(inst, def, params) { +function clone2(inst, def, params) { const cl = new inst._zod.constr(def ?? inst._zod.def); if (!def || params?.parent) cl._zod.parent = inst; @@ -29456,7 +33124,7 @@ var BIGINT_FORMAT_RANGES = { int64: [/* @__PURE__ */ BigInt("-9223372036854775808"), /* @__PURE__ */ BigInt("9223372036854775807")], uint64: [/* @__PURE__ */ BigInt(0), /* @__PURE__ */ BigInt("18446744073709551615")] }; -function pick(schema, mask) { +function pick2(schema, mask) { const currDef = schema._zod.def; const checks = currDef.checks; const hasChecks = checks && checks.length > 0; @@ -29479,9 +33147,9 @@ function pick(schema, mask) { }, checks: [] }); - return clone(schema, def); + return clone2(schema, def); } -function omit(schema, mask) { +function omit2(schema, mask) { const currDef = schema._zod.def; const checks = currDef.checks; const hasChecks = checks && checks.length > 0; @@ -29504,10 +33172,10 @@ function omit(schema, mask) { }, checks: [] }); - return clone(schema, def); + return clone2(schema, def); } function extend(schema, shape) { - if (!isPlainObject(shape)) { + if (!isPlainObject2(shape)) { throw new Error("Invalid input to extend: expected a plain object"); } const checks = schema._zod.def.checks; @@ -29527,10 +33195,10 @@ function extend(schema, shape) { return _shape; } }); - return clone(schema, def); + return clone2(schema, def); } function safeExtend(schema, shape) { - if (!isPlainObject(shape)) { + if (!isPlainObject2(shape)) { throw new Error("Invalid input to safeExtend: expected a plain object"); } const def = mergeDefs(schema._zod.def, { @@ -29540,9 +33208,9 @@ function safeExtend(schema, shape) { return _shape; } }); - return clone(schema, def); + return clone2(schema, def); } -function merge(a, b) { +function merge2(a, b) { const def = mergeDefs(a._zod.def, { get shape() { const _shape = { ...a._zod.def.shape, ...b._zod.def.shape }; @@ -29554,9 +33222,9 @@ function merge(a, b) { }, checks: [] }); - return clone(a, def); + return clone2(a, def); } -function partial(Class, schema, mask) { +function partial2(Class, schema, mask) { const currDef = schema._zod.def; const checks = currDef.checks; const hasChecks = checks && checks.length > 0; @@ -29592,7 +33260,7 @@ function partial(Class, schema, mask) { }, checks: [] }); - return clone(schema, def); + return clone2(schema, def); } function required(Class, schema, mask) { const def = mergeDefs(schema._zod.def, { @@ -29623,7 +33291,7 @@ function required(Class, schema, mask) { return shape; } }); - return clone(schema, def); + return clone2(schema, def); } function aborted(x, startIndex = 0) { if (x.aborted === true) @@ -31312,15 +34980,15 @@ var $ZodDate = /* @__PURE__ */ $constructor("$ZodDate", (inst, def) => { } catch (_err) {} } const input = payload.value; - const isDate = input instanceof Date; - const isValidDate = isDate && !Number.isNaN(input.getTime()); + const isDate2 = input instanceof Date; + const isValidDate = isDate2 && !Number.isNaN(input.getTime()); if (isValidDate) return payload; payload.issues.push({ expected: "date", code: "invalid_type", input, - ...isDate ? { received: "Invalid Date" } : {}, + ...isDate2 ? { received: "Invalid Date" } : {}, inst }); return payload; @@ -31800,7 +35468,7 @@ function mergeValues(a, b) { if (a instanceof Date && b instanceof Date && +a === +b) { return { valid: true, data: a }; } - if (isPlainObject(a) && isPlainObject(b)) { + if (isPlainObject2(a) && isPlainObject2(b)) { const bKeys = Object.keys(b); const sharedKeys = Object.keys(a).filter((key) => bKeys.indexOf(key) !== -1); const newObj = { ...a, ...b }; @@ -31925,8 +35593,8 @@ var $ZodTuple = /* @__PURE__ */ $constructor("$ZodTuple", (inst, def) => { } } if (def.rest) { - const rest = input.slice(items.length); - for (const el of rest) { + const rest2 = input.slice(items.length); + for (const el of rest2) { i++; const result = def.rest._zod.run({ value: el, @@ -31954,7 +35622,7 @@ var $ZodRecord = /* @__PURE__ */ $constructor("$ZodRecord", (inst, def) => { $ZodType.init(inst, def); inst._zod.parse = (payload, ctx) => { const input = payload.value; - if (!isPlainObject(input)) { + if (!isPlainObject2(input)) { payload.issues.push({ expected: "record", code: "invalid_type", @@ -35682,11 +39350,11 @@ var capitalizeFirstCharacter = (text) => { }; function getUnitTypeFromNumber(number2) { const abs = Math.abs(number2); - const last = abs % 10; - const last2 = abs % 100; - if (last2 >= 11 && last2 <= 19 || last === 0) + const last2 = abs % 10; + const last22 = abs % 100; + if (last22 >= 11 && last22 <= 19 || last2 === 0) return "many"; - if (last === 1) + if (last2 === 1) return "one"; return "few"; } @@ -38912,11 +42580,11 @@ function _intersection(Class2, left, right) { function _tuple(Class2, items, _paramsOrRest, _params) { const hasRest = _paramsOrRest instanceof $ZodType; const params = hasRest ? _params : _paramsOrRest; - const rest = hasRest ? _paramsOrRest : null; + const rest2 = hasRest ? _paramsOrRest : null; return new Class2({ type: "tuple", items, - rest, + rest: rest2, ...normalizeParams(params) }); } @@ -39856,30 +43524,30 @@ var tupleProcessor = (schema, ctx, _json, params) => { ...params, path: [...params.path, prefixPath, i] })); - const rest = def.rest ? process2(def.rest, ctx, { + const rest2 = def.rest ? process2(def.rest, ctx, { ...params, path: [...params.path, restPath, ...ctx.target === "openapi-3.0" ? [def.items.length] : []] }) : null; if (ctx.target === "draft-2020-12") { json.prefixItems = prefixItems; - if (rest) { - json.items = rest; + if (rest2) { + json.items = rest2; } } else if (ctx.target === "openapi-3.0") { json.items = { anyOf: prefixItems }; - if (rest) { - json.items.anyOf.push(rest); + if (rest2) { + json.items.anyOf.push(rest2); } json.minItems = prefixItems.length; - if (!rest) { + if (!rest2) { json.maxItems = prefixItems.length; } } else { json.items = prefixItems; - if (rest) { - json.additionalItems = rest; + if (rest2) { + json.additionalItems = rest2; } } const { minimum, maximum } = schema._zod.bag; @@ -40140,7 +43808,7 @@ var exports_json_schema = {}; // ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/schemas.js var exports_schemas2 = {}; __export(exports_schemas2, { - xor: () => xor, + xor: () => xor2, xid: () => xid2, void: () => _void2, uuidv7: () => uuidv7, @@ -40149,7 +43817,7 @@ __export(exports_schemas2, { uuid: () => uuid2, url: () => url, unknown: () => unknown, - union: () => union, + union: () => union2, undefined: () => _undefined3, ulid: () => ulid2, uint64: () => uint64, @@ -40197,7 +43865,7 @@ __export(exports_schemas2, { json: () => json, ipv6: () => ipv62, ipv4: () => ipv42, - intersection: () => intersection, + intersection: () => intersection2, int64: () => int64, int32: () => int32, int: () => int, @@ -40454,7 +44122,7 @@ var ZodType = /* @__PURE__ */ $constructor("ZodType", (inst, def) => { }); }; inst.with = inst.check; - inst.clone = (def2, params) => clone(inst, def2, params); + inst.clone = (def2, params) => clone2(inst, def2, params); inst.brand = () => inst; inst.register = (reg, meta2) => { reg.add(inst, meta2); @@ -40482,8 +44150,8 @@ var ZodType = /* @__PURE__ */ $constructor("ZodType", (inst, def) => { inst.nullish = () => optional(nullable(inst)); inst.nonoptional = (params) => nonoptional(inst, params); inst.array = () => array(inst); - inst.or = (arg) => union([inst, arg]); - inst.and = (arg) => intersection(inst, arg); + inst.or = (arg) => union2([inst, arg]); + inst.and = (arg) => intersection2(inst, arg); inst.transform = (tx) => pipe(inst, transform(tx)); inst.default = (def2) => _default2(inst, def2); inst.prefault = (def2) => prefault(inst, def2); @@ -40986,7 +44654,7 @@ var ZodUnion = /* @__PURE__ */ $constructor("ZodUnion", (inst, def) => { inst._zod.processJSONSchema = (ctx, json, params) => unionProcessor(inst, ctx, json, params); inst.options = def.options; }); -function union(options, params) { +function union2(options, params) { return new ZodUnion({ type: "union", options, @@ -40999,7 +44667,7 @@ var ZodXor = /* @__PURE__ */ $constructor("ZodXor", (inst, def) => { inst._zod.processJSONSchema = (ctx, json, params) => unionProcessor(inst, ctx, json, params); inst.options = def.options; }); -function xor(options, params) { +function xor2(options, params) { return new ZodXor({ type: "union", options, @@ -41024,7 +44692,7 @@ var ZodIntersection = /* @__PURE__ */ $constructor("ZodIntersection", (inst, def ZodType.init(inst, def); inst._zod.processJSONSchema = (ctx, json, params) => intersectionProcessor(inst, ctx, json, params); }); -function intersection(left, right) { +function intersection2(left, right) { return new ZodIntersection({ type: "intersection", left, @@ -41035,19 +44703,19 @@ var ZodTuple = /* @__PURE__ */ $constructor("ZodTuple", (inst, def) => { $ZodTuple.init(inst, def); ZodType.init(inst, def); inst._zod.processJSONSchema = (ctx, json, params) => tupleProcessor(inst, ctx, json, params); - inst.rest = (rest) => inst.clone({ + inst.rest = (rest2) => inst.clone({ ...inst._zod.def, - rest + rest: rest2 }); }); function tuple(items, _paramsOrRest, _params) { const hasRest = _paramsOrRest instanceof $ZodType; const params = hasRest ? _params : _paramsOrRest; - const rest = hasRest ? _paramsOrRest : null; + const rest2 = hasRest ? _paramsOrRest : null; return new ZodTuple({ type: "tuple", items, - rest, + rest: rest2, ...exports_util.normalizeParams(params) }); } @@ -41067,7 +44735,7 @@ function record(keyType, valueType, params) { }); } function partialRecord(keyType, valueType, params) { - const k = clone(keyType); + const k = clone2(keyType); k._zod.values = undefined; return new ZodRecord({ type: "record", @@ -41499,7 +45167,7 @@ var stringbool = (...args) => _stringbool({ }, ...args); function json(params) { const jsonSchema = lazy(() => { - return union([string2(params), number2(), boolean2(), _null3(), array(jsonSchema), record(string2(), jsonSchema)]); + return union2([string2(params), number2(), boolean2(), _null3(), array(jsonSchema), record(string2(), jsonSchema)]); }); return jsonSchema; } @@ -41866,9 +45534,9 @@ function convertBaseSchema(schema, ctx) { const items = schema.items; if (prefixItems && Array.isArray(prefixItems)) { const tupleItems = prefixItems.map((item) => convertSchema(item, ctx)); - const rest = items && typeof items === "object" && !Array.isArray(items) ? convertSchema(items, ctx) : undefined; - if (rest) { - zodSchema = z.tuple(tupleItems).rest(rest); + const rest2 = items && typeof items === "object" && !Array.isArray(items) ? convertSchema(items, ctx) : undefined; + if (rest2) { + zodSchema = z.tuple(tupleItems).rest(rest2); } else { zodSchema = z.tuple(tupleItems); } @@ -41880,9 +45548,9 @@ function convertBaseSchema(schema, ctx) { } } else if (Array.isArray(items)) { const tupleItems = items.map((item) => convertSchema(item, ctx)); - const rest = schema.additionalItems && typeof schema.additionalItems === "object" ? convertSchema(schema.additionalItems, ctx) : undefined; - if (rest) { - zodSchema = z.tuple(tupleItems).rest(rest); + const rest2 = schema.additionalItems && typeof schema.additionalItems === "object" ? convertSchema(schema.additionalItems, ctx) : undefined; + if (rest2) { + zodSchema = z.tuple(tupleItems).rest(rest2); } else { zodSchema = z.tuple(tupleItems); } @@ -44950,8 +48618,8 @@ class ZodTuple2 extends ZodType2 { }); return INVALID; } - const rest = this._def.rest; - if (!rest && ctx.data.length > this._def.items.length) { + const rest2 = this._def.rest; + if (!rest2 && ctx.data.length > this._def.items.length) { addIssueToContext(ctx, { code: ZodIssueCode2.too_big, maximum: this._def.items.length, @@ -44978,10 +48646,10 @@ class ZodTuple2 extends ZodType2 { get items() { return this._def.items; } - rest(rest) { + rest(rest2) { return new ZodTuple2({ ...this._def, - rest + rest: rest2 }); } } @@ -45997,14 +49665,14 @@ class ParseError extends Error { super(message), this.name = "ParseError", this.type = options.type, this.field = options.field, this.value = options.value, this.line = options.line; } } -function noop(_arg) {} +function noop2(_arg) {} function createParser(callbacks) { if (typeof callbacks == "function") throw new TypeError("`callbacks` must be an object, got a function instead. Did you mean `{onEvent: fn}`?"); - const { onEvent = noop, onError = noop, onRetry = noop, onComment } = callbacks; + const { onEvent = noop2, onError = noop2, onRetry = noop2, onComment } = callbacks; let incompleteLine = "", isFirstChunk = true, id, data = "", eventType = ""; function feed(newChunk) { - const chunk = isFirstChunk ? newChunk.replace(/^\xEF\xBB\xBF/, "") : newChunk, [complete, incomplete] = splitLines(`${incompleteLine}${chunk}`); + const chunk2 = isFirstChunk ? newChunk.replace(/^\xEF\xBB\xBF/, "") : newChunk, [complete, incomplete] = splitLines(`${incompleteLine}${chunk2}`); for (const line of complete) parseLine(line); incompleteLine = incomplete, isFirstChunk = false; @@ -46063,19 +49731,19 @@ function createParser(callbacks) { } return { feed, reset }; } -function splitLines(chunk) { +function splitLines(chunk2) { const lines = []; let incompleteLine = "", searchIndex = 0; - for (;searchIndex < chunk.length; ) { - const crIndex = chunk.indexOf("\r", searchIndex), lfIndex = chunk.indexOf(` + for (;searchIndex < chunk2.length; ) { + const crIndex = chunk2.indexOf("\r", searchIndex), lfIndex = chunk2.indexOf(` `, searchIndex); let lineEnd = -1; - if (crIndex !== -1 && lfIndex !== -1 ? lineEnd = Math.min(crIndex, lfIndex) : crIndex !== -1 ? crIndex === chunk.length - 1 ? lineEnd = -1 : lineEnd = crIndex : lfIndex !== -1 && (lineEnd = lfIndex), lineEnd === -1) { - incompleteLine = chunk.slice(searchIndex); + if (crIndex !== -1 && lfIndex !== -1 ? lineEnd = Math.min(crIndex, lfIndex) : crIndex !== -1 ? crIndex === chunk2.length - 1 ? lineEnd = -1 : lineEnd = crIndex : lfIndex !== -1 && (lineEnd = lfIndex), lineEnd === -1) { + incompleteLine = chunk2.slice(searchIndex); break; } else { - const line = chunk.slice(searchIndex, lineEnd); - lines.push(line), searchIndex = lineEnd + 1, chunk[searchIndex - 1] === "\r" && chunk[searchIndex] === ` + const line = chunk2.slice(searchIndex, lineEnd); + lines.push(line), searchIndex = lineEnd + 1, chunk2[searchIndex - 1] === "\r" && chunk2[searchIndex] === ` ` && searchIndex++; } } @@ -46099,8 +49767,8 @@ class EventSourceParserStream extends TransformStream { onComment }); }, - transform(chunk) { - parser.feed(chunk); + transform(chunk2) { + parser.feed(chunk2); } }); } @@ -46113,7 +49781,7 @@ function combineHeaders(...headers) { ...currentHeaders != null ? currentHeaders : {} }), {}); } -async function delay(delayInMs, options) { +async function delay2(delayInMs, options) { if (delayInMs == null) { return Promise.resolve(); } @@ -46297,9 +49965,9 @@ async function readResponseWithSizeLimit({ } const result = new Uint8Array(totalBytes); let offset = 0; - for (const chunk of chunks) { - result.set(chunk, offset); - offset += chunk.length; + for (const chunk2 of chunks) { + result.set(chunk2, offset); + offset += chunk2.length; } return result; } @@ -46815,8 +50483,8 @@ function parseIntersectionDef(def, refs) { } else { let nestedSchema = schema; if ("additionalProperties" in schema && schema.additionalProperties === false) { - const { additionalProperties, ...rest } = schema; - nestedSchema = rest; + const { additionalProperties, ...rest2 } = schema; + nestedSchema = rest2; } mergedAllOf.push(nestedSchema); } @@ -48634,9 +52302,9 @@ var GatewayLanguageModel = class { controller.enqueue({ type: "stream-start", warnings }); } }, - transform(chunk, controller) { - if (chunk.success) { - const streamPart = chunk.value; + transform(chunk2, controller) { + if (chunk2.success) { + const streamPart = chunk2.value; if (streamPart.type === "raw" && !options.includeRawChunks) { return; } @@ -48645,7 +52313,7 @@ var GatewayLanguageModel = class { } controller.enqueue(streamPart); } else { - controller.error(chunk.error); + controller.error(chunk2.error); } } })), @@ -49730,17 +53398,17 @@ function asLanguageModelV3(model) { } function convertV2StreamToV3(stream) { return stream.pipeThrough(new TransformStream({ - transform(chunk, controller) { - switch (chunk.type) { + transform(chunk2, controller) { + switch (chunk2.type) { case "finish": controller.enqueue({ - ...chunk, - finishReason: convertV2FinishReasonToV3(chunk.finishReason), - usage: convertV2UsageToV3(chunk.usage) + ...chunk2, + finishReason: convertV2FinishReasonToV3(chunk2.finishReason), + usage: convertV2UsageToV3(chunk2.usage) }); break; default: - controller.enqueue(chunk); + controller.enqueue(chunk2); break; } } @@ -49785,26 +53453,26 @@ function getGlobalProvider() { var _a21; return (_a21 = globalThis.AI_SDK_DEFAULT_PROVIDER) != null ? _a21 : gateway; } -function getTotalTimeoutMs(timeout) { - if (timeout == null) { +function getTotalTimeoutMs(timeout2) { + if (timeout2 == null) { return; } - if (typeof timeout === "number") { - return timeout; + if (typeof timeout2 === "number") { + return timeout2; } - return timeout.totalMs; + return timeout2.totalMs; } -function getStepTimeoutMs(timeout) { - if (timeout == null || typeof timeout === "number") { +function getStepTimeoutMs(timeout2) { + if (timeout2 == null || typeof timeout2 === "number") { return; } - return timeout.stepMs; + return timeout2.stepMs; } -function getChunkTimeoutMs(timeout) { - if (timeout == null || typeof timeout === "number") { +function getChunkTimeoutMs(timeout2) { + if (timeout2 == null || typeof timeout2 === "number") { return; } - return timeout.chunkMs; + return timeout2.chunkMs; } var imageMediaTypeSignatures = [ { @@ -51082,7 +54750,7 @@ async function _retryWithExponentialBackoff(f, { }); } if (error48 instanceof Error && APICallError.isInstance(error48) && error48.isRetryable === true && tryNumber <= maxRetries) { - await delay(getRetryDelayInMs({ + await delay2(getRetryDelayInMs({ error: error48, exponentialBackoffDelay: delayInMs }), { abortSignal }); @@ -52653,8 +56321,8 @@ var uiMessageChunkSchema = lazySchema(() => zodSchema(exports_external.union([ messageMetadata: exports_external.unknown() }) ]))); -function isDataUIMessageChunk(chunk) { - return chunk.type.startsWith("data-"); +function isDataUIMessageChunk(chunk2) { + return chunk2.type.startsWith("data-"); } function isDataUIPart(part) { return part.type.startsWith("data-"); @@ -52709,7 +56377,7 @@ function processUIMessageStream({ onData }) { return stream.pipeThrough(new TransformStream({ - async transform(chunk, controller) { + async transform(chunk2, controller) { await runUpdateMessageJob(async ({ state, write }) => { var _a21, _b16, _c, _d; function getToolInvocation(toolCallId) { @@ -52811,45 +56479,45 @@ function processUIMessageStream({ state.message.metadata = mergedMetadata; } } - switch (chunk.type) { + switch (chunk2.type) { case "text-start": { const textPart = { type: "text", text: "", - providerMetadata: chunk.providerMetadata, + providerMetadata: chunk2.providerMetadata, state: "streaming" }; - state.activeTextParts[chunk.id] = textPart; + state.activeTextParts[chunk2.id] = textPart; state.message.parts.push(textPart); write(); break; } case "text-delta": { - const textPart = state.activeTextParts[chunk.id]; + const textPart = state.activeTextParts[chunk2.id]; if (textPart == null) { throw new UIMessageStreamError({ chunkType: "text-delta", - chunkId: chunk.id, - message: `Received text-delta for missing text part with ID "${chunk.id}". Ensure a "text-start" chunk is sent before any "text-delta" chunks.` + chunkId: chunk2.id, + message: `Received text-delta for missing text part with ID "${chunk2.id}". Ensure a "text-start" chunk is sent before any "text-delta" chunks.` }); } - textPart.text += chunk.delta; - textPart.providerMetadata = (_a21 = chunk.providerMetadata) != null ? _a21 : textPart.providerMetadata; + textPart.text += chunk2.delta; + textPart.providerMetadata = (_a21 = chunk2.providerMetadata) != null ? _a21 : textPart.providerMetadata; write(); break; } case "text-end": { - const textPart = state.activeTextParts[chunk.id]; + const textPart = state.activeTextParts[chunk2.id]; if (textPart == null) { throw new UIMessageStreamError({ chunkType: "text-end", - chunkId: chunk.id, - message: `Received text-end for missing text part with ID "${chunk.id}". Ensure a "text-start" chunk is sent before any "text-end" chunks.` + chunkId: chunk2.id, + message: `Received text-end for missing text part with ID "${chunk2.id}". Ensure a "text-start" chunk is sent before any "text-end" chunks.` }); } textPart.state = "done"; - textPart.providerMetadata = (_b16 = chunk.providerMetadata) != null ? _b16 : textPart.providerMetadata; - delete state.activeTextParts[chunk.id]; + textPart.providerMetadata = (_b16 = chunk2.providerMetadata) != null ? _b16 : textPart.providerMetadata; + delete state.activeTextParts[chunk2.id]; write(); break; } @@ -52857,48 +56525,48 @@ function processUIMessageStream({ const reasoningPart = { type: "reasoning", text: "", - providerMetadata: chunk.providerMetadata, + providerMetadata: chunk2.providerMetadata, state: "streaming" }; - state.activeReasoningParts[chunk.id] = reasoningPart; + state.activeReasoningParts[chunk2.id] = reasoningPart; state.message.parts.push(reasoningPart); write(); break; } case "reasoning-delta": { - const reasoningPart = state.activeReasoningParts[chunk.id]; + const reasoningPart = state.activeReasoningParts[chunk2.id]; if (reasoningPart == null) { throw new UIMessageStreamError({ chunkType: "reasoning-delta", - chunkId: chunk.id, - message: `Received reasoning-delta for missing reasoning part with ID "${chunk.id}". Ensure a "reasoning-start" chunk is sent before any "reasoning-delta" chunks.` + chunkId: chunk2.id, + message: `Received reasoning-delta for missing reasoning part with ID "${chunk2.id}". Ensure a "reasoning-start" chunk is sent before any "reasoning-delta" chunks.` }); } - reasoningPart.text += chunk.delta; - reasoningPart.providerMetadata = (_c = chunk.providerMetadata) != null ? _c : reasoningPart.providerMetadata; + reasoningPart.text += chunk2.delta; + reasoningPart.providerMetadata = (_c = chunk2.providerMetadata) != null ? _c : reasoningPart.providerMetadata; write(); break; } case "reasoning-end": { - const reasoningPart = state.activeReasoningParts[chunk.id]; + const reasoningPart = state.activeReasoningParts[chunk2.id]; if (reasoningPart == null) { throw new UIMessageStreamError({ chunkType: "reasoning-end", - chunkId: chunk.id, - message: `Received reasoning-end for missing reasoning part with ID "${chunk.id}". Ensure a "reasoning-start" chunk is sent before any "reasoning-end" chunks.` + chunkId: chunk2.id, + message: `Received reasoning-end for missing reasoning part with ID "${chunk2.id}". Ensure a "reasoning-start" chunk is sent before any "reasoning-end" chunks.` }); } - reasoningPart.providerMetadata = (_d = chunk.providerMetadata) != null ? _d : reasoningPart.providerMetadata; + reasoningPart.providerMetadata = (_d = chunk2.providerMetadata) != null ? _d : reasoningPart.providerMetadata; reasoningPart.state = "done"; - delete state.activeReasoningParts[chunk.id]; + delete state.activeReasoningParts[chunk2.id]; write(); break; } case "file": { state.message.parts.push({ type: "file", - mediaType: chunk.mediaType, - url: chunk.url + mediaType: chunk2.mediaType, + url: chunk2.url }); write(); break; @@ -52906,10 +56574,10 @@ function processUIMessageStream({ case "source-url": { state.message.parts.push({ type: "source-url", - sourceId: chunk.sourceId, - url: chunk.url, - title: chunk.title, - providerMetadata: chunk.providerMetadata + sourceId: chunk2.sourceId, + url: chunk2.url, + title: chunk2.title, + providerMetadata: chunk2.providerMetadata }); write(); break; @@ -52917,62 +56585,62 @@ function processUIMessageStream({ case "source-document": { state.message.parts.push({ type: "source-document", - sourceId: chunk.sourceId, - mediaType: chunk.mediaType, - title: chunk.title, - filename: chunk.filename, - providerMetadata: chunk.providerMetadata + sourceId: chunk2.sourceId, + mediaType: chunk2.mediaType, + title: chunk2.title, + filename: chunk2.filename, + providerMetadata: chunk2.providerMetadata }); write(); break; } case "tool-input-start": { const toolInvocations = state.message.parts.filter(isStaticToolUIPart); - state.partialToolCalls[chunk.toolCallId] = { + state.partialToolCalls[chunk2.toolCallId] = { text: "", - toolName: chunk.toolName, + toolName: chunk2.toolName, index: toolInvocations.length, - dynamic: chunk.dynamic, - title: chunk.title + dynamic: chunk2.dynamic, + title: chunk2.title }; - if (chunk.dynamic) { + if (chunk2.dynamic) { updateDynamicToolPart({ - toolCallId: chunk.toolCallId, - toolName: chunk.toolName, + toolCallId: chunk2.toolCallId, + toolName: chunk2.toolName, state: "input-streaming", input: undefined, - providerExecuted: chunk.providerExecuted, - title: chunk.title, - providerMetadata: chunk.providerMetadata + providerExecuted: chunk2.providerExecuted, + title: chunk2.title, + providerMetadata: chunk2.providerMetadata }); } else { updateToolPart({ - toolCallId: chunk.toolCallId, - toolName: chunk.toolName, + toolCallId: chunk2.toolCallId, + toolName: chunk2.toolName, state: "input-streaming", input: undefined, - providerExecuted: chunk.providerExecuted, - title: chunk.title, - providerMetadata: chunk.providerMetadata + providerExecuted: chunk2.providerExecuted, + title: chunk2.title, + providerMetadata: chunk2.providerMetadata }); } write(); break; } case "tool-input-delta": { - const partialToolCall = state.partialToolCalls[chunk.toolCallId]; + const partialToolCall = state.partialToolCalls[chunk2.toolCallId]; if (partialToolCall == null) { throw new UIMessageStreamError({ chunkType: "tool-input-delta", - chunkId: chunk.toolCallId, - message: `Received tool-input-delta for missing tool call with ID "${chunk.toolCallId}". Ensure a "tool-input-start" chunk is sent before any "tool-input-delta" chunks.` + chunkId: chunk2.toolCallId, + message: `Received tool-input-delta for missing tool call with ID "${chunk2.toolCallId}". Ensure a "tool-input-start" chunk is sent before any "tool-input-delta" chunks.` }); } - partialToolCall.text += chunk.inputTextDelta; + partialToolCall.text += chunk2.inputTextDelta; const { value: partialArgs } = await parsePartialJson(partialToolCall.text); if (partialToolCall.dynamic) { updateDynamicToolPart({ - toolCallId: chunk.toolCallId, + toolCallId: chunk2.toolCallId, toolName: partialToolCall.toolName, state: "input-streaming", input: partialArgs, @@ -52980,7 +56648,7 @@ function processUIMessageStream({ }); } else { updateToolPart({ - toolCallId: chunk.toolCallId, + toolCallId: chunk2.toolCallId, toolName: partialToolCall.toolName, state: "input-streaming", input: partialArgs, @@ -52991,96 +56659,96 @@ function processUIMessageStream({ break; } case "tool-input-available": { - if (chunk.dynamic) { + if (chunk2.dynamic) { updateDynamicToolPart({ - toolCallId: chunk.toolCallId, - toolName: chunk.toolName, + toolCallId: chunk2.toolCallId, + toolName: chunk2.toolName, state: "input-available", - input: chunk.input, - providerExecuted: chunk.providerExecuted, - providerMetadata: chunk.providerMetadata, - title: chunk.title + input: chunk2.input, + providerExecuted: chunk2.providerExecuted, + providerMetadata: chunk2.providerMetadata, + title: chunk2.title }); } else { updateToolPart({ - toolCallId: chunk.toolCallId, - toolName: chunk.toolName, + toolCallId: chunk2.toolCallId, + toolName: chunk2.toolName, state: "input-available", - input: chunk.input, - providerExecuted: chunk.providerExecuted, - providerMetadata: chunk.providerMetadata, - title: chunk.title + input: chunk2.input, + providerExecuted: chunk2.providerExecuted, + providerMetadata: chunk2.providerMetadata, + title: chunk2.title }); } write(); - if (onToolCall && !chunk.providerExecuted) { + if (onToolCall && !chunk2.providerExecuted) { await onToolCall({ - toolCall: chunk + toolCall: chunk2 }); } break; } case "tool-input-error": { - if (chunk.dynamic) { + if (chunk2.dynamic) { updateDynamicToolPart({ - toolCallId: chunk.toolCallId, - toolName: chunk.toolName, + toolCallId: chunk2.toolCallId, + toolName: chunk2.toolName, state: "output-error", - input: chunk.input, - errorText: chunk.errorText, - providerExecuted: chunk.providerExecuted, - providerMetadata: chunk.providerMetadata + input: chunk2.input, + errorText: chunk2.errorText, + providerExecuted: chunk2.providerExecuted, + providerMetadata: chunk2.providerMetadata }); } else { updateToolPart({ - toolCallId: chunk.toolCallId, - toolName: chunk.toolName, + toolCallId: chunk2.toolCallId, + toolName: chunk2.toolName, state: "output-error", input: undefined, - rawInput: chunk.input, - errorText: chunk.errorText, - providerExecuted: chunk.providerExecuted, - providerMetadata: chunk.providerMetadata + rawInput: chunk2.input, + errorText: chunk2.errorText, + providerExecuted: chunk2.providerExecuted, + providerMetadata: chunk2.providerMetadata }); } write(); break; } case "tool-approval-request": { - const toolInvocation = getToolInvocation(chunk.toolCallId); + const toolInvocation = getToolInvocation(chunk2.toolCallId); toolInvocation.state = "approval-requested"; - toolInvocation.approval = { id: chunk.approvalId }; + toolInvocation.approval = { id: chunk2.approvalId }; write(); break; } case "tool-output-denied": { - const toolInvocation = getToolInvocation(chunk.toolCallId); + const toolInvocation = getToolInvocation(chunk2.toolCallId); toolInvocation.state = "output-denied"; write(); break; } case "tool-output-available": { - const toolInvocation = getToolInvocation(chunk.toolCallId); + const toolInvocation = getToolInvocation(chunk2.toolCallId); if (toolInvocation.type === "dynamic-tool") { updateDynamicToolPart({ - toolCallId: chunk.toolCallId, + toolCallId: chunk2.toolCallId, toolName: toolInvocation.toolName, state: "output-available", input: toolInvocation.input, - output: chunk.output, - preliminary: chunk.preliminary, - providerExecuted: chunk.providerExecuted, + output: chunk2.output, + preliminary: chunk2.preliminary, + providerExecuted: chunk2.providerExecuted, title: toolInvocation.title }); } else { updateToolPart({ - toolCallId: chunk.toolCallId, + toolCallId: chunk2.toolCallId, toolName: getStaticToolName(toolInvocation), state: "output-available", input: toolInvocation.input, - output: chunk.output, - providerExecuted: chunk.providerExecuted, - preliminary: chunk.preliminary, + output: chunk2.output, + providerExecuted: chunk2.providerExecuted, + preliminary: chunk2.preliminary, title: toolInvocation.title }); } @@ -53088,26 +56756,26 @@ function processUIMessageStream({ break; } case "tool-output-error": { - const toolInvocation = getToolInvocation(chunk.toolCallId); + const toolInvocation = getToolInvocation(chunk2.toolCallId); if (toolInvocation.type === "dynamic-tool") { updateDynamicToolPart({ - toolCallId: chunk.toolCallId, + toolCallId: chunk2.toolCallId, toolName: toolInvocation.toolName, state: "output-error", input: toolInvocation.input, - errorText: chunk.errorText, - providerExecuted: chunk.providerExecuted, + errorText: chunk2.errorText, + providerExecuted: chunk2.providerExecuted, title: toolInvocation.title }); } else { updateToolPart({ - toolCallId: chunk.toolCallId, + toolCallId: chunk2.toolCallId, toolName: getStaticToolName(toolInvocation), state: "output-error", input: toolInvocation.input, rawInput: toolInvocation.rawInput, - errorText: chunk.errorText, - providerExecuted: chunk.providerExecuted, + errorText: chunk2.errorText, + providerExecuted: chunk2.providerExecuted, title: toolInvocation.title }); } @@ -53124,52 +56792,52 @@ function processUIMessageStream({ break; } case "start": { - if (chunk.messageId != null) { - state.message.id = chunk.messageId; + if (chunk2.messageId != null) { + state.message.id = chunk2.messageId; } - await updateMessageMetadata(chunk.messageMetadata); - if (chunk.messageId != null || chunk.messageMetadata != null) { + await updateMessageMetadata(chunk2.messageMetadata); + if (chunk2.messageId != null || chunk2.messageMetadata != null) { write(); } break; } case "finish": { - if (chunk.finishReason != null) { - state.finishReason = chunk.finishReason; + if (chunk2.finishReason != null) { + state.finishReason = chunk2.finishReason; } - await updateMessageMetadata(chunk.messageMetadata); - if (chunk.messageMetadata != null) { + await updateMessageMetadata(chunk2.messageMetadata); + if (chunk2.messageMetadata != null) { write(); } break; } case "message-metadata": { - await updateMessageMetadata(chunk.messageMetadata); - if (chunk.messageMetadata != null) { + await updateMessageMetadata(chunk2.messageMetadata); + if (chunk2.messageMetadata != null) { write(); } break; } case "error": { - onError == null || onError(new Error(chunk.errorText)); + onError == null || onError(new Error(chunk2.errorText)); break; } default: { - if (isDataUIMessageChunk(chunk)) { - if ((dataPartSchemas == null ? undefined : dataPartSchemas[chunk.type]) != null) { - const partIdx = state.message.parts.findIndex((p) => ("id" in p) && ("data" in p) && p.id === chunk.id && p.type === chunk.type); + if (isDataUIMessageChunk(chunk2)) { + if ((dataPartSchemas == null ? undefined : dataPartSchemas[chunk2.type]) != null) { + const partIdx = state.message.parts.findIndex((p) => ("id" in p) && ("data" in p) && p.id === chunk2.id && p.type === chunk2.type); const actualPartIdx = partIdx >= 0 ? partIdx : state.message.parts.length; await validateTypes({ - value: chunk.data, - schema: dataPartSchemas[chunk.type], + value: chunk2.data, + schema: dataPartSchemas[chunk2.type], context: { field: `message.parts[${actualPartIdx}].data`, - entityName: chunk.type, - entityId: chunk.id + entityName: chunk2.type, + entityId: chunk2.id } }); } - const dataChunk = chunk; + const dataChunk = chunk2; if (dataChunk.transient) { onData == null || onData(dataChunk); break; @@ -53185,7 +56853,7 @@ function processUIMessageStream({ } } } - controller.enqueue(chunk); + controller.enqueue(chunk2); }); } })); @@ -53206,17 +56874,17 @@ function handleUIMessageStreamFinish({ } let isAborted2 = false; const idInjectedStream = stream.pipeThrough(new TransformStream({ - transform(chunk, controller) { - if (chunk.type === "start") { - const startChunk = chunk; + transform(chunk2, controller) { + if (chunk2.type === "start") { + const startChunk = chunk2; if (startChunk.messageId == null && messageId != null) { startChunk.messageId = messageId; } } - if (chunk.type === "abort") { + if (chunk2.type === "abort") { isAborted2 = true; } - controller.enqueue(chunk); + controller.enqueue(chunk2); } })); if (onFinish == null && onStepFinish == null) { @@ -53270,11 +56938,11 @@ function handleUIMessageStreamFinish({ runUpdateMessageJob, onError }).pipeThrough(new TransformStream({ - async transform(chunk, controller) { - if (chunk.type === "finish-step") { + async transform(chunk2, controller) { + if (chunk2.type === "finish-step") { await callOnStepFinish(); } - controller.enqueue(chunk); + controller.enqueue(chunk2); }, async cancel() { await callOnFinish(); @@ -53487,8 +57155,8 @@ function runToolsTransformation({ } } const forwardStream = new TransformStream({ - async transform(chunk, controller) { - const chunkType = chunk.type; + async transform(chunk2, controller) { + const chunkType = chunk2.type; switch (chunkType) { case "stream-start": case "text-start": @@ -53504,15 +57172,15 @@ function runToolsTransformation({ case "response-metadata": case "error": case "raw": { - controller.enqueue(chunk); + controller.enqueue(chunk2); break; } case "file": { controller.enqueue({ type: "file", file: new DefaultGeneratedFileWithType({ - data: chunk.data, - mediaType: chunk.mediaType + data: chunk2.data, + mediaType: chunk2.mediaType }) }); break; @@ -53520,28 +57188,28 @@ function runToolsTransformation({ case "finish": { finishChunk = { type: "finish", - finishReason: chunk.finishReason.unified, - rawFinishReason: chunk.finishReason.raw, - usage: asLanguageModelUsage(chunk.usage), - providerMetadata: chunk.providerMetadata + finishReason: chunk2.finishReason.unified, + rawFinishReason: chunk2.finishReason.raw, + usage: asLanguageModelUsage(chunk2.usage), + providerMetadata: chunk2.providerMetadata }; break; } case "tool-approval-request": { - const toolCall = toolCallsByToolCallId.get(chunk.toolCallId); + const toolCall = toolCallsByToolCallId.get(chunk2.toolCallId); if (toolCall == null) { toolResultsStreamController.enqueue({ type: "error", error: new ToolCallNotFoundForApprovalError({ - toolCallId: chunk.toolCallId, - approvalId: chunk.approvalId + toolCallId: chunk2.toolCallId, + approvalId: chunk2.approvalId }) }); break; } controller.enqueue({ type: "tool-approval-request", - approvalId: chunk.approvalId, + approvalId: chunk2.approvalId, toolCall }); break; @@ -53549,7 +57217,7 @@ function runToolsTransformation({ case "tool-call": { try { const toolCall = await parseToolCall({ - toolCall: chunk, + toolCall: chunk2, tools, repairToolCall, system, @@ -53632,26 +57300,26 @@ function runToolsTransformation({ break; } case "tool-result": { - const toolName = chunk.toolName; - if (chunk.isError) { + const toolName = chunk2.toolName; + if (chunk2.isError) { toolResultsStreamController.enqueue({ type: "tool-error", - toolCallId: chunk.toolCallId, + toolCallId: chunk2.toolCallId, toolName, - input: toolInputs.get(chunk.toolCallId), + input: toolInputs.get(chunk2.toolCallId), providerExecuted: true, - error: chunk.result, - dynamic: chunk.dynamic + error: chunk2.result, + dynamic: chunk2.dynamic }); } else { controller.enqueue({ type: "tool-result", - toolCallId: chunk.toolCallId, + toolCallId: chunk2.toolCallId, toolName, - input: toolInputs.get(chunk.toolCallId), - output: chunk.result, + input: toolInputs.get(chunk2.toolCallId), + output: chunk2.result, providerExecuted: true, - dynamic: chunk.dynamic + dynamic: chunk2.dynamic }); } break; @@ -53671,14 +57339,14 @@ function runToolsTransformation({ async start(controller) { return Promise.all([ generatorStream.pipeThrough(forwardStream).pipeTo(new WritableStream({ - write(chunk) { - controller.enqueue(chunk); + write(chunk2) { + controller.enqueue(chunk2); }, close() {} })), toolResultsStream.pipeTo(new WritableStream({ - write(chunk) { - controller.enqueue(chunk); + write(chunk2) { + controller.enqueue(chunk2); }, close() { controller.close(); @@ -53701,7 +57369,7 @@ function streamText({ messages, maxRetries, abortSignal, - timeout, + timeout: timeout2, headers, stopWhen = stepCountIs(1), experimental_output, @@ -53731,9 +57399,9 @@ function streamText({ _internal: { now: now2 = now, generateId: generateId2 = originalGenerateId2 } = {}, ...settings }) { - const totalTimeoutMs = getTotalTimeoutMs(timeout); - const stepTimeoutMs = getStepTimeoutMs(timeout); - const chunkTimeoutMs = getChunkTimeoutMs(timeout); + const totalTimeoutMs = getTotalTimeoutMs(timeout2); + const stepTimeoutMs = getStepTimeoutMs(timeout2); + const chunkTimeoutMs = getChunkTimeoutMs(timeout2); const stepAbortController = stepTimeoutMs != null ? new AbortController : undefined; const chunkAbortController = chunkTimeoutMs != null ? new AbortController : undefined; return new DefaultStreamTextResult({ @@ -53760,7 +57428,7 @@ function streamText({ providerOptions, prepareStep, includeRawChunks, - timeout, + timeout: timeout2, stopWhen, originalAbortSignal: abortSignal, onChunk, @@ -53801,35 +57469,35 @@ function createOutputTransformStream(output) { textChunk = ""; } return new TransformStream({ - async transform(chunk, controller) { + async transform(chunk2, controller) { var _a21; - if (chunk.type === "finish-step" && textChunk.length > 0) { + if (chunk2.type === "finish-step" && textChunk.length > 0) { publishTextChunk({ controller }); } - if (chunk.type !== "text-delta" && chunk.type !== "text-start" && chunk.type !== "text-end") { - controller.enqueue({ part: chunk, partialOutput: undefined }); + if (chunk2.type !== "text-delta" && chunk2.type !== "text-start" && chunk2.type !== "text-end") { + controller.enqueue({ part: chunk2, partialOutput: undefined }); return; } if (firstTextChunkId == null) { - firstTextChunkId = chunk.id; - } else if (chunk.id !== firstTextChunkId) { - controller.enqueue({ part: chunk, partialOutput: undefined }); + firstTextChunkId = chunk2.id; + } else if (chunk2.id !== firstTextChunkId) { + controller.enqueue({ part: chunk2, partialOutput: undefined }); return; } - if (chunk.type === "text-start") { - controller.enqueue({ part: chunk, partialOutput: undefined }); + if (chunk2.type === "text-start") { + controller.enqueue({ part: chunk2, partialOutput: undefined }); return; } - if (chunk.type === "text-end") { + if (chunk2.type === "text-end") { if (textChunk.length > 0) { publishTextChunk({ controller }); } - controller.enqueue({ part: chunk, partialOutput: undefined }); + controller.enqueue({ part: chunk2, partialOutput: undefined }); return; } - text2 += chunk.text; - textChunk += chunk.text; - textProviderMetadata = (_a21 = chunk.providerMetadata) != null ? _a21 : textProviderMetadata; + text2 += chunk2.text; + textChunk += chunk2.text; + textProviderMetadata = (_a21 = chunk2.providerMetadata) != null ? _a21 : textProviderMetadata; const result = await output.parsePartialOutput({ text: text2 }); if (result !== undefined) { const currentJson = JSON.stringify(result.partial); @@ -53868,7 +57536,7 @@ var DefaultStreamTextResult = class { includeRawChunks, now: now2, generateId: generateId2, - timeout, + timeout: timeout2, stopWhen, originalAbortSignal, onChunk, @@ -53905,10 +57573,10 @@ var DefaultStreamTextResult = class { let activeTextContent = {}; let activeReasoningContent = {}; const eventProcessor = new TransformStream({ - async transform(chunk, controller) { + async transform(chunk2, controller) { var _a21, _b16, _c, _d; - controller.enqueue(chunk); - const { part } = chunk; + controller.enqueue(chunk2); + const { part } = chunk2; if (part.type === "text-delta" || part.type === "reasoning-delta" || part.type === "source" || part.type === "tool-call" || part.type === "tool-result" || part.type === "tool-input-start" || part.type === "tool-input-delta" || part.type === "raw") { await (onChunk == null ? undefined : onChunk({ chunk: part })); } @@ -54179,7 +57847,7 @@ var DefaultStreamTextResult = class { })); } this.baseStream = stream.pipeThrough(createOutputTransformStream(output != null ? output : text())).pipeThrough(eventProcessor); - const { maxRetries, retry } = prepareRetries({ + const { maxRetries, retry: retry2 } = prepareRetries({ maxRetries: maxRetriesArg, abortSignal }); @@ -54236,7 +57904,7 @@ var DefaultStreamTextResult = class { stopSequences: callSettings.stopSequences, seed: callSettings.seed, maxRetries, - timeout, + timeout: timeout2, headers, providerOptions, stopWhen, @@ -54420,7 +58088,7 @@ var DefaultStreamTextResult = class { activeTools: stepActiveTools, steps: [...recordedSteps], providerOptions: stepProviderOptions, - timeout, + timeout: timeout2, headers, stopWhen, output, @@ -54434,7 +58102,7 @@ var DefaultStreamTextResult = class { result: { stream: stream2, response, request }, doStreamSpan, startTimestampMs - } = await retry(() => recordSpan({ + } = await retry2(() => recordSpan({ name: "ai.streamText.doStream", attributes: selectTelemetryAttributes({ telemetry, @@ -54517,11 +58185,11 @@ var DefaultStreamTextResult = class { }; let activeText = ""; self.addStream(streamWithToolResults.pipeThrough(new TransformStream({ - async transform(chunk, controller) { + async transform(chunk2, controller) { var _a222, _b23, _c2, _d2, _e2; resetChunkTimeout(); - if (chunk.type === "stream-start") { - warnings = chunk.warnings; + if (chunk2.type === "stream-start") { + warnings = chunk2.warnings; return; } if (stepFirstChunk) { @@ -54539,70 +58207,70 @@ var DefaultStreamTextResult = class { warnings: warnings != null ? warnings : [] }); } - const chunkType = chunk.type; + const chunkType = chunk2.type; switch (chunkType) { case "tool-approval-request": case "text-start": case "text-end": { - controller.enqueue(chunk); + controller.enqueue(chunk2); break; } case "text-delta": { - if (chunk.delta.length > 0) { + if (chunk2.delta.length > 0) { controller.enqueue({ type: "text-delta", - id: chunk.id, - text: chunk.delta, - providerMetadata: chunk.providerMetadata + id: chunk2.id, + text: chunk2.delta, + providerMetadata: chunk2.providerMetadata }); - activeText += chunk.delta; + activeText += chunk2.delta; } break; } case "reasoning-start": case "reasoning-end": { - controller.enqueue(chunk); + controller.enqueue(chunk2); break; } case "reasoning-delta": { controller.enqueue({ type: "reasoning-delta", - id: chunk.id, - text: chunk.delta, - providerMetadata: chunk.providerMetadata + id: chunk2.id, + text: chunk2.delta, + providerMetadata: chunk2.providerMetadata }); break; } case "tool-call": { - controller.enqueue(chunk); - stepToolCalls.push(chunk); + controller.enqueue(chunk2); + stepToolCalls.push(chunk2); break; } case "tool-result": { - controller.enqueue(chunk); - if (!chunk.preliminary) { - stepToolOutputs.push(chunk); + controller.enqueue(chunk2); + if (!chunk2.preliminary) { + stepToolOutputs.push(chunk2); } break; } case "tool-error": { - controller.enqueue(chunk); - stepToolOutputs.push(chunk); + controller.enqueue(chunk2); + stepToolOutputs.push(chunk2); break; } case "response-metadata": { stepResponse = { - id: (_a222 = chunk.id) != null ? _a222 : stepResponse.id, - timestamp: (_b23 = chunk.timestamp) != null ? _b23 : stepResponse.timestamp, - modelId: (_c2 = chunk.modelId) != null ? _c2 : stepResponse.modelId + id: (_a222 = chunk2.id) != null ? _a222 : stepResponse.id, + timestamp: (_b23 = chunk2.timestamp) != null ? _b23 : stepResponse.timestamp, + modelId: (_c2 = chunk2.modelId) != null ? _c2 : stepResponse.modelId }; break; } case "finish": { - stepUsage = chunk.usage; - stepFinishReason = chunk.finishReason; - stepRawFinishReason = chunk.rawFinishReason; - stepProviderMetadata = chunk.providerMetadata; + stepUsage = chunk2.usage; + stepFinishReason = chunk2.finishReason; + stepRawFinishReason = chunk2.rawFinishReason; + stepProviderMetadata = chunk2.providerMetadata; const msToFinish = now2() - startTimestampMs; doStreamSpan.addEvent("ai.stream.finish"); doStreamSpan.setAttributes({ @@ -54612,59 +58280,59 @@ var DefaultStreamTextResult = class { break; } case "file": { - controller.enqueue(chunk); + controller.enqueue(chunk2); break; } case "source": { - controller.enqueue(chunk); + controller.enqueue(chunk2); break; } case "tool-input-start": { - activeToolCallToolNames[chunk.id] = chunk.toolName; - const tool2 = tools == null ? undefined : tools[chunk.toolName]; + activeToolCallToolNames[chunk2.id] = chunk2.toolName; + const tool2 = tools == null ? undefined : tools[chunk2.toolName]; if ((tool2 == null ? undefined : tool2.onInputStart) != null) { await tool2.onInputStart({ - toolCallId: chunk.id, + toolCallId: chunk2.id, messages: stepInputMessages, abortSignal, experimental_context }); } controller.enqueue({ - ...chunk, - dynamic: (_e2 = chunk.dynamic) != null ? _e2 : (tool2 == null ? undefined : tool2.type) === "dynamic", + ...chunk2, + dynamic: (_e2 = chunk2.dynamic) != null ? _e2 : (tool2 == null ? undefined : tool2.type) === "dynamic", title: tool2 == null ? undefined : tool2.title }); break; } case "tool-input-end": { - delete activeToolCallToolNames[chunk.id]; - controller.enqueue(chunk); + delete activeToolCallToolNames[chunk2.id]; + controller.enqueue(chunk2); break; } case "tool-input-delta": { - const toolName = activeToolCallToolNames[chunk.id]; + const toolName = activeToolCallToolNames[chunk2.id]; const tool2 = tools == null ? undefined : tools[toolName]; if ((tool2 == null ? undefined : tool2.onInputDelta) != null) { await tool2.onInputDelta({ - inputTextDelta: chunk.delta, - toolCallId: chunk.id, + inputTextDelta: chunk2.delta, + toolCallId: chunk2.id, messages: stepInputMessages, abortSignal, experimental_context }); } - controller.enqueue(chunk); + controller.enqueue(chunk2); break; } case "error": { - controller.enqueue(chunk); + controller.enqueue(chunk2); stepFinishReason = "error"; break; } case "raw": { if (includeRawChunks2) { - controller.enqueue(chunk); + controller.enqueue(chunk2); } break; } @@ -55761,26 +59429,18 @@ var frontendTools = (tools) => Object.fromEntries(Object.entries(tools).map(([na inputSchema: jsonSchema(tool2.parameters) } ])); -// ../core/types/ai-tools/renameFilesTask.ts -var BEGIN_RENAME_FILES_TASK = "begin-rename-files-task"; -var ADD_RENAME_FILE_TO_TASK = "add-rename-file-to-task"; -var END_RENAME_FILES_TASK = "end-rename-files-task"; -var BEGIN_RENAME_FILES_TASK_DESCRIPTION = "Begin a rename task V2 for batch renaming media files. " + "This tool creates a task that can be used to add multiple files for renaming. " + `Use ${ADD_RENAME_FILE_TO_TASK} to add files, then ${END_RENAME_FILES_TASK} to execute.`; -var ADD_RENAME_FILE_TO_TASK_DESCRIPTION = "Add a file to a rename task. " + `This tool adds a single file to an existing task created by ${BEGIN_RENAME_FILES_TASK}. ` + "Provide the task ID, current file path, and new file path."; -var END_RENAME_FILES_TASK_DESCRIPTION = "End a rename task and execute the batch rename operation. " + `This tool finalizes the task created by ${BEGIN_RENAME_FILES_TASK} and ` + "executes all pending file renames."; -var beginRenameFilesTaskInputSchema = exports_external.object({ - mediaFolderPath: exports_external.string().describe("The absolute path of the media folder, it can be POSIX format or Windows format") -}); -var addRenameFileToTaskInputSchema = exports_external.object({ - taskId: exports_external.string().describe(`The task ID from ${BEGIN_RENAME_FILES_TASK}`), - from: exports_external.string().describe("Current absolute path of the video file to rename (POSIX or Windows format)"), - to: exports_external.string().describe("New absolute path for the file (POSIX or Windows format)") -}); -var endRenameFilesTaskInputSchema = exports_external.object({ - taskId: exports_external.string().describe(`The task ID from ${BEGIN_RENAME_FILES_TASK}`) -}); - -// ../core/types/ai-tools/recognizeMediaFileTask.ts +// ../types/ai-tools/createRenameEpisodePlan.ts +var CREATE_RENAME_EPISODE_PLAN = "create-rename-episode-plan"; +var CREATE_RENAME_EPISODE_PLAN_DESCRIPTION = "Create a rename-files plan for TV episode video files with explicit from/to paths. " + "After success, tell the user to open SMM, review, and approve the plan."; +var createRenameEpisodePlanInputSchema = exports_external.object({ + mediaFolderPath: exports_external.string().describe("Absolute media folder path (POSIX or Windows)"), + files: exports_external.array(exports_external.object({ + from: exports_external.string().describe("Current absolute video path"), + to: exports_external.string().describe("New absolute video path") + })).min(1) +}); + +// ../types/ai-tools/recognizeMediaFileTask.ts var BEGIN_RECOGNIZE_TASK = "begin-recognize-task"; var ADD_RECOGNIZED_MEDIA_FILE = "add-recognized-media-file"; var END_RECOGNIZE_TASK = "end-recognize-task"; @@ -55800,7 +59460,7 @@ var endRecognizeTaskInputSchema = exports_external.object({ taskId: exports_external.string().describe(`The task ID returned from ${BEGIN_RECOGNIZE_TASK}`) }); -// ../core/types/ai-tools/getApplicationContext.ts +// ../types/ai-tools/getApplicationContext.ts var GET_APPLICATION_CONTEXT = "get-app-context"; var GET_APPLICATION_CONTEXT_DESCRIPTION = `Get SMM context: ` + ` * The media folder user selected/focused on SMM UI @@ -55812,7 +59472,7 @@ var getApplicationContextOutputSchema = exports_external.object({ error: exports_external.string().optional().describe("Error message if the operation failed") }); -// ../core/types/ai-tools/getMediaMetadata.ts +// ../types/ai-tools/getMediaMetadata.ts var GET_MEDIA_METADATA = "get-media-metadata"; var GET_MEDIA_METADATA_DESCRIPTION = "Get cached media metadata for a media folder. Returns normalized TV show " + "season/episode data and TMDB/TVDB movie information when available. " + "Use list-files-in-media-folder for raw file paths; episode-to-file mappings " + "are not included in this response."; var GET_MEDIA_METADATA_NOT_MANAGED = "Media folder not found. The folder path may not be correct or the folder is not managed by SMM"; @@ -55868,7 +59528,7 @@ var getMediaMetadataToolOutputSchema = getMediaMetadataDataSchema.extend({ error: exports_external.string().optional().describe("Error message when lookup failed") }); -// ../core/types/ai-tools/getEpisodes.ts +// ../types/ai-tools/getEpisodes.ts var GET_EPISODES = "get-episodes"; var GET_EPISODES_DESCRIPTION = "Get all episodes for a TV show with their video file paths. " + "Combines TMDB or TVDB episode data (from cached metadata) with local media file paths. " + "For each episode, returns season, episode number, and video file path. " + "The video file path may be undefined if the episode has not been recognized yet."; var GET_EPISODES_INVALID_PATH = "Invalid path: 'mediaFolderPath' must be a non-empty string"; @@ -55893,7 +59553,7 @@ var getEpisodesToolOutputSchema = getEpisodesDataSchema.extend({ error: exports_external.string().optional() }); -// ../core/types/ai-tools/listFilesInMediaFolder.ts +// ../types/ai-tools/listFilesInMediaFolder.ts var LIST_FILES_IN_MEDIA_FOLDER = "list-files-in-media-folder"; var LIST_FILES_IN_MEDIA_FOLDER_DESCRIPTION = "List files in a media folder by scanning the file system recursively. " + "Returns file paths in OS-native format. Use videoFileOnly to restrict to video files."; var LIST_FILES_IN_MEDIA_FOLDER_INVALID_PATH = "Invalid path: 'mediaFolderPath' must be a non-empty string"; @@ -55911,7 +59571,214 @@ var listFilesInMediaFolderOutputSchema = listFilesInMediaFolderDataSchema.extend error: exports_external.string().optional() }); -// ../core/ai-tool/systemPrompt.ts +// ../types/ai-tools/scrape.ts +var SCRAPE = "scrape"; +var SCRAPE_DESCRIPTION = "Start a scrape job for a managed TV show or movie folder (poster, fanart, thumbnails, nfo). " + "Returns a job id immediately; the scrape runs in the background. " + "Call get-job with the returned id to check progress and per-task status. " + `Supports TMDB and TVDB. Movie folders skip thumbnails. + +` + 'Example: Scrape media folder "/path/to/Show".'; +var SCRAPE_JOB_CREATED_MESSAGE = "scrape job created, use get-job tool to check job status by id."; +var scrapeInputSchema = exports_external.object({ + path: exports_external.string().describe("Absolute path of the managed media folder to scrape (POSIX or Windows format)"), + language: exports_external.string().optional().describe("Optional language code for metadata/assets (defaults to user preferMediaLanguage)") +}); +var scrapeOutputSchema = exports_external.object({ + id: exports_external.string().describe("Scrape job id; pass to get-job to poll status"), + message: exports_external.string().describe("Guidance for checking job status with get-job"), + error: exports_external.string().optional().describe("Error message when the scrape job could not be started") +}); + +// ../types/ai-tools/getJob.ts +var GET_JOB = "get-job"; +var GET_JOB_DESCRIPTION = "Get the status of a background job by id. " + 'Supports scrape jobs (kind: "scrape" with poster/fanart/thumbnails/nfo tasks) ' + 'and import jobs (kind: "import"). ' + `Poll until status is succeeded, failed, or aborted. + +` + 'Example: Check job status for id "550e8400-e29b-41d4-a716-446655440000".'; +var jobStatusSchema = exports_external.enum([ + "pending", + "running", + "succeeded", + "failed", + "aborted" +]); +var scrapeTaskRuntimeStatusSchema = exports_external.enum([ + "pending", + "running", + "skipped", + "completed", + "failed" +]); +var scrapeJobTaskSchema = exports_external.object({ + status: scrapeTaskRuntimeStatusSchema, + error: exports_external.string().optional() +}); +var scrapeJobSchema = exports_external.object({ + kind: exports_external.literal("scrape"), + id: exports_external.string(), + folderPath: exports_external.string(), + status: jobStatusSchema, + tasks: exports_external.object({ + poster: scrapeJobTaskSchema, + fanart: scrapeJobTaskSchema, + thumbnails: scrapeJobTaskSchema, + nfo: scrapeJobTaskSchema + }), + error: exports_external.string().optional(), + createdAt: exports_external.number(), + updatedAt: exports_external.number() +}); +var importJobSchema = exports_external.object({ + kind: exports_external.literal("import"), + id: exports_external.string(), + folderPath: exports_external.string(), + type: exports_external.string(), + status: jobStatusSchema, + stage: exports_external.string().nullable(), + progress: exports_external.number(), + recognizedTitle: exports_external.string().optional(), + error: exports_external.string().optional(), + createdAt: exports_external.number(), + updatedAt: exports_external.number() +}); +var jobSchema = exports_external.discriminatedUnion("kind", [ + scrapeJobSchema, + importJobSchema +]); +var getJobInputSchema = exports_external.object({ + id: exports_external.string().describe("Job id returned by scrape or import-folder") +}); +var getJobOutputSchema = exports_external.object({ + job: jobSchema.optional().describe("Job payload when found"), + error: exports_external.string().optional().describe("Error message when the job could not be loaded") +}); + +// ../types/ai-tools/tmdbCommon.ts +var tmdbLanguageSchema = exports_external.string().optional().describe("TMDB primary translation IETF tag (e.g. zh-CN, en-US). Defaults from userConfig.preferMediaLanguage."); +var tmdbBaseUrlSchema = exports_external.string().optional().describe("Optional TMDB API base URL override (defaults from userConfig.tmdb.host)"); +function toTmdbCoreOptions(params) { + const host = params.baseURL?.trim(); + return { + language: params.language, + host: host || undefined + }; +} +function formatTmdbToolError(error48) { + const message = error48 instanceof Error ? error48.message : String(error48); + return message.startsWith("Error Reason:") ? message : `Error Reason: ${message}`; +} + +// ../types/ai-tools/tmdbSearch.ts +var TMDB_SEARCH = "tmdb-search"; +var TMDB_SEARCH_DESCRIPTION = "Search TMDB (The Movie Database) for movies or TV shows by keyword. " + `Returns matching results with title, release date, overview, and TMDB ID. + +` + 'Example: Search TV shows matching "naruto".'; +var tmdbSearchInputSchema = exports_external.object({ + keyword: exports_external.string().describe("Search keyword"), + type: exports_external.enum(["tv", "movie"]).describe("Media type to search"), + language: tmdbLanguageSchema, + baseURL: tmdbBaseUrlSchema +}); +var tmdbSearchOutputSchema = exports_external.object({ + results: exports_external.array(exports_external.record(exports_external.string(), exports_external.unknown())).optional(), + page: exports_external.number().optional(), + total_pages: exports_external.number().optional(), + total_results: exports_external.number().optional(), + error: exports_external.string().optional() +}); + +// ../types/ai-tools/tmdbGetMovie.ts +var TMDB_GET_MOVIE = "tmdb-get-movie"; +var TMDB_GET_MOVIE_DESCRIPTION = "Retrieve detailed movie information from TMDB by TMDB ID. " + `Includes title, overview, release date, runtime, genres, poster images, and more. + +` + "Example: Get movie details for TMDB id 550."; +var tmdbGetMovieInputSchema = exports_external.object({ + id: exports_external.number().int().positive().describe("TMDB movie id"), + language: tmdbLanguageSchema, + baseURL: tmdbBaseUrlSchema +}); +var tmdbGetMovieOutputSchema = exports_external.object({ + error: exports_external.string().optional() +}).passthrough(); + +// ../types/ai-tools/tmdbGetTvShow.ts +var TMDB_GET_TV_SHOW = "tmdb-get-tv-show"; +var TMDB_GET_TV_SHOW_DESCRIPTION = "Retrieve detailed TV show information from TMDB by TMDB ID, including seasons and episodes " + `with titles, overviews, and air dates. + +` + "Example: Get TV show details for TMDB id 31917."; +var tmdbGetTvShowInputSchema = exports_external.object({ + id: exports_external.number().int().positive().describe("TMDB TV series id"), + language: tmdbLanguageSchema, + baseURL: tmdbBaseUrlSchema +}); +var tmdbGetTvShowOutputSchema = exports_external.object({ + error: exports_external.string().optional() +}).passthrough(); + +// ../types/ai-tools/tvdbCommon.ts +var tvdbLanguageSchema = exports_external.string().optional().describe("TVDB ISO 639-3 language code (e.g. eng, zho, yue). Defaults from userConfig.preferMediaLanguage."); +var tvdbBaseUrlSchema = exports_external.string().optional().describe("Optional TVDB API base URL override (defaults from userConfig.tvdb.host)"); +function toTvdbCoreOptions(params) { + const host = params.baseURL?.trim(); + return { + language: params.language, + host: host || undefined + }; +} +function formatTvdbToolError(error48) { + const message = error48 instanceof Error ? error48.message : String(error48); + return message.startsWith("Error Reason:") ? message : `Error Reason: ${message}`; +} + +// ../types/ai-tools/tvdbSearch.ts +var TVDB_SEARCH = "tvdb-search"; +var TVDB_SEARCH_DESCRIPTION = "Search TVDB (TheTVDB) for TV series or movies by keyword. " + `Returns matching results with title, overview, and TVDB ID. + +` + 'Example: Search series matching "naruto".'; +var tvdbSearchInputSchema = exports_external.object({ + keyword: exports_external.string().describe("Search keyword"), + type: exports_external.enum(["series", "movie"]).describe("Media type to search"), + language: tvdbLanguageSchema, + baseURL: tvdbBaseUrlSchema +}); +var tvdbSearchOutputSchema = exports_external.object({ + results: exports_external.array(exports_external.record(exports_external.string(), exports_external.unknown())).optional(), + error: exports_external.string().optional() +}); + +// ../types/ai-tools/tvdbGetMovie.ts +var TVDB_GET_MOVIE = "tvdb-get-movie"; +var TVDB_GET_MOVIE_DESCRIPTION = `Retrieve movie metadata from TVDB by TVDB ID, including the localized title. + +` + "Example: Get movie metadata for TVDB id 7."; +var tvdbGetMovieInputSchema = exports_external.object({ + id: exports_external.number().int().positive().describe("TVDB movie id"), + language: tvdbLanguageSchema, + baseURL: tvdbBaseUrlSchema +}); +var tvdbGetMovieOutputSchema = exports_external.object({ error: exports_external.string().optional() }).passthrough(); + +// ../types/ai-tools/tvdbGetTvShow.ts +var TVDB_GET_TV_SHOW = "tvdb-get-tv-show"; +var TVDB_GET_TV_SHOW_DESCRIPTION = `Retrieve TV series metadata from TVDB by TVDB ID, including seasons and episodes with localized titles. + +` + "Example: Get TV series metadata for TVDB id 42."; +var tvdbGetTvShowInputSchema = exports_external.object({ + id: exports_external.number().int().positive().describe("TVDB series id"), + language: tvdbLanguageSchema, + baseURL: tvdbBaseUrlSchema +}); +var tvdbGetTvShowOutputSchema = exports_external.object({ error: exports_external.string().optional() }).passthrough(); + +// ../types/ai-tools/tvdbGetLanguages.ts +var TVDB_GET_LANGUAGES = "tvdb-get-languages"; +var TVDB_GET_LANGUAGES_DESCRIPTION = "Retrieve the list of TVDB supported languages (ISO 639-3 codes). Useful for picking a search language."; +var tvdbGetLanguagesInputSchema = exports_external.object({ + baseURL: tvdbBaseUrlSchema +}); +var tvdbGetLanguagesOutputSchema = exports_external.object({ + languages: exports_external.array(exports_external.record(exports_external.string(), exports_external.unknown())).optional(), + error: exports_external.string().optional() +}); + +// ../../apps/core/src/ai-tool/systemPrompt.ts var SYSTEM_PROMPT = `You're a helpful assistant for Simple Media Manager(SMM) software. SMM is a media manager that helps user to manage their TV Show, anime, movie or music. SMM holds multiple media folders and user can switch between them. @@ -55954,9 +59821,32 @@ You ONLY need to rename the video file. For image files, subtitle files, nfo fil Steps [ ] Call "${GET_MEDIA_METADATA}" to get the video files needs to rename -[ ] Call "${BEGIN_RENAME_FILES_TASK}" to notify AI Agent to start a rename files task -[ ] Call "${ADD_RENAME_FILE_TO_TASK}" to add a file to rename task, call multiple times to add multiple files -[ ] Call "${END_RENAME_FILES_TASK}" to notify AI Agent to end the rename files task +[ ] Call "${CREATE_RENAME_EPISODE_PLAN}" once with mediaFolderPath and a files array of from/to pairs for every video to rename + +### Scrape Media Artwork and NFO + +When user asks to scrape, download poster/fanart/thumbnails, or write NFO files for a media folder, +use the scrape job tools: + +1. Resolve which media folder (ask user or call "${GET_APPLICATION_CONTEXT}"). +2. Call "${SCRAPE}" with the folder path (optional language). It returns a job id immediately. +3. Call "${GET_JOB}" with that id to check progress. Poll until status is succeeded, failed, or aborted. +4. Report per-task results (poster, fanart, thumbnails, nfo) from the scrape job. + +### TMDB Search and Details + +When user asks to search TMDB, find a TV show or movie on TMDB, or look up TMDB metadata by id: + +1. Call "${TMDB_SEARCH}" with keyword and type (\`tv\` or \`movie\`) to find candidates. +2. Call "${TMDB_GET_TV_SHOW}" or "${TMDB_GET_MOVIE}" with the chosen TMDB id for full details (seasons/episodes for TV). + +### TVDB Search and Details + +When user asks to search TVDB, find a TV show or movie on TVDB, or look up TVDB metadata by id: + +1. Call "${TVDB_SEARCH}" with keyword and type (\`series\` or \`movie\`) to find candidates. +2. Call "${TVDB_GET_TV_SHOW}" or "${TVDB_GET_MOVIE}" with the chosen TVDB id for full metadata (seasons/episodes for TV). +3. Use "${TVDB_GET_LANGUAGES}" to discover supported ISO 639-3 language codes when needed. ## User Preferences @@ -55982,7 +59872,7 @@ EpisodeName: The episode name Extension: The file extension, such as "mp4", "mkv", "avi", ... `; -// ../core/types/ai-tools/isFolderExist.ts +// ../types/ai-tools/isFolderExist.ts var IS_FOLDER_EXIST = "is-folder-exist"; var IS_FOLDER_EXIST_DESCRIPTION = "Check if a folder exists in the file system. " + "Returns `{ exists, path, reason? }` where `exists` is true when " + "the path is an existing directory."; var IS_FOLDER_EXIST_INVALID_PATH = "Invalid path: path must be a non-empty string"; @@ -55997,7 +59887,7 @@ var isFolderExistOutputSchema = exports_external.object({ reason: exports_external.string().optional().describe("Reason for non-existence or non-directory") }); -// ../core/types/ai-tools/getMediaFolders.ts +// ../types/ai-tools/getMediaFolders.ts var GET_MEDIA_FOLDERS = "get-media-folders"; var GET_MEDIA_FOLDERS_DESCRIPTION = "Get the list of media folders managed by SMM."; var getMediaFoldersInputSchema = exports_external.object({}); @@ -56008,7 +59898,7 @@ var getMediaFoldersOutputSchema = getMediaFoldersDataSchema.extend({ error: exports_external.string().optional() }); -// ../core/types/ai-tools/renameFolder.ts +// ../types/ai-tools/renameFolder.ts var RENAME_FOLDER = "rename-folder"; var RENAME_FOLDER_DESCRIPTION = "Rename a media folder in SMM. " + "This tool accepts the source folder path and destination folder path. " + "This tool should ONLY be used to rename FOLDER, NOT FILE. " + `This tool will update media metadata accordingly. @@ -56025,7 +59915,46 @@ var renameFolderOutputSchema = exports_external.object({ }); var RENAME_FOLDER_CANCELLED = "User cancelled the operation"; -// ../core/locale.ts +// ../types/ai-tools/renameEpisodeFile.ts +var RENAME_EPISODE_FILE = "rename-episode-file"; +var RENAME_EPISODE_FILE_DESCRIPTION = "Rename a linked TV episode video file (and same-stem associates such as subtitles) in a managed TV show folder. " + "Use ONLY for a single episode file that already has seasonNumber and episodeNumber in media metadata. " + "Do NOT use for folders (use rename-folder), movies, orphan files, or bulk renames " + `(use create-rename-episode-plan for multi-file plans). + +` + 'Example: Rename episode file in folder "/path/to/show" from ".../S01E01.mp4" to ".../S01E01_renamed.mp4".'; +var renameEpisodeFileInputSchema = exports_external.object({ + mediaFolder: exports_external.string().describe("Absolute path of the managed TV show media folder (POSIX or Windows format)"), + from: exports_external.string().describe("Absolute current path of the linked episode video file (POSIX or Windows format)"), + to: exports_external.string().describe("Absolute target path for the episode video file under the same media folder (POSIX or Windows format)") +}); +var renameEpisodeFileOutputSchema = exports_external.object({ + renamed: exports_external.boolean().describe("True when at least one file was renamed successfully"), + mediaFolder: exports_external.string().describe("The media folder path after normalization"), + from: exports_external.string().describe("The primary source episode path after normalization"), + to: exports_external.string().describe("The primary destination episode path after normalization"), + succeeded: exports_external.array(exports_external.object({ + from: exports_external.string(), + to: exports_external.string() + })).describe("Successful rename pairs (episode + associates)"), + failed: exports_external.array(exports_external.object({ + path: exports_external.string(), + error: exports_external.string() + })).describe("Per-path failures"), + error: exports_external.string().optional().describe("Error or cancellation message when rename did not fully succeed") +}); +var RENAME_EPISODE_FILE_CANCELLED = "User cancelled the operation"; + +// ../types/ai-tools/createRecognizeEpisodePlan.ts +var CREATE_RECOGNIZE_EPISODE_PLAN = "create-recognize-episode-plan"; +var CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION = "Create a recognize-media-file plan that maps episode video files to season/episode numbers. " + "Provide every mapping (season, episode, absolute file path) in one call. " + "After success, tell the user to open SMM, review, and approve the plan."; +var createRecognizeEpisodePlanInputSchema = exports_external.object({ + mediaFolderPath: exports_external.string().describe("Absolute media folder path (POSIX or Windows)"), + files: exports_external.array(exports_external.object({ + season: exports_external.number().describe("The season number of the episode."), + episode: exports_external.number().describe("The episode number."), + path: exports_external.string().describe("The absolute path of the media file (POSIX or Windows format).") + })).min(1) +}); + +// ../utils/src/locale.ts var APP_LANGUAGE_FALLBACK = "en"; function normalizeToAppLanguage(raw) { const lng = raw.trim(); @@ -56074,13 +60003,259 @@ function detectOsLocale() { return ""; } +// ../../apps/core/src/ai-tool/toolResult.ts +function toolOk(data) { + return { ...data, error: undefined }; +} +function toolError(reason) { + const message = reason.startsWith("Error Reason:") ? reason : `Error Reason: ${reason}`; + return { error: message }; +} +function requireNonEmptyString(value, field) { + if (typeof value !== "string" || value.trim() === "") { + return { error: `Invalid ${field}: must be a non-empty string` }; + } + return value; +} +function messageFromUnknownError(error48) { + if (error48 instanceof Error) { + return error48.message; + } + if (typeof error48 === "string") { + return error48; + } + if (error48 === null || error48 === undefined) { + return "Unknown error (null/undefined thrown)"; + } + try { + const json3 = JSON.stringify(error48); + if (json3 && json3 !== "{}") { + return json3; + } + } catch {} + const text2 = String(error48); + return text2 || "Unknown error"; +} +function formatToolError(error48) { + return toolError(messageFromUnknownError(error48)); +} + +// src/tools/tmdb.ts +function unavailable(message) { + return { error: message }; +} +function assertNotAborted(abortSignal) { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); + } +} +async function executeTmdbSearch(params, runner, abortSignal) { + assertNotAborted(abortSignal); + const keywordCheck = requireNonEmptyString(params.keyword, "keyword"); + if (typeof keywordCheck !== "string") { + return { error: keywordCheck.error }; + } + if (!runner) { + return unavailable("tmdb-search is not available on this host"); + } + try { + const body = await runner(keywordCheck, { + type: params.type, + ...toTmdbCoreOptions(params) + }); + if (body.error) { + return { error: body.error }; + } + return { + results: body.results, + page: body.page, + total_pages: body.total_pages, + total_results: body.total_results + }; + } catch (error48) { + return { error: formatTmdbToolError(error48) }; + } +} +async function executeTmdbGetMovie(params, runner, abortSignal) { + assertNotAborted(abortSignal); + if (!Number.isInteger(params.id) || params.id <= 0) { + return { error: "Invalid id: 'id' must be a positive integer" }; + } + if (!runner) { + return unavailable("tmdb-get-movie is not available on this host"); + } + try { + const details = await runner(params.id, toTmdbCoreOptions(params)); + return details; + } catch (error48) { + return { error: formatTmdbToolError(error48) }; + } +} +async function executeTmdbGetTvShow(params, runner, abortSignal) { + assertNotAborted(abortSignal); + if (!Number.isInteger(params.id) || params.id <= 0) { + return { error: "Invalid id: 'id' must be a positive integer" }; + } + if (!runner) { + return unavailable("tmdb-get-tv-show is not available on this host"); + } + try { + const details = await runner(params.id, toTmdbCoreOptions(params)); + return details; + } catch (error48) { + return { error: formatTmdbToolError(error48) }; + } +} +function buildTmdbSearchTool(runners, abortSignal) { + return { + description: TMDB_SEARCH_DESCRIPTION, + inputSchema: tmdbSearchInputSchema, + outputSchema: tmdbSearchOutputSchema, + execute: async (args) => { + return executeTmdbSearch(args ?? {}, runners?.searchInTmdb, abortSignal); + } + }; +} +function buildTmdbGetMovieTool(runners, abortSignal) { + return { + description: TMDB_GET_MOVIE_DESCRIPTION, + inputSchema: tmdbGetMovieInputSchema, + outputSchema: tmdbGetMovieOutputSchema, + execute: async (args) => { + return executeTmdbGetMovie(args ?? {}, runners?.getMovieInTmdb, abortSignal); + } + }; +} +function buildTmdbGetTvShowTool(runners, abortSignal) { + return { + description: TMDB_GET_TV_SHOW_DESCRIPTION, + inputSchema: tmdbGetTvShowInputSchema, + outputSchema: tmdbGetTvShowOutputSchema, + execute: async (args) => { + return executeTmdbGetTvShow(args ?? {}, runners?.getTvShowInTmdb, abortSignal); + } + }; +} + +// src/tools/tvdb.ts +function unavailable2(message) { + return { error: message }; +} +function assertNotAborted2(abortSignal) { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); + } +} +async function executeTvdbSearch(params, runner, abortSignal) { + assertNotAborted2(abortSignal); + const keywordCheck = requireNonEmptyString(params.keyword, "keyword"); + if (typeof keywordCheck !== "string") { + return { error: keywordCheck.error }; + } + if (!runner) { + return unavailable2("tvdb-search is not available on this host"); + } + try { + const results = await runner(keywordCheck, { + type: params.type, + ...toTvdbCoreOptions(params) + }); + return { results }; + } catch (error48) { + return { error: formatTvdbToolError(error48) }; + } +} +async function executeTvdbGetMovie(params, runner, abortSignal) { + assertNotAborted2(abortSignal); + if (!Number.isInteger(params.id) || params.id <= 0) { + return { error: "Invalid id: 'id' must be a positive integer" }; + } + if (!runner) { + return unavailable2("tvdb-get-movie is not available on this host"); + } + try { + const details = await runner(params.id, toTvdbCoreOptions(params)); + return details; + } catch (error48) { + return { error: formatTvdbToolError(error48) }; + } +} +async function executeTvdbGetTvShow(params, runner, abortSignal) { + assertNotAborted2(abortSignal); + if (!Number.isInteger(params.id) || params.id <= 0) { + return { error: "Invalid id: 'id' must be a positive integer" }; + } + if (!runner) { + return unavailable2("tvdb-get-tv-show is not available on this host"); + } + try { + const details = await runner(params.id, toTvdbCoreOptions(params)); + return details; + } catch (error48) { + return { error: formatTvdbToolError(error48) }; + } +} +async function executeTvdbGetLanguages(runner, _params = {}, abortSignal) { + assertNotAborted2(abortSignal); + if (!runner) { + return unavailable2("tvdb-get-languages is not available on this host"); + } + try { + const languages = await runner(toTvdbCoreOptions(_params)); + return { languages }; + } catch (error48) { + return { error: formatTvdbToolError(error48) }; + } +} +function buildTvdbSearchTool(runners, abortSignal) { + return { + description: TVDB_SEARCH_DESCRIPTION, + inputSchema: tvdbSearchInputSchema, + outputSchema: tvdbSearchOutputSchema, + execute: async (args) => { + return executeTvdbSearch(args ?? {}, runners?.searchInTvdb, abortSignal); + } + }; +} +function buildTvdbGetMovieTool(runners, abortSignal) { + return { + description: TVDB_GET_MOVIE_DESCRIPTION, + inputSchema: tvdbGetMovieInputSchema, + outputSchema: tvdbGetMovieOutputSchema, + execute: async (args) => { + return executeTvdbGetMovie(args ?? {}, runners?.getMovieInTvdb, abortSignal); + } + }; +} +function buildTvdbGetTvShowTool(runners, abortSignal) { + return { + description: TVDB_GET_TV_SHOW_DESCRIPTION, + inputSchema: tvdbGetTvShowInputSchema, + outputSchema: tvdbGetTvShowOutputSchema, + execute: async (args) => { + return executeTvdbGetTvShow(args ?? {}, runners?.getTvShowInTvdb, abortSignal); + } + }; +} +function buildTvdbGetLanguagesTool(runners, abortSignal) { + return { + description: TVDB_GET_LANGUAGES_DESCRIPTION, + inputSchema: tvdbGetLanguagesInputSchema, + outputSchema: tvdbGetLanguagesOutputSchema, + execute: async (args) => { + return executeTvdbGetLanguages(runners?.getTvdbLanguages, args ?? {}, abortSignal); + } + }; +} + // src/chatFs.ts -import { readFile, writeFile, stat } from "node:fs/promises"; +import { mkdir, readFile, writeFile, stat as stat2 } from "node:fs/promises"; +import { dirname } from "node:path"; function defaultChatFs() { return { async readJson(filePath) { try { - const contents = await readFile(filePath, "utf-8"); + const contents = await readFile(Path.toPlatformPath(filePath), "utf-8"); return JSON.parse(contents); } catch (error48) { if (error48.code === "ENOENT") { @@ -56091,11 +60266,13 @@ function defaultChatFs() { }, async writeJson(filePath, value) { const serialized = JSON.stringify(value, null, 2); - await writeFile(filePath, serialized, "utf-8"); + const platformPath = Path.toPlatformPath(filePath); + await mkdir(dirname(platformPath), { recursive: true }); + await writeFile(platformPath, serialized, "utf-8"); }, async exists(filePath) { try { - await stat(filePath); + await stat2(Path.toPlatformPath(filePath)); return true; } catch { return false; @@ -56152,287 +60329,8 @@ async function resolveSelectedMediaFolder(clientId, acknowledge) { }, 1000); return responseData?.selectedMediaMetadata?.mediaFolderPath ?? ""; } -// ../../node_modules/.pnpm/es-toolkit@1.44.0/node_modules/es-toolkit/dist/array/flatten.mjs -function flatten(arr, depth = 1) { - const result = []; - const flooredDepth = Math.floor(depth); - const recursive = (arr2, currentDepth) => { - for (let i = 0;i < arr2.length; i++) { - const item = arr2[i]; - if (Array.isArray(item) && currentDepth < flooredDepth) { - recursive(item, currentDepth + 1); - } else { - result.push(item); - } - } - }; - recursive(arr, 0); - return result; -} - -// ../../node_modules/.pnpm/es-toolkit@1.44.0/node_modules/es-toolkit/dist/array/flattenDeep.mjs -function flattenDeep(arr) { - return flatten(arr, Infinity); -} -// ../../node_modules/.pnpm/es-toolkit@1.44.0/node_modules/es-toolkit/dist/array/last.mjs -function last(arr) { - return arr[arr.length - 1]; -} -// ../../node_modules/.pnpm/slash@5.1.0/node_modules/slash/index.js -function slash(path) { - const isExtendedLengthPath = path.startsWith("\\\\?\\"); - if (isExtendedLengthPath) { - return path; - } - return path.replace(/\\/g, "/"); -} - -// ../../node_modules/.pnpm/filename-reserved-regex@4.0.0/node_modules/filename-reserved-regex/index.js -function filenameReservedRegex() { - return /[<>:"/\\|?*\u0000-\u001F]|[. ]$/g; -} -function windowsReservedNameRegex() { - return /^(con|prn|aux|nul|com\d|lpt\d)$/i; -} - -// ../../node_modules/.pnpm/filenamify@7.0.1/node_modules/filenamify/filenamify.js -var MAX_FILENAME_LENGTH = 100; -var reRelativePath = /^\.+(\\|\/)|^\.+$/; -var reTrailingDotsAndSpaces = /[. ]+$/; -var reControlChars = /[\p{Control}\p{Format}\p{Zl}\p{Zp}\uFFF0-\uFFFF]/gu; -var reControlCharsTest = /[\p{Control}\p{Format}\p{Zl}\p{Zp}\uFFF0-\uFFFF]/u; -var isZeroWidthJoiner = (char) => char === "‍"; -var reRepeatedReservedCharacters = /([<>:"/\\|?*\u0000-\u001F]){2,}/g; -var reReplacementReservedCharacters = /[<>:"/\\|?*\u0000-\u001F]/; -var reUnicodeWhitespace = /[\t\n\r\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]+/g; -var segmenter; -function getSegmenter() { - segmenter ??= new Intl.Segmenter(undefined, { granularity: "grapheme" }); - return segmenter; -} -function truncateFilename(filename, maxLength) { - if (filename.length <= maxLength) { - return filename; - } - const extensionIndex = filename.lastIndexOf("."); - if (extensionIndex === -1) { - return truncateByGraphemeBudget(filename, maxLength); - } - const base = filename.slice(0, extensionIndex); - const extension = filename.slice(extensionIndex); - const baseBudget = Math.max(0, maxLength - extension.length); - const truncatedBase = truncateByGraphemeBudget(base, baseBudget); - return truncatedBase.replace(/ +$/, "") + extension; -} -function filenamify(string4, options = {}) { - if (typeof string4 !== "string") { - throw new TypeError("Expected a string"); - } - const replacement = options.replacement ?? "!"; - const hasReservedChars = reReplacementReservedCharacters.test(replacement); - const hasControlChars = [...replacement].some((char) => reControlCharsTest.test(char) && !isZeroWidthJoiner(char)); - if (hasReservedChars || hasControlChars) { - throw new Error("Replacement string cannot contain reserved filename characters"); - } - string4 = string4.normalize("NFC"); - string4 = string4.replaceAll(reUnicodeWhitespace, " "); - if (replacement.length > 0) { - string4 = string4.replaceAll(reRepeatedReservedCharacters, "$1"); - } - string4 = string4.replace(reTrailingDotsAndSpaces, ""); - string4 = string4.replace(reRelativePath, replacement); - string4 = string4.replace(filenameReservedRegex(), replacement); - string4 = string4.replaceAll(reControlChars, (char) => isZeroWidthJoiner(char) ? char : replacement); - string4 = string4.replace(reTrailingDotsAndSpaces, ""); - if (string4.length === 0) { - string4 = replacement.replace(reTrailingDotsAndSpaces, ""); - if (string4.length === 0 && replacement.length > 0) { - string4 = "!"; - } - } - const allowedLength = typeof options.maxLength === "number" ? options.maxLength : MAX_FILENAME_LENGTH; - string4 = truncateFilename(string4, allowedLength); - string4 = string4.replace(reTrailingDotsAndSpaces, ""); - if (windowsReservedNameRegex().test(string4)) { - string4 += replacement; - } - return string4; -} -function truncateByGraphemeBudget(input, budget) { - if (input.length <= budget) { - return input; - } - let count = 0; - let output = ""; - for (const { segment } of getSegmenter().segment(input)) { - const next = count + segment.length; - if (next > budget) { - break; - } - output += segment; - count = next; - } - return output; -} -// ../core/path.ts -var WIN_PATH_SEPARATOR = "\\"; -var POSIX_PATH_SEPARATOR = "/"; -function isNotEmpty(part) { - return part.trim() !== ""; -} -function split(path) { - let parts = path.split(":\\").filter(isNotEmpty); - parts = flattenDeep(parts.map((part) => part.split("\\").filter(isNotEmpty))); - parts = flattenDeep(parts.map((part) => part.split("/").filter(isNotEmpty))); - return parts; -} - -class Path { - root; - sub; - unc; - constructor(root, sub) { - if (root.trim() === "") { - throw new Error("InvalidArgumentError: root path cannot be empty"); - } - if (sub !== undefined) { - if (split(sub).length === 0) { - if (sub.length === 0) { - throw new Error("InvalidArgumentError: sub path cannot be empty"); - } else { - throw new Error("InvalidArgumentError: invalid sub path"); - } - } - } - if (sub?.trim() === "") { - throw new Error("InvalidArgumentError: sub path cannot be empty"); - } - this.unc = root.startsWith("\\\\"); - if (!(root.startsWith("/") || /^[A-Za-z]:/.test(root) || root.startsWith("\\\\"))) { - throw new Error(`InvalidArgumentError: root=${root}. root path must start with "/" for POSIX format, "C:" for Windows format, or "\\\\" for Windows UNC format`); - } - this.root = split(root); - this.sub = sub === undefined ? [] : split(sub); - if (this.root.length === 0) { - throw new Error("InvalidArgumentError: invalid root path"); - } - } - _uncPath() { - const serverName = this.root[0]; - const parentPath = this.root.slice(1).join(WIN_PATH_SEPARATOR); - const subPath = this.sub.length === 0 ? "" : WIN_PATH_SEPARATOR + this.sub.join(WIN_PATH_SEPARATOR); - return `\\\\${serverName}\\${parentPath}${subPath}`; - } - abs(type = "posix") { - if (type === "win") { - if (this.unc) { - return this._uncPath(); - } else { - if (this.root[0]?.length !== 1) { - return this._uncPath(); - } - const rootFolderPaths = this.root.slice(1).join(WIN_PATH_SEPARATOR); - const subpath = this.sub.length === 0 ? "" : WIN_PATH_SEPARATOR + this.sub.join(WIN_PATH_SEPARATOR); - return `${this.root[0]}:${WIN_PATH_SEPARATOR}${rootFolderPaths}${subpath}`; - } - } else { - const subpath = this.sub.length === 0 ? "" : POSIX_PATH_SEPARATOR + this.sub.join(POSIX_PATH_SEPARATOR); - return `${POSIX_PATH_SEPARATOR}${this.root.join(POSIX_PATH_SEPARATOR)}${subpath}`; - } - } - rel(type = "posix") { - if (type === "win") { - return this.sub.join(WIN_PATH_SEPARATOR); - } else { - return this.sub.join(POSIX_PATH_SEPARATOR); - } - } - name() { - return last(this.sub) || last(this.root) || ""; - } - dir() { - return "/" + this.root.join(POSIX_PATH_SEPARATOR); - } - cd(subpath) { - return new Path(this.dir(), subpath); - } - platformAbsPath() { - return Path.isWindows() ? this.abs("win") : this.abs("posix"); - } - platformRelPath() { - return Path.isWindows() ? this.rel("win") : this.rel("posix"); - } - join(subpath) { - const parts = split(subpath); - return new Path(POSIX_PATH_SEPARATOR + this.root.join(POSIX_PATH_SEPARATOR), [...this.sub, ...parts].join(POSIX_PATH_SEPARATOR)); - } - filename(newFileName) { - if (this.sub.length === 0) { - throw new Error("InvalidArgumentError: sub path cannot be empty"); - } else { - const validName = filenamify(newFileName); - return new Path(POSIX_PATH_SEPARATOR + this.root.join(POSIX_PATH_SEPARATOR), [...this.sub.slice(0, -1), validName].join(POSIX_PATH_SEPARATOR)); - } - } - parent() { - if (this.sub.length === 0) { - throw new Error("reaching parent folder is not allowed"); - } else { - const parentSub = this.sub.slice(0, -1); - if (parentSub.length === 0) { - return new Path(POSIX_PATH_SEPARATOR + this.root.join(POSIX_PATH_SEPARATOR)); - } else { - return new Path(POSIX_PATH_SEPARATOR + this.root.join(POSIX_PATH_SEPARATOR), parentSub.join(POSIX_PATH_SEPARATOR)); - } - } - } - static fromAbsolutePath(absolutePath, root) { - return new Path(root, absolutePath.replace(root, "")); - } - static posix(windowsPath) { - const p = new Path(windowsPath); - return p.abs("posix"); - } - static win(posixPath) { - const p = new Path(posixPath); - return p.abs("win"); - } - static slash(windowsPath) { - return slash(windowsPath); - } - static backslash(posixPath) { - return posixPath.replace(POSIX_PATH_SEPARATOR, WIN_PATH_SEPARATOR); - } - static isWindows() { - const proc = typeof globalThis !== "undefined" ? globalThis.process : undefined; - if (proc?.platform) { - return proc.platform === "win32"; - } - const win = typeof globalThis !== "undefined" ? globalThis.window : undefined; - if (win) { - const electron = win.electron; - if (electron?.process?.platform) { - return electron.process.platform === "win32"; - } - const nav = win.navigator; - if (nav?.userAgent) { - return /Win/i.test(nav.userAgent); - } - } - return false; - } - static pathSeparator() { - return Path.isWindows() ? WIN_PATH_SEPARATOR : POSIX_PATH_SEPARATOR; - } - static toPlatformPath(path) { - return Path.isWindows() ? Path.win(path) : Path.posix(path); - } - toString() { - return this.abs(); - } -} -// ../core/ai-tool/isFolderExistResult.ts +// ../../apps/core/src/ai-tool/isFolderExistResult.ts function isFolderExistInvalidPath() { return { exists: false, @@ -56469,45 +60367,8 @@ function isFolderExistCheckFailed(path, message) { }; } -// ../core/ai-tool/toolResult.ts -function toolOk(data) { - return { ...data, error: undefined }; -} -function toolError(reason) { - const message = reason.startsWith("Error Reason:") ? reason : `Error Reason: ${reason}`; - return { error: message }; -} -function requireNonEmptyString(value, field) { - if (typeof value !== "string" || value.trim() === "") { - return { error: `Invalid ${field}: must be a non-empty string` }; - } - return value; -} -function messageFromUnknownError(error48) { - if (error48 instanceof Error) { - return error48.message; - } - if (typeof error48 === "string") { - return error48; - } - if (error48 === null || error48 === undefined) { - return "Unknown error (null/undefined thrown)"; - } - try { - const json3 = JSON.stringify(error48); - if (json3 && json3 !== "{}") { - return json3; - } - } catch {} - const text2 = String(error48); - return text2 || "Unknown error"; -} -function formatToolError(error48) { - return toolError(messageFromUnknownError(error48)); -} - // src/isFolderAvailable.ts -import { stat as stat2 } from "node:fs/promises"; +import { stat as stat3 } from "node:fs/promises"; var isFolderAvailableRequestSchema = exports_external2.object({ path: exports_external2.string().min(1, "path is required") }); @@ -56517,7 +60378,7 @@ async function resolveFolderExistence(folderPath) { } try { const normalizedPath = Path.toPlatformPath(folderPath); - const stats = await stat2(normalizedPath); + const stats = await stat3(normalizedPath); if (stats.isDirectory()) { return isFolderExistSucceeded(folderPath); } @@ -56598,9 +60459,9 @@ function buildIsFolderExistTool() { } // src/tools/getMediaMetadata.ts -import { stat as stat3 } from "node:fs/promises"; +import { stat as stat4 } from "node:fs/promises"; -// ../core/ai-tool/getMediaMetadataResponse.ts +// ../../apps/core/src/ai-tool/getMediaMetadataResponse.ts function parseMediaIdString(id) { const n = Number.parseInt(id, 10); return Number.isFinite(n) ? n : 0; @@ -56671,7 +60532,7 @@ function fillMediaMetadataResponseData(metadata, posixPath) { } // src/mediaMetadataCache.ts -import { mkdir, readFile as readFile2, unlink, writeFile as writeFile2 } from "node:fs/promises"; +import { mkdir as mkdir2, readFile as readFile2, unlink, writeFile as writeFile2 } from "node:fs/promises"; import path from "node:path"; function metadataCacheFilePath(appDataDir, folderPathInPosix) { const filename = folderPathInPosix.replace(/[\/\\:?*|<>"]/g, "_"); @@ -56692,7 +60553,7 @@ async function writeMediaMetadataCache(appDataDir, mediaMetadata) { throw new Error("Media folder path is required"); } const metadataDir = path.join(appDataDir, "metadata"); - await mkdir(metadataDir, { recursive: true }); + await mkdir2(metadataDir, { recursive: true }); const filePath = metadataCacheFilePath(appDataDir, Path.posix(mediaMetadata.mediaFolderPath)); await writeFile2(filePath, JSON.stringify(mediaMetadata, null, 2), "utf-8"); } @@ -56727,7 +60588,7 @@ async function executeGetMediaMetadata(params, userConfig, appDataDir, abortSign try { const normalizedPath = Path.toPlatformPath(pathCheck); try { - const stats = await stat3(normalizedPath); + const stats = await stat4(normalizedPath); if (!stats.isDirectory()) { return { ...baseData, error: GET_MEDIA_METADATA_NOT_DIRECTORY }; } @@ -56767,7 +60628,7 @@ function buildGetMediaMetadataTool(userConfig, appDataDir, abortSignal) { }; } -// ../core/ai-tool/buildGetEpisodesResponse.ts +// ../../apps/core/src/ai-tool/buildGetEpisodesResponse.ts function createEmptyGetEpisodesData() { return { episodes: [], @@ -56812,11 +60673,21 @@ function buildGetEpisodesResponse(metadata) { }; } +// src/types.ts +var EMPTY_CORE_ROUTES_CONFIG = { + allowlist: [] +}; + // src/userConfig.ts -import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises"; +import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises"; import path2 from "node:path"; var DEFAULT_USER_CONFIG = { - folders: [] + folders: [], + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: "plex" }; function resolveUserDataDir(config2) { return config2.hello?.userDataDir ?? config2.appDataDir; @@ -56852,7 +60723,7 @@ async function writeUserConfigToDisk(config2, userConfig) { if (!userDataDir) { throw new Error("userDataDir is not configured"); } - await mkdir2(userDataDir, { recursive: true }); + await mkdir3(userDataDir, { recursive: true }); await writeFile3(path2.join(userDataDir, "smm.json"), JSON.stringify(userConfig, null, 2), "utf-8"); } @@ -56860,7 +60731,7 @@ async function writeUserConfigToDisk(config2, userConfig) { var getEpisodesRequestSchema = exports_external2.object({ mediaFolderPath: exports_external2.string().min(1, "The absolute path of the media folder is required") }); -async function doGetEpisodes(body, config2 = {}) { +async function doGetEpisodes(body, config2 = EMPTY_CORE_ROUTES_CONFIG) { const parsed = getEpisodesRequestSchema.safeParse(body); if (!parsed.success) { const msg = parsed.error.issues.map((i) => i.message).join(", "); @@ -56912,7 +60783,7 @@ function buildGetEpisodesTool(config2, abortSignal) { }; } -// ../core/ai-tool/buildGetMediaFoldersResponse.ts +// ../../apps/core/src/ai-tool/buildGetMediaFoldersResponse.ts function createEmptyGetMediaFoldersData() { return { folders: [] }; } @@ -56945,7 +60816,7 @@ function buildGetMediaFoldersTool(userConfig, abortSignal) { }; } -// ../core/utils.ts +// ../types/mediaFileExtensions.ts var extensions = { audioTrackFileExtensions: [".mka"], videoFileExtensions: [ @@ -57039,7 +60910,7 @@ var videoFileExtensions = extensions.videoFileExtensions; var imageFileExtensions = extensions.imageFileExtensions; var subtitleFileExtensions = extensions.subtitleFileExtensions; -// ../core/ai-tool/buildListFilesInMediaFolderResponse.ts +// ../../apps/core/src/ai-tool/buildListFilesInMediaFolderResponse.ts function createEmptyListFilesInMediaFolderData() { return { files: [], count: 0 }; } @@ -57064,7 +60935,7 @@ function buildListFilesInMediaFolderResponse(filePaths, videoFileOnly = false) { // src/listFiles.ts import os from "node:os"; -import { readdir, stat as stat4 } from "node:fs/promises"; +import { readdir, stat as stat5 } from "node:fs/promises"; import path4 from "node:path"; // src/resolveListFilesPath.ts @@ -57194,7 +61065,7 @@ async function doListFiles(body, config2 = {}) { } } try { - const stats = await stat4(validatedPath); + const stats = await stat5(validatedPath); logger?.info({ requestId, validatedPath, isDirectory: stats.isDirectory(), isFile: stats.isFile() }, "[ListFiles] stat result"); if (!stats.isDirectory()) { logger?.info({ requestId, validatedPath }, "[ListFiles] path is not a directory"); @@ -57226,7 +61097,7 @@ async function doListFiles(body, config2 = {}) { for (const item of items) { const fullPath = joinListFilesChildPath(dirPath, item); try { - const itemStats = await stat4(fullPath); + const itemStats = await stat5(fullPath); const isFile2 = itemStats.isFile(); const isDirectory = itemStats.isDirectory(); const filename = path4.basename(item); @@ -57354,7 +61225,7 @@ function buildListFilesInMediaFolderTool(userConfig, abortSignal) { }; } -// ../core/ai-tool/renameFolderConfirm.ts +// ../../apps/core/src/ai-tool/renameFolderConfirm.ts function getFolderBasename(folderPath) { const parts = Path.posix(folderPath).split("/").filter(Boolean); return parts[parts.length - 1] ?? Path.posix(folderPath); @@ -57367,7 +61238,7 @@ function buildRenameFolderConfirmationMessage(from, to) { • Update media metadata`; } -// ../core/ai-tool/renameFolderResult.ts +// ../../apps/core/src/ai-tool/renameFolderResult.ts function renameFolderCancelled(from, to) { return { renamed: false, @@ -57395,7 +61266,7 @@ function renameFolderSucceeded(from, to) { // src/renameFolder.ts import { rename } from "node:fs/promises"; -// ../core/mediaMetadata.ts +// ../../apps/core/src/mediaMetadata.ts function renameFolderInMediaMetadata(mediaMetadata, from, to) { const fromNormalized = from.endsWith("/") ? from : from + "/"; const toNormalized = to.endsWith("/") ? to : to + "/"; @@ -57408,14 +61279,6 @@ function renameFolderInMediaMetadata(mediaMetadata, from, to) { result.mediaFolderPath = toNormalized + result.mediaFolderPath.slice(fromNormalized.length); } } - if (result.files) { - result.files = result.files.map((file2) => { - if (file2.startsWith(fromNormalized)) { - return toNormalized + file2.slice(fromNormalized.length); - } - return file2; - }); - } if (result.mediaFiles) { result.mediaFiles = result.mediaFiles.map((mediaFile) => { if (mediaFile.absolutePath.startsWith(fromNormalized)) { @@ -57434,10 +61297,6 @@ function updateMediaMetadataAfterRename(mediaMetadata, renameMappings) { for (const { from, to } of renameMappings) { renameMap.set(Path.posix(from), Path.posix(to)); } - const updatedFiles = mediaMetadata.files?.map((file2) => { - const normalizedFile = Path.posix(file2); - return renameMap.get(normalizedFile) ?? file2; - }); const updatedMediaFiles = mediaMetadata.mediaFiles?.map((mediaFile) => { const normalizedPath = Path.posix(mediaFile.absolutePath); const newPath = renameMap.get(normalizedPath); @@ -57469,12 +61328,11 @@ function updateMediaMetadataAfterRename(mediaMetadata, renameMappings) { }); return { ...mediaMetadata, - files: updatedFiles, mediaFiles: fullyUpdatedMediaFiles }; } -// ../core/userConfig.ts +// ../../apps/core/src/userConfig.ts function renameFolderInUserConfig(userConfig, from, to) { const actualFromPosix = Path.posix(from); const actualFromWindows = Path.win(from); @@ -57491,7 +61349,7 @@ var renameFolderRequestSchema = exports_external2.object({ from: exports_external2.string().min(1, "Source folder path is required, in POSIX format"), to: exports_external2.string().min(1, "Destination folder path is required, in POSIX format") }); -async function doRenameFolder(body, config2 = {}) { +async function doRenameFolder(body, config2 = EMPTY_CORE_ROUTES_CONFIG) { try { const validationResult = renameFolderRequestSchema.safeParse(body); if (!validationResult.success) { @@ -57609,475 +61467,295 @@ function buildRenameFolderTool(clientId, config2, abortSignal, acknowledge) { }; } -// ../core/plan/renamePlan.ts -function createEmptyRenamePlan(mediaFolderPath, id, options) { - const planId = id ?? (typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`); - return { - id: planId, - task: "rename-files", - status: options?.status ?? "pending", - creator: options?.creator ?? "app", - mediaFolderPath: Path.posix(mediaFolderPath), - files: [] - }; +// ../../apps/core/src/ai-tool/renameEpisodeFileConfirm.ts +function getEpisodeBasename(filePath) { + const parts = Path.posix(filePath).split("/").filter(Boolean); + return parts[parts.length - 1] ?? Path.posix(filePath); } -function assertMediaFolderHasMetadata(exists, folderPath) { - if (!exists) { - return `Error Reason: folderPath "${Path.posix(folderPath)}" is not opened in SMM`; - } - return; +function buildRenameEpisodeFileConfirmationMessage(from, to) { + return `Rename episode file "${getEpisodeBasename(from)}" to "${getEpisodeBasename(to)}"? + +` + `This will: +` + ` • Rename the episode video on disk +` + ` • Rename same-stem associated files (e.g. subtitles) in the same directory +` + " • Update media metadata"; } -function assertEpisodeVideoFile(metadata, fromPath) { - const fromPosix = Path.posix(fromPath); - const mediaFile = (metadata.mediaFiles ?? []).find((mf) => mf.absolutePath === fromPosix); - if (!mediaFile) { - return "Error Reason: Not Episode Video File"; - } - return; + +// ../../apps/core/src/ai-tool/renameEpisodeFileResult.ts +function renameEpisodeFileCancelled(mediaFolder, from, to) { + return { + renamed: false, + mediaFolder: Path.toPlatformPath(mediaFolder), + from: Path.toPlatformPath(from), + to: Path.toPlatformPath(to), + succeeded: [], + failed: [], + error: RENAME_EPISODE_FILE_CANCELLED + }; } -async function prepareAppendRenameEntry(plan, entry, deps) { - const fromPosix = Path.posix(entry.from); - const toPosix = Path.posix(entry.to); - const candidateFiles = [...plan.files, { from: fromPosix, to: toPosix }]; - const validationResult = await deps.validateOperations(candidateFiles, plan.mediaFolderPath); - if (!validationResult.isValid) { - return { error: `Error Reason: ${validationResult.errors.join(` -`)}` }; - } - const mm = await deps.getMediaMetadata(plan.mediaFolderPath); - if (!mm) { - return { - error: `Error Reason: Media metadata not found for media folder: ${plan.mediaFolderPath}` - }; - } - const episodeError = assertEpisodeVideoFile(mm, fromPosix); - if (episodeError) { - return { error: episodeError }; - } +function renameEpisodeFileFailed(mediaFolder, from, to, error48) { return { - ...plan, - files: [...plan.files, { from: fromPosix, to: toPosix }] + renamed: false, + mediaFolder: Path.toPlatformPath(mediaFolder), + from: Path.toPlatformPath(from), + to: Path.toPlatformPath(to), + succeeded: [], + failed: [], + error: error48 }; } - -// ../core/types/ai-tools/planTaskMessages.ts -var END_PLAN_TASK_SUCCESS_MESSAGE = "Task is created successfuly. User need to go to SMM, review and approve the task."; -var PLAN_CANCELLED_BY_USER_MESSAGE = "该任务已被用户取消, 请停止后续操作"; - -// ../core/event-types.ts -var RecognizeMediaFilePlanReady = { - event: "recognizeMediaFilePlanReady" -}; -var RenameFilesPlanReady = { - event: "renameFilesPlanReady" -}; -var USER_CONFIG_UPDATED_EVENT = "userConfigUpdated"; -var USER_CONFIG_FOLDER_RENAMED_EVENT = "userConfig.folderRenamed"; - -// src/tools/plans.ts -import { mkdir as mkdir3, readdir as readdir2, stat as stat5, unlink as unlink2 } from "node:fs/promises"; -import path5 from "node:path"; -import { randomUUID } from "node:crypto"; - -// ../core/types/planCommon.ts -function isActivePlanStatus(status) { - return status === "preparing" || status === "pending"; +function renameEpisodeFileSucceeded(mediaFolder, from, to, succeeded, failed = []) { + return { + renamed: succeeded.length > 0, + mediaFolder: Path.toPlatformPath(mediaFolder), + from: Path.toPlatformPath(from), + to: Path.toPlatformPath(to), + succeeded: succeeded.map((p) => ({ + from: Path.toPlatformPath(p.from), + to: Path.toPlatformPath(p.to) + })), + failed, + ...failed.length > 0 ? { error: failed.map((f) => f.error).join("; ") } : {} + }; } -// src/tools/plans.ts -function plansDir(appDataDir) { - return path5.join(appDataDir, "plans"); -} -function planFilePath(appDataDir, planId) { - return path5.join(plansDir(appDataDir), `${planId}.plan.json`); -} -async function ensurePlansDirExists(appDataDir, fs) { - const dir = plansDir(appDataDir); - try { - const stats = await stat5(dir); - if (!stats.isDirectory()) { - throw new Error("Plans path exists but is not a directory"); - } - } catch (error48) { - if (error48.code === "ENOENT") { - await mkdir3(dir, { recursive: true }); - return; - } - throw error48; +// src/tools/renameEpisodeFile.ts +async function executeRenameEpisodeFile(params, runner, abortSignal) { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); } -} -async function beginRenamePlan(appDataDir, mediaFolderPath, fs) { - await ensurePlansDirExists(appDataDir, fs); - const plan = createEmptyRenamePlan(Path.posix(mediaFolderPath), undefined, { - creator: "ai", - status: "preparing" - }); - await fs.writeJson(planFilePath(appDataDir, plan.id), plan); - return plan.id; -} -async function appendRenamePlanEntry(appDataDir, planId, from, to, fs, deps) { - const filePath = planFilePath(appDataDir, planId); - const plan = await fs.readJson(filePath) ?? null; - if (!plan) { - throw new Error(`Task with id ${planId} not found`); + const folderCheck = requireNonEmptyString(params.mediaFolder, "mediaFolder"); + if (typeof folderCheck !== "string") { + return renameEpisodeFileFailed("", "", "", folderCheck.error); } - if (plan.status === "rejected") { - throw new Error(PLAN_CANCELLED_BY_USER_MESSAGE); + const fromCheck = requireNonEmptyString(params.from, "from"); + if (typeof fromCheck !== "string") { + return renameEpisodeFileFailed(folderCheck, "", "", fromCheck.error); } - const result = await prepareAppendRenameEntry(plan, { from, to }, deps); - if ("error" in result) { - throw new Error(result.error.replace(/^Error Reason: /, "")); + const toCheck = requireNonEmptyString(params.to, "to"); + if (typeof toCheck !== "string") { + return renameEpisodeFileFailed(folderCheck, fromCheck, "", toCheck.error); } - await fs.writeJson(filePath, result); -} -async function readRenamePlan(appDataDir, planId, fs) { - const plan = await readPlanById(appDataDir, planId, fs); - if (!plan || plan.task !== "rename-files") { - return null; + if (!runner) { + return renameEpisodeFileFailed(folderCheck, fromCheck, toCheck, "rename-episode-file is not available on this host"); + } + try { + const result = await runner({ + mediaFolderPath: folderCheck, + from: fromCheck, + to: toCheck + }); + return renameEpisodeFileSucceeded(folderCheck, fromCheck, toCheck, result.succeeded, result.failed); + } catch (error48) { + const message = error48 instanceof Error ? error48.message : String(error48); + return renameEpisodeFileFailed(folderCheck, fromCheck, toCheck, `Error renaming episode file: ${message}`); } - return plan; } -async function readPlanById(appDataDir, planId, fs) { - const plan = await fs.readJson(planFilePath(appDataDir, planId)); - if (!plan) { +async function confirmRenameEpisodeFileViaSocket(clientId, from, to, acknowledge) { + const confirmationMessage = buildRenameEpisodeFileConfirmationMessage(from, to); + try { + const responseData = await acknowledge({ + event: "askForConfirmation", + data: { message: confirmationMessage }, + clientId + }, 30000); + const confirmed = responseData?.confirmed ?? responseData?.response === "yes"; + if (!confirmed) { + return renameEpisodeFileCancelled("", from, to); + } return null; + } catch (error48) { + return renameEpisodeFileFailed("", from, to, `Failed to get user confirmation: ${error48 instanceof Error ? error48.message : "Unknown error"}`); } - return normalizePlanPaths(withCreatorDefault(plan)); } -async function beginRecognizePlan(appDataDir, mediaFolderPath, fs) { - await ensurePlansDirExists(appDataDir, fs); - const planId = randomUUID(); - const plan = { - id: planId, - task: "recognize-media-file", - status: "preparing", - creator: "ai", - mediaFolderPath: Path.posix(mediaFolderPath), - files: [] +function buildRenameEpisodeFileTool(clientId, runner, abortSignal, acknowledge) { + const ack = acknowledge ?? defaultAcknowledge; + return { + description: RENAME_EPISODE_FILE_DESCRIPTION, + inputSchema: renameEpisodeFileInputSchema, + outputSchema: renameEpisodeFileOutputSchema, + execute: async (args) => { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); + } + const params = args ?? {}; + const folderCheck = requireNonEmptyString(params.mediaFolder, "mediaFolder"); + if (typeof folderCheck !== "string") { + return renameEpisodeFileFailed("", "", "", folderCheck.error); + } + const fromCheck = requireNonEmptyString(params.from, "from"); + if (typeof fromCheck !== "string") { + return renameEpisodeFileFailed(folderCheck, "", "", fromCheck.error); + } + const toCheck = requireNonEmptyString(params.to, "to"); + if (typeof toCheck !== "string") { + return renameEpisodeFileFailed(folderCheck, fromCheck, "", toCheck.error); + } + const cancelOrError = await confirmRenameEpisodeFileViaSocket(clientId, fromCheck, toCheck, ack); + if (cancelOrError) { + return { + ...cancelOrError, + mediaFolder: Path.toPlatformPath(folderCheck) + }; + } + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); + } + return executeRenameEpisodeFile({ mediaFolder: folderCheck, from: fromCheck, to: toCheck }, runner, abortSignal); + } }; - await fs.writeJson(planFilePath(appDataDir, planId), plan); - return planId; } -async function defaultValidateRecognizedFiles(files, fs) { - for (const file2 of files) { - if (!file2.path) { - throw new Error(`File path is empty for S${file2.season}E${file2.episode}`); - } - const platformPath = Path.toPlatformPath(Path.posix(file2.path)); - const exists = await fs.exists(platformPath); - if (!exists) { - throw new Error(`File "${Path.posix(file2.path)}" (S${file2.season}E${file2.episode}) does not exist in the media folder`); - } - } + +// ../../apps/core/src/ai-tool/scrapeResult.ts +function scrapeSucceeded(id) { + return { + id, + message: SCRAPE_JOB_CREATED_MESSAGE + }; } -async function appendRecognizedFile(appDataDir, taskId, file2, fs, deps = {}) { - const filePath = planFilePath(appDataDir, taskId); - const plan = await fs.readJson(filePath) ?? null; - if (!plan) { - throw new Error(`Task with id ${taskId} not found`); +function scrapeFailed(path5, error48) { + return { + id: "", + message: "", + error: path5.trim() ? `${error48} (path: ${Path.toPlatformPath(path5)})` : error48 + }; +} + +// src/tools/scrape.ts +async function executeScrape(params, runner, abortSignal) { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); } - if (plan.status === "rejected") { - throw new Error(PLAN_CANCELLED_BY_USER_MESSAGE); + const pathCheck = requireNonEmptyString(params.path, "path"); + if (typeof pathCheck !== "string") { + return scrapeFailed("", pathCheck.error); } - const normalizedPath = Path.posix(file2.path); - const validate = deps.validateFiles ?? ((files) => defaultValidateRecognizedFiles(files, fs)); - await validate([{ ...file2, path: normalizedPath }]); - plan.files.push({ - season: file2.season, - episode: file2.episode, - path: normalizedPath - }); - await fs.writeJson(filePath, plan); -} -async function readRecognizePlan(appDataDir, taskId, fs) { - const plan = await readPlanById(appDataDir, taskId, fs); - if (!plan || plan.task !== "recognize-media-file") { - return null; + if (!runner) { + return scrapeFailed(pathCheck, "scrape is not available on this host"); } - return plan; -} -async function listPlanFiles(appDataDir) { - const dir = plansDir(appDataDir); try { - const stats = await stat5(dir); - if (!stats.isDirectory()) { - return []; - } - } catch { - return []; - } - const files = await readdir2(dir); - return files.filter((file2) => file2.endsWith(".plan.json")).map((file2) => path5.join(dir, file2)); -} -function withCreatorDefault(plan) { - if (plan.creator) { - return plan; + const language = typeof params.language === "string" && params.language.trim() !== "" ? params.language : undefined; + const { id } = await runner(pathCheck, language !== undefined ? { language } : undefined); + return scrapeSucceeded(id); + } catch (error48) { + const message = error48 instanceof Error ? error48.message : String(error48); + const withPrefix = message.startsWith("Error Reason:") ? message : `Error Reason: ${message}`; + return scrapeFailed(pathCheck, withPrefix); } - return { ...plan, creator: "app" }; } -function normalizePlanPaths(plan) { - const mediaFolderPath = Path.posix(plan.mediaFolderPath); - if (plan.task === "recognize-media-file") { - return { - ...plan, - mediaFolderPath, - files: plan.files.map((f) => ({ ...f, path: Path.posix(f.path) })) - }; - } +function buildScrapeTool(runner, abortSignal) { return { - ...plan, - mediaFolderPath, - files: plan.files.map((f) => ({ - from: Path.posix(f.from), - to: Path.posix(f.to) - })) + description: SCRAPE_DESCRIPTION, + inputSchema: scrapeInputSchema, + outputSchema: scrapeOutputSchema, + execute: async (args) => { + const params = args ?? {}; + return executeScrape({ + path: params.path, + language: params.language + }, runner, abortSignal); + } }; } -async function createPlan(appDataDir, input, fs) { - await ensurePlansDirExists(appDataDir, fs); - const id = input.id ?? randomUUID(); - const mediaFolderPath = Path.posix(input.mediaFolderPath); - const plan = input.task === "recognize-media-file" ? { - id, - task: "recognize-media-file", - status: "preparing", - creator: input.creator, - mediaFolderPath, - files: [] - } : { - id, - task: "rename-files", - status: "preparing", - creator: input.creator, - mediaFolderPath, - files: [] - }; - await fs.writeJson(planFilePath(appDataDir, id), plan); - return plan; + +// ../../apps/core/src/ai-tool/getJobResult.ts +function getJobSucceeded(job) { + return { job }; } -async function updatePlanContent(appDataDir, id, patch, fs) { - const filePath = planFilePath(appDataDir, id); - const existing = await fs.readJson(filePath); - if (!existing) { - return null; +function getJobFailed(error48) { + return { error: error48 }; +} + +// src/tools/getJob.ts +async function executeGetJob(id, runner, abortSignal) { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); } - const merged = withCreatorDefault({ - ...existing, - ...patch.status !== undefined ? { status: patch.status } : {}, - ...patch.files !== undefined ? { files: patch.files } : {} - }); - const updated = normalizePlanPaths(merged); - if (patch.status === "completed") { - await deletePlan(appDataDir, id); - return updated; + const idCheck = requireNonEmptyString(id, "id"); + if (typeof idCheck !== "string") { + return getJobFailed(idCheck.error); + } + if (!runner) { + return getJobFailed("get-job is not available on this host"); } - await fs.writeJson(filePath, updated); - return updated; -} -async function deletePlan(appDataDir, id) { try { - await unlink2(planFilePath(appDataDir, id)); - } catch (error48) { - if (error48.code !== "ENOENT") { - throw error48; + const job = runner(idCheck); + if (job == null) { + return getJobFailed("Error Reason: Job not found"); } + return getJobSucceeded(job); + } catch (error48) { + const message = error48 instanceof Error ? error48.message : String(error48); + const withPrefix = message.startsWith("Error Reason:") ? message : `Error Reason: ${message}`; + return getJobFailed(withPrefix); } } -async function cleanPreparingPlans(appDataDir, fs, logger) { - const start = Date.now(); - const plansPath = plansDir(appDataDir); - logger?.info({ appDataDir, plansDir: plansPath }, "[cleanup] plan cleanup: scanning for stale preparing plans"); - const files = await listPlanFiles(appDataDir); - logger?.info({ plansDir: plansPath, scanned: files.length }, "[cleanup] plan cleanup: enumerated plan files"); - let removed = 0; - let failed = 0; - for (const filePath of files) { - try { - const plan = await fs.readJson(filePath); - if (!plan) { - logger?.debug({ filePath }, "[cleanup] plan cleanup: skipping unreadable plan file"); - continue; - } - if (plan.status === "preparing") { - await unlink2(filePath); - removed++; - logger?.debug({ filePath, planId: plan.id, task: plan.task }, "[cleanup] plan cleanup: removed stale preparing plan"); - } else { - logger?.debug({ filePath, planId: plan.id, status: plan.status }, "[cleanup] plan cleanup: keeping plan (not preparing)"); - } - } catch (err) { - failed++; - logger?.warn({ filePath, error: err.message }, "[cleanup] plan cleanup: failed to process plan file, skipping"); +function buildGetJobTool(runner, abortSignal) { + return { + description: GET_JOB_DESCRIPTION, + inputSchema: getJobInputSchema, + outputSchema: getJobOutputSchema, + execute: async (args) => { + const params = args ?? {}; + return executeGetJob(params.id ?? "", runner, abortSignal); } + }; +} + +// ../../apps/core/src/pipeline/createRenameEpisodePlan.ts +import { randomUUID } from "node:crypto"; + +// ../../apps/core/src/plan/renamePlan.ts +function assertMediaFolderHasMetadata(exists, folderPath) { + if (!exists) { + return `Error Reason: folderPath "${Path.posix(folderPath)}" is not opened in SMM`; } - logger?.info({ - plansDir: plansPath, - scanned: files.length, - removed, - failed, - durationMs: Date.now() - start - }, "[cleanup] plan cleanup: complete"); - return removed; + return; } -async function getActivePlansForFolder(appDataDir, mediaFolderPath, fs) { - const target = Path.posix(mediaFolderPath); - const files = await listPlanFiles(appDataDir); - const plans = []; - for (const file2 of files) { - const plan = await fs.readJson(file2); - if (!plan) { - continue; - } - const normalized = normalizePlanPaths(withCreatorDefault(plan)); - if (normalized.mediaFolderPath === target && isActivePlanStatus(normalized.status)) { - plans.push(normalized); - } +function assertEpisodeVideoFile(metadata, fromPath) { + const fromPosix = Path.posix(fromPath); + const mediaFile = (metadata.mediaFiles ?? []).find((mf) => mf.absolutePath === fromPosix); + if (!mediaFile) { + return "Error Reason: Not Episode Video File"; } - return plans; + return; } -// src/tools/renameFilesTask.ts -function makeLogger(logger) { - return { - info: (obj, msg) => logger?.info(obj, msg), - warn: (obj, msg) => logger?.warn(obj, msg), - error: (obj, msg) => logger?.error(obj, msg) - }; -} -function buildBeginRenameFilesTaskTool(clientId, appDataDir, fs, _deps, broadcast, logger, abortSignal) { - const log = makeLogger(logger); - const emit = broadcast ?? defaultBroadcast; - return { - description: BEGIN_RENAME_FILES_TASK_DESCRIPTION, - toolName: BEGIN_RENAME_FILES_TASK, - inputSchema: beginRenameFilesTaskInputSchema, - execute: async (args) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const { mediaFolderPath } = args ?? {}; - log.info({ mediaFolderPath, clientId }, `[tool][${BEGIN_RENAME_FILES_TASK}] Starting new rename task`); - const folderPathInPosix = Path.posix(mediaFolderPath ?? ""); - const metadataFilePath = metadataCacheFilePath(appDataDir, folderPathInPosix); - const metadataExists = await fs.exists(metadataFilePath); - const metadataError = assertMediaFolderHasMetadata(metadataExists, folderPathInPosix); - if (metadataError) { - log.warn({ folderPath: folderPathInPosix }, `[tool][${BEGIN_RENAME_FILES_TASK}] Media metadata not found`); - return toolError(metadataError.replace(/^Error Reason: /, "")); - } - try { - const taskId = await beginRenamePlan(appDataDir, folderPathInPosix, fs); - log.info({ taskId, mediaFolderPath: folderPathInPosix, clientId }, `[tool][${BEGIN_RENAME_FILES_TASK}] Task created successfully`); - const fullPlanPath = planFilePath(appDataDir, taskId); - const planFilePathInPosix = Path.posix(fullPlanPath); - const data = { - taskId, - planFilePath: planFilePathInPosix - }; - emit({ - event: RenameFilesPlanReady.event, - data - }); - log.info({ taskId, mediaFolderPath: folderPathInPosix, clientId, broadcast: true }, `[tool][${BEGIN_RENAME_FILES_TASK}] RenameFilesPlanReady broadcast sent`); - return toolOk({ taskId }); - } catch (error48) { - log.error({ - mediaFolderPath: folderPathInPosix, - error: error48 instanceof Error ? error48.message : String(error48), - clientId - }, `[tool][${BEGIN_RENAME_FILES_TASK}] Failed to create task`); - return formatToolError(error48); +// ../../apps/core/src/validations/rename/validateRenameFileExistence.ts +async function validateSourceFilesExist(tasks, probe) { + const missingFiles = []; + for (const task of tasks) { + try { + if (!await probe.isFile(task.from)) { + missingFiles.push(task.from); } + } catch { + missingFiles.push(task.from); } - }; -} -function buildAddRenameFileToTaskTool(clientId, appDataDir, fs, deps, logger, abortSignal) { - const log = makeLogger(logger); + } return { - description: ADD_RENAME_FILE_TO_TASK_DESCRIPTION, - toolName: ADD_RENAME_FILE_TO_TASK, - inputSchema: addRenameFileToTaskInputSchema, - execute: async (args) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const { taskId, from, to } = args ?? {}; - const normalizedTaskId = (taskId ?? "").trim(); - log.info({ taskId: normalizedTaskId, from, to, clientId }, `[tool][${ADD_RENAME_FILE_TO_TASK}] Adding file to task`); - try { - await appendRenamePlanEntry(appDataDir, normalizedTaskId, from ?? "", to ?? "", fs, deps); - log.info({ taskId: normalizedTaskId, from, to, clientId }, `[tool][${ADD_RENAME_FILE_TO_TASK}] File added successfully`); - return toolOk({}); - } catch (error48) { - log.error({ - taskId: normalizedTaskId, - from, - to, - error: error48 instanceof Error ? error48.message : String(error48), - clientId - }, `[tool][${ADD_RENAME_FILE_TO_TASK}] Failed to add file`); - return formatToolError(error48); - } - } + isValid: missingFiles.length === 0, + missingFiles }; } -function buildEndRenameFilesTaskTool(clientId, appDataDir, fs, broadcast, logger, abortSignal) { - const log = makeLogger(logger); - const emit = broadcast ?? defaultBroadcast; - return { - description: END_RENAME_FILES_TASK_DESCRIPTION, - toolName: END_RENAME_FILES_TASK, - inputSchema: endRenameFilesTaskInputSchema, - execute: async (args) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const { taskId } = args ?? {}; - const normalizedTaskId = (taskId ?? "").trim(); - log.info({ taskId: normalizedTaskId, clientId }, `[tool][${END_RENAME_FILES_TASK}] Ending rename task`); - try { - const task = await readRenamePlan(appDataDir, normalizedTaskId, fs); - if (!task) { - log.error({ taskId: normalizedTaskId, clientId }, `[tool][${END_RENAME_FILES_TASK}] Task not found`); - return toolError(`Task with id "${normalizedTaskId}" not found`); - } - if (task.status === "rejected") { - log.warn({ taskId: normalizedTaskId, clientId }, `[tool][${END_RENAME_FILES_TASK}] Task cancelled by user`); - return toolError(PLAN_CANCELLED_BY_USER_MESSAGE); - } - if (task.files.length === 0) { - log.warn({ taskId: normalizedTaskId, clientId }, `[tool][${END_RENAME_FILES_TASK}] No files in task`); - return toolError("No rename entries in task"); - } - await updatePlanContent(appDataDir, task.id, { status: "pending" }, fs); - const fullPlanPath = planFilePath(appDataDir, task.id); - const planFilePathInPosix = Path.posix(fullPlanPath); - const data = { - taskId: task.id, - planFilePath: planFilePathInPosix - }; - emit({ - event: RenameFilesPlanReady.event, - data - }); - log.info({ taskId: normalizedTaskId, fileCount: task.files.length, clientId }, `[tool][${END_RENAME_FILES_TASK}] Plan ready, UI notified`); - return toolOk({ message: END_PLAN_TASK_SUCCESS_MESSAGE }); - } catch (error48) { - log.error({ - taskId: normalizedTaskId, - error: error48 instanceof Error ? error48.message : String(error48), - clientId - }, `[tool][${END_RENAME_FILES_TASK}] End task error`); - return formatToolError(error48); +async function validateDestFilesNotExist(tasks, probe) { + const existingFiles = []; + for (const task of tasks) { + try { + if (await probe.isFile(task.to)) { + existingFiles.push(task.to); } + } catch { + continue; } + } + return { + isValid: existingFiles.length === 0, + existingFiles }; } -// src/renameFilesValidation.ts -import { stat as stat6 } from "node:fs/promises"; - -// ../core/validations/rename/validateChainingConflicts.ts +// ../../apps/core/src/validations/rename/validateChainingConflicts.ts function validateChainingConflicts(tasks) { const sourcePaths = new Set; for (const task of tasks) { @@ -58091,7 +61769,7 @@ function validateChainingConflicts(tasks) { return true; } -// ../core/validations/rename/validateNoAbnormalPaths.ts +// ../../apps/core/src/validations/rename/validateNoAbnormalPaths.ts function isPathNormal(p) { if (p.startsWith("../")) { return true; @@ -58131,7 +61809,7 @@ function validateNoAbnormalPaths(tasks) { return errors4; } -// ../core/validations/rename/validateNoDuplicatedDestFile.ts +// ../../apps/core/src/validations/rename/validateNoDuplicatedDestFile.ts function validateNoDuplicatedDestFile(tasks) { const destPaths = new Map; for (let i = 0;i < tasks.length; i++) { @@ -58143,9 +61821,9 @@ function validateNoDuplicatedDestFile(tasks) { destPaths.set(task.to, existing); } const duplicates = []; - for (const [path6, indices] of destPaths) { + for (const [path5, indices] of destPaths) { if (indices.length > 1) { - duplicates.push(path6); + duplicates.push(path5); } } return { @@ -58154,7 +61832,7 @@ function validateNoDuplicatedDestFile(tasks) { }; } -// ../core/validations/rename/validateNoDuplicatedSourceFile.ts +// ../../apps/core/src/validations/rename/validateNoDuplicatedSourceFile.ts function validateNoDuplicatedSourceFile(tasks) { const sourcePaths = new Map; for (let i = 0;i < tasks.length; i++) { @@ -58166,9 +61844,9 @@ function validateNoDuplicatedSourceFile(tasks) { sourcePaths.set(task.from, existing); } const duplicates = []; - for (const [path6, indices] of sourcePaths) { + for (const [path5, indices] of sourcePaths) { if (indices.length > 1) { - duplicates.push(path6); + duplicates.push(path5); } } return { @@ -58177,7 +61855,7 @@ function validateNoDuplicatedSourceFile(tasks) { }; } -// ../core/validations/rename/validateNoIdenticalSourceAndDestFile.ts +// ../../apps/core/src/validations/rename/validateNoIdenticalSourceAndDestFile.ts function validateNoIdenticalSourceAndDestFile(tasks) { const identicals = []; for (const task of tasks) { @@ -58191,7 +61869,7 @@ function validateNoIdenticalSourceAndDestFile(tasks) { }; } -// ../core/validations/rename/validatePathWithinMediaFolder.ts +// ../../apps/core/src/validations/rename/validatePathWithinMediaFolder.ts function validatePathWithinMediaFolder(mediaFolderPath, tasks) { const invalidPaths = []; const mediaFolderObj = new Path(mediaFolderPath); @@ -58219,7 +61897,7 @@ function validatePathWithinMediaFolder(mediaFolderPath, tasks) { }; } -// ../core/validations/rename/validateRenameOperationsSync.ts +// ../../apps/core/src/validations/rename/validateRenameOperationsSync.ts function validateRenameOperationsSync(files, folderPathInPosix) { const errors4 = []; const normalizedTasks = []; @@ -58312,14 +61990,12 @@ function validateRenameOperationsSync(files, folderPathInPosix) { }; } -// src/renameFilesValidation.ts -async function validateRenameOperations(files, folderPathInPosix) { +// ../../apps/core/src/validations/rename/validateRenameOperations.ts +async function validateRenameOperations(files, folderPathInPosix, probe) { const normalizedTasks = []; - for (let i = 0;i < files.length; i++) { - const renameOp = files[i]; - if (!renameOp) { + for (const renameOp of files) { + if (!renameOp) continue; - } normalizedTasks.push({ from: Path.posix(renameOp.from), to: Path.posix(renameOp.to) @@ -58334,13 +62010,13 @@ async function validateRenameOperations(files, folderPathInPosix) { } const syncResult = validateRenameOperationsSync(normalizedTasks, folderPathInPosix); const errors4 = [...syncResult.errors]; - const sourceExistResult = await validateSourceFileExist(normalizedTasks); + const sourceExistResult = await validateSourceFilesExist(normalizedTasks, probe); if (!sourceExistResult.isValid) { for (const missingFile of sourceExistResult.missingFiles) { errors4.push(`Source file "${missingFile}" does not exist in the media folder`); } } - const destNotExistResult = await validateDestFileNotExist(normalizedTasks); + const destNotExistResult = await validateDestFilesNotExist(normalizedTasks, probe); if (!destNotExistResult.isValid) { for (const existingFile of destNotExistResult.existingFiles) { errors4.push(`Target file "${existingFile}" already exists in the filesystem`); @@ -58355,206 +62031,322 @@ async function validateRenameOperations(files, folderPathInPosix) { } return syncResult; } -async function validateSourceFileExist(tasks) { - const missingFiles = []; - for (const task of tasks) { - if (!task) - continue; - try { - const platformPath = Path.toPlatformPath(task.from); - const stats = await stat6(platformPath); - if (!stats.isFile()) { - missingFiles.push(task.from); + +// ../types/planCommon.ts +function isActivePlanStatus(status) { + return status === "preparing" || status === "pending"; +} + +// ../../apps/core/src/pipeline/paths.ts +function joinPosix(...parts) { + return parts.join("/"); +} +function plansDir(appDataDir) { + return joinPosix(Path.posix(appDataDir), "plans"); +} +function planFilePath(appDataDir, planId) { + return joinPosix(plansDir(appDataDir), `${planId}.plan.json`); +} + +// ../../apps/core/src/pipeline/plans.ts +async function writePlan(fs, appDataDir, plan) { + await fs.writeTextFile(planFilePath(appDataDir, plan.id), JSON.stringify(plan, null, 2)); +} + +// ../../apps/core/src/pipeline/createRenameEpisodePlan.ts +function renameFileExistenceProbe(fs) { + return { + isFile: async (path5) => { + if (fs.isFile) { + return fs.isFile(path5); } - } catch { - missingFiles.push(task.from); + return fs.exists(path5); } - } - return { - isValid: missingFiles.length === 0, - missingFiles }; } -async function validateDestFileNotExist(tasks) { - const existingFiles = []; - for (const task of tasks) { - if (!task) - continue; - try { - const platformPath = Path.toPlatformPath(task.to); - const stats = await statWithTimeout(platformPath); - if (stats.isFile()) { - existingFiles.push(task.to); - } - } catch { - continue; +async function createRenameEpisodePlanPipeline(mediaFolderPath, files, options, deps) { + const posixFolder = deps.normalizePosix(mediaFolderPath); + const mm = await deps.getMediaMetadata(posixFolder); + const metadataError = assertMediaFolderHasMetadata(!!mm, posixFolder); + if (metadataError) { + throw new Error(metadataError); + } + const normalizedFiles = files.map((entry) => ({ + from: deps.normalizePosix(entry.from), + to: deps.normalizePosix(entry.to) + })); + const allowEmptyFiles = options?.allowEmptyFiles ?? false; + if (normalizedFiles.length === 0 && !allowEmptyFiles) { + throw new Error("No rename entries in task"); + } + for (const entry of normalizedFiles) { + const episodeError = assertEpisodeVideoFile(mm, entry.from); + if (episodeError) { + throw new Error(episodeError); } } - return { - isValid: existingFiles.length === 0, - existingFiles + if (normalizedFiles.length > 0) { + const validation = await validateRenameOperations(normalizedFiles, posixFolder, renameFileExistenceProbe(deps.fs)); + if (!validation.isValid) { + throw new Error(validation.errors.join("; ")); + } + } + const createId = deps.createId ?? randomUUID; + const id = options?.id ?? createId(); + const plan = { + id, + task: "rename-files", + status: "pending", + creator: options?.creator ?? "app", + mediaFolderPath: posixFolder, + files: normalizedFiles }; + await writePlan(deps.fs, deps.appDataDir, plan); + return plan; } -function statWithTimeout(filePath, timeoutMs = 1000) { - return Promise.race([ - stat6(filePath), - new Promise((_, reject) => setTimeout(() => reject(new Error(`stat timeout for path: ${filePath}`)), timeoutMs)) - ]); + +// ../types/types.ts +var DEFAULT_AI_PROVIDERS = [ + { name: "DeepSeek", baseURL: "https://api.deepseek.com", apiKey: "", model: "deepseek-v4-flash" }, + { name: "OpenAI", baseURL: "https://api.openai.com/v1", apiKey: "", model: "gpt-4o" }, + { name: "OpenRouter", baseURL: "https://openrouter.ai/api/v1", apiKey: "", model: "deepseek/deepseek-v4-flash" }, + { name: "GLM", baseURL: "https://open.bigmodel.cn/api/paas/v4", apiKey: "", model: "GLM-4.5" }, + { name: "Other", baseURL: "", apiKey: "", model: "" } +]; +var DEFAULT_SELECTED_AI_PROVIDER = "DeepSeek"; +var AI_AGENT_PERMISSIONS = { + metadataWrite: "metadata.write" +}; +function hasAiAgentPermission(userConfig, permission) { + return userConfig?.aiAgent?.permissions?.includes(permission) ?? false; } -// src/tools/renameFilesTaskDefaults.ts -function defaultRenameFilesTaskDeps(appDataDir) { +// ../types/ai-tools/planTaskMessages.ts +var END_PLAN_TASK_SUCCESS_MESSAGE = "Task is created successfuly. User need to go to SMM, review and approve the task."; +var RENAME_PLAN_AUTO_APPLIED_MESSAGE = "Rename plan applied automatically (metadata.write permission granted). No user approval needed."; +var RECOGNIZE_PLAN_AUTO_APPLIED_MESSAGE = "Recognize plan applied automatically (metadata.write permission granted). No user approval needed."; + +// ../types/event-types.ts +var RecognizeMediaFilePlanReady = { + event: "recognizeMediaFilePlanReady" +}; +var RenameFilesPlanReady = { + event: "renameFilesPlanReady" +}; +var MEDIA_METADATA_UPDATED_EVENT = "mediaMetadataUpdated"; +var USER_CONFIG_UPDATED_EVENT = "userConfigUpdated"; +var USER_CONFIG_FOLDER_RENAMED_EVENT = "userConfig.folderRenamed"; + +// src/tools/chatFsPort.ts +function unsupportedFsOperation(name21) { + throw new Error(`${name21} is not supported by the plan filesystem adapter`); +} +function createFsPort(fs) { return { - validateOperations: async (files, folderPathInPosix) => { - return validateRenameOperations(files, folderPathInPosix); + async readTextFile(path5) { + const value = await fs.readJson(path5); + if (value === null) { + throw new Error(`File not found: ${path5}`); + } + return JSON.stringify(value); + }, + async writeTextFile(path5, content) { + await fs.writeJson(path5, JSON.parse(content)); + }, + async writeBinaryFile() { + unsupportedFsOperation("writeBinaryFile"); + }, + exists: (path5) => fs.exists(path5), + isFile: (path5) => fs.exists(path5), + async listFiles() { + return unsupportedFsOperation("listFiles"); }, - getMediaMetadata: async (folderPathInPosix) => { - return await readMediaMetadataCache(appDataDir, folderPathInPosix) ?? null; + async listSubdirectories() { + return unsupportedFsOperation("listSubdirectories"); + }, + async deleteFile() { + unsupportedFsOperation("deleteFile"); + }, + async rename() { + unsupportedFsOperation("rename"); + }, + async mkdir() { + unsupportedFsOperation("mkdir"); } }; } - -// src/tools/recognizeMediaFilesTask.ts -function defaultRecognizeFilesTaskDeps(fs) { - return { - validateFiles: (files) => defaultValidateRecognizedFiles(files, fs) - }; +function planPath(appDataDir, planId) { + return new Path(appDataDir, `plans/${planId}.plan.json`).abs("posix"); } -function makeLogger2(logger) { - return { - info: (obj, msg) => logger?.info(obj, msg), - warn: (obj, msg) => logger?.warn(obj, msg), - error: (obj, msg) => logger?.error(obj, msg) - }; + +// src/tools/createRenameEpisodePlan.ts +function metadataPath(appDataDir, mediaFolderPath) { + const filename = Path.posix(mediaFolderPath).replace(/[/\\:?*|<>"]/g, "_"); + return new Path(appDataDir, `metadata/${filename}.json`).abs("posix"); } -function buildBeginRecognizeTaskTool(clientId, appDataDir, fs, broadcast, logger, abortSignal) { - const log = makeLogger2(logger); +function buildCreateRenameEpisodePlanTool(appDataDir, fs, broadcast, logger, abortSignal, extra) { const emit = broadcast ?? defaultBroadcast; return { - description: BEGIN_RECOGNIZE_TASK_DESCRIPTION, - toolName: BEGIN_RECOGNIZE_TASK, - inputSchema: beginRecognizeTaskInputSchema, + description: CREATE_RENAME_EPISODE_PLAN_DESCRIPTION, + inputSchema: createRenameEpisodePlanInputSchema, execute: async (args) => { if (abortSignal?.aborted) { throw new Error("Request was aborted"); } - const { mediaFolderPath } = args ?? {}; - log.info({ mediaFolderPath, clientId }, `[tool][${BEGIN_RECOGNIZE_TASK}] Starting new recognition task`); - const folderPathInPosix = Path.posix(mediaFolderPath ?? ""); + const parsed = createRenameEpisodePlanInputSchema.safeParse(args); + if (!parsed.success) { + return formatToolError(parsed.error); + } try { - const taskId = await beginRecognizePlan(appDataDir, folderPathInPosix, fs); - log.info({ taskId, mediaFolderPath: folderPathInPosix, clientId }, `[tool][${BEGIN_RECOGNIZE_TASK}] Task created successfully`); - const fullPlanPath = planFilePath(appDataDir, taskId); - const planFilePathInPosix = Path.posix(fullPlanPath); + const plan = await createRenameEpisodePlanPipeline(parsed.data.mediaFolderPath, parsed.data.files, { creator: "ai" }, { + fs: createFsPort(fs), + appDataDir, + normalizePosix: Path.posix, + getMediaMetadata: (folder) => fs.readJson(metadataPath(appDataDir, folder)) + }); + if (extra?.getUserConfig && extra.applyRenameEpisodePlan) { + try { + const userConfig = await extra.getUserConfig(); + if (hasAiAgentPermission(userConfig, AI_AGENT_PERMISSIONS.metadataWrite)) { + await extra.applyRenameEpisodePlan(plan); + emit({ + event: MEDIA_METADATA_UPDATED_EVENT, + data: { folderPath: plan.mediaFolderPath } + }); + logger?.info({ + planId: plan.id, + folderPath: plan.mediaFolderPath, + fileCount: plan.files.length + }, `[tool][${CREATE_RENAME_EPISODE_PLAN}] Plan applied automatically`); + return toolOk({ + message: RENAME_PLAN_AUTO_APPLIED_MESSAGE, + planId: plan.id + }); + } + } catch (error48) { + logger?.warn({ planId: plan.id, error: error48 }, `[tool][${CREATE_RENAME_EPISODE_PLAN}] Auto-apply failed, plan stays pending`); + } + } const data = { - taskId, - planFilePath: planFilePathInPosix + taskId: plan.id, + planFilePath: planPath(appDataDir, plan.id) }; - emit({ - event: RecognizeMediaFilePlanReady.event, - data + emit({ event: RenameFilesPlanReady.event, data }); + logger?.info({ + planId: plan.id, + folderPath: plan.mediaFolderPath, + fileCount: plan.files.length + }, `[tool][${CREATE_RENAME_EPISODE_PLAN}] Plan created`); + return toolOk({ + message: END_PLAN_TASK_SUCCESS_MESSAGE, + planId: plan.id }); - log.info({ taskId, mediaFolderPath: folderPathInPosix, clientId, broadcast: true }, `[DIAG] begin-recognize-task: plan created, RecognizeMediaFilePlanReady broadcast sent`); - return toolOk({ taskId }); } catch (error48) { - log.error({ - mediaFolderPath: folderPathInPosix, - error: error48 instanceof Error ? error48.message : String(error48), - clientId - }, `[tool][${BEGIN_RECOGNIZE_TASK}] Failed to create task`); return formatToolError(error48); } } }; } -function buildAddRecognizedMediaFileTool(clientId, appDataDir, fs, logger, abortSignal, deps) { - const log = makeLogger2(logger); - return { - description: ADD_RECOGNIZED_MEDIA_FILE_DESCRIPTION, - toolName: ADD_RECOGNIZED_MEDIA_FILE, - inputSchema: addRecognizedMediaFileInputSchema, - execute: async (args) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const { taskId, season, episode, path: filePath } = args ?? {}; - const normalizedTaskId = (taskId ?? "").trim(); - log.info({ taskId: normalizedTaskId, season, episode, path: filePath, clientId }, `[tool][${ADD_RECOGNIZED_MEDIA_FILE}] Adding file to task`); - try { - const recognizedFile = { - season: season ?? 0, - episode: episode ?? 0, - path: filePath ?? "" - }; - await appendRecognizedFile(appDataDir, normalizedTaskId, recognizedFile, fs, { validateFiles: deps?.validateFiles }); - log.info({ - taskId: normalizedTaskId, - season, - episode, - path: filePath, - clientId - }, `[tool][${ADD_RECOGNIZED_MEDIA_FILE}] File added to task successfully`); - return toolOk({}); - } catch (error48) { - log.error({ - taskId: normalizedTaskId, - season, - episode, - path: filePath, - error: error48 instanceof Error ? error48.message : String(error48), - clientId - }, `[tool][${ADD_RECOGNIZED_MEDIA_FILE}] Failed to add file to task`); - return formatToolError(error48); - } + +// ../../apps/core/src/pipeline/createRecognizeEpisodePlan.ts +import { randomUUID as randomUUID2 } from "node:crypto"; +async function createRecognizeEpisodePlanPipeline(mediaFolderPath, files, options, deps) { + const posixFolder = deps.normalizePosix(mediaFolderPath); + if (files.length === 0) { + throw new Error("No recognize entries in task"); + } + const normalizedFiles = files.map((file2) => ({ + season: file2.season, + episode: file2.episode, + path: deps.normalizePosix(file2.path) + })); + const seenPaths = new Set; + const seenEpisodes = new Set; + for (const file2 of normalizedFiles) { + if (seenPaths.has(file2.path)) { + throw new Error(`Duplicate file path in task: ${file2.path}`); + } + seenPaths.add(file2.path); + const episodeKey = `${file2.season}-${file2.episode}`; + if (seenEpisodes.has(episodeKey)) { + throw new Error(`Duplicate season/episode in task: S${file2.season}E${file2.episode}`); } + seenEpisodes.add(episodeKey); + if (!await deps.fs.exists(file2.path)) { + throw new Error(`File "${file2.path}" (S${file2.season}E${file2.episode}) does not exist in the media folder`); + } + } + const createId = deps.createId ?? randomUUID2; + const id = options?.id ?? createId(); + const plan = { + id, + task: "recognize-media-file", + status: "pending", + creator: options?.creator ?? "app", + mediaFolderPath: posixFolder, + files: normalizedFiles }; + await writePlan(deps.fs, deps.appDataDir, plan); + return plan; } -function buildEndRecognizeTaskTool(clientId, appDataDir, fs, broadcast, logger, abortSignal) { - const log = makeLogger2(logger); + +// src/tools/createRecognizeEpisodePlan.ts +function buildCreateRecognizeEpisodePlanTool(appDataDir, fs, broadcast, logger, abortSignal, extra) { const emit = broadcast ?? defaultBroadcast; return { - description: END_RECOGNIZE_TASK_DESCRIPTION, - toolName: END_RECOGNIZE_TASK, - inputSchema: endRecognizeTaskInputSchema, + description: CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION, + inputSchema: createRecognizeEpisodePlanInputSchema, execute: async (args) => { if (abortSignal?.aborted) { throw new Error("Request was aborted"); } - const { taskId } = args ?? {}; - const normalizedTaskId = (taskId ?? "").trim(); - log.info({ taskId: normalizedTaskId, clientId }, `[tool][${END_RECOGNIZE_TASK}] Ending recognition task`); + const parsed = createRecognizeEpisodePlanInputSchema.safeParse(args); + if (!parsed.success) { + return formatToolError(parsed.error); + } try { - const task = await readRecognizePlan(appDataDir, normalizedTaskId, fs); - if (!task) { - log.error({ taskId: normalizedTaskId, clientId }, `[tool][${END_RECOGNIZE_TASK}] Task not found`); - return formatToolError(`Task with id "${normalizedTaskId}" not found`); - } - if (task.status === "rejected") { - log.warn({ taskId: normalizedTaskId, clientId }, `[tool][${END_RECOGNIZE_TASK}] Task cancelled by user`); - return toolError(PLAN_CANCELLED_BY_USER_MESSAGE); - } - if (task.files.length === 0) { - log.warn({ taskId: normalizedTaskId, clientId }, `[tool][${END_RECOGNIZE_TASK}] No files in task`); - return formatToolError("No recognized files in task"); + const plan = await createRecognizeEpisodePlanPipeline(parsed.data.mediaFolderPath, parsed.data.files, { creator: "ai" }, { + fs: createFsPort(fs), + appDataDir, + normalizePosix: Path.posix + }); + if (extra?.getUserConfig && extra.applyRecognizeEpisodePlan) { + try { + const userConfig = await extra.getUserConfig(); + if (hasAiAgentPermission(userConfig, AI_AGENT_PERMISSIONS.metadataWrite)) { + await extra.applyRecognizeEpisodePlan(plan); + emit({ + event: MEDIA_METADATA_UPDATED_EVENT, + data: { folderPath: plan.mediaFolderPath } + }); + logger?.info({ + planId: plan.id, + folderPath: plan.mediaFolderPath, + fileCount: plan.files.length + }, `[tool][${CREATE_RECOGNIZE_EPISODE_PLAN}] Plan applied automatically`); + return toolOk({ + message: RECOGNIZE_PLAN_AUTO_APPLIED_MESSAGE, + planId: plan.id + }); + } + } catch (error48) { + logger?.warn({ planId: plan.id, error: error48 }, `[tool][${CREATE_RECOGNIZE_EPISODE_PLAN}] Auto-apply failed, plan stays pending`); + } } - await updatePlanContent(appDataDir, task.id, { status: "pending" }, fs); - const fullPlanPath = planFilePath(appDataDir, task.id); - const planFilePathInPosix = Path.posix(fullPlanPath); const data = { - taskId: task.id, - planFilePath: planFilePathInPosix + taskId: plan.id, + planFilePath: planPath(appDataDir, plan.id) }; - emit({ - event: RecognizeMediaFilePlanReady.event, - data + emit({ event: RecognizeMediaFilePlanReady.event, data }); + logger?.info({ + planId: plan.id, + folderPath: plan.mediaFolderPath, + fileCount: plan.files.length + }, `[tool][${CREATE_RECOGNIZE_EPISODE_PLAN}] Plan created`); + return toolOk({ + message: END_PLAN_TASK_SUCCESS_MESSAGE, + planId: plan.id }); - log.info({ - taskId: normalizedTaskId, - folderPath: task.mediaFolderPath, - fileCount: task.files.length, - clientId - }, `[tool][${END_RECOGNIZE_TASK}] Task completed successfully`); - return toolOk({ message: END_PLAN_TASK_SUCCESS_MESSAGE }); } catch (error48) { return formatToolError(error48); } @@ -58572,7 +62364,7 @@ function createChatTools(args) { allowlist: [], hello: { version: "0.0.0", - userDataDir: config2.appDataDir, + userDataDir: config2.userDataDir ?? config2.appDataDir, appDataDir: config2.appDataDir, logDir: "", tmpDir: "", @@ -58583,7 +62375,8 @@ function createChatTools(args) { appDataDir: config2.appDataDir, logger }; - const renameFilesTaskDeps = extra?.renameFilesTask ?? defaultRenameFilesTaskDeps(config2.appDataDir); + const tmdbRunners = extra?.tmdb; + const tvdbRunners = extra?.tvdb; return { [GET_APPLICATION_CONTEXT]: buildGetApplicationContextTool(clientId, userConfig, (cfg) => resolveAppLanguage({ configured: cfg.applicationLanguage, @@ -58595,12 +62388,24 @@ function createChatTools(args) { [GET_MEDIA_FOLDERS]: buildGetMediaFoldersTool(userConfig, abortSignal), [LIST_FILES_IN_MEDIA_FOLDER]: buildListFilesInMediaFolderTool(userConfig, abortSignal), [RENAME_FOLDER]: buildRenameFolderTool(clientId, syntheticConfig, abortSignal, acknowledge), - [BEGIN_RENAME_FILES_TASK]: buildBeginRenameFilesTaskTool(clientId, config2.appDataDir, fs, renameFilesTaskDeps, broadcast, logger, abortSignal), - [ADD_RENAME_FILE_TO_TASK]: buildAddRenameFileToTaskTool(clientId, config2.appDataDir, fs, renameFilesTaskDeps, logger, abortSignal), - [END_RENAME_FILES_TASK]: buildEndRenameFilesTaskTool(clientId, config2.appDataDir, fs, broadcast, logger, abortSignal), - [BEGIN_RECOGNIZE_TASK]: buildBeginRecognizeTaskTool(clientId, config2.appDataDir, fs, broadcast, logger, abortSignal), - [ADD_RECOGNIZED_MEDIA_FILE]: buildAddRecognizedMediaFileTool(clientId, config2.appDataDir, fs, logger, abortSignal), - [END_RECOGNIZE_TASK]: buildEndRecognizeTaskTool(clientId, config2.appDataDir, fs, broadcast, logger, abortSignal) + [RENAME_EPISODE_FILE]: buildRenameEpisodeFileTool(clientId, extra?.renameEpisodeFile, abortSignal, acknowledge), + [SCRAPE]: buildScrapeTool(extra?.scrapeFolder, abortSignal), + [GET_JOB]: buildGetJobTool(extra?.getJob, abortSignal), + [TMDB_SEARCH]: buildTmdbSearchTool(tmdbRunners, abortSignal), + [TMDB_GET_MOVIE]: buildTmdbGetMovieTool(tmdbRunners, abortSignal), + [TMDB_GET_TV_SHOW]: buildTmdbGetTvShowTool(tmdbRunners, abortSignal), + [TVDB_SEARCH]: buildTvdbSearchTool(tvdbRunners, abortSignal), + [TVDB_GET_MOVIE]: buildTvdbGetMovieTool(tvdbRunners, abortSignal), + [TVDB_GET_TV_SHOW]: buildTvdbGetTvShowTool(tvdbRunners, abortSignal), + [TVDB_GET_LANGUAGES]: buildTvdbGetLanguagesTool(tvdbRunners, abortSignal), + [CREATE_RENAME_EPISODE_PLAN]: buildCreateRenameEpisodePlanTool(config2.appDataDir, fs, broadcast, logger, abortSignal, { + getUserConfig: () => Promise.resolve(userConfig), + applyRenameEpisodePlan: extra?.applyRenameEpisodePlan + }), + [CREATE_RECOGNIZE_EPISODE_PLAN]: buildCreateRecognizeEpisodePlanTool(config2.appDataDir, fs, broadcast, logger, abortSignal, { + getUserConfig: () => Promise.resolve(userConfig), + applyRecognizeEpisodePlan: extra?.applyRecognizeEpisodePlan + }) }; } @@ -58646,12 +62451,18 @@ async function doChat(config2, request, extra = {}) { [GET_MEDIA_FOLDERS]: tools[GET_MEDIA_FOLDERS], [LIST_FILES_IN_MEDIA_FOLDER]: tools[LIST_FILES_IN_MEDIA_FOLDER], [RENAME_FOLDER]: tools[RENAME_FOLDER], - [BEGIN_RENAME_FILES_TASK]: tools[BEGIN_RENAME_FILES_TASK], - [ADD_RENAME_FILE_TO_TASK]: tools[ADD_RENAME_FILE_TO_TASK], - [END_RENAME_FILES_TASK]: tools[END_RENAME_FILES_TASK], - [BEGIN_RECOGNIZE_TASK]: tools[BEGIN_RECOGNIZE_TASK], - [ADD_RECOGNIZED_MEDIA_FILE]: tools[ADD_RECOGNIZED_MEDIA_FILE], - [END_RECOGNIZE_TASK]: tools[END_RECOGNIZE_TASK] + [RENAME_EPISODE_FILE]: tools[RENAME_EPISODE_FILE], + [SCRAPE]: tools[SCRAPE], + [GET_JOB]: tools[GET_JOB], + [TMDB_SEARCH]: tools[TMDB_SEARCH], + [TMDB_GET_MOVIE]: tools[TMDB_GET_MOVIE], + [TMDB_GET_TV_SHOW]: tools[TMDB_GET_TV_SHOW], + [TVDB_SEARCH]: tools[TVDB_SEARCH], + [TVDB_GET_MOVIE]: tools[TVDB_GET_MOVIE], + [TVDB_GET_TV_SHOW]: tools[TVDB_GET_TV_SHOW], + [TVDB_GET_LANGUAGES]: tools[TVDB_GET_LANGUAGES], + [CREATE_RENAME_EPISODE_PLAN]: tools[CREATE_RENAME_EPISODE_PLAN], + [CREATE_RECOGNIZE_EPISODE_PLAN]: tools[CREATE_RECOGNIZE_EPISODE_PLAN] }, stopWhen: stepCountIs(CHAT_STEP_LIMIT) }); @@ -58787,6 +62598,7 @@ async function forwardWebResponseToNode(response, res) { response.headers.forEach((value, key) => { res.setHeader(key, value); }); + res.setHeader("Cache-Control", "no-store"); if (response.body) { const reader = response.body.getReader(); res.flushHeaders?.(); @@ -60260,8 +64072,8 @@ function createOpenAICompatible(options) { const getHeaders = () => withUserAgentSuffix(headers, `ai-sdk/openai-compatible/${VERSION4}`); const getCommonModelConfig = (modelType) => ({ provider: `${providerName}.${modelType}`, - url: ({ path: path6 }) => { - const url2 = new URL(`${baseURL}${path6}`); + url: ({ path: path5 }) => { + const url2 = new URL(`${baseURL}${path5}`); if (options.queryParams) { url2.search = new URLSearchParams(options.queryParams).toString(); } @@ -60296,17 +64108,7 @@ function createOpenAICompatible(options) { provider.imageModel = createImageModel; return provider; } -// ../core/types.ts -var DEFAULT_AI_PROVIDERS = [ - { name: "DeepSeek", baseURL: "https://api.deepseek.com", apiKey: "", model: "deepseek-v4-flash" }, - { name: "OpenAI", baseURL: "https://api.openai.com/v1", apiKey: "", model: "gpt-4o" }, - { name: "OpenRouter", baseURL: "https://openrouter.ai/api/v1", apiKey: "", model: "deepseek/deepseek-v4-flash" }, - { name: "GLM", baseURL: "https://open.bigmodel.cn/api/paas/v4", apiKey: "", model: "GLM-4.5" }, - { name: "Other", baseURL: "", apiKey: "", model: "" } -]; -var DEFAULT_SELECTED_AI_PROVIDER = "DeepSeek"; - -// ../core/configMigration.ts +// ../../apps/core/src/configMigration.ts var NAME_TO_OLD_KEY = { DeepSeek: "deepseek", OpenAI: "openAI", @@ -60350,12 +64152,361 @@ function migrateAIConfig(raw) { delete raw.selectedAI; return true; } +// src/proxiedFetch.ts +var import_http_proxy_agent = __toESM(require_dist3(), 1); +var import_https_proxy_agent = __toESM(require_dist4(), 1); +var import_socks_proxy_agent = __toESM(require_dist5(), 1); +import http from "node:http"; +import https from "node:https"; + +// src/httpContentEncoding.ts +import zlib from "node:zlib"; +import { promisify } from "node:util"; +var gunzip = promisify(zlib.gunzip); +var inflate = promisify(zlib.inflate); +var brotliDecompress = promisify(zlib.brotliDecompress); +async function decompressBody(buf, contentEncoding) { + if (!contentEncoding || typeof contentEncoding !== "string") { + return buf; + } + const encoding = contentEncoding.split(",")[0]?.trim().toLowerCase(); + if (encoding === "gzip" || encoding === "x-gzip") { + return gunzip(buf); + } + if (encoding === "deflate") { + return inflate(buf); + } + if (encoding === "br") { + return brotliDecompress(buf); + } + return buf; +} +function toFetchApiStatus(statusCode) { + const status = statusCode ?? 502; + if (status === 304) + return 200; + return status; +} +function incomingHeadersToObject(headers) { + const result = {}; + for (const [key, val] of Object.entries(headers)) { + if (val === undefined) + continue; + const lower = key.toLowerCase(); + if (lower === "content-encoding" || lower === "content-length") + continue; + if (Array.isArray(val)) { + result[key] = val.join(", "); + } else { + result[key] = val; + } + } + return result; +} +async function nodeHttpMessageToFetchResponse(res, wireBody) { + const body = await decompressBody(wireBody, res.headers["content-encoding"]); + const headers = incomingHeadersToObject(res.headers); + headers["Content-Length"] = String(body.length); + return new Response(body, { + status: toFetchApiStatus(res.statusCode), + statusText: res.statusMessage ?? "", + headers + }); +} + +// src/fetchInput.ts +function toRequest(input, init) { + if (input instanceof Request) { + return init !== undefined ? new Request(input, init) : input; + } + const url2 = typeof input === "string" ? input : input.href; + return new Request(url2, init); +} + +// src/proxiedFetch.ts +function isBunRuntime() { + return typeof globalThis.Bun !== "undefined"; +} +function formatProxyHostForLog(proxyUrl) { + try { + const u = new URL(proxyUrl); + const defaultPort = u.protocol === "https:" ? "443" : u.protocol === "http:" ? "80" : ""; + const port = u.port || defaultPort; + return port ? `${u.hostname}:${port}` : u.hostname; + } catch { + return "(invalid-proxy-url)"; + } +} +function getOutboundProxyMode(proxyUrl, targetUrl) { + const proxy = new URL(proxyUrl); + if (proxy.protocol === "socks5:" || proxy.protocol === "socks5h:") { + return "socks5"; + } + if (isBunRuntime()) { + return "bun-native"; + } + if (targetUrl) { + const target = new URL(targetUrl); + if (target.protocol === "http:") { + return "node-forward"; + } + } + return "node-connect"; +} +function wrapFetchWithLogging(inner, mode, logger) { + return async (input, init) => { + const request = toRequest(input, init); + const target = new URL(request.url); + logger?.debug({ + proxyMode: mode, + method: request.method, + targetHost: target.host, + targetPath: target.pathname + }, "[ProxiedFetch] outbound request"); + try { + const response = await inner(request); + logger?.debug({ + proxyMode: mode, + status: response.status, + targetHost: target.host + }, "[ProxiedFetch] outbound response"); + return response; + } catch (err) { + logger?.debug({ + proxyMode: mode, + targetHost: target.host, + err, + errorMessage: err instanceof Error ? err.message : String(err) + }, "[ProxiedFetch] outbound failed"); + throw err; + } + }; +} +function buildAgentRequestHeaders(request) { + const headers = new Headers(request.headers); + headers.delete("accept-encoding"); + headers.set("Host", new URL(request.url).host); + return Object.fromEntries(headers.entries()); +} +function requestViaAgent(request, agent, timeoutMessage) { + const url2 = new URL(request.url); + const method = request.method.toUpperCase(); + const isBodyAllowed = method !== "GET" && method !== "HEAD"; + const isHttps = url2.protocol === "https:"; + const headers = buildAgentRequestHeaders(request); + return new Promise((resolve2, reject) => { + const requestOptions = { + hostname: url2.hostname, + port: Number(url2.port) || (isHttps ? 443 : 80), + path: url2.pathname + url2.search, + method, + headers, + agent, + timeout: 30000 + }; + const req = (isHttps ? https : http).request(requestOptions, (res) => { + const chunks = []; + res.on("data", (chunk2) => chunks.push(chunk2)); + res.on("end", () => { + nodeHttpMessageToFetchResponse(res, Buffer.concat(chunks)).then(resolve2, reject); + }); + res.on("error", reject); + }); + req.on("error", reject); + req.on("timeout", () => { + req.destroy(); + reject(new Error(timeoutMessage)); + }); + if (isBodyAllowed) { + request.arrayBuffer().then((buf) => { + req.write(Buffer.from(buf)); + req.end(); + }, reject); + } else { + req.end(); + } + }); +} +function httpProxyAgentRequest(request, proxyUrl) { + const url2 = new URL(request.url); + const isHttps = url2.protocol === "https:"; + const agent = isHttps ? new import_https_proxy_agent.HttpsProxyAgent(proxyUrl) : new import_http_proxy_agent.HttpProxyAgent(proxyUrl); + return requestViaAgent(request, agent, isHttps ? "HTTPS proxy request timeout" : "HTTP proxy request timeout"); +} +function socksProxyRequest(request, proxyUrl) { + return requestViaAgent(request, new import_socks_proxy_agent.SocksProxyAgent(proxyUrl), "SOCKS5 proxy request timeout"); +} +function createProxiedFetch(proxyUrl, logger) { + const proxy = new URL(proxyUrl); + const isSocks = proxy.protocol === "socks5:" || proxy.protocol === "socks5h:"; + if (isSocks) { + logger?.debug({ + proxyMode: "socks5", + httpProxyHost: formatProxyHostForLog(proxyUrl) + }, "[ProxiedFetch] using outbound proxy"); + return wrapFetchWithLogging((request) => socksProxyRequest(request, proxyUrl), "socks5", logger); + } + if (proxy.protocol !== "http:" && proxy.protocol !== "https:") { + throw new Error(`Unsupported proxy scheme: "${proxy.protocol}". Use http://, https://, or socks5://.`); + } + if (isBunRuntime()) { + logger?.debug({ + proxyMode: "bun-native", + httpProxyHost: formatProxyHostForLog(proxyUrl) + }, "[ProxiedFetch] using outbound proxy"); + return wrapFetchWithLogging(async (request) => { + const method = request.method; + const headers = new Headers(request.headers); + const body = method.toUpperCase() !== "GET" && method.toUpperCase() !== "HEAD" ? await request.arrayBuffer() : undefined; + return fetch(request.url, { + method, + headers, + body, + proxy: proxyUrl + }); + }, "bun-native", logger); + } + logger?.debug({ + proxyMode: "node-connect", + httpProxyHost: formatProxyHostForLog(proxyUrl) + }, "[ProxiedFetch] using outbound proxy"); + return async (input, init) => { + const request = toRequest(input, init); + const url2 = new URL(request.url); + const mode = url2.protocol === "https:" ? "node-connect" : "node-forward"; + logger?.debug({ + proxyMode: mode, + method: request.method, + targetHost: url2.host, + targetPath: url2.pathname + }, "[ProxiedFetch] outbound request"); + try { + const response = await httpProxyAgentRequest(request, proxyUrl); + logger?.debug({ proxyMode: mode, status: response.status, targetHost: url2.host }, "[ProxiedFetch] outbound response"); + return response; + } catch (err) { + logger?.debug({ + proxyMode: mode, + targetHost: url2.host, + err, + errorMessage: err instanceof Error ? err.message : String(err) + }, "[ProxiedFetch] outbound failed"); + throw err; + } + }; +} + +// src/downloadImage.ts +import { Buffer as Buffer2 } from "node:buffer"; +import { readFile as readFile4 } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { extname } from "node:path"; +var DEFAULT_CONTENT_TYPE = "image/jpeg"; +var EXTENSION_TO_CONTENT_TYPE = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".bmp": "image/bmp", + ".avif": "image/avif", + ".apng": "image/apng" +}; +function getContentTypeFromExtension(ext) { + return EXTENSION_TO_CONTENT_TYPE[ext.toLowerCase()] ?? DEFAULT_CONTENT_TYPE; +} +function normalizeUrl(url2) { + if (url2.startsWith("//")) { + return `https:${url2}`; + } + return url2; +} +var REMOTE_IMAGE_REQUEST_HEADERS = { + accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", + "accept-language": "en-US,en;q=0.9", + "cache-control": "no-cache", + "sec-fetch-dest": "image", + "sec-fetch-mode": "no-cors", + "sec-fetch-site": "cross-site", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +}; +function describeFetchError(error48) { + if (!(error48 instanceof Error)) { + return String(error48); + } + const cause = error48.cause; + const causeCode = cause && typeof cause === "object" && "code" in cause ? String(cause.code) : undefined; + const causeMessage = cause instanceof Error ? cause.message : undefined; + const directCode = !causeCode && typeof error48 === "object" && "code" in error48 ? String(error48.code) : undefined; + const directErrno = !causeCode && typeof error48 === "object" && "errno" in error48 ? String(error48.errno) : undefined; + const code = causeCode ?? directCode ?? directErrno; + const message = causeMessage; + const segments = []; + if (code) + segments.push(code); + if (message && message !== code) { + segments.push(message); + } + if (segments.length > 0) { + return `${error48.message} (${segments.join(": ")})`; + } + return error48.message; +} +function resolveUrl(url2) { + const normalizedUrl = normalizeUrl(url2); + if (normalizedUrl.startsWith("file://")) { + const platformPath = fileURLToPath(normalizedUrl); + return { kind: "file", normalizedUrl, platformPath }; + } + if (normalizedUrl.startsWith("http://") || normalizedUrl.startsWith("https://")) { + return { kind: "http", normalizedUrl }; + } + throw new Error(`Invalid image URL: ${url2}. ` + `Must be http://, https://, protocol-relative (//), or file://`); +} +async function doDownloadImage(url2, config2) { + const { allowlist, logger, fetchImpl = fetch } = config2; + const resolved = resolveUrl(url2); + logger?.info({ url: resolved.normalizedUrl, kind: resolved.kind }, "[DownloadImage] processing request"); + if (resolved.kind === "file") { + const platformPath = resolved.platformPath; + const posixPath = Path.posix(platformPath); + if (!validatePathIsInAllowlist(posixPath, allowlist)) { + throw new Error(`Permission denied: file ${platformPath} is not allowed to be read`); + } + const buffer2 = await readFile4(platformPath); + const ext = extname(platformPath); + const contentType2 = getContentTypeFromExtension(ext); + logger?.info({ platformPath, bytes: buffer2.length, contentType: contentType2 }, "[DownloadImage] read file"); + return { buffer: buffer2, contentType: contentType2 }; + } + let response; + try { + response = await fetchImpl(resolved.normalizedUrl, { + method: "GET", + headers: REMOTE_IMAGE_REQUEST_HEADERS + }); + } catch (error48) { + throw new Error(`Failed to download image: ${describeFetchError(error48)}`); + } + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const contentType = response.headers.get("content-type") ?? DEFAULT_CONTENT_TYPE; + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer2.from(arrayBuffer); + logger?.info({ url: resolved.normalizedUrl, bytes: buffer.length, contentType }, "[DownloadImage] fetched remote image"); + return { buffer, contentType }; +} + // src/reverseProxy.ts var PORT_RANGE_START = 30000; var PORT_RANGE_END = 31000; var DEFAULT_ALLOWED_UPSTREAM_HOSTS = new Set([ "api.themoviedb.org", "api4.thetvdb.com", + "mediadb.vercel.app", "tmdb-mcp-server.imlc.me", "httpbin.io", "api.deepseek.com", @@ -60386,7 +64537,8 @@ var HOP_BY_HOP_RESPONSE_HEADERS = new Set([ "content-encoding" ]); var PROXY_CONTROL_HEADERS = new Set([ - "x-smm-proxy-upstream-baseurl" + "x-smm-proxy-upstream-baseurl", + "x-http-proxy" ]); var CONDITIONAL_REQUEST_HEADERS = new Set([ "if-none-match", @@ -60398,9 +64550,9 @@ function buildUpstreamUrl(upstreamBaseURL, incomingPath, incomingSearch) { const base = new URL(upstreamBaseURL); const basePath = base.pathname.replace(/\/+$/, ""); const normalizedPath = incomingPath.startsWith("/") ? incomingPath : `/${incomingPath}`; - const path6 = `${basePath}${normalizedPath}`; + const path5 = `${basePath}${normalizedPath}`; const query = incomingSearch.startsWith("?") ? incomingSearch : ""; - return `${base.origin}${path6}${query}`; + return `${base.origin}${path5}${query}`; } function validateUpstreamBaseURL(headerValue, allowedUpstreamHosts) { let upstreamUrl; @@ -60451,7 +64603,21 @@ function corsHeaders() { }; } function applyCorsToBody(body, init = {}) { - const headers = new Headers(init.headers); + const headers = new Headers; + if (init.headers) { + const source = init.headers; + if (source instanceof Headers) { + source.forEach((value, key) => headers.set(key, value)); + } else if (Array.isArray(source)) { + for (const [key, value] of source) { + headers.set(key, value); + } + } else { + for (const [key, value] of Object.entries(source)) { + headers.set(key, value); + } + } + } for (const [key, value] of Object.entries(corsHeaders())) { if (!headers.has(key)) { headers.set(key, value); @@ -60471,29 +64637,215 @@ function noopLogger() { error: () => {} }; } +function buildOutboundProxyLogFields(httpProxyHeader, usingProxiedFetch, forwardUrl) { + const trimmed = httpProxyHeader?.trim(); + const viaHttpProxy = Boolean(trimmed); + if (!viaHttpProxy) { + return { viaHttpProxy: false, proxyMode: "direct" }; + } + const httpProxyHost = formatProxyHostForLog(trimmed); + if (!usingProxiedFetch) { + return { viaHttpProxy: true, httpProxyHost, proxyMode: "direct-fallback" }; + } + return { + viaHttpProxy: true, + httpProxyHost, + proxyMode: getOutboundProxyMode(trimmed, forwardUrl) + }; +} +var PROXY_ERROR_CODES = { + ENOTFOUND: { + message: "DNS resolution failed for upstream host", + code: "DNS_RESOLUTION_FAILED" + }, + ECONNREFUSED: { + message: "Connection refused by upstream host", + code: "CONNECTION_REFUSED" + }, + ConnectionRefused: { + message: "Connection refused by upstream host", + code: "CONNECTION_REFUSED" + }, + ECONNRESET: { + message: "Connection was reset by upstream host", + code: "CONNECTION_RESET" + }, + ETIMEDOUT: { + message: "Connection to upstream host timed out", + code: "CONNECTION_TIMEOUT" + }, + ENETUNREACH: { + message: "Upstream network is unreachable", + code: "NETWORK_UNREACHABLE" + }, + ECONNABORTED: { + message: "Connection was aborted", + code: "CONNECTION_ABORTED" + }, + UND_ERR_CONNECT_TIMEOUT: { + message: "Connection to upstream host timed out", + code: "CONNECTION_TIMEOUT" + }, + UND_ERR_HEADERS_TIMEOUT: { + message: "Upstream host did not respond with headers in time", + code: "HEADERS_TIMEOUT" + } +}; +var TLS_ERROR_CODES = new Set([ + "ERR_TLS_CERT_ALTNAME_INVALID", + "CERT_HAS_EXPIRED", + "DEPTH_ZERO_SELF_SIGNED_CERT", + "UNABLE_TO_VERIFY_LEAF_SIGNATURE" +]); +function extractLoggingErrorDetail(error48) { + if (!(error48 instanceof Error)) { + return { originalError: String(error48) }; + } + const originalError = describeFetchError(error48); + let systemCode; + let causeMessage; + if (typeof error48 === "object" && "code" in error48) { + systemCode = String(error48.code); + } else if (typeof error48 === "object" && "errno" in error48) { + systemCode = String(error48.errno); + } + if (!systemCode) { + let current = error48; + for (let depth = 0;depth < 3; depth++) { + const cause = current.cause; + if (!(cause instanceof Error)) + break; + if (!systemCode) { + systemCode = cause.code; + } + if (cause.message && cause.message !== error48.message) { + causeMessage = cause.message; + } + current = cause; + } + } + return { originalError, systemCode, causeMessage }; +} +function lookupSystemCode(code) { + if (TLS_ERROR_CODES.has(code)) { + return { message: "TLS certificate validation failed for upstream host", code: "TLS_ERROR" }; + } + return PROXY_ERROR_CODES[code]; +} +function classifyProxyError(error48) { + if (!(error48 instanceof Error)) { + return { message: String(error48), code: "UPSTREAM_REQUEST_FAILED" }; + } + let current = error48; + for (let depth = 0;depth < 3; depth++) { + if (!(current instanceof Error)) + break; + const causeCode = current.code; + if (typeof causeCode === "string") { + const found = lookupSystemCode(causeCode); + if (found) + return found; + } + if (!causeCode) { + const causeErrno = current.errno; + if (typeof causeErrno === "string") { + const found = lookupSystemCode(causeErrno); + if (found) + return found; + } + } + current = current.cause; + } + const msg = error48.message; + if (msg.includes("timeout")) { + return { message: "Proxy request timed out", code: "PROXY_TIMEOUT" }; + } + if (msg.includes("CONNECT refused")) { + const statusMatch = msg.match(/HTTP\/\d\.\d\s+(\d+)/); + return { + message: statusMatch ? `Proxy CONNECT tunnel refused with status ${statusMatch[1]}` : "Proxy CONNECT tunnel was refused by the proxy server", + code: "PROXY_CONNECT_REFUSED" + }; + } + if (msg.includes("Unsupported proxy scheme")) { + return { message: msg, code: "UNSUPPORTED_PROXY_SCHEME" }; + } + return { message: error48.message, code: "UPSTREAM_REQUEST_FAILED" }; +} +function proxyErrorToProblemDetails(errInfo, status) { + return { + type: "about:blank", + title: status === 502 ? "Bad Gateway" : status === 400 ? "Bad Request" : "Upstream Error", + status, + detail: errInfo.message + }; +} async function handleProxyRequest(request, config2 = {}) { const logger = config2.logger ?? noopLogger(); - const allowedUpstreamHosts = config2.allowedUpstreamHosts ?? DEFAULT_ALLOWED_UPSTREAM_HOSTS; const fetchImpl = config2.fetchImpl ?? fetch; + let allowedUpstreamHosts; + if (config2.resolveAllowedUpstreamHosts) { + try { + allowedUpstreamHosts = await config2.resolveAllowedUpstreamHosts(); + } catch (error48) { + logger.error({ err: error48, errorMessage: error48 instanceof Error ? error48.message : String(error48) }, "[Reverse Proxy] failed to resolve allowed upstream hosts, falling back to defaults"); + allowedUpstreamHosts = config2.allowedUpstreamHosts ?? DEFAULT_ALLOWED_UPSTREAM_HOSTS; + } + } else { + allowedUpstreamHosts = config2.allowedUpstreamHosts ?? DEFAULT_ALLOWED_UPSTREAM_HOSTS; + } if (request.method === "OPTIONS") { return new Response(null, { status: 204, headers: corsHeaders() }); } + const httpProxyHeader = request.headers.get("X-Http-Proxy"); + let activeFetch = fetchImpl; + let usingProxiedFetch = false; + if (httpProxyHeader?.trim() && config2.createProxiedFetch) { + try { + const proxiedFetch = config2.createProxiedFetch(httpProxyHeader, logger); + if (proxiedFetch) { + activeFetch = proxiedFetch; + usingProxiedFetch = true; + } else { + logger.warn({ + httpProxyHost: formatProxyHostForLog(httpProxyHeader) + }, "[Reverse Proxy] createProxiedFetch returned undefined; using direct fetch"); + } + } catch (error48) { + logger.error({ + httpProxyHost: formatProxyHostForLog(httpProxyHeader), + err: error48, + errorMessage: error48 instanceof Error ? error48.message : String(error48) + }, "[Reverse Proxy] failed to create proxied fetch, falling back to direct"); + } + } else if (httpProxyHeader?.trim() && !config2.createProxiedFetch) { + logger.warn({ + httpProxyHost: formatProxyHostForLog(httpProxyHeader) + }, "[Reverse Proxy] X-Http-Proxy set but createProxiedFetch is not configured; using direct fetch"); + } const upstreamBaseURL = request.headers.get("X-SMM-Proxy-Upstream-BaseURL"); if (!upstreamBaseURL) { - return applyCorsToBody(JSON.stringify({ error: "Missing X-SMM-Proxy-Upstream-BaseURL header" }), { status: 400, headers: { "Content-Type": "application/json" } }); + return applyCorsToBody(JSON.stringify({ + type: "about:blank", + title: "Bad Request", + status: 400, + detail: "Missing X-SMM-Proxy-Upstream-BaseURL header" + }), { status: 400, headers: { "Content-Type": "application/problem+json" } }); } let upstreamUrl; try { upstreamUrl = validateUpstreamBaseURL(upstreamBaseURL, allowedUpstreamHosts); } catch (error48) { - const message = error48 instanceof Error ? error48.message : "Invalid upstream base URL"; - return applyCorsToBody(JSON.stringify({ error: message }), { + return applyCorsToBody(JSON.stringify({ + type: "about:blank", + title: "Bad Request", status: 400, - headers: { "Content-Type": "application/json" } - }); + detail: error48 instanceof Error ? error48.message : "Invalid upstream base URL" + }), { status: 400, headers: { "Content-Type": "application/problem+json" } }); } const incomingUrl = new URL(request.url); const forwardUrl = buildUpstreamUrl(upstreamBaseURL, incomingUrl.pathname, incomingUrl.search); + const proxyLogFields = buildOutboundProxyLogFields(httpProxyHeader, usingProxiedFetch, forwardUrl); try { const reqHeaders = filterRequestHeaders(request, upstreamUrl); const method = request.method; @@ -60503,15 +64855,23 @@ async function handleProxyRequest(request, config2 = {}) { body: method !== "GET" && method !== "HEAD" ? request.body : undefined, ...method !== "GET" && method !== "HEAD" ? { duplex: "half" } : {} }); - logger.info({ method, forwardUrl, upstreamHost: upstreamUrl.host }, "[Reverse Proxy] forwarding request"); - const response = await fetchImpl(upstreamReq); + logger.info({ + method, + forwardUrl, + upstreamHost: upstreamUrl.host, + incomingPath: incomingUrl.pathname, + upstreamBaseURL, + ...proxyLogFields + }, "[Reverse Proxy] forwarding request"); + const response = await activeFetch(upstreamReq); const respHeaders = filterResponseHeaders(response); const respBody = await response.arrayBuffer(); logger.info({ method, forwardUrl, status: response.status, - responseBytes: respBody.byteLength + responseBytes: respBody.byteLength, + ...proxyLogFields }, "[Reverse Proxy] upstream response"); return applyCorsToBody(respBody, { status: response.status, @@ -60519,12 +64879,29 @@ async function handleProxyRequest(request, config2 = {}) { headers: respHeaders }); } catch (error48) { - logger.error({ err: error48, method: request.method, forwardUrl }, "[Reverse Proxy] upstream request failed"); - return applyCorsToBody(JSON.stringify({ error: "Failed to proxy request to upstream" }), { status: 502, headers: { "Content-Type": "application/json" } }); + const errInfo = classifyProxyError(error48); + const errDetail = extractLoggingErrorDetail(error48); + logger.error({ + err: error48, + errorMessage: error48 instanceof Error ? error48.message : String(error48), + originalError: errDetail.originalError, + systemCode: errDetail.systemCode, + causeMessage: errDetail.causeMessage, + method: request.method, + forwardUrl, + incomingPath: incomingUrl.pathname, + upstreamBaseURL, + ...proxyLogFields + }, "[Reverse Proxy] upstream request failed"); + const problem = proxyErrorToProblemDetails(errInfo, 502); + return applyCorsToBody(JSON.stringify(problem), { + status: 502, + headers: { "Content-Type": "application/problem+json" } + }); } } // src/reverseProxyNode.ts -import http from "node:http"; +import http2 from "node:http"; import net from "node:net"; import { Readable } from "node:stream"; function createReverseProxyRequestHandler(config2 = {}) { @@ -60606,11 +64983,11 @@ function tryListen(port, hostname3 = "127.0.0.1") { async function findAvailableReverseProxyPort(reservedPorts = new Set, portRange = { start: PORT_RANGE_START, end: PORT_RANGE_END -}) { +}, bindAddress = resolveReverseProxyBindAddress()) { for (let port = portRange.start;port <= portRange.end; port++) { if (reservedPorts.has(port)) continue; - if (await tryListen(port)) + if (await tryListen(port, bindAddress)) return port; } throw new Error(`Could not find an available port in range ${portRange.start}-${portRange.end}`); @@ -60624,19 +65001,21 @@ function createReverseProxyManager(config2 = {}) { config2.logger?.warn({}, "[Reverse Proxy] already running"); return; } + const bindAddress = config2.bindAddress ?? resolveReverseProxyBindAddress(); + const advertisedHost = resolveReverseProxyAdvertisedHost(bindAddress); let port; try { if (typeof config2.port === "number") { port = config2.port; } else { - port = await findAvailableReverseProxyPort(config2.reservedPorts, config2.portRange); + port = await findAvailableReverseProxyPort(config2.reservedPorts, config2.portRange, bindAddress); } } catch (error48) { config2.logger?.error({ err: error48 }, "[Reverse Proxy] failed to find available port"); currentUrl = null; return; } - const newServer = http.createServer(handler); + const newServer = http2.createServer(handler); await new Promise((resolve2, reject) => { newServer.once("error", (err) => { newServer.removeListener("listening", onListening); @@ -60648,11 +65027,11 @@ function createReverseProxyManager(config2 = {}) { }; const onError = (err) => reject(err); newServer.once("listening", onListening); - newServer.listen(port, "127.0.0.1"); + newServer.listen(port, bindAddress); }); server = newServer; - currentUrl = `http://127.0.0.1:${port}`; - config2.logger?.info({ url: currentUrl }, "[Reverse Proxy] started"); + currentUrl = `http://${advertisedHost}:${port}`; + config2.logger?.info({ url: currentUrl, bindAddress }, "[Reverse Proxy] started"); } async function stop() { if (!server) @@ -60675,13 +65054,9 @@ function createReverseProxyManager(config2 = {}) { } // src/nodeHttpFetch.ts import { Readable as Readable2 } from "node:stream"; -import http2 from "node:http"; -import https from "node:https"; -import zlib from "node:zlib"; -import { promisify } from "node:util"; -var gunzip = promisify(zlib.gunzip); -var inflate = promisify(zlib.inflate); -var brotliDecompress = promisify(zlib.brotliDecompress); +import http3 from "node:http"; +import https2 from "node:https"; +import zlib2 from "node:zlib"; var HOP_BY_HOP_REQUEST_HEADERS2 = new Set([ "connection", "keep-alive", @@ -60703,22 +65078,6 @@ var STRIPPED_RESPONSE_HEADERS = new Set([ "content-encoding", "content-length" ]); -async function decompressBody(buf, contentEncoding) { - if (!contentEncoding || typeof contentEncoding !== "string") { - return buf; - } - const encoding = contentEncoding.split(",")[0]?.trim().toLowerCase(); - if (encoding === "gzip" || encoding === "x-gzip") { - return gunzip(buf); - } - if (encoding === "deflate") { - return inflate(buf); - } - if (encoding === "br") { - return brotliDecompress(buf); - } - return buf; -} function buildOutgoingHeaders(request) { const headers = {}; request.headers.forEach((value, key) => { @@ -60733,7 +65092,7 @@ function buildOutgoingHeaders(request) { }); return headers; } -function toFetchApiStatus(statusCode) { +function toFetchApiStatus2(statusCode) { const status = statusCode ?? 502; if (status === 304) return 200; @@ -60741,7 +65100,7 @@ function toFetchApiStatus(statusCode) { } function createNodeHttpResponse(body, statusCode, statusMessage, sourceHeaders, bodyLength) { return new Response(body, { - status: toFetchApiStatus(statusCode), + status: toFetchApiStatus2(statusCode), statusText: statusMessage ?? "", headers: buildResponseHeaders(sourceHeaders, bodyLength) }); @@ -60771,11 +65130,11 @@ function decompressorFor(contentEncoding) { return null; const encoding = contentEncoding.split(",")[0]?.trim().toLowerCase(); if (encoding === "gzip" || encoding === "x-gzip") - return zlib.createGunzip(); + return zlib2.createGunzip(); if (encoding === "deflate") - return zlib.createInflate(); + return zlib2.createInflate(); if (encoding === "br") - return zlib.createBrotliDecompress(); + return zlib2.createBrotliDecompress(); return null; } function requestViaNodeHttp(request) { @@ -60819,7 +65178,7 @@ function requestViaNodeHttp(request) { method: request.method, headers }; - const req = isHttps ? https.request(requestOptions, onResponse) : http2.request(requestOptions, onResponse); + const req = isHttps ? https2.request(requestOptions, onResponse) : http3.request(requestOptions, onResponse); req.on("error", reject); if (requestBody !== undefined && requestBody.length > 0) { req.write(requestBody); @@ -60868,7 +65227,7 @@ function requestViaNodeHttpStreaming(request) { method: request.method, headers }; - const req = isHttps ? https.request(requestOptions, onResponse) : http2.request(requestOptions, onResponse); + const req = isHttps ? https2.request(requestOptions, onResponse) : http3.request(requestOptions, onResponse); req.on("error", reject); if (requestBody !== undefined && requestBody.length > 0) { req.write(requestBody); @@ -60882,32 +65241,32 @@ function requestViaNodeHttpStreaming(request) { } function createNodeHttpFetch() { return (input, init) => { - const request = input instanceof Request ? input : new Request(input, init); - return requestViaNodeHttp(request); + return requestViaNodeHttp(toRequest(input, init)); }; } function createStreamingNodeHttpFetch() { return (input, init) => { - const request = input instanceof Request ? input : new Request(input, init); - return requestViaNodeHttpStreaming(request); + return requestViaNodeHttpStreaming(toRequest(input, init)); }; } // src/writeFile.ts -import path6 from "node:path"; +import path5 from "node:path"; import { mkdir as mkdir4, appendFile, writeFile as writeFile4, access } from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; -// ../core/errors.ts +// ../types/errorCodes.ts +var ExistedFileError = "File Already Existed"; +var FileNotFoundError = "File Not Found"; + +// ../utils/src/errors.ts function isError2(error48, message) { return error48.startsWith(`${message}:`); } -var ExistedFileError = "File Already Existed"; -function existedFileError(path6) { - return `${ExistedFileError}: ${path6}`; +function existedFileError(path5) { + return `${ExistedFileError}: ${path5}`; } -var FileNotFoundError = "File Not Found"; -function fileNotFoundError(path6) { - return `${FileNotFoundError}: ${path6}`; +function fileNotFoundError(path5) { + return `${FileNotFoundError}: ${path5}`; } // src/writeFile.ts @@ -60955,7 +65314,7 @@ async function doWriteFile(body, config2, traceId = "") { } const { path: filePath, mode, data } = validationResult.data; logger?.debug({ traceId, filePath, mode, dataSize: data.length }, "doWriteFile: Processing write request"); - const resolvedPath = path6.resolve(filePath); + const resolvedPath = path5.resolve(filePath); const posixPath = Path.posix(resolvedPath); if (!validatePathIsInAllowlist(posixPath, allowlist)) { logger?.warn({ traceId, filePath }, "doWriteFile: Path not in allowlist"); @@ -60966,7 +65325,7 @@ async function doWriteFile(body, config2, traceId = "") { const release = await acquireFileLock(resolvedPath); try { const validatedPath = resolvedPath; - const parentDir = path6.dirname(validatedPath); + const parentDir = path5.dirname(validatedPath); try { await mkdir4(parentDir, { recursive: true }); logger?.debug({ traceId, parentDir }, "doWriteFile: Parent directory ensured"); @@ -61038,8 +65397,8 @@ async function doWriteFile(body, config2, traceId = "") { } } // src/readFile.ts -import path7 from "node:path"; -import { access as access2, readFile as readFile4 } from "node:fs/promises"; +import path6 from "node:path"; +import { access as access2, readFile as readFile5 } from "node:fs/promises"; import { constants as fsConstants2 } from "node:fs"; var readFileRequestSchema = exports_external2.object({ path: exports_external2.string().min(1, "Path is required"), @@ -61058,7 +65417,7 @@ async function checkFileIsReadable(filePath) { return null; } try { - return await readFile4(filePath, "utf-8"); + return await readFile5(filePath, "utf-8"); } catch { return null; } @@ -61076,7 +65435,7 @@ async function doReadFile(body, config2) { const { path: filePath, requireValidPath } = validationResult.data; logger?.debug({ filePath, requireValidPath }, "doReadFile: processing request"); const posixPath = Path.posix(filePath); - const resolvedPath = path7.posix.resolve(posixPath); + const resolvedPath = path6.posix.resolve(posixPath); if (requireValidPath === undefined || requireValidPath === true) { const isAllowed = validatePathIsInAllowlist(resolvedPath, allowlist); if (!isAllowed) { @@ -61110,8 +65469,8 @@ async function doReadFile(body, config2) { } } // src/deleteFile.ts -import path8 from "node:path"; -import { stat as stat7, unlink as unlink3 } from "node:fs/promises"; +import path7 from "node:path"; +import { stat as stat6, unlink as unlink2 } from "node:fs/promises"; var deleteFileRequestSchema = exports_external2.object({ path: exports_external2.string().min(1, "Path is required") }); @@ -61127,7 +65486,7 @@ async function doDeleteFile(body, config2) { } const { path: filePath } = validationResult.data; logger?.debug({ filePath }, "doDeleteFile: processing request"); - const resolvedPath = path8.resolve(filePath); + const resolvedPath = path7.resolve(filePath); const posixPath = Path.posix(resolvedPath); if (!validatePathIsInAllowlist(posixPath, allowlist)) { logger?.warn({ filePath: posixPath }, "doDeleteFile: path not in allowlist"); @@ -61137,7 +65496,7 @@ async function doDeleteFile(body, config2) { } const platformPath = Path.toPlatformPath(posixPath); try { - const fileStats = await stat7(platformPath); + const fileStats = await stat6(platformPath); if (!fileStats.isFile()) { logger?.info({ filePath: platformPath }, "doDeleteFile: path is not a file"); return { @@ -61156,7 +65515,7 @@ async function doDeleteFile(body, config2) { }; } try { - await unlink3(platformPath); + await unlink2(platformPath); logger?.info({ filePath: platformPath }, "doDeleteFile: file deleted successfully"); return { data: { path: platformPath } }; } catch (error48) { @@ -61183,13 +65542,87 @@ async function doDeleteFile(body, config2) { }; } } +// src/deleteFolder.ts +import path8 from "node:path"; +import { rm, stat as stat7 } from "node:fs/promises"; +var deleteFolderRequestSchema = exports_external2.object({ + path: exports_external2.string().min(1, "Path is required") +}); +async function doDeleteFolder(body, config2) { + const { logger, allowlist } = config2; + try { + const validationResult = deleteFolderRequestSchema.safeParse(body); + if (!validationResult.success) { + logger?.info({ issues: validationResult.error.issues }, "doDeleteFolder: validation failed"); + return { + error: `Validation Failed: ${validationResult.error.issues.map((i) => i.message).join(", ")}` + }; + } + const { path: folderPath } = validationResult.data; + logger?.debug({ folderPath }, "doDeleteFolder: processing request"); + const resolvedPath = path8.resolve(folderPath); + const posixPath = Path.posix(resolvedPath); + if (!validatePathIsInAllowlist(posixPath, allowlist)) { + logger?.warn({ folderPath: posixPath }, "doDeleteFolder: path not in allowlist"); + return { + error: `Path "${folderPath}" is not in the allowlist` + }; + } + const platformPath = Path.toPlatformPath(posixPath); + try { + const folderStats = await stat7(platformPath); + if (!folderStats.isDirectory()) { + logger?.info({ folderPath: platformPath }, "doDeleteFolder: path is not a directory"); + return { + error: `Path Is File: ${folderPath} is a file, not a directory` + }; + } + } catch (error48) { + const errorCode = error48.code; + if (errorCode === "ENOENT") { + logger?.info({ folderPath: platformPath }, "doDeleteFolder: folder already absent"); + return { data: { path: platformPath } }; + } + logger?.error({ folderPath: platformPath, error: error48 }, "doDeleteFolder: cannot access path"); + return { + error: `Cannot access path: ${error48 instanceof Error ? error48.message : "Unknown error"}` + }; + } + try { + await rm(platformPath, { recursive: true, force: true }); + logger?.info({ folderPath: platformPath }, "doDeleteFolder: folder deleted successfully"); + return { data: { path: platformPath } }; + } catch (error48) { + const errorCode = error48.code; + if (errorCode === "ENOENT") { + logger?.info({ folderPath: platformPath }, "doDeleteFolder: folder already absent during rm"); + return { data: { path: platformPath } }; + } + if (errorCode === "EACCES" || errorCode === "EPERM") { + logger?.warn({ folderPath: platformPath }, "doDeleteFolder: permission denied"); + return { + error: `Permission denied: Cannot delete folder ${folderPath}` + }; + } + logger?.error({ folderPath: platformPath, error: error48 }, "doDeleteFolder: rm failed"); + return { + error: `Failed to delete folder ${folderPath}: ${error48 instanceof Error ? error48.message : "Unknown error"}` + }; + } + } catch (error48) { + logger?.error({ error: error48 }, "doDeleteFolder: unexpected error"); + return { + error: `Unexpected Error: ${error48 instanceof Error ? error48.message : "Unknown error"}` + }; + } +} // src/listFilesInMediaFolder.ts var listFilesInMediaFolderRequestSchema = exports_external2.object({ mediaFolderPath: exports_external2.string().min(1, "The absolute path of the media folder is required"), recursively: exports_external2.boolean().optional(), videoFileOnly: exports_external2.boolean().optional() }); -async function doListFilesInMediaFolder(body, config2 = {}) { +async function doListFilesInMediaFolder(body, config2 = EMPTY_CORE_ROUTES_CONFIG) { const parsed = listFilesInMediaFolderRequestSchema.safeParse(body); if (!parsed.success) { const msg = parsed.error.issues.map((i) => i.message).join(", "); @@ -61227,7 +65660,7 @@ async function doListFilesInMediaFolder(body, config2 = {}) { }; } } -// ../core/getMediaFolder.ts +// ../../apps/core/src/getMediaFolder.ts function getMediaFolder(filePath, folderPaths) { const filePathNorm = new Path(filePath).abs("posix").replace(/^\/[A-Za-z](?::|\/)/, ""); for (const folder of folderPaths) { @@ -61304,83 +65737,8 @@ async function executeBatchRenameOperations(renameMappings, _options = {}) { } // src/validateRenameOperations.ts -import { stat as stat9 } from "node:fs/promises"; -async function validateSourceFileExist2(tasks) { - const missingFiles = []; - for (const task of tasks) { - try { - const platformPath = Path.toPlatformPath(task.from); - const stats = await stat9(platformPath); - if (!stats.isFile()) { - missingFiles.push(task.from); - } - } catch { - missingFiles.push(task.from); - } - } - return { - isValid: missingFiles.length === 0, - missingFiles - }; -} -async function validateDestFileNotExist2(tasks) { - const existingFiles = []; - for (const task of tasks) { - try { - const platformPath = Path.toPlatformPath(task.to); - const stats = await stat9(platformPath); - if (stats.isFile()) { - existingFiles.push(task.to); - } - } catch { - continue; - } - } - return { - isValid: existingFiles.length === 0, - existingFiles - }; -} async function validateRenameOperations2(files, folderPathInPosix) { - const normalizedTasks = []; - for (const renameOp of files) { - if (!renameOp) { - continue; - } - normalizedTasks.push({ - from: Path.posix(renameOp.from), - to: Path.posix(renameOp.to) - }); - } - if (normalizedTasks.length === 0) { - return { - isValid: true, - errors: [], - validatedRenames: [] - }; - } - const syncResult = validateRenameOperationsSync(normalizedTasks, folderPathInPosix); - const errors4 = [...syncResult.errors]; - const sourceExistResult = await validateSourceFileExist2(normalizedTasks); - if (!sourceExistResult.isValid) { - for (const missingFile of sourceExistResult.missingFiles) { - errors4.push(`Source file "${missingFile}" does not exist in the media folder`); - } - } - const destNotExistResult = await validateDestFileNotExist2(normalizedTasks); - if (!destNotExistResult.isValid) { - for (const existingFile of destNotExistResult.existingFiles) { - errors4.push(`Target file "${existingFile}" already exists in the filesystem`); - } - } - if (errors4.length > 0) { - return { - isValid: false, - errors: errors4, - validatedRenames: [] - }; - } - return syncResult; + return validateRenameOperations(files, folderPathInPosix, createNodeRenameFileExistenceProbe()); } // src/renameFiles.ts @@ -61390,6 +65748,7 @@ var requestSchema = exports_external2.object({ to: exports_external2.string().min(1, "The target file path, absolute path in platform-specific format") })).min(1, "At least one file rename is required"), traceId: exports_external2.string().optional(), + strict: exports_external2.boolean().optional(), mediaFolder: exports_external2.string().optional(), clientId: exports_external2.string().optional() }); @@ -61419,21 +65778,48 @@ async function updateMediaMetadataAndBroadcast(mediaFolder, renameMappings, conf } }); } -async function doRenameFiles(body, config2 = {}, headerClientId) { +function findAllowlistViolation(paths, allowlist) { + for (const path10 of paths) { + const posixPath = Path.posix(path10); + if (!validatePathIsInAllowlist(posixPath, allowlist)) { + return path10; + } + } + return; +} +async function doRenameFiles(body, config2 = EMPTY_CORE_ROUTES_CONFIG, headerClientId) { const parsed = requestSchema.safeParse(body); if (!parsed.success) { const msg = parsed.error.issues.map((i) => i.message).join(", "); return { error: `Validation Failed: ${msg}` }; } - const { files, traceId, mediaFolder: mediaFolderFromBody, clientId: clientIdFromBody } = parsed.data; + const { + files, + traceId, + strict: strictFromBody, + mediaFolder: mediaFolderFromBody, + clientId: clientIdFromBody + } = parsed.data; + const strict = strictFromBody ?? true; const effectiveClientId = headerClientId ?? clientIdFromBody; const logCtx = traceId ? { traceId } : {}; + if (strict && !mediaFolderFromBody) { + return { error: "Validation Failed: mediaFolder is required when strict is true" }; + } const userConfig = await readUserConfig(config2); const mediaFolderPath = mediaFolderFromBody ?? getMediaFolder(files[0].from, userConfig.folders ?? []); if (mediaFolderPath === null) { return { error: `Media folder not found for ${files[0].from}` }; } const mediaFolderInPosix = Path.posix(mediaFolderPath); + const pathsToCheck = [ + mediaFolderPath, + ...files.flatMap((f) => [f.from, f.to]) + ]; + const violatedPath = findAllowlistViolation(pathsToCheck, config2.allowlist); + if (violatedPath !== undefined) { + return { error: `Path "${violatedPath}" is not in the allowlist` }; + } const validationResult = await validateRenameOperations2(files, mediaFolderInPosix); if (!validationResult.isValid) { return { error: validationResult.errors.join(", ") }; @@ -61460,6 +65846,178 @@ async function doRenameFiles(body, config2 = {}, headerClientId) { } }; } +// src/tools/plans.ts +import { mkdir as mkdir6, readdir as readdir2, stat as stat9, unlink as unlink3 } from "node:fs/promises"; +import path10 from "node:path"; +import { randomUUID as randomUUID3 } from "node:crypto"; +function plansDir2(appDataDir) { + return path10.join(appDataDir, "plans"); +} +function planFilePath2(appDataDir, planId) { + return path10.join(plansDir2(appDataDir), `${planId}.plan.json`); +} +async function ensurePlansDirExists(appDataDir, fs) { + const dir = plansDir2(appDataDir); + try { + const stats = await stat9(dir); + if (!stats.isDirectory()) { + throw new Error("Plans path exists but is not a directory"); + } + } catch (error48) { + if (error48.code === "ENOENT") { + await mkdir6(dir, { recursive: true }); + return; + } + throw error48; + } +} +async function readPlanById(appDataDir, planId, fs) { + const plan = await fs.readJson(planFilePath2(appDataDir, planId)); + if (!plan) { + return null; + } + return normalizePlanPaths(withCreatorDefault(plan)); +} +async function listPlanFiles(appDataDir) { + const dir = plansDir2(appDataDir); + try { + const stats = await stat9(dir); + if (!stats.isDirectory()) { + return []; + } + } catch { + return []; + } + const files = await readdir2(dir); + return files.filter((file2) => file2.endsWith(".plan.json")).map((file2) => path10.join(dir, file2)); +} +function withCreatorDefault(plan) { + if (plan.creator) { + return plan; + } + return { ...plan, creator: "app" }; +} +function normalizePlanPaths(plan) { + const mediaFolderPath = Path.posix(plan.mediaFolderPath); + if (plan.task === "recognize-media-file") { + return { + ...plan, + mediaFolderPath, + files: plan.files.map((f) => ({ ...f, path: Path.posix(f.path) })) + }; + } + return { + ...plan, + mediaFolderPath, + files: plan.files.map((f) => ({ + from: Path.posix(f.from), + to: Path.posix(f.to) + })) + }; +} +async function createPlan(appDataDir, input, fs) { + await ensurePlansDirExists(appDataDir, fs); + const id = input.id ?? randomUUID3(); + const mediaFolderPath = Path.posix(input.mediaFolderPath); + const plan = input.task === "recognize-media-file" ? { + id, + task: "recognize-media-file", + status: "preparing", + creator: input.creator, + mediaFolderPath, + files: [] + } : { + id, + task: "rename-files", + status: "preparing", + creator: input.creator, + mediaFolderPath, + files: [] + }; + await fs.writeJson(planFilePath2(appDataDir, id), plan); + return plan; +} +async function updatePlanContent(appDataDir, id, patch, fs) { + const filePath = planFilePath2(appDataDir, id); + const existing = await fs.readJson(filePath); + if (!existing) { + return null; + } + const merged = withCreatorDefault({ + ...existing, + ...patch.status !== undefined ? { status: patch.status } : {}, + ...patch.files !== undefined ? { files: patch.files } : {} + }); + const updated = normalizePlanPaths(merged); + if (patch.status === "completed") { + await deletePlan(appDataDir, id); + return updated; + } + await fs.writeJson(filePath, updated); + return updated; +} +async function deletePlan(appDataDir, id) { + try { + await unlink3(planFilePath2(appDataDir, id)); + } catch (error48) { + if (error48.code !== "ENOENT") { + throw error48; + } + } +} +async function cleanPreparingPlans(appDataDir, fs, logger) { + const start = Date.now(); + const plansPath = plansDir2(appDataDir); + logger?.info({ appDataDir, plansDir: plansPath }, "[cleanup] plan cleanup: scanning for stale preparing plans"); + const files = await listPlanFiles(appDataDir); + logger?.info({ plansDir: plansPath, scanned: files.length }, "[cleanup] plan cleanup: enumerated plan files"); + let removed = 0; + let failed = 0; + for (const filePath of files) { + try { + const plan = await fs.readJson(filePath); + if (!plan) { + logger?.debug({ filePath }, "[cleanup] plan cleanup: skipping unreadable plan file"); + continue; + } + if (plan.status === "preparing") { + await unlink3(filePath); + removed++; + logger?.debug({ filePath, planId: plan.id, task: plan.task }, "[cleanup] plan cleanup: removed stale preparing plan"); + } else { + logger?.debug({ filePath, planId: plan.id, status: plan.status }, "[cleanup] plan cleanup: keeping plan (not preparing)"); + } + } catch (err) { + failed++; + logger?.warn({ filePath, error: err.message }, "[cleanup] plan cleanup: failed to process plan file, skipping"); + } + } + logger?.info({ + plansDir: plansPath, + scanned: files.length, + removed, + failed, + durationMs: Date.now() - start + }, "[cleanup] plan cleanup: complete"); + return removed; +} +async function getActivePlansForFolder(appDataDir, mediaFolderPath, fs) { + const target = Path.posix(mediaFolderPath); + const files = await listPlanFiles(appDataDir); + const plans = []; + for (const file2 of files) { + const plan = await fs.readJson(file2); + if (!plan) { + continue; + } + const normalized = normalizePlanPaths(withCreatorDefault(plan)); + if (normalized.mediaFolderPath === target && isActivePlanStatus(normalized.status)) { + plans.push(normalized); + } + } + return plans; +} + // src/plansApi.ts var getPlansRequestSchema = exports_external2.object({ mediaFolderPath: exports_external2.string().min(1, "mediaFolderPath is required") @@ -61557,114 +66115,11 @@ async function doUpdatePlan(body, config2 = { allowlist: [] }) { async function cleanupStalePlans(appDataDir, fs = defaultChatFs(), logger) { return cleanPreparingPlans(appDataDir, fs, logger); } -// src/downloadImage.ts -import { Buffer as Buffer2 } from "node:buffer"; -import { readFile as readFile5 } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; -import { extname } from "node:path"; -var DEFAULT_CONTENT_TYPE = "image/jpeg"; -var EXTENSION_TO_CONTENT_TYPE = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".webp": "image/webp", - ".svg": "image/svg+xml", - ".ico": "image/x-icon", - ".bmp": "image/bmp", - ".avif": "image/avif", - ".apng": "image/apng" -}; -function getContentTypeFromExtension(ext) { - return EXTENSION_TO_CONTENT_TYPE[ext.toLowerCase()] ?? DEFAULT_CONTENT_TYPE; -} -function normalizeUrl(url2) { - if (url2.startsWith("//")) { - return `https:${url2}`; - } - return url2; -} -var REMOTE_IMAGE_REQUEST_HEADERS = { - accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", - "accept-language": "en-US,en;q=0.9", - "cache-control": "no-cache", - "sec-fetch-dest": "image", - "sec-fetch-mode": "no-cors", - "sec-fetch-site": "cross-site", - "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" -}; -function describeFetchError(error48) { - if (!(error48 instanceof Error)) { - return String(error48); - } - const cause = error48.cause; - const causeCode = cause && typeof cause === "object" && "code" in cause ? String(cause.code) : undefined; - const causeMessage = cause instanceof Error ? cause.message : undefined; - const directCode = !causeCode && typeof error48 === "object" && "code" in error48 ? String(error48.code) : undefined; - const directErrno = !causeCode && typeof error48 === "object" && "errno" in error48 ? String(error48.errno) : undefined; - const code = causeCode ?? directCode ?? directErrno; - const message = causeMessage; - const segments = []; - if (code) - segments.push(code); - if (message && message !== code) { - segments.push(message); - } - if (segments.length > 0) { - return `${error48.message} (${segments.join(": ")})`; - } - return error48.message; -} -function resolveUrl(url2) { - const normalizedUrl = normalizeUrl(url2); - if (normalizedUrl.startsWith("file://")) { - const platformPath = fileURLToPath(normalizedUrl); - return { kind: "file", normalizedUrl, platformPath }; - } - if (normalizedUrl.startsWith("http://") || normalizedUrl.startsWith("https://")) { - return { kind: "http", normalizedUrl }; - } - throw new Error(`Invalid image URL: ${url2}. ` + `Must be http://, https://, protocol-relative (//), or file://`); -} -async function doDownloadImage(url2, config2) { - const { allowlist, logger, fetchImpl = fetch } = config2; - const resolved = resolveUrl(url2); - logger?.info({ url: resolved.normalizedUrl, kind: resolved.kind }, "[DownloadImage] processing request"); - if (resolved.kind === "file") { - const platformPath = resolved.platformPath; - const posixPath = Path.posix(platformPath); - if (!validatePathIsInAllowlist(posixPath, allowlist)) { - throw new Error(`Permission denied: file ${platformPath} is not allowed to be read`); - } - const buffer2 = await readFile5(platformPath); - const ext = extname(platformPath); - const contentType2 = getContentTypeFromExtension(ext); - logger?.info({ platformPath, bytes: buffer2.length, contentType: contentType2 }, "[DownloadImage] read file"); - return { buffer: buffer2, contentType: contentType2 }; - } - let response; - try { - response = await fetchImpl(resolved.normalizedUrl, { - method: "GET", - headers: REMOTE_IMAGE_REQUEST_HEADERS - }); - } catch (error48) { - throw new Error(`Failed to download image: ${describeFetchError(error48)}`); - } - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const contentType = response.headers.get("content-type") ?? DEFAULT_CONTENT_TYPE; - const arrayBuffer = await response.arrayBuffer(); - const buffer = Buffer2.from(arrayBuffer); - logger?.info({ url: resolved.normalizedUrl, bytes: buffer.length, contentType }, "[DownloadImage] fetched remote image"); - return { buffer, contentType }; -} // src/downloadImageAsFile.ts import { Buffer as Buffer3 } from "node:buffer"; import { writeFile as writeFile5, access as access3 } from "node:fs/promises"; import { constants as fsConstants3 } from "node:fs"; -import path10 from "node:path"; +import path11 from "node:path"; var downloadImageAsFileRequestSchema = exports_external2.object({ url: exports_external2.string().min(1, "url is required"), path: exports_external2.string().min(1, "path is required") @@ -61699,7 +66154,7 @@ async function doDownloadImageAsFile(body, config2) { } const { url: url2, path: destPath } = validationResult.data; logger?.debug({ url: url2, destPath }, "[DownloadImageAsFile] processing request"); - const posixDestPath = path10.posix.resolve(Path.posix(destPath)); + const posixDestPath = path11.posix.resolve(Path.posix(destPath)); if (!validatePathIsInAllowlist(posixDestPath, allowlist)) { logger?.warn({ destPath, posixDestPath }, "[DownloadImageAsFile] destination not in allowlist"); return { @@ -61770,7 +66225,7 @@ async function doDownloadImageAsFile(body, config2) { import { Buffer as Buffer4 } from "node:buffer"; import { access as access4, readFile as readFile6 } from "node:fs/promises"; import { constants as fsConstants4 } from "node:fs"; -import path11 from "node:path"; +import path12 from "node:path"; var readImageRequestSchema = exports_external2.object({ path: exports_external2.string().min(1, "path is required") }); @@ -61799,11 +66254,11 @@ var EXTENSION_TO_MIME = { ".tif": "image/tiff" }; function isValidImageFile(filePath) { - const ext = path11.extname(filePath).toLowerCase(); + const ext = path12.extname(filePath).toLowerCase(); return VALID_IMAGE_EXTENSIONS.includes(ext); } function getImageMimeType(filePath) { - const ext = path11.extname(filePath).toLowerCase(); + const ext = path12.extname(filePath).toLowerCase(); return EXTENSION_TO_MIME[ext] ?? "image/jpeg"; } async function fileExists4(filePath) { @@ -61825,7 +66280,7 @@ async function doReadImage(body, config2) { }; } const { path: filePath } = validationResult.data; - const posixPath = path11.posix.resolve(Path.posix(filePath)); + const posixPath = path12.posix.resolve(Path.posix(filePath)); if (!validatePathIsInAllowlist(posixPath, allowlist)) { logger?.warn({ filePath, posixPath }, "[ReadImage] path not in allowlist"); return { @@ -61863,6 +66318,375 @@ async function doReadImage(body, config2) { }; } } +// src/discover.ts +function discoverLog(logger, level, message, details) { + const payload = details ?? {}; + const msg = `[Discover] ${message}`; + if (logger) { + logger[level](payload, msg); + return; + } + const line = details === undefined ? msg : `${msg} ${JSON.stringify(details)}`; + if (level === "error") { + console.error(line); + } else if (level === "warn") { + console.warn(line); + } else { + console.log(line); + } +} +var DEFAULT_DISCOVER_CONFIG_URL = "https://lawrenceching.github.io/SMM/config.json"; +var DISCOVER_TIMEOUT_MS = 1e4; +var EMPTY_DISCOVER_CONFIG = { + mediaDatabases: [], + reverseProxies: [] +}; +var FALLBACK_MEDIA_DATABASES = [ + { + type: "tmdb", + url: "https://mediadb.vercel.app/api/tmdb", + authorizationMethod: "none" + }, + { + type: "tmdb", + url: "https://1255396852-23teay8jtp.ap-hongkong.tencentscf.com", + authorizationMethod: "none" + }, + { + type: "tvdb", + url: "https://mediadb.vercel.app/api/tvdb", + authorizationMethod: "none" + }, + { + type: "tvdb", + url: "https://1255396852-24lotax0vl.ap-hongkong.tencentscf.com", + authorizationMethod: "none" + }, + { + type: "tmdb-asset", + url: "https://1255396852-19bqcvs6wn.ap-hongkong.tencentscf.com", + authorizationMethod: "none" + }, + { + type: "tvdb-asset", + url: "https://1255396852-2gz8ynvtkt.ap-hongkong.tencentscf.com", + authorizationMethod: "none" + } +]; +var FALLBACK_DISCOVER_CONFIG = { + mediaDatabases: FALLBACK_MEDIA_DATABASES, + reverseProxies: [] +}; +function fallbackDiscoverConfig(logger, reason, details) { + discoverLog(logger, "warn", `using hardcoded fallback mediaDatabases (${reason})`, { + mediaDatabasesCount: FALLBACK_MEDIA_DATABASES.length, + ...details + }); + return { + mediaDatabases: [...FALLBACK_MEDIA_DATABASES], + reverseProxies: [] + }; +} +function resolveDiscoverConfigUrl() { + const fromEnv = process.env.EXTERNAL_CONFIG_FILE_URL?.trim(); + if (fromEnv) { + return { url: fromEnv, urlFromEnv: true }; + } + return { url: DEFAULT_DISCOVER_CONFIG_URL, urlFromEnv: false }; +} +function normalizeAuthorizationMethod(value) { + if (typeof value !== "string") + return "none"; + if (value === "date-token") + return "date-token"; + return "none"; +} +function normalizeMediaDatabaseEntry(entry) { + const endpointUrl = (entry.baseUrl ?? entry.url ?? "").trim(); + if (!endpointUrl) + return null; + const type = entry.type; + if (type !== "tmdb" && type !== "tvdb" && type !== "tmdb-asset" && type !== "tvdb-asset") { + return null; + } + return { + type, + url: endpointUrl, + authorizationMethod: normalizeAuthorizationMethod(entry.authorizationMethod) + }; +} +function normalizeReverseProxyEntry(entry) { + const id = (entry.id ?? "").trim(); + const url2 = (entry.url ?? "").trim(); + if (!id || !url2) + return null; + const type = entry.type; + if (type !== "general") + return null; + return { + id, + type, + url: url2, + authorizationMethod: normalizeAuthorizationMethod(entry.authMethod) + }; +} +function normalizeMediaDatabases(rawEntries) { + if (!Array.isArray(rawEntries)) { + return []; + } + const normalized = []; + for (const raw of rawEntries) { + if (!raw || typeof raw !== "object") + continue; + const entry = normalizeMediaDatabaseEntry(raw); + if (entry) + normalized.push(entry); + } + return normalized; +} +function normalizeReverseProxies(rawEntries) { + if (!Array.isArray(rawEntries)) { + return []; + } + const normalized = []; + for (const raw of rawEntries) { + if (!raw || typeof raw !== "object") + continue; + const entry = normalizeReverseProxyEntry(raw); + if (entry) + normalized.push(entry); + } + return normalized; +} +function normalizeLatestVersion(value) { + if (value === undefined || value === null) { + return {}; + } + if (typeof value !== "string") { + return { invalidReason: `expected string, got ${typeof value}` }; + } + const trimmed = value.trim(); + if (trimmed.length === 0) { + return { invalidReason: "blank string" }; + } + return { latestVersion: trimmed }; +} +function formatFetchError(error48) { + if (!(error48 instanceof Error)) { + return { message: String(error48) }; + } + const cause = error48.cause; + return { + message: error48.message, + name: error48.name, + cause: cause instanceof Error ? cause.message : cause !== undefined ? String(cause) : undefined, + stack: error48.stack + }; +} +function isAbortError2(error48) { + return error48 instanceof Error && error48.name === "AbortError" || typeof DOMException !== "undefined" && error48 instanceof DOMException && error48.name === "AbortError"; +} +function isPlainObject3(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +async function readResponseBodyPreview(logger, response) { + try { + const text2 = await response.text(); + if (!text2) + return; + return text2.length > 300 ? `${text2.slice(0, 300)}…` : text2; + } catch (error48) { + discoverLog(logger, "warn", "failed to read response body for preview", { + err: formatFetchError(error48) + }); + return; + } +} +function logEmptyDiscoverResult(logger, url2, durationMs, body, mediaDatabases, latestVersion) { + const rawMediaDatabasesCount = Array.isArray(body.mediaDatabases) ? body.mediaDatabases.length : null; + const rawReverseProxiesCount = Array.isArray(body.reverseProxies) ? body.reverseProxies.length : null; + const common = { + url: url2, + durationMs, + latestVersion, + hasLatestVersionField: "latestVersion" in body, + rawReverseProxiesCount + }; + if (rawMediaDatabasesCount === null) { + discoverLog(logger, "warn", "remote config missing mediaDatabases array", { + ...common, + hasMediaDatabasesField: false + }); + return; + } + if (rawMediaDatabasesCount === 0) { + discoverLog(logger, "warn", "remote config has empty mediaDatabases array", { + ...common, + rawMediaDatabasesCount + }); + return; + } + if (mediaDatabases.length === 0) { + discoverLog(logger, "warn", "remote config mediaDatabases entries were all filtered out during normalization", { + ...common, + rawMediaDatabasesCount, + normalizedMediaDatabasesCount: 0 + }); + } +} +async function doFetchDiscoverConfig(config2 = {}) { + const { logger, fetchImpl = fetch } = config2; + const { url: url2, urlFromEnv } = resolveDiscoverConfigUrl(); + const startedAt = Date.now(); + const controller = new AbortController; + const timeout2 = setTimeout(() => controller.abort(), DISCOVER_TIMEOUT_MS); + discoverLog(logger, "info", "fetching remote config", { + url: url2, + urlFromEnv, + timeoutMs: DISCOVER_TIMEOUT_MS, + method: "GET" + }); + try { + let response; + try { + response = await fetchImpl(url2, { + method: "GET", + signal: controller.signal, + headers: { Accept: "application/json" } + }); + } catch (fetchError) { + const durationMs2 = Date.now() - startedAt; + const aborted2 = isAbortError2(fetchError) || controller.signal.aborted; + discoverLog(logger, "error", aborted2 ? "remote config fetch aborted (timeout or cancellation)" : "failed to fetch remote config", { + url: url2, + urlFromEnv, + durationMs: durationMs2, + aborted: aborted2, + signalAborted: controller.signal.aborted, + timedOut: aborted2 && durationMs2 >= DISCOVER_TIMEOUT_MS - 50, + err: formatFetchError(fetchError) + }); + return fallbackDiscoverConfig(logger, aborted2 ? "fetch aborted" : "fetch failed", { + url: url2, + urlFromEnv, + durationMs: durationMs2 + }); + } + const durationMs = Date.now() - startedAt; + discoverLog(logger, "info", "remote config HTTP response", { + url: url2, + urlFromEnv, + durationMs, + status: response.status, + statusText: response.statusText, + contentType: response.headers.get("content-type"), + redirected: response.redirected, + responseUrl: response.url + }); + if (!response.ok) { + const bodyPreview = await readResponseBodyPreview(logger, response); + discoverLog(logger, "error", "remote config returned non-OK status", { + url: url2, + urlFromEnv, + durationMs, + status: response.status, + statusText: response.statusText, + contentType: response.headers.get("content-type"), + bodyPreview + }); + return fallbackDiscoverConfig(logger, "non-OK status", { + url: url2, + status: response.status + }); + } + let rawJson; + try { + rawJson = await response.json(); + } catch (parseError) { + discoverLog(logger, "error", "remote config response is not valid JSON", { + url: url2, + urlFromEnv, + durationMs, + contentType: response.headers.get("content-type"), + err: formatFetchError(parseError) + }); + return fallbackDiscoverConfig(logger, "invalid JSON", { url: url2 }); + } + if (!isPlainObject3(rawJson)) { + discoverLog(logger, "error", "remote config JSON root is not an object", { + url: url2, + urlFromEnv, + durationMs, + rootType: rawJson === null ? "null" : Array.isArray(rawJson) ? "array" : typeof rawJson + }); + return fallbackDiscoverConfig(logger, "JSON root is not an object", { url: url2 }); + } + let mediaDatabases; + let reverseProxies; + let latestVersion; + try { + mediaDatabases = normalizeMediaDatabases(rawJson.mediaDatabases); + reverseProxies = normalizeReverseProxies(rawJson.reverseProxies); + const versionResult = normalizeLatestVersion(rawJson.latestVersion); + if (versionResult.invalidReason) { + discoverLog(logger, "warn", "remote config latestVersion ignored", { + url: url2, + reason: versionResult.invalidReason, + rawType: typeof rawJson.latestVersion + }); + } + latestVersion = versionResult.latestVersion; + } catch (normalizeError) { + discoverLog(logger, "error", "failed to normalize remote config", { + url: url2, + urlFromEnv, + durationMs, + err: formatFetchError(normalizeError) + }); + return fallbackDiscoverConfig(logger, "normalize failed", { url: url2 }); + } + if (mediaDatabases.length === 0) { + logEmptyDiscoverResult(logger, url2, durationMs, rawJson, mediaDatabases, latestVersion); + const fallback = fallbackDiscoverConfig(logger, "empty mediaDatabases", { + url: url2 + }); + return { + mediaDatabases: fallback.mediaDatabases, + reverseProxies, + latestVersion + }; + } + discoverLog(logger, "info", "remote config loaded", { + url: url2, + urlFromEnv, + durationMs, + mediaDatabasesCount: mediaDatabases.length, + reverseProxiesCount: reverseProxies.length, + mediaDatabaseTypes: [ + ...new Set(mediaDatabases.map((entry) => entry.type)) + ], + latestVersion, + hasLatestVersionField: "latestVersion" in rawJson + }); + return { mediaDatabases, reverseProxies, latestVersion }; + } catch (error48) { + const durationMs = Date.now() - startedAt; + discoverLog(logger, "error", "unexpected error while loading remote config", { + url: url2, + urlFromEnv, + durationMs, + signalAborted: controller.signal.aborted, + err: formatFetchError(error48) + }); + return fallbackDiscoverConfig(logger, "unexpected error", { url: url2, durationMs }); + } finally { + clearTimeout(timeout2); + } +} +async function doFetchDiscoveredMediaDatabases(config2 = {}) { + const discoverConfig = await doFetchDiscoverConfig(config2); + return discoverConfig.mediaDatabases; +} // src/routes/listFilesRoute.ts var emptyListFilesResponse = { data: { path: "", items: [], size: 0 } @@ -61932,10 +66756,14 @@ async function handleListFilesPost(req, res, ctx) { } // src/routes/helloRoute.ts -async function handleHelloPost(req, res, ctx) { - if (req.method !== "POST" || ctx.url.pathname !== "/api/hello") { +async function handleHelloGet(req, res, ctx) { + if (req.method !== "GET" || ctx.url.pathname !== "/api/hello") { return false; } + if (ctx.config.resolveHello) { + sendJson(res, 200, ctx.config.resolveHello()); + return true; + } if (ctx.config.hello === undefined) { sendJson(res, 200, { error: "hello not configured" }); return true; @@ -61944,6 +66772,7 @@ async function handleHelloPost(req, res, ctx) { sendJson(res, 200, result); return true; } +var handleHelloPost = handleHelloGet; // src/routes/isFolderAvailableRoute.ts var isFolderAvailableRequestSchema2 = exports_external2.object({ @@ -62049,6 +66878,27 @@ async function handleDeleteFilePost(req, res, ctx) { } } +// src/routes/deleteFolderRoute.ts +async function handleDeleteFolderPost(req, res, ctx) { + if (req.method !== "POST" || ctx.url.pathname !== "/api/deleteFolder") { + return false; + } + try { + const rawBody = await readJsonBody(req); + ctx.config.logger?.info({ rawBody }, "[DeleteFolder] POST /api/deleteFolder"); + const result = await doDeleteFolder(rawBody, ctx.config); + sendJson(res, 200, result); + return true; + } catch (error48) { + ctx.config.logger?.error({ error: error48 }, "DeleteFolder POST route error"); + sendJson(res, 400, { + error: "Invalid JSON body", + details: error48 instanceof Error ? error48.message : "Unknown error" + }); + return true; + } +} + // src/routes/getEpisodesRoute.ts async function handleGetEpisodesPost(req, res, ctx) { if (req.method !== "POST" || ctx.url.pathname !== "/api/getEpisodes") { @@ -62172,8 +67022,24 @@ async function handleDownloadImageAsFilePost(req, res, ctx) { } try { const rawBody = await readJsonBody(req); - ctx.config.logger?.info({ rawBody }, "[DownloadImageAsFile] POST /api/downloadImage"); - const result = await doDownloadImageAsFile(rawBody, ctx.config); + const { url: url2, path: path13, httpProxy } = rawBody; + ctx.config.logger?.info({ url: url2, path: path13 }, "[DownloadImageAsFile] POST /api/downloadImage"); + const trimmedProxy = httpProxy?.trim(); + const fetchImpl = trimmedProxy ? createProxiedFetch(trimmedProxy, ctx.config.logger) : ctx.config.fetchImpl; + let allowlist = ctx.config.allowlist; + if (ctx.config.resolveAllowlist) { + try { + allowlist = await ctx.config.resolveAllowlist(); + } catch (error48) { + ctx.config.logger?.warn({ error: error48 }, "[DownloadImageAsFile] failed to resolve allowlist, falling back to static"); + allowlist = ctx.config.allowlist; + } + } + const result = await doDownloadImageAsFile(rawBody, { + ...ctx.config, + allowlist, + fetchImpl + }); sendJson(res, 200, result); return true; } catch (error48) { @@ -62207,8 +67073,32 @@ async function handleReadImagePost(req, res, ctx) { } } +// src/routes/discoverRoute.ts +async function handleDiscoverGet(req, res, ctx) { + if (req.method !== "GET" || ctx.url.pathname !== "/api/discover") { + return false; + } + try { + const config2 = await doFetchDiscoverConfig({ + logger: ctx.config.logger, + fetchImpl: ctx.config.fetchImpl + }); + const body = { data: config2 }; + sendJson(res, 200, body); + return true; + } catch (error48) { + const message = error48 instanceof Error ? error48.message : String(error48); + ctx.config.logger?.error({ error: message }, "[Discover] handleDiscoverGet unexpected throw"); + sendJson(res, 200, { data: { ...EMPTY_DISCOVER_CONFIG } }); + return true; + } +} + // src/mcp/lifecycle.ts function parseStartOptions(body) { + return parseStartOptionsFromBody(body); +} +function parseStartOptionsFromBody(body) { if (!body || typeof body !== "object") { return; } @@ -62320,6 +67210,141 @@ async function handleMcpStatusGet(req, res, ctx) { return true; } +// src/mcp/mcpServerConfig.ts +var DEFAULT_MCP_HOST = "127.0.0.1"; +var DEFAULT_MCP_PORT = 30001; +function mcpErrorMessage(error48) { + return error48 instanceof Error ? error48.message : String(error48); +} +function resolveMcpStartOptions(config2, options) { + return { + hostname: options?.hostname ?? config2.mcpHost ?? DEFAULT_MCP_HOST, + port: options?.port ?? config2.mcpPort ?? DEFAULT_MCP_PORT + }; +} +async function startMcpServerWithUserConfig(manager, routesConfig, body, operation) { + const options = parseStartOptionsFromBody(body); + const userConfig = await readUserConfig(routesConfig); + const { hostname: hostname3, port } = resolveMcpStartOptions(userConfig, options); + try { + await manager.start({ hostname: hostname3, port }); + const state = manager.getState(); + if (state.status === "error") { + return { + data: state, + error: `Error Reason: ${state.error ?? "Failed to start MCP server"}` + }; + } + if (operation?.persistUserConfig !== false) { + await writeUserConfigToDisk(routesConfig, { + ...userConfig, + enableMcpServer: true, + mcpHost: hostname3, + mcpPort: port + }); + } + return { data: state, error: null }; + } catch (error48) { + const message = mcpErrorMessage(error48); + const state = manager.getState(); + return { + data: { ...state, status: "error", error: message }, + error: `Error Reason: ${message}` + }; + } +} +async function stopMcpServerWithUserConfig(manager, routesConfig, operation) { + const userConfig = await readUserConfig(routesConfig); + try { + await manager.stop(); + const state = manager.getState(); + if (state.status === "error") { + return { + data: state, + error: `Error Reason: ${state.error ?? "Failed to stop MCP server"}` + }; + } + return { data: state, error: null }; + } catch (error48) { + const message = mcpErrorMessage(error48); + return { + data: { status: "error", error: message }, + error: `Error Reason: ${message}` + }; + } finally { + if (operation?.persistUserConfig !== false) { + await writeUserConfigToDisk(routesConfig, { + ...userConfig, + enableMcpServer: false + }); + } + } +} +async function getMcpServerStatusWithUserConfig(manager, routesConfig) { + const state = manager.getState(); + if (state.status !== "running") { + const userConfig = await readUserConfig(routesConfig); + if (userConfig.enableMcpServer) { + await writeUserConfigToDisk(routesConfig, { + ...userConfig, + enableMcpServer: false + }); + } + } + return { data: state, error: null }; +} + +// src/routes/mcpServerRpcRoute.ts +async function handleMcpGetServerStatusGet(req, res, ctx) { + if (req.method !== "GET" || ctx.url.pathname !== "/api/get-mcp-server-status") { + return false; + } + const manager = ctx.config.mcp?.manager; + if (!manager) { + sendJson(res, 200, { error: "Error Reason: MCP lifecycle not configured" }); + return true; + } + try { + const result = await getMcpServerStatusWithUserConfig(manager, ctx.config); + sendJson(res, 200, result); + } catch (error48) { + const message = error48 instanceof Error ? error48.message : String(error48); + sendJson(res, 200, { error: `Error Reason: ${message}` }); + } + return true; +} +async function handleMcpStartPost(req, res, ctx) { + if (req.method !== "POST" || ctx.url.pathname !== "/api/start-mcp-server") { + return false; + } + const manager = ctx.config.mcp?.manager; + if (!manager) { + sendJson(res, 200, { error: "Error Reason: MCP lifecycle not configured" }); + return true; + } + const body = await readJsonBody(req); + const result = await startMcpServerWithUserConfig(manager, ctx.config, body, { + persistUserConfig: true + }); + sendJson(res, 200, result); + return true; +} +async function handleMcpStopPost(req, res, ctx) { + if (req.method !== "POST" || ctx.url.pathname !== "/api/stop-mcp-server") { + return false; + } + const manager = ctx.config.mcp?.manager; + if (!manager) { + sendJson(res, 200, { error: "Error Reason: MCP lifecycle not configured" }); + return true; + } + const result = await stopMcpServerWithUserConfig(manager, ctx.config, { + persistUserConfig: true + }); + sendJson(res, 200, result); + return true; +} + // src/routes/plansRoute.ts async function handleGetPlansPost(req, res, ctx) { if (req.method !== "POST" || ctx.url.pathname !== "/api/getPlans") { @@ -62391,7 +67416,7 @@ var coreRouteHandlers = [ handleListFilesGet, handleListFilesPost, handleWriteFilePost, - handleHelloPost, + handleHelloGet, handleIsFolderAvailablePost, handleGetEpisodesPost, handleListFilesInMediaFolderPost, @@ -62399,10 +67424,15 @@ var coreRouteHandlers = [ handleRenameFilesPost, handleReadFilePost, handleDeleteFilePost, + handleDeleteFolderPost, handleDownloadImageGet, handleDownloadImageAsFilePost, handleReadImagePost, + handleDiscoverGet, handleChatPost, + handleMcpGetServerStatusGet, + handleMcpStartPost, + handleMcpStopPost, handleMcpStartPut, handleMcpStopPut, handleMcpStatusGet, @@ -62436,7 +67466,7 @@ function registerCoreRoutes(server, config2) { server.on("request", createCoreRoutesRequestHandler(config2, { fallbackPort })); } // ../../node_modules/.pnpm/socket.io@4.8.3/node_modules/socket.io/wrapper.mjs -var import_dist = __toESM(require_dist3(), 1); +var import_dist = __toESM(require_dist7(), 1); var { Server, Namespace, Socket } = import_dist.default; // src/socketIO/connection.ts @@ -62700,7 +67730,7 @@ var ZodMiniType = /* @__PURE__ */ $constructor("ZodMiniType", (inst, def) => { }, { parent: true }); }; inst.with = inst.check; - inst.clone = (_def, params) => clone(inst, _def, params); + inst.clone = (_def, params) => clone2(inst, _def, params); inst.brand = () => inst; inst.register = (reg, meta3) => { reg.add(inst, meta3); @@ -62872,10 +67902,10 @@ var SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, "2025-06-18", "2025- var RELATED_TASK_META_KEY = "io.modelcontextprotocol/related-task"; var JSONRPC_VERSION = "2.0"; var AssertObjectSchema = custom((v) => v !== null && (typeof v === "object" || typeof v === "function")); -var ProgressTokenSchema = union([string2(), number2().int()]); +var ProgressTokenSchema = union2([string2(), number2().int()]); var CursorSchema = string2(); var TaskCreationParamsSchema = looseObject({ - ttl: union([number2(), _null3()]).optional(), + ttl: union2([number2(), _null3()]).optional(), pollInterval: number2().optional() }); var TaskMetadataSchema = object({ @@ -62909,7 +67939,7 @@ var NotificationSchema = object({ var ResultSchema = looseObject({ _meta: RequestMetaSchema.optional() }); -var RequestIdSchema = union([string2(), number2().int()]); +var RequestIdSchema = union2([string2(), number2().int()]); var JSONRPCRequestSchema = object({ jsonrpc: literal(JSONRPC_VERSION), id: RequestIdSchema, @@ -62948,13 +67978,13 @@ var JSONRPCErrorResponseSchema = object({ }) }).strict(); var isJSONRPCErrorResponse = (value) => JSONRPCErrorResponseSchema.safeParse(value).success; -var JSONRPCMessageSchema = union([ +var JSONRPCMessageSchema = union2([ JSONRPCRequestSchema, JSONRPCNotificationSchema, JSONRPCResultResponseSchema, JSONRPCErrorResponseSchema ]); -var JSONRPCResponseSchema = union([JSONRPCResultResponseSchema, JSONRPCErrorResponseSchema]); +var JSONRPCResponseSchema = union2([JSONRPCResultResponseSchema, JSONRPCErrorResponseSchema]); var EmptyResultSchema = ResultSchema.strict(); var CancelledNotificationParamsSchema = NotificationsParamsSchema.extend({ requestId: RequestIdSchema.optional(), @@ -62984,7 +68014,7 @@ var ImplementationSchema = BaseMetadataSchema.extend({ websiteUrl: string2().optional(), description: string2().optional() }); -var FormElicitationCapabilitySchema = intersection(object({ +var FormElicitationCapabilitySchema = intersection2(object({ applyDefaults: boolean2().optional() }), record(string2(), unknown())); var ElicitationCapabilitySchema = preprocess((value) => { @@ -62994,7 +68024,7 @@ var ElicitationCapabilitySchema = preprocess((value) => { } } return value; -}, intersection(object({ +}, intersection2(object({ form: FormElicitationCapabilitySchema.optional(), url: AssertObjectSchema.optional() }), record(string2(), unknown()).optional())); @@ -63098,7 +68128,7 @@ var TaskStatusSchema = _enum2(["working", "input_required", "completed", "failed var TaskSchema = object({ taskId: string2(), status: TaskStatusSchema, - ttl: union([number2(), _null3()]), + ttl: union2([number2(), _null3()]), createdAt: string2(), lastUpdatedAt: string2(), pollInterval: optional(number2()), @@ -63203,7 +68233,7 @@ var ReadResourceRequestSchema = RequestSchema.extend({ params: ReadResourceRequestParamsSchema }); var ReadResourceResultSchema = ResultSchema.extend({ - contents: array(union([TextResourceContentsSchema, BlobResourceContentsSchema])) + contents: array(union2([TextResourceContentsSchema, BlobResourceContentsSchema])) }); var ResourceListChangedNotificationSchema = NotificationSchema.extend({ method: literal("notifications/resources/list_changed"), @@ -63281,14 +68311,14 @@ var ToolUseContentSchema = object({ }); var EmbeddedResourceSchema = object({ type: literal("resource"), - resource: union([TextResourceContentsSchema, BlobResourceContentsSchema]), + resource: union2([TextResourceContentsSchema, BlobResourceContentsSchema]), annotations: AnnotationsSchema.optional(), _meta: record(string2(), unknown()).optional() }); var ResourceLinkSchema = ResourceSchema.extend({ type: literal("resource_link") }); -var ContentBlockSchema = union([ +var ContentBlockSchema = union2([ TextContentSchema, ImageContentSchema, AudioContentSchema, @@ -63412,7 +68442,7 @@ var SamplingMessageContentBlockSchema = discriminatedUnion("type", [ ]); var SamplingMessageSchema = object({ role: RoleSchema, - content: union([SamplingMessageContentBlockSchema, array(SamplingMessageContentBlockSchema)]), + content: union2([SamplingMessageContentBlockSchema, array(SamplingMessageContentBlockSchema)]), _meta: record(string2(), unknown()).optional() }); var CreateMessageRequestParamsSchema = TaskAugmentedRequestParamsSchema.extend({ @@ -63441,7 +68471,7 @@ var CreateMessageResultWithToolsSchema = ResultSchema.extend({ model: string2(), stopReason: optional(_enum2(["endTurn", "stopSequence", "maxTokens", "toolUse"]).or(string2())), role: RoleSchema, - content: union([SamplingMessageContentBlockSchema, array(SamplingMessageContentBlockSchema)]) + content: union2([SamplingMessageContentBlockSchema, array(SamplingMessageContentBlockSchema)]) }); var BooleanSchemaSchema = object({ type: literal("boolean"), @@ -63491,7 +68521,7 @@ var LegacyTitledEnumSchemaSchema = object({ enumNames: array(string2()).optional(), default: string2().optional() }); -var SingleSelectEnumSchemaSchema = union([UntitledSingleSelectEnumSchemaSchema, TitledSingleSelectEnumSchemaSchema]); +var SingleSelectEnumSchemaSchema = union2([UntitledSingleSelectEnumSchemaSchema, TitledSingleSelectEnumSchemaSchema]); var UntitledMultiSelectEnumSchemaSchema = object({ type: literal("array"), title: string2().optional(), @@ -63518,9 +68548,9 @@ var TitledMultiSelectEnumSchemaSchema = object({ }), default: array(string2()).optional() }); -var MultiSelectEnumSchemaSchema = union([UntitledMultiSelectEnumSchemaSchema, TitledMultiSelectEnumSchemaSchema]); -var EnumSchemaSchema = union([LegacyTitledEnumSchemaSchema, SingleSelectEnumSchemaSchema, MultiSelectEnumSchemaSchema]); -var PrimitiveSchemaDefinitionSchema = union([EnumSchemaSchema, BooleanSchemaSchema, StringSchemaSchema, NumberSchemaSchema]); +var MultiSelectEnumSchemaSchema = union2([UntitledMultiSelectEnumSchemaSchema, TitledMultiSelectEnumSchemaSchema]); +var EnumSchemaSchema = union2([LegacyTitledEnumSchemaSchema, SingleSelectEnumSchemaSchema, MultiSelectEnumSchemaSchema]); +var PrimitiveSchemaDefinitionSchema = union2([EnumSchemaSchema, BooleanSchemaSchema, StringSchemaSchema, NumberSchemaSchema]); var ElicitRequestFormParamsSchema = TaskAugmentedRequestParamsSchema.extend({ mode: literal("form").optional(), message: string2(), @@ -63536,7 +68566,7 @@ var ElicitRequestURLParamsSchema = TaskAugmentedRequestParamsSchema.extend({ elicitationId: string2(), url: string2().url() }); -var ElicitRequestParamsSchema = union([ElicitRequestFormParamsSchema, ElicitRequestURLParamsSchema]); +var ElicitRequestParamsSchema = union2([ElicitRequestFormParamsSchema, ElicitRequestURLParamsSchema]); var ElicitRequestSchema = RequestSchema.extend({ method: literal("elicitation/create"), params: ElicitRequestParamsSchema @@ -63550,7 +68580,7 @@ var ElicitationCompleteNotificationSchema = NotificationSchema.extend({ }); var ElicitResultSchema = ResultSchema.extend({ action: _enum2(["accept", "decline", "cancel"]), - content: preprocess((val) => val === null ? undefined : val, record(string2(), union([string2(), number2(), boolean2(), array(string2())])).optional()) + content: preprocess((val) => val === null ? undefined : val, record(string2(), union2([string2(), number2(), boolean2(), array(string2())])).optional()) }); var ResourceTemplateReferenceSchema = object({ type: literal("ref/resource"), @@ -63561,7 +68591,7 @@ var PromptReferenceSchema = object({ name: string2() }); var CompleteRequestParamsSchema = BaseRequestParamsSchema.extend({ - ref: union([PromptReferenceSchema, ResourceTemplateReferenceSchema]), + ref: union2([PromptReferenceSchema, ResourceTemplateReferenceSchema]), argument: object({ name: string2(), value: string2() @@ -63607,7 +68637,7 @@ var RootsListChangedNotificationSchema = NotificationSchema.extend({ method: literal("notifications/roots/list_changed"), params: NotificationsParamsSchema.optional() }); -var ClientRequestSchema = union([ +var ClientRequestSchema = union2([ PingRequestSchema, InitializeRequestSchema, CompleteRequestSchema, @@ -63626,14 +68656,14 @@ var ClientRequestSchema = union([ ListTasksRequestSchema, CancelTaskRequestSchema ]); -var ClientNotificationSchema = union([ +var ClientNotificationSchema = union2([ CancelledNotificationSchema, ProgressNotificationSchema, InitializedNotificationSchema, RootsListChangedNotificationSchema, TaskStatusNotificationSchema ]); -var ClientResultSchema = union([ +var ClientResultSchema = union2([ EmptyResultSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, @@ -63643,7 +68673,7 @@ var ClientResultSchema = union([ ListTasksResultSchema, CreateTaskResultSchema ]); -var ServerRequestSchema = union([ +var ServerRequestSchema = union2([ PingRequestSchema, CreateMessageRequestSchema, ElicitRequestSchema, @@ -63653,7 +68683,7 @@ var ServerRequestSchema = union([ ListTasksRequestSchema, CancelTaskRequestSchema ]); -var ServerNotificationSchema = union([ +var ServerNotificationSchema = union2([ CancelledNotificationSchema, ProgressNotificationSchema, LoggingMessageNotificationSchema, @@ -63664,7 +68694,7 @@ var ServerNotificationSchema = union([ TaskStatusNotificationSchema, ElicitationCompleteNotificationSchema ]); -var ServerResultSchema = union([ +var ServerResultSchema = union2([ EmptyResultSchema, InitializeResultSchema, CompleteResultSchema, @@ -65807,7 +70837,7 @@ class Protocol { }; } } -function isPlainObject3(value) { +function isPlainObject4(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function mergeCapabilities(base, additional) { @@ -65818,7 +70848,7 @@ function mergeCapabilities(base, additional) { if (addValue === undefined) continue; const baseValue = result[k]; - if (isPlainObject3(baseValue) && isPlainObject3(addValue)) { + if (isPlainObject4(baseValue) && isPlainObject4(addValue)) { result[k] = { ...baseValue, ...addValue }; } else { result[k] = addValue; @@ -65829,7 +70859,7 @@ function mergeCapabilities(base, additional) { // ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.0_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/validation/ajv-provider.js var import_ajv = __toESM(require_ajv(), 1); -var import_ajv_formats = __toESM(require_dist4(), 1); +var import_ajv_formats = __toESM(require_dist8(), 1); function createDefaultAjvInstance() { const ajv = new import_ajv.default({ strict: false, @@ -66059,17 +71089,6 @@ class Server2 extends Protocol { } return taskValidationResult.data; } - if (typeof result === "object" && result !== null && "content" in result) { - const content0 = result.content?.[0]; - console.error("[DIAG-SRV] result keys:", Object.keys(result)); - console.error("[DIAG-SRV] content[0]:", JSON.stringify(content0)); - console.error("[DIAG-SRV] content[0].text typeof:", typeof content0?.text); - if (content0?.text !== undefined) { - console.error("[DIAG-SRV] content[0].text length:", content0.text.length); - } - } else { - console.error("[DIAG-SRV] result has NO content:", JSON.stringify(result)); - } const validationResult = safeParse3(CallToolResultSchema, result); if (!validationResult.success) { const errorMessage = validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); @@ -67646,264 +72665,41 @@ data: } } -// src/mcp/toolHandlers/addRecognizedFile.ts -function registerAddRecognizedFileTool(server, config2) { - const fs = config2.fs ?? defaultChatFs(); - const deps = defaultRecognizeFilesTaskDeps(fs); - const agentTool = buildAddRecognizedMediaFileTool("mcp", config2.appDataDir, fs, config2.logger, undefined, deps); - const inputSchema = exports_external.object({ - taskId: exports_external.string().describe("The task ID from begin-recognize-task"), - season: exports_external.number().describe("The season number of the episode"), - episode: exports_external.number().describe("The episode number"), - path: exports_external.string().describe("The absolute path of the media file (POSIX or Windows format)") - }); - server.registerTool(ADD_RECOGNIZED_MEDIA_FILE, { - description: agentTool.description, - inputSchema - }, async (args) => { - const { taskId, season, episode, path: path12 } = args ?? {}; - if (typeof taskId !== "string" || taskId.trim() === "") { - return createErrorResponse("Invalid taskId: 'taskId' must be a non-empty string"); - } - if (typeof season !== "number" || season < 0) { - return createErrorResponse("Invalid season: 'season' must be a non-negative number"); - } - if (typeof episode !== "number" || episode < 0) { - return createErrorResponse("Invalid episode: 'episode' must be a non-negative number"); - } - if (typeof path12 !== "string" || path12.trim() === "") { - return createErrorResponse("Invalid path: 'path' must be a non-empty string"); - } - try { - const result = await agentTool.execute({ - taskId, - season, - episode, - path: Path.posix(path12) - }); - if (typeof result === "object" && result !== null && "error" in result && typeof result.error === "string") { - return createSuccessResponse({ - success: false, - error: result.error - }); - } - return createSuccessResponse({ success: true, taskId }); - } catch (error48) { - return createErrorResponse(`Error adding recognized file: ${error48 instanceof Error ? error48.message : String(error48)}`); - } - }); -} - -// src/mcp/toolHandlers/addRenameFile.ts -import { extname as extname2 } from "node:path"; -function registerAddRenameFileTool(server, config2, deps) { - const fs = config2.fs ?? defaultChatFs(); - const agentTool = buildAddRenameFileToTaskTool("mcp", config2.appDataDir, fs, deps, config2.logger, undefined); - const inputSchema = exports_external.object({ - taskId: exports_external.string().describe("The task ID returned from begin-rename-task"), - from: exports_external.string().describe("The current absolute path of the file to rename"), - to: exports_external.string().describe("The new absolute path for the file") - }); - server.registerTool(ADD_RENAME_FILE_TO_TASK, { - description: agentTool.description, - inputSchema - }, async (args) => { - const { taskId, from, to } = args ?? {}; - if (typeof taskId !== "string" || taskId.trim() === "") { - return createErrorResponse("Invalid taskId: 'taskId' must be a non-empty string"); - } - if (typeof from !== "string" || from.trim() === "") { - return createErrorResponse("Invalid path: 'from' must be a non-empty string"); - } - if (typeof to !== "string" || to.trim() === "") { - return createErrorResponse("Invalid path: 'to' must be a non-empty string"); - } - if (!isVideoFile2(from)) { - return createErrorResponse("Invalid path: 'from' must be a video file"); - } - if (!isVideoFile2(to)) { - return createErrorResponse("Invalid path: 'to' must be a video file"); - } - try { - const result = await agentTool.execute({ - taskId, - from: Path.posix(from), - to: Path.posix(to) - }); - if (typeof result === "object" && result !== null && "error" in result && typeof result.error === "string") { - const message = result.error; - if (message.includes("Not Episode Video File")) { - return createSuccessResponse({ - success: false, - error: `"${from}" is not video file to any episode, you're not allowed to rename it. ` + `Call "get-episodes" tool to get the list of episode video files that needs to rename.` - }); - } - return createSuccessResponse({ success: false, error: message }); - } - return createSuccessResponse({ success: true, taskId }); - } catch (error48) { - const message = error48 instanceof Error ? error48.message : String(error48); - if (message.includes("Not Episode Video File")) { - return createErrorResponse(`"${from}" is not video file to any episode, you're not allowed to rename it. ` + `Call "get-episodes" tool to get the list of episode video files that needs to rename.`); - } - return createErrorResponse(message); - } - }); -} -function isVideoFile2(filePath) { - const extension = extname2(filePath).toLowerCase(); - return videoFileExtensions.includes(extension); -} - -// src/mcp/toolHandlers/beginRecognizeTask.ts -function registerBeginRecognizeTaskTool(server, config2) { - const fs = config2.fs ?? defaultChatFs(); - const agentTool = buildBeginRecognizeTaskTool("mcp", config2.appDataDir, fs, config2.broadcast, config2.logger, undefined); - const inputSchema = exports_external.object({ - mediaFolderPath: exports_external.string().describe("The absolute path of the media folder") - }); - server.registerTool(BEGIN_RECOGNIZE_TASK, { - description: agentTool.description, - inputSchema - }, async (args) => { - const { mediaFolderPath } = args ?? {}; - if (typeof mediaFolderPath !== "string" || mediaFolderPath.trim() === "") { - return createErrorResponse("Invalid path: 'mediaFolderPath' must be a non-empty string"); - } - try { - const result = await agentTool.execute({ mediaFolderPath }); - if (typeof result === "object" && result !== null && "error" in result) { - const errorResult = result; - if (errorResult.error) { - return createErrorResponse(errorResult.error); - } - } - if (typeof result === "object" && result !== null && "taskId" in result) { - return createSuccessResponse({ - success: true, - taskId: result.taskId, - mediaFolderPath: Path.posix(mediaFolderPath) - }); - } - return createSuccessResponse(result); - } catch (error48) { - return createErrorResponse(`Error starting recognize task: ${error48 instanceof Error ? error48.message : String(error48)}`); - } - }); -} - -// src/mcp/toolHandlers/beginRenameTask.ts -function registerBeginRenameTaskTool(server, config2, deps) { - const fs = config2.fs ?? defaultChatFs(); - const agentTool = buildBeginRenameFilesTaskTool("mcp", config2.appDataDir, fs, deps, config2.broadcast, config2.logger, undefined); - const inputSchema = exports_external.object({ - mediaFolderPath: exports_external.string().describe("The absolute path of the media folder, in POSIX or Windows format") - }); - server.registerTool(BEGIN_RENAME_FILES_TASK, { - description: agentTool.description, - inputSchema - }, async (args) => { - const { mediaFolderPath } = args ?? {}; - if (typeof mediaFolderPath !== "string" || mediaFolderPath.trim() === "") { - return createErrorResponse("Invalid path: 'mediaFolderPath' must be a non-empty string"); - } - try { - const result = await agentTool.execute({ mediaFolderPath }); - if (typeof result === "object" && result !== null && "error" in result) { - const errorResult = result; - if (errorResult.error) { - return createErrorResponse(errorResult.error); - } - } - if (typeof result === "object" && result !== null && "taskId" in result) { - return createSuccessResponse({ - success: true, - taskId: result.taskId, - mediaFolderPath: Path.posix(mediaFolderPath) - }); - } - config2.logger?.error?.({ - resultType: typeof result, - resultKeys: typeof result === "object" && result !== null ? Object.keys(result) : [] - }, `[tool][${BEGIN_RENAME_FILES_TASK}] Unexpected agent tool result shape`); - return createSuccessResponse(result); - } catch (error48) { - return createErrorResponse(`Error starting rename task: ${error48 instanceof Error ? error48.message : String(error48)}`); - } - }); -} - -// src/mcp/toolHandlers/endRecognizeTask.ts -function registerEndRecognizeTaskTool(server, config2) { - const fs = config2.fs ?? defaultChatFs(); - const agentTool = buildEndRecognizeTaskTool("mcp", config2.appDataDir, fs, config2.broadcast, config2.logger, undefined); - const inputSchema = exports_external.object({ - taskId: exports_external.string().describe("The task ID from begin-recognize-task") +// src/mcp/toolHandlers/createRecognizeEpisodePlan.ts +function registerCreateRecognizeEpisodePlanTool(server, config2) { + const tool2 = buildCreateRecognizeEpisodePlanTool(config2.appDataDir, config2.fs ?? defaultChatFs(), config2.broadcast, config2.logger, undefined, { + getUserConfig: config2.getUserConfig, + applyRecognizeEpisodePlan: config2.applyRecognizeEpisodePlan }); - server.registerTool(END_RECOGNIZE_TASK, { - description: agentTool.description, - inputSchema + const description = config2.toolDescriptions?.[CREATE_RECOGNIZE_EPISODE_PLAN] ?? CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION; + server.registerTool(CREATE_RECOGNIZE_EPISODE_PLAN, { + description, + inputSchema: createRecognizeEpisodePlanInputSchema }, async (args) => { - const { taskId } = args ?? {}; - if (typeof taskId !== "string" || taskId.trim() === "") { - return createErrorResponse("Invalid taskId: 'taskId' must be a non-empty string"); - } - try { - const result = await agentTool.execute({ taskId }); - if (typeof result === "object" && result !== null && "error" in result) { - const errorResult = result; - if (errorResult.error) { - return createSuccessResponse({ - success: false, - error: errorResult.error - }); - } - } - return createSuccessResponse({ - success: true, - taskId, - message: END_PLAN_TASK_SUCCESS_MESSAGE - }); - } catch (error48) { - return createErrorResponse(`Error ending recognize task: ${error48 instanceof Error ? error48.message : String(error48)}`); + const result = await tool2.execute(args); + if (result.error) { + return createErrorResponse(result.error); } + return createSuccessResponse(result); }); } -// src/mcp/toolHandlers/endRenameTask.ts -function registerEndRenameTaskTool(server, config2, deps) { - const fs = config2.fs ?? defaultChatFs(); - const agentTool = buildEndRenameFilesTaskTool("mcp", config2.appDataDir, fs, config2.broadcast, config2.logger, undefined); - const inputSchema = exports_external.object({ - taskId: exports_external.string().describe("The task ID returned from begin-rename-task") +// src/mcp/toolHandlers/createRenameEpisodePlan.ts +function registerCreateRenameEpisodePlanTool(server, config2) { + const tool2 = buildCreateRenameEpisodePlanTool(config2.appDataDir, config2.fs ?? defaultChatFs(), config2.broadcast, config2.logger, undefined, { + getUserConfig: config2.getUserConfig, + applyRenameEpisodePlan: config2.applyRenameEpisodePlan }); - server.registerTool(END_RENAME_FILES_TASK, { - description: agentTool.description, - inputSchema + const description = config2.toolDescriptions?.[CREATE_RENAME_EPISODE_PLAN] ?? CREATE_RENAME_EPISODE_PLAN_DESCRIPTION; + server.registerTool(CREATE_RENAME_EPISODE_PLAN, { + description, + inputSchema: createRenameEpisodePlanInputSchema }, async (args) => { - const { taskId } = args ?? {}; - if (typeof taskId !== "string" || taskId.trim() === "") { - return createErrorResponse("Invalid taskId: 'taskId' must be a non-empty string"); - } - try { - const result = await agentTool.execute({ taskId }); - if (typeof result === "object" && result !== null && "error" in result) { - const errorResult = result; - if (errorResult.error) { - return createSuccessResponse({ - success: false, - error: errorResult.error - }); - } - } - return createSuccessResponse({ - success: true, - taskId, - message: END_PLAN_TASK_SUCCESS_MESSAGE - }); - } catch (error48) { - return createErrorResponse(`Error ending rename task: ${error48 instanceof Error ? error48.message : String(error48)}`); + const result = await tool2.execute(args); + if (result.error) { + return createErrorResponse(result.error); } + return createSuccessResponse(result); }); } @@ -68107,12 +72903,12 @@ function registerIsFolderExistTool(server, config2) { inputSchema: isFolderExistInputSchema, outputSchema: isFolderExistOutputSchema }, async (args) => { - const { path: path12 } = args ?? {}; - if (typeof path12 !== "string" || path12.trim() === "") { + const { path: path13 } = args ?? {}; + if (typeof path13 !== "string" || path13.trim() === "") { return createErrorResponse("Invalid path: 'path' must be a non-empty string"); } try { - const result = await executeIsFolderExist(path12); + const result = await executeIsFolderExist(path13); return createSuccessResponse(result); } catch (error48) { return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); @@ -68222,6 +73018,231 @@ function registerRenameFolderTool(server, config2) { }); } +// src/mcp/toolHandlers/renameEpisodeFile.ts +function registerRenameEpisodeFileTool(server, config2) { + const description = config2.toolDescriptions?.[RENAME_EPISODE_FILE] ?? RENAME_EPISODE_FILE_DESCRIPTION; + server.registerTool(RENAME_EPISODE_FILE, { + description, + inputSchema: renameEpisodeFileInputSchema, + outputSchema: renameEpisodeFileOutputSchema + }, async (args) => { + const params = args ?? {}; + if (typeof params.mediaFolder !== "string" || params.mediaFolder.trim() === "") { + return createErrorResponse("Invalid path: 'mediaFolder' must be a non-empty string"); + } + if (typeof params.from !== "string" || params.from.trim() === "") { + return createErrorResponse("Invalid path: 'from' must be a non-empty string"); + } + if (typeof params.to !== "string" || params.to.trim() === "") { + return createErrorResponse("Invalid path: 'to' must be a non-empty string"); + } + try { + if (config2.acknowledge) { + const confirmationMessage = buildRenameEpisodeFileConfirmationMessage(params.from, params.to); + const responseData = await config2.acknowledge({ + event: "askForConfirmation", + data: { message: confirmationMessage }, + clientId: "mcp" + }, 30000); + const confirmed = responseData?.confirmed ?? responseData?.response === "yes"; + if (!confirmed) { + return createSuccessResponse(renameEpisodeFileCancelled(params.mediaFolder, params.from, params.to)); + } + } + const result = await executeRenameEpisodeFile({ + mediaFolder: params.mediaFolder, + from: params.from, + to: params.to + }, config2.renameEpisodeFile); + if (result.renamed) { + config2.broadcast?.({ + event: "mediaMetadataUpdated", + data: { + folderPath: Path.posix(params.mediaFolder) + } + }); + } + if (result.error && !result.renamed) { + return createSuccessResponse(result); + } + return createSuccessResponse(result); + } catch (error48) { + return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); + } + }); +} + +// src/mcp/toolHandlers/scrape.ts +function registerScrapeTool(server, config2) { + const description = config2.toolDescriptions?.[SCRAPE] ?? SCRAPE_DESCRIPTION; + server.registerTool(SCRAPE, { + description, + inputSchema: scrapeInputSchema, + outputSchema: scrapeOutputSchema + }, async (args) => { + const params = args ?? {}; + if (typeof params.path !== "string" || params.path.trim() === "") { + return createErrorResponse("Invalid path: 'path' must be a non-empty string"); + } + try { + const result = await executeScrape({ + path: params.path, + language: params.language + }, config2.scrapeFolder); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + } catch (error48) { + return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); + } + }); +} + +// src/mcp/toolHandlers/getJob.ts +function registerGetJobTool(server, config2) { + const description = config2.toolDescriptions?.[GET_JOB] ?? GET_JOB_DESCRIPTION; + server.registerTool(GET_JOB, { + description, + inputSchema: getJobInputSchema, + outputSchema: getJobOutputSchema + }, async (args) => { + const params = args ?? {}; + if (typeof params.id !== "string" || params.id.trim() === "") { + return createErrorResponse("Invalid id: 'id' must be a non-empty string"); + } + try { + const result = await executeGetJob(params.id, config2.getJob); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + } catch (error48) { + return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); + } + }); +} + +// src/mcp/toolHandlers/tmdbTools.ts +function registerTmdbTools(server, config2) { + const searchDescription = config2.toolDescriptions?.[TMDB_SEARCH] ?? TMDB_SEARCH_DESCRIPTION; + const movieDescription = config2.toolDescriptions?.[TMDB_GET_MOVIE] ?? TMDB_GET_MOVIE_DESCRIPTION; + const tvShowDescription = config2.toolDescriptions?.[TMDB_GET_TV_SHOW] ?? TMDB_GET_TV_SHOW_DESCRIPTION; + server.registerTool(TMDB_SEARCH, { + description: searchDescription, + inputSchema: tmdbSearchInputSchema, + outputSchema: tmdbSearchOutputSchema + }, async (args) => { + try { + const result = await executeTmdbSearch(args ?? {}, config2.searchInTmdb); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + } catch (error48) { + return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); + } + }); + server.registerTool(TMDB_GET_MOVIE, { + description: movieDescription, + inputSchema: tmdbGetMovieInputSchema, + outputSchema: tmdbGetMovieOutputSchema + }, async (args) => { + try { + const result = await executeTmdbGetMovie(args ?? {}, config2.getMovieInTmdb); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + } catch (error48) { + return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); + } + }); + server.registerTool(TMDB_GET_TV_SHOW, { + description: tvShowDescription, + inputSchema: tmdbGetTvShowInputSchema, + outputSchema: tmdbGetTvShowOutputSchema + }, async (args) => { + try { + const result = await executeTmdbGetTvShow(args ?? {}, config2.getTvShowInTmdb); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + } catch (error48) { + return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); + } + }); +} + +// src/mcp/toolHandlers/tvdbTools.ts +function registerTvdbTools(server, config2) { + const searchDescription = config2.toolDescriptions?.[TVDB_SEARCH] ?? TVDB_SEARCH_DESCRIPTION; + const movieDescription = config2.toolDescriptions?.[TVDB_GET_MOVIE] ?? TVDB_GET_MOVIE_DESCRIPTION; + const tvShowDescription = config2.toolDescriptions?.[TVDB_GET_TV_SHOW] ?? TVDB_GET_TV_SHOW_DESCRIPTION; + const languagesDescription = config2.toolDescriptions?.[TVDB_GET_LANGUAGES] ?? TVDB_GET_LANGUAGES_DESCRIPTION; + server.registerTool(TVDB_SEARCH, { + description: searchDescription, + inputSchema: tvdbSearchInputSchema, + outputSchema: tvdbSearchOutputSchema + }, async (args) => { + try { + const result = await executeTvdbSearch(args ?? {}, config2.searchInTvdb); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + } catch (error48) { + return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); + } + }); + server.registerTool(TVDB_GET_MOVIE, { + description: movieDescription, + inputSchema: tvdbGetMovieInputSchema, + outputSchema: tvdbGetMovieOutputSchema + }, async (args) => { + try { + const result = await executeTvdbGetMovie(args ?? {}, config2.getMovieInTvdb); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + } catch (error48) { + return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); + } + }); + server.registerTool(TVDB_GET_TV_SHOW, { + description: tvShowDescription, + inputSchema: tvdbGetTvShowInputSchema, + outputSchema: tvdbGetTvShowOutputSchema + }, async (args) => { + try { + const result = await executeTvdbGetTvShow(args ?? {}, config2.getTvShowInTvdb); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + } catch (error48) { + return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); + } + }); + server.registerTool(TVDB_GET_LANGUAGES, { + description: languagesDescription, + inputSchema: tvdbGetLanguagesInputSchema, + outputSchema: tvdbGetLanguagesOutputSchema + }, async (args) => { + try { + const result = await executeTvdbGetLanguages(config2.getTvdbLanguages, args ?? {}); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + } catch (error48) { + return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); + } + }); +} + // src/mcp/toolHandlers/staticText.ts var README_CONTENT = `# Simple Media Manager (SMM) @@ -68280,9 +73301,7 @@ AI助手应该参考一下步骤: 2. 使用 "get-media-metadata" 工具获取媒体目录的媒体元数据, 主要关注电视剧的季集信息 3. 使用 "get-episodes" 工具获取需要季集视频文件 4. 思考重命名命名方案 -5. 使用 "begin-rename-episode-video-file-task" 工具开始重命名任务 -6. 使用 "add-rename-episode-video-file-to-task" 工具添加需要重命名的文件 -7. 使用 "end-rename-episode-video-file-task" 工具结束重命名任务 +5. 使用 "create-rename-episode-plan" 工具一次提交全部需要重命名的文件 ## 文件命名规则 @@ -68354,8 +73373,6 @@ function registerStaticTextTools(server, config2) { // src/mcp/createServer.ts async function createMcpStreamableHttpHandler(config2) { - const fs = config2.fs ?? defaultChatFs(); - const renameFilesTaskDeps = defaultRenameFilesTaskDeps(config2.appDataDir); const server = new McpServer({ name: "Simple Media Manager (SMM)", version: "1.0.0", @@ -68370,16 +73387,23 @@ async function createMcpStreamableHttpHandler(config2) { registerIsFolderExistTool(server, config2); registerListFilesTool(server, config2); registerGetMediaMetadataTool(server, config2); + registerTmdbTools(server, config2); + registerTvdbTools(server, config2); registerStaticTextTools(server, config2); if (!config2.disabledTools?.includes(RENAME_FOLDER)) { registerRenameFolderTool(server, config2); } - registerBeginRenameTaskTool(server, config2, renameFilesTaskDeps); - registerAddRenameFileTool(server, config2, renameFilesTaskDeps); - registerEndRenameTaskTool(server, config2, renameFilesTaskDeps); - registerBeginRecognizeTaskTool(server, config2); - registerAddRecognizedFileTool(server, config2); - registerEndRecognizeTaskTool(server, config2); + if (!config2.disabledTools?.includes(RENAME_EPISODE_FILE)) { + registerRenameEpisodeFileTool(server, config2); + } + if (!config2.disabledTools?.includes(SCRAPE)) { + registerScrapeTool(server, config2); + } + if (!config2.disabledTools?.includes(GET_JOB)) { + registerGetJobTool(server, config2); + } + registerCreateRenameEpisodePlanTool(server, config2); + registerCreateRecognizeEpisodePlanTool(server, config2); registerGetEpisodeTool(server, config2); registerGetEpisodesTool(server, config2); await server.connect(new WebStandardStreamableHTTPServerTransport({})); @@ -68404,13 +73428,29 @@ function createErrorResponse(message) { }; } // src/mcp/index.ts -var MCP_TOOL_NAMES = { RENAME_FOLDER }; +var MCP_TOOL_NAMES = { + RENAME_FOLDER, + RENAME_EPISODE_FILE, + SCRAPE, + GET_JOB, + TMDB_SEARCH, + TMDB_GET_MOVIE, + TMDB_GET_TV_SHOW +}; export { validateUpstreamBaseURL, validatePathIsInAllowlist, + stopMcpServerWithUserConfig, + startMcpServerWithUserConfig, + resolveWebUiBindAddress, + resolveReverseProxyBindAddress, + resolveReverseProxyAdvertisedHost, + resolveMcpBindAddress, + resolveMcpAdvertisedHost, resolveFolderExistence, rejectUnauthorized, registerCoreRoutes, + parseStartOptionsFromBody, parseBearerToken, migrateAIConfig, isRequestAuthorized, @@ -68424,24 +73464,33 @@ export { handleReadFilePost, handleProxyRequest, handleMcpStopPut, + handleMcpStopPost, handleMcpStatusGet, handleMcpStartPut, + handleMcpStartPost, + handleMcpGetServerStatusGet, handleListFilesPost, handleListFilesInMediaFolderPost, handleListFilesGet, handleIsFolderAvailablePost, handleHelloPost, + handleHelloGet, handleGetPlansPost, handleGetEpisodesPost, handleDownloadImageGet, handleDownloadImageAsFilePost, + handleDiscoverGet, + handleDeleteFolderPost, handleDeleteFilePost, handleCreatePlanPost, handleCoreRoutesRequest, handleChatPost, + getMcpServerStatusWithUserConfig, findAvailableReverseProxyPort, filterResponseHeaders, filterRequestHeaders, + executeScrape, + executeGetJob, enforceCoreRoutesAuth, doWriteFile, doUpdatePlan, @@ -68459,8 +73508,11 @@ export { doGetPlans, doGetPlanById, doGetEpisodes, + doFetchDiscoveredMediaDatabases, + doFetchDiscoverConfig, doDownloadImageAsFile, doDownloadImage, + doDeleteFolder, doDeleteFile, doCreatePlan, doChat, @@ -68470,7 +73522,9 @@ export { createSocketIOManager, createReverseProxyRequestHandler, createReverseProxyManager, + createProxiedFetch, createOpenAICompatible, + createNodeRenameFileExistenceProbe, createNodeHttpFetch, createMcpStreamableHttpHandler, createErrorResponse, @@ -68482,10 +73536,18 @@ export { checkFolderPathAvailable, checkFileIsReadable, buildUpstreamUrl, + buildScrapeTool, + buildGetJobTool, applyMcpLifecycleFromConfig, PORT_RANGE_START, PORT_RANGE_END, MCP_TOOL_NAMES, + FALLBACK_DISCOVER_CONFIG, ExistedFileError, + EMPTY_DISCOVER_CONFIG, + DISCOVER_TIMEOUT_MS, + DEFAULT_MCP_PORT, + DEFAULT_MCP_HOST, + DEFAULT_DISCOVER_CONFIG_URL, DEFAULT_ALLOWED_UPSTREAM_HOSTS }; diff --git a/packages/core-routes/src/chat.ts b/packages/core-routes/src/chat.ts index 7b584e9a..a73076de 100644 --- a/packages/core-routes/src/chat.ts +++ b/packages/core-routes/src/chat.ts @@ -20,11 +20,7 @@ import { TVDB_GET_MOVIE } from "@smm/types/ai-tools/tvdbGetMovie"; import { TVDB_GET_TV_SHOW } from "@smm/types/ai-tools/tvdbGetTvShow"; import { TVDB_GET_LANGUAGES } from "@smm/types/ai-tools/tvdbGetLanguages"; import { CREATE_RENAME_EPISODE_PLAN } from "@smm/types/ai-tools/createRenameEpisodePlan"; -import { - BEGIN_RECOGNIZE_TASK, - ADD_RECOGNIZED_MEDIA_FILE, - END_RECOGNIZE_TASK, -} from "@smm/types/ai-tools/recognizeMediaFileTask"; +import { CREATE_RECOGNIZE_EPISODE_PLAN } from "@smm/types/ai-tools/createRecognizeEpisodePlan"; import type { IncomingMessage, ServerResponse } from "node:http"; import { createChatTools, defaultChatFs } from "./tools/index.ts"; import { sendJson } from "./http.ts"; @@ -150,9 +146,7 @@ export async function doChat( [TVDB_GET_TV_SHOW]: tools[TVDB_GET_TV_SHOW], [TVDB_GET_LANGUAGES]: tools[TVDB_GET_LANGUAGES], [CREATE_RENAME_EPISODE_PLAN]: tools[CREATE_RENAME_EPISODE_PLAN], - [BEGIN_RECOGNIZE_TASK]: tools[BEGIN_RECOGNIZE_TASK], - [ADD_RECOGNIZED_MEDIA_FILE]: tools[ADD_RECOGNIZED_MEDIA_FILE], - [END_RECOGNIZE_TASK]: tools[END_RECOGNIZE_TASK], + [CREATE_RECOGNIZE_EPISODE_PLAN]: tools[CREATE_RECOGNIZE_EPISODE_PLAN], }, stopWhen: stepCountIs(CHAT_STEP_LIMIT), }); diff --git a/packages/core-routes/src/cleanup.test.ts b/packages/core-routes/src/cleanup.test.ts index 704133e4..87a96a18 100644 --- a/packages/core-routes/src/cleanup.test.ts +++ b/packages/core-routes/src/cleanup.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { cleanupStalePlans } from "./cleanup.ts"; -import { beginRecognizePlan, listPlanFiles, readPlanById, updatePlanContent } from "./tools/plans.ts"; +import { createPlan, listPlanFiles, readPlanById, updatePlanContent } from "./tools/plans.ts"; import { defaultChatFs } from "./chatFs.ts"; import type { CoreRoutesLogger } from "./types.ts"; @@ -31,8 +31,12 @@ describe("cleanupStalePlans", () => { }); it("uses the default ChatFs when none is provided", async () => { - const preparingId = await beginRecognizePlan(appDataDir, "/media/a", fs); - const pendingId = await beginRecognizePlan(appDataDir, "/media/b", fs); + const preparingId = ( + await createPlan(appDataDir, { task: "recognize-media-file", mediaFolderPath: "/media/a", creator: "ai" }, fs) + ).id; + const pendingId = ( + await createPlan(appDataDir, { task: "recognize-media-file", mediaFolderPath: "/media/b", creator: "ai" }, fs) + ).id; await updatePlanContent(appDataDir, pendingId, { status: "pending" }, fs); expect((await listPlanFiles(appDataDir)).length).toBe(2); @@ -48,7 +52,9 @@ describe("cleanupStalePlans", () => { it("accepts an explicit ChatFs override", async () => { const overrideDir = await mkdtemp(join(tmpdir(), "smm-cleanup-override-")); try { - const id = await beginRecognizePlan(overrideDir, "/media/c", fs); + const id = ( + await createPlan(overrideDir, { task: "recognize-media-file", mediaFolderPath: "/media/c", creator: "ai" }, fs) + ).id; // `defaultChatFs()` reads from disk; passing the same fs we used // to create the plan keeps the test hermetic. @@ -63,9 +69,13 @@ describe("cleanupStalePlans", () => { it("logs start, per-file decisions, and a completion summary", async () => { const logDir = await mkdtemp(join(tmpdir(), "smm-cleanup-logging-")); try { - const keepingId = await beginRecognizePlan(logDir, "/media/keep", fs); + const keepingId = ( + await createPlan(logDir, { task: "recognize-media-file", mediaFolderPath: "/media/keep", creator: "ai" }, fs) + ).id; await updatePlanContent(logDir, keepingId, { status: "pending" }, fs); - const removingId = await beginRecognizePlan(logDir, "/media/drop", fs); + const removingId = ( + await createPlan(logDir, { task: "recognize-media-file", mediaFolderPath: "/media/drop", creator: "ai" }, fs) + ).id; const info = vi.fn(); const debug = vi.fn(); @@ -124,7 +134,9 @@ describe("cleanupStalePlans", () => { const logDir = await mkdtemp(join(tmpdir(), "smm-cleanup-bad-file-")); try { // One valid preparing plan + one corrupt JSON file. - const goodId = await beginRecognizePlan(logDir, "/media/good", fs); + const goodId = ( + await createPlan(logDir, { task: "recognize-media-file", mediaFolderPath: "/media/good", creator: "ai" }, fs) + ).id; const plansPath = join(logDir, "plans"); const corruptPath = join(plansPath, "corrupt.plan.json"); const { writeFile } = await import("node:fs/promises"); @@ -161,7 +173,9 @@ describe("cleanupStalePlans", () => { it("prefixes every emitted log message with [cleanup]", async () => { const logDir = await mkdtemp(join(tmpdir(), "smm-cleanup-prefix-")); try { - const goodId = await beginRecognizePlan(logDir, "/media/good", fs); + const goodId = ( + await createPlan(logDir, { task: "recognize-media-file", mediaFolderPath: "/media/good", creator: "ai" }, fs) + ).id; // Add a corrupt file so we exercise the warn path too. const plansPath = join(logDir, "plans"); const corruptPath = join(plansPath, "corrupt.plan.json"); diff --git a/packages/core-routes/src/mcp/createServer.ts b/packages/core-routes/src/mcp/createServer.ts index 87bdb339..46549eed 100644 --- a/packages/core-routes/src/mcp/createServer.ts +++ b/packages/core-routes/src/mcp/createServer.ts @@ -2,10 +2,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { RENAME_FOLDER } from "@smm/types/ai-tools/renameFolder"; import { RENAME_EPISODE_FILE } from "@smm/types/ai-tools/renameEpisodeFile"; -import { registerAddRecognizedFileTool } from "./toolHandlers/addRecognizedFile.ts"; -import { registerBeginRecognizeTaskTool } from "./toolHandlers/beginRecognizeTask.ts"; +import { registerCreateRecognizeEpisodePlanTool } from "./toolHandlers/createRecognizeEpisodePlan.ts"; import { registerCreateRenameEpisodePlanTool } from "./toolHandlers/createRenameEpisodePlan.ts"; -import { registerEndRecognizeTaskTool } from "./toolHandlers/endRecognizeTask.ts"; import { registerGetApplicationContextTool } from "./toolHandlers/getApplicationContext.ts"; import { registerGetEpisodeTool } from "./toolHandlers/getEpisode.ts"; import { registerGetEpisodesTool } from "./toolHandlers/getEpisodes.ts"; @@ -115,10 +113,8 @@ export async function createMcpStreamableHttpHandler( // Episode-level rename plan. registerCreateRenameEpisodePlanTool(server, config); - // Episode recognition task (begin / add / end). - registerBeginRecognizeTaskTool(server, config); - registerAddRecognizedFileTool(server, config); - registerEndRecognizeTaskTool(server, config); + // Episode recognition plan (single call). + registerCreateRecognizeEpisodePlanTool(server, config); // Episode lookup. registerGetEpisodeTool(server, config); diff --git a/packages/core-routes/src/mcp/toolHandlers/addRecognizedFile.ts b/packages/core-routes/src/mcp/toolHandlers/addRecognizedFile.ts deleted file mode 100644 index a9106384..00000000 --- a/packages/core-routes/src/mcp/toolHandlers/addRecognizedFile.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { Path } from "@smm/utils/path"; -import { defaultChatFs } from "../../chatFs.ts"; -import { - createErrorResponse, - createSuccessResponse, - type McpToolResponse, -} from "../index.ts"; -import type { McpConfig } from "../types.ts"; -import { - buildAddRecognizedMediaFileTool, - defaultRecognizeFilesTaskDeps, -} from "../../tools/recognizeMediaFilesTask.ts"; -import { defaultValidateRecognizedFiles } from "../../tools/plans.ts"; -import { ADD_RECOGNIZED_MEDIA_FILE } from "@smm/types/ai-tools/recognizeMediaFileTask"; - -/** - * Register the `add-recognized-file` MCP tool. Adds a single - * `(season, episode, path)` entry to an existing recognize task. - * - * The underlying agent tool performs a filesystem-existence check on - * the path via {@link defaultValidateRecognizedFiles}. Errors from - * that check, plus any other validation failure, are surfaced to the - * MCP client as `success: false` payloads so the AI sees a failed - * tool call instead of a silent success. - */ -export function registerAddRecognizedFileTool( - server: McpServer, - config: McpConfig, -): void { - const fs = config.fs ?? defaultChatFs(); - const deps = defaultRecognizeFilesTaskDeps(fs); - - const agentTool = buildAddRecognizedMediaFileTool( - "mcp", - config.appDataDir, - fs, - config.logger, - undefined, - deps, - ); - - const inputSchema = z.object({ - taskId: z - .string() - .describe("The task ID from begin-recognize-task"), - season: z.number().describe("The season number of the episode"), - episode: z.number().describe("The episode number"), - path: z - .string() - .describe( - "The absolute path of the media file (POSIX or Windows format)", - ), - }); - - server.registerTool( - ADD_RECOGNIZED_MEDIA_FILE, - { - description: agentTool.description, - inputSchema, - }, - async (args: unknown): Promise => { - const { taskId, season, episode, path } = (args ?? {}) as { - taskId?: string; - season?: number; - episode?: number; - path?: string; - }; - - if (typeof taskId !== "string" || taskId.trim() === "") { - return createErrorResponse( - "Invalid taskId: 'taskId' must be a non-empty string", - ); - } - if (typeof season !== "number" || season < 0) { - return createErrorResponse( - "Invalid season: 'season' must be a non-negative number", - ); - } - if (typeof episode !== "number" || episode < 0) { - return createErrorResponse( - "Invalid episode: 'episode' must be a non-negative number", - ); - } - if (typeof path !== "string" || path.trim() === "") { - return createErrorResponse( - "Invalid path: 'path' must be a non-empty string", - ); - } - - try { - const result = await agentTool.execute({ - taskId, - season, - episode, - path: Path.posix(path), - }); - - // Agent tools return `{ error: "..." }` instead of throwing - // when validation fails (e.g. file does not exist on disk). - // Surface those failures to the MCP client so the AI model - // does not silently add a non-existent file to the plan. - if ( - typeof result === "object" && - result !== null && - "error" in result && - typeof result.error === "string" - ) { - return createSuccessResponse({ - success: false, - error: result.error, - }); - } - - return createSuccessResponse({ success: true, taskId }); - } catch (error) { - return createErrorResponse( - `Error adding recognized file: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - }, - ); -} diff --git a/packages/core-routes/src/mcp/toolHandlers/beginRecognizeTask.ts b/packages/core-routes/src/mcp/toolHandlers/beginRecognizeTask.ts deleted file mode 100644 index 9bc74643..00000000 --- a/packages/core-routes/src/mcp/toolHandlers/beginRecognizeTask.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { Path } from "@smm/utils/path"; -import { defaultChatFs } from "../../chatFs.ts"; -import { - createErrorResponse, - createSuccessResponse, - type McpToolResponse, -} from "../index.ts"; -import type { McpConfig } from "../types.ts"; -import { buildBeginRecognizeTaskTool } from "../../tools/recognizeMediaFilesTask.ts"; -import { BEGIN_RECOGNIZE_TASK } from "@smm/types/ai-tools/recognizeMediaFileTask"; - -/** - * Register the `begin-recognize-task` MCP tool. Creates a new - * recognise-media-file task for a media folder and returns the - * task ID. Delegates to core-routes' runtime-neutral - * {@link buildBeginRecognizeTaskTool}. - */ -export function registerBeginRecognizeTaskTool( - server: McpServer, - config: McpConfig, -): void { - const fs = config.fs ?? defaultChatFs(); - - const agentTool = buildBeginRecognizeTaskTool( - "mcp", - config.appDataDir, - fs, - config.broadcast, - config.logger, - undefined, - ); - - const inputSchema = z.object({ - mediaFolderPath: z - .string() - .describe("The absolute path of the media folder"), - }); - - server.registerTool( - BEGIN_RECOGNIZE_TASK, - { - description: agentTool.description, - inputSchema, - }, - async (args: unknown): Promise => { - const { mediaFolderPath } = (args ?? {}) as { mediaFolderPath?: string }; - if ( - typeof mediaFolderPath !== "string" || - mediaFolderPath.trim() === "" - ) { - return createErrorResponse( - "Invalid path: 'mediaFolderPath' must be a non-empty string", - ); - } - - try { - const result = await agentTool.execute({ mediaFolderPath }); - - if (typeof result === "object" && result !== null && "error" in result) { - const errorResult = result as { error?: string }; - if (errorResult.error) { - return createErrorResponse(errorResult.error); - } - } - - if (typeof result === "object" && result !== null && "taskId" in result) { - return createSuccessResponse({ - success: true, - taskId: (result as { taskId: string }).taskId, - mediaFolderPath: Path.posix(mediaFolderPath), - }); - } - - return createSuccessResponse(result as { [x: string]: unknown }); - } catch (error) { - return createErrorResponse( - `Error starting recognize task: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - }, - ); -} diff --git a/packages/core-routes/src/mcp/toolHandlers/createRecognizeEpisodePlan.ts b/packages/core-routes/src/mcp/toolHandlers/createRecognizeEpisodePlan.ts new file mode 100644 index 00000000..d083b764 --- /dev/null +++ b/packages/core-routes/src/mcp/toolHandlers/createRecognizeEpisodePlan.ts @@ -0,0 +1,49 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + CREATE_RECOGNIZE_EPISODE_PLAN, + CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION, + createRecognizeEpisodePlanInputSchema, +} from "@smm/types/ai-tools/createRecognizeEpisodePlan"; +import { defaultChatFs } from "../../chatFs.ts"; +import { buildCreateRecognizeEpisodePlanTool } from "../../tools/createRecognizeEpisodePlan.ts"; +import { + createErrorResponse, + createSuccessResponse, + type McpToolResponse, +} from "../index.ts"; +import type { McpConfig } from "../types.ts"; + +export function registerCreateRecognizeEpisodePlanTool( + server: McpServer, + config: McpConfig, +): void { + const tool = buildCreateRecognizeEpisodePlanTool( + config.appDataDir, + config.fs ?? defaultChatFs(), + config.broadcast, + config.logger, + undefined, + { + getUserConfig: config.getUserConfig, + applyRecognizeEpisodePlan: config.applyRecognizeEpisodePlan, + }, + ); + const description = + config.toolDescriptions?.[CREATE_RECOGNIZE_EPISODE_PLAN] ?? + CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION; + + server.registerTool( + CREATE_RECOGNIZE_EPISODE_PLAN, + { + description, + inputSchema: createRecognizeEpisodePlanInputSchema, + }, + async (args: unknown): Promise => { + const result = await tool.execute(args); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + }, + ); +} diff --git a/packages/core-routes/src/mcp/toolHandlers/endRecognizeTask.ts b/packages/core-routes/src/mcp/toolHandlers/endRecognizeTask.ts deleted file mode 100644 index a521d84c..00000000 --- a/packages/core-routes/src/mcp/toolHandlers/endRecognizeTask.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { defaultChatFs } from "../../chatFs.ts"; -import { - createErrorResponse, - createSuccessResponse, - type McpToolResponse, -} from "../index.ts"; -import type { McpConfig } from "../types.ts"; -import { buildEndRecognizeTaskTool } from "../../tools/recognizeMediaFilesTask.ts"; -import { END_RECOGNIZE_TASK } from "@smm/types/ai-tools/recognizeMediaFileTask"; -import { END_PLAN_TASK_SUCCESS_MESSAGE } from "@smm/types/ai-tools/planTaskMessages"; - -/** - * Register the `end-recognize-task` MCP tool. Finalises a recognise - * task and broadcasts a Socket.IO `RecognizeMediaFilePlanReady` - * event so the UI can pick up the plan. - */ -export function registerEndRecognizeTaskTool( - server: McpServer, - config: McpConfig, -): void { - const fs = config.fs ?? defaultChatFs(); - - const agentTool = buildEndRecognizeTaskTool( - "mcp", - config.appDataDir, - fs, - config.broadcast, - config.logger, - undefined, - ); - - const inputSchema = z.object({ - taskId: z - .string() - .describe("The task ID from begin-recognize-task"), - }); - - server.registerTool( - END_RECOGNIZE_TASK, - { - description: agentTool.description, - inputSchema, - }, - async (args: unknown): Promise => { - const { taskId } = (args ?? {}) as { taskId?: string }; - if (typeof taskId !== "string" || taskId.trim() === "") { - return createErrorResponse( - "Invalid taskId: 'taskId' must be a non-empty string", - ); - } - - try { - const result = await agentTool.execute({ taskId }); - - if (typeof result === "object" && result !== null && "error" in result) { - const errorResult = result as { error?: string }; - if (errorResult.error) { - return createSuccessResponse({ - success: false, - error: errorResult.error, - }); - } - } - - return createSuccessResponse({ - success: true, - taskId, - message: END_PLAN_TASK_SUCCESS_MESSAGE, - }); - } catch (error) { - return createErrorResponse( - `Error ending recognize task: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - }, - ); -} diff --git a/packages/core-routes/src/mcp/types.ts b/packages/core-routes/src/mcp/types.ts index 3a47fa73..8e153c89 100644 --- a/packages/core-routes/src/mcp/types.ts +++ b/packages/core-routes/src/mcp/types.ts @@ -1,5 +1,6 @@ import type { UserConfig } from "@smm/types"; import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan"; +import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan"; import type { ChatFs } from "../chatTypes.ts"; import type { CoreRoutesLogger } from "../types.ts"; import type { WebSocketMessage } from "../socketIO/types.ts"; @@ -104,6 +105,9 @@ export interface McpConfig { */ applyRenameEpisodePlan?: (plan: RenameFilesPlan) => Promise; + /** Host Core runner for applying AI recognize plans (Bun cli / Electron). */ + applyRecognizeEpisodePlan?: (plan: RecognizeMediaFilePlan) => Promise; + /** * Optional runner for `scrape`. Hosts that expose Core inject * `Core.scrapeFolder`. When omitted, the tool reports unavailable. diff --git a/packages/core-routes/src/tools/index.ts b/packages/core-routes/src/tools/index.ts index 61ad8e5e..8c345287 100644 --- a/packages/core-routes/src/tools/index.ts +++ b/packages/core-routes/src/tools/index.ts @@ -1,5 +1,6 @@ import type { UserConfig } from "@smm/types"; import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan"; +import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan"; import { resolveAppLanguage, detectOsLocale } from "@smm/utils/locale"; import { GET_APPLICATION_CONTEXT } from "@smm/types/ai-tools/getApplicationContext"; import { IS_FOLDER_EXIST } from "@smm/types/ai-tools/isFolderExist"; @@ -35,10 +36,8 @@ import { CREATE_RENAME_EPISODE_PLAN, } from "@smm/types/ai-tools/createRenameEpisodePlan"; import { - BEGIN_RECOGNIZE_TASK, - ADD_RECOGNIZED_MEDIA_FILE, - END_RECOGNIZE_TASK, -} from "@smm/types/ai-tools/recognizeMediaFileTask"; + CREATE_RECOGNIZE_EPISODE_PLAN, +} from "@smm/types/ai-tools/createRecognizeEpisodePlan"; import type { CoreRoutesConfig } from "../types.ts"; import { defaultChatFs } from "../chatFs.ts"; import type { ChatConfig, ChatFs } from "../chatTypes.ts"; @@ -64,11 +63,7 @@ import { type GetJobRunner, } from "./getJob.ts"; import { buildCreateRenameEpisodePlanTool } from "./createRenameEpisodePlan.ts"; -import { - buildAddRecognizedMediaFileTool, - buildBeginRecognizeTaskTool, - buildEndRecognizeTaskTool, -} from "./recognizeMediaFilesTask.ts"; +import { buildCreateRecognizeEpisodePlanTool } from "./createRecognizeEpisodePlan.ts"; /** * The chat tools registered in `streamText({ tools })`, keyed by @@ -97,9 +92,9 @@ export interface ChatTools { [CREATE_RENAME_EPISODE_PLAN]: ReturnType< typeof buildCreateRenameEpisodePlanTool >; - [BEGIN_RECOGNIZE_TASK]: ReturnType; - [ADD_RECOGNIZED_MEDIA_FILE]: ReturnType; - [END_RECOGNIZE_TASK]: ReturnType; + [CREATE_RECOGNIZE_EPISODE_PLAN]: ReturnType< + typeof buildCreateRecognizeEpisodePlanTool + >; } /** @@ -120,6 +115,8 @@ export interface ChatToolsExtraDeps { tvdb?: TvdbToolRunners; /** Host Core runner for applying AI rename plans (Bun cli / Electron). */ applyRenameEpisodePlan?: (plan: RenameFilesPlan) => Promise; + /** Host Core runner for applying AI recognize plans (Bun cli / Electron). */ + applyRecognizeEpisodePlan?: (plan: RecognizeMediaFilePlan) => Promise; } export interface CreateChatToolsArgs { @@ -222,28 +219,16 @@ export function createChatTools(args: CreateChatToolsArgs): ChatTools { applyRenameEpisodePlan: extra?.applyRenameEpisodePlan, }, ), - [BEGIN_RECOGNIZE_TASK]: buildBeginRecognizeTaskTool( - clientId, - config.appDataDir, - fs, - broadcast, - logger, - abortSignal, - ), - [ADD_RECOGNIZED_MEDIA_FILE]: buildAddRecognizedMediaFileTool( - clientId, - config.appDataDir, - fs, - logger, - abortSignal, - ), - [END_RECOGNIZE_TASK]: buildEndRecognizeTaskTool( - clientId, + [CREATE_RECOGNIZE_EPISODE_PLAN]: buildCreateRecognizeEpisodePlanTool( config.appDataDir, fs, broadcast, logger, abortSignal, + { + getUserConfig: () => Promise.resolve(userConfig), + applyRecognizeEpisodePlan: extra?.applyRecognizeEpisodePlan, + }, ), }; } diff --git a/packages/core-routes/src/tools/plans.test.ts b/packages/core-routes/src/tools/plans.test.ts index 38915795..5a0de56f 100644 --- a/packages/core-routes/src/tools/plans.test.ts +++ b/packages/core-routes/src/tools/plans.test.ts @@ -1,38 +1,39 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, rm } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { - appendRecognizedFile, - beginRecognizePlan, - defaultValidateRecognizedFiles, - readRecognizePlan, -} from "./plans.ts"; +import { readRecognizePlan } from "./plans.ts"; import { defaultChatFs } from "../chatFs.ts"; import type { ChatFs } from "../chatTypes.ts"; import type { AnyPlan } from "./plans.ts"; -import type { - RecognizeMediaFilePlan, - RecognizedFile, -} from "@smm/types/RecognizeMediaFilePlan"; /** - * Tests for the recognise-media-file plan pipeline. They focus on the - * filesystem-existence guard added to prevent the AI from silently - * queueing non-existent files for the user to confirm later. - * - * `defaultValidateRecognizedFiles` is exercised twice: - * - * - Through a real filesystem (only on Linux/CI, where - * `@smm/utils/path`'s POSIX→Windows conversion does not mutate the - * path) to prove the validator correctly accepts an existing file - * and rejects a missing one. - * - Through an in-memory {@link ChatFs} that records plans in a Map - * to prove that {@link appendRecognizedFile} rejects the call when - * the validator reports the file is missing, and never mutates the - * plan in that case. + * Tests for the recognise-media-file plan storage helpers that are + * still owned by core-routes (status updates, cancellation, cleanup). + * Plan creation itself now goes through the single-call + * `create-recognize-episode-plan` pipeline; tests seed plans directly + * on the filesystem via {@link seedPreparingPlan}. */ +async function seedPreparingPlan( + appDataDir: string, + planId: string, + fs: ChatFs, +): Promise { + await fs.writeJson( + `${appDataDir}/plans/${planId}.plan.json`, + { + id: planId, + task: "recognize-media-file", + status: "preparing", + creator: "ai", + mediaFolderPath: "/media/show", + files: [], + }, + ); +} + function makeInMemoryFs(options: { exists: (p: string) => boolean }): ChatFs & { readonly plans: Map; } { @@ -70,123 +71,6 @@ function makeInMemoryFs(options: { exists: (p: string) => boolean }): ChatFs & { }; } -describe("appendRecognizedFile with an in-memory filesystem (cross-platform)", () => { - let appDataDir: string; - const fs = makeInMemoryFs({ exists: () => false }); - - beforeAll(async () => { - appDataDir = await mkdtemp(join(tmpdir(), "smm-plans-inmem-fs-")); - }); - - afterAll(async () => { - await rm(appDataDir, { recursive: true, force: true }); - }); - - it("rejects non-existent files and does not mutate the plan", async () => { - const taskId = await beginRecognizePlan(appDataDir, "/media/show", fs); - await expect( - appendRecognizedFile( - appDataDir, - taskId, - { season: 2, episode: 3, path: "/media/show/Missing.mkv" }, - fs, - ), - ).rejects.toThrow(/does not exist in the media folder/); - - const after = await readRecognizePlan(appDataDir, taskId, fs); - expect(after?.files ?? []).toEqual([]); - }); - - it("honours a custom validateFiles override (e.g. tests)", async () => { - const taskId = await beginRecognizePlan(appDataDir, "/media/show", fs); - - await expect( - appendRecognizedFile( - appDataDir, - taskId, - { season: 1, episode: 1, path: "/media/show/Missing.mkv" }, - fs, - { validateFiles: async () => undefined }, - ), - ).resolves.toBeUndefined(); - }); - - it("defaultValidateRecognizedFiles rejects when the filesystem says no", async () => { - await expect( - defaultValidateRecognizedFiles( - [{ season: 1, episode: 1, path: "/media/show/S01E01.mkv" }], - fs, - ), - ).rejects.toThrow( - 'File "/media/show/S01E01.mkv" (S1E1) does not exist in the media folder', - ); - }); - - it("defaultValidateRecognizedFiles rejects an empty path with a clear message", async () => { - await expect( - defaultValidateRecognizedFiles( - [{ season: 1, episode: 3, path: "" }], - fs, - ), - ).rejects.toThrow(/File path is empty for S1E3/); - }); - - it("exposes RecognizedFile typing through the default validator (acceptance)", async () => { - const accepting = makeInMemoryFs({ exists: () => true }); - const sample: RecognizedFile = { - season: 1, - episode: 1, - path: "/media/show/S01E01.mkv", - }; - await expect( - defaultValidateRecognizedFiles([sample], accepting), - ).resolves.toBeUndefined(); - }); -}); - -describe("appendRecognizedFile with a real filesystem (Linux/CI)", () => { - let appDataDir: string; - let existingFilePosix: string; - const fs = defaultChatFs(); - - beforeAll(async () => { - appDataDir = await mkdtemp(join(tmpdir(), "smm-plans-real-fs-")); - const subDir = join(appDataDir, "media", "Season 01"); - await mkdir(subDir, { recursive: true }); - const existingFilePlatform = join(subDir, "Episode.mkv"); - await writeFile(existingFilePlatform, "x", "utf-8"); - existingFilePosix = existingFilePlatform.split("\\").join("/"); - }); - - afterAll(async () => { - await rm(appDataDir, { recursive: true, force: true }); - }); - - // `Path.toPlatformPath` is broken for POSIX inputs on Windows - // (`@smm/utils/path` incorrectly routes through the UNC branch). Skip - // the real-fs round-trip there and let CI on Linux exercise it. - it.skipIf(process.platform === "win32")( - "adds the file when it exists on disk", - async () => { - const taskId = await beginRecognizePlan(appDataDir, "/media/show", fs); - - await expect( - appendRecognizedFile( - appDataDir, - taskId, - { season: 1, episode: 1, path: existingFilePosix }, - fs, - ), - ).resolves.toBeUndefined(); - - const plan = await readRecognizePlan(appDataDir, taskId, fs); - expect(plan?.files).toEqual([ - { season: 1, episode: 1, path: existingFilePosix }, - ]); - }, - ); -}); - describe("plan cancellation (rejected status)", () => { let appDataDir: string; const fs = makeInMemoryFs({ exists: () => false }); @@ -199,27 +83,10 @@ describe("plan cancellation (rejected status)", () => { await rm(appDataDir, { recursive: true, force: true }); }); - it("appendRecognizedFile throws the cancellation message when the plan is rejected", async () => { - const taskId = await beginRecognizePlan(appDataDir, "/media/show", fs); - // Mark the plan as rejected (simulating the user clicking Cancel). - fs.plans.set(taskId, { - ...(fs.plans.get(taskId) as RecognizeMediaFilePlan), - status: "rejected", - }); - - await expect( - appendRecognizedFile( - appDataDir, - taskId, - { season: 1, episode: 1, path: "/media/show/Ep.mkv" }, - fs, - ), - ).rejects.toThrow("该任务已被用户取消, 请停止后续操作"); - }); - it("updatePlanContent keeps the plan file when status is 'rejected' (no delete)", async () => { const { updatePlanContent } = await import("./plans.ts"); - const taskId = await beginRecognizePlan(appDataDir, "/media/show", fs); + const taskId = randomUUID(); + await seedPreparingPlan(appDataDir, taskId, fs); const updated = await updatePlanContent( appDataDir, @@ -229,8 +96,8 @@ describe("plan cancellation (rejected status)", () => { ); expect(updated?.status).toBe("rejected"); - // The plan file must still be on disk so subsequent MCP tool - // calls (add-*-file / end-*-task) can detect the cancellation. + // The plan file must still be on disk so a still-in-flight AI + // workflow can detect the cancellation via the persisted status. const planAfter = await readRecognizePlan(appDataDir, taskId, fs); expect(planAfter?.status).toBe("rejected"); }); @@ -243,7 +110,8 @@ describe("plan cancellation (rejected status)", () => { const { readPlanById, updatePlanContent } = await import("./plans.ts"); const realAppDataDir = await mkdtemp(join(tmpdir(), "smm-plans-cancel-real-")); try { - const taskId = await beginRecognizePlan(realAppDataDir, "/media/show", realFs); + const taskId = randomUUID(); + await seedPreparingPlan(realAppDataDir, taskId, realFs); await updatePlanContent( realAppDataDir, taskId, @@ -275,9 +143,12 @@ describe("cleanPreparingPlans (real filesystem)", () => { await import("./plans.ts"); // Three plans in different states. - const preparingId = await beginRecognizePlan(appDataDir, "/media/show-a", fs); - const pendingId = await beginRecognizePlan(appDataDir, "/media/show-b", fs); - const rejectedId = await beginRecognizePlan(appDataDir, "/media/show-c", fs); + const preparingId = randomUUID(); + const pendingId = randomUUID(); + const rejectedId = randomUUID(); + await seedPreparingPlan(appDataDir, preparingId, fs); + await seedPreparingPlan(appDataDir, pendingId, fs); + await seedPreparingPlan(appDataDir, rejectedId, fs); await updatePlanContent(appDataDir, pendingId, { status: "pending" }, fs); await updatePlanContent(appDataDir, rejectedId, { status: "rejected" }, fs); diff --git a/packages/core-routes/src/tools/plans.ts b/packages/core-routes/src/tools/plans.ts index 5acfe10e..a7897884 100644 --- a/packages/core-routes/src/tools/plans.ts +++ b/packages/core-routes/src/tools/plans.ts @@ -9,7 +9,6 @@ import type { import type { RenameFileEntry, RenameFilesPlan } from "@smm/types/RenameFilesPlan"; import type { PlanCreator, PlanStatus } from "@smm/types/planCommon"; import { isActivePlanStatus } from "@smm/types/planCommon"; -import { PLAN_CANCELLED_BY_USER_MESSAGE } from "@smm/types/ai-tools/planTaskMessages"; import type { ChatFs } from "../chatTypes.ts"; import type { CoreRoutesLogger } from "../types.ts"; @@ -85,101 +84,6 @@ export async function readPlanById( // ─── Recognize-media-file plan ─────────────────────────────────── -/** - * Begin a recognition task: create an empty plan file and return the - * new plan id. - * - * AI/MCP-created plans start as `preparing` with `creator: "ai"`; the - * end-task tool flips them to `pending` once files are added. - */ -export async function beginRecognizePlan( - appDataDir: string, - mediaFolderPath: string, - fs: ChatFs, -): Promise { - await ensurePlansDirExists(appDataDir, fs); - const planId = randomUUID(); - const plan: RecognizeMediaFilePlan = { - id: planId, - task: "recognize-media-file", - status: "preparing", - creator: "ai", - mediaFolderPath: Path.posix(mediaFolderPath), - files: [], - }; - await fs.writeJson(planFilePath(appDataDir, planId), plan); - return planId; -} - -export interface RecognizePlanAppendDeps { - /** - * Filesystem-existence check for the path being added. Defaults to - * a runtime-neutral {@link ChatFs.exists} probe when omitted. The - * legacy CLI used Bun's `Bun.file(...).exists()`; both backends - * behave identically for the "regular file exists" question this - * tool needs to answer. - */ - validateFiles?: (files: RecognizedFile[]) => Promise; -} - -/** - * Validate that every recognized file path points to a regular file - * on disk. Throws on the first missing path with a descriptive - * message. Uses {@link ChatFs.exists} so the same code runs on both - * Bun (`apps/cli`) and Node (`apps/ohos`). - */ -export async function defaultValidateRecognizedFiles( - files: RecognizedFile[], - fs: ChatFs, -): Promise { - for (const file of files) { - if (!file.path) { - throw new Error( - `File path is empty for S${file.season}E${file.episode}`, - ); - } - const platformPath = Path.toPlatformPath(Path.posix(file.path)); - const exists = await fs.exists(platformPath); - if (!exists) { - throw new Error( - `File "${Path.posix(file.path)}" (S${file.season}E${file.episode}) does not exist in the media folder`, - ); - } - } -} - -export async function appendRecognizedFile( - appDataDir: string, - taskId: string, - file: RecognizedFile, - fs: ChatFs, - deps: RecognizePlanAppendDeps = {}, -): Promise { - const filePath = planFilePath(appDataDir, taskId); - const plan = (await fs.readJson(filePath)) ?? null; - if (!plan) { - throw new Error(`Task with id ${taskId} not found`); - } - - if (plan.status === "rejected") { - throw new Error(PLAN_CANCELLED_BY_USER_MESSAGE); - } - - const normalizedPath = Path.posix(file.path); - const validate = - deps.validateFiles ?? - ((files) => defaultValidateRecognizedFiles(files, fs)); - await validate([{ ...file, path: normalizedPath }]); - - plan.files.push({ - season: file.season, - episode: file.episode, - path: normalizedPath, - }); - - await fs.writeJson(filePath, plan); -} - export async function readRecognizePlan( appDataDir: string, taskId: string, @@ -301,9 +205,8 @@ export interface UpdatePlanPatch { * - `completed` (user confirmed and applied): delete the plan file. * - `rejected` (user cancelled, possibly mid-`preparing`): keep the * plan file with `status: "rejected"` so that a still-in-flight AI - * workflow calling `add-*-file` or `end-*-task` afterwards can detect - * the cancellation and return a clear message instead of silently - * queueing entries into a deleted plan. + * workflow can detect the cancellation afterwards and stop instead + * of silently queueing entries into a deleted plan. * * Returns `null` if the plan file does not exist. */ diff --git a/packages/core-routes/src/tools/recognizeMediaFilesTask.ts b/packages/core-routes/src/tools/recognizeMediaFilesTask.ts deleted file mode 100644 index 8cf4b06d..00000000 --- a/packages/core-routes/src/tools/recognizeMediaFilesTask.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { Path } from "@smm/utils/path"; -import { - BEGIN_RECOGNIZE_TASK, - ADD_RECOGNIZED_MEDIA_FILE, - END_RECOGNIZE_TASK, - BEGIN_RECOGNIZE_TASK_DESCRIPTION, - ADD_RECOGNIZED_MEDIA_FILE_DESCRIPTION, - END_RECOGNIZE_TASK_DESCRIPTION, - beginRecognizeTaskInputSchema, - addRecognizedMediaFileInputSchema, - endRecognizeTaskInputSchema, -} from "@smm/types/ai-tools/recognizeMediaFileTask"; -import { END_PLAN_TASK_SUCCESS_MESSAGE, PLAN_CANCELLED_BY_USER_MESSAGE } from "@smm/types/ai-tools/planTaskMessages"; -import { formatToolError, toolError, toolOk } from "@smm/core/ai-tool/toolResult"; -import { - RecognizeMediaFilePlanReady, - type RecognizeMediaFilePlanReadyRequestData, -} from "@smm/types/event-types"; -import type { RecognizedFile } from "@smm/types/RecognizeMediaFilePlan"; -import type { CoreRoutesLogger } from "../types.ts"; -import { defaultBroadcast } from "./broadcast.ts"; -import type { WebSocketMessage } from "../socketIO/types.ts"; -import { - appendRecognizedFile, - beginRecognizePlan, - defaultValidateRecognizedFiles, - planFilePath, - readRecognizePlan, - updatePlanContent, - type RecognizePlanAppendDeps, -} from "./plans.ts"; -import type { ChatFs } from "../chatTypes.ts"; - -/** - * Dependencies the `recognize-media-file-task` tools need in - * addition to the runtime-neutral plumbing (`fs`, `logger`, - * `appDataDir`). Mirrors the shape of {@link RenameFilesTaskDeps} - * for the rename pipeline. - * - * - `validateFiles` — verifies each `path` exists on disk before - * adding it to the plan. Default uses {@link ChatFs.exists}; hosts - * may override (e.g. to surface richer diagnostics or to skip the - * check in tests). - */ -export interface RecognizeFilesTaskDeps { - validateFiles?: RecognizePlanAppendDeps["validateFiles"]; -} - -export function defaultRecognizeFilesTaskDeps( - fs: ChatFs, -): RecognizeFilesTaskDeps { - return { - validateFiles: (files) => defaultValidateRecognizedFiles(files, fs), - }; -} - -function makeLogger(logger: CoreRoutesLogger | undefined) { - return { - info: (obj: Record, msg?: string) => - logger?.info(obj, msg), - warn: (obj: Record, msg?: string) => - logger?.warn(obj, msg), - error: (obj: Record, msg?: string) => - logger?.error(obj, msg), - }; -} - -export function buildBeginRecognizeTaskTool( - clientId: string, - appDataDir: string, - fs: ChatFs, - broadcast: ((message: WebSocketMessage) => void) | undefined, - logger: CoreRoutesLogger | undefined, - abortSignal?: AbortSignal, -) { - const log = makeLogger(logger); - const emit = broadcast ?? defaultBroadcast; - return { - description: BEGIN_RECOGNIZE_TASK_DESCRIPTION, - toolName: BEGIN_RECOGNIZE_TASK, - inputSchema: beginRecognizeTaskInputSchema, - execute: async (args: unknown) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const { mediaFolderPath } = (args ?? {}) as { mediaFolderPath?: string }; - log.info( - { mediaFolderPath, clientId }, - `[tool][${BEGIN_RECOGNIZE_TASK}] Starting new recognition task`, - ); - - const folderPathInPosix = Path.posix(mediaFolderPath ?? ""); - - try { - const taskId = await beginRecognizePlan( - appDataDir, - folderPathInPosix, - fs, - ); - log.info( - { taskId, mediaFolderPath: folderPathInPosix, clientId }, - `[tool][${BEGIN_RECOGNIZE_TASK}] Task created successfully`, - ); - - const fullPlanPath = planFilePath(appDataDir, taskId); - const planFilePathInPosix = Path.posix(fullPlanPath); - const data: RecognizeMediaFilePlanReadyRequestData = { - taskId, - planFilePath: planFilePathInPosix, - }; - emit({ - event: RecognizeMediaFilePlanReady.event, - data, - }); - log.info( - { taskId, mediaFolderPath: folderPathInPosix, clientId, broadcast: true }, - `[DIAG] begin-recognize-task: plan created, RecognizeMediaFilePlanReady broadcast sent`, - ); - return toolOk({ taskId }); - } catch (error) { - log.error( - { - mediaFolderPath: folderPathInPosix, - error: error instanceof Error ? error.message : String(error), - clientId, - }, - `[tool][${BEGIN_RECOGNIZE_TASK}] Failed to create task`, - ); - return formatToolError(error); - } - }, - }; -} - -export function buildAddRecognizedMediaFileTool( - clientId: string, - appDataDir: string, - fs: ChatFs, - logger: CoreRoutesLogger | undefined, - abortSignal?: AbortSignal, - deps?: RecognizeFilesTaskDeps, -) { - const log = makeLogger(logger); - return { - description: ADD_RECOGNIZED_MEDIA_FILE_DESCRIPTION, - toolName: ADD_RECOGNIZED_MEDIA_FILE, - inputSchema: addRecognizedMediaFileInputSchema, - execute: async (args: unknown) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const { taskId, season, episode, path: filePath } = (args ?? {}) as { - taskId?: string; - season?: number; - episode?: number; - path?: string; - }; - const normalizedTaskId = (taskId ?? "").trim(); - log.info( - { taskId: normalizedTaskId, season, episode, path: filePath, clientId }, - `[tool][${ADD_RECOGNIZED_MEDIA_FILE}] Adding file to task`, - ); - - try { - const recognizedFile: RecognizedFile = { - season: season ?? 0, - episode: episode ?? 0, - path: filePath ?? "", - }; - await appendRecognizedFile( - appDataDir, - normalizedTaskId, - recognizedFile, - fs, - { validateFiles: deps?.validateFiles }, - ); - - log.info( - { - taskId: normalizedTaskId, - season, - episode, - path: filePath, - clientId, - }, - `[tool][${ADD_RECOGNIZED_MEDIA_FILE}] File added to task successfully`, - ); - - return toolOk({}); - } catch (error) { - log.error( - { - taskId: normalizedTaskId, - season, - episode, - path: filePath, - error: error instanceof Error ? error.message : String(error), - clientId, - }, - `[tool][${ADD_RECOGNIZED_MEDIA_FILE}] Failed to add file to task`, - ); - return formatToolError(error); - } - }, - }; -} - -export function buildEndRecognizeTaskTool( - clientId: string, - appDataDir: string, - fs: ChatFs, - broadcast: ((message: WebSocketMessage) => void) | undefined, - logger: CoreRoutesLogger | undefined, - abortSignal?: AbortSignal, -) { - const log = makeLogger(logger); - const emit = broadcast ?? defaultBroadcast; - return { - description: END_RECOGNIZE_TASK_DESCRIPTION, - toolName: END_RECOGNIZE_TASK, - inputSchema: endRecognizeTaskInputSchema, - execute: async (args: unknown) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const { taskId } = (args ?? {}) as { taskId?: string }; - const normalizedTaskId = (taskId ?? "").trim(); - log.info( - { taskId: normalizedTaskId, clientId }, - `[tool][${END_RECOGNIZE_TASK}] Ending recognition task`, - ); - - try { - const task = await readRecognizePlan(appDataDir, normalizedTaskId, fs); - - if (!task) { - log.error( - { taskId: normalizedTaskId, clientId }, - `[tool][${END_RECOGNIZE_TASK}] Task not found`, - ); - return formatToolError(`Task with id "${normalizedTaskId}" not found`); - } - - if (task.status === "rejected") { - log.warn( - { taskId: normalizedTaskId, clientId }, - `[tool][${END_RECOGNIZE_TASK}] Task cancelled by user`, - ); - return toolError(PLAN_CANCELLED_BY_USER_MESSAGE); - } - - if (task.files.length === 0) { - log.warn( - { taskId: normalizedTaskId, clientId }, - `[tool][${END_RECOGNIZE_TASK}] No files in task`, - ); - return formatToolError("No recognized files in task"); - } - - // Flip preparing → pending so the plan becomes visible to the UI. - await updatePlanContent(appDataDir, task.id, { status: "pending" }, fs); - - const fullPlanPath = planFilePath(appDataDir, task.id); - const planFilePathInPosix = Path.posix(fullPlanPath); - - const data: RecognizeMediaFilePlanReadyRequestData = { - taskId: task.id, - planFilePath: planFilePathInPosix, - }; - - emit({ - event: RecognizeMediaFilePlanReady.event, - data, - }); - - log.info( - { - taskId: normalizedTaskId, - folderPath: task.mediaFolderPath, - fileCount: task.files.length, - clientId, - }, - `[tool][${END_RECOGNIZE_TASK}] Task completed successfully`, - ); - - return toolOk({ message: END_PLAN_TASK_SUCCESS_MESSAGE }); - } catch (error) { - return formatToolError(error); - } - }, - }; -} - -/** Re-exported tool name constants for the tools registry. */ -export const BEGIN_RECOGNIZE_TASK_TOOL_NAME = BEGIN_RECOGNIZE_TASK; -export const ADD_RECOGNIZED_MEDIA_FILE_TOOL_NAME = ADD_RECOGNIZED_MEDIA_FILE; -export const END_RECOGNIZE_TASK_TOOL_NAME = END_RECOGNIZE_TASK; From b0a212a610d21659f09f1cba0e609bd9e744b4f5 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 7 Sep 2026 02:36:59 +0800 Subject: [PATCH 60/83] feat(cli): add create-recognize-episode-plan route and metadata.write auto-apply wiring --- apps/cli/server.ts | 2 + apps/cli/src/mcp/mcp.ts | 1 + .../src/route/RecognizeEpisodesPlan.test.ts | 114 ++++++++++++++++++ apps/cli/src/route/RecognizeEpisodesPlan.ts | 101 ++++++++++++++++ apps/cli/src/route/chatRoute.ts | 1 + apps/cli/src/test/ai-tool-registry.test.ts | 4 +- apps/core/src/ai-tool/registry.ts | 12 +- docs/api/index.md | 2 + 8 files changed, 225 insertions(+), 12 deletions(-) create mode 100644 apps/cli/src/route/RecognizeEpisodesPlan.test.ts create mode 100644 apps/cli/src/route/RecognizeEpisodesPlan.ts diff --git a/apps/cli/server.ts b/apps/cli/server.ts index 03be8d18..e6a1cece 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -45,6 +45,7 @@ import { handleDebugGetEpisodesToolRoute } from './src/route/debug/debugGetEpiso import { handleDebugIsFolderExistToolRoute } from './src/route/debug/debugIsFolderExistTool'; import { handlePlans } from './src/route/Plans'; import { handleRenameEpisodesPlan } from './src/route/RenameEpisodesPlan'; +import { handleRecognizeEpisodesPlan } from './src/route/RecognizeEpisodesPlan'; import { handleTryToRecognizeEpisodes } from './src/route/TryToRecognizeEpisodes'; import { handleGetFolders } from './src/route/GetFolders'; import { handleUnimportFolder } from './src/route/UnimportFolder'; @@ -300,6 +301,7 @@ export class Server { handleDebugIsFolderExistToolRoute(this.app); handlePlans(this.app); handleRenameEpisodesPlan(this.app); + handleRecognizeEpisodesPlan(this.app); handleTryToRecognizeEpisodes(this.app); handleGetFolders(this.app); handleUnimportFolder(this.app); diff --git a/apps/cli/src/mcp/mcp.ts b/apps/cli/src/mcp/mcp.ts index 356c1e7f..417bf3ad 100644 --- a/apps/cli/src/mcp/mcp.ts +++ b/apps/cli/src/mcp/mcp.ts @@ -149,6 +149,7 @@ async function buildMcpConfig(): Promise { toolDescriptions: await loadLocalizedToolDescriptions(), renameEpisodeFile: (input) => getCore().renameEpisodeFile(input), applyRenameEpisodePlan: (plan) => getCore().applyPlan(plan), + applyRecognizeEpisodePlan: (plan) => getCore().applyPlan(plan), scrapeFolder: (path, options) => getCore().scrapeFolder(path, options), getJob: (id) => getCore().getJob(id), searchInTmdb: (keyword, options) => getCore().searchInTmdb(keyword, options), diff --git a/apps/cli/src/route/RecognizeEpisodesPlan.test.ts b/apps/cli/src/route/RecognizeEpisodesPlan.test.ts new file mode 100644 index 00000000..60d35120 --- /dev/null +++ b/apps/cli/src/route/RecognizeEpisodesPlan.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Hono } from 'hono' + +const mocks = vi.hoisted(() => ({ + createRecognizeEpisodePlan: vi.fn(), + broadcast: vi.fn(), +})) + +vi.mock('../core/getCore', () => ({ + getCore: () => mocks, +})) + +vi.mock('@/utils/socketIO', () => ({ + broadcast: mocks.broadcast, +})) + +vi.mock('@/utils/config', async (importOriginal) => ({ + ...(await importOriginal()), + getAppDataDir: () => 'C:/smm-app-data', +})) + +import { handleRecognizeEpisodesPlan } from './RecognizeEpisodesPlan' + +const plan = { + id: 'plan-1', + task: 'recognize-media-file' as const, + status: 'pending' as const, + creator: 'ai' as const, + mediaFolderPath: '/media/Show', + files: [{ season: 1, episode: 1, path: '/media/Show/S01E01.mkv' }], +} + +describe('POST /api/create-recognize-episode-plan', () => { + let app: Hono + + beforeEach(() => { + mocks.createRecognizeEpisodePlan.mockReset() + mocks.broadcast.mockReset() + app = new Hono() + handleRecognizeEpisodesPlan(app) + }) + + async function post(body: unknown) { + return app.request('/api/create-recognize-episode-plan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + it('creates an AI plan through Core and broadcasts it', async () => { + mocks.createRecognizeEpisodePlan.mockResolvedValue(plan) + + const response = await post({ + mediaFolderPath: '/media/Show', + files: plan.files, + }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ data: { plan } }) + expect(mocks.createRecognizeEpisodePlan).toHaveBeenCalledWith( + '/media/Show', + plan.files, + { creator: 'ai' }, + ) + expect(mocks.broadcast).toHaveBeenCalledWith({ + event: 'recognizeMediaFilePlanReady', + data: { + taskId: 'plan-1', + planFilePath: '/C:/smm-app-data/plans/plan-1.plan.json', + }, + }) + }) + + it('creates an app plan without broadcasting it', async () => { + mocks.createRecognizeEpisodePlan.mockResolvedValue({ ...plan, creator: 'app' }) + + const response = await post({ + mediaFolderPath: '/media/Show', + files: plan.files, + creator: 'app', + }) + + expect(response.status).toBe(200) + expect(mocks.createRecognizeEpisodePlan).toHaveBeenCalledWith( + '/media/Show', + plan.files, + { creator: 'app' }, + ) + expect(mocks.broadcast).not.toHaveBeenCalled() + }) + + it('returns an Error Reason when mediaFolderPath is missing', async () => { + const response = await post({ files: plan.files }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + error: 'Error Reason: mediaFolderPath is required', + }) + expect(mocks.createRecognizeEpisodePlan).not.toHaveBeenCalled() + expect(mocks.broadcast).not.toHaveBeenCalled() + }) + + it('returns an Error Reason for invalid files', async () => { + const response = await post({ mediaFolderPath: '/media/Show', files: 'invalid' }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + error: 'Error Reason: files must be an array', + }) + expect(mocks.createRecognizeEpisodePlan).not.toHaveBeenCalled() + expect(mocks.broadcast).not.toHaveBeenCalled() + }) +}) diff --git a/apps/cli/src/route/RecognizeEpisodesPlan.ts b/apps/cli/src/route/RecognizeEpisodesPlan.ts new file mode 100644 index 00000000..8c27af62 --- /dev/null +++ b/apps/cli/src/route/RecognizeEpisodesPlan.ts @@ -0,0 +1,101 @@ +import type { Hono } from 'hono' +import { Path } from '@smm/utils/path' +import type { RecognizeMediaFilePlan } from '@smm/types/RecognizeMediaFilePlan' +import { + RecognizeMediaFilePlanReady, + type RecognizeMediaFilePlanReadyRequestData, +} from '@smm/types/event-types' +import { formatToolError } from '@smm/core/ai-tool/toolResult' +import { getCore } from '../core/getCore' +import { broadcast } from '@/utils/socketIO' +import { getAppDataDir } from '@/utils/config' +import { logger } from '../../lib/logger' + +export interface CreateRecognizeEpisodePlanRequestBody { + mediaFolderPath: string + files: Array<{ season: number; episode: number; path: string }> + creator?: 'ai' | 'app' +} + +export interface CreateRecognizeEpisodePlanResponseBody { + data?: { plan: RecognizeMediaFilePlan } + error?: string +} + +function readStringField(body: unknown, key: string): string | undefined { + if (typeof body !== 'object' || body === null || !(key in body)) return undefined + const value = (body as Record)[key] + return typeof value === 'string' ? value : undefined +} + +function readRecognizeFiles( + body: unknown, +): Array<{ season: number; episode: number; path: string }> | undefined { + if (typeof body !== 'object' || body === null || !('files' in body)) return undefined + const files = (body as Record).files + if (!Array.isArray(files)) return undefined + if ( + !files.every( + (file) => + typeof file === 'object' && + file !== null && + typeof (file as Record).season === 'number' && + typeof (file as Record).episode === 'number' && + typeof (file as Record).path === 'string', + ) + ) { + return undefined + } + return files as Array<{ season: number; episode: number; path: string }> +} + +export async function createRecognizeEpisodePlanFromBody( + body: unknown, +): Promise { + const mediaFolderPath = readStringField(body, 'mediaFolderPath') + if (!mediaFolderPath?.trim()) { + return { error: 'Error Reason: mediaFolderPath is required' } + } + + const files = readRecognizeFiles(body) + if (!files) { + return { error: 'Error Reason: files must be an array' } + } + + const creator = readStringField(body, 'creator') === 'app' ? 'app' : 'ai' + const plan = await getCore().createRecognizeEpisodePlan(mediaFolderPath, files, { creator }) + + if (creator === 'ai') { + const planFilePath = Path.posix(`${getAppDataDir()}/plans/${plan.id}.plan.json`) + const data: RecognizeMediaFilePlanReadyRequestData = { + taskId: plan.id, + planFilePath, + } + broadcast({ event: RecognizeMediaFilePlanReady.event, data }) + } + + return { data: { plan } } +} + +/** + * Recognize-episodes plan HTTP surface (single call): + * - POST /api/create-recognize-episode-plan → Core.createRecognizeEpisodePlan + * (apply/reject reuse POST /api/apply-plan and /api/reject-plan in RenameEpisodesPlan.ts) + */ +export function handleRecognizeEpisodesPlan(app: Hono): void { + app.post('/api/create-recognize-episode-plan', async (c) => { + try { + let body: unknown = {} + try { + body = await c.req.json() + } catch { + /* empty */ + } + return c.json(await createRecognizeEpisodePlanFromBody(body), 200) + } catch (error) { + logger.error({ error }, '[POST /api/create-recognize-episode-plan] route error') + const err: CreateRecognizeEpisodePlanResponseBody = formatToolError(error) + return c.json(err, 200) + } + }) +} diff --git a/apps/cli/src/route/chatRoute.ts b/apps/cli/src/route/chatRoute.ts index bc15413d..61ef9f0c 100644 --- a/apps/cli/src/route/chatRoute.ts +++ b/apps/cli/src/route/chatRoute.ts @@ -18,6 +18,7 @@ export function handleChatRequest(app: Hono, chatConfig: ChatConfig) { const response = await doChat(chatConfig, c.req.raw, { renameEpisodeFile: (input) => getCore().renameEpisodeFile(input), applyRenameEpisodePlan: (plan) => getCore().applyPlan(plan), + applyRecognizeEpisodePlan: (plan) => getCore().applyPlan(plan), scrapeFolder: (path, options) => getCore().scrapeFolder(path, options), getJob: (id) => getCore().getJob(id), tmdb: { diff --git a/apps/cli/src/test/ai-tool-registry.test.ts b/apps/cli/src/test/ai-tool-registry.test.ts index 0b22fcbe..a38370e9 100644 --- a/apps/cli/src/test/ai-tool-registry.test.ts +++ b/apps/cli/src/test/ai-tool-registry.test.ts @@ -68,9 +68,7 @@ const CONSTANT_NAME_TO_TOOL_NAME: Record = { TVDB_GET_TV_SHOW: 'tvdb-get-tv-show', TVDB_GET_LANGUAGES: 'tvdb-get-languages', CREATE_RENAME_EPISODE_PLAN: 'create-rename-episode-plan', - BEGIN_RECOGNIZE_TASK: 'begin-recognize-task', - ADD_RECOGNIZED_MEDIA_FILE: 'add-recognized-media-file', - END_RECOGNIZE_TASK: 'end-recognize-task', + CREATE_RECOGNIZE_EPISODE_PLAN: 'create-recognize-episode-plan', } function extractBackendToolNames(source: string): Set { diff --git a/apps/core/src/ai-tool/registry.ts b/apps/core/src/ai-tool/registry.ts index 43e4034a..22fd19b4 100644 --- a/apps/core/src/ai-tool/registry.ts +++ b/apps/core/src/ai-tool/registry.ts @@ -56,11 +56,7 @@ import { TVDB_GET_MOVIE } from '@smm/types/ai-tools/tvdbGetMovie' import { TVDB_GET_TV_SHOW } from '@smm/types/ai-tools/tvdbGetTvShow' import { TVDB_GET_LANGUAGES } from '@smm/types/ai-tools/tvdbGetLanguages' import { CREATE_RENAME_EPISODE_PLAN } from '@smm/types/ai-tools/createRenameEpisodePlan' -import { - BEGIN_RECOGNIZE_TASK, - ADD_RECOGNIZED_MEDIA_FILE, - END_RECOGNIZE_TASK, -} from '@smm/types/ai-tools/recognizeMediaFileTask' +import { CREATE_RECOGNIZE_EPISODE_PLAN } from '@smm/types/ai-tools/createRecognizeEpisodePlan' /** * Flags describing which transports a tool is exposed on. The LLM @@ -123,10 +119,8 @@ export const AI_TOOL_REGISTRY: readonly AiToolDescriptor[] = [ // Rename episode plan { name: CREATE_RENAME_EPISODE_PLAN, backend: true, frontend: true }, - // Recognize media file task - { name: BEGIN_RECOGNIZE_TASK, backend: true, frontend: true }, - { name: ADD_RECOGNIZED_MEDIA_FILE, backend: true, frontend: true }, - { name: END_RECOGNIZE_TASK, backend: true, frontend: true }, + // Recognize episode plan + { name: CREATE_RECOGNIZE_EPISODE_PLAN, backend: true, frontend: true }, ] as const /** diff --git a/docs/api/index.md b/docs/api/index.md index 3e1f4eb7..d7efcdb8 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -114,6 +114,8 @@ Source Code: apps/cli/src/cli/runCli.ts + apps/core Core.tryToRecognizeEpisodes Source Code: apps/cli/src/route/TryToRecognizeEpisodes.ts + apps/core Core.tryToRecognizeEpisodes HTTP: `POST /api/try-to-recognize-episodes` — rule-based episode recognition via Layer 2 `Core.tryToRecognizeEpisodes(path)` → pending `RecognizeMediaFilePlan` persisted under `{appDataDir}/plans/`. Request body: `{ mediaFolderPath: string }`. Response: `{ data: { plan } }` or `{ error }` (HTTP 200). Apply/reject reuse `POST /api/apply-plan` / `POST /api/reject-plan`; `apply-plan` honors `data.files` for `recognize-media-file` plans (applies only the selected `plan.files[].path` entries; unknown paths → 400 ProblemDetails). Product doc: [docs/dev/recognize-episodes.md](../dev/recognize-episodes.md). +HTTP: `POST /api/create-recognize-episode-plan` — single-call AI/HTTP episode recognition via Layer 2 `Core.createRecognizeEpisodePlan(mediaFolderPath, files, { creator })`. Request body: `{ mediaFolderPath: string, files: Array<{ season: number, episode: number, path: string }>, creator?: "ai" | "app" }`. Response: `{ data: { plan } }` or `{ error }` (HTTP 200). When `creator` is `"ai"` (default), broadcasts the `recognizeMediaFilePlanReady` Socket.IO event with `{ taskId, planFilePath }`; apply/reject reuse `POST /api/apply-plan` / `POST /api/reject-plan`. + ## CLI: scrape Source Code: apps/cli/src/cli/runCli.ts + apps/core Core.scrapeFolder `smm scrape [--language ]` — scrape TMDB TV poster, fanart, episode thumbnails, and NFO files for a managed TV show folder. Prints each task as `poster|fanart|thumbnails|nfo: completed|skipped|failed`. Requires TMDB metadata and linked episodes (for thumbnails / episode NFO). From 46d356d48a7a410340632a85875f0eb23458d005 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 7 Sep 2026 02:48:58 +0800 Subject: [PATCH 61/83] refactor: update recognize prompts to single-call create-recognize-episode-plan tool --- apps/cli/src/mcp/mcp.ts | 10 ++-------- .../src/tools/howToRecognizeEpisodeVideoFiles.ts | 4 +--- apps/core/src/ai-tool/systemPrompt.test.ts | 16 ++++++++-------- apps/core/src/ai-tool/systemPrompt.ts | 10 ++-------- 4 files changed, 13 insertions(+), 27 deletions(-) diff --git a/apps/cli/src/mcp/mcp.ts b/apps/cli/src/mcp/mcp.ts index 417bf3ad..dccfd5f8 100644 --- a/apps/cli/src/mcp/mcp.ts +++ b/apps/cli/src/mcp/mcp.ts @@ -10,6 +10,7 @@ import { GET_MEDIA_METADATA } from "@smm/types/ai-tools/getMediaMetadata"; import { RENAME_FOLDER } from "@smm/types/ai-tools/renameFolder"; import { RENAME_EPISODE_FILE } from "@smm/types/ai-tools/renameEpisodeFile"; import { CREATE_RENAME_EPISODE_PLAN } from "@smm/types/ai-tools/createRenameEpisodePlan"; +import { CREATE_RECOGNIZE_EPISODE_PLAN } from "@smm/types/ai-tools/createRecognizeEpisodePlan"; import { SCRAPE } from "@smm/types/ai-tools/scrape"; import { GET_JOB } from "@smm/types/ai-tools/getJob"; import { TMDB_SEARCH } from "@smm/types/ai-tools/tmdbSearch"; @@ -19,11 +20,6 @@ import { TVDB_SEARCH } from "@smm/types/ai-tools/tvdbSearch"; import { TVDB_GET_MOVIE } from "@smm/types/ai-tools/tvdbGetMovie"; import { TVDB_GET_TV_SHOW } from "@smm/types/ai-tools/tvdbGetTvShow"; import { TVDB_GET_LANGUAGES } from "@smm/types/ai-tools/tvdbGetLanguages"; -import { - BEGIN_RECOGNIZE_TASK, - ADD_RECOGNIZED_MEDIA_FILE, - END_RECOGNIZE_TASK, -} from "@smm/types/ai-tools/recognizeMediaFileTask"; import { GET_EPISODES } from "@smm/types/ai-tools/getEpisodes"; import { getAppDataDir, getUserDataDir } from "@/utils/config"; import { acknowledge, broadcast } from "@/utils/socketIO"; @@ -52,11 +48,9 @@ const TOOL_NAME_KEYS = [ RENAME_FOLDER, RENAME_EPISODE_FILE, CREATE_RENAME_EPISODE_PLAN, + CREATE_RECOGNIZE_EPISODE_PLAN, SCRAPE, GET_JOB, - BEGIN_RECOGNIZE_TASK, - ADD_RECOGNIZED_MEDIA_FILE, - END_RECOGNIZE_TASK, GET_EPISODES, GET_EPISODE_KEY, HOW_TO_RENAME_KEY, diff --git a/apps/cli/src/tools/howToRecognizeEpisodeVideoFiles.ts b/apps/cli/src/tools/howToRecognizeEpisodeVideoFiles.ts index cda8c77c..6960f87d 100644 --- a/apps/cli/src/tools/howToRecognizeEpisodeVideoFiles.ts +++ b/apps/cli/src/tools/howToRecognizeEpisodeVideoFiles.ts @@ -22,9 +22,7 @@ AI助手应该参考以下步骤: 2. 使用 get-metadata 工具获得媒体文件夹的媒体元数据. 该工具返回该文件夹的媒体类型, TMDB ID, 季集信息等. 你需要为每一季每一集识别对应的本地视频文件 3. 使用 list-files 工具(设置 videoFileOnly=true)并查询媒体文件夹中的所有视频文件 -2. 使用 "begin-recognize-task" 工具开始识别任务, 指定媒体文件夹路径 -3. 使用 "add-recognized-media-file" 工具添加已识别的视频文件 -4. 使用 "end-recognize-task" 工具结束识别任务, SMM 将处理识别计划 +4. 使用 "create-recognize-episode-plan" 工具一次性提交识别计划, 指定媒体文件夹路径和所有视频文件的 season/episode/path 映射 **NOTE** 识别任务完成后, SMM 会在后台处理识别计划, 用户可以在 SMM UI 中查看和确认识别结果. `; diff --git a/apps/core/src/ai-tool/systemPrompt.test.ts b/apps/core/src/ai-tool/systemPrompt.test.ts index c5bdd205..eeaf9ed2 100644 --- a/apps/core/src/ai-tool/systemPrompt.test.ts +++ b/apps/core/src/ai-tool/systemPrompt.test.ts @@ -5,11 +5,7 @@ import { GET_MEDIA_METADATA } from '@smm/types/ai-tools/getMediaMetadata' import { GET_EPISODES } from '@smm/types/ai-tools/getEpisodes' import { LIST_FILES_IN_MEDIA_FOLDER } from '@smm/types/ai-tools/listFilesInMediaFolder' import { CREATE_RENAME_EPISODE_PLAN } from '@smm/types/ai-tools/createRenameEpisodePlan' -import { - BEGIN_RECOGNIZE_TASK, - ADD_RECOGNIZED_MEDIA_FILE, - END_RECOGNIZE_TASK, -} from '@smm/types/ai-tools/recognizeMediaFileTask' +import { CREATE_RECOGNIZE_EPISODE_PLAN } from '@smm/types/ai-tools/createRecognizeEpisodePlan' import { SCRAPE } from '@smm/types/ai-tools/scrape' import { GET_JOB } from '@smm/types/ai-tools/getJob' import { TMDB_SEARCH } from '@smm/types/ai-tools/tmdbSearch' @@ -36,9 +32,7 @@ describe('systemPrompt', () => { GET_EPISODES, LIST_FILES_IN_MEDIA_FOLDER, CREATE_RENAME_EPISODE_PLAN, - BEGIN_RECOGNIZE_TASK, - ADD_RECOGNIZED_MEDIA_FILE, - END_RECOGNIZE_TASK, + CREATE_RECOGNIZE_EPISODE_PLAN, SCRAPE, GET_JOB, TMDB_SEARCH, @@ -59,6 +53,12 @@ describe('systemPrompt', () => { expect(SYSTEM_PROMPT).not.toContain('end-rename-files-task') }) + it('does not reference deprecated begin/add/end recognize task tools', () => { + expect(SYSTEM_PROMPT).not.toContain('begin-recognize-task') + expect(SYSTEM_PROMPT).not.toContain('add-recognized-media-file') + expect(SYSTEM_PROMPT).not.toContain('end-recognize-task') + }) + it('does not reference any kebab-case token that is not a known tool', () => { // Spot-check that we have not introduced stale tool names // (e.g. an old "get-selected-media-metadata" that the LLM diff --git a/apps/core/src/ai-tool/systemPrompt.ts b/apps/core/src/ai-tool/systemPrompt.ts index 34a21d2e..259dd568 100644 --- a/apps/core/src/ai-tool/systemPrompt.ts +++ b/apps/core/src/ai-tool/systemPrompt.ts @@ -1,9 +1,5 @@ import { CREATE_RENAME_EPISODE_PLAN } from '@smm/types/ai-tools/createRenameEpisodePlan' -import { - ADD_RECOGNIZED_MEDIA_FILE, - BEGIN_RECOGNIZE_TASK, - END_RECOGNIZE_TASK, -} from '@smm/types/ai-tools/recognizeMediaFileTask' +import { CREATE_RECOGNIZE_EPISODE_PLAN } from '@smm/types/ai-tools/createRecognizeEpisodePlan' import { GET_APPLICATION_CONTEXT } from '@smm/types/ai-tools/getApplicationContext' import { GET_MEDIA_METADATA } from '@smm/types/ai-tools/getMediaMetadata' import { GET_EPISODES } from '@smm/types/ai-tools/getEpisodes' @@ -54,10 +50,8 @@ Below is the steps to recognize media file: If user don't tell which folder he is asking for, you should call "${GET_APPLICATION_CONTEXT}" to get the selected media folder in UI. 2. Get episodes using "${GET_EPISODES}" tool 3. Get local files using "${LIST_FILES_IN_MEDIA_FOLDER}" tool -4. Call "${BEGIN_RECOGNIZE_TASK}" tool to notify AI Agent to start a recognize task -5. iterate each episodes, find the local video file for the episode, and call "${ADD_RECOGNIZED_MEDIA_FILE}" tool to add the recognized media file to the task +4. Call "${CREATE_RECOGNIZE_EPISODE_PLAN}" once with mediaFolderPath and a files array of season/episode/path pairs for every recognized video file IMPORTANT: It's OK to skip the episode if the local video file is not found. -6. Call "${END_RECOGNIZE_TASK}" tool to notify AI Agent to end the recognize task ### Rename Files From 9c54b851fc0de4ba6487f56781bc0f16c30c03d2 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 7 Sep 2026 02:54:43 +0800 Subject: [PATCH 62/83] refactor(ui): replace recognize 3-step chat tools with single-call createRecognizeEpisodePlan --- apps/ui/src/ai/Assistant.registry.test.ts | 4 +- apps/ui/src/ai/Assistant.tsx | 8 +- apps/ui/src/ai/plan/aiPlanDrafts.ts | 25 ----- apps/ui/src/ai/plan/cleanupRenamePlan.ts | 5 - apps/ui/src/ai/plan/recognizePlanService.ts | 31 ------ .../src/ai/tools/AddRecognizedMediaFile.tsx | 98 ------------------- apps/ui/src/ai/tools/BeginRecognizeTask.tsx | 43 -------- .../ai/tools/CreateRecognizeEpisodePlan.tsx | 41 ++++++++ apps/ui/src/ai/tools/EndRecognizeTask.tsx | 77 --------------- apps/ui/src/ai/tools/index.ts | 4 +- apps/ui/src/api/createRecognizeEpisodePlan.ts | 32 ++++++ .../components/tv/TvShowPanelUtils.test.ts | 3 - apps/ui/src/hooks/plans/index.ts | 1 - .../src/hooks/plans/useCreatePlanMutation.ts | 76 -------------- .../tv/useAiBasedRecognizeEpisodeFlow.test.ts | 9 +- .../tv/useAiBasedRecognizeEpisodeFlow.ts | 3 - .../tv/useAiBasedRenameEpisodeFlow.test.ts | 12 +-- .../hooks/tv/useAiBasedRenameEpisodeFlow.ts | 3 - 18 files changed, 80 insertions(+), 395 deletions(-) delete mode 100644 apps/ui/src/ai/plan/aiPlanDrafts.ts delete mode 100644 apps/ui/src/ai/plan/cleanupRenamePlan.ts delete mode 100644 apps/ui/src/ai/plan/recognizePlanService.ts delete mode 100644 apps/ui/src/ai/tools/AddRecognizedMediaFile.tsx delete mode 100644 apps/ui/src/ai/tools/BeginRecognizeTask.tsx create mode 100644 apps/ui/src/ai/tools/CreateRecognizeEpisodePlan.tsx delete mode 100644 apps/ui/src/ai/tools/EndRecognizeTask.tsx create mode 100644 apps/ui/src/api/createRecognizeEpisodePlan.ts delete mode 100644 apps/ui/src/hooks/plans/useCreatePlanMutation.ts diff --git a/apps/ui/src/ai/Assistant.registry.test.ts b/apps/ui/src/ai/Assistant.registry.test.ts index c8a020f4..100218dd 100644 --- a/apps/ui/src/ai/Assistant.registry.test.ts +++ b/apps/ui/src/ai/Assistant.registry.test.ts @@ -30,9 +30,7 @@ const ASSISTANT_PATH = join(__dirname, 'Assistant.tsx') const FRONTEND_TRANSPORT_ONLY_TASK_COMPONENTS = new Set([ 'CreateRenameEpisodePlanTool', - 'BeginRecognizeTaskTool', - 'AddRecognizedMediaFileTool', - 'EndRecognizeTaskTool', + 'CreateRecognizeEpisodePlanTool', ]) interface MountedTool { diff --git a/apps/ui/src/ai/Assistant.tsx b/apps/ui/src/ai/Assistant.tsx index c81fd3f3..c38494da 100644 --- a/apps/ui/src/ai/Assistant.tsx +++ b/apps/ui/src/ai/Assistant.tsx @@ -25,9 +25,7 @@ import { TmdbSearchTool, TmdbGetMovieTool, TmdbGetTvShowTool, - BeginRecognizeTaskTool, - AddRecognizedMediaFileTool, - EndRecognizeTaskTool, + CreateRecognizeEpisodePlanTool, CreateRenameEpisodePlanTool, } from "./tools"; import { AIBasedConfirmationBridge } from "./AIBasedConfirmationBridge"; @@ -330,9 +328,7 @@ function AssistantImpl() { plan creation (orphan id 1 + LLM id 2). */} {useFrontendTransport && ( <> - - - + )} diff --git a/apps/ui/src/ai/plan/aiPlanDrafts.ts b/apps/ui/src/ai/plan/aiPlanDrafts.ts deleted file mode 100644 index 9885b695..00000000 --- a/apps/ui/src/ai/plan/aiPlanDrafts.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Plan } from "@/api/getPlans" - -/** - * In-memory drafts for the *frontend* AI tool path - * (`ReverseProxyChatTransport`). The unified `/api/updatePlan` endpoint - * replaces a plan's `files` wholesale, so the browser-side `add-*` - * tools accumulate entries here between `begin` and `end` instead of - * round-tripping the full plan each call. - * - * The backend (cli/MCP) path does NOT use this — it appends directly to - * the plan file via `@smm/core-routes`. - */ -const drafts = new Map() - -export function setPlanDraft(plan: Plan): void { - drafts.set(plan.id, plan) -} - -export function getPlanDraft(id: string): Plan | null { - return drafts.get(id) ?? null -} - -export function deletePlanDraft(id: string): void { - drafts.delete(id) -} diff --git a/apps/ui/src/ai/plan/cleanupRenamePlan.ts b/apps/ui/src/ai/plan/cleanupRenamePlan.ts deleted file mode 100644 index 5206a069..00000000 --- a/apps/ui/src/ai/plan/cleanupRenamePlan.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { deletePlanDraft } from './aiPlanDrafts' - -export async function cleanupRenamePlan(planId: string): Promise { - deletePlanDraft(planId) -} diff --git a/apps/ui/src/ai/plan/recognizePlanService.ts b/apps/ui/src/ai/plan/recognizePlanService.ts deleted file mode 100644 index 2f1d73b8..00000000 --- a/apps/ui/src/ai/plan/recognizePlanService.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { RecognizeMediaFilePlan } from '@smm/types/RecognizeMediaFilePlan' -import { getPlanById } from '@/api/getPlanById' -import { getPlanDraft, setPlanDraft } from './aiPlanDrafts' - -/** - * Resolve a recognize-media-file plan from the in-memory draft, or - * rehydrate it from disk when the draft was lost (page refresh, mixed - * frontend/backend tool execution). - */ -export async function resolveRecognizePlanDraft( - planId: string, -): Promise { - const normalizedId = planId.trim() - const draft = getPlanDraft(normalizedId) - if (draft?.task === 'recognize-media-file') { - return draft - } - - const resp = await getPlanById(normalizedId) - if ( - resp.error || - !resp.data?.plan || - resp.data.plan.task !== 'recognize-media-file' - ) { - return null - } - - const plan = resp.data.plan as RecognizeMediaFilePlan - setPlanDraft(plan) - return plan -} diff --git a/apps/ui/src/ai/tools/AddRecognizedMediaFile.tsx b/apps/ui/src/ai/tools/AddRecognizedMediaFile.tsx deleted file mode 100644 index 92eb66f5..00000000 --- a/apps/ui/src/ai/tools/AddRecognizedMediaFile.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { makeAssistantTool, tool } from "@assistant-ui/react" -import { z } from "zod" -import { Path } from "@smm/utils/path" -import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan" -import { updatePlan } from "@/api/updatePlan" -import { checkFileExists } from "@/lib/utils" -import { setPlanDraft } from "../plan/aiPlanDrafts" -import { resolveRecognizePlanDraft } from "../plan/recognizePlanService" - -/** - * Frontend AI tool: `add-recognized-media-file`. - * - * Appends a `{ season, episode, path }` entry to a recognize-media-file - * plan created by `beginRecognizeTask`. The plan is persisted on the - * backend via the unified `/api/updatePlan` endpoint; entries are - * accumulated in the in-memory draft store between `begin` and `end`. - * - * The path is validated against the filesystem before being added so - * the AI cannot silently queue a non-existent file for the user to - * confirm later. The check uses {@link checkFileExists} (HTTP-backed - * `listFiles` on the cli) so it works in both the browser-only mode - * and the Electron desktop app. - */ -const addRecognizedMediaFile = tool({ - description: - "Add a recognized media file to a recognition task. " + - "This tool adds a single video file to an existing task created " + - "by beginRecognizeTask. " + - "Provide the task ID, season number, episode number, and file " + - "path.", - parameters: z.object({ - taskId: z - .string() - .describe("The task ID returned from beginRecognizeTask."), - season: z.number().describe("The season number of the episode."), - episode: z.number().describe("The episode number."), - path: z - .string() - .describe( - "The absolute path of the media file " + - "(POSIX or Windows format).", - ), - }), - execute: async ({ taskId, season, episode, path: filePath }) => { - if (!taskId || typeof taskId !== "string" || taskId.trim() === "") { - return { error: "Invalid taskId: must be a non-empty string" } - } - if (!filePath || typeof filePath !== "string" || filePath.trim() === "") { - return { error: "Invalid path: 'path' must be a non-empty string" } - } - - try { - const normalizedFilePath = Path.posix(filePath) - - // Reject non-existent files before they enter the plan. The - // filesystem check is HTTP-backed via the cli `/api/listFiles` - // endpoint, so the AI sees a failed tool call instead of a - // silent queue-up that the user has to unwind at confirmation. - const exists = await checkFileExists(normalizedFilePath) - if (!exists) { - return { - error: - `Error Reason: File "${normalizedFilePath}" (S${season}E${episode}) does not exist in the media folder. ` + - `Call "list-files-in-media-folder" tool to discover the actual file paths inside the folder before calling add-recognized-media-file again.`, - } - } - - const plan = await resolveRecognizePlanDraft(taskId) - if (!plan) { - return { - error: `Error Reason: Task with id "${taskId.trim()}" not found`, - } - } - - const files = [ - ...plan.files, - { season, episode, path: normalizedFilePath }, - ] - const resp = await updatePlan(taskId.trim(), { files }) - if (resp.error || !resp.data) { - return { error: resp.error ?? "updatePlan failed" } - } - setPlanDraft(resp.data.plan as RecognizeMediaFilePlan) - return { error: undefined } - } catch (error) { - return { - error: `Error Reason: ${ - error instanceof Error ? error.message : "Unknown error" - }`, - } - } - }, -}) - -export const AddRecognizedMediaFileTool = makeAssistantTool({ - ...addRecognizedMediaFile, - toolName: "add-recognized-media-file", -}) diff --git a/apps/ui/src/ai/tools/BeginRecognizeTask.tsx b/apps/ui/src/ai/tools/BeginRecognizeTask.tsx deleted file mode 100644 index 41fa0660..00000000 --- a/apps/ui/src/ai/tools/BeginRecognizeTask.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { makeAssistantTool, tool } from "@assistant-ui/react" -import { - BEGIN_RECOGNIZE_TASK, - BEGIN_RECOGNIZE_TASK_DESCRIPTION, - beginRecognizeTaskInputSchema, -} from "@smm/types/ai-tools/recognizeMediaFileTask" -import { formatToolError, requireNonEmptyString, toolOk } from "@smm/core/ai-tool/toolResult" -import { createPlan } from "@/api/createPlan" -import { PLANS_QUERY_ROOT } from "@/hooks/plans" -import { queryClient } from "@/lib/queryClient" -import { setPlanDraft } from "../plan/aiPlanDrafts" - -const beginRecognizeTask = tool({ - description: BEGIN_RECOGNIZE_TASK_DESCRIPTION, - parameters: beginRecognizeTaskInputSchema, - execute: async ({ mediaFolderPath }) => { - const pathCheck = requireNonEmptyString(mediaFolderPath, "mediaFolderPath") - if (typeof pathCheck !== "string") { - return { taskId: undefined, ...pathCheck } - } - - try { - const resp = await createPlan({ - task: "recognize-media-file", - mediaFolderPath: pathCheck, - creator: "ai", - }) - if (resp.error || !resp.data) { - return { taskId: undefined, error: resp.error ?? "createPlan failed" } - } - setPlanDraft(resp.data.plan) - void queryClient.invalidateQueries({ queryKey: [PLANS_QUERY_ROOT] }) - return toolOk({ taskId: resp.data.plan.id }) - } catch (error) { - return { taskId: undefined, ...formatToolError(error) } - } - }, -}) - -export const BeginRecognizeTaskTool = makeAssistantTool({ - ...beginRecognizeTask, - toolName: BEGIN_RECOGNIZE_TASK, -}) diff --git a/apps/ui/src/ai/tools/CreateRecognizeEpisodePlan.tsx b/apps/ui/src/ai/tools/CreateRecognizeEpisodePlan.tsx new file mode 100644 index 00000000..f1ac3a03 --- /dev/null +++ b/apps/ui/src/ai/tools/CreateRecognizeEpisodePlan.tsx @@ -0,0 +1,41 @@ +import { makeAssistantTool, tool } from '@assistant-ui/react' +import { + CREATE_RECOGNIZE_EPISODE_PLAN, + CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION, + createRecognizeEpisodePlanInputSchema, +} from '@smm/types/ai-tools/createRecognizeEpisodePlan' +import { END_PLAN_TASK_SUCCESS_MESSAGE } from '@smm/types/ai-tools/planTaskMessages' +import { formatToolError, toolOk } from '@smm/core/ai-tool/toolResult' +import { createRecognizeEpisodePlanApi } from '@/api/createRecognizeEpisodePlan' +import { PLANS_QUERY_ROOT } from '@/hooks/plans' +import { queryClient } from '@/lib/queryClient' + +const createRecognizeEpisodePlan = tool({ + description: CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION, + parameters: createRecognizeEpisodePlanInputSchema, + execute: async ({ mediaFolderPath, files }) => { + try { + const resp = await createRecognizeEpisodePlanApi({ + mediaFolderPath, + files, + creator: 'ai', + }) + if (resp.error || !resp.data) { + return { error: resp.error ?? 'Error Reason: Plan creation returned no data' } + } + + await queryClient.invalidateQueries({ queryKey: [PLANS_QUERY_ROOT] }) + return toolOk({ + message: END_PLAN_TASK_SUCCESS_MESSAGE, + taskId: resp.data.plan.id, + }) + } catch (error) { + return formatToolError(error) + } + }, +}) + +export const CreateRecognizeEpisodePlanTool = makeAssistantTool({ + ...createRecognizeEpisodePlan, + toolName: CREATE_RECOGNIZE_EPISODE_PLAN, +}) diff --git a/apps/ui/src/ai/tools/EndRecognizeTask.tsx b/apps/ui/src/ai/tools/EndRecognizeTask.tsx deleted file mode 100644 index d91057ff..00000000 --- a/apps/ui/src/ai/tools/EndRecognizeTask.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { makeAssistantTool, tool } from "@assistant-ui/react" -import { z } from 'zod' -import { END_PLAN_TASK_SUCCESS_MESSAGE } from "@smm/types/ai-tools/planTaskMessages" -import { toolOk } from "@smm/core/ai-tool/toolResult" -import { updatePlan } from "@/api/updatePlan" -import { queryClient } from "@/lib/queryClient" -import { PLANS_QUERY_ROOT } from "@/hooks/plans" -import { deletePlanDraft } from "../plan/aiPlanDrafts" -import { resolveRecognizePlanDraft } from "../plan/recognizePlanService" - -/** - * Frontend AI tool: `end-recognize-task`. - * - * Finalizes a recognize-media-file task: flips the backend plan from - * `preparing` to `pending` (so it becomes visible to the UI via - * `/api/getPlans`) and invalidates the plans query so the recognition - * prompt opens. The plan lives on the backend (created via - * `/api/createPlan`); the in-memory draft is dropped. - */ -const endRecognizeTask = tool({ - description: - "End a recognition task and execute the recognition. " + - "This tool finalizes the task created by beginRecognizeTask and " + - "processes all added media files.", - parameters: z.object({ - taskId: z - .string() - .describe("The task ID returned from beginRecognizeTask."), - }), - execute: async ({ taskId }) => { - if (!taskId || typeof taskId !== "string" || taskId.trim() === "") { - return { error: "Invalid taskId: must be a non-empty string" } - } - - try { - const plan = await resolveRecognizePlanDraft(taskId) - if (!plan) { - return { error: `Error Reason: Task with id "${taskId.trim()}" not found` } - } - if (plan.files.length === 0) { - return { error: "Error Reason: No recognized files in task" } - } - - const resp = await updatePlan(taskId.trim(), { status: "pending" }) - if (resp.error) { - return { error: resp.error } - } - - deletePlanDraft(taskId.trim()) - await queryClient.invalidateQueries({ queryKey: [PLANS_QUERY_ROOT] }) - - return toolOk({ message: END_PLAN_TASK_SUCCESS_MESSAGE }) - } catch (error) { - return { - error: `Error Reason: ${ - error instanceof Error ? error.message : "Unknown error" - }`, - } - } - }, -}) - -export const EndRecognizeTaskTool = makeAssistantTool({ - ...endRecognizeTask, - toolName: "end-recognize-task", -}) - -/** - * Drop the in-memory draft for a plan that has been confirmed or - * rejected. Kept for compatibility with the UI confirm/reject code - * paths; the backend file is removed when the plan reaches a terminal - * status via `/api/updatePlan`. - */ -// eslint-disable-next-line react-refresh/only-export-components -export async function cleanupRecognizePlan(planId: string): Promise { - deletePlanDraft(planId) -} diff --git a/apps/ui/src/ai/tools/index.ts b/apps/ui/src/ai/tools/index.ts index 56fae39d..86ad80fc 100644 --- a/apps/ui/src/ai/tools/index.ts +++ b/apps/ui/src/ai/tools/index.ts @@ -11,7 +11,5 @@ export { GetJobTool } from './GetJob'; export { TmdbSearchTool } from './TmdbSearch'; export { TmdbGetMovieTool } from './TmdbGetMovie'; export { TmdbGetTvShowTool } from './TmdbGetTvShow'; -export { BeginRecognizeTaskTool } from './BeginRecognizeTask'; -export { AddRecognizedMediaFileTool } from './AddRecognizedMediaFile'; -export { EndRecognizeTaskTool, cleanupRecognizePlan } from './EndRecognizeTask'; +export { CreateRecognizeEpisodePlanTool } from './CreateRecognizeEpisodePlan'; export { CreateRenameEpisodePlanTool } from './CreateRenameEpisodePlan'; diff --git a/apps/ui/src/api/createRecognizeEpisodePlan.ts b/apps/ui/src/api/createRecognizeEpisodePlan.ts new file mode 100644 index 00000000..11089e6b --- /dev/null +++ b/apps/ui/src/api/createRecognizeEpisodePlan.ts @@ -0,0 +1,32 @@ +import type { PlanCreator } from '@smm/types/planCommon' +import type { RecognizeMediaFilePlan } from '@smm/types/RecognizeMediaFilePlan' +import { apiFetch } from '@/lib/apiFetch' + +export interface CreateRecognizeEpisodePlanRequest { + mediaFolderPath: string + files: Array<{ season: number; episode: number; path: string }> + creator: PlanCreator +} + +export interface CreateRecognizeEpisodePlanResponseBody { + data?: { plan: RecognizeMediaFilePlan } + error?: string +} + +export async function createRecognizeEpisodePlanApi( + request: CreateRecognizeEpisodePlanRequest, + signal?: AbortSignal, +): Promise { + const resp = await apiFetch('/api/create-recognize-episode-plan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal, + }) + + if (!resp.ok) { + throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`) + } + + return (await resp.json()) as CreateRecognizeEpisodePlanResponseBody +} diff --git a/apps/ui/src/components/tv/TvShowPanelUtils.test.ts b/apps/ui/src/components/tv/TvShowPanelUtils.test.ts index 68cab887..dee453f4 100644 --- a/apps/ui/src/components/tv/TvShowPanelUtils.test.ts +++ b/apps/ui/src/components/tv/TvShowPanelUtils.test.ts @@ -10,9 +10,6 @@ import { toast } from 'sonner' vi.mock('@/api/readFile') vi.mock('@/lib/nfo') -vi.mock('@/ai/tools/EndRecognizeTask', () => ({ - cleanupRecognizePlan: vi.fn(() => Promise.resolve()), -})) vi.mock('@/lib/recognizeEpisodesUi', async (importOriginal) => { const mod = await importOriginal() diff --git a/apps/ui/src/hooks/plans/index.ts b/apps/ui/src/hooks/plans/index.ts index 287555c7..e4124055 100644 --- a/apps/ui/src/hooks/plans/index.ts +++ b/apps/ui/src/hooks/plans/index.ts @@ -1,7 +1,6 @@ export { PLANS_QUERY_ROOT, plansQueryKey } from "./plansQueryKeys" export { usePlansPullOnVisible } from "./usePlansPullOnVisible" export { usePlansQuery } from "./usePlansQuery" -export { useCreatePlanMutation } from "./useCreatePlanMutation" export { useUpdatePlanMutation, toUpdatePlanPatch, diff --git a/apps/ui/src/hooks/plans/useCreatePlanMutation.ts b/apps/ui/src/hooks/plans/useCreatePlanMutation.ts deleted file mode 100644 index 6eac9b93..00000000 --- a/apps/ui/src/hooks/plans/useCreatePlanMutation.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query" -import { Path } from "@smm/utils/path" -import { createPlan, type CreatePlanRequest } from "@/api/createPlan" -import type { Plan } from "@/api/getPlans" -import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" -import { plansQueryKey } from "./plansQueryKeys" - -function buildOptimisticPlan(request: CreatePlanRequest): Plan { - const id = - request.id ?? - (typeof crypto !== "undefined" && "randomUUID" in crypto - ? crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(16).slice(2)}`) - const mediaFolderPath = Path.posix(request.mediaFolderPath) - if (request.task === "recognize-media-file") { - return { - id, - task: "recognize-media-file", - status: "preparing", - creator: request.creator, - mediaFolderPath, - files: [], - } - } - return { - id, - task: "rename-files", - status: "preparing", - creator: request.creator, - mediaFolderPath, - files: [], - } -} - -/** - * Create a `preparing` plan with an optimistic cache insert. The - * returned `createPlanOptimistic` resolves to the created plan so - * callers can immediately compute + `useUpdatePlanMutation` it. - */ -export function useCreatePlanMutation() { - const queryClient = useQueryClient() - - const mutation = useMutation({ - mutationFn: async (request: CreatePlanRequest): Promise => { - const resp = await createPlan(request) - if (resp.error || !resp.data) { - throw new Error(resp.error ?? "createPlan: empty response") - } - return resp.data.plan - }, - }) - - const createPlanOptimistic = async ( - request: CreatePlanRequest, - ): Promise => { - const folderPosix = normalizeMediaFolderPathForQuery(request.mediaFolderPath) - const key = plansQueryKey(folderPosix) - const optimistic = buildOptimisticPlan(request) - const previous = queryClient.getQueryData(key) - - queryClient.setQueryData(key, (prev) => [...(prev ?? []), optimistic]) - - try { - const created = await mutation.mutateAsync({ ...request, id: optimistic.id }) - queryClient.setQueryData(key, (prev) => - (prev ?? []).map((p) => (p.id === optimistic.id ? created : p)), - ) - return created - } catch (error) { - queryClient.setQueryData(key, previous) - throw error - } - } - - return { ...mutation, createPlanOptimistic } -} diff --git a/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts b/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts index 65871240..830f2be9 100644 --- a/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts +++ b/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts @@ -7,7 +7,6 @@ import type { MediaMetadata } from "@smm/types" const h = vi.hoisted(() => ({ plans: [] as unknown[], updatePlanMutateAsync: vi.fn(), - cleanupRecognizePlan: vi.fn(), handleAiRecognizeConfirm: vi.fn(), })) @@ -25,10 +24,6 @@ vi.mock("@/actions/handleAiRecognizeConfirm", () => ({ handleAiRecognizeConfirm: h.handleAiRecognizeConfirm, })) -vi.mock("@/ai/tools/EndRecognizeTask", () => ({ - cleanupRecognizePlan: h.cleanupRecognizePlan, -})) - describe("useAiBasedRecognizeEpisodeFlow", () => { const mediaFolderPath = "/storage/Users/currentUser/Download/Anime/show" const mediaMetadata = { mediaFolderPath, type: "tvshow-folder" } as MediaMetadata @@ -90,10 +85,9 @@ describe("useAiBasedRecognizeEpisodeFlow", () => { expect.any(Function), expect.any(Function), ) - expect(h.cleanupRecognizePlan).toHaveBeenCalledWith("plan-1") }) - it("rejects and cleans up the plan on cancel", async () => { + it("rejects the plan on cancel", async () => { h.plans = [pendingAiPlan] const { result } = renderHook(() => useAiBasedRecognizeEpisodeFlow({ @@ -109,6 +103,5 @@ describe("useAiBasedRecognizeEpisodeFlow", () => { mediaFolderPath, patch: { status: "rejected" }, }) - expect(h.cleanupRecognizePlan).toHaveBeenCalledWith("plan-1") }) }) diff --git a/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.ts b/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.ts index fa0d1192..b37b59f8 100644 --- a/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.ts +++ b/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.ts @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo } from "react" import { toast } from "sonner" import { handleAiRecognizeConfirm } from "@/actions/handleAiRecognizeConfirm" -import { cleanupRecognizePlan } from "@/ai/tools/EndRecognizeTask" import { selectActiveAiPlan } from "@/components/tv/plans/selectActiveAppPlan" import { toUpdatePlanPatch, usePlansQuery, useUpdatePlanMutation } from "@/hooks/plans" import { useUpdateMediaMetadataMutation } from "@/hooks/mediaMetadata/useUpdateMediaMetadataMutation" @@ -67,7 +66,6 @@ export function useAiBasedRecognizeEpisodeFlow({ }) }, ) - await cleanupRecognizePlan(plan.id) }, [ plan, mediaMetadata, @@ -84,7 +82,6 @@ export function useAiBasedRecognizeEpisodeFlow({ mediaFolderPath, patch: toUpdatePlanPatch({ status: "rejected" }), }) - await cleanupRecognizePlan(plan.id) } catch (error) { console.error("[useAiBasedRecognizeEpisodeFlow] Error rejecting recognize plan:", error) toast.error( diff --git a/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts b/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts index 7e8e5a54..8c2dd65c 100644 --- a/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts +++ b/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts @@ -8,7 +8,6 @@ const h = vi.hoisted(() => ({ plans: [] as unknown[], updatePlanMutateAsync: vi.fn(), applyPlanMutateAsync: vi.fn(), - cleanupRenamePlan: vi.fn(), toastError: vi.fn(), })) @@ -32,10 +31,6 @@ vi.mock("./useTvShowWebSocketEvents", () => ({ useTvShowWebSocketEvents: () => undefined, })) -vi.mock("@/ai/plan/cleanupRenamePlan", () => ({ - cleanupRenamePlan: h.cleanupRenamePlan, -})) - describe("useAiBasedRenameEpisodeFlow", () => { const mediaFolderPath = "/storage/Users/currentUser/Download/Anime/show" const mediaMetadata = { mediaFolderPath, type: "tvshow-folder" } as MediaMetadata @@ -87,7 +82,7 @@ describe("useAiBasedRenameEpisodeFlow", () => { expect(result.current.promptProps.isOpen).toBe(false) }) - it("rejects and cleans up the plan on cancel", async () => { + it("rejects the plan on cancel", async () => { h.plans = [pendingAiPlan] const { result } = renderHook(() => useAiBasedRenameEpisodeFlow({ mediaMetadata }), @@ -100,10 +95,9 @@ describe("useAiBasedRenameEpisodeFlow", () => { mediaFolderPath, patch: { status: "rejected" }, }) - expect(h.cleanupRenamePlan).toHaveBeenCalledWith("rename-plan-1") }) - it("applies the full plan on confirm and cleans up the draft", async () => { + it("applies the full plan on confirm", async () => { h.plans = [pendingAiPlan] const { result } = renderHook(() => useAiBasedRenameEpisodeFlow({ mediaMetadata }), @@ -115,7 +109,6 @@ describe("useAiBasedRenameEpisodeFlow", () => { id: "rename-plan-1", mediaFolderPath, }) - expect(h.cleanupRenamePlan).toHaveBeenCalledWith("rename-plan-1") }) it("shows a toast and keeps the plan when apply fails", async () => { @@ -130,6 +123,5 @@ describe("useAiBasedRenameEpisodeFlow", () => { expect(h.toastError).toHaveBeenCalledWith( expect.stringContaining("Failed to apply rename plan"), ) - expect(h.cleanupRenamePlan).not.toHaveBeenCalled() }) }) diff --git a/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.ts b/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.ts index ed1185ad..9dd26915 100644 --- a/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.ts +++ b/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo } from "react" import { toast } from "sonner" -import { cleanupRenamePlan } from "@/ai/plan/cleanupRenamePlan" import { selectActiveAiPlan } from "@/components/tv/plans/selectActiveAppPlan" import { useTvShowWebSocketEvents } from "./useTvShowWebSocketEvents" import { @@ -67,7 +66,6 @@ export function useAiBasedRenameEpisodeFlow({ if (!plan || !mediaFolderPath) return try { await applyPlanMutation.mutateAsync({ id: plan.id, mediaFolderPath }) - await cleanupRenamePlan(plan.id) } catch (error) { console.error("[useAiBasedRenameEpisodeFlow] Error applying rename plan:", error) toast.error( @@ -84,7 +82,6 @@ export function useAiBasedRenameEpisodeFlow({ mediaFolderPath, patch: toUpdatePlanPatch({ status: "rejected" }), }) - await cleanupRenamePlan(plan.id) } catch (error) { console.error("[useAiBasedRenameEpisodeFlow] Error rejecting rename plan:", error) toast.error( From 376a730e569e27c5d45dd5def7f831a2ac950814 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 7 Sep 2026 03:06:18 +0800 Subject: [PATCH 63/83] refactor(ui): remove promptStatus and preparing UI after single-call recognize migration --- apps/ui/public/locales/en/components.json | 2 -- apps/ui/public/locales/zh-CN/components.json | 2 -- apps/ui/public/locales/zh-HK/components.json | 2 -- apps/ui/public/locales/zh-TW/components.json | 2 -- .../tv/AiBasedRecognizeEpisodePrompt.tsx | 35 +++---------------- .../tv/AiBasedRenameEpisodePrompt.tsx | 35 +++---------------- .../tv/plans/selectActiveAppPlan.test.ts | 19 ++++++++++ .../tv/plans/selectActiveAppPlan.ts | 15 ++++++-- .../tv/useAiBasedRecognizeEpisodeFlow.test.ts | 4 +-- .../tv/useAiBasedRecognizeEpisodeFlow.ts | 7 +--- .../tv/useAiBasedRenameEpisodeFlow.test.ts | 14 ++------ .../hooks/tv/useAiBasedRenameEpisodeFlow.ts | 7 +--- apps/ui/src/types/i18next.d.ts | 2 -- 13 files changed, 46 insertions(+), 100 deletions(-) diff --git a/apps/ui/public/locales/en/components.json b/apps/ui/public/locales/en/components.json index 8e66a90e..704020c4 100644 --- a/apps/ui/public/locales/en/components.json +++ b/apps/ui/public/locales/en/components.json @@ -66,10 +66,8 @@ "cancel": "Cancel", "selectPlaceholder": "Select...", "generating": "Generating...", - "aiGenerating": "AI is generating file names...", "aiRenaming": "AI is renaming episodes, please wait...", "aiReview": "AI is going to rename episodes, please review...", - "aiRecognizing": "AI is recognizing episodes...", "aiReviewEpisodes": "Review recognized episodes", "recognizePrompt": "Is it {{tvShowTitle}} ({{tvShowTmdbId}})?", "recognizeReviewPrompt": "Please review", diff --git a/apps/ui/public/locales/zh-CN/components.json b/apps/ui/public/locales/zh-CN/components.json index 72a7a4f0..4a583996 100644 --- a/apps/ui/public/locales/zh-CN/components.json +++ b/apps/ui/public/locales/zh-CN/components.json @@ -66,10 +66,8 @@ "cancel": "取消", "selectPlaceholder": "选择...", "generating": "生成中...", - "aiGenerating": "AI正在生成文件名...", "aiRenaming": "AI正在重命名剧集,请稍候...", "aiReview": "AI将重命名剧集,请查看...", - "aiRecognizing": "AI正在识别剧集...", "aiReviewEpisodes": "查看已识别的剧集", "recognizePrompt": "可能是 {{tvShowTitle}} ({{tvShowTmdbId}})?", "recognizeReviewPrompt": "请核对", diff --git a/apps/ui/public/locales/zh-HK/components.json b/apps/ui/public/locales/zh-HK/components.json index 400851aa..384c3140 100644 --- a/apps/ui/public/locales/zh-HK/components.json +++ b/apps/ui/public/locales/zh-HK/components.json @@ -64,10 +64,8 @@ "cancel": "取消", "selectPlaceholder": "選擇...", "generating": "生成中...", - "aiGenerating": "AI正在生成檔案名稱...", "aiRenaming": "AI正在重新命名劇集,請稍候...", "aiReview": "AI將重新命名劇集,請查看...", - "aiRecognizing": "AI正在識別劇集...", "aiReviewEpisodes": "檢視已識別的劇集", "recognizePrompt": "可能是 {{tvShowTitle}} ({{tvShowTmdbId}})?", "recognizeReviewPrompt": "請核對", diff --git a/apps/ui/public/locales/zh-TW/components.json b/apps/ui/public/locales/zh-TW/components.json index d9e5ce42..afd31534 100644 --- a/apps/ui/public/locales/zh-TW/components.json +++ b/apps/ui/public/locales/zh-TW/components.json @@ -64,10 +64,8 @@ "cancel": "取消", "selectPlaceholder": "選擇...", "generating": "生成中...", - "aiGenerating": "AI正在生成檔案名稱...", "aiRenaming": "AI正在重新命名劇集,請稍候...", "aiReview": "AI將重新命名劇集,請查看...", - "aiRecognizing": "AI正在識別劇集...", "aiReviewEpisodes": "檢視已識別的劇集", "recognizePrompt": "可能是 {{tvShowTitle}} ({{tvShowTmdbId}})?", "recognizeReviewPrompt": "請核對", diff --git a/apps/ui/src/components/tv/AiBasedRecognizeEpisodePrompt.tsx b/apps/ui/src/components/tv/AiBasedRecognizeEpisodePrompt.tsx index 812e03fb..e1eab89d 100644 --- a/apps/ui/src/components/tv/AiBasedRecognizeEpisodePrompt.tsx +++ b/apps/ui/src/components/tv/AiBasedRecognizeEpisodePrompt.tsx @@ -1,23 +1,15 @@ import { FloatingPrompt, type FloatingPromptProps } from "../FloatingPrompt" -import { Loader2 } from "lucide-react" import { cn } from "@/lib/utils" import { useTranslation } from "@/lib/i18n" export interface AiBasedRecognizeEpisodePromptProps extends Omit { - /** - * Status of the AI recognition operation - * - "generating": AI is generating output - * - "wait-for-ack": Waiting for user to confirm - */ - status: "generating" | "wait-for-ack" } /** * AiBasedRecognizeEpisodePrompt component built on top of FloatingPrompt. - * Used to show AI episode recognition status with status indicators. + * Used to confirm AI episode recognition operations. */ export function AiBasedRecognizeEpisodePrompt({ - status, onConfirm, onCancel, isOpen = false, @@ -30,21 +22,6 @@ export function AiBasedRecognizeEpisodePrompt({ }: AiBasedRecognizeEpisodePromptProps) { const { t } = useTranslation('components') - // Map status to FloatingPrompt's status prop - const floatingPromptStatus = status === "generating" ? "running" : "wait-for-ack" - - // The cancel button (mapped to isConfirmDisabled in FloatingPrompt) - // must stay enabled during "generating" so the user can stop a stuck - // preparing-state plan. Only the confirm button is disabled while - // the AI is still preparing results. - const isConfirmButtonDisabledFinal = isConfirmButtonDisabled || status === "generating" - const isConfirmDisabledFinal = isConfirmDisabled - - // Get status message - const statusMessage = status === "generating" - ? t('toolbar.aiRecognizing', { defaultValue: 'AI is recognizing episodes...' }) - : t('toolbar.aiReviewEpisodes', { defaultValue: 'Review recognized episodes' }) - return (
- {status === "generating" && ( - - )} - {statusMessage} + {t('toolbar.aiReviewEpisodes', { defaultValue: 'Review recognized episodes' })}
diff --git a/apps/ui/src/components/tv/AiBasedRenameEpisodePrompt.tsx b/apps/ui/src/components/tv/AiBasedRenameEpisodePrompt.tsx index 561bec2d..bb59032c 100644 --- a/apps/ui/src/components/tv/AiBasedRenameEpisodePrompt.tsx +++ b/apps/ui/src/components/tv/AiBasedRenameEpisodePrompt.tsx @@ -1,23 +1,15 @@ import { FloatingPrompt, type FloatingPromptProps } from "../FloatingPrompt" -import { Loader2 } from "lucide-react" import { cn } from "@/lib/utils" import { useTranslation } from "@/lib/i18n" export interface AiBasedRenameEpisodePromptProps extends Omit { - /** - * Status of the AI renaming operation - * - "generating": AI is generating output - * - "wait-for-ack": Waiting for user to confirm - */ - status: "generating" | "wait-for-ack" } /** * AiBasedRenameEpisodePrompt component built on top of FloatingPrompt. - * Used to confirm AI episode renaming operations with status indicators. + * Used to confirm AI episode renaming operations. */ export function AiBasedRenameEpisodePrompt({ - status, onConfirm, onCancel, isOpen = false, @@ -30,21 +22,6 @@ export function AiBasedRenameEpisodePrompt({ }: AiBasedRenameEpisodePromptProps) { const { t } = useTranslation('components') - // Map status to FloatingPrompt's status prop - const floatingPromptStatus = status === "generating" ? "running" : "wait-for-ack" - - // The cancel button (mapped to isConfirmDisabled in FloatingPrompt) - // must stay enabled during "generating" so the user can stop a stuck - // preparing-state plan. Only the confirm button is disabled while - // the AI is still preparing results. - const isConfirmButtonDisabledFinal = isConfirmButtonDisabled || status === "generating" - const isConfirmDisabledFinal = isConfirmDisabled - - // Get status message - const statusMessage = status === "generating" - ? t('toolbar.aiGenerating', { defaultValue: 'AI is generating file names...' }) - : t('toolbar.aiReview', { defaultValue: 'Review AI-generated file names' }) - return (
- {status === "generating" && ( - - )} - {statusMessage} + {t('toolbar.aiReview', { defaultValue: 'Review AI-generated file names' })}
diff --git a/apps/ui/src/components/tv/plans/selectActiveAppPlan.test.ts b/apps/ui/src/components/tv/plans/selectActiveAppPlan.test.ts index fae3cd04..c40ddc47 100644 --- a/apps/ui/src/components/tv/plans/selectActiveAppPlan.test.ts +++ b/apps/ui/src/components/tv/plans/selectActiveAppPlan.test.ts @@ -104,6 +104,25 @@ describe("selectActiveAiPlan", () => { ).toBeUndefined() }) + it("ignores preparing AI plans (preparing no longer surfaces)", () => { + expect( + selectActiveAiPlan( + [ + { + id: "rename-ai-preparing", + task: "rename-files", + status: "preparing", + creator: "ai", + mediaFolderPath: "/media/show", + files: [], + }, + ], + "/media/show", + "rename-files", + ), + ).toBeUndefined() + }) + it("returns active AI recognize plan", () => { expect( selectActiveAiPlan( diff --git a/apps/ui/src/components/tv/plans/selectActiveAppPlan.ts b/apps/ui/src/components/tv/plans/selectActiveAppPlan.ts index 5df9b3f3..f366f9ff 100644 --- a/apps/ui/src/components/tv/plans/selectActiveAppPlan.ts +++ b/apps/ui/src/components/tv/plans/selectActiveAppPlan.ts @@ -35,14 +35,25 @@ export function selectActiveAppPlan( } /** - * Active (`preparing` / `pending`) AI/MCP-created plan for a media folder. + * Active (`pending`) AI/MCP-created plan for a media folder. * Used by AI rename/recognize flows; preview mode and prompt visibility * derive from the returned plan the same way as rule-based plans. + * Only `pending` plans surface: since the single-call recognize migration + * no code creates `preparing` AI plans, so a preparing plan must not open + * the prompt. */ export function selectActiveAiPlan( plans: Plan[], mediaFolderPath: string | undefined, task: PlanTask, ): T | undefined { - return selectActivePlanByCreator(plans, mediaFolderPath, task, "ai") + if (!mediaFolderPath) return undefined + + return plans.find( + (p) => + p.task === task && + p.creator === "ai" && + p.status === "pending" && + mediaFolderPathEqual(p.mediaFolderPath, mediaFolderPath), + ) as T | undefined } diff --git a/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts b/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts index 830f2be9..0678ee4b 100644 --- a/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts +++ b/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts @@ -50,9 +50,9 @@ describe("useAiBasedRecognizeEpisodeFlow", () => { ) expect(result.current.plan?.id).toBe("plan-1") - expect(result.current.promptStatus).toBe("wait-for-ack") + expect(result.current.promptStatus).toBeUndefined() expect(result.current.promptProps.isOpen).toBe(true) - expect(result.current.promptProps.status).toBe("wait-for-ack") + expect(result.current.promptProps).not.toHaveProperty("status") }) it("ignores plans of other media folders", () => { diff --git a/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.ts b/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.ts index b37b59f8..0ffcef17 100644 --- a/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.ts +++ b/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.ts @@ -48,9 +48,6 @@ export function useAiBasedRecognizeEpisodeFlow({ [plans, mediaFolderPath], ) - const promptStatus: "generating" | "wait-for-ack" = - plan?.status === "preparing" ? "generating" : "wait-for-ack" - const onConfirm = useCallback(async () => { if (!plan || !mediaMetadata?.mediaFolderPath) return const preparedPlan = beforeConfirm(plan) as RecognizeMediaFilePlan @@ -99,18 +96,16 @@ export function useAiBasedRecognizeEpisodeFlow({ const promptProps = useMemo((): AiBasedRecognizeEpisodePromptProps => ({ isOpen: plan !== undefined, - status: promptStatus, onConfirm: () => { void onConfirm() }, onCancel: () => { void onCancel() }, - }), [plan, promptStatus, onConfirm, onCancel]) + }), [plan, onConfirm, onCancel]) return { plan, - promptStatus, onConfirm, onCancel, promptProps, diff --git a/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts b/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts index 8c2dd65c..7826e75c 100644 --- a/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts +++ b/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts @@ -57,19 +57,9 @@ describe("useAiBasedRenameEpisodeFlow", () => { ) expect(result.current.plan?.id).toBe("rename-plan-1") - expect(result.current.promptStatus).toBe("wait-for-ack") + expect(result.current.promptStatus).toBeUndefined() expect(result.current.promptProps.isOpen).toBe(true) - expect(result.current.promptProps.status).toBe("wait-for-ack") - }) - - it("maps a preparing plan to the generating prompt status", () => { - h.plans = [{ ...pendingAiPlan, status: "preparing" }] - const { result } = renderHook(() => - useAiBasedRenameEpisodeFlow({ mediaMetadata }), - ) - - expect(result.current.promptStatus).toBe("generating") - expect(result.current.promptProps.status).toBe("generating") + expect(result.current.promptProps).not.toHaveProperty("status") }) it("ignores plans of other media folders", () => { diff --git a/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.ts b/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.ts index 9dd26915..0b44533b 100644 --- a/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.ts +++ b/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.ts @@ -48,9 +48,6 @@ export function useAiBasedRenameEpisodeFlow({ [plans, mediaFolderPath], ) - const promptStatus: "generating" | "wait-for-ack" = - plan?.status === "preparing" ? "generating" : "wait-for-ack" - useEffect(() => { console.log( `[rename] useAiBasedRenameEpisodeFlow: plan=${plan ? `id=${plan.id} status=${plan.status}` : "undefined"}, ` + @@ -109,18 +106,16 @@ export function useAiBasedRenameEpisodeFlow({ const promptProps = useMemo((): AiBasedRenameEpisodePromptProps => ({ isOpen: plan !== undefined, - status: promptStatus, onConfirm: () => { void onConfirm() }, onCancel: () => { void onCancel() }, - }), [plan, promptStatus, onConfirm, onCancel]) + }), [plan, onConfirm, onCancel]) return { plan, - promptStatus, onConfirm, onCancel, promptProps, diff --git a/apps/ui/src/types/i18next.d.ts b/apps/ui/src/types/i18next.d.ts index 62e2dc1f..b482393d 100644 --- a/apps/ui/src/types/i18next.d.ts +++ b/apps/ui/src/types/i18next.d.ts @@ -96,10 +96,8 @@ interface ComponentsResources { cancel: string selectPlaceholder: string generating: string - aiGenerating: string aiRenaming: string aiReview: string - aiRecognizing: string aiReviewEpisodes: string reviewRecognizeEpisodes: string useNfoMetadata: string From 386dc6e0152d53eeaa5b091b082b4643b4243531 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 7 Sep 2026 03:16:18 +0800 Subject: [PATCH 64/83] chore: cleanup stale promptStatus references after recognize migration --- apps/ui/src/components/tv/TvShowPanel.test.tsx | 6 ++---- apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts | 2 +- apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/ui/src/components/tv/TvShowPanel.test.tsx b/apps/ui/src/components/tv/TvShowPanel.test.tsx index 023d4672..32ed6049 100644 --- a/apps/ui/src/components/tv/TvShowPanel.test.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.test.tsx @@ -170,20 +170,18 @@ vi.mock("@/hooks/tv/useRuleBasedRenameFilesFlow", () => ({ vi.mock("@/hooks/tv/useAiBasedRenameEpisodeFlow", () => ({ useAiBasedRenameEpisodeFlow: () => ({ plan: undefined, - promptStatus: "generating", onConfirm: vi.fn(), onCancel: vi.fn(), - promptProps: { isOpen: false, status: "generating", onConfirm: vi.fn(), onCancel: vi.fn() }, + promptProps: { isOpen: false, onConfirm: vi.fn(), onCancel: vi.fn() }, }), })) vi.mock("@/hooks/tv/useAiBasedRecognizeEpisodeFlow", () => ({ useAiBasedRecognizeEpisodeFlow: () => ({ plan: undefined, - promptStatus: "generating", onConfirm: vi.fn(), onCancel: vi.fn(), - promptProps: { isOpen: false, status: "generating", onConfirm: vi.fn(), onCancel: vi.fn() }, + promptProps: { isOpen: false, onConfirm: vi.fn(), onCancel: vi.fn() }, }), })) diff --git a/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts b/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts index 0678ee4b..52a77621 100644 --- a/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts +++ b/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts @@ -50,7 +50,7 @@ describe("useAiBasedRecognizeEpisodeFlow", () => { ) expect(result.current.plan?.id).toBe("plan-1") - expect(result.current.promptStatus).toBeUndefined() + expect(result.current).not.toHaveProperty("promptStatus") expect(result.current.promptProps.isOpen).toBe(true) expect(result.current.promptProps).not.toHaveProperty("status") }) diff --git a/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts b/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts index 7826e75c..2bf8bbd7 100644 --- a/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts +++ b/apps/ui/src/hooks/tv/useAiBasedRenameEpisodeFlow.test.ts @@ -57,7 +57,7 @@ describe("useAiBasedRenameEpisodeFlow", () => { ) expect(result.current.plan?.id).toBe("rename-plan-1") - expect(result.current.promptStatus).toBeUndefined() + expect(result.current).not.toHaveProperty("promptStatus") expect(result.current.promptProps.isOpen).toBe(true) expect(result.current.promptProps).not.toHaveProperty("status") }) From 0352822c61ef5c8965f9a805d6167326ca23abd6 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 7 Sep 2026 03:29:07 +0800 Subject: [PATCH 65/83] fix(core-routes): update recognize how-to text and rebuild dist bundle --- packages/core-routes/dist/core-routes.cjs | 4317 ++++++++++------- packages/core-routes/dist/core-routes.js | 50 +- .../src/mcp/toolHandlers/staticText.ts | 6 +- .../tools/createRecognizeEpisodePlan.test.ts | 8 +- packages/core-routes/src/tools/plans.ts | 4 +- 5 files changed, 2519 insertions(+), 1866 deletions(-) diff --git a/packages/core-routes/dist/core-routes.cjs b/packages/core-routes/dist/core-routes.cjs index 6fc6369f..141fa220 100644 --- a/packages/core-routes/dist/core-routes.cjs +++ b/packages/core-routes/dist/core-routes.cjs @@ -161,19 +161,19 @@ var require_token_io = __commonJS((exports2, module2) => { getUserDataDir: () => getUserDataDir }); module2.exports = __toCommonJS2(token_io_exports); - var import_path = __toESM2(require("path")); + var import_path2 = __toESM2(require("path")); var import_fs = __toESM2(require("fs")); var import_os = __toESM2(require("os")); var import_token_error = require_token_error(); function findRootDir() { try { let dir = process.cwd(); - while (dir !== import_path.default.dirname(dir)) { - const pkgPath = import_path.default.join(dir, ".vercel"); + while (dir !== import_path2.default.dirname(dir)) { + const pkgPath = import_path2.default.join(dir, ".vercel"); if (import_fs.default.existsSync(pkgPath)) { return dir; } - dir = import_path.default.dirname(dir); + dir = import_path2.default.dirname(dir); } } catch (e) { throw new import_token_error.VercelOidcTokenError("Token refresh only supported in node server environments"); @@ -186,9 +186,9 @@ var require_token_io = __commonJS((exports2, module2) => { } switch (import_os.default.platform()) { case "darwin": - return import_path.default.join(import_os.default.homedir(), "Library/Application Support"); + return import_path2.default.join(import_os.default.homedir(), "Library/Application Support"); case "linux": - return import_path.default.join(import_os.default.homedir(), ".local/share"); + return import_path2.default.join(import_os.default.homedir(), ".local/share"); case "win32": if (process.env.LOCALAPPDATA) { return process.env.LOCALAPPDATA; @@ -31443,6 +31443,8 @@ var exports_src = {}; __export(exports_src, { validateUpstreamBaseURL: () => validateUpstreamBaseURL, validatePathIsInAllowlist: () => validatePathIsInAllowlist, + stopMcpServerWithUserConfig: () => stopMcpServerWithUserConfig, + startMcpServerWithUserConfig: () => startMcpServerWithUserConfig, resolveWebUiBindAddress: () => resolveWebUiBindAddress, resolveReverseProxyBindAddress: () => resolveReverseProxyBindAddress, resolveReverseProxyAdvertisedHost: () => resolveReverseProxyAdvertisedHost, @@ -31451,6 +31453,7 @@ __export(exports_src, { resolveFolderExistence: () => resolveFolderExistence, rejectUnauthorized: () => rejectUnauthorized, registerCoreRoutes: () => registerCoreRoutes, + parseStartOptionsFromBody: () => parseStartOptionsFromBody, parseBearerToken: () => parseBearerToken, migrateAIConfig: () => migrateAIConfig, isRequestAuthorized: () => isRequestAuthorized, @@ -31464,13 +31467,17 @@ __export(exports_src, { handleReadFilePost: () => handleReadFilePost, handleProxyRequest: () => handleProxyRequest, handleMcpStopPut: () => handleMcpStopPut, + handleMcpStopPost: () => handleMcpStopPost, handleMcpStatusGet: () => handleMcpStatusGet, handleMcpStartPut: () => handleMcpStartPut, + handleMcpStartPost: () => handleMcpStartPost, + handleMcpGetServerStatusGet: () => handleMcpGetServerStatusGet, handleListFilesPost: () => handleListFilesPost, handleListFilesInMediaFolderPost: () => handleListFilesInMediaFolderPost, handleListFilesGet: () => handleListFilesGet, handleIsFolderAvailablePost: () => handleIsFolderAvailablePost, handleHelloPost: () => handleHelloPost, + handleHelloGet: () => handleHelloGet, handleGetPlansPost: () => handleGetPlansPost, handleGetEpisodesPost: () => handleGetEpisodesPost, handleDownloadImageGet: () => handleDownloadImageGet, @@ -31481,9 +31488,12 @@ __export(exports_src, { handleCreatePlanPost: () => handleCreatePlanPost, handleCoreRoutesRequest: () => handleCoreRoutesRequest, handleChatPost: () => handleChatPost, + getMcpServerStatusWithUserConfig: () => getMcpServerStatusWithUserConfig, findAvailableReverseProxyPort: () => findAvailableReverseProxyPort, filterResponseHeaders: () => filterResponseHeaders, filterRequestHeaders: () => filterRequestHeaders, + executeScrape: () => executeScrape, + executeGetJob: () => executeGetJob, enforceCoreRoutesAuth: () => enforceCoreRoutesAuth, doWriteFile: () => doWriteFile, doUpdatePlan: () => doUpdatePlan, @@ -31517,6 +31527,7 @@ __export(exports_src, { createReverseProxyManager: () => createReverseProxyManager, createProxiedFetch: () => createProxiedFetch, createOpenAICompatible: () => createOpenAICompatible, + createNodeRenameFileExistenceProbe: () => createNodeRenameFileExistenceProbe, createNodeHttpFetch: () => createNodeHttpFetch, createMcpStreamableHttpHandler: () => createMcpStreamableHttpHandler, createErrorResponse: () => createErrorResponse, @@ -31528,6 +31539,8 @@ __export(exports_src, { checkFolderPathAvailable: () => checkFolderPathAvailable, checkFileIsReadable: () => checkFileIsReadable, buildUpstreamUrl: () => buildUpstreamUrl, + buildScrapeTool: () => buildScrapeTool, + buildGetJobTool: () => buildGetJobTool, applyMcpLifecycleFromConfig: () => applyMcpLifecycleFromConfig, PORT_RANGE_START: () => PORT_RANGE_START, PORT_RANGE_END: () => PORT_RANGE_END, @@ -31536,6 +31549,8 @@ __export(exports_src, { ExistedFileError: () => ExistedFileError, EMPTY_DISCOVER_CONFIG: () => EMPTY_DISCOVER_CONFIG, DISCOVER_TIMEOUT_MS: () => DISCOVER_TIMEOUT_MS, + DEFAULT_MCP_PORT: () => DEFAULT_MCP_PORT, + DEFAULT_MCP_HOST: () => DEFAULT_MCP_HOST, DEFAULT_DISCOVER_CONFIG_URL: () => DEFAULT_DISCOVER_CONFIG_URL, DEFAULT_ALLOWED_UPSTREAM_HOSTS: () => DEFAULT_ALLOWED_UPSTREAM_HOSTS }); @@ -31553,7 +31568,8 @@ function sendJson(res, status, body) { const payload = JSON.stringify(body); res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", - "Content-Length": Buffer.byteLength(payload, "utf8") + "Content-Length": Buffer.byteLength(payload, "utf8"), + "Cache-Control": "no-store" }); res.end(payload); } @@ -31624,6 +31640,316 @@ function isRequestAuthorized(authorizationHeader, auth) { function validatePathIsInAllowlist(filePath, allowlist) { return allowlist.some((allowlistItem) => filePath.startsWith(allowlistItem)); } +// src/nodeRenameFileExistenceProbe.ts +var import_promises = require("node:fs/promises"); +// ../../node_modules/.pnpm/es-toolkit@1.44.0/node_modules/es-toolkit/dist/array/flatten.mjs +function flatten(arr, depth = 1) { + const result = []; + const flooredDepth = Math.floor(depth); + const recursive = (arr2, currentDepth) => { + for (let i = 0;i < arr2.length; i++) { + const item = arr2[i]; + if (Array.isArray(item) && currentDepth < flooredDepth) { + recursive(item, currentDepth + 1); + } else { + result.push(item); + } + } + }; + recursive(arr, 0); + return result; +} + +// ../../node_modules/.pnpm/es-toolkit@1.44.0/node_modules/es-toolkit/dist/array/flattenDeep.mjs +function flattenDeep(arr) { + return flatten(arr, Infinity); +} +// ../../node_modules/.pnpm/es-toolkit@1.44.0/node_modules/es-toolkit/dist/array/last.mjs +function last(arr) { + return arr[arr.length - 1]; +} +// ../../node_modules/.pnpm/slash@5.1.0/node_modules/slash/index.js +function slash(path) { + const isExtendedLengthPath = path.startsWith("\\\\?\\"); + if (isExtendedLengthPath) { + return path; + } + return path.replace(/\\/g, "/"); +} + +// ../../node_modules/.pnpm/filename-reserved-regex@4.0.0/node_modules/filename-reserved-regex/index.js +function filenameReservedRegex() { + return /[<>:"/\\|?*\u0000-\u001F]|[. ]$/g; +} +function windowsReservedNameRegex() { + return /^(con|prn|aux|nul|com\d|lpt\d)$/i; +} + +// ../../node_modules/.pnpm/filenamify@7.0.1/node_modules/filenamify/filenamify.js +var MAX_FILENAME_LENGTH = 100; +var reRelativePath = /^\.+(\\|\/)|^\.+$/; +var reTrailingDotsAndSpaces = /[. ]+$/; +var reControlChars = /[\p{Control}\p{Format}\p{Zl}\p{Zp}\uFFF0-\uFFFF]/gu; +var reControlCharsTest = /[\p{Control}\p{Format}\p{Zl}\p{Zp}\uFFF0-\uFFFF]/u; +var isZeroWidthJoiner = (char) => char === "‍"; +var reRepeatedReservedCharacters = /([<>:"/\\|?*\u0000-\u001F]){2,}/g; +var reReplacementReservedCharacters = /[<>:"/\\|?*\u0000-\u001F]/; +var reUnicodeWhitespace = /[\t\n\r\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]+/g; +var segmenter; +function getSegmenter() { + segmenter ??= new Intl.Segmenter(undefined, { granularity: "grapheme" }); + return segmenter; +} +function truncateFilename(filename, maxLength) { + if (filename.length <= maxLength) { + return filename; + } + const extensionIndex = filename.lastIndexOf("."); + if (extensionIndex === -1) { + return truncateByGraphemeBudget(filename, maxLength); + } + const base = filename.slice(0, extensionIndex); + const extension = filename.slice(extensionIndex); + const baseBudget = Math.max(0, maxLength - extension.length); + const truncatedBase = truncateByGraphemeBudget(base, baseBudget); + return truncatedBase.replace(/ +$/, "") + extension; +} +function filenamify(string, options = {}) { + if (typeof string !== "string") { + throw new TypeError("Expected a string"); + } + const replacement = options.replacement ?? "!"; + const hasReservedChars = reReplacementReservedCharacters.test(replacement); + const hasControlChars = [...replacement].some((char) => reControlCharsTest.test(char) && !isZeroWidthJoiner(char)); + if (hasReservedChars || hasControlChars) { + throw new Error("Replacement string cannot contain reserved filename characters"); + } + string = string.normalize("NFC"); + string = string.replaceAll(reUnicodeWhitespace, " "); + if (replacement.length > 0) { + string = string.replaceAll(reRepeatedReservedCharacters, "$1"); + } + string = string.replace(reTrailingDotsAndSpaces, ""); + string = string.replace(reRelativePath, replacement); + string = string.replace(filenameReservedRegex(), replacement); + string = string.replaceAll(reControlChars, (char) => isZeroWidthJoiner(char) ? char : replacement); + string = string.replace(reTrailingDotsAndSpaces, ""); + if (string.length === 0) { + string = replacement.replace(reTrailingDotsAndSpaces, ""); + if (string.length === 0 && replacement.length > 0) { + string = "!"; + } + } + const allowedLength = typeof options.maxLength === "number" ? options.maxLength : MAX_FILENAME_LENGTH; + string = truncateFilename(string, allowedLength); + string = string.replace(reTrailingDotsAndSpaces, ""); + if (windowsReservedNameRegex().test(string)) { + string += replacement; + } + return string; +} +function truncateByGraphemeBudget(input, budget) { + if (input.length <= budget) { + return input; + } + let count = 0; + let output = ""; + for (const { segment } of getSegmenter().segment(input)) { + const next = count + segment.length; + if (next > budget) { + break; + } + output += segment; + count = next; + } + return output; +} +// ../utils/src/path.ts +var WIN_PATH_SEPARATOR = "\\"; +var POSIX_PATH_SEPARATOR = "/"; +function isNotEmpty(part) { + return part.trim() !== ""; +} +function split(path) { + let parts = path.split(":\\").filter(isNotEmpty); + parts = flattenDeep(parts.map((part) => part.split("\\").filter(isNotEmpty))); + parts = flattenDeep(parts.map((part) => part.split("/").filter(isNotEmpty))); + return parts; +} + +class Path { + static serverPlatform = null; + root; + sub; + unc; + constructor(root, sub) { + if (root.trim() === "") { + throw new Error("InvalidArgumentError: root path cannot be empty"); + } + if (sub !== undefined) { + if (split(sub).length === 0) { + if (sub.length === 0) { + throw new Error("InvalidArgumentError: sub path cannot be empty"); + } else { + throw new Error("InvalidArgumentError: invalid sub path"); + } + } + } + if (sub?.trim() === "") { + throw new Error("InvalidArgumentError: sub path cannot be empty"); + } + this.unc = root.startsWith("\\\\"); + if (!(root.startsWith("/") || /^[A-Za-z]:/.test(root) || root.startsWith("\\\\"))) { + throw new Error(`InvalidArgumentError: root=${root}. root path must start with "/" for POSIX format, "C:" for Windows format, or "\\\\" for Windows UNC format`); + } + this.root = split(root); + this.sub = sub === undefined ? [] : split(sub); + if (this.root.length === 0) { + throw new Error("InvalidArgumentError: invalid root path"); + } + } + _uncPath() { + const serverName = this.root[0]; + const parentPath = this.root.slice(1).join(WIN_PATH_SEPARATOR); + const subPath = this.sub.length === 0 ? "" : WIN_PATH_SEPARATOR + this.sub.join(WIN_PATH_SEPARATOR); + return `\\\\${serverName}\\${parentPath}${subPath}`; + } + abs(type = "posix") { + if (type === "win") { + if (this.unc) { + return this._uncPath(); + } else { + if (this.root[0]?.length !== 1) { + return this._uncPath(); + } + const rootFolderPaths = this.root.slice(1).join(WIN_PATH_SEPARATOR); + const subpath = this.sub.length === 0 ? "" : WIN_PATH_SEPARATOR + this.sub.join(WIN_PATH_SEPARATOR); + return `${this.root[0]}:${WIN_PATH_SEPARATOR}${rootFolderPaths}${subpath}`; + } + } else { + const subpath = this.sub.length === 0 ? "" : POSIX_PATH_SEPARATOR + this.sub.join(POSIX_PATH_SEPARATOR); + return `${POSIX_PATH_SEPARATOR}${this.root.join(POSIX_PATH_SEPARATOR)}${subpath}`; + } + } + rel(type = "posix") { + if (type === "win") { + return this.sub.join(WIN_PATH_SEPARATOR); + } else { + return this.sub.join(POSIX_PATH_SEPARATOR); + } + } + name() { + return last(this.sub) || last(this.root) || ""; + } + dir() { + return "/" + this.root.join(POSIX_PATH_SEPARATOR); + } + cd(subpath) { + return new Path(this.dir(), subpath); + } + platformAbsPath() { + return Path.isWindows() ? this.abs("win") : this.abs("posix"); + } + platformRelPath() { + return Path.isWindows() ? this.rel("win") : this.rel("posix"); + } + join(subpath) { + const parts = split(subpath); + return new Path(POSIX_PATH_SEPARATOR + this.root.join(POSIX_PATH_SEPARATOR), [...this.sub, ...parts].join(POSIX_PATH_SEPARATOR)); + } + filename(newFileName) { + if (this.sub.length === 0) { + throw new Error("InvalidArgumentError: sub path cannot be empty"); + } else { + const validName = filenamify(newFileName); + return new Path(POSIX_PATH_SEPARATOR + this.root.join(POSIX_PATH_SEPARATOR), [...this.sub.slice(0, -1), validName].join(POSIX_PATH_SEPARATOR)); + } + } + parent() { + if (this.sub.length === 0) { + throw new Error("reaching parent folder is not allowed"); + } else { + const parentSub = this.sub.slice(0, -1); + if (parentSub.length === 0) { + return new Path(POSIX_PATH_SEPARATOR + this.root.join(POSIX_PATH_SEPARATOR)); + } else { + return new Path(POSIX_PATH_SEPARATOR + this.root.join(POSIX_PATH_SEPARATOR), parentSub.join(POSIX_PATH_SEPARATOR)); + } + } + } + static fromAbsolutePath(absolutePath, root) { + return new Path(root, absolutePath.replace(root, "")); + } + static posix(windowsPath) { + const p = new Path(windowsPath); + return p.abs("posix"); + } + static win(posixPath) { + const p = new Path(posixPath); + return p.abs("win"); + } + static slash(windowsPath) { + return slash(windowsPath); + } + static backslash(posixPath) { + return posixPath.replace(POSIX_PATH_SEPARATOR, WIN_PATH_SEPARATOR); + } + static setServerPlatform(platform) { + Path.serverPlatform = platform; + } + static resetServerPlatformForTests() { + Path.serverPlatform = null; + } + static getServerPlatform() { + return Path.serverPlatform; + } + static isWindows() { + if (Path.serverPlatform !== null) { + return Path.serverPlatform === "win32"; + } + const proc = typeof globalThis !== "undefined" ? globalThis.process : undefined; + if (proc?.platform) { + return proc.platform === "win32"; + } + const win = typeof globalThis !== "undefined" ? globalThis.window : undefined; + if (win) { + const electron = win.electron; + if (electron?.process?.platform) { + return electron.process.platform === "win32"; + } + } + return false; + } + static pathSeparator() { + return Path.isWindows() ? WIN_PATH_SEPARATOR : POSIX_PATH_SEPARATOR; + } + static toPlatformPath(path) { + return Path.isWindows() ? Path.win(path) : Path.posix(path); + } + toString() { + return this.abs(); + } +} + +// src/nodeRenameFileExistenceProbe.ts +function statWithTimeout(filePath, timeoutMs) { + return Promise.race([ + import_promises.stat(filePath), + new Promise((_, reject) => setTimeout(() => reject(new Error(`stat timeout for path: ${filePath}`)), timeoutMs)) + ]); +} +function createNodeRenameFileExistenceProbe(timeoutMs = 1000) { + return { + async isFile(posixPath) { + try { + const stats = await statWithTimeout(Path.toPlatformPath(posixPath), timeoutMs); + return stats?.isFile() ?? false; + } catch { + return false; + } + } + }; +} // src/bindAddresses.ts var DEFAULT_BIND_ADDRESS = "127.0.0.1"; function resolveWebUiBindAddress() { @@ -31976,7 +32302,7 @@ var UnsupportedFunctionalityError = class extends (_b14 = AISDKError, _a14 = sym // ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/external.js var exports_external = {}; __export(exports_external, { - xor: () => xor, + xor: () => xor2, xid: () => xid2, void: () => _void2, uuidv7: () => uuidv7, @@ -31987,7 +32313,7 @@ __export(exports_external, { url: () => url, uppercase: () => _uppercase, unknown: () => unknown, - union: () => union, + union: () => union2, undefined: () => _undefined3, ulid: () => ulid2, uint64: () => uint64, @@ -32075,7 +32401,7 @@ __export(exports_external, { iso: () => exports_iso, ipv6: () => ipv62, ipv4: () => ipv42, - intersection: () => intersection, + intersection: () => intersection2, int64: () => int64, int32: () => int32, int: () => int, @@ -32117,7 +32443,7 @@ __export(exports_external, { config: () => config, coerce: () => exports_coerce, codec: () => codec, - clone: () => clone, + clone: () => clone2, cidrv6: () => cidrv62, cidrv4: () => cidrv42, check: () => check, @@ -32254,7 +32580,7 @@ __export(exports_core2, { createToJSONSchemaMethod: () => createToJSONSchemaMethod, createStandardJSONSchemaMethod: () => createStandardJSONSchemaMethod, config: () => config, - clone: () => clone, + clone: () => clone2, _xor: () => _xor, _xid: () => _xid, _void: () => _void, @@ -32585,21 +32911,21 @@ __export(exports_util, { promiseAllObject: () => promiseAllObject, primitiveTypes: () => primitiveTypes, prefixIssues: () => prefixIssues, - pick: () => pick, - partial: () => partial, + pick: () => pick2, + partial: () => partial2, parsedType: () => parsedType, optionalKeys: () => optionalKeys, - omit: () => omit, + omit: () => omit2, objectClone: () => objectClone, numKeys: () => numKeys, nullish: () => nullish, normalizeParams: () => normalizeParams, mergeDefs: () => mergeDefs, - merge: () => merge, + merge: () => merge2, jsonStringifyReplacer: () => jsonStringifyReplacer, joinValues: () => joinValues, issue: () => issue, - isPlainObject: () => isPlainObject, + isPlainObject: () => isPlainObject2, isObject: () => isObject, hexToUint8Array: () => hexToUint8Array, getSizableOrigin: () => getSizableOrigin, @@ -32615,7 +32941,7 @@ __export(exports_util, { defineLazy: () => defineLazy, createTransparentProxy: () => createTransparentProxy, cloneDef: () => cloneDef, - clone: () => clone, + clone: () => clone2, cleanRegex: () => cleanRegex, cleanEnum: () => cleanEnum, captureStackTrace: () => captureStackTrace, @@ -32784,7 +33110,7 @@ var allowsEval = cached(() => { return false; } }); -function isPlainObject(o) { +function isPlainObject2(o) { if (isObject(o) === false) return false; const ctor = o.constructor; @@ -32801,7 +33127,7 @@ function isPlainObject(o) { return true; } function shallowClone(o) { - if (isPlainObject(o)) + if (isPlainObject2(o)) return { ...o }; if (Array.isArray(o)) return [...o]; @@ -32865,7 +33191,7 @@ var primitiveTypes = new Set(["string", "number", "bigint", "boolean", "symbol", function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -function clone(inst, def, params) { +function clone2(inst, def, params) { const cl = new inst._zod.constr(def ?? inst._zod.def); if (!def || params?.parent) cl._zod.parent = inst; @@ -32943,7 +33269,7 @@ var BIGINT_FORMAT_RANGES = { int64: [/* @__PURE__ */ BigInt("-9223372036854775808"), /* @__PURE__ */ BigInt("9223372036854775807")], uint64: [/* @__PURE__ */ BigInt(0), /* @__PURE__ */ BigInt("18446744073709551615")] }; -function pick(schema, mask) { +function pick2(schema, mask) { const currDef = schema._zod.def; const checks = currDef.checks; const hasChecks = checks && checks.length > 0; @@ -32966,9 +33292,9 @@ function pick(schema, mask) { }, checks: [] }); - return clone(schema, def); + return clone2(schema, def); } -function omit(schema, mask) { +function omit2(schema, mask) { const currDef = schema._zod.def; const checks = currDef.checks; const hasChecks = checks && checks.length > 0; @@ -32991,10 +33317,10 @@ function omit(schema, mask) { }, checks: [] }); - return clone(schema, def); + return clone2(schema, def); } function extend(schema, shape) { - if (!isPlainObject(shape)) { + if (!isPlainObject2(shape)) { throw new Error("Invalid input to extend: expected a plain object"); } const checks = schema._zod.def.checks; @@ -33014,10 +33340,10 @@ function extend(schema, shape) { return _shape; } }); - return clone(schema, def); + return clone2(schema, def); } function safeExtend(schema, shape) { - if (!isPlainObject(shape)) { + if (!isPlainObject2(shape)) { throw new Error("Invalid input to safeExtend: expected a plain object"); } const def = mergeDefs(schema._zod.def, { @@ -33027,9 +33353,9 @@ function safeExtend(schema, shape) { return _shape; } }); - return clone(schema, def); + return clone2(schema, def); } -function merge(a, b) { +function merge2(a, b) { const def = mergeDefs(a._zod.def, { get shape() { const _shape = { ...a._zod.def.shape, ...b._zod.def.shape }; @@ -33041,9 +33367,9 @@ function merge(a, b) { }, checks: [] }); - return clone(a, def); + return clone2(a, def); } -function partial(Class, schema, mask) { +function partial2(Class, schema, mask) { const currDef = schema._zod.def; const checks = currDef.checks; const hasChecks = checks && checks.length > 0; @@ -33079,7 +33405,7 @@ function partial(Class, schema, mask) { }, checks: [] }); - return clone(schema, def); + return clone2(schema, def); } function required(Class, schema, mask) { const def = mergeDefs(schema._zod.def, { @@ -33110,7 +33436,7 @@ function required(Class, schema, mask) { return shape; } }); - return clone(schema, def); + return clone2(schema, def); } function aborted(x, startIndex = 0) { if (x.aborted === true) @@ -34799,15 +35125,15 @@ var $ZodDate = /* @__PURE__ */ $constructor("$ZodDate", (inst, def) => { } catch (_err) {} } const input = payload.value; - const isDate = input instanceof Date; - const isValidDate = isDate && !Number.isNaN(input.getTime()); + const isDate2 = input instanceof Date; + const isValidDate = isDate2 && !Number.isNaN(input.getTime()); if (isValidDate) return payload; payload.issues.push({ expected: "date", code: "invalid_type", input, - ...isDate ? { received: "Invalid Date" } : {}, + ...isDate2 ? { received: "Invalid Date" } : {}, inst }); return payload; @@ -35287,7 +35613,7 @@ function mergeValues(a, b) { if (a instanceof Date && b instanceof Date && +a === +b) { return { valid: true, data: a }; } - if (isPlainObject(a) && isPlainObject(b)) { + if (isPlainObject2(a) && isPlainObject2(b)) { const bKeys = Object.keys(b); const sharedKeys = Object.keys(a).filter((key) => bKeys.indexOf(key) !== -1); const newObj = { ...a, ...b }; @@ -35412,8 +35738,8 @@ var $ZodTuple = /* @__PURE__ */ $constructor("$ZodTuple", (inst, def) => { } } if (def.rest) { - const rest = input.slice(items.length); - for (const el of rest) { + const rest2 = input.slice(items.length); + for (const el of rest2) { i++; const result = def.rest._zod.run({ value: el, @@ -35441,7 +35767,7 @@ var $ZodRecord = /* @__PURE__ */ $constructor("$ZodRecord", (inst, def) => { $ZodType.init(inst, def); inst._zod.parse = (payload, ctx) => { const input = payload.value; - if (!isPlainObject(input)) { + if (!isPlainObject2(input)) { payload.issues.push({ expected: "record", code: "invalid_type", @@ -39169,11 +39495,11 @@ var capitalizeFirstCharacter = (text) => { }; function getUnitTypeFromNumber(number2) { const abs = Math.abs(number2); - const last = abs % 10; - const last2 = abs % 100; - if (last2 >= 11 && last2 <= 19 || last === 0) + const last2 = abs % 10; + const last22 = abs % 100; + if (last22 >= 11 && last22 <= 19 || last2 === 0) return "many"; - if (last === 1) + if (last2 === 1) return "one"; return "few"; } @@ -42399,11 +42725,11 @@ function _intersection(Class2, left, right) { function _tuple(Class2, items, _paramsOrRest, _params) { const hasRest = _paramsOrRest instanceof $ZodType; const params = hasRest ? _params : _paramsOrRest; - const rest = hasRest ? _paramsOrRest : null; + const rest2 = hasRest ? _paramsOrRest : null; return new Class2({ type: "tuple", items, - rest, + rest: rest2, ...normalizeParams(params) }); } @@ -43343,30 +43669,30 @@ var tupleProcessor = (schema, ctx, _json, params) => { ...params, path: [...params.path, prefixPath, i] })); - const rest = def.rest ? process2(def.rest, ctx, { + const rest2 = def.rest ? process2(def.rest, ctx, { ...params, path: [...params.path, restPath, ...ctx.target === "openapi-3.0" ? [def.items.length] : []] }) : null; if (ctx.target === "draft-2020-12") { json.prefixItems = prefixItems; - if (rest) { - json.items = rest; + if (rest2) { + json.items = rest2; } } else if (ctx.target === "openapi-3.0") { json.items = { anyOf: prefixItems }; - if (rest) { - json.items.anyOf.push(rest); + if (rest2) { + json.items.anyOf.push(rest2); } json.minItems = prefixItems.length; - if (!rest) { + if (!rest2) { json.maxItems = prefixItems.length; } } else { json.items = prefixItems; - if (rest) { - json.additionalItems = rest; + if (rest2) { + json.additionalItems = rest2; } } const { minimum, maximum } = schema._zod.bag; @@ -43627,7 +43953,7 @@ var exports_json_schema = {}; // ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/schemas.js var exports_schemas2 = {}; __export(exports_schemas2, { - xor: () => xor, + xor: () => xor2, xid: () => xid2, void: () => _void2, uuidv7: () => uuidv7, @@ -43636,7 +43962,7 @@ __export(exports_schemas2, { uuid: () => uuid2, url: () => url, unknown: () => unknown, - union: () => union, + union: () => union2, undefined: () => _undefined3, ulid: () => ulid2, uint64: () => uint64, @@ -43684,7 +44010,7 @@ __export(exports_schemas2, { json: () => json, ipv6: () => ipv62, ipv4: () => ipv42, - intersection: () => intersection, + intersection: () => intersection2, int64: () => int64, int32: () => int32, int: () => int, @@ -43941,7 +44267,7 @@ var ZodType = /* @__PURE__ */ $constructor("ZodType", (inst, def) => { }); }; inst.with = inst.check; - inst.clone = (def2, params) => clone(inst, def2, params); + inst.clone = (def2, params) => clone2(inst, def2, params); inst.brand = () => inst; inst.register = (reg, meta2) => { reg.add(inst, meta2); @@ -43969,8 +44295,8 @@ var ZodType = /* @__PURE__ */ $constructor("ZodType", (inst, def) => { inst.nullish = () => optional(nullable(inst)); inst.nonoptional = (params) => nonoptional(inst, params); inst.array = () => array(inst); - inst.or = (arg) => union([inst, arg]); - inst.and = (arg) => intersection(inst, arg); + inst.or = (arg) => union2([inst, arg]); + inst.and = (arg) => intersection2(inst, arg); inst.transform = (tx) => pipe(inst, transform(tx)); inst.default = (def2) => _default2(inst, def2); inst.prefault = (def2) => prefault(inst, def2); @@ -44473,7 +44799,7 @@ var ZodUnion = /* @__PURE__ */ $constructor("ZodUnion", (inst, def) => { inst._zod.processJSONSchema = (ctx, json, params) => unionProcessor(inst, ctx, json, params); inst.options = def.options; }); -function union(options, params) { +function union2(options, params) { return new ZodUnion({ type: "union", options, @@ -44486,7 +44812,7 @@ var ZodXor = /* @__PURE__ */ $constructor("ZodXor", (inst, def) => { inst._zod.processJSONSchema = (ctx, json, params) => unionProcessor(inst, ctx, json, params); inst.options = def.options; }); -function xor(options, params) { +function xor2(options, params) { return new ZodXor({ type: "union", options, @@ -44511,7 +44837,7 @@ var ZodIntersection = /* @__PURE__ */ $constructor("ZodIntersection", (inst, def ZodType.init(inst, def); inst._zod.processJSONSchema = (ctx, json, params) => intersectionProcessor(inst, ctx, json, params); }); -function intersection(left, right) { +function intersection2(left, right) { return new ZodIntersection({ type: "intersection", left, @@ -44522,19 +44848,19 @@ var ZodTuple = /* @__PURE__ */ $constructor("ZodTuple", (inst, def) => { $ZodTuple.init(inst, def); ZodType.init(inst, def); inst._zod.processJSONSchema = (ctx, json, params) => tupleProcessor(inst, ctx, json, params); - inst.rest = (rest) => inst.clone({ + inst.rest = (rest2) => inst.clone({ ...inst._zod.def, - rest + rest: rest2 }); }); function tuple(items, _paramsOrRest, _params) { const hasRest = _paramsOrRest instanceof $ZodType; const params = hasRest ? _params : _paramsOrRest; - const rest = hasRest ? _paramsOrRest : null; + const rest2 = hasRest ? _paramsOrRest : null; return new ZodTuple({ type: "tuple", items, - rest, + rest: rest2, ...exports_util.normalizeParams(params) }); } @@ -44554,7 +44880,7 @@ function record(keyType, valueType, params) { }); } function partialRecord(keyType, valueType, params) { - const k = clone(keyType); + const k = clone2(keyType); k._zod.values = undefined; return new ZodRecord({ type: "record", @@ -44986,7 +45312,7 @@ var stringbool = (...args) => _stringbool({ }, ...args); function json(params) { const jsonSchema = lazy(() => { - return union([string2(params), number2(), boolean2(), _null3(), array(jsonSchema), record(string2(), jsonSchema)]); + return union2([string2(params), number2(), boolean2(), _null3(), array(jsonSchema), record(string2(), jsonSchema)]); }); return jsonSchema; } @@ -45353,9 +45679,9 @@ function convertBaseSchema(schema, ctx) { const items = schema.items; if (prefixItems && Array.isArray(prefixItems)) { const tupleItems = prefixItems.map((item) => convertSchema(item, ctx)); - const rest = items && typeof items === "object" && !Array.isArray(items) ? convertSchema(items, ctx) : undefined; - if (rest) { - zodSchema = z.tuple(tupleItems).rest(rest); + const rest2 = items && typeof items === "object" && !Array.isArray(items) ? convertSchema(items, ctx) : undefined; + if (rest2) { + zodSchema = z.tuple(tupleItems).rest(rest2); } else { zodSchema = z.tuple(tupleItems); } @@ -45367,9 +45693,9 @@ function convertBaseSchema(schema, ctx) { } } else if (Array.isArray(items)) { const tupleItems = items.map((item) => convertSchema(item, ctx)); - const rest = schema.additionalItems && typeof schema.additionalItems === "object" ? convertSchema(schema.additionalItems, ctx) : undefined; - if (rest) { - zodSchema = z.tuple(tupleItems).rest(rest); + const rest2 = schema.additionalItems && typeof schema.additionalItems === "object" ? convertSchema(schema.additionalItems, ctx) : undefined; + if (rest2) { + zodSchema = z.tuple(tupleItems).rest(rest2); } else { zodSchema = z.tuple(tupleItems); } @@ -48437,8 +48763,8 @@ class ZodTuple2 extends ZodType2 { }); return INVALID; } - const rest = this._def.rest; - if (!rest && ctx.data.length > this._def.items.length) { + const rest2 = this._def.rest; + if (!rest2 && ctx.data.length > this._def.items.length) { addIssueToContext(ctx, { code: ZodIssueCode2.too_big, maximum: this._def.items.length, @@ -48465,10 +48791,10 @@ class ZodTuple2 extends ZodType2 { get items() { return this._def.items; } - rest(rest) { + rest(rest2) { return new ZodTuple2({ ...this._def, - rest + rest: rest2 }); } } @@ -49484,14 +49810,14 @@ class ParseError extends Error { super(message), this.name = "ParseError", this.type = options.type, this.field = options.field, this.value = options.value, this.line = options.line; } } -function noop(_arg) {} +function noop2(_arg) {} function createParser(callbacks) { if (typeof callbacks == "function") throw new TypeError("`callbacks` must be an object, got a function instead. Did you mean `{onEvent: fn}`?"); - const { onEvent = noop, onError = noop, onRetry = noop, onComment } = callbacks; + const { onEvent = noop2, onError = noop2, onRetry = noop2, onComment } = callbacks; let incompleteLine = "", isFirstChunk = true, id, data = "", eventType = ""; function feed(newChunk) { - const chunk = isFirstChunk ? newChunk.replace(/^\xEF\xBB\xBF/, "") : newChunk, [complete, incomplete] = splitLines(`${incompleteLine}${chunk}`); + const chunk2 = isFirstChunk ? newChunk.replace(/^\xEF\xBB\xBF/, "") : newChunk, [complete, incomplete] = splitLines(`${incompleteLine}${chunk2}`); for (const line of complete) parseLine(line); incompleteLine = incomplete, isFirstChunk = false; @@ -49550,19 +49876,19 @@ function createParser(callbacks) { } return { feed, reset }; } -function splitLines(chunk) { +function splitLines(chunk2) { const lines = []; let incompleteLine = "", searchIndex = 0; - for (;searchIndex < chunk.length; ) { - const crIndex = chunk.indexOf("\r", searchIndex), lfIndex = chunk.indexOf(` + for (;searchIndex < chunk2.length; ) { + const crIndex = chunk2.indexOf("\r", searchIndex), lfIndex = chunk2.indexOf(` `, searchIndex); let lineEnd = -1; - if (crIndex !== -1 && lfIndex !== -1 ? lineEnd = Math.min(crIndex, lfIndex) : crIndex !== -1 ? crIndex === chunk.length - 1 ? lineEnd = -1 : lineEnd = crIndex : lfIndex !== -1 && (lineEnd = lfIndex), lineEnd === -1) { - incompleteLine = chunk.slice(searchIndex); + if (crIndex !== -1 && lfIndex !== -1 ? lineEnd = Math.min(crIndex, lfIndex) : crIndex !== -1 ? crIndex === chunk2.length - 1 ? lineEnd = -1 : lineEnd = crIndex : lfIndex !== -1 && (lineEnd = lfIndex), lineEnd === -1) { + incompleteLine = chunk2.slice(searchIndex); break; } else { - const line = chunk.slice(searchIndex, lineEnd); - lines.push(line), searchIndex = lineEnd + 1, chunk[searchIndex - 1] === "\r" && chunk[searchIndex] === ` + const line = chunk2.slice(searchIndex, lineEnd); + lines.push(line), searchIndex = lineEnd + 1, chunk2[searchIndex - 1] === "\r" && chunk2[searchIndex] === ` ` && searchIndex++; } } @@ -49586,8 +49912,8 @@ class EventSourceParserStream extends TransformStream { onComment }); }, - transform(chunk) { - parser.feed(chunk); + transform(chunk2) { + parser.feed(chunk2); } }); } @@ -49600,7 +49926,7 @@ function combineHeaders(...headers) { ...currentHeaders != null ? currentHeaders : {} }), {}); } -async function delay(delayInMs, options) { +async function delay2(delayInMs, options) { if (delayInMs == null) { return Promise.resolve(); } @@ -49784,9 +50110,9 @@ async function readResponseWithSizeLimit({ } const result = new Uint8Array(totalBytes); let offset = 0; - for (const chunk of chunks) { - result.set(chunk, offset); - offset += chunk.length; + for (const chunk2 of chunks) { + result.set(chunk2, offset); + offset += chunk2.length; } return result; } @@ -50302,8 +50628,8 @@ function parseIntersectionDef(def, refs) { } else { let nestedSchema = schema; if ("additionalProperties" in schema && schema.additionalProperties === false) { - const { additionalProperties, ...rest } = schema; - nestedSchema = rest; + const { additionalProperties, ...rest2 } = schema; + nestedSchema = rest2; } mergedAllOf.push(nestedSchema); } @@ -52121,9 +52447,9 @@ var GatewayLanguageModel = class { controller.enqueue({ type: "stream-start", warnings }); } }, - transform(chunk, controller) { - if (chunk.success) { - const streamPart = chunk.value; + transform(chunk2, controller) { + if (chunk2.success) { + const streamPart = chunk2.value; if (streamPart.type === "raw" && !options.includeRawChunks) { return; } @@ -52132,7 +52458,7 @@ var GatewayLanguageModel = class { } controller.enqueue(streamPart); } else { - controller.error(chunk.error); + controller.error(chunk2.error); } } })), @@ -53217,17 +53543,17 @@ function asLanguageModelV3(model) { } function convertV2StreamToV3(stream) { return stream.pipeThrough(new TransformStream({ - transform(chunk, controller) { - switch (chunk.type) { + transform(chunk2, controller) { + switch (chunk2.type) { case "finish": controller.enqueue({ - ...chunk, - finishReason: convertV2FinishReasonToV3(chunk.finishReason), - usage: convertV2UsageToV3(chunk.usage) + ...chunk2, + finishReason: convertV2FinishReasonToV3(chunk2.finishReason), + usage: convertV2UsageToV3(chunk2.usage) }); break; default: - controller.enqueue(chunk); + controller.enqueue(chunk2); break; } } @@ -53272,26 +53598,26 @@ function getGlobalProvider() { var _a21; return (_a21 = globalThis.AI_SDK_DEFAULT_PROVIDER) != null ? _a21 : gateway; } -function getTotalTimeoutMs(timeout) { - if (timeout == null) { +function getTotalTimeoutMs(timeout2) { + if (timeout2 == null) { return; } - if (typeof timeout === "number") { - return timeout; + if (typeof timeout2 === "number") { + return timeout2; } - return timeout.totalMs; + return timeout2.totalMs; } -function getStepTimeoutMs(timeout) { - if (timeout == null || typeof timeout === "number") { +function getStepTimeoutMs(timeout2) { + if (timeout2 == null || typeof timeout2 === "number") { return; } - return timeout.stepMs; + return timeout2.stepMs; } -function getChunkTimeoutMs(timeout) { - if (timeout == null || typeof timeout === "number") { +function getChunkTimeoutMs(timeout2) { + if (timeout2 == null || typeof timeout2 === "number") { return; } - return timeout.chunkMs; + return timeout2.chunkMs; } var imageMediaTypeSignatures = [ { @@ -54569,7 +54895,7 @@ async function _retryWithExponentialBackoff(f, { }); } if (error48 instanceof Error && APICallError.isInstance(error48) && error48.isRetryable === true && tryNumber <= maxRetries) { - await delay(getRetryDelayInMs({ + await delay2(getRetryDelayInMs({ error: error48, exponentialBackoffDelay: delayInMs }), { abortSignal }); @@ -56140,8 +56466,8 @@ var uiMessageChunkSchema = lazySchema(() => zodSchema(exports_external.union([ messageMetadata: exports_external.unknown() }) ]))); -function isDataUIMessageChunk(chunk) { - return chunk.type.startsWith("data-"); +function isDataUIMessageChunk(chunk2) { + return chunk2.type.startsWith("data-"); } function isDataUIPart(part) { return part.type.startsWith("data-"); @@ -56196,7 +56522,7 @@ function processUIMessageStream({ onData }) { return stream.pipeThrough(new TransformStream({ - async transform(chunk, controller) { + async transform(chunk2, controller) { await runUpdateMessageJob(async ({ state, write }) => { var _a21, _b16, _c, _d; function getToolInvocation(toolCallId) { @@ -56298,45 +56624,45 @@ function processUIMessageStream({ state.message.metadata = mergedMetadata; } } - switch (chunk.type) { + switch (chunk2.type) { case "text-start": { const textPart = { type: "text", text: "", - providerMetadata: chunk.providerMetadata, + providerMetadata: chunk2.providerMetadata, state: "streaming" }; - state.activeTextParts[chunk.id] = textPart; + state.activeTextParts[chunk2.id] = textPart; state.message.parts.push(textPart); write(); break; } case "text-delta": { - const textPart = state.activeTextParts[chunk.id]; + const textPart = state.activeTextParts[chunk2.id]; if (textPart == null) { throw new UIMessageStreamError({ chunkType: "text-delta", - chunkId: chunk.id, - message: `Received text-delta for missing text part with ID "${chunk.id}". Ensure a "text-start" chunk is sent before any "text-delta" chunks.` + chunkId: chunk2.id, + message: `Received text-delta for missing text part with ID "${chunk2.id}". Ensure a "text-start" chunk is sent before any "text-delta" chunks.` }); } - textPart.text += chunk.delta; - textPart.providerMetadata = (_a21 = chunk.providerMetadata) != null ? _a21 : textPart.providerMetadata; + textPart.text += chunk2.delta; + textPart.providerMetadata = (_a21 = chunk2.providerMetadata) != null ? _a21 : textPart.providerMetadata; write(); break; } case "text-end": { - const textPart = state.activeTextParts[chunk.id]; + const textPart = state.activeTextParts[chunk2.id]; if (textPart == null) { throw new UIMessageStreamError({ chunkType: "text-end", - chunkId: chunk.id, - message: `Received text-end for missing text part with ID "${chunk.id}". Ensure a "text-start" chunk is sent before any "text-end" chunks.` + chunkId: chunk2.id, + message: `Received text-end for missing text part with ID "${chunk2.id}". Ensure a "text-start" chunk is sent before any "text-end" chunks.` }); } textPart.state = "done"; - textPart.providerMetadata = (_b16 = chunk.providerMetadata) != null ? _b16 : textPart.providerMetadata; - delete state.activeTextParts[chunk.id]; + textPart.providerMetadata = (_b16 = chunk2.providerMetadata) != null ? _b16 : textPart.providerMetadata; + delete state.activeTextParts[chunk2.id]; write(); break; } @@ -56344,48 +56670,48 @@ function processUIMessageStream({ const reasoningPart = { type: "reasoning", text: "", - providerMetadata: chunk.providerMetadata, + providerMetadata: chunk2.providerMetadata, state: "streaming" }; - state.activeReasoningParts[chunk.id] = reasoningPart; + state.activeReasoningParts[chunk2.id] = reasoningPart; state.message.parts.push(reasoningPart); write(); break; } case "reasoning-delta": { - const reasoningPart = state.activeReasoningParts[chunk.id]; + const reasoningPart = state.activeReasoningParts[chunk2.id]; if (reasoningPart == null) { throw new UIMessageStreamError({ chunkType: "reasoning-delta", - chunkId: chunk.id, - message: `Received reasoning-delta for missing reasoning part with ID "${chunk.id}". Ensure a "reasoning-start" chunk is sent before any "reasoning-delta" chunks.` + chunkId: chunk2.id, + message: `Received reasoning-delta for missing reasoning part with ID "${chunk2.id}". Ensure a "reasoning-start" chunk is sent before any "reasoning-delta" chunks.` }); } - reasoningPart.text += chunk.delta; - reasoningPart.providerMetadata = (_c = chunk.providerMetadata) != null ? _c : reasoningPart.providerMetadata; + reasoningPart.text += chunk2.delta; + reasoningPart.providerMetadata = (_c = chunk2.providerMetadata) != null ? _c : reasoningPart.providerMetadata; write(); break; } case "reasoning-end": { - const reasoningPart = state.activeReasoningParts[chunk.id]; + const reasoningPart = state.activeReasoningParts[chunk2.id]; if (reasoningPart == null) { throw new UIMessageStreamError({ chunkType: "reasoning-end", - chunkId: chunk.id, - message: `Received reasoning-end for missing reasoning part with ID "${chunk.id}". Ensure a "reasoning-start" chunk is sent before any "reasoning-end" chunks.` + chunkId: chunk2.id, + message: `Received reasoning-end for missing reasoning part with ID "${chunk2.id}". Ensure a "reasoning-start" chunk is sent before any "reasoning-end" chunks.` }); } - reasoningPart.providerMetadata = (_d = chunk.providerMetadata) != null ? _d : reasoningPart.providerMetadata; + reasoningPart.providerMetadata = (_d = chunk2.providerMetadata) != null ? _d : reasoningPart.providerMetadata; reasoningPart.state = "done"; - delete state.activeReasoningParts[chunk.id]; + delete state.activeReasoningParts[chunk2.id]; write(); break; } case "file": { state.message.parts.push({ type: "file", - mediaType: chunk.mediaType, - url: chunk.url + mediaType: chunk2.mediaType, + url: chunk2.url }); write(); break; @@ -56393,10 +56719,10 @@ function processUIMessageStream({ case "source-url": { state.message.parts.push({ type: "source-url", - sourceId: chunk.sourceId, - url: chunk.url, - title: chunk.title, - providerMetadata: chunk.providerMetadata + sourceId: chunk2.sourceId, + url: chunk2.url, + title: chunk2.title, + providerMetadata: chunk2.providerMetadata }); write(); break; @@ -56404,62 +56730,62 @@ function processUIMessageStream({ case "source-document": { state.message.parts.push({ type: "source-document", - sourceId: chunk.sourceId, - mediaType: chunk.mediaType, - title: chunk.title, - filename: chunk.filename, - providerMetadata: chunk.providerMetadata + sourceId: chunk2.sourceId, + mediaType: chunk2.mediaType, + title: chunk2.title, + filename: chunk2.filename, + providerMetadata: chunk2.providerMetadata }); write(); break; } case "tool-input-start": { const toolInvocations = state.message.parts.filter(isStaticToolUIPart); - state.partialToolCalls[chunk.toolCallId] = { + state.partialToolCalls[chunk2.toolCallId] = { text: "", - toolName: chunk.toolName, + toolName: chunk2.toolName, index: toolInvocations.length, - dynamic: chunk.dynamic, - title: chunk.title + dynamic: chunk2.dynamic, + title: chunk2.title }; - if (chunk.dynamic) { + if (chunk2.dynamic) { updateDynamicToolPart({ - toolCallId: chunk.toolCallId, - toolName: chunk.toolName, + toolCallId: chunk2.toolCallId, + toolName: chunk2.toolName, state: "input-streaming", input: undefined, - providerExecuted: chunk.providerExecuted, - title: chunk.title, - providerMetadata: chunk.providerMetadata + providerExecuted: chunk2.providerExecuted, + title: chunk2.title, + providerMetadata: chunk2.providerMetadata }); } else { updateToolPart({ - toolCallId: chunk.toolCallId, - toolName: chunk.toolName, + toolCallId: chunk2.toolCallId, + toolName: chunk2.toolName, state: "input-streaming", input: undefined, - providerExecuted: chunk.providerExecuted, - title: chunk.title, - providerMetadata: chunk.providerMetadata + providerExecuted: chunk2.providerExecuted, + title: chunk2.title, + providerMetadata: chunk2.providerMetadata }); } write(); break; } case "tool-input-delta": { - const partialToolCall = state.partialToolCalls[chunk.toolCallId]; + const partialToolCall = state.partialToolCalls[chunk2.toolCallId]; if (partialToolCall == null) { throw new UIMessageStreamError({ chunkType: "tool-input-delta", - chunkId: chunk.toolCallId, - message: `Received tool-input-delta for missing tool call with ID "${chunk.toolCallId}". Ensure a "tool-input-start" chunk is sent before any "tool-input-delta" chunks.` + chunkId: chunk2.toolCallId, + message: `Received tool-input-delta for missing tool call with ID "${chunk2.toolCallId}". Ensure a "tool-input-start" chunk is sent before any "tool-input-delta" chunks.` }); } - partialToolCall.text += chunk.inputTextDelta; + partialToolCall.text += chunk2.inputTextDelta; const { value: partialArgs } = await parsePartialJson(partialToolCall.text); if (partialToolCall.dynamic) { updateDynamicToolPart({ - toolCallId: chunk.toolCallId, + toolCallId: chunk2.toolCallId, toolName: partialToolCall.toolName, state: "input-streaming", input: partialArgs, @@ -56467,7 +56793,7 @@ function processUIMessageStream({ }); } else { updateToolPart({ - toolCallId: chunk.toolCallId, + toolCallId: chunk2.toolCallId, toolName: partialToolCall.toolName, state: "input-streaming", input: partialArgs, @@ -56478,96 +56804,96 @@ function processUIMessageStream({ break; } case "tool-input-available": { - if (chunk.dynamic) { + if (chunk2.dynamic) { updateDynamicToolPart({ - toolCallId: chunk.toolCallId, - toolName: chunk.toolName, + toolCallId: chunk2.toolCallId, + toolName: chunk2.toolName, state: "input-available", - input: chunk.input, - providerExecuted: chunk.providerExecuted, - providerMetadata: chunk.providerMetadata, - title: chunk.title + input: chunk2.input, + providerExecuted: chunk2.providerExecuted, + providerMetadata: chunk2.providerMetadata, + title: chunk2.title }); } else { updateToolPart({ - toolCallId: chunk.toolCallId, - toolName: chunk.toolName, + toolCallId: chunk2.toolCallId, + toolName: chunk2.toolName, state: "input-available", - input: chunk.input, - providerExecuted: chunk.providerExecuted, - providerMetadata: chunk.providerMetadata, - title: chunk.title + input: chunk2.input, + providerExecuted: chunk2.providerExecuted, + providerMetadata: chunk2.providerMetadata, + title: chunk2.title }); } write(); - if (onToolCall && !chunk.providerExecuted) { + if (onToolCall && !chunk2.providerExecuted) { await onToolCall({ - toolCall: chunk + toolCall: chunk2 }); } break; } case "tool-input-error": { - if (chunk.dynamic) { + if (chunk2.dynamic) { updateDynamicToolPart({ - toolCallId: chunk.toolCallId, - toolName: chunk.toolName, + toolCallId: chunk2.toolCallId, + toolName: chunk2.toolName, state: "output-error", - input: chunk.input, - errorText: chunk.errorText, - providerExecuted: chunk.providerExecuted, - providerMetadata: chunk.providerMetadata + input: chunk2.input, + errorText: chunk2.errorText, + providerExecuted: chunk2.providerExecuted, + providerMetadata: chunk2.providerMetadata }); } else { updateToolPart({ - toolCallId: chunk.toolCallId, - toolName: chunk.toolName, + toolCallId: chunk2.toolCallId, + toolName: chunk2.toolName, state: "output-error", input: undefined, - rawInput: chunk.input, - errorText: chunk.errorText, - providerExecuted: chunk.providerExecuted, - providerMetadata: chunk.providerMetadata + rawInput: chunk2.input, + errorText: chunk2.errorText, + providerExecuted: chunk2.providerExecuted, + providerMetadata: chunk2.providerMetadata }); } write(); break; } case "tool-approval-request": { - const toolInvocation = getToolInvocation(chunk.toolCallId); + const toolInvocation = getToolInvocation(chunk2.toolCallId); toolInvocation.state = "approval-requested"; - toolInvocation.approval = { id: chunk.approvalId }; + toolInvocation.approval = { id: chunk2.approvalId }; write(); break; } case "tool-output-denied": { - const toolInvocation = getToolInvocation(chunk.toolCallId); + const toolInvocation = getToolInvocation(chunk2.toolCallId); toolInvocation.state = "output-denied"; write(); break; } case "tool-output-available": { - const toolInvocation = getToolInvocation(chunk.toolCallId); + const toolInvocation = getToolInvocation(chunk2.toolCallId); if (toolInvocation.type === "dynamic-tool") { updateDynamicToolPart({ - toolCallId: chunk.toolCallId, + toolCallId: chunk2.toolCallId, toolName: toolInvocation.toolName, state: "output-available", input: toolInvocation.input, - output: chunk.output, - preliminary: chunk.preliminary, - providerExecuted: chunk.providerExecuted, + output: chunk2.output, + preliminary: chunk2.preliminary, + providerExecuted: chunk2.providerExecuted, title: toolInvocation.title }); } else { updateToolPart({ - toolCallId: chunk.toolCallId, + toolCallId: chunk2.toolCallId, toolName: getStaticToolName(toolInvocation), state: "output-available", input: toolInvocation.input, - output: chunk.output, - providerExecuted: chunk.providerExecuted, - preliminary: chunk.preliminary, + output: chunk2.output, + providerExecuted: chunk2.providerExecuted, + preliminary: chunk2.preliminary, title: toolInvocation.title }); } @@ -56575,26 +56901,26 @@ function processUIMessageStream({ break; } case "tool-output-error": { - const toolInvocation = getToolInvocation(chunk.toolCallId); + const toolInvocation = getToolInvocation(chunk2.toolCallId); if (toolInvocation.type === "dynamic-tool") { updateDynamicToolPart({ - toolCallId: chunk.toolCallId, + toolCallId: chunk2.toolCallId, toolName: toolInvocation.toolName, state: "output-error", input: toolInvocation.input, - errorText: chunk.errorText, - providerExecuted: chunk.providerExecuted, + errorText: chunk2.errorText, + providerExecuted: chunk2.providerExecuted, title: toolInvocation.title }); } else { updateToolPart({ - toolCallId: chunk.toolCallId, + toolCallId: chunk2.toolCallId, toolName: getStaticToolName(toolInvocation), state: "output-error", input: toolInvocation.input, rawInput: toolInvocation.rawInput, - errorText: chunk.errorText, - providerExecuted: chunk.providerExecuted, + errorText: chunk2.errorText, + providerExecuted: chunk2.providerExecuted, title: toolInvocation.title }); } @@ -56611,52 +56937,52 @@ function processUIMessageStream({ break; } case "start": { - if (chunk.messageId != null) { - state.message.id = chunk.messageId; + if (chunk2.messageId != null) { + state.message.id = chunk2.messageId; } - await updateMessageMetadata(chunk.messageMetadata); - if (chunk.messageId != null || chunk.messageMetadata != null) { + await updateMessageMetadata(chunk2.messageMetadata); + if (chunk2.messageId != null || chunk2.messageMetadata != null) { write(); } break; } case "finish": { - if (chunk.finishReason != null) { - state.finishReason = chunk.finishReason; + if (chunk2.finishReason != null) { + state.finishReason = chunk2.finishReason; } - await updateMessageMetadata(chunk.messageMetadata); - if (chunk.messageMetadata != null) { + await updateMessageMetadata(chunk2.messageMetadata); + if (chunk2.messageMetadata != null) { write(); } break; } case "message-metadata": { - await updateMessageMetadata(chunk.messageMetadata); - if (chunk.messageMetadata != null) { + await updateMessageMetadata(chunk2.messageMetadata); + if (chunk2.messageMetadata != null) { write(); } break; } case "error": { - onError == null || onError(new Error(chunk.errorText)); + onError == null || onError(new Error(chunk2.errorText)); break; } default: { - if (isDataUIMessageChunk(chunk)) { - if ((dataPartSchemas == null ? undefined : dataPartSchemas[chunk.type]) != null) { - const partIdx = state.message.parts.findIndex((p) => ("id" in p) && ("data" in p) && p.id === chunk.id && p.type === chunk.type); + if (isDataUIMessageChunk(chunk2)) { + if ((dataPartSchemas == null ? undefined : dataPartSchemas[chunk2.type]) != null) { + const partIdx = state.message.parts.findIndex((p) => ("id" in p) && ("data" in p) && p.id === chunk2.id && p.type === chunk2.type); const actualPartIdx = partIdx >= 0 ? partIdx : state.message.parts.length; await validateTypes({ - value: chunk.data, - schema: dataPartSchemas[chunk.type], + value: chunk2.data, + schema: dataPartSchemas[chunk2.type], context: { field: `message.parts[${actualPartIdx}].data`, - entityName: chunk.type, - entityId: chunk.id + entityName: chunk2.type, + entityId: chunk2.id } }); } - const dataChunk = chunk; + const dataChunk = chunk2; if (dataChunk.transient) { onData == null || onData(dataChunk); break; @@ -56672,7 +56998,7 @@ function processUIMessageStream({ } } } - controller.enqueue(chunk); + controller.enqueue(chunk2); }); } })); @@ -56693,17 +57019,17 @@ function handleUIMessageStreamFinish({ } let isAborted2 = false; const idInjectedStream = stream.pipeThrough(new TransformStream({ - transform(chunk, controller) { - if (chunk.type === "start") { - const startChunk = chunk; + transform(chunk2, controller) { + if (chunk2.type === "start") { + const startChunk = chunk2; if (startChunk.messageId == null && messageId != null) { startChunk.messageId = messageId; } } - if (chunk.type === "abort") { + if (chunk2.type === "abort") { isAborted2 = true; } - controller.enqueue(chunk); + controller.enqueue(chunk2); } })); if (onFinish == null && onStepFinish == null) { @@ -56757,11 +57083,11 @@ function handleUIMessageStreamFinish({ runUpdateMessageJob, onError }).pipeThrough(new TransformStream({ - async transform(chunk, controller) { - if (chunk.type === "finish-step") { + async transform(chunk2, controller) { + if (chunk2.type === "finish-step") { await callOnStepFinish(); } - controller.enqueue(chunk); + controller.enqueue(chunk2); }, async cancel() { await callOnFinish(); @@ -56974,8 +57300,8 @@ function runToolsTransformation({ } } const forwardStream = new TransformStream({ - async transform(chunk, controller) { - const chunkType = chunk.type; + async transform(chunk2, controller) { + const chunkType = chunk2.type; switch (chunkType) { case "stream-start": case "text-start": @@ -56991,15 +57317,15 @@ function runToolsTransformation({ case "response-metadata": case "error": case "raw": { - controller.enqueue(chunk); + controller.enqueue(chunk2); break; } case "file": { controller.enqueue({ type: "file", file: new DefaultGeneratedFileWithType({ - data: chunk.data, - mediaType: chunk.mediaType + data: chunk2.data, + mediaType: chunk2.mediaType }) }); break; @@ -57007,28 +57333,28 @@ function runToolsTransformation({ case "finish": { finishChunk = { type: "finish", - finishReason: chunk.finishReason.unified, - rawFinishReason: chunk.finishReason.raw, - usage: asLanguageModelUsage(chunk.usage), - providerMetadata: chunk.providerMetadata + finishReason: chunk2.finishReason.unified, + rawFinishReason: chunk2.finishReason.raw, + usage: asLanguageModelUsage(chunk2.usage), + providerMetadata: chunk2.providerMetadata }; break; } case "tool-approval-request": { - const toolCall = toolCallsByToolCallId.get(chunk.toolCallId); + const toolCall = toolCallsByToolCallId.get(chunk2.toolCallId); if (toolCall == null) { toolResultsStreamController.enqueue({ type: "error", error: new ToolCallNotFoundForApprovalError({ - toolCallId: chunk.toolCallId, - approvalId: chunk.approvalId + toolCallId: chunk2.toolCallId, + approvalId: chunk2.approvalId }) }); break; } controller.enqueue({ type: "tool-approval-request", - approvalId: chunk.approvalId, + approvalId: chunk2.approvalId, toolCall }); break; @@ -57036,7 +57362,7 @@ function runToolsTransformation({ case "tool-call": { try { const toolCall = await parseToolCall({ - toolCall: chunk, + toolCall: chunk2, tools, repairToolCall, system, @@ -57119,26 +57445,26 @@ function runToolsTransformation({ break; } case "tool-result": { - const toolName = chunk.toolName; - if (chunk.isError) { + const toolName = chunk2.toolName; + if (chunk2.isError) { toolResultsStreamController.enqueue({ type: "tool-error", - toolCallId: chunk.toolCallId, + toolCallId: chunk2.toolCallId, toolName, - input: toolInputs.get(chunk.toolCallId), + input: toolInputs.get(chunk2.toolCallId), providerExecuted: true, - error: chunk.result, - dynamic: chunk.dynamic + error: chunk2.result, + dynamic: chunk2.dynamic }); } else { controller.enqueue({ type: "tool-result", - toolCallId: chunk.toolCallId, + toolCallId: chunk2.toolCallId, toolName, - input: toolInputs.get(chunk.toolCallId), - output: chunk.result, + input: toolInputs.get(chunk2.toolCallId), + output: chunk2.result, providerExecuted: true, - dynamic: chunk.dynamic + dynamic: chunk2.dynamic }); } break; @@ -57158,14 +57484,14 @@ function runToolsTransformation({ async start(controller) { return Promise.all([ generatorStream.pipeThrough(forwardStream).pipeTo(new WritableStream({ - write(chunk) { - controller.enqueue(chunk); + write(chunk2) { + controller.enqueue(chunk2); }, close() {} })), toolResultsStream.pipeTo(new WritableStream({ - write(chunk) { - controller.enqueue(chunk); + write(chunk2) { + controller.enqueue(chunk2); }, close() { controller.close(); @@ -57188,7 +57514,7 @@ function streamText({ messages, maxRetries, abortSignal, - timeout, + timeout: timeout2, headers, stopWhen = stepCountIs(1), experimental_output, @@ -57218,9 +57544,9 @@ function streamText({ _internal: { now: now2 = now, generateId: generateId2 = originalGenerateId2 } = {}, ...settings }) { - const totalTimeoutMs = getTotalTimeoutMs(timeout); - const stepTimeoutMs = getStepTimeoutMs(timeout); - const chunkTimeoutMs = getChunkTimeoutMs(timeout); + const totalTimeoutMs = getTotalTimeoutMs(timeout2); + const stepTimeoutMs = getStepTimeoutMs(timeout2); + const chunkTimeoutMs = getChunkTimeoutMs(timeout2); const stepAbortController = stepTimeoutMs != null ? new AbortController : undefined; const chunkAbortController = chunkTimeoutMs != null ? new AbortController : undefined; return new DefaultStreamTextResult({ @@ -57247,7 +57573,7 @@ function streamText({ providerOptions, prepareStep, includeRawChunks, - timeout, + timeout: timeout2, stopWhen, originalAbortSignal: abortSignal, onChunk, @@ -57288,35 +57614,35 @@ function createOutputTransformStream(output) { textChunk = ""; } return new TransformStream({ - async transform(chunk, controller) { + async transform(chunk2, controller) { var _a21; - if (chunk.type === "finish-step" && textChunk.length > 0) { + if (chunk2.type === "finish-step" && textChunk.length > 0) { publishTextChunk({ controller }); } - if (chunk.type !== "text-delta" && chunk.type !== "text-start" && chunk.type !== "text-end") { - controller.enqueue({ part: chunk, partialOutput: undefined }); + if (chunk2.type !== "text-delta" && chunk2.type !== "text-start" && chunk2.type !== "text-end") { + controller.enqueue({ part: chunk2, partialOutput: undefined }); return; } if (firstTextChunkId == null) { - firstTextChunkId = chunk.id; - } else if (chunk.id !== firstTextChunkId) { - controller.enqueue({ part: chunk, partialOutput: undefined }); + firstTextChunkId = chunk2.id; + } else if (chunk2.id !== firstTextChunkId) { + controller.enqueue({ part: chunk2, partialOutput: undefined }); return; } - if (chunk.type === "text-start") { - controller.enqueue({ part: chunk, partialOutput: undefined }); + if (chunk2.type === "text-start") { + controller.enqueue({ part: chunk2, partialOutput: undefined }); return; } - if (chunk.type === "text-end") { + if (chunk2.type === "text-end") { if (textChunk.length > 0) { publishTextChunk({ controller }); } - controller.enqueue({ part: chunk, partialOutput: undefined }); + controller.enqueue({ part: chunk2, partialOutput: undefined }); return; } - text2 += chunk.text; - textChunk += chunk.text; - textProviderMetadata = (_a21 = chunk.providerMetadata) != null ? _a21 : textProviderMetadata; + text2 += chunk2.text; + textChunk += chunk2.text; + textProviderMetadata = (_a21 = chunk2.providerMetadata) != null ? _a21 : textProviderMetadata; const result = await output.parsePartialOutput({ text: text2 }); if (result !== undefined) { const currentJson = JSON.stringify(result.partial); @@ -57355,7 +57681,7 @@ var DefaultStreamTextResult = class { includeRawChunks, now: now2, generateId: generateId2, - timeout, + timeout: timeout2, stopWhen, originalAbortSignal, onChunk, @@ -57392,10 +57718,10 @@ var DefaultStreamTextResult = class { let activeTextContent = {}; let activeReasoningContent = {}; const eventProcessor = new TransformStream({ - async transform(chunk, controller) { + async transform(chunk2, controller) { var _a21, _b16, _c, _d; - controller.enqueue(chunk); - const { part } = chunk; + controller.enqueue(chunk2); + const { part } = chunk2; if (part.type === "text-delta" || part.type === "reasoning-delta" || part.type === "source" || part.type === "tool-call" || part.type === "tool-result" || part.type === "tool-input-start" || part.type === "tool-input-delta" || part.type === "raw") { await (onChunk == null ? undefined : onChunk({ chunk: part })); } @@ -57666,7 +57992,7 @@ var DefaultStreamTextResult = class { })); } this.baseStream = stream.pipeThrough(createOutputTransformStream(output != null ? output : text())).pipeThrough(eventProcessor); - const { maxRetries, retry } = prepareRetries({ + const { maxRetries, retry: retry2 } = prepareRetries({ maxRetries: maxRetriesArg, abortSignal }); @@ -57723,7 +58049,7 @@ var DefaultStreamTextResult = class { stopSequences: callSettings.stopSequences, seed: callSettings.seed, maxRetries, - timeout, + timeout: timeout2, headers, providerOptions, stopWhen, @@ -57907,7 +58233,7 @@ var DefaultStreamTextResult = class { activeTools: stepActiveTools, steps: [...recordedSteps], providerOptions: stepProviderOptions, - timeout, + timeout: timeout2, headers, stopWhen, output, @@ -57921,7 +58247,7 @@ var DefaultStreamTextResult = class { result: { stream: stream2, response, request }, doStreamSpan, startTimestampMs - } = await retry(() => recordSpan({ + } = await retry2(() => recordSpan({ name: "ai.streamText.doStream", attributes: selectTelemetryAttributes({ telemetry, @@ -58004,11 +58330,11 @@ var DefaultStreamTextResult = class { }; let activeText = ""; self.addStream(streamWithToolResults.pipeThrough(new TransformStream({ - async transform(chunk, controller) { + async transform(chunk2, controller) { var _a222, _b23, _c2, _d2, _e2; resetChunkTimeout(); - if (chunk.type === "stream-start") { - warnings = chunk.warnings; + if (chunk2.type === "stream-start") { + warnings = chunk2.warnings; return; } if (stepFirstChunk) { @@ -58026,70 +58352,70 @@ var DefaultStreamTextResult = class { warnings: warnings != null ? warnings : [] }); } - const chunkType = chunk.type; + const chunkType = chunk2.type; switch (chunkType) { case "tool-approval-request": case "text-start": case "text-end": { - controller.enqueue(chunk); + controller.enqueue(chunk2); break; } case "text-delta": { - if (chunk.delta.length > 0) { + if (chunk2.delta.length > 0) { controller.enqueue({ type: "text-delta", - id: chunk.id, - text: chunk.delta, - providerMetadata: chunk.providerMetadata + id: chunk2.id, + text: chunk2.delta, + providerMetadata: chunk2.providerMetadata }); - activeText += chunk.delta; + activeText += chunk2.delta; } break; } case "reasoning-start": case "reasoning-end": { - controller.enqueue(chunk); + controller.enqueue(chunk2); break; } case "reasoning-delta": { controller.enqueue({ type: "reasoning-delta", - id: chunk.id, - text: chunk.delta, - providerMetadata: chunk.providerMetadata + id: chunk2.id, + text: chunk2.delta, + providerMetadata: chunk2.providerMetadata }); break; } case "tool-call": { - controller.enqueue(chunk); - stepToolCalls.push(chunk); + controller.enqueue(chunk2); + stepToolCalls.push(chunk2); break; } case "tool-result": { - controller.enqueue(chunk); - if (!chunk.preliminary) { - stepToolOutputs.push(chunk); + controller.enqueue(chunk2); + if (!chunk2.preliminary) { + stepToolOutputs.push(chunk2); } break; } case "tool-error": { - controller.enqueue(chunk); - stepToolOutputs.push(chunk); + controller.enqueue(chunk2); + stepToolOutputs.push(chunk2); break; } case "response-metadata": { stepResponse = { - id: (_a222 = chunk.id) != null ? _a222 : stepResponse.id, - timestamp: (_b23 = chunk.timestamp) != null ? _b23 : stepResponse.timestamp, - modelId: (_c2 = chunk.modelId) != null ? _c2 : stepResponse.modelId + id: (_a222 = chunk2.id) != null ? _a222 : stepResponse.id, + timestamp: (_b23 = chunk2.timestamp) != null ? _b23 : stepResponse.timestamp, + modelId: (_c2 = chunk2.modelId) != null ? _c2 : stepResponse.modelId }; break; } case "finish": { - stepUsage = chunk.usage; - stepFinishReason = chunk.finishReason; - stepRawFinishReason = chunk.rawFinishReason; - stepProviderMetadata = chunk.providerMetadata; + stepUsage = chunk2.usage; + stepFinishReason = chunk2.finishReason; + stepRawFinishReason = chunk2.rawFinishReason; + stepProviderMetadata = chunk2.providerMetadata; const msToFinish = now2() - startTimestampMs; doStreamSpan.addEvent("ai.stream.finish"); doStreamSpan.setAttributes({ @@ -58099,59 +58425,59 @@ var DefaultStreamTextResult = class { break; } case "file": { - controller.enqueue(chunk); + controller.enqueue(chunk2); break; } case "source": { - controller.enqueue(chunk); + controller.enqueue(chunk2); break; } case "tool-input-start": { - activeToolCallToolNames[chunk.id] = chunk.toolName; - const tool2 = tools == null ? undefined : tools[chunk.toolName]; + activeToolCallToolNames[chunk2.id] = chunk2.toolName; + const tool2 = tools == null ? undefined : tools[chunk2.toolName]; if ((tool2 == null ? undefined : tool2.onInputStart) != null) { await tool2.onInputStart({ - toolCallId: chunk.id, + toolCallId: chunk2.id, messages: stepInputMessages, abortSignal, experimental_context }); } controller.enqueue({ - ...chunk, - dynamic: (_e2 = chunk.dynamic) != null ? _e2 : (tool2 == null ? undefined : tool2.type) === "dynamic", + ...chunk2, + dynamic: (_e2 = chunk2.dynamic) != null ? _e2 : (tool2 == null ? undefined : tool2.type) === "dynamic", title: tool2 == null ? undefined : tool2.title }); break; } case "tool-input-end": { - delete activeToolCallToolNames[chunk.id]; - controller.enqueue(chunk); + delete activeToolCallToolNames[chunk2.id]; + controller.enqueue(chunk2); break; } case "tool-input-delta": { - const toolName = activeToolCallToolNames[chunk.id]; + const toolName = activeToolCallToolNames[chunk2.id]; const tool2 = tools == null ? undefined : tools[toolName]; if ((tool2 == null ? undefined : tool2.onInputDelta) != null) { await tool2.onInputDelta({ - inputTextDelta: chunk.delta, - toolCallId: chunk.id, + inputTextDelta: chunk2.delta, + toolCallId: chunk2.id, messages: stepInputMessages, abortSignal, experimental_context }); } - controller.enqueue(chunk); + controller.enqueue(chunk2); break; } case "error": { - controller.enqueue(chunk); + controller.enqueue(chunk2); stepFinishReason = "error"; break; } case "raw": { if (includeRawChunks2) { - controller.enqueue(chunk); + controller.enqueue(chunk2); } break; } @@ -59248,46 +59574,30 @@ var frontendTools = (tools) => Object.fromEntries(Object.entries(tools).map(([na inputSchema: jsonSchema(tool2.parameters) } ])); -// ../core/types/ai-tools/renameFilesTask.ts -var BEGIN_RENAME_FILES_TASK = "begin-rename-files-task"; -var ADD_RENAME_FILE_TO_TASK = "add-rename-file-to-task"; -var END_RENAME_FILES_TASK = "end-rename-files-task"; -var BEGIN_RENAME_FILES_TASK_DESCRIPTION = "Begin a rename task V2 for batch renaming media files. " + "This tool creates a task that can be used to add multiple files for renaming. " + `Use ${ADD_RENAME_FILE_TO_TASK} to add files, then ${END_RENAME_FILES_TASK} to execute.`; -var ADD_RENAME_FILE_TO_TASK_DESCRIPTION = "Add a file to a rename task. " + `This tool adds a single file to an existing task created by ${BEGIN_RENAME_FILES_TASK}. ` + "Provide the task ID, current file path, and new file path."; -var END_RENAME_FILES_TASK_DESCRIPTION = "End a rename task and execute the batch rename operation. " + `This tool finalizes the task created by ${BEGIN_RENAME_FILES_TASK} and ` + "executes all pending file renames."; -var beginRenameFilesTaskInputSchema = exports_external.object({ - mediaFolderPath: exports_external.string().describe("The absolute path of the media folder, it can be POSIX format or Windows format") -}); -var addRenameFileToTaskInputSchema = exports_external.object({ - taskId: exports_external.string().describe(`The task ID from ${BEGIN_RENAME_FILES_TASK}`), - from: exports_external.string().describe("Current absolute path of the video file to rename (POSIX or Windows format)"), - to: exports_external.string().describe("New absolute path for the file (POSIX or Windows format)") -}); -var endRenameFilesTaskInputSchema = exports_external.object({ - taskId: exports_external.string().describe(`The task ID from ${BEGIN_RENAME_FILES_TASK}`) -}); - -// ../core/types/ai-tools/recognizeMediaFileTask.ts -var BEGIN_RECOGNIZE_TASK = "begin-recognize-task"; -var ADD_RECOGNIZED_MEDIA_FILE = "add-recognized-media-file"; -var END_RECOGNIZE_TASK = "end-recognize-task"; -var BEGIN_RECOGNIZE_TASK_DESCRIPTION = "Begin a recognition task for identifying media files. " + "This tool creates a task that can be used to add media files for recognition. " + `Use ${ADD_RECOGNIZED_MEDIA_FILE} to add files, then ${END_RECOGNIZE_TASK} to execute.`; -var ADD_RECOGNIZED_MEDIA_FILE_DESCRIPTION = "Add a recognized media file to a recognition task. " + `This tool adds a single video file to an existing task created by ${BEGIN_RECOGNIZE_TASK}. ` + "Provide the task ID, season number, episode number, and file path."; -var END_RECOGNIZE_TASK_DESCRIPTION = "End a recognition task and execute the recognition. " + `This tool finalizes the task created by ${BEGIN_RECOGNIZE_TASK} and ` + "processes all added media files."; -var beginRecognizeTaskInputSchema = exports_external.object({ - mediaFolderPath: exports_external.string().describe("The absolute path of the media folder, it can be POSIX format or Windows format") -}); -var addRecognizedMediaFileInputSchema = exports_external.object({ - taskId: exports_external.string().describe(`The task ID returned from ${BEGIN_RECOGNIZE_TASK}`), - season: exports_external.number().describe("The season number of the episode."), - episode: exports_external.number().describe("The episode number."), - path: exports_external.string().describe("The absolute path of the media file (POSIX or Windows format).") -}); -var endRecognizeTaskInputSchema = exports_external.object({ - taskId: exports_external.string().describe(`The task ID returned from ${BEGIN_RECOGNIZE_TASK}`) -}); - -// ../core/types/ai-tools/getApplicationContext.ts +// ../types/ai-tools/createRenameEpisodePlan.ts +var CREATE_RENAME_EPISODE_PLAN = "create-rename-episode-plan"; +var CREATE_RENAME_EPISODE_PLAN_DESCRIPTION = "Create a rename-files plan for TV episode video files with explicit from/to paths. " + "After success, tell the user to open SMM, review, and approve the plan."; +var createRenameEpisodePlanInputSchema = exports_external.object({ + mediaFolderPath: exports_external.string().describe("Absolute media folder path (POSIX or Windows)"), + files: exports_external.array(exports_external.object({ + from: exports_external.string().describe("Current absolute video path"), + to: exports_external.string().describe("New absolute video path") + })).min(1) +}); + +// ../types/ai-tools/createRecognizeEpisodePlan.ts +var CREATE_RECOGNIZE_EPISODE_PLAN = "create-recognize-episode-plan"; +var CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION = "Create a recognize-media-file plan that maps episode video files to season/episode numbers. " + "Provide every mapping (season, episode, absolute file path) in one call. " + "After success, tell the user to open SMM, review, and approve the plan."; +var createRecognizeEpisodePlanInputSchema = exports_external.object({ + mediaFolderPath: exports_external.string().describe("Absolute media folder path (POSIX or Windows)"), + files: exports_external.array(exports_external.object({ + season: exports_external.number().describe("The season number of the episode."), + episode: exports_external.number().describe("The episode number."), + path: exports_external.string().describe("The absolute path of the media file (POSIX or Windows format).") + })).min(1) +}); + +// ../types/ai-tools/getApplicationContext.ts var GET_APPLICATION_CONTEXT = "get-app-context"; var GET_APPLICATION_CONTEXT_DESCRIPTION = `Get SMM context: ` + ` * The media folder user selected/focused on SMM UI @@ -59299,7 +59609,7 @@ var getApplicationContextOutputSchema = exports_external.object({ error: exports_external.string().optional().describe("Error message if the operation failed") }); -// ../core/types/ai-tools/getMediaMetadata.ts +// ../types/ai-tools/getMediaMetadata.ts var GET_MEDIA_METADATA = "get-media-metadata"; var GET_MEDIA_METADATA_DESCRIPTION = "Get cached media metadata for a media folder. Returns normalized TV show " + "season/episode data and TMDB/TVDB movie information when available. " + "Use list-files-in-media-folder for raw file paths; episode-to-file mappings " + "are not included in this response."; var GET_MEDIA_METADATA_NOT_MANAGED = "Media folder not found. The folder path may not be correct or the folder is not managed by SMM"; @@ -59355,7 +59665,7 @@ var getMediaMetadataToolOutputSchema = getMediaMetadataDataSchema.extend({ error: exports_external.string().optional().describe("Error message when lookup failed") }); -// ../core/types/ai-tools/getEpisodes.ts +// ../types/ai-tools/getEpisodes.ts var GET_EPISODES = "get-episodes"; var GET_EPISODES_DESCRIPTION = "Get all episodes for a TV show with their video file paths. " + "Combines TMDB or TVDB episode data (from cached metadata) with local media file paths. " + "For each episode, returns season, episode number, and video file path. " + "The video file path may be undefined if the episode has not been recognized yet."; var GET_EPISODES_INVALID_PATH = "Invalid path: 'mediaFolderPath' must be a non-empty string"; @@ -59380,7 +59690,7 @@ var getEpisodesToolOutputSchema = getEpisodesDataSchema.extend({ error: exports_external.string().optional() }); -// ../core/types/ai-tools/listFilesInMediaFolder.ts +// ../types/ai-tools/listFilesInMediaFolder.ts var LIST_FILES_IN_MEDIA_FOLDER = "list-files-in-media-folder"; var LIST_FILES_IN_MEDIA_FOLDER_DESCRIPTION = "List files in a media folder by scanning the file system recursively. " + "Returns file paths in OS-native format. Use videoFileOnly to restrict to video files."; var LIST_FILES_IN_MEDIA_FOLDER_INVALID_PATH = "Invalid path: 'mediaFolderPath' must be a non-empty string"; @@ -59398,7 +59708,214 @@ var listFilesInMediaFolderOutputSchema = listFilesInMediaFolderDataSchema.extend error: exports_external.string().optional() }); -// ../core/ai-tool/systemPrompt.ts +// ../types/ai-tools/scrape.ts +var SCRAPE = "scrape"; +var SCRAPE_DESCRIPTION = "Start a scrape job for a managed TV show or movie folder (poster, fanart, thumbnails, nfo). " + "Returns a job id immediately; the scrape runs in the background. " + "Call get-job with the returned id to check progress and per-task status. " + `Supports TMDB and TVDB. Movie folders skip thumbnails. + +` + 'Example: Scrape media folder "/path/to/Show".'; +var SCRAPE_JOB_CREATED_MESSAGE = "scrape job created, use get-job tool to check job status by id."; +var scrapeInputSchema = exports_external.object({ + path: exports_external.string().describe("Absolute path of the managed media folder to scrape (POSIX or Windows format)"), + language: exports_external.string().optional().describe("Optional language code for metadata/assets (defaults to user preferMediaLanguage)") +}); +var scrapeOutputSchema = exports_external.object({ + id: exports_external.string().describe("Scrape job id; pass to get-job to poll status"), + message: exports_external.string().describe("Guidance for checking job status with get-job"), + error: exports_external.string().optional().describe("Error message when the scrape job could not be started") +}); + +// ../types/ai-tools/getJob.ts +var GET_JOB = "get-job"; +var GET_JOB_DESCRIPTION = "Get the status of a background job by id. " + 'Supports scrape jobs (kind: "scrape" with poster/fanart/thumbnails/nfo tasks) ' + 'and import jobs (kind: "import"). ' + `Poll until status is succeeded, failed, or aborted. + +` + 'Example: Check job status for id "550e8400-e29b-41d4-a716-446655440000".'; +var jobStatusSchema = exports_external.enum([ + "pending", + "running", + "succeeded", + "failed", + "aborted" +]); +var scrapeTaskRuntimeStatusSchema = exports_external.enum([ + "pending", + "running", + "skipped", + "completed", + "failed" +]); +var scrapeJobTaskSchema = exports_external.object({ + status: scrapeTaskRuntimeStatusSchema, + error: exports_external.string().optional() +}); +var scrapeJobSchema = exports_external.object({ + kind: exports_external.literal("scrape"), + id: exports_external.string(), + folderPath: exports_external.string(), + status: jobStatusSchema, + tasks: exports_external.object({ + poster: scrapeJobTaskSchema, + fanart: scrapeJobTaskSchema, + thumbnails: scrapeJobTaskSchema, + nfo: scrapeJobTaskSchema + }), + error: exports_external.string().optional(), + createdAt: exports_external.number(), + updatedAt: exports_external.number() +}); +var importJobSchema = exports_external.object({ + kind: exports_external.literal("import"), + id: exports_external.string(), + folderPath: exports_external.string(), + type: exports_external.string(), + status: jobStatusSchema, + stage: exports_external.string().nullable(), + progress: exports_external.number(), + recognizedTitle: exports_external.string().optional(), + error: exports_external.string().optional(), + createdAt: exports_external.number(), + updatedAt: exports_external.number() +}); +var jobSchema = exports_external.discriminatedUnion("kind", [ + scrapeJobSchema, + importJobSchema +]); +var getJobInputSchema = exports_external.object({ + id: exports_external.string().describe("Job id returned by scrape or import-folder") +}); +var getJobOutputSchema = exports_external.object({ + job: jobSchema.optional().describe("Job payload when found"), + error: exports_external.string().optional().describe("Error message when the job could not be loaded") +}); + +// ../types/ai-tools/tmdbCommon.ts +var tmdbLanguageSchema = exports_external.string().optional().describe("TMDB primary translation IETF tag (e.g. zh-CN, en-US). Defaults from userConfig.preferMediaLanguage."); +var tmdbBaseUrlSchema = exports_external.string().optional().describe("Optional TMDB API base URL override (defaults from userConfig.tmdb.host)"); +function toTmdbCoreOptions(params) { + const host = params.baseURL?.trim(); + return { + language: params.language, + host: host || undefined + }; +} +function formatTmdbToolError(error48) { + const message = error48 instanceof Error ? error48.message : String(error48); + return message.startsWith("Error Reason:") ? message : `Error Reason: ${message}`; +} + +// ../types/ai-tools/tmdbSearch.ts +var TMDB_SEARCH = "tmdb-search"; +var TMDB_SEARCH_DESCRIPTION = "Search TMDB (The Movie Database) for movies or TV shows by keyword. " + `Returns matching results with title, release date, overview, and TMDB ID. + +` + 'Example: Search TV shows matching "naruto".'; +var tmdbSearchInputSchema = exports_external.object({ + keyword: exports_external.string().describe("Search keyword"), + type: exports_external.enum(["tv", "movie"]).describe("Media type to search"), + language: tmdbLanguageSchema, + baseURL: tmdbBaseUrlSchema +}); +var tmdbSearchOutputSchema = exports_external.object({ + results: exports_external.array(exports_external.record(exports_external.string(), exports_external.unknown())).optional(), + page: exports_external.number().optional(), + total_pages: exports_external.number().optional(), + total_results: exports_external.number().optional(), + error: exports_external.string().optional() +}); + +// ../types/ai-tools/tmdbGetMovie.ts +var TMDB_GET_MOVIE = "tmdb-get-movie"; +var TMDB_GET_MOVIE_DESCRIPTION = "Retrieve detailed movie information from TMDB by TMDB ID. " + `Includes title, overview, release date, runtime, genres, poster images, and more. + +` + "Example: Get movie details for TMDB id 550."; +var tmdbGetMovieInputSchema = exports_external.object({ + id: exports_external.number().int().positive().describe("TMDB movie id"), + language: tmdbLanguageSchema, + baseURL: tmdbBaseUrlSchema +}); +var tmdbGetMovieOutputSchema = exports_external.object({ + error: exports_external.string().optional() +}).passthrough(); + +// ../types/ai-tools/tmdbGetTvShow.ts +var TMDB_GET_TV_SHOW = "tmdb-get-tv-show"; +var TMDB_GET_TV_SHOW_DESCRIPTION = "Retrieve detailed TV show information from TMDB by TMDB ID, including seasons and episodes " + `with titles, overviews, and air dates. + +` + "Example: Get TV show details for TMDB id 31917."; +var tmdbGetTvShowInputSchema = exports_external.object({ + id: exports_external.number().int().positive().describe("TMDB TV series id"), + language: tmdbLanguageSchema, + baseURL: tmdbBaseUrlSchema +}); +var tmdbGetTvShowOutputSchema = exports_external.object({ + error: exports_external.string().optional() +}).passthrough(); + +// ../types/ai-tools/tvdbCommon.ts +var tvdbLanguageSchema = exports_external.string().optional().describe("TVDB ISO 639-3 language code (e.g. eng, zho, yue). Defaults from userConfig.preferMediaLanguage."); +var tvdbBaseUrlSchema = exports_external.string().optional().describe("Optional TVDB API base URL override (defaults from userConfig.tvdb.host)"); +function toTvdbCoreOptions(params) { + const host = params.baseURL?.trim(); + return { + language: params.language, + host: host || undefined + }; +} +function formatTvdbToolError(error48) { + const message = error48 instanceof Error ? error48.message : String(error48); + return message.startsWith("Error Reason:") ? message : `Error Reason: ${message}`; +} + +// ../types/ai-tools/tvdbSearch.ts +var TVDB_SEARCH = "tvdb-search"; +var TVDB_SEARCH_DESCRIPTION = "Search TVDB (TheTVDB) for TV series or movies by keyword. " + `Returns matching results with title, overview, and TVDB ID. + +` + 'Example: Search series matching "naruto".'; +var tvdbSearchInputSchema = exports_external.object({ + keyword: exports_external.string().describe("Search keyword"), + type: exports_external.enum(["series", "movie"]).describe("Media type to search"), + language: tvdbLanguageSchema, + baseURL: tvdbBaseUrlSchema +}); +var tvdbSearchOutputSchema = exports_external.object({ + results: exports_external.array(exports_external.record(exports_external.string(), exports_external.unknown())).optional(), + error: exports_external.string().optional() +}); + +// ../types/ai-tools/tvdbGetMovie.ts +var TVDB_GET_MOVIE = "tvdb-get-movie"; +var TVDB_GET_MOVIE_DESCRIPTION = `Retrieve movie metadata from TVDB by TVDB ID, including the localized title. + +` + "Example: Get movie metadata for TVDB id 7."; +var tvdbGetMovieInputSchema = exports_external.object({ + id: exports_external.number().int().positive().describe("TVDB movie id"), + language: tvdbLanguageSchema, + baseURL: tvdbBaseUrlSchema +}); +var tvdbGetMovieOutputSchema = exports_external.object({ error: exports_external.string().optional() }).passthrough(); + +// ../types/ai-tools/tvdbGetTvShow.ts +var TVDB_GET_TV_SHOW = "tvdb-get-tv-show"; +var TVDB_GET_TV_SHOW_DESCRIPTION = `Retrieve TV series metadata from TVDB by TVDB ID, including seasons and episodes with localized titles. + +` + "Example: Get TV series metadata for TVDB id 42."; +var tvdbGetTvShowInputSchema = exports_external.object({ + id: exports_external.number().int().positive().describe("TVDB series id"), + language: tvdbLanguageSchema, + baseURL: tvdbBaseUrlSchema +}); +var tvdbGetTvShowOutputSchema = exports_external.object({ error: exports_external.string().optional() }).passthrough(); + +// ../types/ai-tools/tvdbGetLanguages.ts +var TVDB_GET_LANGUAGES = "tvdb-get-languages"; +var TVDB_GET_LANGUAGES_DESCRIPTION = "Retrieve the list of TVDB supported languages (ISO 639-3 codes). Useful for picking a search language."; +var tvdbGetLanguagesInputSchema = exports_external.object({ + baseURL: tvdbBaseUrlSchema +}); +var tvdbGetLanguagesOutputSchema = exports_external.object({ + languages: exports_external.array(exports_external.record(exports_external.string(), exports_external.unknown())).optional(), + error: exports_external.string().optional() +}); + +// ../../apps/core/src/ai-tool/systemPrompt.ts var SYSTEM_PROMPT = `You're a helpful assistant for Simple Media Manager(SMM) software. SMM is a media manager that helps user to manage their TV Show, anime, movie or music. SMM holds multiple media folders and user can switch between them. @@ -59426,10 +59943,8 @@ Below is the steps to recognize media file: If user don't tell which folder he is asking for, you should call "${GET_APPLICATION_CONTEXT}" to get the selected media folder in UI. 2. Get episodes using "${GET_EPISODES}" tool 3. Get local files using "${LIST_FILES_IN_MEDIA_FOLDER}" tool -4. Call "${BEGIN_RECOGNIZE_TASK}" tool to notify AI Agent to start a recognize task -5. iterate each episodes, find the local video file for the episode, and call "${ADD_RECOGNIZED_MEDIA_FILE}" tool to add the recognized media file to the task +4. Call "${CREATE_RECOGNIZE_EPISODE_PLAN}" once with mediaFolderPath and a files array of season/episode/path pairs for every recognized video file IMPORTANT: It's OK to skip the episode if the local video file is not found. -6. Call "${END_RECOGNIZE_TASK}" tool to notify AI Agent to end the recognize task ### Rename Files @@ -59441,9 +59956,32 @@ You ONLY need to rename the video file. For image files, subtitle files, nfo fil Steps [ ] Call "${GET_MEDIA_METADATA}" to get the video files needs to rename -[ ] Call "${BEGIN_RENAME_FILES_TASK}" to notify AI Agent to start a rename files task -[ ] Call "${ADD_RENAME_FILE_TO_TASK}" to add a file to rename task, call multiple times to add multiple files -[ ] Call "${END_RENAME_FILES_TASK}" to notify AI Agent to end the rename files task +[ ] Call "${CREATE_RENAME_EPISODE_PLAN}" once with mediaFolderPath and a files array of from/to pairs for every video to rename + +### Scrape Media Artwork and NFO + +When user asks to scrape, download poster/fanart/thumbnails, or write NFO files for a media folder, +use the scrape job tools: + +1. Resolve which media folder (ask user or call "${GET_APPLICATION_CONTEXT}"). +2. Call "${SCRAPE}" with the folder path (optional language). It returns a job id immediately. +3. Call "${GET_JOB}" with that id to check progress. Poll until status is succeeded, failed, or aborted. +4. Report per-task results (poster, fanart, thumbnails, nfo) from the scrape job. + +### TMDB Search and Details + +When user asks to search TMDB, find a TV show or movie on TMDB, or look up TMDB metadata by id: + +1. Call "${TMDB_SEARCH}" with keyword and type (\`tv\` or \`movie\`) to find candidates. +2. Call "${TMDB_GET_TV_SHOW}" or "${TMDB_GET_MOVIE}" with the chosen TMDB id for full details (seasons/episodes for TV). + +### TVDB Search and Details + +When user asks to search TVDB, find a TV show or movie on TVDB, or look up TVDB metadata by id: + +1. Call "${TVDB_SEARCH}" with keyword and type (\`series\` or \`movie\`) to find candidates. +2. Call "${TVDB_GET_TV_SHOW}" or "${TVDB_GET_MOVIE}" with the chosen TVDB id for full metadata (seasons/episodes for TV). +3. Use "${TVDB_GET_LANGUAGES}" to discover supported ISO 639-3 language codes when needed. ## User Preferences @@ -59469,7 +60007,7 @@ EpisodeName: The episode name Extension: The file extension, such as "mp4", "mkv", "avi", ... `; -// ../core/types/ai-tools/isFolderExist.ts +// ../types/ai-tools/isFolderExist.ts var IS_FOLDER_EXIST = "is-folder-exist"; var IS_FOLDER_EXIST_DESCRIPTION = "Check if a folder exists in the file system. " + "Returns `{ exists, path, reason? }` where `exists` is true when " + "the path is an existing directory."; var IS_FOLDER_EXIST_INVALID_PATH = "Invalid path: path must be a non-empty string"; @@ -59484,7 +60022,7 @@ var isFolderExistOutputSchema = exports_external.object({ reason: exports_external.string().optional().describe("Reason for non-existence or non-directory") }); -// ../core/types/ai-tools/getMediaFolders.ts +// ../types/ai-tools/getMediaFolders.ts var GET_MEDIA_FOLDERS = "get-media-folders"; var GET_MEDIA_FOLDERS_DESCRIPTION = "Get the list of media folders managed by SMM."; var getMediaFoldersInputSchema = exports_external.object({}); @@ -59495,7 +60033,7 @@ var getMediaFoldersOutputSchema = getMediaFoldersDataSchema.extend({ error: exports_external.string().optional() }); -// ../core/types/ai-tools/renameFolder.ts +// ../types/ai-tools/renameFolder.ts var RENAME_FOLDER = "rename-folder"; var RENAME_FOLDER_DESCRIPTION = "Rename a media folder in SMM. " + "This tool accepts the source folder path and destination folder path. " + "This tool should ONLY be used to rename FOLDER, NOT FILE. " + `This tool will update media metadata accordingly. @@ -59512,7 +60050,34 @@ var renameFolderOutputSchema = exports_external.object({ }); var RENAME_FOLDER_CANCELLED = "User cancelled the operation"; -// ../core/locale.ts +// ../types/ai-tools/renameEpisodeFile.ts +var RENAME_EPISODE_FILE = "rename-episode-file"; +var RENAME_EPISODE_FILE_DESCRIPTION = "Rename a linked TV episode video file (and same-stem associates such as subtitles) in a managed TV show folder. " + "Use ONLY for a single episode file that already has seasonNumber and episodeNumber in media metadata. " + "Do NOT use for folders (use rename-folder), movies, orphan files, or bulk renames " + `(use create-rename-episode-plan for multi-file plans). + +` + 'Example: Rename episode file in folder "/path/to/show" from ".../S01E01.mp4" to ".../S01E01_renamed.mp4".'; +var renameEpisodeFileInputSchema = exports_external.object({ + mediaFolder: exports_external.string().describe("Absolute path of the managed TV show media folder (POSIX or Windows format)"), + from: exports_external.string().describe("Absolute current path of the linked episode video file (POSIX or Windows format)"), + to: exports_external.string().describe("Absolute target path for the episode video file under the same media folder (POSIX or Windows format)") +}); +var renameEpisodeFileOutputSchema = exports_external.object({ + renamed: exports_external.boolean().describe("True when at least one file was renamed successfully"), + mediaFolder: exports_external.string().describe("The media folder path after normalization"), + from: exports_external.string().describe("The primary source episode path after normalization"), + to: exports_external.string().describe("The primary destination episode path after normalization"), + succeeded: exports_external.array(exports_external.object({ + from: exports_external.string(), + to: exports_external.string() + })).describe("Successful rename pairs (episode + associates)"), + failed: exports_external.array(exports_external.object({ + path: exports_external.string(), + error: exports_external.string() + })).describe("Per-path failures"), + error: exports_external.string().optional().describe("Error or cancellation message when rename did not fully succeed") +}); +var RENAME_EPISODE_FILE_CANCELLED = "User cancelled the operation"; + +// ../utils/src/locale.ts var APP_LANGUAGE_FALLBACK = "en"; function normalizeToAppLanguage(raw) { const lng = raw.trim(); @@ -59561,13 +60126,259 @@ function detectOsLocale() { return ""; } +// ../../apps/core/src/ai-tool/toolResult.ts +function toolOk(data) { + return { ...data, error: undefined }; +} +function toolError(reason) { + const message = reason.startsWith("Error Reason:") ? reason : `Error Reason: ${reason}`; + return { error: message }; +} +function requireNonEmptyString(value, field) { + if (typeof value !== "string" || value.trim() === "") { + return { error: `Invalid ${field}: must be a non-empty string` }; + } + return value; +} +function messageFromUnknownError(error48) { + if (error48 instanceof Error) { + return error48.message; + } + if (typeof error48 === "string") { + return error48; + } + if (error48 === null || error48 === undefined) { + return "Unknown error (null/undefined thrown)"; + } + try { + const json3 = JSON.stringify(error48); + if (json3 && json3 !== "{}") { + return json3; + } + } catch {} + const text2 = String(error48); + return text2 || "Unknown error"; +} +function formatToolError(error48) { + return toolError(messageFromUnknownError(error48)); +} + +// src/tools/tmdb.ts +function unavailable(message) { + return { error: message }; +} +function assertNotAborted(abortSignal) { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); + } +} +async function executeTmdbSearch(params, runner, abortSignal) { + assertNotAborted(abortSignal); + const keywordCheck = requireNonEmptyString(params.keyword, "keyword"); + if (typeof keywordCheck !== "string") { + return { error: keywordCheck.error }; + } + if (!runner) { + return unavailable("tmdb-search is not available on this host"); + } + try { + const body = await runner(keywordCheck, { + type: params.type, + ...toTmdbCoreOptions(params) + }); + if (body.error) { + return { error: body.error }; + } + return { + results: body.results, + page: body.page, + total_pages: body.total_pages, + total_results: body.total_results + }; + } catch (error48) { + return { error: formatTmdbToolError(error48) }; + } +} +async function executeTmdbGetMovie(params, runner, abortSignal) { + assertNotAborted(abortSignal); + if (!Number.isInteger(params.id) || params.id <= 0) { + return { error: "Invalid id: 'id' must be a positive integer" }; + } + if (!runner) { + return unavailable("tmdb-get-movie is not available on this host"); + } + try { + const details = await runner(params.id, toTmdbCoreOptions(params)); + return details; + } catch (error48) { + return { error: formatTmdbToolError(error48) }; + } +} +async function executeTmdbGetTvShow(params, runner, abortSignal) { + assertNotAborted(abortSignal); + if (!Number.isInteger(params.id) || params.id <= 0) { + return { error: "Invalid id: 'id' must be a positive integer" }; + } + if (!runner) { + return unavailable("tmdb-get-tv-show is not available on this host"); + } + try { + const details = await runner(params.id, toTmdbCoreOptions(params)); + return details; + } catch (error48) { + return { error: formatTmdbToolError(error48) }; + } +} +function buildTmdbSearchTool(runners, abortSignal) { + return { + description: TMDB_SEARCH_DESCRIPTION, + inputSchema: tmdbSearchInputSchema, + outputSchema: tmdbSearchOutputSchema, + execute: async (args) => { + return executeTmdbSearch(args ?? {}, runners?.searchInTmdb, abortSignal); + } + }; +} +function buildTmdbGetMovieTool(runners, abortSignal) { + return { + description: TMDB_GET_MOVIE_DESCRIPTION, + inputSchema: tmdbGetMovieInputSchema, + outputSchema: tmdbGetMovieOutputSchema, + execute: async (args) => { + return executeTmdbGetMovie(args ?? {}, runners?.getMovieInTmdb, abortSignal); + } + }; +} +function buildTmdbGetTvShowTool(runners, abortSignal) { + return { + description: TMDB_GET_TV_SHOW_DESCRIPTION, + inputSchema: tmdbGetTvShowInputSchema, + outputSchema: tmdbGetTvShowOutputSchema, + execute: async (args) => { + return executeTmdbGetTvShow(args ?? {}, runners?.getTvShowInTmdb, abortSignal); + } + }; +} + +// src/tools/tvdb.ts +function unavailable2(message) { + return { error: message }; +} +function assertNotAborted2(abortSignal) { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); + } +} +async function executeTvdbSearch(params, runner, abortSignal) { + assertNotAborted2(abortSignal); + const keywordCheck = requireNonEmptyString(params.keyword, "keyword"); + if (typeof keywordCheck !== "string") { + return { error: keywordCheck.error }; + } + if (!runner) { + return unavailable2("tvdb-search is not available on this host"); + } + try { + const results = await runner(keywordCheck, { + type: params.type, + ...toTvdbCoreOptions(params) + }); + return { results }; + } catch (error48) { + return { error: formatTvdbToolError(error48) }; + } +} +async function executeTvdbGetMovie(params, runner, abortSignal) { + assertNotAborted2(abortSignal); + if (!Number.isInteger(params.id) || params.id <= 0) { + return { error: "Invalid id: 'id' must be a positive integer" }; + } + if (!runner) { + return unavailable2("tvdb-get-movie is not available on this host"); + } + try { + const details = await runner(params.id, toTvdbCoreOptions(params)); + return details; + } catch (error48) { + return { error: formatTvdbToolError(error48) }; + } +} +async function executeTvdbGetTvShow(params, runner, abortSignal) { + assertNotAborted2(abortSignal); + if (!Number.isInteger(params.id) || params.id <= 0) { + return { error: "Invalid id: 'id' must be a positive integer" }; + } + if (!runner) { + return unavailable2("tvdb-get-tv-show is not available on this host"); + } + try { + const details = await runner(params.id, toTvdbCoreOptions(params)); + return details; + } catch (error48) { + return { error: formatTvdbToolError(error48) }; + } +} +async function executeTvdbGetLanguages(runner, _params = {}, abortSignal) { + assertNotAborted2(abortSignal); + if (!runner) { + return unavailable2("tvdb-get-languages is not available on this host"); + } + try { + const languages = await runner(toTvdbCoreOptions(_params)); + return { languages }; + } catch (error48) { + return { error: formatTvdbToolError(error48) }; + } +} +function buildTvdbSearchTool(runners, abortSignal) { + return { + description: TVDB_SEARCH_DESCRIPTION, + inputSchema: tvdbSearchInputSchema, + outputSchema: tvdbSearchOutputSchema, + execute: async (args) => { + return executeTvdbSearch(args ?? {}, runners?.searchInTvdb, abortSignal); + } + }; +} +function buildTvdbGetMovieTool(runners, abortSignal) { + return { + description: TVDB_GET_MOVIE_DESCRIPTION, + inputSchema: tvdbGetMovieInputSchema, + outputSchema: tvdbGetMovieOutputSchema, + execute: async (args) => { + return executeTvdbGetMovie(args ?? {}, runners?.getMovieInTvdb, abortSignal); + } + }; +} +function buildTvdbGetTvShowTool(runners, abortSignal) { + return { + description: TVDB_GET_TV_SHOW_DESCRIPTION, + inputSchema: tvdbGetTvShowInputSchema, + outputSchema: tvdbGetTvShowOutputSchema, + execute: async (args) => { + return executeTvdbGetTvShow(args ?? {}, runners?.getTvShowInTvdb, abortSignal); + } + }; +} +function buildTvdbGetLanguagesTool(runners, abortSignal) { + return { + description: TVDB_GET_LANGUAGES_DESCRIPTION, + inputSchema: tvdbGetLanguagesInputSchema, + outputSchema: tvdbGetLanguagesOutputSchema, + execute: async (args) => { + return executeTvdbGetLanguages(runners?.getTvdbLanguages, args ?? {}, abortSignal); + } + }; +} + // src/chatFs.ts -var import_promises = require("node:fs/promises"); +var import_promises2 = require("node:fs/promises"); +var import_node_path = require("node:path"); function defaultChatFs() { return { async readJson(filePath) { try { - const contents = await import_promises.readFile(filePath, "utf-8"); + const contents = await import_promises2.readFile(Path.toPlatformPath(filePath), "utf-8"); return JSON.parse(contents); } catch (error48) { if (error48.code === "ENOENT") { @@ -59578,11 +60389,13 @@ function defaultChatFs() { }, async writeJson(filePath, value) { const serialized = JSON.stringify(value, null, 2); - await import_promises.writeFile(filePath, serialized, "utf-8"); + const platformPath = Path.toPlatformPath(filePath); + await import_promises2.mkdir(import_node_path.dirname(platformPath), { recursive: true }); + await import_promises2.writeFile(platformPath, serialized, "utf-8"); }, async exists(filePath) { try { - await import_promises.stat(filePath); + await import_promises2.stat(Path.toPlatformPath(filePath)); return true; } catch { return false; @@ -59639,296 +60452,8 @@ async function resolveSelectedMediaFolder(clientId, acknowledge) { }, 1000); return responseData?.selectedMediaMetadata?.mediaFolderPath ?? ""; } -// ../../node_modules/.pnpm/es-toolkit@1.44.0/node_modules/es-toolkit/dist/array/flatten.mjs -function flatten(arr, depth = 1) { - const result = []; - const flooredDepth = Math.floor(depth); - const recursive = (arr2, currentDepth) => { - for (let i = 0;i < arr2.length; i++) { - const item = arr2[i]; - if (Array.isArray(item) && currentDepth < flooredDepth) { - recursive(item, currentDepth + 1); - } else { - result.push(item); - } - } - }; - recursive(arr, 0); - return result; -} - -// ../../node_modules/.pnpm/es-toolkit@1.44.0/node_modules/es-toolkit/dist/array/flattenDeep.mjs -function flattenDeep(arr) { - return flatten(arr, Infinity); -} -// ../../node_modules/.pnpm/es-toolkit@1.44.0/node_modules/es-toolkit/dist/array/last.mjs -function last(arr) { - return arr[arr.length - 1]; -} -// ../../node_modules/.pnpm/slash@5.1.0/node_modules/slash/index.js -function slash(path) { - const isExtendedLengthPath = path.startsWith("\\\\?\\"); - if (isExtendedLengthPath) { - return path; - } - return path.replace(/\\/g, "/"); -} - -// ../../node_modules/.pnpm/filename-reserved-regex@4.0.0/node_modules/filename-reserved-regex/index.js -function filenameReservedRegex() { - return /[<>:"/\\|?*\u0000-\u001F]|[. ]$/g; -} -function windowsReservedNameRegex() { - return /^(con|prn|aux|nul|com\d|lpt\d)$/i; -} - -// ../../node_modules/.pnpm/filenamify@7.0.1/node_modules/filenamify/filenamify.js -var MAX_FILENAME_LENGTH = 100; -var reRelativePath = /^\.+(\\|\/)|^\.+$/; -var reTrailingDotsAndSpaces = /[. ]+$/; -var reControlChars = /[\p{Control}\p{Format}\p{Zl}\p{Zp}\uFFF0-\uFFFF]/gu; -var reControlCharsTest = /[\p{Control}\p{Format}\p{Zl}\p{Zp}\uFFF0-\uFFFF]/u; -var isZeroWidthJoiner = (char) => char === "‍"; -var reRepeatedReservedCharacters = /([<>:"/\\|?*\u0000-\u001F]){2,}/g; -var reReplacementReservedCharacters = /[<>:"/\\|?*\u0000-\u001F]/; -var reUnicodeWhitespace = /[\t\n\r\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]+/g; -var segmenter; -function getSegmenter() { - segmenter ??= new Intl.Segmenter(undefined, { granularity: "grapheme" }); - return segmenter; -} -function truncateFilename(filename, maxLength) { - if (filename.length <= maxLength) { - return filename; - } - const extensionIndex = filename.lastIndexOf("."); - if (extensionIndex === -1) { - return truncateByGraphemeBudget(filename, maxLength); - } - const base = filename.slice(0, extensionIndex); - const extension = filename.slice(extensionIndex); - const baseBudget = Math.max(0, maxLength - extension.length); - const truncatedBase = truncateByGraphemeBudget(base, baseBudget); - return truncatedBase.replace(/ +$/, "") + extension; -} -function filenamify(string4, options = {}) { - if (typeof string4 !== "string") { - throw new TypeError("Expected a string"); - } - const replacement = options.replacement ?? "!"; - const hasReservedChars = reReplacementReservedCharacters.test(replacement); - const hasControlChars = [...replacement].some((char) => reControlCharsTest.test(char) && !isZeroWidthJoiner(char)); - if (hasReservedChars || hasControlChars) { - throw new Error("Replacement string cannot contain reserved filename characters"); - } - string4 = string4.normalize("NFC"); - string4 = string4.replaceAll(reUnicodeWhitespace, " "); - if (replacement.length > 0) { - string4 = string4.replaceAll(reRepeatedReservedCharacters, "$1"); - } - string4 = string4.replace(reTrailingDotsAndSpaces, ""); - string4 = string4.replace(reRelativePath, replacement); - string4 = string4.replace(filenameReservedRegex(), replacement); - string4 = string4.replaceAll(reControlChars, (char) => isZeroWidthJoiner(char) ? char : replacement); - string4 = string4.replace(reTrailingDotsAndSpaces, ""); - if (string4.length === 0) { - string4 = replacement.replace(reTrailingDotsAndSpaces, ""); - if (string4.length === 0 && replacement.length > 0) { - string4 = "!"; - } - } - const allowedLength = typeof options.maxLength === "number" ? options.maxLength : MAX_FILENAME_LENGTH; - string4 = truncateFilename(string4, allowedLength); - string4 = string4.replace(reTrailingDotsAndSpaces, ""); - if (windowsReservedNameRegex().test(string4)) { - string4 += replacement; - } - return string4; -} -function truncateByGraphemeBudget(input, budget) { - if (input.length <= budget) { - return input; - } - let count = 0; - let output = ""; - for (const { segment } of getSegmenter().segment(input)) { - const next = count + segment.length; - if (next > budget) { - break; - } - output += segment; - count = next; - } - return output; -} -// ../core/path.ts -var WIN_PATH_SEPARATOR = "\\"; -var POSIX_PATH_SEPARATOR = "/"; -function isNotEmpty(part) { - return part.trim() !== ""; -} -function split(path) { - let parts = path.split(":\\").filter(isNotEmpty); - parts = flattenDeep(parts.map((part) => part.split("\\").filter(isNotEmpty))); - parts = flattenDeep(parts.map((part) => part.split("/").filter(isNotEmpty))); - return parts; -} - -class Path { - static serverPlatform = null; - root; - sub; - unc; - constructor(root, sub) { - if (root.trim() === "") { - throw new Error("InvalidArgumentError: root path cannot be empty"); - } - if (sub !== undefined) { - if (split(sub).length === 0) { - if (sub.length === 0) { - throw new Error("InvalidArgumentError: sub path cannot be empty"); - } else { - throw new Error("InvalidArgumentError: invalid sub path"); - } - } - } - if (sub?.trim() === "") { - throw new Error("InvalidArgumentError: sub path cannot be empty"); - } - this.unc = root.startsWith("\\\\"); - if (!(root.startsWith("/") || /^[A-Za-z]:/.test(root) || root.startsWith("\\\\"))) { - throw new Error(`InvalidArgumentError: root=${root}. root path must start with "/" for POSIX format, "C:" for Windows format, or "\\\\" for Windows UNC format`); - } - this.root = split(root); - this.sub = sub === undefined ? [] : split(sub); - if (this.root.length === 0) { - throw new Error("InvalidArgumentError: invalid root path"); - } - } - _uncPath() { - const serverName = this.root[0]; - const parentPath = this.root.slice(1).join(WIN_PATH_SEPARATOR); - const subPath = this.sub.length === 0 ? "" : WIN_PATH_SEPARATOR + this.sub.join(WIN_PATH_SEPARATOR); - return `\\\\${serverName}\\${parentPath}${subPath}`; - } - abs(type = "posix") { - if (type === "win") { - if (this.unc) { - return this._uncPath(); - } else { - if (this.root[0]?.length !== 1) { - return this._uncPath(); - } - const rootFolderPaths = this.root.slice(1).join(WIN_PATH_SEPARATOR); - const subpath = this.sub.length === 0 ? "" : WIN_PATH_SEPARATOR + this.sub.join(WIN_PATH_SEPARATOR); - return `${this.root[0]}:${WIN_PATH_SEPARATOR}${rootFolderPaths}${subpath}`; - } - } else { - const subpath = this.sub.length === 0 ? "" : POSIX_PATH_SEPARATOR + this.sub.join(POSIX_PATH_SEPARATOR); - return `${POSIX_PATH_SEPARATOR}${this.root.join(POSIX_PATH_SEPARATOR)}${subpath}`; - } - } - rel(type = "posix") { - if (type === "win") { - return this.sub.join(WIN_PATH_SEPARATOR); - } else { - return this.sub.join(POSIX_PATH_SEPARATOR); - } - } - name() { - return last(this.sub) || last(this.root) || ""; - } - dir() { - return "/" + this.root.join(POSIX_PATH_SEPARATOR); - } - cd(subpath) { - return new Path(this.dir(), subpath); - } - platformAbsPath() { - return Path.isWindows() ? this.abs("win") : this.abs("posix"); - } - platformRelPath() { - return Path.isWindows() ? this.rel("win") : this.rel("posix"); - } - join(subpath) { - const parts = split(subpath); - return new Path(POSIX_PATH_SEPARATOR + this.root.join(POSIX_PATH_SEPARATOR), [...this.sub, ...parts].join(POSIX_PATH_SEPARATOR)); - } - filename(newFileName) { - if (this.sub.length === 0) { - throw new Error("InvalidArgumentError: sub path cannot be empty"); - } else { - const validName = filenamify(newFileName); - return new Path(POSIX_PATH_SEPARATOR + this.root.join(POSIX_PATH_SEPARATOR), [...this.sub.slice(0, -1), validName].join(POSIX_PATH_SEPARATOR)); - } - } - parent() { - if (this.sub.length === 0) { - throw new Error("reaching parent folder is not allowed"); - } else { - const parentSub = this.sub.slice(0, -1); - if (parentSub.length === 0) { - return new Path(POSIX_PATH_SEPARATOR + this.root.join(POSIX_PATH_SEPARATOR)); - } else { - return new Path(POSIX_PATH_SEPARATOR + this.root.join(POSIX_PATH_SEPARATOR), parentSub.join(POSIX_PATH_SEPARATOR)); - } - } - } - static fromAbsolutePath(absolutePath, root) { - return new Path(root, absolutePath.replace(root, "")); - } - static posix(windowsPath) { - const p = new Path(windowsPath); - return p.abs("posix"); - } - static win(posixPath) { - const p = new Path(posixPath); - return p.abs("win"); - } - static slash(windowsPath) { - return slash(windowsPath); - } - static backslash(posixPath) { - return posixPath.replace(POSIX_PATH_SEPARATOR, WIN_PATH_SEPARATOR); - } - static setServerPlatform(platform) { - Path.serverPlatform = platform; - } - static resetServerPlatformForTests() { - Path.serverPlatform = null; - } - static getServerPlatform() { - return Path.serverPlatform; - } - static isWindows() { - if (Path.serverPlatform !== null) { - return Path.serverPlatform === "win32"; - } - const proc = typeof globalThis !== "undefined" ? globalThis.process : undefined; - if (proc?.platform) { - return proc.platform === "win32"; - } - const win = typeof globalThis !== "undefined" ? globalThis.window : undefined; - if (win) { - const electron = win.electron; - if (electron?.process?.platform) { - return electron.process.platform === "win32"; - } - } - return false; - } - static pathSeparator() { - return Path.isWindows() ? WIN_PATH_SEPARATOR : POSIX_PATH_SEPARATOR; - } - static toPlatformPath(path) { - return Path.isWindows() ? Path.win(path) : Path.posix(path); - } - toString() { - return this.abs(); - } -} -// ../core/ai-tool/isFolderExistResult.ts +// ../../apps/core/src/ai-tool/isFolderExistResult.ts function isFolderExistInvalidPath() { return { exists: false, @@ -59965,45 +60490,8 @@ function isFolderExistCheckFailed(path, message) { }; } -// ../core/ai-tool/toolResult.ts -function toolOk(data) { - return { ...data, error: undefined }; -} -function toolError(reason) { - const message = reason.startsWith("Error Reason:") ? reason : `Error Reason: ${reason}`; - return { error: message }; -} -function requireNonEmptyString(value, field) { - if (typeof value !== "string" || value.trim() === "") { - return { error: `Invalid ${field}: must be a non-empty string` }; - } - return value; -} -function messageFromUnknownError(error48) { - if (error48 instanceof Error) { - return error48.message; - } - if (typeof error48 === "string") { - return error48; - } - if (error48 === null || error48 === undefined) { - return "Unknown error (null/undefined thrown)"; - } - try { - const json3 = JSON.stringify(error48); - if (json3 && json3 !== "{}") { - return json3; - } - } catch {} - const text2 = String(error48); - return text2 || "Unknown error"; -} -function formatToolError(error48) { - return toolError(messageFromUnknownError(error48)); -} - // src/isFolderAvailable.ts -var import_promises2 = require("node:fs/promises"); +var import_promises3 = require("node:fs/promises"); var isFolderAvailableRequestSchema = exports_external2.object({ path: exports_external2.string().min(1, "path is required") }); @@ -60013,7 +60501,7 @@ async function resolveFolderExistence(folderPath) { } try { const normalizedPath = Path.toPlatformPath(folderPath); - const stats = await import_promises2.stat(normalizedPath); + const stats = await import_promises3.stat(normalizedPath); if (stats.isDirectory()) { return isFolderExistSucceeded(folderPath); } @@ -60094,9 +60582,9 @@ function buildIsFolderExistTool() { } // src/tools/getMediaMetadata.ts -var import_promises4 = require("node:fs/promises"); +var import_promises5 = require("node:fs/promises"); -// ../core/ai-tool/getMediaMetadataResponse.ts +// ../../apps/core/src/ai-tool/getMediaMetadataResponse.ts function parseMediaIdString(id) { const n = Number.parseInt(id, 10); return Number.isFinite(n) ? n : 0; @@ -60167,17 +60655,17 @@ function fillMediaMetadataResponseData(metadata, posixPath) { } // src/mediaMetadataCache.ts -var import_promises3 = require("node:fs/promises"); -var import_node_path = __toESM(require("node:path")); +var import_promises4 = require("node:fs/promises"); +var import_node_path2 = __toESM(require("node:path")); function metadataCacheFilePath(appDataDir, folderPathInPosix) { const filename = folderPathInPosix.replace(/[\/\\:?*|<>"]/g, "_"); - return import_node_path.default.join(appDataDir, "metadata", `${filename}.json`); + return import_node_path2.default.join(appDataDir, "metadata", `${filename}.json`); } async function readMediaMetadataCache(appDataDir, mediaFolderPath) { const folderPathInPosix = Path.posix(mediaFolderPath); const filePath = metadataCacheFilePath(appDataDir, folderPathInPosix); try { - const content = await import_promises3.readFile(filePath, "utf-8"); + const content = await import_promises4.readFile(filePath, "utf-8"); return JSON.parse(content); } catch { return null; @@ -60187,15 +60675,15 @@ async function writeMediaMetadataCache(appDataDir, mediaMetadata) { if (!mediaMetadata.mediaFolderPath) { throw new Error("Media folder path is required"); } - const metadataDir = import_node_path.default.join(appDataDir, "metadata"); - await import_promises3.mkdir(metadataDir, { recursive: true }); + const metadataDir = import_node_path2.default.join(appDataDir, "metadata"); + await import_promises4.mkdir(metadataDir, { recursive: true }); const filePath = metadataCacheFilePath(appDataDir, Path.posix(mediaMetadata.mediaFolderPath)); - await import_promises3.writeFile(filePath, JSON.stringify(mediaMetadata, null, 2), "utf-8"); + await import_promises4.writeFile(filePath, JSON.stringify(mediaMetadata, null, 2), "utf-8"); } async function deleteMediaMetadataCache(appDataDir, mediaFolderPath) { const filePath = metadataCacheFilePath(appDataDir, Path.posix(mediaFolderPath)); try { - await import_promises3.unlink(filePath); + await import_promises4.unlink(filePath); } catch (error48) { const code = error48.code; if (code !== "ENOENT") { @@ -60223,7 +60711,7 @@ async function executeGetMediaMetadata(params, userConfig, appDataDir, abortSign try { const normalizedPath = Path.toPlatformPath(pathCheck); try { - const stats = await import_promises4.stat(normalizedPath); + const stats = await import_promises5.stat(normalizedPath); if (!stats.isDirectory()) { return { ...baseData, error: GET_MEDIA_METADATA_NOT_DIRECTORY }; } @@ -60263,7 +60751,7 @@ function buildGetMediaMetadataTool(userConfig, appDataDir, abortSignal) { }; } -// ../core/ai-tool/buildGetEpisodesResponse.ts +// ../../apps/core/src/ai-tool/buildGetEpisodesResponse.ts function createEmptyGetEpisodesData() { return { episodes: [], @@ -60314,8 +60802,8 @@ var EMPTY_CORE_ROUTES_CONFIG = { }; // src/userConfig.ts -var import_promises5 = require("node:fs/promises"); -var import_node_path2 = __toESM(require("node:path")); +var import_promises6 = require("node:fs/promises"); +var import_node_path3 = __toESM(require("node:path")); var DEFAULT_USER_CONFIG = { folders: [], tmdb: {}, @@ -60335,9 +60823,9 @@ async function readUserConfig(config2) { if (!userDataDir) { return DEFAULT_USER_CONFIG; } - const configPath = import_node_path2.default.join(userDataDir, "smm.json"); + const configPath = import_node_path3.default.join(userDataDir, "smm.json"); try { - const content = await import_promises5.readFile(configPath, "utf-8"); + const content = await import_promises6.readFile(configPath, "utf-8"); return JSON.parse(content); } catch { return DEFAULT_USER_CONFIG; @@ -60358,8 +60846,8 @@ async function writeUserConfigToDisk(config2, userConfig) { if (!userDataDir) { throw new Error("userDataDir is not configured"); } - await import_promises5.mkdir(userDataDir, { recursive: true }); - await import_promises5.writeFile(import_node_path2.default.join(userDataDir, "smm.json"), JSON.stringify(userConfig, null, 2), "utf-8"); + await import_promises6.mkdir(userDataDir, { recursive: true }); + await import_promises6.writeFile(import_node_path3.default.join(userDataDir, "smm.json"), JSON.stringify(userConfig, null, 2), "utf-8"); } // src/getEpisodes.ts @@ -60418,7 +60906,7 @@ function buildGetEpisodesTool(config2, abortSignal) { }; } -// ../core/ai-tool/buildGetMediaFoldersResponse.ts +// ../../apps/core/src/ai-tool/buildGetMediaFoldersResponse.ts function createEmptyGetMediaFoldersData() { return { folders: [] }; } @@ -60451,7 +60939,7 @@ function buildGetMediaFoldersTool(userConfig, abortSignal) { }; } -// ../core/utils.ts +// ../types/mediaFileExtensions.ts var extensions = { audioTrackFileExtensions: [".mka"], videoFileExtensions: [ @@ -60545,7 +61033,7 @@ var videoFileExtensions = extensions.videoFileExtensions; var imageFileExtensions = extensions.imageFileExtensions; var subtitleFileExtensions = extensions.subtitleFileExtensions; -// ../core/ai-tool/buildListFilesInMediaFolderResponse.ts +// ../../apps/core/src/ai-tool/buildListFilesInMediaFolderResponse.ts function createEmptyListFilesInMediaFolderData() { return { files: [], count: 0 }; } @@ -60570,23 +61058,23 @@ function buildListFilesInMediaFolderResponse(filePaths, videoFileOnly = false) { // src/listFiles.ts var import_node_os = __toESM(require("node:os")); -var import_promises6 = require("node:fs/promises"); -var import_node_path4 = __toESM(require("node:path")); +var import_promises7 = require("node:fs/promises"); +var import_node_path5 = __toESM(require("node:path")); // src/resolveListFilesPath.ts -var import_node_path3 = __toESM(require("node:path")); +var import_node_path4 = __toESM(require("node:path")); function joinListFilesChildPath(dirPath, childName) { if (dirPath.startsWith("file://")) { const separator = dirPath.endsWith("/") ? "" : "/"; return `${dirPath}${separator}${childName}`; } - return import_node_path3.default.join(dirPath, childName); + return import_node_path4.default.join(dirPath, childName); } function resolveListFilesAbsolutePath(folderPath) { if (folderPath.startsWith("file://")) { return folderPath; } - return import_node_path3.default.resolve(folderPath); + return import_node_path4.default.resolve(folderPath); } function normalizeListFilesInputPath(folderPath) { if (folderPath.startsWith("file://")) { @@ -60674,7 +61162,7 @@ async function doListFiles(body, config2 = {}) { logger?.debug({ requestId, folderPath, onlyFiles, onlyFolders, includeHiddenFiles, recursively }, "[ListFiles] validated params"); if (folderPath === "~" || folderPath.startsWith("~/")) { const homeDir = import_node_os.default.homedir(); - folderPath = folderPath === "~" ? homeDir : import_node_path4.default.join(homeDir, folderPath.slice(2)); + folderPath = folderPath === "~" ? homeDir : import_node_path5.default.join(homeDir, folderPath.slice(2)); } try { folderPath = normalizeListFilesInputPath(folderPath); @@ -60700,7 +61188,7 @@ async function doListFiles(body, config2 = {}) { } } try { - const stats = await import_promises6.stat(validatedPath); + const stats = await import_promises7.stat(validatedPath); logger?.info({ requestId, validatedPath, isDirectory: stats.isDirectory(), isFile: stats.isFile() }, "[ListFiles] stat result"); if (!stats.isDirectory()) { logger?.info({ requestId, validatedPath }, "[ListFiles] path is not a directory"); @@ -60728,14 +61216,14 @@ async function doListFiles(body, config2 = {}) { let totalCount = 0; async function scanDirectory(dirPath, isTopLevel = false) { logger?.debug({ requestId, dirPath, isTopLevel }, "[ListFiles] scanDirectory readdir"); - const items = await import_promises6.readdir(dirPath); + const items = await import_promises7.readdir(dirPath); for (const item of items) { const fullPath = joinListFilesChildPath(dirPath, item); try { - const itemStats = await import_promises6.stat(fullPath); + const itemStats = await import_promises7.stat(fullPath); const isFile2 = itemStats.isFile(); const isDirectory = itemStats.isDirectory(); - const filename = import_node_path4.default.basename(item); + const filename = import_node_path5.default.basename(item); const isHidden = filename.startsWith(".") || filename === "Thumbs.db" || filename === "desktop.ini"; if (!includeHiddenFiles && isHidden) { continue; @@ -60860,7 +61348,7 @@ function buildListFilesInMediaFolderTool(userConfig, abortSignal) { }; } -// ../core/ai-tool/renameFolderConfirm.ts +// ../../apps/core/src/ai-tool/renameFolderConfirm.ts function getFolderBasename(folderPath) { const parts = Path.posix(folderPath).split("/").filter(Boolean); return parts[parts.length - 1] ?? Path.posix(folderPath); @@ -60873,7 +61361,7 @@ function buildRenameFolderConfirmationMessage(from, to) { • Update media metadata`; } -// ../core/ai-tool/renameFolderResult.ts +// ../../apps/core/src/ai-tool/renameFolderResult.ts function renameFolderCancelled(from, to) { return { renamed: false, @@ -60899,9 +61387,9 @@ function renameFolderSucceeded(from, to) { } // src/renameFolder.ts -var import_promises7 = require("node:fs/promises"); +var import_promises8 = require("node:fs/promises"); -// ../core/mediaMetadata.ts +// ../../apps/core/src/mediaMetadata.ts function renameFolderInMediaMetadata(mediaMetadata, from, to) { const fromNormalized = from.endsWith("/") ? from : from + "/"; const toNormalized = to.endsWith("/") ? to : to + "/"; @@ -60914,14 +61402,6 @@ function renameFolderInMediaMetadata(mediaMetadata, from, to) { result.mediaFolderPath = toNormalized + result.mediaFolderPath.slice(fromNormalized.length); } } - if (result.files) { - result.files = result.files.map((file2) => { - if (file2.startsWith(fromNormalized)) { - return toNormalized + file2.slice(fromNormalized.length); - } - return file2; - }); - } if (result.mediaFiles) { result.mediaFiles = result.mediaFiles.map((mediaFile) => { if (mediaFile.absolutePath.startsWith(fromNormalized)) { @@ -60940,10 +61420,6 @@ function updateMediaMetadataAfterRename(mediaMetadata, renameMappings) { for (const { from, to } of renameMappings) { renameMap.set(Path.posix(from), Path.posix(to)); } - const updatedFiles = mediaMetadata.files?.map((file2) => { - const normalizedFile = Path.posix(file2); - return renameMap.get(normalizedFile) ?? file2; - }); const updatedMediaFiles = mediaMetadata.mediaFiles?.map((mediaFile) => { const normalizedPath = Path.posix(mediaFile.absolutePath); const newPath = renameMap.get(normalizedPath); @@ -60975,12 +61451,11 @@ function updateMediaMetadataAfterRename(mediaMetadata, renameMappings) { }); return { ...mediaMetadata, - files: updatedFiles, mediaFiles: fullyUpdatedMediaFiles }; } -// ../core/userConfig.ts +// ../../apps/core/src/userConfig.ts function renameFolderInUserConfig(userConfig, from, to) { const actualFromPosix = Path.posix(from); const actualFromWindows = Path.win(from); @@ -61031,7 +61506,7 @@ async function doRenameFolder(body, config2 = EMPTY_CORE_ROUTES_CONFIG) { const userConfig = await readUserConfig(config2); const newUserConfig = renameFolderInUserConfig(userConfig, fromAsPosix, toAsPosix); await writeUserConfigToDisk(config2, newUserConfig); - await import_promises7.rename(Path.toPlatformPath(fromAsPosix), Path.toPlatformPath(toAsPosix)); + await import_promises8.rename(Path.toPlatformPath(fromAsPosix), Path.toPlatformPath(toAsPosix)); config2.logger?.info({ from: fromAsPosix, to: toAsPosix }, "[renameFolder] renamed media folder"); return {}; } catch (error48) { @@ -61115,475 +61590,295 @@ function buildRenameFolderTool(clientId, config2, abortSignal, acknowledge) { }; } -// ../core/plan/renamePlan.ts -function createEmptyRenamePlan(mediaFolderPath, id, options) { - const planId = id ?? (typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`); - return { - id: planId, - task: "rename-files", - status: options?.status ?? "pending", - creator: options?.creator ?? "app", - mediaFolderPath: Path.posix(mediaFolderPath), - files: [] - }; +// ../../apps/core/src/ai-tool/renameEpisodeFileConfirm.ts +function getEpisodeBasename(filePath) { + const parts = Path.posix(filePath).split("/").filter(Boolean); + return parts[parts.length - 1] ?? Path.posix(filePath); } -function assertMediaFolderHasMetadata(exists, folderPath) { - if (!exists) { - return `Error Reason: folderPath "${Path.posix(folderPath)}" is not opened in SMM`; - } - return; +function buildRenameEpisodeFileConfirmationMessage(from, to) { + return `Rename episode file "${getEpisodeBasename(from)}" to "${getEpisodeBasename(to)}"? + +` + `This will: +` + ` • Rename the episode video on disk +` + ` • Rename same-stem associated files (e.g. subtitles) in the same directory +` + " • Update media metadata"; } -function assertEpisodeVideoFile(metadata, fromPath) { - const fromPosix = Path.posix(fromPath); - const mediaFile = (metadata.mediaFiles ?? []).find((mf) => mf.absolutePath === fromPosix); - if (!mediaFile) { - return "Error Reason: Not Episode Video File"; - } - return; + +// ../../apps/core/src/ai-tool/renameEpisodeFileResult.ts +function renameEpisodeFileCancelled(mediaFolder, from, to) { + return { + renamed: false, + mediaFolder: Path.toPlatformPath(mediaFolder), + from: Path.toPlatformPath(from), + to: Path.toPlatformPath(to), + succeeded: [], + failed: [], + error: RENAME_EPISODE_FILE_CANCELLED + }; } -async function prepareAppendRenameEntry(plan, entry, deps) { - const fromPosix = Path.posix(entry.from); - const toPosix = Path.posix(entry.to); - const candidateFiles = [...plan.files, { from: fromPosix, to: toPosix }]; - const validationResult = await deps.validateOperations(candidateFiles, plan.mediaFolderPath); - if (!validationResult.isValid) { - return { error: `Error Reason: ${validationResult.errors.join(` -`)}` }; - } - const mm = await deps.getMediaMetadata(plan.mediaFolderPath); - if (!mm) { - return { - error: `Error Reason: Media metadata not found for media folder: ${plan.mediaFolderPath}` - }; - } - const episodeError = assertEpisodeVideoFile(mm, fromPosix); - if (episodeError) { - return { error: episodeError }; - } +function renameEpisodeFileFailed(mediaFolder, from, to, error48) { return { - ...plan, - files: [...plan.files, { from: fromPosix, to: toPosix }] + renamed: false, + mediaFolder: Path.toPlatformPath(mediaFolder), + from: Path.toPlatformPath(from), + to: Path.toPlatformPath(to), + succeeded: [], + failed: [], + error: error48 }; } - -// ../core/types/ai-tools/planTaskMessages.ts -var END_PLAN_TASK_SUCCESS_MESSAGE = "Task is created successfuly. User need to go to SMM, review and approve the task."; -var PLAN_CANCELLED_BY_USER_MESSAGE = "该任务已被用户取消, 请停止后续操作"; - -// ../core/event-types.ts -var RecognizeMediaFilePlanReady = { - event: "recognizeMediaFilePlanReady" -}; -var RenameFilesPlanReady = { - event: "renameFilesPlanReady" -}; -var USER_CONFIG_UPDATED_EVENT = "userConfigUpdated"; -var USER_CONFIG_FOLDER_RENAMED_EVENT = "userConfig.folderRenamed"; - -// src/tools/plans.ts -var import_promises8 = require("node:fs/promises"); -var import_node_path5 = __toESM(require("node:path")); -var import_node_crypto2 = require("node:crypto"); - -// ../core/types/planCommon.ts -function isActivePlanStatus(status) { - return status === "preparing" || status === "pending"; +function renameEpisodeFileSucceeded(mediaFolder, from, to, succeeded, failed = []) { + return { + renamed: succeeded.length > 0, + mediaFolder: Path.toPlatformPath(mediaFolder), + from: Path.toPlatformPath(from), + to: Path.toPlatformPath(to), + succeeded: succeeded.map((p) => ({ + from: Path.toPlatformPath(p.from), + to: Path.toPlatformPath(p.to) + })), + failed, + ...failed.length > 0 ? { error: failed.map((f) => f.error).join("; ") } : {} + }; } -// src/tools/plans.ts -function plansDir(appDataDir) { - return import_node_path5.default.join(appDataDir, "plans"); -} -function planFilePath(appDataDir, planId) { - return import_node_path5.default.join(plansDir(appDataDir), `${planId}.plan.json`); -} -async function ensurePlansDirExists(appDataDir, fs) { - const dir = plansDir(appDataDir); - try { - const stats = await import_promises8.stat(dir); - if (!stats.isDirectory()) { - throw new Error("Plans path exists but is not a directory"); - } - } catch (error48) { - if (error48.code === "ENOENT") { - await import_promises8.mkdir(dir, { recursive: true }); - return; - } - throw error48; +// src/tools/renameEpisodeFile.ts +async function executeRenameEpisodeFile(params, runner, abortSignal) { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); } -} -async function beginRenamePlan(appDataDir, mediaFolderPath, fs) { - await ensurePlansDirExists(appDataDir, fs); - const plan = createEmptyRenamePlan(Path.posix(mediaFolderPath), undefined, { - creator: "ai", - status: "preparing" - }); - await fs.writeJson(planFilePath(appDataDir, plan.id), plan); - return plan.id; -} -async function appendRenamePlanEntry(appDataDir, planId, from, to, fs, deps) { - const filePath = planFilePath(appDataDir, planId); - const plan = await fs.readJson(filePath) ?? null; - if (!plan) { - throw new Error(`Task with id ${planId} not found`); + const folderCheck = requireNonEmptyString(params.mediaFolder, "mediaFolder"); + if (typeof folderCheck !== "string") { + return renameEpisodeFileFailed("", "", "", folderCheck.error); } - if (plan.status === "rejected") { - throw new Error(PLAN_CANCELLED_BY_USER_MESSAGE); + const fromCheck = requireNonEmptyString(params.from, "from"); + if (typeof fromCheck !== "string") { + return renameEpisodeFileFailed(folderCheck, "", "", fromCheck.error); } - const result = await prepareAppendRenameEntry(plan, { from, to }, deps); - if ("error" in result) { - throw new Error(result.error.replace(/^Error Reason: /, "")); + const toCheck = requireNonEmptyString(params.to, "to"); + if (typeof toCheck !== "string") { + return renameEpisodeFileFailed(folderCheck, fromCheck, "", toCheck.error); } - await fs.writeJson(filePath, result); -} -async function readRenamePlan(appDataDir, planId, fs) { - const plan = await readPlanById(appDataDir, planId, fs); - if (!plan || plan.task !== "rename-files") { - return null; + if (!runner) { + return renameEpisodeFileFailed(folderCheck, fromCheck, toCheck, "rename-episode-file is not available on this host"); + } + try { + const result = await runner({ + mediaFolderPath: folderCheck, + from: fromCheck, + to: toCheck + }); + return renameEpisodeFileSucceeded(folderCheck, fromCheck, toCheck, result.succeeded, result.failed); + } catch (error48) { + const message = error48 instanceof Error ? error48.message : String(error48); + return renameEpisodeFileFailed(folderCheck, fromCheck, toCheck, `Error renaming episode file: ${message}`); } - return plan; } -async function readPlanById(appDataDir, planId, fs) { - const plan = await fs.readJson(planFilePath(appDataDir, planId)); - if (!plan) { +async function confirmRenameEpisodeFileViaSocket(clientId, from, to, acknowledge) { + const confirmationMessage = buildRenameEpisodeFileConfirmationMessage(from, to); + try { + const responseData = await acknowledge({ + event: "askForConfirmation", + data: { message: confirmationMessage }, + clientId + }, 30000); + const confirmed = responseData?.confirmed ?? responseData?.response === "yes"; + if (!confirmed) { + return renameEpisodeFileCancelled("", from, to); + } return null; + } catch (error48) { + return renameEpisodeFileFailed("", from, to, `Failed to get user confirmation: ${error48 instanceof Error ? error48.message : "Unknown error"}`); } - return normalizePlanPaths(withCreatorDefault(plan)); } -async function beginRecognizePlan(appDataDir, mediaFolderPath, fs) { - await ensurePlansDirExists(appDataDir, fs); - const planId = import_node_crypto2.randomUUID(); - const plan = { - id: planId, - task: "recognize-media-file", - status: "preparing", - creator: "ai", - mediaFolderPath: Path.posix(mediaFolderPath), - files: [] +function buildRenameEpisodeFileTool(clientId, runner, abortSignal, acknowledge) { + const ack = acknowledge ?? defaultAcknowledge; + return { + description: RENAME_EPISODE_FILE_DESCRIPTION, + inputSchema: renameEpisodeFileInputSchema, + outputSchema: renameEpisodeFileOutputSchema, + execute: async (args) => { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); + } + const params = args ?? {}; + const folderCheck = requireNonEmptyString(params.mediaFolder, "mediaFolder"); + if (typeof folderCheck !== "string") { + return renameEpisodeFileFailed("", "", "", folderCheck.error); + } + const fromCheck = requireNonEmptyString(params.from, "from"); + if (typeof fromCheck !== "string") { + return renameEpisodeFileFailed(folderCheck, "", "", fromCheck.error); + } + const toCheck = requireNonEmptyString(params.to, "to"); + if (typeof toCheck !== "string") { + return renameEpisodeFileFailed(folderCheck, fromCheck, "", toCheck.error); + } + const cancelOrError = await confirmRenameEpisodeFileViaSocket(clientId, fromCheck, toCheck, ack); + if (cancelOrError) { + return { + ...cancelOrError, + mediaFolder: Path.toPlatformPath(folderCheck) + }; + } + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); + } + return executeRenameEpisodeFile({ mediaFolder: folderCheck, from: fromCheck, to: toCheck }, runner, abortSignal); + } }; - await fs.writeJson(planFilePath(appDataDir, planId), plan); - return planId; } -async function defaultValidateRecognizedFiles(files, fs) { - for (const file2 of files) { - if (!file2.path) { - throw new Error(`File path is empty for S${file2.season}E${file2.episode}`); - } - const platformPath = Path.toPlatformPath(Path.posix(file2.path)); - const exists = await fs.exists(platformPath); - if (!exists) { - throw new Error(`File "${Path.posix(file2.path)}" (S${file2.season}E${file2.episode}) does not exist in the media folder`); - } - } + +// ../../apps/core/src/ai-tool/scrapeResult.ts +function scrapeSucceeded(id) { + return { + id, + message: SCRAPE_JOB_CREATED_MESSAGE + }; } -async function appendRecognizedFile(appDataDir, taskId, file2, fs, deps = {}) { - const filePath = planFilePath(appDataDir, taskId); - const plan = await fs.readJson(filePath) ?? null; - if (!plan) { - throw new Error(`Task with id ${taskId} not found`); +function scrapeFailed(path5, error48) { + return { + id: "", + message: "", + error: path5.trim() ? `${error48} (path: ${Path.toPlatformPath(path5)})` : error48 + }; +} + +// src/tools/scrape.ts +async function executeScrape(params, runner, abortSignal) { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); } - if (plan.status === "rejected") { - throw new Error(PLAN_CANCELLED_BY_USER_MESSAGE); + const pathCheck = requireNonEmptyString(params.path, "path"); + if (typeof pathCheck !== "string") { + return scrapeFailed("", pathCheck.error); } - const normalizedPath = Path.posix(file2.path); - const validate = deps.validateFiles ?? ((files) => defaultValidateRecognizedFiles(files, fs)); - await validate([{ ...file2, path: normalizedPath }]); - plan.files.push({ - season: file2.season, - episode: file2.episode, - path: normalizedPath - }); - await fs.writeJson(filePath, plan); -} -async function readRecognizePlan(appDataDir, taskId, fs) { - const plan = await readPlanById(appDataDir, taskId, fs); - if (!plan || plan.task !== "recognize-media-file") { - return null; + if (!runner) { + return scrapeFailed(pathCheck, "scrape is not available on this host"); } - return plan; -} -async function listPlanFiles(appDataDir) { - const dir = plansDir(appDataDir); try { - const stats = await import_promises8.stat(dir); - if (!stats.isDirectory()) { - return []; - } - } catch { - return []; + const language = typeof params.language === "string" && params.language.trim() !== "" ? params.language : undefined; + const { id } = await runner(pathCheck, language !== undefined ? { language } : undefined); + return scrapeSucceeded(id); + } catch (error48) { + const message = error48 instanceof Error ? error48.message : String(error48); + const withPrefix = message.startsWith("Error Reason:") ? message : `Error Reason: ${message}`; + return scrapeFailed(pathCheck, withPrefix); } - const files = await import_promises8.readdir(dir); - return files.filter((file2) => file2.endsWith(".plan.json")).map((file2) => import_node_path5.default.join(dir, file2)); } -function withCreatorDefault(plan) { - if (plan.creator) { - return plan; - } - return { ...plan, creator: "app" }; -} -function normalizePlanPaths(plan) { - const mediaFolderPath = Path.posix(plan.mediaFolderPath); - if (plan.task === "recognize-media-file") { - return { - ...plan, - mediaFolderPath, - files: plan.files.map((f) => ({ ...f, path: Path.posix(f.path) })) - }; - } +function buildScrapeTool(runner, abortSignal) { return { - ...plan, - mediaFolderPath, - files: plan.files.map((f) => ({ - from: Path.posix(f.from), - to: Path.posix(f.to) - })) + description: SCRAPE_DESCRIPTION, + inputSchema: scrapeInputSchema, + outputSchema: scrapeOutputSchema, + execute: async (args) => { + const params = args ?? {}; + return executeScrape({ + path: params.path, + language: params.language + }, runner, abortSignal); + } }; } -async function createPlan(appDataDir, input, fs) { - await ensurePlansDirExists(appDataDir, fs); - const id = input.id ?? import_node_crypto2.randomUUID(); - const mediaFolderPath = Path.posix(input.mediaFolderPath); - const plan = input.task === "recognize-media-file" ? { - id, - task: "recognize-media-file", - status: "preparing", - creator: input.creator, - mediaFolderPath, - files: [] - } : { - id, - task: "rename-files", - status: "preparing", - creator: input.creator, - mediaFolderPath, - files: [] - }; - await fs.writeJson(planFilePath(appDataDir, id), plan); - return plan; + +// ../../apps/core/src/ai-tool/getJobResult.ts +function getJobSucceeded(job) { + return { job }; } -async function updatePlanContent(appDataDir, id, patch, fs) { - const filePath = planFilePath(appDataDir, id); - const existing = await fs.readJson(filePath); - if (!existing) { - return null; +function getJobFailed(error48) { + return { error: error48 }; +} + +// src/tools/getJob.ts +async function executeGetJob(id, runner, abortSignal) { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); } - const merged = withCreatorDefault({ - ...existing, - ...patch.status !== undefined ? { status: patch.status } : {}, - ...patch.files !== undefined ? { files: patch.files } : {} - }); - const updated = normalizePlanPaths(merged); - if (patch.status === "completed") { - await deletePlan(appDataDir, id); - return updated; + const idCheck = requireNonEmptyString(id, "id"); + if (typeof idCheck !== "string") { + return getJobFailed(idCheck.error); + } + if (!runner) { + return getJobFailed("get-job is not available on this host"); } - await fs.writeJson(filePath, updated); - return updated; -} -async function deletePlan(appDataDir, id) { try { - await import_promises8.unlink(planFilePath(appDataDir, id)); - } catch (error48) { - if (error48.code !== "ENOENT") { - throw error48; + const job = runner(idCheck); + if (job == null) { + return getJobFailed("Error Reason: Job not found"); } + return getJobSucceeded(job); + } catch (error48) { + const message = error48 instanceof Error ? error48.message : String(error48); + const withPrefix = message.startsWith("Error Reason:") ? message : `Error Reason: ${message}`; + return getJobFailed(withPrefix); } } -async function cleanPreparingPlans(appDataDir, fs, logger) { - const start = Date.now(); - const plansPath = plansDir(appDataDir); - logger?.info({ appDataDir, plansDir: plansPath }, "[cleanup] plan cleanup: scanning for stale preparing plans"); - const files = await listPlanFiles(appDataDir); - logger?.info({ plansDir: plansPath, scanned: files.length }, "[cleanup] plan cleanup: enumerated plan files"); - let removed = 0; - let failed = 0; - for (const filePath of files) { - try { - const plan = await fs.readJson(filePath); - if (!plan) { - logger?.debug({ filePath }, "[cleanup] plan cleanup: skipping unreadable plan file"); - continue; - } - if (plan.status === "preparing") { - await import_promises8.unlink(filePath); - removed++; - logger?.debug({ filePath, planId: plan.id, task: plan.task }, "[cleanup] plan cleanup: removed stale preparing plan"); - } else { - logger?.debug({ filePath, planId: plan.id, status: plan.status }, "[cleanup] plan cleanup: keeping plan (not preparing)"); - } - } catch (err) { - failed++; - logger?.warn({ filePath, error: err.message }, "[cleanup] plan cleanup: failed to process plan file, skipping"); +function buildGetJobTool(runner, abortSignal) { + return { + description: GET_JOB_DESCRIPTION, + inputSchema: getJobInputSchema, + outputSchema: getJobOutputSchema, + execute: async (args) => { + const params = args ?? {}; + return executeGetJob(params.id ?? "", runner, abortSignal); } + }; +} + +// ../../apps/core/src/pipeline/createRenameEpisodePlan.ts +var import_node_crypto2 = require("node:crypto"); + +// ../../apps/core/src/plan/renamePlan.ts +function assertMediaFolderHasMetadata(exists, folderPath) { + if (!exists) { + return `Error Reason: folderPath "${Path.posix(folderPath)}" is not opened in SMM`; } - logger?.info({ - plansDir: plansPath, - scanned: files.length, - removed, - failed, - durationMs: Date.now() - start - }, "[cleanup] plan cleanup: complete"); - return removed; + return; } -async function getActivePlansForFolder(appDataDir, mediaFolderPath, fs) { - const target = Path.posix(mediaFolderPath); - const files = await listPlanFiles(appDataDir); - const plans = []; - for (const file2 of files) { - const plan = await fs.readJson(file2); - if (!plan) { - continue; - } - const normalized = normalizePlanPaths(withCreatorDefault(plan)); - if (normalized.mediaFolderPath === target && isActivePlanStatus(normalized.status)) { - plans.push(normalized); - } +function assertEpisodeVideoFile(metadata, fromPath) { + const fromPosix = Path.posix(fromPath); + const mediaFile = (metadata.mediaFiles ?? []).find((mf) => mf.absolutePath === fromPosix); + if (!mediaFile) { + return "Error Reason: Not Episode Video File"; } - return plans; + return; } -// src/tools/renameFilesTask.ts -function makeLogger(logger) { - return { - info: (obj, msg) => logger?.info(obj, msg), - warn: (obj, msg) => logger?.warn(obj, msg), - error: (obj, msg) => logger?.error(obj, msg) - }; -} -function buildBeginRenameFilesTaskTool(clientId, appDataDir, fs, _deps, broadcast, logger, abortSignal) { - const log = makeLogger(logger); - const emit = broadcast ?? defaultBroadcast; - return { - description: BEGIN_RENAME_FILES_TASK_DESCRIPTION, - toolName: BEGIN_RENAME_FILES_TASK, - inputSchema: beginRenameFilesTaskInputSchema, - execute: async (args) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const { mediaFolderPath } = args ?? {}; - log.info({ mediaFolderPath, clientId }, `[tool][${BEGIN_RENAME_FILES_TASK}] Starting new rename task`); - const folderPathInPosix = Path.posix(mediaFolderPath ?? ""); - const metadataFilePath = metadataCacheFilePath(appDataDir, folderPathInPosix); - const metadataExists = await fs.exists(metadataFilePath); - const metadataError = assertMediaFolderHasMetadata(metadataExists, folderPathInPosix); - if (metadataError) { - log.warn({ folderPath: folderPathInPosix }, `[tool][${BEGIN_RENAME_FILES_TASK}] Media metadata not found`); - return toolError(metadataError.replace(/^Error Reason: /, "")); - } - try { - const taskId = await beginRenamePlan(appDataDir, folderPathInPosix, fs); - log.info({ taskId, mediaFolderPath: folderPathInPosix, clientId }, `[tool][${BEGIN_RENAME_FILES_TASK}] Task created successfully`); - const fullPlanPath = planFilePath(appDataDir, taskId); - const planFilePathInPosix = Path.posix(fullPlanPath); - const data = { - taskId, - planFilePath: planFilePathInPosix - }; - emit({ - event: RenameFilesPlanReady.event, - data - }); - log.info({ taskId, mediaFolderPath: folderPathInPosix, clientId, broadcast: true }, `[tool][${BEGIN_RENAME_FILES_TASK}] RenameFilesPlanReady broadcast sent`); - return toolOk({ taskId }); - } catch (error48) { - log.error({ - mediaFolderPath: folderPathInPosix, - error: error48 instanceof Error ? error48.message : String(error48), - clientId - }, `[tool][${BEGIN_RENAME_FILES_TASK}] Failed to create task`); - return formatToolError(error48); +// ../../apps/core/src/validations/rename/validateRenameFileExistence.ts +async function validateSourceFilesExist(tasks, probe) { + const missingFiles = []; + for (const task of tasks) { + try { + if (!await probe.isFile(task.from)) { + missingFiles.push(task.from); } + } catch { + missingFiles.push(task.from); } - }; -} -function buildAddRenameFileToTaskTool(clientId, appDataDir, fs, deps, logger, abortSignal) { - const log = makeLogger(logger); + } return { - description: ADD_RENAME_FILE_TO_TASK_DESCRIPTION, - toolName: ADD_RENAME_FILE_TO_TASK, - inputSchema: addRenameFileToTaskInputSchema, - execute: async (args) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const { taskId, from, to } = args ?? {}; - const normalizedTaskId = (taskId ?? "").trim(); - log.info({ taskId: normalizedTaskId, from, to, clientId }, `[tool][${ADD_RENAME_FILE_TO_TASK}] Adding file to task`); - try { - await appendRenamePlanEntry(appDataDir, normalizedTaskId, from ?? "", to ?? "", fs, deps); - log.info({ taskId: normalizedTaskId, from, to, clientId }, `[tool][${ADD_RENAME_FILE_TO_TASK}] File added successfully`); - return toolOk({}); - } catch (error48) { - log.error({ - taskId: normalizedTaskId, - from, - to, - error: error48 instanceof Error ? error48.message : String(error48), - clientId - }, `[tool][${ADD_RENAME_FILE_TO_TASK}] Failed to add file`); - return formatToolError(error48); - } - } + isValid: missingFiles.length === 0, + missingFiles }; } -function buildEndRenameFilesTaskTool(clientId, appDataDir, fs, broadcast, logger, abortSignal) { - const log = makeLogger(logger); - const emit = broadcast ?? defaultBroadcast; - return { - description: END_RENAME_FILES_TASK_DESCRIPTION, - toolName: END_RENAME_FILES_TASK, - inputSchema: endRenameFilesTaskInputSchema, - execute: async (args) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const { taskId } = args ?? {}; - const normalizedTaskId = (taskId ?? "").trim(); - log.info({ taskId: normalizedTaskId, clientId }, `[tool][${END_RENAME_FILES_TASK}] Ending rename task`); - try { - const task = await readRenamePlan(appDataDir, normalizedTaskId, fs); - if (!task) { - log.error({ taskId: normalizedTaskId, clientId }, `[tool][${END_RENAME_FILES_TASK}] Task not found`); - return toolError(`Task with id "${normalizedTaskId}" not found`); - } - if (task.status === "rejected") { - log.warn({ taskId: normalizedTaskId, clientId }, `[tool][${END_RENAME_FILES_TASK}] Task cancelled by user`); - return toolError(PLAN_CANCELLED_BY_USER_MESSAGE); - } - if (task.files.length === 0) { - log.warn({ taskId: normalizedTaskId, clientId }, `[tool][${END_RENAME_FILES_TASK}] No files in task`); - return toolError("No rename entries in task"); - } - await updatePlanContent(appDataDir, task.id, { status: "pending" }, fs); - const fullPlanPath = planFilePath(appDataDir, task.id); - const planFilePathInPosix = Path.posix(fullPlanPath); - const data = { - taskId: task.id, - planFilePath: planFilePathInPosix - }; - emit({ - event: RenameFilesPlanReady.event, - data - }); - log.info({ taskId: normalizedTaskId, fileCount: task.files.length, clientId }, `[tool][${END_RENAME_FILES_TASK}] Plan ready, UI notified`); - return toolOk({ message: END_PLAN_TASK_SUCCESS_MESSAGE }); - } catch (error48) { - log.error({ - taskId: normalizedTaskId, - error: error48 instanceof Error ? error48.message : String(error48), - clientId - }, `[tool][${END_RENAME_FILES_TASK}] End task error`); - return formatToolError(error48); +async function validateDestFilesNotExist(tasks, probe) { + const existingFiles = []; + for (const task of tasks) { + try { + if (await probe.isFile(task.to)) { + existingFiles.push(task.to); } + } catch { + continue; } + } + return { + isValid: existingFiles.length === 0, + existingFiles }; } -// src/renameFilesValidation.ts -var import_promises9 = require("node:fs/promises"); - -// ../core/validations/rename/validateChainingConflicts.ts +// ../../apps/core/src/validations/rename/validateChainingConflicts.ts function validateChainingConflicts(tasks) { const sourcePaths = new Set; for (const task of tasks) { @@ -61597,7 +61892,7 @@ function validateChainingConflicts(tasks) { return true; } -// ../core/validations/rename/validateNoAbnormalPaths.ts +// ../../apps/core/src/validations/rename/validateNoAbnormalPaths.ts function isPathNormal(p) { if (p.startsWith("../")) { return true; @@ -61637,7 +61932,7 @@ function validateNoAbnormalPaths(tasks) { return errors4; } -// ../core/validations/rename/validateNoDuplicatedDestFile.ts +// ../../apps/core/src/validations/rename/validateNoDuplicatedDestFile.ts function validateNoDuplicatedDestFile(tasks) { const destPaths = new Map; for (let i = 0;i < tasks.length; i++) { @@ -61649,9 +61944,9 @@ function validateNoDuplicatedDestFile(tasks) { destPaths.set(task.to, existing); } const duplicates = []; - for (const [path6, indices] of destPaths) { + for (const [path5, indices] of destPaths) { if (indices.length > 1) { - duplicates.push(path6); + duplicates.push(path5); } } return { @@ -61660,7 +61955,7 @@ function validateNoDuplicatedDestFile(tasks) { }; } -// ../core/validations/rename/validateNoDuplicatedSourceFile.ts +// ../../apps/core/src/validations/rename/validateNoDuplicatedSourceFile.ts function validateNoDuplicatedSourceFile(tasks) { const sourcePaths = new Map; for (let i = 0;i < tasks.length; i++) { @@ -61672,9 +61967,9 @@ function validateNoDuplicatedSourceFile(tasks) { sourcePaths.set(task.from, existing); } const duplicates = []; - for (const [path6, indices] of sourcePaths) { + for (const [path5, indices] of sourcePaths) { if (indices.length > 1) { - duplicates.push(path6); + duplicates.push(path5); } } return { @@ -61683,7 +61978,7 @@ function validateNoDuplicatedSourceFile(tasks) { }; } -// ../core/validations/rename/validateNoIdenticalSourceAndDestFile.ts +// ../../apps/core/src/validations/rename/validateNoIdenticalSourceAndDestFile.ts function validateNoIdenticalSourceAndDestFile(tasks) { const identicals = []; for (const task of tasks) { @@ -61697,7 +61992,7 @@ function validateNoIdenticalSourceAndDestFile(tasks) { }; } -// ../core/validations/rename/validatePathWithinMediaFolder.ts +// ../../apps/core/src/validations/rename/validatePathWithinMediaFolder.ts function validatePathWithinMediaFolder(mediaFolderPath, tasks) { const invalidPaths = []; const mediaFolderObj = new Path(mediaFolderPath); @@ -61725,7 +62020,7 @@ function validatePathWithinMediaFolder(mediaFolderPath, tasks) { }; } -// ../core/validations/rename/validateRenameOperationsSync.ts +// ../../apps/core/src/validations/rename/validateRenameOperationsSync.ts function validateRenameOperationsSync(files, folderPathInPosix) { const errors4 = []; const normalizedTasks = []; @@ -61818,14 +62113,12 @@ function validateRenameOperationsSync(files, folderPathInPosix) { }; } -// src/renameFilesValidation.ts -async function validateRenameOperations(files, folderPathInPosix) { +// ../../apps/core/src/validations/rename/validateRenameOperations.ts +async function validateRenameOperations(files, folderPathInPosix, probe) { const normalizedTasks = []; - for (let i = 0;i < files.length; i++) { - const renameOp = files[i]; - if (!renameOp) { + for (const renameOp of files) { + if (!renameOp) continue; - } normalizedTasks.push({ from: Path.posix(renameOp.from), to: Path.posix(renameOp.to) @@ -61840,13 +62133,13 @@ async function validateRenameOperations(files, folderPathInPosix) { } const syncResult = validateRenameOperationsSync(normalizedTasks, folderPathInPosix); const errors4 = [...syncResult.errors]; - const sourceExistResult = await validateSourceFileExist(normalizedTasks); + const sourceExistResult = await validateSourceFilesExist(normalizedTasks, probe); if (!sourceExistResult.isValid) { for (const missingFile of sourceExistResult.missingFiles) { errors4.push(`Source file "${missingFile}" does not exist in the media folder`); } } - const destNotExistResult = await validateDestFileNotExist(normalizedTasks); + const destNotExistResult = await validateDestFilesNotExist(normalizedTasks, probe); if (!destNotExistResult.isValid) { for (const existingFile of destNotExistResult.existingFiles) { errors4.push(`Target file "${existingFile}" already exists in the filesystem`); @@ -61861,206 +62154,322 @@ async function validateRenameOperations(files, folderPathInPosix) { } return syncResult; } -async function validateSourceFileExist(tasks) { - const missingFiles = []; - for (const task of tasks) { - if (!task) - continue; - try { - const platformPath = Path.toPlatformPath(task.from); - const stats = await import_promises9.stat(platformPath); - if (!stats.isFile()) { - missingFiles.push(task.from); + +// ../types/planCommon.ts +function isActivePlanStatus(status) { + return status === "preparing" || status === "pending"; +} + +// ../../apps/core/src/pipeline/paths.ts +function joinPosix(...parts) { + return parts.join("/"); +} +function plansDir(appDataDir) { + return joinPosix(Path.posix(appDataDir), "plans"); +} +function planFilePath(appDataDir, planId) { + return joinPosix(plansDir(appDataDir), `${planId}.plan.json`); +} + +// ../../apps/core/src/pipeline/plans.ts +async function writePlan(fs, appDataDir, plan) { + await fs.writeTextFile(planFilePath(appDataDir, plan.id), JSON.stringify(plan, null, 2)); +} + +// ../../apps/core/src/pipeline/createRenameEpisodePlan.ts +function renameFileExistenceProbe(fs) { + return { + isFile: async (path5) => { + if (fs.isFile) { + return fs.isFile(path5); } - } catch { - missingFiles.push(task.from); + return fs.exists(path5); } - } - return { - isValid: missingFiles.length === 0, - missingFiles }; } -async function validateDestFileNotExist(tasks) { - const existingFiles = []; - for (const task of tasks) { - if (!task) - continue; - try { - const platformPath = Path.toPlatformPath(task.to); - const stats = await statWithTimeout(platformPath); - if (stats.isFile()) { - existingFiles.push(task.to); - } - } catch { - continue; +async function createRenameEpisodePlanPipeline(mediaFolderPath, files, options, deps) { + const posixFolder = deps.normalizePosix(mediaFolderPath); + const mm = await deps.getMediaMetadata(posixFolder); + const metadataError = assertMediaFolderHasMetadata(!!mm, posixFolder); + if (metadataError) { + throw new Error(metadataError); + } + const normalizedFiles = files.map((entry) => ({ + from: deps.normalizePosix(entry.from), + to: deps.normalizePosix(entry.to) + })); + const allowEmptyFiles = options?.allowEmptyFiles ?? false; + if (normalizedFiles.length === 0 && !allowEmptyFiles) { + throw new Error("No rename entries in task"); + } + for (const entry of normalizedFiles) { + const episodeError = assertEpisodeVideoFile(mm, entry.from); + if (episodeError) { + throw new Error(episodeError); } } - return { - isValid: existingFiles.length === 0, - existingFiles + if (normalizedFiles.length > 0) { + const validation = await validateRenameOperations(normalizedFiles, posixFolder, renameFileExistenceProbe(deps.fs)); + if (!validation.isValid) { + throw new Error(validation.errors.join("; ")); + } + } + const createId = deps.createId ?? import_node_crypto2.randomUUID; + const id = options?.id ?? createId(); + const plan = { + id, + task: "rename-files", + status: "pending", + creator: options?.creator ?? "app", + mediaFolderPath: posixFolder, + files: normalizedFiles }; + await writePlan(deps.fs, deps.appDataDir, plan); + return plan; } -function statWithTimeout(filePath, timeoutMs = 1000) { - return Promise.race([ - import_promises9.stat(filePath), - new Promise((_, reject) => setTimeout(() => reject(new Error(`stat timeout for path: ${filePath}`)), timeoutMs)) - ]); + +// ../types/types.ts +var DEFAULT_AI_PROVIDERS = [ + { name: "DeepSeek", baseURL: "https://api.deepseek.com", apiKey: "", model: "deepseek-v4-flash" }, + { name: "OpenAI", baseURL: "https://api.openai.com/v1", apiKey: "", model: "gpt-4o" }, + { name: "OpenRouter", baseURL: "https://openrouter.ai/api/v1", apiKey: "", model: "deepseek/deepseek-v4-flash" }, + { name: "GLM", baseURL: "https://open.bigmodel.cn/api/paas/v4", apiKey: "", model: "GLM-4.5" }, + { name: "Other", baseURL: "", apiKey: "", model: "" } +]; +var DEFAULT_SELECTED_AI_PROVIDER = "DeepSeek"; +var AI_AGENT_PERMISSIONS = { + metadataWrite: "metadata.write" +}; +function hasAiAgentPermission(userConfig, permission) { + return userConfig?.aiAgent?.permissions?.includes(permission) ?? false; } -// src/tools/renameFilesTaskDefaults.ts -function defaultRenameFilesTaskDeps(appDataDir) { +// ../types/ai-tools/planTaskMessages.ts +var END_PLAN_TASK_SUCCESS_MESSAGE = "Task is created successfuly. User need to go to SMM, review and approve the task."; +var RENAME_PLAN_AUTO_APPLIED_MESSAGE = "Rename plan applied automatically (metadata.write permission granted). No user approval needed."; +var RECOGNIZE_PLAN_AUTO_APPLIED_MESSAGE = "Recognize plan applied automatically (metadata.write permission granted). No user approval needed."; + +// ../types/event-types.ts +var RecognizeMediaFilePlanReady = { + event: "recognizeMediaFilePlanReady" +}; +var RenameFilesPlanReady = { + event: "renameFilesPlanReady" +}; +var MEDIA_METADATA_UPDATED_EVENT = "mediaMetadataUpdated"; +var USER_CONFIG_UPDATED_EVENT = "userConfigUpdated"; +var USER_CONFIG_FOLDER_RENAMED_EVENT = "userConfig.folderRenamed"; + +// src/tools/chatFsPort.ts +function unsupportedFsOperation(name21) { + throw new Error(`${name21} is not supported by the plan filesystem adapter`); +} +function createFsPort(fs) { return { - validateOperations: async (files, folderPathInPosix) => { - return validateRenameOperations(files, folderPathInPosix); + async readTextFile(path5) { + const value = await fs.readJson(path5); + if (value === null) { + throw new Error(`File not found: ${path5}`); + } + return JSON.stringify(value); }, - getMediaMetadata: async (folderPathInPosix) => { - return await readMediaMetadataCache(appDataDir, folderPathInPosix) ?? null; + async writeTextFile(path5, content) { + await fs.writeJson(path5, JSON.parse(content)); + }, + async writeBinaryFile() { + unsupportedFsOperation("writeBinaryFile"); + }, + exists: (path5) => fs.exists(path5), + isFile: (path5) => fs.exists(path5), + async listFiles() { + return unsupportedFsOperation("listFiles"); + }, + async listSubdirectories() { + return unsupportedFsOperation("listSubdirectories"); + }, + async deleteFile() { + unsupportedFsOperation("deleteFile"); + }, + async rename() { + unsupportedFsOperation("rename"); + }, + async mkdir() { + unsupportedFsOperation("mkdir"); } }; } - -// src/tools/recognizeMediaFilesTask.ts -function defaultRecognizeFilesTaskDeps(fs) { - return { - validateFiles: (files) => defaultValidateRecognizedFiles(files, fs) - }; +function planPath(appDataDir, planId) { + return new Path(appDataDir, `plans/${planId}.plan.json`).abs("posix"); } -function makeLogger2(logger) { - return { - info: (obj, msg) => logger?.info(obj, msg), - warn: (obj, msg) => logger?.warn(obj, msg), - error: (obj, msg) => logger?.error(obj, msg) - }; + +// src/tools/createRenameEpisodePlan.ts +function metadataPath(appDataDir, mediaFolderPath) { + const filename = Path.posix(mediaFolderPath).replace(/[/\\:?*|<>"]/g, "_"); + return new Path(appDataDir, `metadata/${filename}.json`).abs("posix"); } -function buildBeginRecognizeTaskTool(clientId, appDataDir, fs, broadcast, logger, abortSignal) { - const log = makeLogger2(logger); +function buildCreateRenameEpisodePlanTool(appDataDir, fs, broadcast, logger, abortSignal, extra) { const emit = broadcast ?? defaultBroadcast; return { - description: BEGIN_RECOGNIZE_TASK_DESCRIPTION, - toolName: BEGIN_RECOGNIZE_TASK, - inputSchema: beginRecognizeTaskInputSchema, + description: CREATE_RENAME_EPISODE_PLAN_DESCRIPTION, + inputSchema: createRenameEpisodePlanInputSchema, execute: async (args) => { if (abortSignal?.aborted) { throw new Error("Request was aborted"); } - const { mediaFolderPath } = args ?? {}; - log.info({ mediaFolderPath, clientId }, `[tool][${BEGIN_RECOGNIZE_TASK}] Starting new recognition task`); - const folderPathInPosix = Path.posix(mediaFolderPath ?? ""); + const parsed = createRenameEpisodePlanInputSchema.safeParse(args); + if (!parsed.success) { + return formatToolError(parsed.error); + } try { - const taskId = await beginRecognizePlan(appDataDir, folderPathInPosix, fs); - log.info({ taskId, mediaFolderPath: folderPathInPosix, clientId }, `[tool][${BEGIN_RECOGNIZE_TASK}] Task created successfully`); - const fullPlanPath = planFilePath(appDataDir, taskId); - const planFilePathInPosix = Path.posix(fullPlanPath); + const plan = await createRenameEpisodePlanPipeline(parsed.data.mediaFolderPath, parsed.data.files, { creator: "ai" }, { + fs: createFsPort(fs), + appDataDir, + normalizePosix: Path.posix, + getMediaMetadata: (folder) => fs.readJson(metadataPath(appDataDir, folder)) + }); + if (extra?.getUserConfig && extra.applyRenameEpisodePlan) { + try { + const userConfig = await extra.getUserConfig(); + if (hasAiAgentPermission(userConfig, AI_AGENT_PERMISSIONS.metadataWrite)) { + await extra.applyRenameEpisodePlan(plan); + emit({ + event: MEDIA_METADATA_UPDATED_EVENT, + data: { folderPath: plan.mediaFolderPath } + }); + logger?.info({ + planId: plan.id, + folderPath: plan.mediaFolderPath, + fileCount: plan.files.length + }, `[tool][${CREATE_RENAME_EPISODE_PLAN}] Plan applied automatically`); + return toolOk({ + message: RENAME_PLAN_AUTO_APPLIED_MESSAGE, + planId: plan.id + }); + } + } catch (error48) { + logger?.warn({ planId: plan.id, error: error48 }, `[tool][${CREATE_RENAME_EPISODE_PLAN}] Auto-apply failed, plan stays pending`); + } + } const data = { - taskId, - planFilePath: planFilePathInPosix + taskId: plan.id, + planFilePath: planPath(appDataDir, plan.id) }; - emit({ - event: RecognizeMediaFilePlanReady.event, - data + emit({ event: RenameFilesPlanReady.event, data }); + logger?.info({ + planId: plan.id, + folderPath: plan.mediaFolderPath, + fileCount: plan.files.length + }, `[tool][${CREATE_RENAME_EPISODE_PLAN}] Plan created`); + return toolOk({ + message: END_PLAN_TASK_SUCCESS_MESSAGE, + planId: plan.id }); - log.info({ taskId, mediaFolderPath: folderPathInPosix, clientId, broadcast: true }, `[DIAG] begin-recognize-task: plan created, RecognizeMediaFilePlanReady broadcast sent`); - return toolOk({ taskId }); } catch (error48) { - log.error({ - mediaFolderPath: folderPathInPosix, - error: error48 instanceof Error ? error48.message : String(error48), - clientId - }, `[tool][${BEGIN_RECOGNIZE_TASK}] Failed to create task`); return formatToolError(error48); } } }; } -function buildAddRecognizedMediaFileTool(clientId, appDataDir, fs, logger, abortSignal, deps) { - const log = makeLogger2(logger); - return { - description: ADD_RECOGNIZED_MEDIA_FILE_DESCRIPTION, - toolName: ADD_RECOGNIZED_MEDIA_FILE, - inputSchema: addRecognizedMediaFileInputSchema, - execute: async (args) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const { taskId, season, episode, path: filePath } = args ?? {}; - const normalizedTaskId = (taskId ?? "").trim(); - log.info({ taskId: normalizedTaskId, season, episode, path: filePath, clientId }, `[tool][${ADD_RECOGNIZED_MEDIA_FILE}] Adding file to task`); - try { - const recognizedFile = { - season: season ?? 0, - episode: episode ?? 0, - path: filePath ?? "" - }; - await appendRecognizedFile(appDataDir, normalizedTaskId, recognizedFile, fs, { validateFiles: deps?.validateFiles }); - log.info({ - taskId: normalizedTaskId, - season, - episode, - path: filePath, - clientId - }, `[tool][${ADD_RECOGNIZED_MEDIA_FILE}] File added to task successfully`); - return toolOk({}); - } catch (error48) { - log.error({ - taskId: normalizedTaskId, - season, - episode, - path: filePath, - error: error48 instanceof Error ? error48.message : String(error48), - clientId - }, `[tool][${ADD_RECOGNIZED_MEDIA_FILE}] Failed to add file to task`); - return formatToolError(error48); - } + +// ../../apps/core/src/pipeline/createRecognizeEpisodePlan.ts +var import_node_crypto3 = require("node:crypto"); +async function createRecognizeEpisodePlanPipeline(mediaFolderPath, files, options, deps) { + const posixFolder = deps.normalizePosix(mediaFolderPath); + if (files.length === 0) { + throw new Error("No recognize entries in task"); + } + const normalizedFiles = files.map((file2) => ({ + season: file2.season, + episode: file2.episode, + path: deps.normalizePosix(file2.path) + })); + const seenPaths = new Set; + const seenEpisodes = new Set; + for (const file2 of normalizedFiles) { + if (seenPaths.has(file2.path)) { + throw new Error(`Duplicate file path in task: ${file2.path}`); } + seenPaths.add(file2.path); + const episodeKey = `${file2.season}-${file2.episode}`; + if (seenEpisodes.has(episodeKey)) { + throw new Error(`Duplicate season/episode in task: S${file2.season}E${file2.episode}`); + } + seenEpisodes.add(episodeKey); + if (!await deps.fs.exists(file2.path)) { + throw new Error(`File "${file2.path}" (S${file2.season}E${file2.episode}) does not exist in the media folder`); + } + } + const createId = deps.createId ?? import_node_crypto3.randomUUID; + const id = options?.id ?? createId(); + const plan = { + id, + task: "recognize-media-file", + status: "pending", + creator: options?.creator ?? "app", + mediaFolderPath: posixFolder, + files: normalizedFiles }; + await writePlan(deps.fs, deps.appDataDir, plan); + return plan; } -function buildEndRecognizeTaskTool(clientId, appDataDir, fs, broadcast, logger, abortSignal) { - const log = makeLogger2(logger); + +// src/tools/createRecognizeEpisodePlan.ts +function buildCreateRecognizeEpisodePlanTool(appDataDir, fs, broadcast, logger, abortSignal, extra) { const emit = broadcast ?? defaultBroadcast; return { - description: END_RECOGNIZE_TASK_DESCRIPTION, - toolName: END_RECOGNIZE_TASK, - inputSchema: endRecognizeTaskInputSchema, + description: CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION, + inputSchema: createRecognizeEpisodePlanInputSchema, execute: async (args) => { if (abortSignal?.aborted) { throw new Error("Request was aborted"); } - const { taskId } = args ?? {}; - const normalizedTaskId = (taskId ?? "").trim(); - log.info({ taskId: normalizedTaskId, clientId }, `[tool][${END_RECOGNIZE_TASK}] Ending recognition task`); + const parsed = createRecognizeEpisodePlanInputSchema.safeParse(args); + if (!parsed.success) { + return formatToolError(parsed.error); + } try { - const task = await readRecognizePlan(appDataDir, normalizedTaskId, fs); - if (!task) { - log.error({ taskId: normalizedTaskId, clientId }, `[tool][${END_RECOGNIZE_TASK}] Task not found`); - return formatToolError(`Task with id "${normalizedTaskId}" not found`); - } - if (task.status === "rejected") { - log.warn({ taskId: normalizedTaskId, clientId }, `[tool][${END_RECOGNIZE_TASK}] Task cancelled by user`); - return toolError(PLAN_CANCELLED_BY_USER_MESSAGE); - } - if (task.files.length === 0) { - log.warn({ taskId: normalizedTaskId, clientId }, `[tool][${END_RECOGNIZE_TASK}] No files in task`); - return formatToolError("No recognized files in task"); + const plan = await createRecognizeEpisodePlanPipeline(parsed.data.mediaFolderPath, parsed.data.files, { creator: "ai" }, { + fs: createFsPort(fs), + appDataDir, + normalizePosix: Path.posix + }); + if (extra?.getUserConfig && extra.applyRecognizeEpisodePlan) { + try { + const userConfig = await extra.getUserConfig(); + if (hasAiAgentPermission(userConfig, AI_AGENT_PERMISSIONS.metadataWrite)) { + await extra.applyRecognizeEpisodePlan(plan); + emit({ + event: MEDIA_METADATA_UPDATED_EVENT, + data: { folderPath: plan.mediaFolderPath } + }); + logger?.info({ + planId: plan.id, + folderPath: plan.mediaFolderPath, + fileCount: plan.files.length + }, `[tool][${CREATE_RECOGNIZE_EPISODE_PLAN}] Plan applied automatically`); + return toolOk({ + message: RECOGNIZE_PLAN_AUTO_APPLIED_MESSAGE, + planId: plan.id + }); + } + } catch (error48) { + logger?.warn({ planId: plan.id, error: error48 }, `[tool][${CREATE_RECOGNIZE_EPISODE_PLAN}] Auto-apply failed, plan stays pending`); + } } - await updatePlanContent(appDataDir, task.id, { status: "pending" }, fs); - const fullPlanPath = planFilePath(appDataDir, task.id); - const planFilePathInPosix = Path.posix(fullPlanPath); const data = { - taskId: task.id, - planFilePath: planFilePathInPosix + taskId: plan.id, + planFilePath: planPath(appDataDir, plan.id) }; - emit({ - event: RecognizeMediaFilePlanReady.event, - data + emit({ event: RecognizeMediaFilePlanReady.event, data }); + logger?.info({ + planId: plan.id, + folderPath: plan.mediaFolderPath, + fileCount: plan.files.length + }, `[tool][${CREATE_RECOGNIZE_EPISODE_PLAN}] Plan created`); + return toolOk({ + message: END_PLAN_TASK_SUCCESS_MESSAGE, + planId: plan.id }); - log.info({ - taskId: normalizedTaskId, - folderPath: task.mediaFolderPath, - fileCount: task.files.length, - clientId - }, `[tool][${END_RECOGNIZE_TASK}] Task completed successfully`); - return toolOk({ message: END_PLAN_TASK_SUCCESS_MESSAGE }); } catch (error48) { return formatToolError(error48); } @@ -62078,7 +62487,7 @@ function createChatTools(args) { allowlist: [], hello: { version: "0.0.0", - userDataDir: config2.appDataDir, + userDataDir: config2.userDataDir ?? config2.appDataDir, appDataDir: config2.appDataDir, logDir: "", tmpDir: "", @@ -62089,7 +62498,8 @@ function createChatTools(args) { appDataDir: config2.appDataDir, logger }; - const renameFilesTaskDeps = extra?.renameFilesTask ?? defaultRenameFilesTaskDeps(config2.appDataDir); + const tmdbRunners = extra?.tmdb; + const tvdbRunners = extra?.tvdb; return { [GET_APPLICATION_CONTEXT]: buildGetApplicationContextTool(clientId, userConfig, (cfg) => resolveAppLanguage({ configured: cfg.applicationLanguage, @@ -62101,12 +62511,24 @@ function createChatTools(args) { [GET_MEDIA_FOLDERS]: buildGetMediaFoldersTool(userConfig, abortSignal), [LIST_FILES_IN_MEDIA_FOLDER]: buildListFilesInMediaFolderTool(userConfig, abortSignal), [RENAME_FOLDER]: buildRenameFolderTool(clientId, syntheticConfig, abortSignal, acknowledge), - [BEGIN_RENAME_FILES_TASK]: buildBeginRenameFilesTaskTool(clientId, config2.appDataDir, fs, renameFilesTaskDeps, broadcast, logger, abortSignal), - [ADD_RENAME_FILE_TO_TASK]: buildAddRenameFileToTaskTool(clientId, config2.appDataDir, fs, renameFilesTaskDeps, logger, abortSignal), - [END_RENAME_FILES_TASK]: buildEndRenameFilesTaskTool(clientId, config2.appDataDir, fs, broadcast, logger, abortSignal), - [BEGIN_RECOGNIZE_TASK]: buildBeginRecognizeTaskTool(clientId, config2.appDataDir, fs, broadcast, logger, abortSignal), - [ADD_RECOGNIZED_MEDIA_FILE]: buildAddRecognizedMediaFileTool(clientId, config2.appDataDir, fs, logger, abortSignal), - [END_RECOGNIZE_TASK]: buildEndRecognizeTaskTool(clientId, config2.appDataDir, fs, broadcast, logger, abortSignal) + [RENAME_EPISODE_FILE]: buildRenameEpisodeFileTool(clientId, extra?.renameEpisodeFile, abortSignal, acknowledge), + [SCRAPE]: buildScrapeTool(extra?.scrapeFolder, abortSignal), + [GET_JOB]: buildGetJobTool(extra?.getJob, abortSignal), + [TMDB_SEARCH]: buildTmdbSearchTool(tmdbRunners, abortSignal), + [TMDB_GET_MOVIE]: buildTmdbGetMovieTool(tmdbRunners, abortSignal), + [TMDB_GET_TV_SHOW]: buildTmdbGetTvShowTool(tmdbRunners, abortSignal), + [TVDB_SEARCH]: buildTvdbSearchTool(tvdbRunners, abortSignal), + [TVDB_GET_MOVIE]: buildTvdbGetMovieTool(tvdbRunners, abortSignal), + [TVDB_GET_TV_SHOW]: buildTvdbGetTvShowTool(tvdbRunners, abortSignal), + [TVDB_GET_LANGUAGES]: buildTvdbGetLanguagesTool(tvdbRunners, abortSignal), + [CREATE_RENAME_EPISODE_PLAN]: buildCreateRenameEpisodePlanTool(config2.appDataDir, fs, broadcast, logger, abortSignal, { + getUserConfig: () => Promise.resolve(userConfig), + applyRenameEpisodePlan: extra?.applyRenameEpisodePlan + }), + [CREATE_RECOGNIZE_EPISODE_PLAN]: buildCreateRecognizeEpisodePlanTool(config2.appDataDir, fs, broadcast, logger, abortSignal, { + getUserConfig: () => Promise.resolve(userConfig), + applyRecognizeEpisodePlan: extra?.applyRecognizeEpisodePlan + }) }; } @@ -62152,12 +62574,18 @@ async function doChat(config2, request, extra = {}) { [GET_MEDIA_FOLDERS]: tools[GET_MEDIA_FOLDERS], [LIST_FILES_IN_MEDIA_FOLDER]: tools[LIST_FILES_IN_MEDIA_FOLDER], [RENAME_FOLDER]: tools[RENAME_FOLDER], - [BEGIN_RENAME_FILES_TASK]: tools[BEGIN_RENAME_FILES_TASK], - [ADD_RENAME_FILE_TO_TASK]: tools[ADD_RENAME_FILE_TO_TASK], - [END_RENAME_FILES_TASK]: tools[END_RENAME_FILES_TASK], - [BEGIN_RECOGNIZE_TASK]: tools[BEGIN_RECOGNIZE_TASK], - [ADD_RECOGNIZED_MEDIA_FILE]: tools[ADD_RECOGNIZED_MEDIA_FILE], - [END_RECOGNIZE_TASK]: tools[END_RECOGNIZE_TASK] + [RENAME_EPISODE_FILE]: tools[RENAME_EPISODE_FILE], + [SCRAPE]: tools[SCRAPE], + [GET_JOB]: tools[GET_JOB], + [TMDB_SEARCH]: tools[TMDB_SEARCH], + [TMDB_GET_MOVIE]: tools[TMDB_GET_MOVIE], + [TMDB_GET_TV_SHOW]: tools[TMDB_GET_TV_SHOW], + [TVDB_SEARCH]: tools[TVDB_SEARCH], + [TVDB_GET_MOVIE]: tools[TVDB_GET_MOVIE], + [TVDB_GET_TV_SHOW]: tools[TVDB_GET_TV_SHOW], + [TVDB_GET_LANGUAGES]: tools[TVDB_GET_LANGUAGES], + [CREATE_RENAME_EPISODE_PLAN]: tools[CREATE_RENAME_EPISODE_PLAN], + [CREATE_RECOGNIZE_EPISODE_PLAN]: tools[CREATE_RECOGNIZE_EPISODE_PLAN] }, stopWhen: stepCountIs(CHAT_STEP_LIMIT) }); @@ -62293,6 +62721,7 @@ async function forwardWebResponseToNode(response, res) { response.headers.forEach((value, key) => { res.setHeader(key, value); }); + res.setHeader("Cache-Control", "no-store"); if (response.body) { const reader = response.body.getReader(); res.flushHeaders?.(); @@ -63766,8 +64195,8 @@ function createOpenAICompatible(options) { const getHeaders = () => withUserAgentSuffix(headers, `ai-sdk/openai-compatible/${VERSION4}`); const getCommonModelConfig = (modelType) => ({ provider: `${providerName}.${modelType}`, - url: ({ path: path6 }) => { - const url2 = new URL(`${baseURL}${path6}`); + url: ({ path: path5 }) => { + const url2 = new URL(`${baseURL}${path5}`); if (options.queryParams) { url2.search = new URLSearchParams(options.queryParams).toString(); } @@ -63802,17 +64231,7 @@ function createOpenAICompatible(options) { provider.imageModel = createImageModel; return provider; } -// ../core/types.ts -var DEFAULT_AI_PROVIDERS = [ - { name: "DeepSeek", baseURL: "https://api.deepseek.com", apiKey: "", model: "deepseek-v4-flash" }, - { name: "OpenAI", baseURL: "https://api.openai.com/v1", apiKey: "", model: "gpt-4o" }, - { name: "OpenRouter", baseURL: "https://openrouter.ai/api/v1", apiKey: "", model: "deepseek/deepseek-v4-flash" }, - { name: "GLM", baseURL: "https://open.bigmodel.cn/api/paas/v4", apiKey: "", model: "GLM-4.5" }, - { name: "Other", baseURL: "", apiKey: "", model: "" } -]; -var DEFAULT_SELECTED_AI_PROVIDER = "DeepSeek"; - -// ../core/configMigration.ts +// ../../apps/core/src/configMigration.ts var NAME_TO_OLD_KEY = { DeepSeek: "deepseek", OpenAI: "openAI", @@ -64102,7 +64521,7 @@ function createProxiedFetch(proxyUrl, logger) { // src/downloadImage.ts var import_node_buffer = require("node:buffer"); -var import_promises10 = require("node:fs/promises"); +var import_promises9 = require("node:fs/promises"); var import_node_url = require("node:url"); var import_node_path6 = require("node:path"); var DEFAULT_CONTENT_TYPE = "image/jpeg"; @@ -64179,7 +64598,7 @@ async function doDownloadImage(url2, config2) { if (!validatePathIsInAllowlist(posixPath, allowlist)) { throw new Error(`Permission denied: file ${platformPath} is not allowed to be read`); } - const buffer2 = await import_promises10.readFile(platformPath); + const buffer2 = await import_promises9.readFile(platformPath); const ext = import_node_path6.extname(platformPath); const contentType2 = getContentTypeFromExtension(ext); logger?.info({ platformPath, bytes: buffer2.length, contentType: contentType2 }, "[DownloadImage] read file"); @@ -64254,9 +64673,9 @@ function buildUpstreamUrl(upstreamBaseURL, incomingPath, incomingSearch) { const base = new URL(upstreamBaseURL); const basePath = base.pathname.replace(/\/+$/, ""); const normalizedPath = incomingPath.startsWith("/") ? incomingPath : `/${incomingPath}`; - const path6 = `${basePath}${normalizedPath}`; + const path5 = `${basePath}${normalizedPath}`; const query = incomingSearch.startsWith("?") ? incomingSearch : ""; - return `${base.origin}${path6}${query}`; + return `${base.origin}${path5}${query}`; } function validateUpstreamBaseURL(headerValue, allowedUpstreamHosts) { let upstreamUrl; @@ -64955,20 +65374,22 @@ function createStreamingNodeHttpFetch() { } // src/writeFile.ts var import_node_path7 = __toESM(require("node:path")); -var import_promises11 = require("node:fs/promises"); +var import_promises10 = require("node:fs/promises"); var import_node_fs = require("node:fs"); -// ../core/errors.ts +// ../types/errorCodes.ts +var ExistedFileError = "File Already Existed"; +var FileNotFoundError = "File Not Found"; + +// ../utils/src/errors.ts function isError2(error48, message) { return error48.startsWith(`${message}:`); } -var ExistedFileError = "File Already Existed"; -function existedFileError(path6) { - return `${ExistedFileError}: ${path6}`; +function existedFileError(path5) { + return `${ExistedFileError}: ${path5}`; } -var FileNotFoundError = "File Not Found"; -function fileNotFoundError(path6) { - return `${FileNotFoundError}: ${path6}`; +function fileNotFoundError(path5) { + return `${FileNotFoundError}: ${path5}`; } // src/writeFile.ts @@ -64997,7 +65418,7 @@ async function acquireFileLock(resolvedPath) { } async function fileExists(filePath) { try { - await import_promises11.access(filePath, import_node_fs.constants.F_OK); + await import_promises10.access(filePath, import_node_fs.constants.F_OK); return true; } catch { return false; @@ -65029,7 +65450,7 @@ async function doWriteFile(body, config2, traceId = "") { const validatedPath = resolvedPath; const parentDir = import_node_path7.default.dirname(validatedPath); try { - await import_promises11.mkdir(parentDir, { recursive: true }); + await import_promises10.mkdir(parentDir, { recursive: true }); logger?.debug({ traceId, parentDir }, "doWriteFile: Parent directory ensured"); } catch (error48) { logger?.warn({ traceId, error: error48 }, "doWriteFile: Failed to ensure parent directory"); @@ -65043,7 +65464,7 @@ async function doWriteFile(body, config2, traceId = "") { }; } try { - await import_promises11.writeFile(validatedPath, data, "utf-8"); + await import_promises10.writeFile(validatedPath, data, "utf-8"); logger?.info({ traceId, path: validatedPath, size: data.length }, "doWriteFile: File written successfully (create mode)"); return {}; } catch (error48) { @@ -65056,7 +65477,7 @@ async function doWriteFile(body, config2, traceId = "") { if (mode === "overwrite") { logger?.debug({ traceId, path: validatedPath }, "doWriteFile: Overwrite mode"); try { - await import_promises11.writeFile(validatedPath, data, "utf-8"); + await import_promises10.writeFile(validatedPath, data, "utf-8"); logger?.info({ traceId, path: validatedPath, size: data.length }, "doWriteFile: File written successfully (overwrite mode)"); return {}; } catch (error48) { @@ -65069,7 +65490,7 @@ async function doWriteFile(body, config2, traceId = "") { if (mode === "append") { logger?.debug({ traceId, path: validatedPath }, "doWriteFile: Append mode"); try { - await import_promises11.appendFile(validatedPath, data, "utf-8"); + await import_promises10.appendFile(validatedPath, data, "utf-8"); logger?.info({ traceId, path: validatedPath, appendedSize: data.length }, "doWriteFile: Data appended successfully"); return {}; } catch (error48) { @@ -65100,7 +65521,7 @@ async function doWriteFile(body, config2, traceId = "") { } // src/readFile.ts var import_node_path8 = __toESM(require("node:path")); -var import_promises12 = require("node:fs/promises"); +var import_promises11 = require("node:fs/promises"); var import_node_fs2 = require("node:fs"); var readFileRequestSchema = exports_external2.object({ path: exports_external2.string().min(1, "Path is required"), @@ -65108,7 +65529,7 @@ var readFileRequestSchema = exports_external2.object({ }); async function fileExists2(filePath) { try { - await import_promises12.access(filePath, import_node_fs2.constants.F_OK); + await import_promises11.access(filePath, import_node_fs2.constants.F_OK); return true; } catch { return false; @@ -65119,7 +65540,7 @@ async function checkFileIsReadable(filePath) { return null; } try { - return await import_promises12.readFile(filePath, "utf-8"); + return await import_promises11.readFile(filePath, "utf-8"); } catch { return null; } @@ -65172,7 +65593,7 @@ async function doReadFile(body, config2) { } // src/deleteFile.ts var import_node_path9 = __toESM(require("node:path")); -var import_promises13 = require("node:fs/promises"); +var import_promises12 = require("node:fs/promises"); var deleteFileRequestSchema = exports_external2.object({ path: exports_external2.string().min(1, "Path is required") }); @@ -65198,7 +65619,7 @@ async function doDeleteFile(body, config2) { } const platformPath = Path.toPlatformPath(posixPath); try { - const fileStats = await import_promises13.stat(platformPath); + const fileStats = await import_promises12.stat(platformPath); if (!fileStats.isFile()) { logger?.info({ filePath: platformPath }, "doDeleteFile: path is not a file"); return { @@ -65217,7 +65638,7 @@ async function doDeleteFile(body, config2) { }; } try { - await import_promises13.unlink(platformPath); + await import_promises12.unlink(platformPath); logger?.info({ filePath: platformPath }, "doDeleteFile: file deleted successfully"); return { data: { path: platformPath } }; } catch (error48) { @@ -65246,7 +65667,7 @@ async function doDeleteFile(body, config2) { } // src/deleteFolder.ts var import_node_path10 = __toESM(require("node:path")); -var import_promises14 = require("node:fs/promises"); +var import_promises13 = require("node:fs/promises"); var deleteFolderRequestSchema = exports_external2.object({ path: exports_external2.string().min(1, "Path is required") }); @@ -65272,7 +65693,7 @@ async function doDeleteFolder(body, config2) { } const platformPath = Path.toPlatformPath(posixPath); try { - const folderStats = await import_promises14.stat(platformPath); + const folderStats = await import_promises13.stat(platformPath); if (!folderStats.isDirectory()) { logger?.info({ folderPath: platformPath }, "doDeleteFolder: path is not a directory"); return { @@ -65291,7 +65712,7 @@ async function doDeleteFolder(body, config2) { }; } try { - await import_promises14.rm(platformPath, { recursive: true, force: true }); + await import_promises13.rm(platformPath, { recursive: true, force: true }); logger?.info({ folderPath: platformPath }, "doDeleteFolder: folder deleted successfully"); return { data: { path: platformPath } }; } catch (error48) { @@ -65362,7 +65783,7 @@ async function doListFilesInMediaFolder(body, config2 = EMPTY_CORE_ROUTES_CONFIG }; } } -// ../core/getMediaFolder.ts +// ../../apps/core/src/getMediaFolder.ts function getMediaFolder(filePath, folderPaths) { const filePathNorm = new Path(filePath).abs("posix").replace(/^\/[A-Za-z](?::|\/)/, ""); for (const folder of folderPaths) { @@ -65376,11 +65797,11 @@ function getMediaFolder(filePath, folderPaths) { } // src/renameFileExecution.ts -var import_promises15 = require("node:fs/promises"); +var import_promises14 = require("node:fs/promises"); var import_node_path11 = __toESM(require("node:path")); async function directoryExists(dirPath) { try { - const stats = await import_promises15.stat(dirPath); + const stats = await import_promises14.stat(dirPath); return stats.isDirectory(); } catch { return false; @@ -65391,14 +65812,14 @@ async function executeRenameOperation(from, to) { const toPathPlatform = new Path(to).platformAbsPath(); try { const destDir = import_node_path11.default.dirname(toPathPlatform); - await import_promises15.mkdir(destDir, { recursive: true }); + await import_promises14.mkdir(destDir, { recursive: true }); if (!await directoryExists(destDir)) { return { success: false, error: `Destination directory does not exist and could not be created: ${destDir}` }; } - await import_promises15.rename(fromPathPlatform, toPathPlatform); + await import_promises14.rename(fromPathPlatform, toPathPlatform); return { success: true }; } catch (error48) { const errorMessage = error48 instanceof Error ? error48.message : "Unknown error"; @@ -65439,83 +65860,8 @@ async function executeBatchRenameOperations(renameMappings, _options = {}) { } // src/validateRenameOperations.ts -var import_promises16 = require("node:fs/promises"); -async function validateSourceFileExist2(tasks) { - const missingFiles = []; - for (const task of tasks) { - try { - const platformPath = Path.toPlatformPath(task.from); - const stats = await import_promises16.stat(platformPath); - if (!stats.isFile()) { - missingFiles.push(task.from); - } - } catch { - missingFiles.push(task.from); - } - } - return { - isValid: missingFiles.length === 0, - missingFiles - }; -} -async function validateDestFileNotExist2(tasks) { - const existingFiles = []; - for (const task of tasks) { - try { - const platformPath = Path.toPlatformPath(task.to); - const stats = await import_promises16.stat(platformPath); - if (stats.isFile()) { - existingFiles.push(task.to); - } - } catch { - continue; - } - } - return { - isValid: existingFiles.length === 0, - existingFiles - }; -} async function validateRenameOperations2(files, folderPathInPosix) { - const normalizedTasks = []; - for (const renameOp of files) { - if (!renameOp) { - continue; - } - normalizedTasks.push({ - from: Path.posix(renameOp.from), - to: Path.posix(renameOp.to) - }); - } - if (normalizedTasks.length === 0) { - return { - isValid: true, - errors: [], - validatedRenames: [] - }; - } - const syncResult = validateRenameOperationsSync(normalizedTasks, folderPathInPosix); - const errors4 = [...syncResult.errors]; - const sourceExistResult = await validateSourceFileExist2(normalizedTasks); - if (!sourceExistResult.isValid) { - for (const missingFile of sourceExistResult.missingFiles) { - errors4.push(`Source file "${missingFile}" does not exist in the media folder`); - } - } - const destNotExistResult = await validateDestFileNotExist2(normalizedTasks); - if (!destNotExistResult.isValid) { - for (const existingFile of destNotExistResult.existingFiles) { - errors4.push(`Target file "${existingFile}" already exists in the filesystem`); - } - } - if (errors4.length > 0) { - return { - isValid: false, - errors: errors4, - validatedRenames: [] - }; - } - return syncResult; + return validateRenameOperations(files, folderPathInPosix, createNodeRenameFileExistenceProbe()); } // src/renameFiles.ts @@ -65556,10 +65902,10 @@ async function updateMediaMetadataAndBroadcast(mediaFolder, renameMappings, conf }); } function findAllowlistViolation(paths, allowlist) { - for (const path11 of paths) { - const posixPath = Path.posix(path11); + for (const path10 of paths) { + const posixPath = Path.posix(path10); if (!validatePathIsInAllowlist(posixPath, allowlist)) { - return path11; + return path10; } } return; @@ -65623,6 +65969,178 @@ async function doRenameFiles(body, config2 = EMPTY_CORE_ROUTES_CONFIG, headerCli } }; } +// src/tools/plans.ts +var import_promises15 = require("node:fs/promises"); +var import_node_path12 = __toESM(require("node:path")); +var import_node_crypto4 = require("node:crypto"); +function plansDir2(appDataDir) { + return import_node_path12.default.join(appDataDir, "plans"); +} +function planFilePath2(appDataDir, planId) { + return import_node_path12.default.join(plansDir2(appDataDir), `${planId}.plan.json`); +} +async function ensurePlansDirExists(appDataDir, fs) { + const dir = plansDir2(appDataDir); + try { + const stats = await import_promises15.stat(dir); + if (!stats.isDirectory()) { + throw new Error("Plans path exists but is not a directory"); + } + } catch (error48) { + if (error48.code === "ENOENT") { + await import_promises15.mkdir(dir, { recursive: true }); + return; + } + throw error48; + } +} +async function readPlanById(appDataDir, planId, fs) { + const plan = await fs.readJson(planFilePath2(appDataDir, planId)); + if (!plan) { + return null; + } + return normalizePlanPaths(withCreatorDefault(plan)); +} +async function listPlanFiles(appDataDir) { + const dir = plansDir2(appDataDir); + try { + const stats = await import_promises15.stat(dir); + if (!stats.isDirectory()) { + return []; + } + } catch { + return []; + } + const files = await import_promises15.readdir(dir); + return files.filter((file2) => file2.endsWith(".plan.json")).map((file2) => import_node_path12.default.join(dir, file2)); +} +function withCreatorDefault(plan) { + if (plan.creator) { + return plan; + } + return { ...plan, creator: "app" }; +} +function normalizePlanPaths(plan) { + const mediaFolderPath = Path.posix(plan.mediaFolderPath); + if (plan.task === "recognize-media-file") { + return { + ...plan, + mediaFolderPath, + files: plan.files.map((f) => ({ ...f, path: Path.posix(f.path) })) + }; + } + return { + ...plan, + mediaFolderPath, + files: plan.files.map((f) => ({ + from: Path.posix(f.from), + to: Path.posix(f.to) + })) + }; +} +async function createPlan(appDataDir, input, fs) { + await ensurePlansDirExists(appDataDir, fs); + const id = input.id ?? import_node_crypto4.randomUUID(); + const mediaFolderPath = Path.posix(input.mediaFolderPath); + const plan = input.task === "recognize-media-file" ? { + id, + task: "recognize-media-file", + status: "preparing", + creator: input.creator, + mediaFolderPath, + files: [] + } : { + id, + task: "rename-files", + status: "preparing", + creator: input.creator, + mediaFolderPath, + files: [] + }; + await fs.writeJson(planFilePath2(appDataDir, id), plan); + return plan; +} +async function updatePlanContent(appDataDir, id, patch, fs) { + const filePath = planFilePath2(appDataDir, id); + const existing = await fs.readJson(filePath); + if (!existing) { + return null; + } + const merged = withCreatorDefault({ + ...existing, + ...patch.status !== undefined ? { status: patch.status } : {}, + ...patch.files !== undefined ? { files: patch.files } : {} + }); + const updated = normalizePlanPaths(merged); + if (patch.status === "completed") { + await deletePlan(appDataDir, id); + return updated; + } + await fs.writeJson(filePath, updated); + return updated; +} +async function deletePlan(appDataDir, id) { + try { + await import_promises15.unlink(planFilePath2(appDataDir, id)); + } catch (error48) { + if (error48.code !== "ENOENT") { + throw error48; + } + } +} +async function cleanPreparingPlans(appDataDir, fs, logger) { + const start = Date.now(); + const plansPath = plansDir2(appDataDir); + logger?.info({ appDataDir, plansDir: plansPath }, "[cleanup] plan cleanup: scanning for stale preparing plans"); + const files = await listPlanFiles(appDataDir); + logger?.info({ plansDir: plansPath, scanned: files.length }, "[cleanup] plan cleanup: enumerated plan files"); + let removed = 0; + let failed = 0; + for (const filePath of files) { + try { + const plan = await fs.readJson(filePath); + if (!plan) { + logger?.debug({ filePath }, "[cleanup] plan cleanup: skipping unreadable plan file"); + continue; + } + if (plan.status === "preparing") { + await import_promises15.unlink(filePath); + removed++; + logger?.debug({ filePath, planId: plan.id, task: plan.task }, "[cleanup] plan cleanup: removed stale preparing plan"); + } else { + logger?.debug({ filePath, planId: plan.id, status: plan.status }, "[cleanup] plan cleanup: keeping plan (not preparing)"); + } + } catch (err) { + failed++; + logger?.warn({ filePath, error: err.message }, "[cleanup] plan cleanup: failed to process plan file, skipping"); + } + } + logger?.info({ + plansDir: plansPath, + scanned: files.length, + removed, + failed, + durationMs: Date.now() - start + }, "[cleanup] plan cleanup: complete"); + return removed; +} +async function getActivePlansForFolder(appDataDir, mediaFolderPath, fs) { + const target = Path.posix(mediaFolderPath); + const files = await listPlanFiles(appDataDir); + const plans = []; + for (const file2 of files) { + const plan = await fs.readJson(file2); + if (!plan) { + continue; + } + const normalized = normalizePlanPaths(withCreatorDefault(plan)); + if (normalized.mediaFolderPath === target && isActivePlanStatus(normalized.status)) { + plans.push(normalized); + } + } + return plans; +} + // src/plansApi.ts var getPlansRequestSchema = exports_external2.object({ mediaFolderPath: exports_external2.string().min(1, "mediaFolderPath is required") @@ -65722,16 +66240,16 @@ async function cleanupStalePlans(appDataDir, fs = defaultChatFs(), logger) { } // src/downloadImageAsFile.ts var import_node_buffer2 = require("node:buffer"); -var import_promises17 = require("node:fs/promises"); +var import_promises16 = require("node:fs/promises"); var import_node_fs3 = require("node:fs"); -var import_node_path12 = __toESM(require("node:path")); +var import_node_path13 = __toESM(require("node:path")); var downloadImageAsFileRequestSchema = exports_external2.object({ url: exports_external2.string().min(1, "url is required"), path: exports_external2.string().min(1, "path is required") }); async function fileExists3(filePath) { try { - await import_promises17.access(filePath, import_node_fs3.constants.F_OK); + await import_promises16.access(filePath, import_node_fs3.constants.F_OK); return true; } catch { return false; @@ -65759,7 +66277,7 @@ async function doDownloadImageAsFile(body, config2) { } const { url: url2, path: destPath } = validationResult.data; logger?.debug({ url: url2, destPath }, "[DownloadImageAsFile] processing request"); - const posixDestPath = import_node_path12.default.posix.resolve(Path.posix(destPath)); + const posixDestPath = import_node_path13.default.posix.resolve(Path.posix(destPath)); if (!validatePathIsInAllowlist(posixDestPath, allowlist)) { logger?.warn({ destPath, posixDestPath }, "[DownloadImageAsFile] destination not in allowlist"); return { @@ -65812,7 +66330,7 @@ async function doDownloadImageAsFile(body, config2) { } const arrayBuffer = await response.arrayBuffer(); const buffer = import_node_buffer2.Buffer.from(arrayBuffer); - await import_promises17.writeFile(platformDestPath, buffer); + await import_promises16.writeFile(platformDestPath, buffer); logger?.info({ url: normalizedUrl, destPath: platformDestPath, bytes: buffer.length }, "[DownloadImageAsFile] wrote file"); return { data: { url: url2, path: destPath } }; } catch (error48) { @@ -65828,9 +66346,9 @@ async function doDownloadImageAsFile(body, config2) { } // src/readImage.ts var import_node_buffer3 = require("node:buffer"); -var import_promises18 = require("node:fs/promises"); +var import_promises17 = require("node:fs/promises"); var import_node_fs4 = require("node:fs"); -var import_node_path13 = __toESM(require("node:path")); +var import_node_path14 = __toESM(require("node:path")); var readImageRequestSchema = exports_external2.object({ path: exports_external2.string().min(1, "path is required") }); @@ -65859,16 +66377,16 @@ var EXTENSION_TO_MIME = { ".tif": "image/tiff" }; function isValidImageFile(filePath) { - const ext = import_node_path13.default.extname(filePath).toLowerCase(); + const ext = import_node_path14.default.extname(filePath).toLowerCase(); return VALID_IMAGE_EXTENSIONS.includes(ext); } function getImageMimeType(filePath) { - const ext = import_node_path13.default.extname(filePath).toLowerCase(); + const ext = import_node_path14.default.extname(filePath).toLowerCase(); return EXTENSION_TO_MIME[ext] ?? "image/jpeg"; } async function fileExists4(filePath) { try { - await import_promises18.access(filePath, import_node_fs4.constants.F_OK); + await import_promises17.access(filePath, import_node_fs4.constants.F_OK); return true; } catch { return false; @@ -65885,7 +66403,7 @@ async function doReadImage(body, config2) { }; } const { path: filePath } = validationResult.data; - const posixPath = import_node_path13.default.posix.resolve(Path.posix(filePath)); + const posixPath = import_node_path14.default.posix.resolve(Path.posix(filePath)); if (!validatePathIsInAllowlist(posixPath, allowlist)) { logger?.warn({ filePath, posixPath }, "[ReadImage] path not in allowlist"); return { @@ -65904,7 +66422,7 @@ async function doReadImage(body, config2) { }; } try { - const arrayBuffer = await import_promises18.readFile(platformPath); + const arrayBuffer = await import_promises17.readFile(platformPath); const base643 = import_node_buffer3.Buffer.from(arrayBuffer).toString("base64"); const mimeType = getImageMimeType(platformPath); return { @@ -66361,10 +66879,14 @@ async function handleListFilesPost(req, res, ctx) { } // src/routes/helloRoute.ts -async function handleHelloPost(req, res, ctx) { - if (req.method !== "POST" || ctx.url.pathname !== "/api/hello") { +async function handleHelloGet(req, res, ctx) { + if (req.method !== "GET" || ctx.url.pathname !== "/api/hello") { return false; } + if (ctx.config.resolveHello) { + sendJson(res, 200, ctx.config.resolveHello()); + return true; + } if (ctx.config.hello === undefined) { sendJson(res, 200, { error: "hello not configured" }); return true; @@ -66373,6 +66895,7 @@ async function handleHelloPost(req, res, ctx) { sendJson(res, 200, result); return true; } +var handleHelloPost = handleHelloGet; // src/routes/isFolderAvailableRoute.ts var isFolderAvailableRequestSchema2 = exports_external2.object({ @@ -66696,6 +67219,9 @@ async function handleDiscoverGet(req, res, ctx) { // src/mcp/lifecycle.ts function parseStartOptions(body) { + return parseStartOptionsFromBody(body); +} +function parseStartOptionsFromBody(body) { if (!body || typeof body !== "object") { return; } @@ -66807,6 +67333,141 @@ async function handleMcpStatusGet(req, res, ctx) { return true; } +// src/mcp/mcpServerConfig.ts +var DEFAULT_MCP_HOST = "127.0.0.1"; +var DEFAULT_MCP_PORT = 30001; +function mcpErrorMessage(error48) { + return error48 instanceof Error ? error48.message : String(error48); +} +function resolveMcpStartOptions(config2, options) { + return { + hostname: options?.hostname ?? config2.mcpHost ?? DEFAULT_MCP_HOST, + port: options?.port ?? config2.mcpPort ?? DEFAULT_MCP_PORT + }; +} +async function startMcpServerWithUserConfig(manager, routesConfig, body, operation) { + const options = parseStartOptionsFromBody(body); + const userConfig = await readUserConfig(routesConfig); + const { hostname: hostname3, port } = resolveMcpStartOptions(userConfig, options); + try { + await manager.start({ hostname: hostname3, port }); + const state = manager.getState(); + if (state.status === "error") { + return { + data: state, + error: `Error Reason: ${state.error ?? "Failed to start MCP server"}` + }; + } + if (operation?.persistUserConfig !== false) { + await writeUserConfigToDisk(routesConfig, { + ...userConfig, + enableMcpServer: true, + mcpHost: hostname3, + mcpPort: port + }); + } + return { data: state, error: null }; + } catch (error48) { + const message = mcpErrorMessage(error48); + const state = manager.getState(); + return { + data: { ...state, status: "error", error: message }, + error: `Error Reason: ${message}` + }; + } +} +async function stopMcpServerWithUserConfig(manager, routesConfig, operation) { + const userConfig = await readUserConfig(routesConfig); + try { + await manager.stop(); + const state = manager.getState(); + if (state.status === "error") { + return { + data: state, + error: `Error Reason: ${state.error ?? "Failed to stop MCP server"}` + }; + } + return { data: state, error: null }; + } catch (error48) { + const message = mcpErrorMessage(error48); + return { + data: { status: "error", error: message }, + error: `Error Reason: ${message}` + }; + } finally { + if (operation?.persistUserConfig !== false) { + await writeUserConfigToDisk(routesConfig, { + ...userConfig, + enableMcpServer: false + }); + } + } +} +async function getMcpServerStatusWithUserConfig(manager, routesConfig) { + const state = manager.getState(); + if (state.status !== "running") { + const userConfig = await readUserConfig(routesConfig); + if (userConfig.enableMcpServer) { + await writeUserConfigToDisk(routesConfig, { + ...userConfig, + enableMcpServer: false + }); + } + } + return { data: state, error: null }; +} + +// src/routes/mcpServerRpcRoute.ts +async function handleMcpGetServerStatusGet(req, res, ctx) { + if (req.method !== "GET" || ctx.url.pathname !== "/api/get-mcp-server-status") { + return false; + } + const manager = ctx.config.mcp?.manager; + if (!manager) { + sendJson(res, 200, { error: "Error Reason: MCP lifecycle not configured" }); + return true; + } + try { + const result = await getMcpServerStatusWithUserConfig(manager, ctx.config); + sendJson(res, 200, result); + } catch (error48) { + const message = error48 instanceof Error ? error48.message : String(error48); + sendJson(res, 200, { error: `Error Reason: ${message}` }); + } + return true; +} +async function handleMcpStartPost(req, res, ctx) { + if (req.method !== "POST" || ctx.url.pathname !== "/api/start-mcp-server") { + return false; + } + const manager = ctx.config.mcp?.manager; + if (!manager) { + sendJson(res, 200, { error: "Error Reason: MCP lifecycle not configured" }); + return true; + } + const body = await readJsonBody(req); + const result = await startMcpServerWithUserConfig(manager, ctx.config, body, { + persistUserConfig: true + }); + sendJson(res, 200, result); + return true; +} +async function handleMcpStopPost(req, res, ctx) { + if (req.method !== "POST" || ctx.url.pathname !== "/api/stop-mcp-server") { + return false; + } + const manager = ctx.config.mcp?.manager; + if (!manager) { + sendJson(res, 200, { error: "Error Reason: MCP lifecycle not configured" }); + return true; + } + const result = await stopMcpServerWithUserConfig(manager, ctx.config, { + persistUserConfig: true + }); + sendJson(res, 200, result); + return true; +} + // src/routes/plansRoute.ts async function handleGetPlansPost(req, res, ctx) { if (req.method !== "POST" || ctx.url.pathname !== "/api/getPlans") { @@ -66878,7 +67539,7 @@ var coreRouteHandlers = [ handleListFilesGet, handleListFilesPost, handleWriteFilePost, - handleHelloPost, + handleHelloGet, handleIsFolderAvailablePost, handleGetEpisodesPost, handleListFilesInMediaFolderPost, @@ -66892,6 +67553,9 @@ var coreRouteHandlers = [ handleReadImagePost, handleDiscoverGet, handleChatPost, + handleMcpGetServerStatusGet, + handleMcpStartPost, + handleMcpStopPost, handleMcpStartPut, handleMcpStopPut, handleMcpStatusGet, @@ -67189,7 +67853,7 @@ var ZodMiniType = /* @__PURE__ */ $constructor("ZodMiniType", (inst, def) => { }, { parent: true }); }; inst.with = inst.check; - inst.clone = (_def, params) => clone(inst, _def, params); + inst.clone = (_def, params) => clone2(inst, _def, params); inst.brand = () => inst; inst.register = (reg, meta3) => { reg.add(inst, meta3); @@ -67361,10 +68025,10 @@ var SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, "2025-06-18", "2025- var RELATED_TASK_META_KEY = "io.modelcontextprotocol/related-task"; var JSONRPC_VERSION = "2.0"; var AssertObjectSchema = custom((v) => v !== null && (typeof v === "object" || typeof v === "function")); -var ProgressTokenSchema = union([string2(), number2().int()]); +var ProgressTokenSchema = union2([string2(), number2().int()]); var CursorSchema = string2(); var TaskCreationParamsSchema = looseObject({ - ttl: union([number2(), _null3()]).optional(), + ttl: union2([number2(), _null3()]).optional(), pollInterval: number2().optional() }); var TaskMetadataSchema = object({ @@ -67398,7 +68062,7 @@ var NotificationSchema = object({ var ResultSchema = looseObject({ _meta: RequestMetaSchema.optional() }); -var RequestIdSchema = union([string2(), number2().int()]); +var RequestIdSchema = union2([string2(), number2().int()]); var JSONRPCRequestSchema = object({ jsonrpc: literal(JSONRPC_VERSION), id: RequestIdSchema, @@ -67437,13 +68101,13 @@ var JSONRPCErrorResponseSchema = object({ }) }).strict(); var isJSONRPCErrorResponse = (value) => JSONRPCErrorResponseSchema.safeParse(value).success; -var JSONRPCMessageSchema = union([ +var JSONRPCMessageSchema = union2([ JSONRPCRequestSchema, JSONRPCNotificationSchema, JSONRPCResultResponseSchema, JSONRPCErrorResponseSchema ]); -var JSONRPCResponseSchema = union([JSONRPCResultResponseSchema, JSONRPCErrorResponseSchema]); +var JSONRPCResponseSchema = union2([JSONRPCResultResponseSchema, JSONRPCErrorResponseSchema]); var EmptyResultSchema = ResultSchema.strict(); var CancelledNotificationParamsSchema = NotificationsParamsSchema.extend({ requestId: RequestIdSchema.optional(), @@ -67473,7 +68137,7 @@ var ImplementationSchema = BaseMetadataSchema.extend({ websiteUrl: string2().optional(), description: string2().optional() }); -var FormElicitationCapabilitySchema = intersection(object({ +var FormElicitationCapabilitySchema = intersection2(object({ applyDefaults: boolean2().optional() }), record(string2(), unknown())); var ElicitationCapabilitySchema = preprocess((value) => { @@ -67483,7 +68147,7 @@ var ElicitationCapabilitySchema = preprocess((value) => { } } return value; -}, intersection(object({ +}, intersection2(object({ form: FormElicitationCapabilitySchema.optional(), url: AssertObjectSchema.optional() }), record(string2(), unknown()).optional())); @@ -67587,7 +68251,7 @@ var TaskStatusSchema = _enum2(["working", "input_required", "completed", "failed var TaskSchema = object({ taskId: string2(), status: TaskStatusSchema, - ttl: union([number2(), _null3()]), + ttl: union2([number2(), _null3()]), createdAt: string2(), lastUpdatedAt: string2(), pollInterval: optional(number2()), @@ -67692,7 +68356,7 @@ var ReadResourceRequestSchema = RequestSchema.extend({ params: ReadResourceRequestParamsSchema }); var ReadResourceResultSchema = ResultSchema.extend({ - contents: array(union([TextResourceContentsSchema, BlobResourceContentsSchema])) + contents: array(union2([TextResourceContentsSchema, BlobResourceContentsSchema])) }); var ResourceListChangedNotificationSchema = NotificationSchema.extend({ method: literal("notifications/resources/list_changed"), @@ -67770,14 +68434,14 @@ var ToolUseContentSchema = object({ }); var EmbeddedResourceSchema = object({ type: literal("resource"), - resource: union([TextResourceContentsSchema, BlobResourceContentsSchema]), + resource: union2([TextResourceContentsSchema, BlobResourceContentsSchema]), annotations: AnnotationsSchema.optional(), _meta: record(string2(), unknown()).optional() }); var ResourceLinkSchema = ResourceSchema.extend({ type: literal("resource_link") }); -var ContentBlockSchema = union([ +var ContentBlockSchema = union2([ TextContentSchema, ImageContentSchema, AudioContentSchema, @@ -67901,7 +68565,7 @@ var SamplingMessageContentBlockSchema = discriminatedUnion("type", [ ]); var SamplingMessageSchema = object({ role: RoleSchema, - content: union([SamplingMessageContentBlockSchema, array(SamplingMessageContentBlockSchema)]), + content: union2([SamplingMessageContentBlockSchema, array(SamplingMessageContentBlockSchema)]), _meta: record(string2(), unknown()).optional() }); var CreateMessageRequestParamsSchema = TaskAugmentedRequestParamsSchema.extend({ @@ -67930,7 +68594,7 @@ var CreateMessageResultWithToolsSchema = ResultSchema.extend({ model: string2(), stopReason: optional(_enum2(["endTurn", "stopSequence", "maxTokens", "toolUse"]).or(string2())), role: RoleSchema, - content: union([SamplingMessageContentBlockSchema, array(SamplingMessageContentBlockSchema)]) + content: union2([SamplingMessageContentBlockSchema, array(SamplingMessageContentBlockSchema)]) }); var BooleanSchemaSchema = object({ type: literal("boolean"), @@ -67980,7 +68644,7 @@ var LegacyTitledEnumSchemaSchema = object({ enumNames: array(string2()).optional(), default: string2().optional() }); -var SingleSelectEnumSchemaSchema = union([UntitledSingleSelectEnumSchemaSchema, TitledSingleSelectEnumSchemaSchema]); +var SingleSelectEnumSchemaSchema = union2([UntitledSingleSelectEnumSchemaSchema, TitledSingleSelectEnumSchemaSchema]); var UntitledMultiSelectEnumSchemaSchema = object({ type: literal("array"), title: string2().optional(), @@ -68007,9 +68671,9 @@ var TitledMultiSelectEnumSchemaSchema = object({ }), default: array(string2()).optional() }); -var MultiSelectEnumSchemaSchema = union([UntitledMultiSelectEnumSchemaSchema, TitledMultiSelectEnumSchemaSchema]); -var EnumSchemaSchema = union([LegacyTitledEnumSchemaSchema, SingleSelectEnumSchemaSchema, MultiSelectEnumSchemaSchema]); -var PrimitiveSchemaDefinitionSchema = union([EnumSchemaSchema, BooleanSchemaSchema, StringSchemaSchema, NumberSchemaSchema]); +var MultiSelectEnumSchemaSchema = union2([UntitledMultiSelectEnumSchemaSchema, TitledMultiSelectEnumSchemaSchema]); +var EnumSchemaSchema = union2([LegacyTitledEnumSchemaSchema, SingleSelectEnumSchemaSchema, MultiSelectEnumSchemaSchema]); +var PrimitiveSchemaDefinitionSchema = union2([EnumSchemaSchema, BooleanSchemaSchema, StringSchemaSchema, NumberSchemaSchema]); var ElicitRequestFormParamsSchema = TaskAugmentedRequestParamsSchema.extend({ mode: literal("form").optional(), message: string2(), @@ -68025,7 +68689,7 @@ var ElicitRequestURLParamsSchema = TaskAugmentedRequestParamsSchema.extend({ elicitationId: string2(), url: string2().url() }); -var ElicitRequestParamsSchema = union([ElicitRequestFormParamsSchema, ElicitRequestURLParamsSchema]); +var ElicitRequestParamsSchema = union2([ElicitRequestFormParamsSchema, ElicitRequestURLParamsSchema]); var ElicitRequestSchema = RequestSchema.extend({ method: literal("elicitation/create"), params: ElicitRequestParamsSchema @@ -68039,7 +68703,7 @@ var ElicitationCompleteNotificationSchema = NotificationSchema.extend({ }); var ElicitResultSchema = ResultSchema.extend({ action: _enum2(["accept", "decline", "cancel"]), - content: preprocess((val) => val === null ? undefined : val, record(string2(), union([string2(), number2(), boolean2(), array(string2())])).optional()) + content: preprocess((val) => val === null ? undefined : val, record(string2(), union2([string2(), number2(), boolean2(), array(string2())])).optional()) }); var ResourceTemplateReferenceSchema = object({ type: literal("ref/resource"), @@ -68050,7 +68714,7 @@ var PromptReferenceSchema = object({ name: string2() }); var CompleteRequestParamsSchema = BaseRequestParamsSchema.extend({ - ref: union([PromptReferenceSchema, ResourceTemplateReferenceSchema]), + ref: union2([PromptReferenceSchema, ResourceTemplateReferenceSchema]), argument: object({ name: string2(), value: string2() @@ -68096,7 +68760,7 @@ var RootsListChangedNotificationSchema = NotificationSchema.extend({ method: literal("notifications/roots/list_changed"), params: NotificationsParamsSchema.optional() }); -var ClientRequestSchema = union([ +var ClientRequestSchema = union2([ PingRequestSchema, InitializeRequestSchema, CompleteRequestSchema, @@ -68115,14 +68779,14 @@ var ClientRequestSchema = union([ ListTasksRequestSchema, CancelTaskRequestSchema ]); -var ClientNotificationSchema = union([ +var ClientNotificationSchema = union2([ CancelledNotificationSchema, ProgressNotificationSchema, InitializedNotificationSchema, RootsListChangedNotificationSchema, TaskStatusNotificationSchema ]); -var ClientResultSchema = union([ +var ClientResultSchema = union2([ EmptyResultSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, @@ -68132,7 +68796,7 @@ var ClientResultSchema = union([ ListTasksResultSchema, CreateTaskResultSchema ]); -var ServerRequestSchema = union([ +var ServerRequestSchema = union2([ PingRequestSchema, CreateMessageRequestSchema, ElicitRequestSchema, @@ -68142,7 +68806,7 @@ var ServerRequestSchema = union([ ListTasksRequestSchema, CancelTaskRequestSchema ]); -var ServerNotificationSchema = union([ +var ServerNotificationSchema = union2([ CancelledNotificationSchema, ProgressNotificationSchema, LoggingMessageNotificationSchema, @@ -68153,7 +68817,7 @@ var ServerNotificationSchema = union([ TaskStatusNotificationSchema, ElicitationCompleteNotificationSchema ]); -var ServerResultSchema = union([ +var ServerResultSchema = union2([ EmptyResultSchema, InitializeResultSchema, CompleteResultSchema, @@ -72124,264 +72788,41 @@ data: } } -// src/mcp/toolHandlers/addRecognizedFile.ts -function registerAddRecognizedFileTool(server, config2) { - const fs = config2.fs ?? defaultChatFs(); - const deps = defaultRecognizeFilesTaskDeps(fs); - const agentTool = buildAddRecognizedMediaFileTool("mcp", config2.appDataDir, fs, config2.logger, undefined, deps); - const inputSchema = exports_external.object({ - taskId: exports_external.string().describe("The task ID from begin-recognize-task"), - season: exports_external.number().describe("The season number of the episode"), - episode: exports_external.number().describe("The episode number"), - path: exports_external.string().describe("The absolute path of the media file (POSIX or Windows format)") +// src/mcp/toolHandlers/createRecognizeEpisodePlan.ts +function registerCreateRecognizeEpisodePlanTool(server, config2) { + const tool2 = buildCreateRecognizeEpisodePlanTool(config2.appDataDir, config2.fs ?? defaultChatFs(), config2.broadcast, config2.logger, undefined, { + getUserConfig: config2.getUserConfig, + applyRecognizeEpisodePlan: config2.applyRecognizeEpisodePlan }); - server.registerTool(ADD_RECOGNIZED_MEDIA_FILE, { - description: agentTool.description, - inputSchema - }, async (args) => { - const { taskId, season, episode, path: path13 } = args ?? {}; - if (typeof taskId !== "string" || taskId.trim() === "") { - return createErrorResponse("Invalid taskId: 'taskId' must be a non-empty string"); - } - if (typeof season !== "number" || season < 0) { - return createErrorResponse("Invalid season: 'season' must be a non-negative number"); - } - if (typeof episode !== "number" || episode < 0) { - return createErrorResponse("Invalid episode: 'episode' must be a non-negative number"); - } - if (typeof path13 !== "string" || path13.trim() === "") { - return createErrorResponse("Invalid path: 'path' must be a non-empty string"); - } - try { - const result = await agentTool.execute({ - taskId, - season, - episode, - path: Path.posix(path13) - }); - if (typeof result === "object" && result !== null && "error" in result && typeof result.error === "string") { - return createSuccessResponse({ - success: false, - error: result.error - }); - } - return createSuccessResponse({ success: true, taskId }); - } catch (error48) { - return createErrorResponse(`Error adding recognized file: ${error48 instanceof Error ? error48.message : String(error48)}`); - } - }); -} - -// src/mcp/toolHandlers/addRenameFile.ts -var import_node_path14 = require("node:path"); -function registerAddRenameFileTool(server, config2, deps) { - const fs = config2.fs ?? defaultChatFs(); - const agentTool = buildAddRenameFileToTaskTool("mcp", config2.appDataDir, fs, deps, config2.logger, undefined); - const inputSchema = exports_external.object({ - taskId: exports_external.string().describe("The task ID returned from begin-rename-task"), - from: exports_external.string().describe("The current absolute path of the file to rename"), - to: exports_external.string().describe("The new absolute path for the file") - }); - server.registerTool(ADD_RENAME_FILE_TO_TASK, { - description: agentTool.description, - inputSchema - }, async (args) => { - const { taskId, from, to } = args ?? {}; - if (typeof taskId !== "string" || taskId.trim() === "") { - return createErrorResponse("Invalid taskId: 'taskId' must be a non-empty string"); - } - if (typeof from !== "string" || from.trim() === "") { - return createErrorResponse("Invalid path: 'from' must be a non-empty string"); - } - if (typeof to !== "string" || to.trim() === "") { - return createErrorResponse("Invalid path: 'to' must be a non-empty string"); - } - if (!isVideoFile2(from)) { - return createErrorResponse("Invalid path: 'from' must be a video file"); - } - if (!isVideoFile2(to)) { - return createErrorResponse("Invalid path: 'to' must be a video file"); - } - try { - const result = await agentTool.execute({ - taskId, - from: Path.posix(from), - to: Path.posix(to) - }); - if (typeof result === "object" && result !== null && "error" in result && typeof result.error === "string") { - const message = result.error; - if (message.includes("Not Episode Video File")) { - return createSuccessResponse({ - success: false, - error: `"${from}" is not video file to any episode, you're not allowed to rename it. ` + `Call "get-episodes" tool to get the list of episode video files that needs to rename.` - }); - } - return createSuccessResponse({ success: false, error: message }); - } - return createSuccessResponse({ success: true, taskId }); - } catch (error48) { - const message = error48 instanceof Error ? error48.message : String(error48); - if (message.includes("Not Episode Video File")) { - return createErrorResponse(`"${from}" is not video file to any episode, you're not allowed to rename it. ` + `Call "get-episodes" tool to get the list of episode video files that needs to rename.`); - } - return createErrorResponse(message); - } - }); -} -function isVideoFile2(filePath) { - const extension = import_node_path14.extname(filePath).toLowerCase(); - return videoFileExtensions.includes(extension); -} - -// src/mcp/toolHandlers/beginRecognizeTask.ts -function registerBeginRecognizeTaskTool(server, config2) { - const fs = config2.fs ?? defaultChatFs(); - const agentTool = buildBeginRecognizeTaskTool("mcp", config2.appDataDir, fs, config2.broadcast, config2.logger, undefined); - const inputSchema = exports_external.object({ - mediaFolderPath: exports_external.string().describe("The absolute path of the media folder") - }); - server.registerTool(BEGIN_RECOGNIZE_TASK, { - description: agentTool.description, - inputSchema - }, async (args) => { - const { mediaFolderPath } = args ?? {}; - if (typeof mediaFolderPath !== "string" || mediaFolderPath.trim() === "") { - return createErrorResponse("Invalid path: 'mediaFolderPath' must be a non-empty string"); - } - try { - const result = await agentTool.execute({ mediaFolderPath }); - if (typeof result === "object" && result !== null && "error" in result) { - const errorResult = result; - if (errorResult.error) { - return createErrorResponse(errorResult.error); - } - } - if (typeof result === "object" && result !== null && "taskId" in result) { - return createSuccessResponse({ - success: true, - taskId: result.taskId, - mediaFolderPath: Path.posix(mediaFolderPath) - }); - } - return createSuccessResponse(result); - } catch (error48) { - return createErrorResponse(`Error starting recognize task: ${error48 instanceof Error ? error48.message : String(error48)}`); - } - }); -} - -// src/mcp/toolHandlers/beginRenameTask.ts -function registerBeginRenameTaskTool(server, config2, deps) { - const fs = config2.fs ?? defaultChatFs(); - const agentTool = buildBeginRenameFilesTaskTool("mcp", config2.appDataDir, fs, deps, config2.broadcast, config2.logger, undefined); - const inputSchema = exports_external.object({ - mediaFolderPath: exports_external.string().describe("The absolute path of the media folder, in POSIX or Windows format") - }); - server.registerTool(BEGIN_RENAME_FILES_TASK, { - description: agentTool.description, - inputSchema - }, async (args) => { - const { mediaFolderPath } = args ?? {}; - if (typeof mediaFolderPath !== "string" || mediaFolderPath.trim() === "") { - return createErrorResponse("Invalid path: 'mediaFolderPath' must be a non-empty string"); - } - try { - const result = await agentTool.execute({ mediaFolderPath }); - if (typeof result === "object" && result !== null && "error" in result) { - const errorResult = result; - if (errorResult.error) { - return createErrorResponse(errorResult.error); - } - } - if (typeof result === "object" && result !== null && "taskId" in result) { - return createSuccessResponse({ - success: true, - taskId: result.taskId, - mediaFolderPath: Path.posix(mediaFolderPath) - }); - } - config2.logger?.error?.({ - resultType: typeof result, - resultKeys: typeof result === "object" && result !== null ? Object.keys(result) : [] - }, `[tool][${BEGIN_RENAME_FILES_TASK}] Unexpected agent tool result shape`); - return createSuccessResponse(result); - } catch (error48) { - return createErrorResponse(`Error starting rename task: ${error48 instanceof Error ? error48.message : String(error48)}`); - } - }); -} - -// src/mcp/toolHandlers/endRecognizeTask.ts -function registerEndRecognizeTaskTool(server, config2) { - const fs = config2.fs ?? defaultChatFs(); - const agentTool = buildEndRecognizeTaskTool("mcp", config2.appDataDir, fs, config2.broadcast, config2.logger, undefined); - const inputSchema = exports_external.object({ - taskId: exports_external.string().describe("The task ID from begin-recognize-task") - }); - server.registerTool(END_RECOGNIZE_TASK, { - description: agentTool.description, - inputSchema + const description = config2.toolDescriptions?.[CREATE_RECOGNIZE_EPISODE_PLAN] ?? CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION; + server.registerTool(CREATE_RECOGNIZE_EPISODE_PLAN, { + description, + inputSchema: createRecognizeEpisodePlanInputSchema }, async (args) => { - const { taskId } = args ?? {}; - if (typeof taskId !== "string" || taskId.trim() === "") { - return createErrorResponse("Invalid taskId: 'taskId' must be a non-empty string"); - } - try { - const result = await agentTool.execute({ taskId }); - if (typeof result === "object" && result !== null && "error" in result) { - const errorResult = result; - if (errorResult.error) { - return createSuccessResponse({ - success: false, - error: errorResult.error - }); - } - } - return createSuccessResponse({ - success: true, - taskId, - message: END_PLAN_TASK_SUCCESS_MESSAGE - }); - } catch (error48) { - return createErrorResponse(`Error ending recognize task: ${error48 instanceof Error ? error48.message : String(error48)}`); + const result = await tool2.execute(args); + if (result.error) { + return createErrorResponse(result.error); } + return createSuccessResponse(result); }); } -// src/mcp/toolHandlers/endRenameTask.ts -function registerEndRenameTaskTool(server, config2, deps) { - const fs = config2.fs ?? defaultChatFs(); - const agentTool = buildEndRenameFilesTaskTool("mcp", config2.appDataDir, fs, config2.broadcast, config2.logger, undefined); - const inputSchema = exports_external.object({ - taskId: exports_external.string().describe("The task ID returned from begin-rename-task") +// src/mcp/toolHandlers/createRenameEpisodePlan.ts +function registerCreateRenameEpisodePlanTool(server, config2) { + const tool2 = buildCreateRenameEpisodePlanTool(config2.appDataDir, config2.fs ?? defaultChatFs(), config2.broadcast, config2.logger, undefined, { + getUserConfig: config2.getUserConfig, + applyRenameEpisodePlan: config2.applyRenameEpisodePlan }); - server.registerTool(END_RENAME_FILES_TASK, { - description: agentTool.description, - inputSchema + const description = config2.toolDescriptions?.[CREATE_RENAME_EPISODE_PLAN] ?? CREATE_RENAME_EPISODE_PLAN_DESCRIPTION; + server.registerTool(CREATE_RENAME_EPISODE_PLAN, { + description, + inputSchema: createRenameEpisodePlanInputSchema }, async (args) => { - const { taskId } = args ?? {}; - if (typeof taskId !== "string" || taskId.trim() === "") { - return createErrorResponse("Invalid taskId: 'taskId' must be a non-empty string"); - } - try { - const result = await agentTool.execute({ taskId }); - if (typeof result === "object" && result !== null && "error" in result) { - const errorResult = result; - if (errorResult.error) { - return createSuccessResponse({ - success: false, - error: errorResult.error - }); - } - } - return createSuccessResponse({ - success: true, - taskId, - message: END_PLAN_TASK_SUCCESS_MESSAGE - }); - } catch (error48) { - return createErrorResponse(`Error ending rename task: ${error48 instanceof Error ? error48.message : String(error48)}`); + const result = await tool2.execute(args); + if (result.error) { + return createErrorResponse(result.error); } + return createSuccessResponse(result); }); } @@ -72700,6 +73141,231 @@ function registerRenameFolderTool(server, config2) { }); } +// src/mcp/toolHandlers/renameEpisodeFile.ts +function registerRenameEpisodeFileTool(server, config2) { + const description = config2.toolDescriptions?.[RENAME_EPISODE_FILE] ?? RENAME_EPISODE_FILE_DESCRIPTION; + server.registerTool(RENAME_EPISODE_FILE, { + description, + inputSchema: renameEpisodeFileInputSchema, + outputSchema: renameEpisodeFileOutputSchema + }, async (args) => { + const params = args ?? {}; + if (typeof params.mediaFolder !== "string" || params.mediaFolder.trim() === "") { + return createErrorResponse("Invalid path: 'mediaFolder' must be a non-empty string"); + } + if (typeof params.from !== "string" || params.from.trim() === "") { + return createErrorResponse("Invalid path: 'from' must be a non-empty string"); + } + if (typeof params.to !== "string" || params.to.trim() === "") { + return createErrorResponse("Invalid path: 'to' must be a non-empty string"); + } + try { + if (config2.acknowledge) { + const confirmationMessage = buildRenameEpisodeFileConfirmationMessage(params.from, params.to); + const responseData = await config2.acknowledge({ + event: "askForConfirmation", + data: { message: confirmationMessage }, + clientId: "mcp" + }, 30000); + const confirmed = responseData?.confirmed ?? responseData?.response === "yes"; + if (!confirmed) { + return createSuccessResponse(renameEpisodeFileCancelled(params.mediaFolder, params.from, params.to)); + } + } + const result = await executeRenameEpisodeFile({ + mediaFolder: params.mediaFolder, + from: params.from, + to: params.to + }, config2.renameEpisodeFile); + if (result.renamed) { + config2.broadcast?.({ + event: "mediaMetadataUpdated", + data: { + folderPath: Path.posix(params.mediaFolder) + } + }); + } + if (result.error && !result.renamed) { + return createSuccessResponse(result); + } + return createSuccessResponse(result); + } catch (error48) { + return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); + } + }); +} + +// src/mcp/toolHandlers/scrape.ts +function registerScrapeTool(server, config2) { + const description = config2.toolDescriptions?.[SCRAPE] ?? SCRAPE_DESCRIPTION; + server.registerTool(SCRAPE, { + description, + inputSchema: scrapeInputSchema, + outputSchema: scrapeOutputSchema + }, async (args) => { + const params = args ?? {}; + if (typeof params.path !== "string" || params.path.trim() === "") { + return createErrorResponse("Invalid path: 'path' must be a non-empty string"); + } + try { + const result = await executeScrape({ + path: params.path, + language: params.language + }, config2.scrapeFolder); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + } catch (error48) { + return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); + } + }); +} + +// src/mcp/toolHandlers/getJob.ts +function registerGetJobTool(server, config2) { + const description = config2.toolDescriptions?.[GET_JOB] ?? GET_JOB_DESCRIPTION; + server.registerTool(GET_JOB, { + description, + inputSchema: getJobInputSchema, + outputSchema: getJobOutputSchema + }, async (args) => { + const params = args ?? {}; + if (typeof params.id !== "string" || params.id.trim() === "") { + return createErrorResponse("Invalid id: 'id' must be a non-empty string"); + } + try { + const result = await executeGetJob(params.id, config2.getJob); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + } catch (error48) { + return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); + } + }); +} + +// src/mcp/toolHandlers/tmdbTools.ts +function registerTmdbTools(server, config2) { + const searchDescription = config2.toolDescriptions?.[TMDB_SEARCH] ?? TMDB_SEARCH_DESCRIPTION; + const movieDescription = config2.toolDescriptions?.[TMDB_GET_MOVIE] ?? TMDB_GET_MOVIE_DESCRIPTION; + const tvShowDescription = config2.toolDescriptions?.[TMDB_GET_TV_SHOW] ?? TMDB_GET_TV_SHOW_DESCRIPTION; + server.registerTool(TMDB_SEARCH, { + description: searchDescription, + inputSchema: tmdbSearchInputSchema, + outputSchema: tmdbSearchOutputSchema + }, async (args) => { + try { + const result = await executeTmdbSearch(args ?? {}, config2.searchInTmdb); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + } catch (error48) { + return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); + } + }); + server.registerTool(TMDB_GET_MOVIE, { + description: movieDescription, + inputSchema: tmdbGetMovieInputSchema, + outputSchema: tmdbGetMovieOutputSchema + }, async (args) => { + try { + const result = await executeTmdbGetMovie(args ?? {}, config2.getMovieInTmdb); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + } catch (error48) { + return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); + } + }); + server.registerTool(TMDB_GET_TV_SHOW, { + description: tvShowDescription, + inputSchema: tmdbGetTvShowInputSchema, + outputSchema: tmdbGetTvShowOutputSchema + }, async (args) => { + try { + const result = await executeTmdbGetTvShow(args ?? {}, config2.getTvShowInTmdb); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + } catch (error48) { + return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); + } + }); +} + +// src/mcp/toolHandlers/tvdbTools.ts +function registerTvdbTools(server, config2) { + const searchDescription = config2.toolDescriptions?.[TVDB_SEARCH] ?? TVDB_SEARCH_DESCRIPTION; + const movieDescription = config2.toolDescriptions?.[TVDB_GET_MOVIE] ?? TVDB_GET_MOVIE_DESCRIPTION; + const tvShowDescription = config2.toolDescriptions?.[TVDB_GET_TV_SHOW] ?? TVDB_GET_TV_SHOW_DESCRIPTION; + const languagesDescription = config2.toolDescriptions?.[TVDB_GET_LANGUAGES] ?? TVDB_GET_LANGUAGES_DESCRIPTION; + server.registerTool(TVDB_SEARCH, { + description: searchDescription, + inputSchema: tvdbSearchInputSchema, + outputSchema: tvdbSearchOutputSchema + }, async (args) => { + try { + const result = await executeTvdbSearch(args ?? {}, config2.searchInTvdb); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + } catch (error48) { + return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); + } + }); + server.registerTool(TVDB_GET_MOVIE, { + description: movieDescription, + inputSchema: tvdbGetMovieInputSchema, + outputSchema: tvdbGetMovieOutputSchema + }, async (args) => { + try { + const result = await executeTvdbGetMovie(args ?? {}, config2.getMovieInTvdb); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + } catch (error48) { + return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); + } + }); + server.registerTool(TVDB_GET_TV_SHOW, { + description: tvShowDescription, + inputSchema: tvdbGetTvShowInputSchema, + outputSchema: tvdbGetTvShowOutputSchema + }, async (args) => { + try { + const result = await executeTvdbGetTvShow(args ?? {}, config2.getTvShowInTvdb); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + } catch (error48) { + return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); + } + }); + server.registerTool(TVDB_GET_LANGUAGES, { + description: languagesDescription, + inputSchema: tvdbGetLanguagesInputSchema, + outputSchema: tvdbGetLanguagesOutputSchema + }, async (args) => { + try { + const result = await executeTvdbGetLanguages(config2.getTvdbLanguages, args ?? {}); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + } catch (error48) { + return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); + } + }); +} + // src/mcp/toolHandlers/staticText.ts var README_CONTENT = `# Simple Media Manager (SMM) @@ -72758,9 +73424,7 @@ AI助手应该参考一下步骤: 2. 使用 "get-media-metadata" 工具获取媒体目录的媒体元数据, 主要关注电视剧的季集信息 3. 使用 "get-episodes" 工具获取需要季集视频文件 4. 思考重命名命名方案 -5. 使用 "begin-rename-episode-video-file-task" 工具开始重命名任务 -6. 使用 "add-rename-episode-video-file-to-task" 工具添加需要重命名的文件 -7. 使用 "end-rename-episode-video-file-task" 工具结束重命名任务 +5. 使用 "create-rename-episode-plan" 工具一次提交全部需要重命名的文件 ## 文件命名规则 @@ -72798,9 +73462,9 @@ AI助手应该参考以下步骤: 2. 使用 "get-media-metadata" 工具获取媒体目录的媒体元数据, 主要关注电视剧的季集信息 3. 使用 "list-files" 工具列出媒体目录下的所有视频文件 4. 对比视频文件名和季集信息, 为每个视频文件确定它属于哪一季的哪一集 -5. 使用 "begin-recognize-task" 工具开始识别任务 -6. 使用 "add-recognized-file" 工具添加每个视频文件的识别结果 -7. 使用 "end-recognize-task" 工具结束识别任务 +5. 使用 "create-recognize-episode-plan" 工具一次性提交识别计划, 指定媒体文件夹路径和所有视频文件的 season/episode/path 映射 + +**NOTE** 识别任务完成后, SMM 会在后台处理识别计划, 用户可以在 SMM UI 中查看和确认识别结果. `; var STATIC_TEXT_TOOLS = { "how-to-rename-episode-video-files": HOW_TO_RENAME_EPISODE_VIDEO_FILES, @@ -72832,8 +73496,6 @@ function registerStaticTextTools(server, config2) { // src/mcp/createServer.ts async function createMcpStreamableHttpHandler(config2) { - const fs = config2.fs ?? defaultChatFs(); - const renameFilesTaskDeps = defaultRenameFilesTaskDeps(config2.appDataDir); const server = new McpServer({ name: "Simple Media Manager (SMM)", version: "1.0.0", @@ -72848,16 +73510,23 @@ async function createMcpStreamableHttpHandler(config2) { registerIsFolderExistTool(server, config2); registerListFilesTool(server, config2); registerGetMediaMetadataTool(server, config2); + registerTmdbTools(server, config2); + registerTvdbTools(server, config2); registerStaticTextTools(server, config2); if (!config2.disabledTools?.includes(RENAME_FOLDER)) { registerRenameFolderTool(server, config2); } - registerBeginRenameTaskTool(server, config2, renameFilesTaskDeps); - registerAddRenameFileTool(server, config2, renameFilesTaskDeps); - registerEndRenameTaskTool(server, config2, renameFilesTaskDeps); - registerBeginRecognizeTaskTool(server, config2); - registerAddRecognizedFileTool(server, config2); - registerEndRecognizeTaskTool(server, config2); + if (!config2.disabledTools?.includes(RENAME_EPISODE_FILE)) { + registerRenameEpisodeFileTool(server, config2); + } + if (!config2.disabledTools?.includes(SCRAPE)) { + registerScrapeTool(server, config2); + } + if (!config2.disabledTools?.includes(GET_JOB)) { + registerGetJobTool(server, config2); + } + registerCreateRenameEpisodePlanTool(server, config2); + registerCreateRecognizeEpisodePlanTool(server, config2); registerGetEpisodeTool(server, config2); registerGetEpisodesTool(server, config2); await server.connect(new WebStandardStreamableHTTPServerTransport({})); @@ -72882,4 +73551,12 @@ function createErrorResponse(message) { }; } // src/mcp/index.ts -var MCP_TOOL_NAMES = { RENAME_FOLDER }; +var MCP_TOOL_NAMES = { + RENAME_FOLDER, + RENAME_EPISODE_FILE, + SCRAPE, + GET_JOB, + TMDB_SEARCH, + TMDB_GET_MOVIE, + TMDB_GET_TV_SHOW +}; diff --git a/packages/core-routes/dist/core-routes.js b/packages/core-routes/dist/core-routes.js index 9a3af8c5..ac4f2378 100644 --- a/packages/core-routes/dist/core-routes.js +++ b/packages/core-routes/dist/core-routes.js @@ -59440,24 +59440,16 @@ var createRenameEpisodePlanInputSchema = exports_external.object({ })).min(1) }); -// ../types/ai-tools/recognizeMediaFileTask.ts -var BEGIN_RECOGNIZE_TASK = "begin-recognize-task"; -var ADD_RECOGNIZED_MEDIA_FILE = "add-recognized-media-file"; -var END_RECOGNIZE_TASK = "end-recognize-task"; -var BEGIN_RECOGNIZE_TASK_DESCRIPTION = "Begin a recognition task for identifying media files. " + "This tool creates a task that can be used to add media files for recognition. " + `Use ${ADD_RECOGNIZED_MEDIA_FILE} to add files, then ${END_RECOGNIZE_TASK} to execute.`; -var ADD_RECOGNIZED_MEDIA_FILE_DESCRIPTION = "Add a recognized media file to a recognition task. " + `This tool adds a single video file to an existing task created by ${BEGIN_RECOGNIZE_TASK}. ` + "Provide the task ID, season number, episode number, and file path."; -var END_RECOGNIZE_TASK_DESCRIPTION = "End a recognition task and execute the recognition. " + `This tool finalizes the task created by ${BEGIN_RECOGNIZE_TASK} and ` + "processes all added media files."; -var beginRecognizeTaskInputSchema = exports_external.object({ - mediaFolderPath: exports_external.string().describe("The absolute path of the media folder, it can be POSIX format or Windows format") -}); -var addRecognizedMediaFileInputSchema = exports_external.object({ - taskId: exports_external.string().describe(`The task ID returned from ${BEGIN_RECOGNIZE_TASK}`), - season: exports_external.number().describe("The season number of the episode."), - episode: exports_external.number().describe("The episode number."), - path: exports_external.string().describe("The absolute path of the media file (POSIX or Windows format).") -}); -var endRecognizeTaskInputSchema = exports_external.object({ - taskId: exports_external.string().describe(`The task ID returned from ${BEGIN_RECOGNIZE_TASK}`) +// ../types/ai-tools/createRecognizeEpisodePlan.ts +var CREATE_RECOGNIZE_EPISODE_PLAN = "create-recognize-episode-plan"; +var CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION = "Create a recognize-media-file plan that maps episode video files to season/episode numbers. " + "Provide every mapping (season, episode, absolute file path) in one call. " + "After success, tell the user to open SMM, review, and approve the plan."; +var createRecognizeEpisodePlanInputSchema = exports_external.object({ + mediaFolderPath: exports_external.string().describe("Absolute media folder path (POSIX or Windows)"), + files: exports_external.array(exports_external.object({ + season: exports_external.number().describe("The season number of the episode."), + episode: exports_external.number().describe("The episode number."), + path: exports_external.string().describe("The absolute path of the media file (POSIX or Windows format).") + })).min(1) }); // ../types/ai-tools/getApplicationContext.ts @@ -59806,10 +59798,8 @@ Below is the steps to recognize media file: If user don't tell which folder he is asking for, you should call "${GET_APPLICATION_CONTEXT}" to get the selected media folder in UI. 2. Get episodes using "${GET_EPISODES}" tool 3. Get local files using "${LIST_FILES_IN_MEDIA_FOLDER}" tool -4. Call "${BEGIN_RECOGNIZE_TASK}" tool to notify AI Agent to start a recognize task -5. iterate each episodes, find the local video file for the episode, and call "${ADD_RECOGNIZED_MEDIA_FILE}" tool to add the recognized media file to the task +4. Call "${CREATE_RECOGNIZE_EPISODE_PLAN}" once with mediaFolderPath and a files array of season/episode/path pairs for every recognized video file IMPORTANT: It's OK to skip the episode if the local video file is not found. -6. Call "${END_RECOGNIZE_TASK}" tool to notify AI Agent to end the recognize task ### Rename Files @@ -59942,18 +59932,6 @@ var renameEpisodeFileOutputSchema = exports_external.object({ }); var RENAME_EPISODE_FILE_CANCELLED = "User cancelled the operation"; -// ../types/ai-tools/createRecognizeEpisodePlan.ts -var CREATE_RECOGNIZE_EPISODE_PLAN = "create-recognize-episode-plan"; -var CREATE_RECOGNIZE_EPISODE_PLAN_DESCRIPTION = "Create a recognize-media-file plan that maps episode video files to season/episode numbers. " + "Provide every mapping (season, episode, absolute file path) in one call. " + "After success, tell the user to open SMM, review, and approve the plan."; -var createRecognizeEpisodePlanInputSchema = exports_external.object({ - mediaFolderPath: exports_external.string().describe("Absolute media folder path (POSIX or Windows)"), - files: exports_external.array(exports_external.object({ - season: exports_external.number().describe("The season number of the episode."), - episode: exports_external.number().describe("The episode number."), - path: exports_external.string().describe("The absolute path of the media file (POSIX or Windows format).") - })).min(1) -}); - // ../utils/src/locale.ts var APP_LANGUAGE_FALLBACK = "en"; function normalizeToAppLanguage(raw) { @@ -73339,9 +73317,9 @@ AI助手应该参考以下步骤: 2. 使用 "get-media-metadata" 工具获取媒体目录的媒体元数据, 主要关注电视剧的季集信息 3. 使用 "list-files" 工具列出媒体目录下的所有视频文件 4. 对比视频文件名和季集信息, 为每个视频文件确定它属于哪一季的哪一集 -5. 使用 "begin-recognize-task" 工具开始识别任务 -6. 使用 "add-recognized-file" 工具添加每个视频文件的识别结果 -7. 使用 "end-recognize-task" 工具结束识别任务 +5. 使用 "create-recognize-episode-plan" 工具一次性提交识别计划, 指定媒体文件夹路径和所有视频文件的 season/episode/path 映射 + +**NOTE** 识别任务完成后, SMM 会在后台处理识别计划, 用户可以在 SMM UI 中查看和确认识别结果. `; var STATIC_TEXT_TOOLS = { "how-to-rename-episode-video-files": HOW_TO_RENAME_EPISODE_VIDEO_FILES, diff --git a/packages/core-routes/src/mcp/toolHandlers/staticText.ts b/packages/core-routes/src/mcp/toolHandlers/staticText.ts index f2adbfe5..949a248d 100644 --- a/packages/core-routes/src/mcp/toolHandlers/staticText.ts +++ b/packages/core-routes/src/mcp/toolHandlers/staticText.ts @@ -105,9 +105,9 @@ AI助手应该参考以下步骤: 2. 使用 "get-media-metadata" 工具获取媒体目录的媒体元数据, 主要关注电视剧的季集信息 3. 使用 "list-files" 工具列出媒体目录下的所有视频文件 4. 对比视频文件名和季集信息, 为每个视频文件确定它属于哪一季的哪一集 -5. 使用 "begin-recognize-task" 工具开始识别任务 -6. 使用 "add-recognized-file" 工具添加每个视频文件的识别结果 -7. 使用 "end-recognize-task" 工具结束识别任务 +5. 使用 "create-recognize-episode-plan" 工具一次性提交识别计划, 指定媒体文件夹路径和所有视频文件的 season/episode/path 映射 + +**NOTE** 识别任务完成后, SMM 会在后台处理识别计划, 用户可以在 SMM UI 中查看和确认识别结果. `; const STATIC_TEXT_TOOLS = { diff --git a/packages/core-routes/src/tools/createRecognizeEpisodePlan.test.ts b/packages/core-routes/src/tools/createRecognizeEpisodePlan.test.ts index 01582d01..6902e1cf 100644 --- a/packages/core-routes/src/tools/createRecognizeEpisodePlan.test.ts +++ b/packages/core-routes/src/tools/createRecognizeEpisodePlan.test.ts @@ -204,11 +204,8 @@ describe(`buildCreateRecognizeEpisodePlanTool (${CREATE_RECOGNIZE_EPISODE_PLAN}) it("validation failure returns an error payload and writes nothing", async () => { const broadcast = vi.fn(); - const tool = buildCreateRecognizeEpisodePlanTool( - "/app-data", - createMockFs("/media/show"), - broadcast, - ); + const fs = createMockFs("/media/show"); + const tool = buildCreateRecognizeEpisodePlanTool("/app-data", fs, broadcast); const result = await tool.execute({ mediaFolderPath: "/media/show", @@ -219,5 +216,6 @@ describe(`buildCreateRecognizeEpisodePlanTool (${CREATE_RECOGNIZE_EPISODE_PLAN}) }); expect(result.error).toContain("Duplicate season/episode"); + expect(fs.writeJson).not.toHaveBeenCalled(); }); }); diff --git a/packages/core-routes/src/tools/plans.ts b/packages/core-routes/src/tools/plans.ts index a7897884..08565af0 100644 --- a/packages/core-routes/src/tools/plans.ts +++ b/packages/core-routes/src/tools/plans.ts @@ -15,8 +15,8 @@ import type { CoreRoutesLogger } from "../types.ts"; export type AnyPlan = RecognizeMediaFilePlan | RenameFilesPlan; /** - * Plan-file storage helpers shared by the `rename-files-task` and - * `recognize-media-file-task` agent tools. Plans live in + * Plan-file storage helpers shared by the `create-rename-episode-plan` + * and `create-recognize-episode-plan` tools. Plans live in * `{appDataDir}/plans/*.plan.json` and are written via the * runtime-neutral {@link ChatFs} abstraction so the same code works * for both Node (OHOS) and Bun (cli). From e5c7def4f2cde47703007a413365e6d1c10c6ad5 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 7 Sep 2026 13:03:10 +0800 Subject: [PATCH 66/83] test: cleanup --- apps/ui/src/components/tv/TvShowPanel.tsx | 13 ++--- docs/dev/rename-episodes.md | 65 +++++++++++++++++++---- 2 files changed, 57 insertions(+), 21 deletions(-) diff --git a/apps/ui/src/components/tv/TvShowPanel.tsx b/apps/ui/src/components/tv/TvShowPanel.tsx index 6f8526c3..19a50cd9 100644 --- a/apps/ui/src/components/tv/TvShowPanel.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.tsx @@ -363,18 +363,11 @@ function TvShowPanel() { return (
- - - { - - } - - { - - } + {/* */} + + - diff --git a/docs/dev/rename-episodes.md b/docs/dev/rename-episodes.md index 4dd99a10..090e73d0 100644 --- a/docs/dev/rename-episodes.md +++ b/docs/dev/rename-episodes.md @@ -180,23 +180,66 @@ The tool calls `Core.createRenameEpisodePlan(..., { creator: "ai" })`, writes a HTTP surface (same Core call): `POST /api/create-rename-episode-plan`. E2e/debug helper: `POST /debug/createRenameEpisodePlan`. +If user config `metadata.write` is false, then user needs to approve the plan in SMM UI. + ```mermaid sequenceDiagram - participant U as User - participant A as AI Agent - participant T as MCP Tool/AI Tool - participant C as Core + participant U as WebUI User participant W as UI + participant S as Server + participant C as Core + participant T as MCP Tool/AI Tool + participant A as AI Agent + participant AgentUser - U->>A: ask for renaming episodes + AgentUser->>A: ask for renaming episodes A->>T: create-rename-episode-plan(folder, files) T->>C: createRenameEpisodePlan(..., creator ai) - C->>T: RenameFilesPlan (pending) - T->>W: RenameFilesPlanReady - T->>A: success message (review in SMM) - A->>U: message to user - U->>W: review + confirm - W->>C: applyPlan() + C->>C: build RenameEpisodePlan + alt if metadata.write is true + C->>C: apply plan + C->>T: print message + else + C->>T: return + T->>AgentUser: print message + C->>S: emit PlanAddedEvent + S->>W: emit PlanAddedEvent + W->>U: show AiBasedRenameEpisodePrompt + U->>W: click confirm button + W->>S: POST /api/apply-plan + S->>C: applyPlan + C->>S: return + S->>W: return + end + +``` + +### Browser-side Pulling + +If browser move to background, the browser side JavaScript may pause and may not receive event push by server. + +To increase the robustness, we need to implement the brower-side pulling. +There are 2 trigger points: +1. User select folder in Sidebar +2. Browser reactives and one folder was already selected. + + +```mermaid +sequenceDiagram + participant U as WebUI User + participant W as UI + participant S as Server + participant C as Core + + U->>W: select folder or reactive browser window + W->>S: pull tasks + W->>U: show AiBasedRenameEpisodePrompt + U->>W: click confirm button + W->>S: POST /api/apply-plan + S->>C: applyPlan + C->>S: return + S->>W: return + ``` From c6fa662c0499d71fc57a75dca3e8b94be586b104 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 7 Sep 2026 13:03:49 +0800 Subject: [PATCH 67/83] test: cleanup --- .superpowers/sdd/task-3-fix-report.md | 25 ----- .superpowers/sdd/task-3-report.md | 109 ---------------------- .superpowers/sdd/task-4-fix-report.md | 23 ----- .superpowers/sdd/task-6-report.md | 22 ----- .superpowers/sdd/task-final-fix-report.md | 17 ---- docs/dev/v3-onboarding.md | 25 ----- 6 files changed, 221 deletions(-) delete mode 100644 .superpowers/sdd/task-3-fix-report.md delete mode 100644 .superpowers/sdd/task-3-report.md delete mode 100644 .superpowers/sdd/task-4-fix-report.md delete mode 100644 .superpowers/sdd/task-6-report.md delete mode 100644 .superpowers/sdd/task-final-fix-report.md delete mode 100644 docs/dev/v3-onboarding.md diff --git a/.superpowers/sdd/task-3-fix-report.md b/.superpowers/sdd/task-3-fix-report.md deleted file mode 100644 index 3c3d6763..00000000 --- a/.superpowers/sdd/task-3-fix-report.md +++ /dev/null @@ -1,25 +0,0 @@ -# Task 3 Important Findings Fix Report - -## Status - -Fixed metadata-root propagation and made Core metadata create/update operations atomic within the existing `MediaMetadataHelper` path mutex. - -## Changes - -- Added `Core.getMetadataRoot()` and used it for metadata helpers, scrape/recognition/rename/import pipelines, and plan persistence operations. -- Added `MediaMetadataHelper.createIfAbsent()` so existence check and write share one path lock. -- Added `MediaMetadataHelper.updateIfPresent()` so read-modify-write patches share one path lock. -- Added focused regressions for distinct metadata roots, concurrent creates, and concurrent non-overlapping patches. - -## Verification - -- Red phase: all three new regressions failed for the expected reasons. -- `pnpm exec vitest run src/Core.metadataCrud.test.ts`: 11 tests passed. -- `pnpm test` in `apps/core`: 45 files and 313 tests passed. -- `pnpm typecheck` in `apps/core`: passed. -- IDE lint diagnostics for changed TypeScript files: none. - -## Residual - -- Locking remains process-local, matching the existing helper design; no distributed or cross-process lock was added. -- A corrupt existing cache is not overwritten: create reports already-exists based on the physical file, while update reports not-found because the helper cannot read valid metadata. diff --git a/.superpowers/sdd/task-3-report.md b/.superpowers/sdd/task-3-report.md deleted file mode 100644 index 943dde70..00000000 --- a/.superpowers/sdd/task-3-report.md +++ /dev/null @@ -1,109 +0,0 @@ -# Task 3 Report: Core metadata CRUD and separate roots - -## Status - -Implemented the requested Core metadata CRUD API and separated user configuration storage from application metadata storage. - -## Changes - -- Added `Core.getMetadata(folderPath)`, which returns persisted metadata or throws `MetadataNotFoundError`. -- Added `Core.createMetadata(mm)`, which writes new metadata or throws `MetadataAlreadyExistsError`. -- Replaced the old full-document `setMetadata(mm)` API with `setMetadata(folderPath, patch)`, using Task 2's `applyMetadataPatch`. -- Added idempotent `Core.deleteMetadata(folderPath)`. -- Kept null-if-missing reads and full-document writes private to Core through `MediaMetadataHelper`. -- Wired `UserConfigHelper` to `userDataDir` and `MediaMetadataHelper` to `reportedAppDataDir ?? appDataDir`. -- Wired CLI `getCore()` with `getAppDataDir()` for application data and `getUserDataDir()` for configuration. -- Passed the split roots through the import pipeline so imports continue to read/write configuration and metadata in their respective locations. -- Migrated Core and CLI consumers away from the removed public `getMediaMetadata` and full-document `setMetadata` methods. - -## TDD evidence - -### RED - -Command: - -```bash -cd apps/core && pnpm exec vitest run src/Core.metadataCrud.test.ts -``` - -Result: failed as expected — 8 tests failed. The new methods were absent (`core.getMetadata is not a function`, `core.createMetadata is not a function`), and the old `setMetadata` signature rejected the new call shape. - -The failing tests covered: - -- get missing metadata -- create then get -- duplicate create -- patch merge -- patch missing metadata -- illegal patch key -- idempotent delete -- separate metadata/config roots - -### GREEN - -Command: - -```bash -cd apps/core && pnpm exec vitest run src/Core.metadataCrud.test.ts -``` - -Result: passed — 8/8 tests. - -The separate-root CLI integration test initially exposed that `ImportFolderPipeline` still used one root for both stores. After passing `userDataDir` separately, the previously failing `FolderMetadata.test.ts` passed. - -## Verification - -- `cd apps/core && pnpm test && pnpm typecheck`: passed, 45 files / 310 tests, followed by a clean Core TypeScript check. -- `cd apps/core && pnpm exec vitest run src/Core.metadataCrud.test.ts src/Core.test.ts`: passed, 80 tests in the required Task 3 suites. -- `cd apps/cli && pnpm exec vitest run src/cli/folderDisplay.test.ts src/route/FolderMetadata.test.ts src/route/Scrape.test.ts`: passed, 3 files / 14 tests. -- IDE lint diagnostics for edited files: no errors. - -## Known concern - -`apps/cli` typecheck remains blocked by existing Task 1 migration debt: several unrelated files still reference the removed `MediaMetadata.files` property. The Task 3 Core typecheck passes; Task 3 did not reimplement the prior migration. - -## Commit - -Planned commit message: `feat(core): metadata CRUD API and separate config/data dirs` -# Task 3 Report: Align plan HTTP appDataDir with Core - -## Status - -Completed and committed as `fa063cc4` (`fix(cli): use Core userDataDir for plan HTTP routes on Linux`). - -## Changes - -- `apps/cli/src/route/Plans.ts`: plan HTTP routes now use `getUserDataDir()`. -- `apps/cli/src/mcp/mcp.ts`: MCP plan-writing tools now use `getUserDataDir()`. -- `apps/cli/server.ts`: chat plan-writing tools now use `getUserDataDir()`. -- `apps/cli/src/route/Plans.test.ts`: added a split-directory regression test proving `/api/createPlan` writes only under `USER_DATA_DIR/plans`. - -## Audit - -- `apps/cli/src/route/coreRoutesConfig.ts` retains `getAppDataDir()` because its callers perform list-files, get-episodes, rename-folder, and rename-files HTTP operations rather than plan persistence. Changing it would relocate metadata-cache reads outside this task. -- Plan persistence hosts in `Plans.ts`, `mcp.ts`, and `server.ts` now match `getCore().appDataDir`. -- No second plan store was introduced. - -## TDD and Verification - -- RED: the focused test failed because no plan existed under `USER_DATA_DIR/plans`. -- GREEN: focused regression passed (1 test). -- Full CLI suite passed: 73 files and 474 tests; 2 files and 13 tests skipped. -- CLI `tsc --noEmit` passed. -- Changed-file lint diagnostics and `git show --check` passed. - -## Self-review - -- Commit contains only the three plan-host wiring changes and regression test. -- Unrelated dirty E2E and documentation files were not staged or committed. -- No correctness issues found. The core-routes `appDataDir` field is overloaded for plan and metadata dependencies in chat/MCP, but Core's directory is the required compatibility behavior for these plan-producing hosts. - -## Important review follow-up - -- `apps/cli/src/coreRoutesServer.ts` now supplies `getUserDataDir()` to the shared core-routes handler. -- Startup and shutdown stale-plan cleanup in `apps/cli/index.ts` now scan `userDataDir`. -- The legacy rename and recognition plan writers now store plans under `getUserDataDir()/plans`. -- Added `apps/cli/src/coreRoutesServer.test.ts`; its RED run received `/metadata/app-data`, and its GREEN run received `/core/user-data`. -- Focused plan-route tests passed: 2 files and 2 tests. -- Full CLI tests passed: 74 files and 475 tests; 2 files and 13 tests skipped. -- CLI `tsc --noEmit` and changed-file lint diagnostics passed. diff --git a/.superpowers/sdd/task-4-fix-report.md b/.superpowers/sdd/task-4-fix-report.md deleted file mode 100644 index 1902b971..00000000 --- a/.superpowers/sdd/task-4-fix-report.md +++ /dev/null @@ -1,23 +0,0 @@ -# Task 4 Important Findings Fix Report - -## Status - -Fixed metadata RPC request validation so malformed JSON, invalid Zod input, and invalid Core metadata patches return 400 validation ProblemDetails. - -## Changes - -- Mapped `ZodError` and malformed-JSON `SyntaxError` to `urn:smm:problem:metadata-validation`. -- Exported and matched Core metadata errors with `instanceof` instead of error-name strings. -- Required create requests to contain a plain-object `data` with `mediaFolderPath: string`. -- Restricted set patches to a strict plain object containing only `type`, `mediaFiles`, `tvShow`, and `movie`. -- Added regressions for malformed JSON, missing `mediaFolderPath`, array patches, and complete 404 ProblemDetails fields. - -## Verification - -- Red phase: three regressions failed as expected (malformed JSON returned 500, missing `mediaFolderPath` returned 500, and `patch: []` returned 200). -- `pnpm exec vitest run src/route/metadata`: 4 files and 8 tests passed. -- IDE lint diagnostics for changed TypeScript files: none. - -## Scope - -No Task 5+ code or unrelated E2E/log artifacts were changed. diff --git a/.superpowers/sdd/task-6-report.md b/.superpowers/sdd/task-6-report.md deleted file mode 100644 index af2c8212..00000000 --- a/.superpowers/sdd/task-6-report.md +++ /dev/null @@ -1,22 +0,0 @@ -# Task 6 Report - -## Status - -Completed. UI metadata deletion now uses the Core HTTP RPC client, and the obsolete path-based metadata repository, cache readers/writers, and their tests were removed. - -## Changes - -- Redirected metadata deletion in `AppV2.tsx` and `components/v2/Sidebar.tsx` to `deleteMetadata`. -- Removed `metadataCacheFilePath`, `readMediaMetadataV2`, direct metadata `writeFile` persistence, and the unused repository facade. -- Removed the unused legacy `/api/writeMediaMetadata` UI wrapper and obsolete API index. - -## Verification - -- `git grep -n -E "metadataCacheFilePath|writeMediaMetadata\(|readMediaMetadataV2|mediaMetadataRepository|deleteMediaMetadata" -- "apps/ui/src"`: no matches. -- `pnpm --filter ui typecheck`: passed. -- `pnpm --filter ui test -- --run`: passed, 203 files and 1,749 tests (1 file and 23 tests skipped). -- `git diff --check -- "apps/ui/src"`: passed. - -## Concerns - -- The full UI suite still emits pre-existing accessibility, React `act(...)`, and duplicate locale-key warnings; no test failures occurred. diff --git a/.superpowers/sdd/task-final-fix-report.md b/.superpowers/sdd/task-final-fix-report.md deleted file mode 100644 index 57c37756..00000000 --- a/.superpowers/sdd/task-final-fix-report.md +++ /dev/null @@ -1,17 +0,0 @@ -# Final whole-branch review fixes - -## Fixed - -- Migrated `McpOther-RenameTaskFlow` and `McpPrompt-CancelPreparingPlan` from the removed begin/add/end MCP rename flow to one-shot `create-rename-episode-plan` calls with non-empty `files`. -- Replaced the e2e `McpClient` begin/add/end rename helpers and request/response types with `createRenameEpisodePlan`. -- Updated MCP documentation, CLI tool-description localization registration, and English/Chinese locale descriptions for `create-rename-episode-plan`; removed obsolete rename tool descriptions. -- Removed the dead `renameFilesPlanReady` Debug API documentation and deleted the unreferenced legacy `renameFilesTool.ts`. -- Left the app-data-directory architecture unchanged for follow-up work. - -## Test evidence - -- `pnpm --filter e2e typecheck` — passed. -- `bun ci/run-e2e-test.ts --spec ./common/mcp/McpOther-RenameTaskFlow.e2e.ts` — passed (artifact `artifacts/cicd/1788022664`). -- `bun ci/run-e2e-test.ts --spec ./common/mcp/McpPrompt-CancelPreparingPlan.e2e.ts` — passed (artifact `artifacts/cicd/1788022711`). -- `git diff --check` — passed. -- `pnpm --filter cli typecheck` — blocked by existing `Response.json(): unknown` assertions in `debugCreateRenameEpisodePlan.test.ts` and `RenameEpisodesPlan.test.ts`; no reported error was in a file changed by this fix. diff --git a/docs/dev/v3-onboarding.md b/docs/dev/v3-onboarding.md deleted file mode 100644 index 2661bff7..00000000 --- a/docs/dev/v3-onboarding.md +++ /dev/null @@ -1,25 +0,0 @@ -# The process of onboarding v3 - -## Prepare for Testing - -添加 "E2E_SMM_V3" 环境变量 -当 E2E_SMM_V3 为 true 时, apps/e2e 下的 wdio 测试开始前, 注入 localStorage "smm.v3.enabled". - -## Features - -V: passed -X: failed - -### Import Folders - -| Web UI | Electron | ohos | CLI | -|--|--|--|--| -|V|V||V| - -## Checklist - -[x] MCP tests - -[ ] All e2e tests pass when v3 is enabled -[ ] All test/mcp tests pass - From 6bf99919cc2d7a6c71c67b14b3bfc3db764da25f Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 7 Sep 2026 14:08:03 +0800 Subject: [PATCH 68/83] docs: add unused code detection design spec (tsconfig flags + knip CI) --- ...2026-09-07-unused-code-detection-design.md | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-07-unused-code-detection-design.md diff --git a/docs/superpowers/specs/2026-09-07-unused-code-detection-design.md b/docs/superpowers/specs/2026-09-07-unused-code-detection-design.md new file mode 100644 index 00000000..05d57c28 --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-unused-code-detection-design.md @@ -0,0 +1,153 @@ +# Unused Code Detection (noUnusedLocals / noUnusedParameters / knip) + +This design document describes the high level design of enabling unused code +detection across the monorepo and integrating [knip](https://knip.dev) into CI. + +## 1. Background + +Dead code (unused locals, unused parameters, unused files/exports/dependencies) +accumulates silently. The monorepo currently has inconsistent coverage: + +| Workspace | `noUnusedLocals` / `noUnusedParameters` | Notes | +|-----------|-----------------------------------------|-------| +| `apps/ui` | enabled (`tsconfig.app.json`, `tsconfig.node.json`) | ESLint `@typescript-eslint/no-unused-vars` also on (error) | +| `apps/cli`, `apps/core`, `apps/e2e` | explicitly `false` | | +| `packages/*`, `apps/convex`, `apps/cicd`, `apps/tools`, `apps/ohos`, `apps/electron` | not set (default off) | electron inherits `@electron-toolkit/tsconfig` | + +A baseline scan (`tsc --noEmit --noUnusedLocals --noUnusedParameters` per +workspace) found ~87 existing violations: + +| Workspace | Errors | +|-----------|--------| +| `apps/cli` | 45 | +| `packages/core-routes` | 15 | +| `apps/e2e` | 15 | +| `apps/core` | 7 | +| `packages/tvdb4` | 3 | +| `apps/cicd` | 2 | +| others (types, utils, electron-common, test, electron, convex, tools) | 0 | +| `apps/ohos` | 274 (excluded — see below) | + +`apps/ohos` is **excluded from this change**. Its tsconfig pulls +`packages/core-routes` sources in via `@smm/core-routes/*` path mappings, so +its scan re-reports (and amplifies) core-routes findings under a different +config — enabling the flags there would surface errors in files ohos does not +own. ohos will be enabled in a follow-up change after `core-routes` is cleaned +up and the config interaction is triaged. + +Goals: + +1. Enable `noUnusedLocals` / `noUnusedParameters` in all TS workspaces so new + unused code fails typecheck. +2. Integrate knip at the monorepo root to report unused files, dependencies, + and exports. +3. Add a knip job to the `CI` GitHub Actions workflow. **Advisory only for + now** (`continue-on-error: true`): the report must not block CI until the + existing findings are triaged and cleaned up. + +Non-goals: + +- Fixing knip findings (unused files/exports/deps) in this change; the CI job + is report-only. A follow-up change will clean up and then remove + `continue-on-error` to turn the job into a gate. +- Enabling the flags in `apps/ohos` (see the exclusion note above). +- Changes to ESLint rule severity. `apps/ui` keeps its existing + `no-unused-vars` error rule; no new ESLint rules are added. + +## 2. Architecture + +### 2.1 Project Level Architecture + +The monorepo (pnpm workspaces: `apps/*`, `packages/*`) gains two repo-wide +quality gates: + +- **Typecheck gate (blocking, existing)**: `pnpm typecheck` per workspace now + also fails on unused locals/parameters once the flags are enabled. +- **Knip report (non-blocking, new)**: a new `knip` job in + `.github/workflows/ci.yml` runs `knip` from the repo root across all + workspaces. It is excluded from `RELEASE_REQUIRED_CHECKS` and from all gate + job dependencies. + +### 2.2 App Level Architecture + +No runtime code paths change. The touched artifacts are: + +- `tsconfig.json` (or `tsconfig.app.json` where the pattern exists) of every + TS workspace except `apps/ohos`: add + `"noUnusedLocals": true, "noUnusedParameters": true` + (flip existing `false` values in `apps/cli`, `apps/core`, `apps/e2e`). +- Source/test files with the ~87 existing violations: unused locals/imports + are deleted; intentionally-unused parameters are renamed with a `_` prefix + (matching the existing `argsIgnorePattern: '^_'` ESLint convention in + `apps/ui`). +- Root `package.json`: `knip` devDependency + `"knip": "knip"` script. +- Root `knip.json`: knip configuration (workspaces, entry patterns, ignores). +- `.github/workflows/ci.yml`: new `knip` job. + +### 2.3 Key Design + +**Fix strategy for the 87 violations** — case-by-case, not mechanical: + +- Unused local variable / import → delete it. +- Unused parameter that is part of a fixed signature (interface + implementation, callback contract, test fixture) → prefix with `_`. +- If a deletion would change public behavior or API surface, keep the code + and prefix with `_` instead. + +**knip configuration** — single root `knip.json` using knip's automatic pnpm +workspace detection plus its built-in plugins (vite, vitest, electron, bun). +Each workspace declares its entry points; known framework files that knip +cannot see through (generated code, config-only entry files) are listed in +`ignore`/`ignoreDependencies`. The `apps/ohos` workspace is ignored initially +(HarmonyOS/hvigor entry points are outside knip's plugin coverage); it can be +added once its entry points are mapped. The first real CI run's output drives +the initial ignore list; noise is acceptable because the job is advisory. + +**CI job** — + +```yaml +knip: + name: Knip (unused code report) + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - run: pnpm install --frozen-lockfile + - run: pnpm knip +``` + +`knip.json` is added to the workflow's `paths` triggers so config changes run +the job. The job is independent (no `needs`), so it never blocks test / lint / +typecheck / build / e2e gates. Removing `continue-on-error: true` later +promotes it to a gate with no other edits. + +**Verification** + +- `pnpm typecheck` green across all workspaces after the flags are enabled. +- `pnpm -r test` and `pnpm build` unaffected (no runtime changes). +- `pnpm knip` runs locally and in CI; exits non-zero on findings but the CI + job stays green due to `continue-on-error`. + +## 3. User Stories + +### 3.1 Typecheck catches new unused code in any workspace + +* **Given** - a workspace has `noUnusedLocals` / `noUnusedParameters` enabled +* **When** - a developer commits a file with an unused local or parameter +* **Then** - `pnpm typecheck` fails with TS6133/TS6196 and CI blocks the merge + +### 3.2 CI reports unused files/exports/dependencies without blocking + +* **Given** - the `knip` job exists in the `CI` workflow +* **When** - a push/PR contains unused files, exports, or dependencies +* **Then** - the job prints the knip report in the run log, the job (and the + whole workflow) still succeeds, and the report is not part of any required + check + +### 3.3 Knip config changes re-run the report + +* **Given** - `knip.json` is listed in the workflow `paths` triggers +* **When** - a developer tunes the ignore list +* **Then** - CI runs the knip job again so the effect of the config change is + visible in the run From 0946ca4d6066fddb03fda2aff66fad082db0bda7 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 7 Sep 2026 14:58:59 +0800 Subject: [PATCH 69/83] refactor: clean up unused methods --- .github/workflows/ci.yml | 23 + apps/cicd/src/debug-log.ts | 2 +- apps/cicd/test/config.test.ts | 2 +- apps/cicd/tsconfig.json | 2 + apps/cli/index.ts | 8 - apps/cli/scripts/smoke-frontend-log.ts | 8 +- apps/cli/src/cli/addlib.test.ts | 2 +- apps/cli/src/route/Debug.ts | 12 +- apps/cli/src/route/ListDrives.ts | 1 - apps/cli/src/route/Log.test.ts | 2 +- apps/cli/src/route/UnimportFolder.test.ts | 3 +- apps/cli/src/route/ai.ts | 2 +- .../src/route/executeCmd.streaming.test.ts | 1 - apps/cli/src/route/executeCmd.ts | 4 +- apps/cli/src/route/speedtest.ts | 2 +- apps/cli/src/tools/getApplicationContext.ts | 1 - apps/cli/src/tools/getEpisode.ts | 4 +- apps/cli/src/tools/listFiles.ts | 2 - apps/cli/src/tools/renameFilesInBatch.ts | 6 - apps/cli/src/tools/renameFolder.ts | 1 - apps/cli/src/utils/QuickJS.ts | 1 - apps/cli/src/utils/Ytdlp.ts | 1 - apps/cli/src/utils/cmd.orchestrator.test.ts | 2 +- apps/cli/src/utils/cmd.ts | 3 +- apps/cli/src/utils/files.ts | 3 +- apps/cli/test/test-mcp.e2e.ts | 1 - apps/cli/tsconfig.json | 6 +- apps/convex/tsconfig.json | 4 +- apps/core/src/jobs/types.ts | 1 - .../applySelectedRecognizeFilesPlan.test.ts | 1 - .../core/src/pipeline/recognizeMediaFolder.ts | 3 +- .../src/pipeline/scrape/scrapeNfoTmdb.test.ts | 1 - .../pipeline/scrape/scrapePosterTmdb.test.ts | 4 +- apps/core/tsconfig.json | 4 +- apps/e2e/cli/import-folder.test.ts | 2 +- .../config/ConfigDialog-Settings.e2e.ts | 8 - .../e2e/test/componentobjects/ConfigDialog.ts | 1 - .../test/componentobjects/TVShowPanel.co.ts | 1 - apps/e2e/test/lib/e2e-tutorial-fixtures.ts | 1 - apps/e2e/test/lib/testbed.ts | 1 - .../test/specs/ai/AiTool-RecognizeTool.e2e.ts | 3 +- .../test/specs/ai/AiTool-RenameTool.e2e.ts | 1 - apps/e2e/test/specs/ai/Assistant.e2e.ts | 2 - apps/e2e/tsconfig.json | 6 +- apps/e2e/wdio.conf.ts | 47 +- apps/electron/tsconfig.node.json | 2 + apps/electron/tsconfig.web.json | 4 +- apps/tools/tsconfig.json | 2 + apps/ui/src/components/tv/TvShowPanel.tsx | 4 +- .../src/components/tv/TvShowPanelPrompts.tsx | 50 -- .../tv/useAiBasedRecognizeEpisodeFlow.test.ts | 4 +- .../tv/useAiBasedRecognizeEpisodeFlow.ts | 7 +- .../lib/buildTvShowEpisodeTableRows.test.ts | 633 -------------- .../ui/src/lib/buildTvShowEpisodeTableRows.ts | 457 ----------- apps/ui/src/stores/tvShowPromptsStore.ts | 6 +- apps/ui/src/types/UIRecognizeMediaFilePlan.ts | 8 - .../context.md | 140 ---- .../tvshow-panel-ui-metadata-deprecation.md | 310 ------- knip.json | 94 +++ package.json | 4 +- packages/core-routes/src/auth.test.ts | 1 - packages/core-routes/src/chat.ts | 2 +- packages/core-routes/src/chatTypes.ts | 1 - .../src/downloadImageAsFile.test.ts | 1 - .../src/mcp/toolHandlers/getEpisodes.ts | 2 +- .../src/tools/createRecognizeEpisodePlan.ts | 1 - .../src/tools/createRenameEpisodePlan.test.ts | 1 - .../src/tools/createRenameEpisodePlan.ts | 1 - .../src/tools/listFilesInMediaFolder.ts | 1 - packages/core-routes/src/tools/plans.ts | 3 +- .../core-routes/src/tools/renameFolder.ts | 1 - packages/core-routes/src/tools/tmdb.ts | 1 - packages/core-routes/src/tools/tvdb.ts | 1 - packages/core-routes/src/tools/types.ts | 2 - packages/core-routes/tsconfig.json | 2 + packages/electron-common/tsconfig.json | 4 +- packages/test/tsconfig.json | 2 + packages/tvdb4/src/client.test.ts | 6 +- packages/tvdb4/tsconfig.json | 2 + packages/types/tsconfig.json | 2 + packages/utils/tsconfig.json | 2 + pnpm-lock.yaml | 769 +++++++++++++++--- 82 files changed, 842 insertions(+), 1887 deletions(-) delete mode 100644 apps/ui/src/components/tv/TvShowPanelPrompts.tsx delete mode 100644 apps/ui/src/lib/buildTvShowEpisodeTableRows.test.ts delete mode 100644 apps/ui/src/lib/buildTvShowEpisodeTableRows.ts delete mode 100644 apps/ui/src/types/UIRecognizeMediaFilePlan.ts delete mode 100644 docs/superpowers/design/movie-panel-missing-associated-files/context.md delete mode 100644 docs/superpowers/design/tvshow-panel-ui-metadata-deprecation.md create mode 100644 knip.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e5f5c9e..7c63e6e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ on: - 'ci/**' - 'pnpm-workspace.yaml' - 'pnpm-lock.yaml' + - 'knip.json' - '.github/workflows/ci.yml' - '.github/workflows/_e2e-cli.yml' - '.github/workflows/e2e-cli.yml' @@ -29,6 +30,7 @@ on: - 'ci/**' - 'pnpm-workspace.yaml' - 'pnpm-lock.yaml' + - 'knip.json' - '.github/workflows/ci.yml' - '.github/workflows/_e2e-cli.yml' - '.github/workflows/e2e-cli.yml' @@ -102,6 +104,27 @@ jobs: - name: Typecheck run: pnpm run typecheck + # Advisory report of unused files/exports/dependencies (knip). + # Not a gate: continue-on-error keeps the workflow green until the + # existing findings are triaged (see docs/superpowers/specs/2026-09-07-unused-code-detection-design.md). + knip: + name: Knip (unused code report) + runs-on: ubuntu-latest + continue-on-error: true + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Install all dependencies + run: pnpm install --frozen-lockfile + + - name: Run knip + run: pnpm knip + build-ui: name: Build UI needs: [test, lint-ui, typecheck] diff --git a/apps/cicd/src/debug-log.ts b/apps/cicd/src/debug-log.ts index b590f799..42c64103 100644 --- a/apps/cicd/src/debug-log.ts +++ b/apps/cicd/src/debug-log.ts @@ -10,7 +10,7 @@ export type DebugLogEvent = { export class DebugLog { private stream: fs.WriteStream | null = null; - constructor(private readonly filePath: string) { + constructor(filePath: string) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); this.stream = fs.createWriteStream(filePath, { flags: 'a' }); } diff --git a/apps/cicd/test/config.test.ts b/apps/cicd/test/config.test.ts index ea85852b..87647394 100644 --- a/apps/cicd/test/config.test.ts +++ b/apps/cicd/test/config.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'bun:test'; -import { parseConfig, type Config } from '../src/config.ts'; +import { parseConfig } from '../src/config.ts'; describe('parseConfig', () => { test('accepts a minimal valid config', () => { diff --git a/apps/cicd/tsconfig.json b/apps/cicd/tsconfig.json index 4c897b59..de5ba4f6 100644 --- a/apps/cicd/tsconfig.json +++ b/apps/cicd/tsconfig.json @@ -13,6 +13,8 @@ "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "types": ["bun"] }, "include": ["src/**/*", "test/**/*", "run.ts"] diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 516dff6b..549b89e4 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -3,7 +3,6 @@ import { applyTmdbTlsDevBypassToProcessIfEnabled, trustAllTmdbCertEnabled, } from '@/utils/tmdbTls'; -import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import { Server } from './server'; import { getUserDataDir, getLogDir, getAppDataDir } from '@/utils/config'; import { CommandLogCleaner } from '@/utils/CommandLogCleaner'; @@ -62,13 +61,6 @@ function parseArgs(): CommandLineArguments { return result; } -// Create a custom provider with your baseURL and API key -const customProvider = createOpenAICompatible({ - name: 'DeepSeek', - baseURL: 'https://api.deepseek.com/v1', // Your custom base URL - apiKey: '', // Your API key -}); - // Parse command line arguments const args = parseArgs(); diff --git a/apps/cli/scripts/smoke-frontend-log.ts b/apps/cli/scripts/smoke-frontend-log.ts index 4638a119..215d4c3a 100644 --- a/apps/cli/scripts/smoke-frontend-log.ts +++ b/apps/cli/scripts/smoke-frontend-log.ts @@ -101,10 +101,10 @@ async function main(): Promise { _resetRateLimiterForTests(); const big = Array.from({ length: 51 }, (_, i) => ({ level: "info", message: `big-${i}` })); const r4a = await postLog({ entries: big, appVersion: "9.9.9-smoke" }); - const r4b = await postLog({ entries: big, appVersion: "9.9.9-smoke" }); - const r4c = await postLog({ entries: big, appVersion: "9.9.9-smoke" }); - const r4d = await postLog({ entries: big, appVersion: "9.9.9-smoke" }); - const r4e = await postLog({ entries: big, appVersion: "9.9.9-smoke" }); + await postLog({ entries: big, appVersion: "9.9.9-smoke" }); + await postLog({ entries: big, appVersion: "9.9.9-smoke" }); + await postLog({ entries: big, appVersion: "9.9.9-smoke" }); + await postLog({ entries: big, appVersion: "9.9.9-smoke" }); const r4f = await postLog({ entries: big, appVersion: "9.9.9-smoke" }); check("first big batch → 204", r4a.status === 204, `got ${r4a.status}`); check("sixth big batch (12 credits > 10/s budget) → 429", r4f.status === 429, `got ${r4f.status} body=${JSON.stringify(r4f.body)}`); diff --git a/apps/cli/src/cli/addlib.test.ts b/apps/cli/src/cli/addlib.test.ts index bda48cc7..44cea4d9 100644 --- a/apps/cli/src/cli/addlib.test.ts +++ b/apps/cli/src/cli/addlib.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { MockInstance } from 'vitest' -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { mkdtempSync, readFileSync, rmSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' import { createFolderInTestFolder, musicFolder } from '@smm/test' diff --git a/apps/cli/src/route/Debug.ts b/apps/cli/src/route/Debug.ts index b1434a3f..f332fbe4 100644 --- a/apps/cli/src/route/Debug.ts +++ b/apps/cli/src/route/Debug.ts @@ -1,5 +1,5 @@ import { z } from 'zod/v3'; -import { broadcast, acknowledge, type WebSocketMessage } from '../utils/socketIO'; +import { broadcast, acknowledge } from '../utils/socketIO'; import type { Hono } from 'hono'; import { logger } from '../../lib/logger'; import { getUserConfigPath } from '../utils/config'; @@ -78,11 +78,6 @@ export async function processDebugRequest(body: any): Promise ({ diff --git a/apps/cli/src/route/executeCmd.ts b/apps/cli/src/route/executeCmd.ts index e5c835c2..64dab8a2 100644 --- a/apps/cli/src/route/executeCmd.ts +++ b/apps/cli/src/route/executeCmd.ts @@ -51,8 +51,6 @@ const executeCmdRequestSchema = z.object({ tty: z.boolean().optional().default(false), }); -type ExecuteCmdRequestBody = z.infer; - // ─── NDJSON envelope ───────────────────────────────────────────────────────── interface NdjsonStdoutStderrMessage { @@ -118,7 +116,7 @@ export function handleExecuteCmd(app: Hono) { // Pre-compute the final args/env so the response header can report // them. We also use this as a guard for the streaming case below. - const { args: spawnArgs, env: spawnEnv } = await resolveSpawnArgsAndEnv(command, args, { + await resolveSpawnArgsAndEnv(command, args, { tty, }); diff --git a/apps/cli/src/route/speedtest.ts b/apps/cli/src/route/speedtest.ts index 3ae4650d..9fe8f9bc 100644 --- a/apps/cli/src/route/speedtest.ts +++ b/apps/cli/src/route/speedtest.ts @@ -56,7 +56,7 @@ async function testUrl(url: string): Promise { const start = performance.now(); try { - const response = await fetch(url, { + await fetch(url, { method: 'HEAD', signal: controller.signal, redirect: 'follow', diff --git a/apps/cli/src/tools/getApplicationContext.ts b/apps/cli/src/tools/getApplicationContext.ts index 4110fc76..7661dd7e 100644 --- a/apps/cli/src/tools/getApplicationContext.ts +++ b/apps/cli/src/tools/getApplicationContext.ts @@ -1,5 +1,4 @@ import { acknowledge, getFirstAvailableSocket } from '@/utils/socketIO' -import { z } from 'zod' import type { ToolDefinition } from './types' import { createSuccessResponse, createErrorResponse } from '@/mcp/tools/mcpToolBase' import { resolveAppLanguage, detectOsLocale } from '@smm/utils/locale' diff --git a/apps/cli/src/tools/getEpisode.ts b/apps/cli/src/tools/getEpisode.ts index b88fbb04..53a01c5e 100644 --- a/apps/cli/src/tools/getEpisode.ts +++ b/apps/cli/src/tools/getEpisode.ts @@ -213,7 +213,7 @@ export async function getTool(abortSignal?: AbortSignal): Promise ({ +export const createGetEpisodeTool = (_clientId: string, abortSignal?: AbortSignal) => ({ description: `Get episode information from a media folder in SMM. This tool accepts the media folder path, season number, and episode number. It returns the absolute video file path for the specified episode. diff --git a/apps/cli/src/tools/listFiles.ts b/apps/cli/src/tools/listFiles.ts index 2279395e..e07c8c5d 100644 --- a/apps/cli/src/tools/listFiles.ts +++ b/apps/cli/src/tools/listFiles.ts @@ -8,7 +8,6 @@ import { getLocalizedToolDescription } from '@/i18n/helpers' import { doListFiles } from '@/route/ListFiles' import { buildListFilesInMediaFolderResponse, - createEmptyListFilesInMediaFolderData, } from '@smm/core/ai-tool/buildListFilesInMediaFolderResponse' import { formatToolError } from '@smm/core/ai-tool/toolResult' @@ -34,7 +33,6 @@ export async function executeListFilesMcp( } const { folderPath, recursive, videoFileOnly } = params - const empty = createEmptyListFilesInMediaFolderData() if (!folderPath || typeof folderPath !== 'string' || folderPath.trim() === '') { return createErrorResponse( diff --git a/apps/cli/src/tools/renameFilesInBatch.ts b/apps/cli/src/tools/renameFilesInBatch.ts index 55fa9197..b88dcaab 100644 --- a/apps/cli/src/tools/renameFilesInBatch.ts +++ b/apps/cli/src/tools/renameFilesInBatch.ts @@ -181,12 +181,6 @@ This tool return JSON response with the following format: } // 5. Ask for user confirmation - const getFilename = (path: string) => { - const pathInPosix = Path.posix(path); - const parts = pathInPosix.split('/').filter(p => p); - return parts[parts.length - 1] || pathInPosix; - }; - try { // TODO: Check abortSignal during confirmation wait const posixFiles = files.map(file => ({ diff --git a/apps/cli/src/tools/renameFolder.ts b/apps/cli/src/tools/renameFolder.ts index d61530ca..b8fc0f0a 100644 --- a/apps/cli/src/tools/renameFolder.ts +++ b/apps/cli/src/tools/renameFolder.ts @@ -1,4 +1,3 @@ -import { Path } from '@smm/utils/path' import { buildRenameFolderConfirmationMessage, } from '@smm/core/ai-tool/renameFolderConfirm' diff --git a/apps/cli/src/utils/QuickJS.ts b/apps/cli/src/utils/QuickJS.ts index 1e33305f..81ec4c20 100644 --- a/apps/cli/src/utils/QuickJS.ts +++ b/apps/cli/src/utils/QuickJS.ts @@ -1,5 +1,4 @@ import { getUserConfig } from "./config"; -import path from "path"; import os from "os"; import { execSync } from "child_process"; import { logger } from "../../lib/logger"; diff --git a/apps/cli/src/utils/Ytdlp.ts b/apps/cli/src/utils/Ytdlp.ts index 5877a9c0..21185f74 100644 --- a/apps/cli/src/utils/Ytdlp.ts +++ b/apps/cli/src/utils/Ytdlp.ts @@ -1,7 +1,6 @@ import { getUserConfig, getTmpDir } from "./config"; import path from "path"; import os from "os"; -import fs from "fs"; import { mkdir, rename, rm, readdir } from "fs/promises"; import { spawn, execSync } from "child_process"; import { logger } from "../../lib/logger"; diff --git a/apps/cli/src/utils/cmd.orchestrator.test.ts b/apps/cli/src/utils/cmd.orchestrator.test.ts index c10088b8..17a64319 100644 --- a/apps/cli/src/utils/cmd.orchestrator.test.ts +++ b/apps/cli/src/utils/cmd.orchestrator.test.ts @@ -4,7 +4,7 @@ * client receives a non-empty body containing the exit system event. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, readFileSync, rmSync, existsSync } from 'fs' +import { mkdtempSync, rmSync, existsSync } from 'fs' import { tmpdir } from 'os' import path from 'path' import { runCommand } from './cmd' diff --git a/apps/cli/src/utils/cmd.ts b/apps/cli/src/utils/cmd.ts index 9d3f9597..d8c82c4c 100644 --- a/apps/cli/src/utils/cmd.ts +++ b/apps/cli/src/utils/cmd.ts @@ -593,7 +593,6 @@ function spawnAndPump(internals: SpawnInternals): Promise { } = internals return new Promise((resolve) => { let child: ChildProcess | IPty | null = null - let timeoutTimer: ReturnType | null = null let ptyExited = false let cmdLogEnded = false // Buffer for the trailing partial line from stdout. yt-dlp may @@ -787,7 +786,7 @@ function spawnAndPump(internals: SpawnInternals): Promise { }) } - timeoutTimer = setTimeout(() => { + setTimeout(() => { if (isChildRunning()) { logger.warn( { commandExecutionId: cmdLog.executionId, command, timeoutMs }, diff --git a/apps/cli/src/utils/files.ts b/apps/cli/src/utils/files.ts index af9d3a13..7d087e14 100644 --- a/apps/cli/src/utils/files.ts +++ b/apps/cli/src/utils/files.ts @@ -205,7 +205,6 @@ export async function listFiles(_folderPath: Path, recursively: boolean = false, async function moveToTrashLinux(filePath: string): Promise { const homeDir = require('os').homedir(); const xdgDataHome = process.env.XDG_DATA_HOME || path.join(homeDir, '.local', 'share'); - const trashDir = path.join(xdgDataHome, 'Trash'); const trashInfoDir = path.join(xdgDataHome, 'Trash', 'info'); const trashFilesDir = path.join(xdgDataHome, 'Trash', 'files'); @@ -221,7 +220,7 @@ export async function listFiles(_folderPath: Path, recursively: boolean = false, const trashFileDest = path.join(trashFilesDir, uniqueFileName); const trashInfoDest = path.join(trashInfoDir, `${uniqueFileName}.trashinfo`); - const fileStats = await stat(filePath); + await stat(filePath); const deletionDate = new Date().toISOString(); const trashInfoContent = `[Trash Info] diff --git a/apps/cli/test/test-mcp.e2e.ts b/apps/cli/test/test-mcp.e2e.ts index cb45e93a..5948b50a 100644 --- a/apps/cli/test/test-mcp.e2e.ts +++ b/apps/cli/test/test-mcp.e2e.ts @@ -832,7 +832,6 @@ describe('MCP Server - BeginRecognizeTaskTool, AddRecognizedFileTool, EndRecogni ); // Verify our specific files exist in any pending plan const episode1PathPosix = Path.posix(episode1Path); - const episode2PathPosix = Path.posix(episode2Path); const ourTask = recognizePlans.find(p => p.files.some(f => f.path === episode1PathPosix && f.season === 1 && f.episode === 1) ); diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index c92d86fa..eed15627 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -21,9 +21,9 @@ "noUncheckedIndexedAccess": true, "noImplicitOverride": true, - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, + // Some stricter flags + "noUnusedLocals": true, + "noUnusedParameters": true, "noPropertyAccessFromIndexSignature": false, "baseUrl": ".", "paths": { diff --git a/apps/convex/tsconfig.json b/apps/convex/tsconfig.json index 8f4963ec..95904f5f 100644 --- a/apps/convex/tsconfig.json +++ b/apps/convex/tsconfig.json @@ -9,7 +9,9 @@ "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true, - "isolatedModules": true + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true }, "include": ["./convex"] } diff --git a/apps/core/src/jobs/types.ts b/apps/core/src/jobs/types.ts index daba4dfd..54c8c503 100644 --- a/apps/core/src/jobs/types.ts +++ b/apps/core/src/jobs/types.ts @@ -1,7 +1,6 @@ import type { FolderType } from "@smm/types"; import type { ImportLibraryJob as ImportLibraryJobPayload, - ImportLibraryJobTask, } from "@smm/types/job/ImportLibraryJob"; import type { ScrapeTaskId } from "../pipeline/scrape/types"; diff --git a/apps/core/src/pipeline/applySelectedRecognizeFilesPlan.test.ts b/apps/core/src/pipeline/applySelectedRecognizeFilesPlan.test.ts index c448f1e9..9c4f78e7 100644 --- a/apps/core/src/pipeline/applySelectedRecognizeFilesPlan.test.ts +++ b/apps/core/src/pipeline/applySelectedRecognizeFilesPlan.test.ts @@ -5,7 +5,6 @@ import type { FsPort } from "../ports/FsPort"; import { metadataCachePath, planFilePath } from "./paths"; import { applySelectedRecognizeFilesPlanPipeline, - RecognizedFilesNotInPlanError, } from "./applySelectedRecognizeFilesPlan"; import { applyPlanPipeline } from "./applyPlan"; diff --git a/apps/core/src/pipeline/recognizeMediaFolder.ts b/apps/core/src/pipeline/recognizeMediaFolder.ts index a9432d53..b8e478c3 100644 --- a/apps/core/src/pipeline/recognizeMediaFolder.ts +++ b/apps/core/src/pipeline/recognizeMediaFolder.ts @@ -90,7 +90,6 @@ function folderNameOf(mm: MediaMetadata): string { } async function recognizeByNfo( - mm: MediaMetadata, deps: RecognitionDeps, result: RecognitionResult, isTvShow: boolean, @@ -240,7 +239,7 @@ export async function recognizeMediaFolder( ? (await deps.fs.listFiles(mm.mediaFolderPath)).map((f) => Path.posix(f)) : []); - await recognizeByNfo(mm, deps, result, isTvShow, tvdbLang, paths); + await recognizeByNfo(deps, result, isTvShow, tvdbLang, paths); const tmdbId = getTmdbIdFromFolderName(folderName); if (tmdbId !== null && result.tvShow === undefined && result.movie === undefined) { diff --git a/apps/core/src/pipeline/scrape/scrapeNfoTmdb.test.ts b/apps/core/src/pipeline/scrape/scrapeNfoTmdb.test.ts index 1d9194b8..ebc209d5 100644 --- a/apps/core/src/pipeline/scrape/scrapeNfoTmdb.test.ts +++ b/apps/core/src/pipeline/scrape/scrapeNfoTmdb.test.ts @@ -2,7 +2,6 @@ import type { MediaMetadata, TmdbSeasonDetails, TmdbSeriesDetails } from "@smm/t import { describe, expect, it, vi } from "vitest"; import type { TmdbClient } from "../../clients/TmdbClient"; import type { FsPort } from "../../ports/FsPort"; -import type { NetworkPort } from "../../ports/NetworkPort"; import { DEFAULT_USER_CONFIG } from "../userConfigHelper"; import { scrapeNfoTmdb } from "./scrapeNfoTmdb"; import type { ScrapeTaskDeps } from "./scrapeTaskDeps"; diff --git a/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts b/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts index 947b905e..572a62a2 100644 --- a/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts +++ b/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts @@ -1,8 +1,8 @@ -import type { MediaMetadata, TmdbSeasonDetails, TmdbSeriesDetails, UserConfig } from "@smm/types"; +import type { MediaMetadata, TmdbSeriesDetails } from "@smm/types"; import { describe, expect, it, vi } from "vitest"; import type { TmdbClient } from "../../clients/TmdbClient"; import type { DiscoverPort } from "../../ports/DiscoverPort"; -import type { FetchInit, HttpResponse, NetworkPort } from "../../ports/NetworkPort"; +import type { HttpResponse, NetworkPort } from "../../ports/NetworkPort"; import type { FsPort } from "../../ports/FsPort"; import { DEFAULT_USER_CONFIG } from "../userConfigHelper"; import { resolvePosterUrl, scrapePosterTmdb } from "./scrapePosterTmdb"; diff --git a/apps/core/tsconfig.json b/apps/core/tsconfig.json index ca2790cb..1feaa1ff 100644 --- a/apps/core/tsconfig.json +++ b/apps/core/tsconfig.json @@ -17,8 +17,8 @@ "noUncheckedIndexedAccess": true, "noImplicitOverride": true, - "noUnusedLocals": false, - "noUnusedParameters": false, + "noUnusedLocals": true, + "noUnusedParameters": true, "noPropertyAccessFromIndexSignature": false, "baseUrl": ".", diff --git a/apps/e2e/cli/import-folder.test.ts b/apps/e2e/cli/import-folder.test.ts index c2f2e56b..8e178962 100644 --- a/apps/e2e/cli/import-folder.test.ts +++ b/apps/e2e/cli/import-folder.test.ts @@ -14,7 +14,7 @@ describe('import folder', () => { removeMetadataDir: true, removePlansDir: true, removeMediaFolders: true, - resetUserConfig: (config) => { + resetUserConfig: (_config) => { // config.primaryDatabase = 'TMDB' // config.preferMediaLanguage = 'zh-CN' }, diff --git a/apps/e2e/common/config/ConfigDialog-Settings.e2e.ts b/apps/e2e/common/config/ConfigDialog-Settings.e2e.ts index 22385eb7..471f8389 100644 --- a/apps/e2e/common/config/ConfigDialog-Settings.e2e.ts +++ b/apps/e2e/common/config/ConfigDialog-Settings.e2e.ts @@ -52,14 +52,6 @@ describe('Config Dialog Settings - General Settings', () => { await browser.pause(500) } - async function saveAndCloseDialog(): Promise { - await ConfigDialog.clickSave() - await ConfigDialog.pressEscape() - await browser.pause(200) - await ConfigDialog.pressEscape() - await ConfigDialog.waitForClosed() - } - it('should persist all general settings after save and page refresh', async function() { if (slowdown) { this.timeout(120 * 1000) diff --git a/apps/e2e/test/componentobjects/ConfigDialog.ts b/apps/e2e/test/componentobjects/ConfigDialog.ts index fb03c653..41c7dbe0 100644 --- a/apps/e2e/test/componentobjects/ConfigDialog.ts +++ b/apps/e2e/test/componentobjects/ConfigDialog.ts @@ -871,7 +871,6 @@ class ConfigDialog { async getActiveProviderIndex(): Promise { const count = await this.getProviderCount() for (let i = 0; i < count; i++) { - const radio = await this.getProviderRadio(i) // The active provider has a CircleCheck icon (which may have aria-checked or classes) // We check if the radio is the active one by looking at the provider card's border class const card = await this.getProviderCard(i) diff --git a/apps/e2e/test/componentobjects/TVShowPanel.co.ts b/apps/e2e/test/componentobjects/TVShowPanel.co.ts index e3f2fd23..a5a3cd63 100644 --- a/apps/e2e/test/componentobjects/TVShowPanel.co.ts +++ b/apps/e2e/test/componentobjects/TVShowPanel.co.ts @@ -299,7 +299,6 @@ class TVShowPanel { const table = await this.episodeTable if (await table.isExisting()) { const rows = await table.$$('tr') - const rowCount = await rows.length for (const row of rows) { const cells = await row.$$('td') diff --git a/apps/e2e/test/lib/e2e-tutorial-fixtures.ts b/apps/e2e/test/lib/e2e-tutorial-fixtures.ts index f798d165..d2ca06fa 100644 --- a/apps/e2e/test/lib/e2e-tutorial-fixtures.ts +++ b/apps/e2e/test/lib/e2e-tutorial-fixtures.ts @@ -12,7 +12,6 @@ import { fileURLToPath } from 'node:url' import { browser } from '@wdio/globals' import type { TestFolder } from 'test/actions/import-folders' import { - createTestFolderViaBrowser, joinPlatformPath, listFileNamesViaBrowser, resolveSmmTestFolderViaBrowser, diff --git a/apps/e2e/test/lib/testbed.ts b/apps/e2e/test/lib/testbed.ts index 9f62bdbb..db5fd1ed 100644 --- a/apps/e2e/test/lib/testbed.ts +++ b/apps/e2e/test/lib/testbed.ts @@ -17,7 +17,6 @@ import StatusBar from '../componentobjects/StatusBar' import { deleteAppDataSubdirViaBrowser, ensureBrowserOnUiPage, - fetchHelloPathsViaBrowser, joinPlatformPath, resetUserConfigViaBrowser, setActiveTestbedOs, diff --git a/apps/e2e/test/specs/ai/AiTool-RecognizeTool.e2e.ts b/apps/e2e/test/specs/ai/AiTool-RecognizeTool.e2e.ts index 631f6462..4787f9c2 100644 --- a/apps/e2e/test/specs/ai/AiTool-RecognizeTool.e2e.ts +++ b/apps/e2e/test/specs/ai/AiTool-RecognizeTool.e2e.ts @@ -1,4 +1,4 @@ -import { expect, browser } from '@wdio/globals' +import { browser } from '@wdio/globals' import * as fs from 'node:fs' import * as path from 'node:path' import * as os from 'node:os' @@ -14,7 +14,6 @@ import Prompts from 'test/componentobjects/Prompts' import { Path } from '@smm/utils/path' const tmpMediaRoot = path.join(os.tmpdir(), 'smm-test-media') -const mediaDir = path.join(tmpMediaRoot, 'media') describe('AI Assistant - Recognize Tool', async () => { diff --git a/apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts b/apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts index 8a9fbd9c..9686e3d5 100644 --- a/apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts +++ b/apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts @@ -15,7 +15,6 @@ import { Path } from '@smm/utils/path' import Prompts from 'test/componentobjects/Prompts' const tmpMediaRoot = path.join(os.tmpdir(), 'smm-test-media') -const mediaDir = path.join(tmpMediaRoot, 'media') describe('AI Assistant - Rename Tool', async () => { diff --git a/apps/e2e/test/specs/ai/Assistant.e2e.ts b/apps/e2e/test/specs/ai/Assistant.e2e.ts index 647e361b..33722a4d 100644 --- a/apps/e2e/test/specs/ai/Assistant.e2e.ts +++ b/apps/e2e/test/specs/ai/Assistant.e2e.ts @@ -6,8 +6,6 @@ import Menu from '../../componentobjects/Menu' import { createBeforeHook } from '../../lib/testbed' import { delay } from 'es-toolkit' -const slowdown = process.env.SLOWDOWN === 'true' - const tmpMediaRoot = path.join(os.tmpdir(), 'smm-test-media') const mediaDir = path.join(tmpMediaRoot, 'media') diff --git a/apps/e2e/tsconfig.json b/apps/e2e/tsconfig.json index 3ff87e53..c4c896d0 100644 --- a/apps/e2e/tsconfig.json +++ b/apps/e2e/tsconfig.json @@ -21,9 +21,9 @@ "noUncheckedIndexedAccess": true, "noImplicitOverride": true, - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, + // Some stricter flags + "noUnusedLocals": true, + "noUnusedParameters": true, "noPropertyAccessFromIndexSignature": false, // Include mocha and node types for test hooks and fs/path/os modules diff --git a/apps/e2e/wdio.conf.ts b/apps/e2e/wdio.conf.ts index e4481485..8a03f0a7 100644 --- a/apps/e2e/wdio.conf.ts +++ b/apps/e2e/wdio.conf.ts @@ -3,12 +3,7 @@ import fs from 'fs'; import { ReportAggregator } from 'wdio-html-nice-reporter'; import { browser } from '@wdio/globals'; import { WDIO_CACHE_DIR } from './lib/wdioCacheDir'; -import { setup, updateUserConfig } from './test/lib/testbed'; import { registerExpectExtensions } from './test/lib/expect-extensions'; -import { - disableMcpFromStatusBarAndClearGlobal, - enableMcpFromStatusBarAndStoreAddress, -} from './test/lib/mcpSpecShared'; import { clearNetworkLogDir, initNetworkLogCapture, @@ -131,36 +126,6 @@ const formatBrowserLogEntry = (entry: BrowserLogEntry): string => { }; -function workerSpecsIncludeMcp(specs: string[] | undefined): boolean { - if (!specs?.length) return false; - return specs.some((specPath) => { - const n = specPath.replace(/\\/g, '/'); - return ( - n.includes('/specs/mcp/') || - n.includes('/common/mcp/') || - n.includes('/specs/tvdb/McpServerTools-TVDB') || - n.includes('/common/tvdb/McpServerTools-TVDB') - ); - }); -} - -async function enableMcpServerForE2eWorker(): Promise { - await setup({ - removeMetadataDir: false, - removePlansDir: false, - removeMediaFolders: false, - removeDirInSidebar: false, - resetUserConfig: false, - openBrowserPage: true, - }); - - await enableMcpFromStatusBarAndStoreAddress(); -} - -async function disableMcpServerForE2eWorker(): Promise { - await disableMcpFromStatusBarAndClearGlobal(); -} - const chromeOptionsForDockerEnv: string[] = [ '--window-size=1920,1080', '--disable-dev-shm-usage', @@ -444,7 +409,7 @@ export const config: WebdriverIO.Config = { * @param {Array.} specs List of spec file paths that are to be run * @param {object} browser instance of created browser/device session */ - before: async function (_capabilities, specs) { + before: async function (_capabilities, _specs) { registerExpectExtensions(); await applyE2eWindowSize(); @@ -485,13 +450,6 @@ export const config: WebdriverIO.Config = { } await setupNetworkLogCapture(browser); - - // if (workerSpecsIncludeMcp(specs)) { - // await updateUserConfig((userConfig) => { - // userConfig.enableMcpServer = true; - // return userConfig; - // }); - // } }, /** * Runs before a WebdriverIO command gets executed. @@ -562,9 +520,6 @@ export const config: WebdriverIO.Config = { */ after: async function (_result, _capabilities, _specs) { saveNetworkLog(); - // if (workerSpecsIncludeMcp(specs)) { - // await disableMcpServerForE2eWorker(); - // } }, /** * Gets executed right after terminating the webdriver session. diff --git a/apps/electron/tsconfig.node.json b/apps/electron/tsconfig.node.json index 032de13e..cd800205 100644 --- a/apps/electron/tsconfig.node.json +++ b/apps/electron/tsconfig.node.json @@ -4,6 +4,8 @@ "exclude": ["**/*.test.ts"], "compilerOptions": { "composite": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "types": ["electron-vite/node"] } } diff --git a/apps/electron/tsconfig.web.json b/apps/electron/tsconfig.web.json index 9f8ebb6f..a752a383 100644 --- a/apps/electron/tsconfig.web.json +++ b/apps/electron/tsconfig.web.json @@ -2,6 +2,8 @@ "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", "include": ["src/renderer/**/*.ts", "src/preload/*.d.ts"], "compilerOptions": { - "composite": true + "composite": true, + "noUnusedLocals": true, + "noUnusedParameters": true } } diff --git a/apps/tools/tsconfig.json b/apps/tools/tsconfig.json index e7b16a59..3d81eee8 100644 --- a/apps/tools/tsconfig.json +++ b/apps/tools/tsconfig.json @@ -13,6 +13,8 @@ "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "types": ["bun"] }, "include": ["*.ts"] diff --git a/apps/ui/src/components/tv/TvShowPanel.tsx b/apps/ui/src/components/tv/TvShowPanel.tsx index 19a50cd9..83ab6fa2 100644 --- a/apps/ui/src/components/tv/TvShowPanel.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.tsx @@ -7,7 +7,6 @@ import type { MediaMetadata } from "@/lib/mediaFolderFiles" import { useMediaFolderFilesQuery } from "@/hooks/useMediaFolderFilesQuery" import type { TMDBTVShow, TMDBTVShowDetails } from "@smm/types" import type { SearchResultSelectedArgs } from "../MediaDatabaseSearchbox" -import { TvShowPanelPrompts } from "./TvShowPanelPrompts" import { useTvShowPromptsStore } from "@/stores/tvShowPromptsStore" import { useTvShowPanelState } from "@/hooks/tv/useTvShowPanelState" import { useTvShowEpisodeVideoCompress } from "@/hooks/tv/useTvShowEpisodeVideoCompress" @@ -40,7 +39,6 @@ import { } from "./TvShowPanelUtils" import { useLatest } from "react-use" import type { UIMediaFolderStatus } from "@/types/UIMediaFolder" -import type { UIRecognizeMediaFilePlan } from "@/types/UIRecognizeMediaFilePlan" import { useTvShowPanel } from "@/hooks/useTvShowPanel" import { RuleBasedRenameFilePrompt } from "../RuleBasedRenameFilePrompt" import { RuleBasedRecognizePrompt } from "./RuleBasedRecognizePrompt" @@ -133,7 +131,7 @@ function TvShowPanel() { ) const recognizeBeforeConfirm = useCallback( - (plan: UIRecognizeMediaFilePlan) => + (plan: RecognizeMediaFilePlan) => rebuildPlanWithSelectedEpisodes(plan, getSelectedEpisodes()), [getSelectedEpisodes], ) diff --git a/apps/ui/src/components/tv/TvShowPanelPrompts.tsx b/apps/ui/src/components/tv/TvShowPanelPrompts.tsx deleted file mode 100644 index e6c71c9b..00000000 --- a/apps/ui/src/components/tv/TvShowPanelPrompts.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { UseNfoPrompt } from "./UseNfoPrompt" -import type { TMDBTVShow } from "@smm/types" -import { useTvShowPromptsStore } from "@/stores/tvShowPromptsStore" - -export function TvShowPanelPrompts() { - const closeUseNfoPrompt = useTvShowPromptsStore((state) => state.closeUseNfoPrompt) - - const useNfoPrompt = useTvShowPromptsStore((state) => state.useNfoPrompt) - - return ( -
- { - const callback = useNfoPrompt.onConfirm - const nfoData = useNfoPrompt.data - closeUseNfoPrompt() - - if (nfoData && callback) { - const minimalTvShow: TMDBTVShow = { - id: nfoData.id, - name: nfoData.name, - original_name: nfoData.original_name, - overview: nfoData.overview, - poster_path: nfoData.poster_path, - backdrop_path: nfoData.backdrop_path, - first_air_date: nfoData.first_air_date, - vote_average: nfoData.vote_average, - vote_count: nfoData.vote_count, - popularity: nfoData.popularity, - genre_ids: nfoData.genre_ids, - origin_country: nfoData.origin_country, - media_type: "tv", - } - callback(minimalTvShow) - } - }} - onCancel={() => { - closeUseNfoPrompt() - const cancelCallback = useNfoPrompt.onCancel - if (cancelCallback) { - cancelCallback() - } - }} - /> -
- ) -} diff --git a/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts b/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts index 52a77621..1a3f82ef 100644 --- a/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts +++ b/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest" import { renderHook } from "@testing-library/react" import { useAiBasedRecognizeEpisodeFlow } from "./useAiBasedRecognizeEpisodeFlow" -import type { UIRecognizeMediaFilePlan } from "@/types/UIRecognizeMediaFilePlan" +import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan" import type { MediaMetadata } from "@smm/types" const h = vi.hoisted(() => ({ @@ -28,7 +28,7 @@ describe("useAiBasedRecognizeEpisodeFlow", () => { const mediaFolderPath = "/storage/Users/currentUser/Download/Anime/show" const mediaMetadata = { mediaFolderPath, type: "tvshow-folder" } as MediaMetadata - const pendingAiPlan: UIRecognizeMediaFilePlan = { + const pendingAiPlan: RecognizeMediaFilePlan = { id: "plan-1", task: "recognize-media-file", status: "pending", diff --git a/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.ts b/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.ts index 0ffcef17..292d5977 100644 --- a/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.ts +++ b/apps/ui/src/hooks/tv/useAiBasedRecognizeEpisodeFlow.ts @@ -6,12 +6,11 @@ import { toUpdatePlanPatch, usePlansQuery, useUpdatePlanMutation } from "@/hooks import { useUpdateMediaMetadataMutation } from "@/hooks/mediaMetadata/useUpdateMediaMetadataMutation" import type { MediaMetadata } from "@smm/types" import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan" -import type { UIRecognizeMediaFilePlan } from "@/types/UIRecognizeMediaFilePlan" import type { AiBasedRecognizeEpisodePromptProps } from "@/components/tv/AiBasedRecognizeEpisodePrompt" export interface UseAiBasedRecognizeEpisodeFlowOptions { mediaMetadata: MediaMetadata | undefined - beforeConfirm: (plan: UIRecognizeMediaFilePlan) => UIRecognizeMediaFilePlan + beforeConfirm: (plan: RecognizeMediaFilePlan) => RecognizeMediaFilePlan /** Called when an AI recognize plan is detected (e.g. switch episode table to simple layout). */ onFlowStart?: () => void } @@ -40,7 +39,7 @@ export function useAiBasedRecognizeEpisodeFlow({ const plan = useMemo( () => - selectActiveAiPlan( + selectActiveAiPlan( plans, mediaFolderPath, "recognize-media-file", @@ -50,7 +49,7 @@ export function useAiBasedRecognizeEpisodeFlow({ const onConfirm = useCallback(async () => { if (!plan || !mediaMetadata?.mediaFolderPath) return - const preparedPlan = beforeConfirm(plan) as RecognizeMediaFilePlan + const preparedPlan = beforeConfirm(plan) await handleAiRecognizeConfirm( preparedPlan, mediaMetadata, diff --git a/apps/ui/src/lib/buildTvShowEpisodeTableRows.test.ts b/apps/ui/src/lib/buildTvShowEpisodeTableRows.test.ts deleted file mode 100644 index 7d7c5844..00000000 --- a/apps/ui/src/lib/buildTvShowEpisodeTableRows.test.ts +++ /dev/null @@ -1,633 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { - buildTvShowEpisodeTableRows, - buildTvShowEpisodeTableRowsForPlan, - fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan, - fillTvShowEpisodeTableRowByRenameFilesPlan, -} from './buildTvShowEpisodeTableRows' -import type { - UIMediaFileTableRow, - UIMediaFileDataRow, - UIMediaEpisodeSelection, -} from '@/components/media/UIMediaFileTable' -import type { UIRecognizeMediaFilePlan } from '@/types/UIRecognizeMediaFilePlan' -import type { UIRenameFilesPlan } from '@/types/UIRenameFilesPlan' -import type { MediaMetadata } from '@smm/types' - -function episodeRow(season: number, episode: number, videoFile?: string): UIMediaFileDataRow { - return { - season, - episode, - type: 'episode', - videoFile, - thumbnail: undefined, - subtitle: undefined, - nfo: undefined, - episodeTitle: '', - } -} - -/** Set of "s-e" keys for a defaultChecked list, for easy membership asserts. */ -function selectedKeys(defaultChecked: UIMediaEpisodeSelection[]): Set { - return new Set(defaultChecked.map((e) => `${e.season}-${e.episode}`)) -} - -function recognizePlan(files: { season: number; episode: number; path: string }[]): UIRecognizeMediaFilePlan { - return { - id: 'plan-1', - task: 'recognize-media-file', - status: 'completed', - mediaFolderPath: '/media/show', - files: files.map((f) => ({ season: f.season, episode: f.episode, path: f.path })), - tmp: false, - } -} - -function renamePlan(files: { from: string; to: string }[]): UIRenameFilesPlan { - return { - id: 'rename-plan-1', - task: 'rename-files', - status: 'completed', - mediaFolderPath: '/media/show', - files, - tmp: false, - } -} - -/** Matches `TvShowMediaMetadata` shape used by `buildTvShowEpisodeTableRows` (`mm.tvShow`). */ -function tvShowForPlanTests() { - return { - id: '1', - name: 'Test Show', - database: 'TMDB' as const, - airDate: '2024-01-01', - seasons: [ - { - season: 1, - name: 'Season 1', - episodes: [{ season: 1, episode: 1, name: 'Episode 1' }], - }, - ], - } -} - -describe('fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan', () => { - beforeEach(() => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - it('fills matching episode row with video path and adds it to defaultChecked', () => { - const rows: UIMediaFileTableRow[] = [ - episodeRow(1, 1), - episodeRow(1, 2), - ] - const plan = recognizePlan([ - { season: 1, episode: 1, path: '/media/show/S01E01.mkv' }, - ]) - - const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) - const keys = selectedKeys(defaultChecked) - - const row1 = result.find((r) => r.type === 'episode' && r.season === 1 && r.episode === 1) as UIMediaFileDataRow - expect(row1.videoFile).toBe('/media/show/S01E01.mkv') - expect(keys.has('1-1')).toBe(true) - expect(row1.disabled).toBe(false) - expect(row1.newVideoFile).toBeUndefined() - - const row2 = result.find((r) => r.type === 'episode' && r.season === 1 && r.episode === 2) as UIMediaFileDataRow - expect(row2.videoFile).toBeUndefined() - expect(keys.has('1-2')).toBe(false) - expect(row2.disabled).toBe(true) - }) - - it('keeps existing videoFile but disables row when episode is not in plan', () => { - const rows: UIMediaFileTableRow[] = [ - episodeRow(1, 1, '/media/show/existing-S01E01.mkv'), - episodeRow(1, 2, '/media/show/existing-S01E02.mkv'), - ] - const plan = recognizePlan([ - { season: 1, episode: 1, path: '/media/show/S01E01.mkv' }, - ]) - - const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) - const keys = selectedKeys(defaultChecked) - - const row1 = result.find((r) => r.type === 'episode' && r.season === 1 && r.episode === 1) as UIMediaFileDataRow - expect(row1.videoFile).toBe('/media/show/S01E01.mkv') - expect(keys.has('1-1')).toBe(true) - expect(row1.disabled).toBe(false) - - const row2 = result.find((r) => r.type === 'episode' && r.season === 1 && r.episode === 2) as UIMediaFileDataRow - expect(row2.videoFile).toBe('/media/show/existing-S01E02.mkv') - expect(keys.has('1-2')).toBe(false) - expect(row2.disabled).toBe(true) - }) - - it('disables row when plan path matches existing mediaFiles path', () => { - const rows: UIMediaFileTableRow[] = [ - episodeRow(1, 1, '/media/show/S01E01.mkv'), - episodeRow(1, 2, '/media/show/S01E02.mkv'), - ] - const plan = recognizePlan([ - { season: 1, episode: 1, path: '/media/show/S01E01.mkv' }, - { season: 1, episode: 2, path: '/media/show/S01E02.mkv' }, - ]) - - const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) - - expect(defaultChecked).toEqual([]) - - const row1 = result.find((r) => r.type === 'episode' && r.season === 1 && r.episode === 1) as UIMediaFileDataRow - expect(row1.videoFile).toBe('/media/show/S01E01.mkv') - expect(row1.disabled).toBe(true) - - const row2 = result.find((r) => r.type === 'episode' && r.season === 1 && r.episode === 2) as UIMediaFileDataRow - expect(row2.videoFile).toBe('/media/show/S01E02.mkv') - expect(row2.disabled).toBe(true) - }) - - it('enables row when plan path differs from existing mediaFiles path', () => { - const rows: UIMediaFileTableRow[] = [ - episodeRow(1, 1, '/media/show/old-S01E01.mkv'), - ] - const plan = recognizePlan([ - { season: 1, episode: 1, path: '/media/show/S01E01.mkv' }, - ]) - - const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) - - const row = result[0] as UIMediaFileDataRow - expect(row.videoFile).toBe('/media/show/S01E01.mkv') - expect(selectedKeys(defaultChecked).has('1-1')).toBe(true) - expect(row.disabled).toBe(false) - }) - - it('clears newVideoFile when filling from plan', () => { - const rows: UIMediaFileTableRow[] = [ - episodeRow(1, 1, '/media/show/old.mkv'), - ] - ;(rows[0] as UIMediaFileDataRow).newVideoFile = '/media/show/new.mkv' - const plan = recognizePlan([{ season: 1, episode: 1, path: '/media/show/S01E01.mkv' }]) - - const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) - - const row = result[0] as UIMediaFileDataRow - expect(row.videoFile).toBe('/media/show/S01E01.mkv') - expect(row.newVideoFile).toBeUndefined() - expect(selectedKeys(defaultChecked).has('1-1')).toBe(true) - expect(row.disabled).toBe(false) - }) - - it('does not mutate input rows', () => { - const rows: UIMediaFileTableRow[] = [episodeRow(1, 1)] - const plan = recognizePlan([{ season: 1, episode: 1, path: '/media/show/S01E01.mkv' }]) - - fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) - - expect((rows[0] as UIMediaFileDataRow).videoFile).toBeUndefined() - expect((rows[0] as UIMediaFileDataRow).newVideoFile).toBeUndefined() - }) - - it('handles multiple recognized files in one plan', () => { - const rows: UIMediaFileTableRow[] = [ - episodeRow(1, 1), - episodeRow(1, 2), - episodeRow(2, 1), - ] - const plan = recognizePlan([ - { season: 1, episode: 1, path: '/media/show/S01E01.mkv' }, - { season: 1, episode: 2, path: '/media/show/S01E02.mkv' }, - { season: 2, episode: 1, path: '/media/show/S02E01.mkv' }, - ]) - - const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) - const keys = selectedKeys(defaultChecked) - - expect((result[0] as UIMediaFileDataRow).videoFile).toBe('/media/show/S01E01.mkv') - expect(keys.has('1-1')).toBe(true) - expect((result[0] as UIMediaFileDataRow).disabled).toBe(false) - expect((result[1] as UIMediaFileDataRow).videoFile).toBe('/media/show/S01E02.mkv') - expect(keys.has('1-2')).toBe(true) - expect((result[1] as UIMediaFileDataRow).disabled).toBe(false) - expect((result[2] as UIMediaFileDataRow).videoFile).toBe('/media/show/S02E01.mkv') - expect(keys.has('2-1')).toBe(true) - expect((result[2] as UIMediaFileDataRow).disabled).toBe(false) - }) - - it('skips recognized files that do not match any row and warns', () => { - const rows: UIMediaFileTableRow[] = [episodeRow(1, 1)] - const plan = recognizePlan([ - { season: 1, episode: 1, path: '/media/show/S01E01.mkv' }, - { season: 5, episode: 99, path: '/media/show/unknown.mkv' }, - ]) - - const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) - - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining('season 5 episode 99'), - ) - expect((result[0] as UIMediaFileDataRow).videoFile).toBe('/media/show/S01E01.mkv') - expect(selectedKeys(defaultChecked).has('1-1')).toBe(true) - }) - - it('ignores non-episode rows (dividers, folder files)', () => { - const rows: UIMediaFileTableRow[] = [ - { id: 'season-1', type: 'divider', text: 'Season 1' }, - episodeRow(1, 1), - { id: 'poster', type: 'folderFile', path: '/media/show/poster.jpg' }, - ] - const plan = recognizePlan([{ season: 1, episode: 1, path: '/media/show/S01E01.mkv' }]) - - const { rows: result } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) - - expect(result).toHaveLength(3) - expect(result[0]).toEqual({ id: 'season-1', type: 'divider', text: 'Season 1' }) - expect((result[1] as UIMediaFileDataRow).videoFile).toBe('/media/show/S01E01.mkv') - expect(result[2]).toEqual({ id: 'poster', type: 'folderFile', path: '/media/show/poster.jpg' }) - }) - - it('returns unchanged clone when plan has no files', () => { - const rows: UIMediaFileTableRow[] = [episodeRow(1, 1)] - const plan = recognizePlan([]) - - const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) - - expect(result).toHaveLength(1) - expect((result[0] as UIMediaFileDataRow).videoFile).toBeUndefined() - expect(defaultChecked).toEqual([]) - expect((result[0] as UIMediaFileDataRow).disabled).toBe(true) - }) - - it('sets disabled true for episodes not in plan when plan has no files', () => { - const rows: UIMediaFileTableRow[] = [ - episodeRow(1, 1, '/media/show/existing.mkv'), - ] - const plan = recognizePlan([]) - - const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) - - const row = result[0] as UIMediaFileDataRow - expect(row.videoFile).toBe('/media/show/existing.mkv') - expect(defaultChecked).toEqual([]) - expect(row.disabled).toBe(true) - }) - - it('leaves row enabled and unselected when recognized file path is undefined', () => { - const rows: UIMediaFileTableRow[] = [episodeRow(1, 1)] - const plan = recognizePlan([{ season: 1, episode: 1, path: undefined! }]) as UIRecognizeMediaFilePlan - - const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) - - const row = result[0] as UIMediaFileDataRow - expect(row.videoFile).toBeUndefined() - expect(defaultChecked).toEqual([]) - expect(row.disabled).toBe(false) - }) -}) - -describe('fillTvShowEpisodeTableRowByRenameFilesPlan', () => { - it('sets newVideoFile and adds to defaultChecked when rename from matches episode videoFile', () => { - const rows: UIMediaFileTableRow[] = [ - episodeRow(1, 1, '/media/show/S01E01.mkv'), - episodeRow(1, 2, '/media/show/S01E02.mkv'), - ] - const plan = renamePlan([ - { from: '/media/show/S01E01.mkv', to: '/media/show/Season 01/Episode 01.mkv' }, - ]) - - const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRenameFilesPlan(rows, plan) - const keys = selectedKeys(defaultChecked) - - expect((result[0] as UIMediaFileDataRow).newVideoFile).toBe('/media/show/Season 01/Episode 01.mkv') - expect(keys.has('1-1')).toBe(true) - expect((result[0] as UIMediaFileDataRow).disabled).toBe(false) - expect((result[1] as UIMediaFileDataRow).newVideoFile).toBeUndefined() - expect(keys.has('1-2')).toBe(false) - expect((result[1] as UIMediaFileDataRow).disabled).toBe(true) - }) - - it('applies multiple rename mappings to multiple episode rows', () => { - const rows: UIMediaFileTableRow[] = [ - episodeRow(1, 1, '/media/show/S01E01.mkv'), - episodeRow(1, 2, '/media/show/S01E02.mkv'), - episodeRow(2, 1, '/media/show/S02E01.mkv'), - ] - const plan = renamePlan([ - { from: '/media/show/S01E01.mkv', to: '/media/show/new/S01E01.mkv' }, - { from: '/media/show/S02E01.mkv', to: '/media/show/new/S02E01.mkv' }, - ]) - - const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRenameFilesPlan(rows, plan) - const keys = selectedKeys(defaultChecked) - - expect((result[0] as UIMediaFileDataRow).newVideoFile).toBe('/media/show/new/S01E01.mkv') - expect(keys.has('1-1')).toBe(true) - expect((result[0] as UIMediaFileDataRow).disabled).toBe(false) - expect((result[1] as UIMediaFileDataRow).newVideoFile).toBeUndefined() - expect(keys.has('1-2')).toBe(false) - expect((result[1] as UIMediaFileDataRow).disabled).toBe(true) - expect((result[2] as UIMediaFileDataRow).newVideoFile).toBe('/media/show/new/S02E01.mkv') - expect(keys.has('2-1')).toBe(true) - expect((result[2] as UIMediaFileDataRow).disabled).toBe(false) - }) - - it('keeps rows unchanged when no rename from path matches', () => { - const rows: UIMediaFileTableRow[] = [ - episodeRow(1, 1, '/media/show/S01E01.mkv'), - episodeRow(1, 2, '/media/show/S01E02.mkv'), - ] - const plan = renamePlan([ - { from: '/media/show/UNKNOWN.mkv', to: '/media/show/new/UNKNOWN.mkv' }, - ]) - - const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRenameFilesPlan(rows, plan) - - expect((result[0] as UIMediaFileDataRow).newVideoFile).toBeUndefined() - expect(defaultChecked).toEqual([]) - expect((result[0] as UIMediaFileDataRow).disabled).toBe(true) - expect((result[1] as UIMediaFileDataRow).newVideoFile).toBeUndefined() - expect((result[1] as UIMediaFileDataRow).disabled).toBe(true) - }) - - it('ignores non-episode rows', () => { - const rows: UIMediaFileTableRow[] = [ - { id: 'season-1', type: 'divider', text: 'Season 1' }, - episodeRow(1, 1, '/media/show/S01E01.mkv'), - { id: 'fanart', type: 'folderFile', path: '/media/show/fanart.jpg' }, - ] - const plan = renamePlan([ - { from: '/media/show/S01E01.mkv', to: '/media/show/new/S01E01.mkv' }, - ]) - - const { rows: result, defaultChecked } = fillTvShowEpisodeTableRowByRenameFilesPlan(rows, plan) - - expect(result[0]).toEqual({ id: 'season-1', type: 'divider', text: 'Season 1' }) - expect((result[1] as UIMediaFileDataRow).newVideoFile).toBe('/media/show/new/S01E01.mkv') - expect(selectedKeys(defaultChecked).has('1-1')).toBe(true) - expect(result[2]).toEqual({ id: 'fanart', type: 'folderFile', path: '/media/show/fanart.jpg' }) - }) - - it('does not mutate input rows', () => { - const rows: UIMediaFileTableRow[] = [ - episodeRow(1, 1, '/media/show/S01E01.mkv'), - ] - const plan = renamePlan([ - { from: '/media/show/S01E01.mkv', to: '/media/show/new/S01E01.mkv' }, - ]) - - fillTvShowEpisodeTableRowByRenameFilesPlan(rows, plan) - - expect((rows[0] as UIMediaFileDataRow).newVideoFile).toBeUndefined() - }) -}) - -describe('buildTvShowEpisodeTableRows', () => { - it('returns initializing divider row when uiStatus is initializing', () => { - const mm = {} as MediaMetadata - - const rows = buildTvShowEpisodeTableRows(mm, 'initializing', (key) => key) - - expect(rows).toEqual([ - { - id: 'initializing', - type: 'divider', - text: 'mediaFolder.initializing', - }, - ]) - }) - - it('returns folder_not_found divider row when uiStatus is folder_not_found', () => { - const mm = {} as MediaMetadata - - const rows = buildTvShowEpisodeTableRows(mm, 'folder_not_found', (key) => key) - - expect(rows).toEqual([ - { - id: 'folder_not_found', - type: 'divider', - text: 'mediaFolder.folderNotFound', - }, - ]) - }) - - it('returns error_loading_metadata divider row when uiStatus is error_loading_metadata', () => { - const mm = {} as MediaMetadata - - const rows = buildTvShowEpisodeTableRows(mm, 'error_loading_metadata', (key) => key) - - expect(rows).toEqual([ - { - id: 'error_loading_metadata', - type: 'divider', - text: 'mediaFolder.errorLoadingMetadata', - }, - ]) - }) - - it('bulid rows for fanart, poster, theme, nfo files', () => { - const mm = { - mediaFolderPath: '/media/show', - files: [ - '/media/show/fanart.jpg', - '/media/show/poster.png', - '/media/show/theme.mp3', - '/media/show/tvshow.nfo', - ], - } as MediaMetadata - - const rows = buildTvShowEpisodeTableRows(mm, 'ok', (key) => key, mm.files ?? []) - const folderRows = rows.filter((row) => row.type === 'folderFile') - - expect(folderRows).toEqual([ - { id: 'fanart', type: 'folderFile', path: '/media/show/fanart.jpg' }, - { id: 'poster', type: 'folderFile', path: '/media/show/poster.png' }, - { id: 'theme', type: 'folderFile', path: '/media/show/theme.mp3' }, - { id: 'nfo', type: 'folderFile', path: '/media/show/tvshow.nfo' }, - ]) - }) -}) - -describe('buildTvShowEpisodeTableRows with tmdb/tvdb branches', () => { - it('shows the episode video from mediaFiles when live folder files are missing', () => { - const mm = { - mediaFolderPath: '/media/show', - mediaFiles: [ - { - absolutePath: '/media/show/S01E01.mkv', - seasonNumber: 1, - episodeNumber: 1, - }, - ], - tvShow: tvShowForPlanTests(), - } as MediaMetadata - - const rows = buildTvShowEpisodeTableRows(mm, 'ok', (key) => key, mm.files ?? []) - const ep = rows.find((row) => row.type === 'episode' && row.season === 1 && row.episode === 1) as UIMediaFileDataRow - - expect(ep.videoFile).toBe('/media/show/S01E01.mkv') - }) - - it('includes fanart row when tmdbTvShow branch is used', () => { - const mm = { - mediaFolderPath: '/media/show', - files: ['/media/show/fanart.jpg'], - tmdbTvShow: { - id: 1, - name: 'Test Show', - original_name: 'Test Show', - overview: '', - poster_path: null, - backdrop_path: null, - first_air_date: '2024-01-01', - vote_average: 0, - vote_count: 0, - popularity: 0, - genre_ids: [], - origin_country: [], - number_of_seasons: 0, - number_of_episodes: 0, - seasons: [], - status: 'Ended', - type: 'Scripted', - in_production: false, - last_air_date: '2024-01-01', - networks: [], - production_companies: [], - }, - } as MediaMetadata - - const rows = buildTvShowEpisodeTableRows(mm, 'ok', (key) => key, mm.files ?? []) - - expect(rows).toContainEqual({ - id: 'fanart', - type: 'folderFile', - path: '/media/show/fanart.jpg', - }) - }) - - it('includes fanart row when tvdbTvShow branch is used', () => { - const mm = { - mediaFolderPath: '/media/show', - files: ['/media/show/fanart.jpg'], - tvdbTvShow: { - id: '1', - name: 'Test Show', - database: 'TVDB', - seasons: [], - }, - } as MediaMetadata - - const rows = buildTvShowEpisodeTableRows(mm, 'ok', (key) => key, mm.files ?? []) - - expect(rows).toContainEqual({ - id: 'fanart', - type: 'folderFile', - path: '/media/show/fanart.jpg', - }) - }) -}) - -describe('buildTvShowEpisodeTableRowsForPlan', () => { - it('returns initializing divider when uiStatus is initializing', () => { - const mm = {} as MediaMetadata - const plan = recognizePlan([{ season: 1, episode: 1, path: '/media/show/S01E01.mkv' }]) - - const { rows, defaultChecked } = buildTvShowEpisodeTableRowsForPlan(mm, 'initializing', plan, (key) => key) - - expect(rows).toEqual([ - { - id: 'initializing', - type: 'divider', - text: 'mediaFolder.initializing', - }, - ]) - expect(defaultChecked).toEqual([]) - }) - - it('returns folder_not_found divider when uiStatus is folder_not_found', () => { - const mm = {} as MediaMetadata - const plan = recognizePlan([{ season: 1, episode: 1, path: '/media/show/S01E01.mkv' }]) - - const { rows, defaultChecked } = buildTvShowEpisodeTableRowsForPlan(mm, 'folder_not_found', plan, (key) => key) - - expect(rows).toEqual([ - { - id: 'folder_not_found', - type: 'divider', - text: 'mediaFolder.folderNotFound', - }, - ]) - expect(defaultChecked).toEqual([]) - }) - - it('returns error_loading_metadata divider when uiStatus is error_loading_metadata', () => { - const mm = {} as MediaMetadata - const plan = recognizePlan([{ season: 1, episode: 1, path: '/media/show/S01E01.mkv' }]) - - const { rows, defaultChecked } = buildTvShowEpisodeTableRowsForPlan(mm, 'error_loading_metadata', plan, (key) => key) - - expect(rows).toEqual([ - { - id: 'error_loading_metadata', - type: 'divider', - text: 'mediaFolder.errorLoadingMetadata', - }, - ]) - expect(defaultChecked).toEqual([]) - }) - - it('returns base rows unchanged and no default selection when recognize plan is preparing', () => { - const mm = { - tvShow: tvShowForPlanTests(), - } as MediaMetadata - const plan = { - ...recognizePlan([{ season: 1, episode: 1, path: '/media/show/S01E01.mkv' }]), - status: 'preparing', - } as UIRecognizeMediaFilePlan - - const { rows, defaultChecked } = buildTvShowEpisodeTableRowsForPlan(mm, 'ok', plan, (key) => key) - const ep = rows.find((row) => row.type === 'episode' && row.season === 1 && row.episode === 1) as UIMediaFileDataRow - - expect(ep.videoFile).toBeUndefined() - expect(defaultChecked).toEqual([]) - }) - - it('fills episode row from recognize plan when recognize plan is completed', () => { - const mm = { - tvShow: tvShowForPlanTests(), - } as MediaMetadata - const plan = recognizePlan([{ season: 1, episode: 1, path: '/media/show/S01E01.mkv' }]) - - const { rows, defaultChecked } = buildTvShowEpisodeTableRowsForPlan(mm, 'ok', plan, (key) => key) - const ep = rows.find((row) => row.type === 'episode' && row.season === 1 && row.episode === 1) as UIMediaFileDataRow - - expect(ep.videoFile).toBe('/media/show/S01E01.mkv') - expect(selectedKeys(defaultChecked).has('1-1')).toBe(true) - expect(ep.newVideoFile).toBeUndefined() - }) - - it('fills newVideoFile from rename plan when rename plan is completed', () => { - const mm = { - mediaFolderPath: '/media/show', - files: ['/media/show/S01E01.mkv'], - mediaFiles: [ - { - absolutePath: '/media/show/S01E01.mkv', - seasonNumber: 1, - episodeNumber: 1, - }, - ], - tvShow: tvShowForPlanTests(), - } as MediaMetadata - const plan = renamePlan([ - { from: '/media/show/S01E01.mkv', to: '/media/show/new/S01E01.mkv' }, - ]) - - const { rows, defaultChecked } = buildTvShowEpisodeTableRowsForPlan(mm, 'ok', plan, (key) => key) - const ep = rows.find((row) => row.type === 'episode' && row.season === 1 && row.episode === 1) as UIMediaFileDataRow - - expect(ep.videoFile).toBe('/media/show/S01E01.mkv') - expect(ep.newVideoFile).toBe('/media/show/new/S01E01.mkv') - expect(selectedKeys(defaultChecked).has('1-1')).toBe(true) - }) -}) diff --git a/apps/ui/src/lib/buildTvShowEpisodeTableRows.ts b/apps/ui/src/lib/buildTvShowEpisodeTableRows.ts deleted file mode 100644 index 1ef81adc..00000000 --- a/apps/ui/src/lib/buildTvShowEpisodeTableRows.ts +++ /dev/null @@ -1,457 +0,0 @@ -import type { UIMediaFileTableRow, UIMediaFileFolderRow, UIMediaEpisodeSelection } from "@/components/media/UIMediaFileTable"; -import type { MediaMetadata } from "@/lib/mediaFolderFiles" -import { basename, join } from "@/lib/path"; -import type { UIRecognizeMediaFilePlan } from "@/types/UIRecognizeMediaFilePlan"; -import { findAssociatedFiles } from "@/lib/utils"; -import { mapTagToFileType } from "@/components/tv/TvShowPanelUtils"; -import type { UIMediaFolderStatus } from "@/types/UIMediaFolder"; -import type { UIRenameFilesPlan } from "@/types/UIRenameFilesPlan"; -import { mediaFilePathEqual } from "@smm/core/pipeline/mediaFilePathEqual"; -import Debug from 'debug' -const debug = Debug('buildTvShowEpisodeTableRows') -const FOLDER_FILE_IDS: UIMediaFileFolderRow["id"][] = ["clearlogo", "fanart", "poster", "theme", "nfo"] - -/** - * Result of building episode rows: rows to render plus the episodes that should - * be pre-checked for the current plan / preview. Selection state itself stays - * in the caller; this only describes the derived default. - */ -export interface BuiltTvShowEpisodeTableRows { - rows: UIMediaFileTableRow[] - defaultChecked: UIMediaEpisodeSelection[] -} - -/** Episodes that currently have a linked video file (base default selection). */ -function episodesWithVideoFile(rows: UIMediaFileTableRow[]): UIMediaEpisodeSelection[] { - const out: UIMediaEpisodeSelection[] = [] - for (const row of rows) { - if (row.type === "episode" && row.videoFile !== undefined) { - out.push({ season: row.season, episode: row.episode }) - } - } - return out -} - -function matchFolderFile(files: string[], id: UIMediaFileFolderRow["id"]): string | undefined { - if (!files.length) return undefined - if (id === "nfo") { - return files.find((f) => basename(f) === "tvshow.nfo") - } - const prefix = `${id}.` - return files.find((f) => { - const name = basename(f) - return name != null && name.startsWith(prefix) - }) -} - -/** - * Build rows for fanart, poster, theme, nfo files - * @param files - * @returns - */ -function buildFolderFileRows(files: string[]): UIMediaFileFolderRow[] { - - const rows: UIMediaFileFolderRow[] = [] - for (const id of FOLDER_FILE_IDS) { - const path = matchFolderFile(files, id) - if (path) rows.push({ id, type: "folderFile", path }) - } - - debug(`buildFolderFileRows RETURNED: %O`, rows) - - return rows -} - -export function buildTvShowEpisodeTableRows( - mm: MediaMetadata, - uiStatus: UIMediaFolderStatus, - t: (key: string) => string, - folderFiles: string[] = [], -): UIMediaFileTableRow[] { - const rows: UIMediaFileTableRow[] = [] - - if (uiStatus === "initializing") { - return [{ - id: "initializing", - type: "divider", - text: t ? t('mediaFolder.initializing') : "Initializing", - }] - } - - if (uiStatus === "folder_not_found") { - return [{ - id: "folder_not_found", - type: "divider", - text: t ? t('mediaFolder.folderNotFound') : "Folder not found", - }] - } - - if (uiStatus === "error_loading_metadata") { - return [{ - id: "error_loading_metadata", - type: "divider", - text: t ? t('mediaFolder.errorLoadingMetadata') : "Error loading metadata", - }] - } - - const folderFileRows = folderFiles.length > 0 && mm.mediaFolderPath - ? buildFolderFileRows(folderFiles) - : [] - if (folderFileRows.length > 0) { - rows.push(...folderFileRows) - } - - if (mm.tvShow !== undefined) { - debug(`use tmdbTvShow to build episode table rows`) - const rowsFromTmdbTvShow = _buildTvShowEpisodeTableRowsFromTmdb(mm, folderFiles) - rows.push(...rowsFromTmdbTvShow) - return rows; - } - - debug(`empty tmdbTvShow and tvdbTvShow, return empty rows`) - return rows -} - -export function _buildTvShowEpisodeTableRowsFromTmdb(_in_mm: MediaMetadata, folderFiles: string[] = []) { - - const rows: UIMediaFileTableRow[] = [] - - if (!_in_mm.tvShow) { - return rows - } - - // Process each season and episode directly from tmdbTvShow - for (const season of _in_mm.tvShow.seasons || []) { - const seasonNo = season.season - const seasonText = season.name || `Season ${seasonNo}` - rows.push({ - id: `season-${seasonNo}`, - type: "divider", - text: seasonText, - }) - - for (const episode of season.episodes || []) { - const episodeNo = episode.episode - - // Find the media file for this episode - const mediaFile = _in_mm.mediaFiles?.find( - file => file.seasonNumber === seasonNo && file.episodeNumber === episodeNo - ) - - let videoFile: { path: string; newPath?: string } | undefined - let thumbnailFile: { path: string; newPath?: string } | undefined - let subtitleFile: { path: string; newPath?: string } | undefined - let nfoFile: { path: string; newPath?: string } | undefined - - if (mediaFile) { - videoFile = { - path: mediaFile.absolutePath, - newPath: undefined - } - - if (_in_mm.mediaFolderPath && folderFiles.length > 0) { - const associatedFiles = findAssociatedFiles(_in_mm.mediaFolderPath, folderFiles, mediaFile.absolutePath) - - for (const file of associatedFiles) { - const filePath = join(_in_mm.mediaFolderPath, file.path) - const fileType = mapTagToFileType(file.tag) - - switch (fileType) { - case 'poster': - thumbnailFile = { path: filePath } - break - case 'subtitle': - subtitleFile = { path: filePath } - break - case 'nfo': - nfoFile = { path: filePath } - break - } - } - } - } - - rows.push({ - season: seasonNo, - episode: episodeNo, - type: "episode", - videoFile: videoFile?.path, - thumbnail: thumbnailFile?.path, - subtitle: subtitleFile?.path, - nfo: nfoFile?.path, - episodeTitle: episode.name ?? "", - newVideoFile: videoFile?.newPath, - newThumbnail: thumbnailFile?.newPath, - newSubtitle: subtitleFile?.newPath, - newNfo: nfoFile?.newPath, - }) - } - } - - return rows; -} - -export function _buildTvShowEpisodeTableRowsFromTvdb(_in_mm: MediaMetadata, folderFiles: string[] = []) { - - const rows: UIMediaFileTableRow[] = [] - - if(!_in_mm.tvShow || !_in_mm.tvShow.seasons) { - return rows; - } - - // Process each season and episode directly from tmdbTvShow - for (const season of _in_mm.tvShow.seasons || []) { - const seasonNo = season.season - const seasonText = season.name || `Season ${seasonNo}` - rows.push({ - id: `season-${seasonNo}`, - type: "divider", - text: seasonText, - }) - - for (const episode of season.episodes || []) { - const episodeNo = episode.episode - - // Find the media file for this episode - const mediaFile = _in_mm.mediaFiles?.find( - file => file.seasonNumber === seasonNo && file.episodeNumber === episodeNo - ) - - let videoFile: { path: string; newPath?: string } | undefined - let thumbnailFile: { path: string; newPath?: string } | undefined - let subtitleFile: { path: string; newPath?: string } | undefined - let nfoFile: { path: string; newPath?: string } | undefined - - if (mediaFile) { - videoFile = { - path: mediaFile.absolutePath, - newPath: undefined - } - - if (_in_mm.mediaFolderPath && folderFiles.length > 0) { - const associatedFiles = findAssociatedFiles(_in_mm.mediaFolderPath, folderFiles, mediaFile.absolutePath) - - for (const file of associatedFiles) { - const filePath = join(_in_mm.mediaFolderPath, file.path) - const fileType = mapTagToFileType(file.tag) - - switch (fileType) { - case 'poster': - thumbnailFile = { path: filePath } - break - case 'subtitle': - subtitleFile = { path: filePath } - break - case 'nfo': - nfoFile = { path: filePath } - break - } - } - } - } - - rows.push({ - season: seasonNo, - episode: episodeNo, - type: "episode", - videoFile: videoFile?.path, - thumbnail: thumbnailFile?.path, - subtitle: subtitleFile?.path, - nfo: nfoFile?.path, - episodeTitle: episode.name ?? "", - newVideoFile: videoFile?.newPath, - newThumbnail: thumbnailFile?.newPath, - newSubtitle: subtitleFile?.newPath, - newNfo: nfoFile?.newPath, - }) - } - } - - return rows -} - -export function buildTvShowEpisodeTableRowsForPlan( - mm: MediaMetadata, - uiStatus: UIMediaFolderStatus, - plan: UIRenameFilesPlan | UIRecognizeMediaFilePlan, - t: (key: string) => string, - folderFiles: string[] = [], -): BuiltTvShowEpisodeTableRows { - - if (uiStatus === "initializing") { - return { - rows: [{ - id: "initializing", - type: "divider", - text: t ? t('mediaFolder.initializing') : "Initializing", - }], - defaultChecked: [], - } - } - - if (uiStatus === "folder_not_found") { - return { - rows: [{ - id: "folder_not_found", - type: "divider", - text: t ? t('mediaFolder.folderNotFound') : "Folder not found", - }], - defaultChecked: [], - } - } - - if (uiStatus === "error_loading_metadata") { - return { - rows: [{ - id: "error_loading_metadata", - type: "divider", - text: t ? t('mediaFolder.errorLoadingMetadata') : "Error loading metadata", - }], - defaultChecked: [], - } - } - - const rows: UIMediaFileTableRow[] = buildTvShowEpisodeTableRows(mm, uiStatus, t, folderFiles) - - if(plan.task === "recognize-media-file") { - if(plan.status === 'preparing') { - return { rows, defaultChecked: episodesWithVideoFile(rows) } - } - - return fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan(rows, plan) - - } else if(plan.task === "rename-files") { - return fillTvShowEpisodeTableRowByRenameFilesPlan(rows, plan) - } - - debug(`buildTvShowEpisodeTableRowsForPlan RETURNED: %O`, rows) - - return { rows, defaultChecked: episodesWithVideoFile(rows) } -} - -/** - * Builds the rows shown by the TV show panel together with the episodes that - * should be pre-checked. Selection defaults are co-located with row building; - * the selection state itself lives in the caller (TvShowPanel). - */ -export function buildTvShowEpisodeTableRowsForPanel( - mm: MediaMetadata, - uiStatus: UIMediaFolderStatus, - plan: UIRenameFilesPlan | UIRecognizeMediaFilePlan | undefined, - t: (key: string) => string, - folderFiles: string[] = [], -): BuiltTvShowEpisodeTableRows { - - if (plan === undefined) { - // No plan → no preview checkboxes; no episodes are pre-selected. - return { - rows: buildTvShowEpisodeTableRows(mm, uiStatus, t, folderFiles), - defaultChecked: [], - } - } - - return buildTvShowEpisodeTableRowsForPlan(mm, uiStatus, plan, t, folderFiles) -} - -export function fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan( - _in_rows: UIMediaFileTableRow[], - plan: UIRecognizeMediaFilePlan, -): BuiltTvShowEpisodeTableRows { - - const rows = structuredClone(_in_rows) as UIMediaFileTableRow[] - const defaultChecked: UIMediaEpisodeSelection[] = [] - const planFilesByKey = new Map( - plan.files.map((file) => [`${file.season}:${file.episode}`, file] as const), - ) - - for (const row of rows) { - if (row.type !== 'episode') { - continue - } - - const recognizedFile = planFilesByKey.get(`${row.season}:${row.episode}`) - - if (recognizedFile) { - const existingVideoFile = row.videoFile - const planPath = recognizedFile.path - row.videoFile = planPath - row.newVideoFile = undefined - - const unchanged = existingVideoFile != null - && planPath != null - && mediaFilePathEqual(existingVideoFile, planPath) - - if (unchanged) { - row.disabled = true - } else { - row.disabled = false - if (planPath !== undefined) { - defaultChecked.push({ season: row.season, episode: row.episode }) - } - } - } else { - row.disabled = true - } - } - - for (const recognizedFile of plan.files) { - const row = rows.find( - (r) => r.type === 'episode' && r.season === recognizedFile.season && r.episode === recognizedFile.episode, - ) - if (!row) { - console.warn( - `recognized video file ${recognizedFile.path} for season ${recognizedFile.season} episode ${recognizedFile.episode} but not found in episode table rows`, - ) - } - } - - return { rows, defaultChecked } -} - -export function fillTvShowEpisodeTableRowByRenameFilesPlan( - _in_rows: UIMediaFileTableRow[], - plan: UIRenameFilesPlan, -): BuiltTvShowEpisodeTableRows { - const rows = structuredClone(_in_rows) as UIMediaFileTableRow[] - const defaultChecked: UIMediaEpisodeSelection[] = [] - const renameFiles = plan.files - - for (const row of rows) { - if (row.type !== "episode") { - continue - } - row.newVideoFile = undefined - row.disabled = undefined - } - - for(const renameFile of renameFiles) { - for(const row of rows) { - - if(row.type !== "episode") { - continue; - } - - if(row.videoFile === renameFile.from) { - row.newVideoFile = renameFile.to; - row.disabled = false; - if (!defaultChecked.some( - (e) => e.season === row.season && e.episode === row.episode, - )) { - defaultChecked.push({ season: row.season, episode: row.episode }) - } - } - - } - } - - for (const row of rows) { - if (row.type !== "episode" || !row.videoFile) { - continue - } - if (!row.newVideoFile) { - row.disabled = true - } - } - - return { rows, defaultChecked }; -} \ No newline at end of file diff --git a/apps/ui/src/stores/tvShowPromptsStore.ts b/apps/ui/src/stores/tvShowPromptsStore.ts index c78abd36..6342bdf7 100644 --- a/apps/ui/src/stores/tvShowPromptsStore.ts +++ b/apps/ui/src/stores/tvShowPromptsStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' import type { TMDBTVShow, TMDBTVShowDetails } from '@smm/types' -import type { UIRecognizeMediaFilePlan } from '@/types/UIRecognizeMediaFilePlan' +import type { RecognizeMediaFilePlan } from '@smm/types/RecognizeMediaFilePlan' interface ToolbarOption { value: "plex" | "emby" @@ -33,7 +33,7 @@ interface RuleBasedRecognizePromptData { tvShowTitle: string | undefined tvShowTmdbId: number | undefined planId: string | undefined - onConfirm: ((plan: UIRecognizeMediaFilePlan) => void) | undefined + onConfirm: ((plan: RecognizeMediaFilePlan) => void) | undefined onCancel: (() => void) | undefined } @@ -67,7 +67,7 @@ interface TvShowPromptsState { tvShowTitle: string tvShowTmdbId: number planId?: string - onConfirm?: (plan: UIRecognizeMediaFilePlan) => void + onConfirm?: (plan: RecognizeMediaFilePlan) => void onCancel?: () => void }) => void diff --git a/apps/ui/src/types/UIRecognizeMediaFilePlan.ts b/apps/ui/src/types/UIRecognizeMediaFilePlan.ts deleted file mode 100644 index de3d61d6..00000000 --- a/apps/ui/src/types/UIRecognizeMediaFilePlan.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { RecognizeMediaFilePlan } from '@smm/types/RecognizeMediaFilePlan'; - -/** - * @deprecated Use {@link RecognizeMediaFilePlan} directly. The UI no - * longer adds `tmp`/`loading`; the `preparing` status models the - * "being computed" phase. Kept as an alias to avoid churn. - */ -export type UIRecognizeMediaFilePlan = RecognizeMediaFilePlan; diff --git a/docs/superpowers/design/movie-panel-missing-associated-files/context.md b/docs/superpowers/design/movie-panel-missing-associated-files/context.md deleted file mode 100644 index ffdfe146..00000000 --- a/docs/superpowers/design/movie-panel-missing-associated-files/context.md +++ /dev/null @@ -1,140 +0,0 @@ -# MoviePanel 视频文件行不显示关联文件 - -## Background - -`MoviePanel` 在功能探索阶段引入了 `isUseMediaFileTableEnabled` 特性开关(`useFeatures`),启用后渲染新增的 ``(基于 `UIMediaFileTable`),未启用时仍使用复用自 `TvShowPanel` 的 ``(见 `docs/superpowers/design/movie-panel-reuse-tv-table.md`)。 - -两个分支最终都会调用同一个适配器 `buildMovieEpisodeTableRows`(`apps/ui/src/lib/buildMovieEpisodeTableRows.ts`),把 movie 类型的 `MediaMetadata` 适配成 "S01E01 单集" 的 `TvShowEpisodeTableRow[]`,交给表格组件渲染。 - -用户反馈:MoviePanel 中**视频文件行(episode row)的 thumbnail / subtitle / nfo 列都为空**(勾选标记为 `MinusIcon`,表示无关联)。电影目录里只有 `poster.*` / `fanart.*` / `movie.nfo` 三类 folder-level 资产,没有与视频同 stem 的 `*.srt` / `*.nfo`。 - -## Goal - -排查 MoviePanel 视频文件行不显示关联文件列的原因:定位是 `buildMovieEpisodeTableRows` 适配器未产出关联文件路径,还是 `MediaFileTable` / `TvShowEpisodeTable` 渲染层未读取数据,抑或 column visibility 默认隐藏。 - -预期行为(参照 TvShowPanel):视频行能正确反映已识别的关联文件(subtitle / nfo / poster),即便电影目录下没有 stem-matched 的关联文件,也应该把 folder-level 的 `poster.jpg` / `movie.nfo` 关联到行内对应列,或至少呈现合理状态。 - -## Code Flow - -### 1. MoviePanel 数据装配 - -`apps/ui/src/components/movie/MoviePanel.tsx`: - -- `useMediaMetadataQuery(selectedFolder)` 拉取后端 metadata(TanStack Query,缓存键基于 POSIX path)。 -- `mediaMetadata` 经 `findMediaFilesForMovieMediaMetadata` (`apps/ui/src/helpers/movie/MovieMediaMetadataUtils.ts`) 加工:扫描 `mm.files`,按 `videoFileExtensions` 过滤出视频文件,构造 `mm.mediaFiles = [{ absolutePath }]`(注意 Movie 不带 `seasonNumber/episodeNumber`,与 TvShow 不同)。 -- 同步触发 `buildMovieFilesFromMediaMetadata` (`apps/ui/src/helpers/movie/buildMovieFilesFromMediaMetadata.ts`) 维护 `movieFiles` 状态,供 `RuleBasedRenameFilePrompt` 重命名预览使用。 -- 表格数据 `tableData` 由 `buildMovieEpisodeTableRows(mediaMetadata, folderStatus, t, { renamePreview })` 生成(`apps/ui/src/lib/buildMovieEpisodeTableRows.ts`)。 - -### 2. buildMovieEpisodeTableRows 适配器 - -`apps/ui/src/lib/buildMovieEpisodeTableRows.ts`: - -1. 短路空态:`initializing` / `folder_not_found` / `error_loading_metadata` / 无 video 文件 → 返回单个 divider。 -2. Folder-level 行:扫描 `mm.files`,**仅**把以 `poster.` / `fanart.` 开头的文件和名为 `movie.nfo` 的文件作为 `folderFile` 行(参考 `apps/ui/src/lib/buildTvShowEpisodeTableRows.ts:31-42` 的 `buildFolderFileRows`,但 ID 集合更窄,只支持 `poster/fanart/nfo`,不支持 `clearlogo/theme`)。 -3. 推一个 `divider: { id: "movie", text: "Movie" }`,对应 `_buildTvShowEpisodeTableRowsFromTmdb` 中的 season divider。 -4. 关键步骤:从 `mm.mediaFiles[0]` 取出 `videoFile.absolutePath`,调用 `findAssociatedFiles(mediaFolderPath, mm.files, videoFile.absolutePath)` 检索关联文件: - - `findAssociatedFiles` (`apps/ui/src/lib/utils.ts:80-118`) 用 **严格 stem 匹配**: - - 取 `videoFilePath` 的 basename → 去扩展名 → `filenameWithoutExtension` - - 候选名 = `${filenameWithoutExtension}.srt` / `${filenameWithoutExtension}.zh-CN.srt` / `${filenameWithoutExtension}.jpg` 等 - - 必须以 `{stem}.` 开头并以已知扩展名结尾 - - 返回 `[POSTER, SUB, AUD, NFO]` 列表 -5. 把第一个 `POSTER` → `row.thumbnail`、第一个 `SUB` → `row.subtitle`、第一个 `NFO` → `row.nfo`。 -6. `subtitle` 兜底:若 `findAssociatedFiles` 没找到,且 `videoFile.subtitleFilePaths[0]` 存在,则使用之(仅 subtitle 有此兜底,thumbnail / nfo 没有)。 -7. 推一条 `TvShowEpisodeDataRow`(`season=1, episode=1, videoFile, thumbnail, subtitle, nfo, episodeTitle=movie.name`)。 - -**问题点 A**:`findAssociatedFiles` 只匹配与视频文件 stem 一致的关联文件。用户的目录是 `Movie (2024).mkv` + `Movie.srt` + `poster.jpg` + `movie.nfo`,因为字幕 stem 不匹配,subtitle 拿不到(且用户也无 stem-matched 字幕);poster 作为 folder-level 行被识别,但 episode row 的 `thumbnail` 仍为 `undefined`,因为 `findAssociatedFiles` 没有命中任何 `Movie (2024).*`。 - -**问题点 B**:TvShow adapter (`buildTvShowEpisodeTableRows`) 走相同的 `findAssociatedFiles` 路径,但 TvShow 每集视频文件名(如 `S01E01.mkv`)通常与字幕 / NFO stem 一致(如 `S01E01.srt`、`S01E01.nfo`),所以 TvShowPanel 正常显示。Movie 命名场景天然更松散 → stem 匹配经常失败。 - -### 3. 表格组件渲染 - -两套组件共用同一份 row schema: - -#### 3a. ``(feature flag 关闭分支) - -`apps/ui/src/components/tv/TvShowEpisodeTable.tsx`: - -- Props 类型 `TvShowEpisodeTableRow = DividerRow | DataRow | FolderFileRow`(与适配器输出兼容)。 -- DataRow 直接渲染 `thumbnail` / `subtitle` / `nfo` 三列,使用 ``:`value !== undefined` 时显示绿色 CheckIcon,否则 MinusIcon。 -- FolderFileRow 把 `poster` / `fanart` / `movie.nfo` 渲染为独立行,仅在 video 列展示 path。 - -#### 3b. ``(feature flag 启用分支) - -`apps/ui/src/components/media/MediaFileTable.tsx` + `UIMediaFileTable.tsx`: - -- 同样的行 schema(`UIMediaFileTableRow` 类型在 `UIMediaFileTable.tsx:90`,与 `TvShowEpisodeTableRow` 形状一致)。 -- `` (`mediaFileTableColumns.tsx:80-131`) 渲染 episode 行的 thumbnail / subtitle / nfo 三列。 -- DataRow 的 subtitle/nfo 用 `` (`MediaFileTableRow.tsx:95-108`):value 非 undefined → CheckIcon,undefined → MinusIcon。 -- 详情布局(detail / preview)下 thumbnail 列显示真实缩略图(`UIThumbnailImage` + HoverCard);simple 布局下 thumbnail 列也走 ``。 -- Column visibility:默认 `defaultColumnVisibility = { video: true, thumbnail: true, subtitle: true, nfo: true }`(`UIMediaFileTable.tsx:179-184`)。用户可以通过表头右键菜单切换列显隐,状态保存在 `UIMediaFileTable` 的 `columnVisibility` state 中。 - -**问题点 C**:`` / `` 内部维护 `columnVisibility` 状态。如果 MoviePanel 切换 feature flag 时多次创建/销毁组件实例,状态不持久化,但不会把列隐藏成"看不到"——默认就是全显示。 - -### 4. MoviePanel 渲染分支 - -`MoviePanel.tsx:349-369`: - -```tsx -{folderStatus === "initializing" ? ( - -) : isUseMediaFileTableEnabled ? ( - -) : ( - -)} -``` - -`tableData` 通过 `tableData as UIMediaFileTableRow[]` 断言后传入两个分支。`TvShowEpisodeTableRow` 与 `UIMediaFileTableRow` 结构兼容(divider / episode / folderFile),所以类型断言是合理的。 - -### 5. 数据来源对照(媒体识别阶段) - -`MediaFileMetadata` (`packages/core/types.ts:506-530`): -- `absolutePath: string` -- `seasonNumber? / episodeNumber?`(仅 TV show) -- `subtitleFilePaths?: string[]`(识别后由后端填充) -- `audioFilePaths?: string[]` -- ❌ **没有** `thumbnailFilePaths` 字段 - -后端识别(movie 流程)只会把 `subtitleFilePaths` / `audioFilePaths` 写回 `mediaFiles[i]`,封面 / NFO 仍依赖前端 `findAssociatedFiles` 扫描 `mm.files`。 - -## Files to Investigate - -| 文件 | 关注点 | -|---|---| -| `apps/ui/src/lib/buildMovieEpisodeTableRows.ts` | `findAssociatedFiles` 调用、subtitle 兜底、thumbnail/nfo 无兜底 | -| `apps/ui/src/lib/utils.ts:80-118` | `findAssociatedFiles` 的 stem 匹配逻辑;movie 命名场景天然不命中 | -| `apps/ui/src/lib/buildTvShowEpisodeTableRows.ts:90-168` | TV show adapter 同问题对照,是否有更宽松的兜底 | -| `apps/ui/src/components/movie/MoviePanel.tsx:313-318` | `tableData` 是否被正确传入 | -| `apps/ui/src/components/media/MediaFileTable.tsx` / `UIMediaFileTable.tsx` | column visibility 默认值与渲染分支 | -| `apps/ui/src/components/tv/TvShowEpisodeTable.tsx` | 与 MediaFileTable 对照,确认 TvShowEpisodeTable 渲染没有遗漏 | -| `apps/ui/src/hooks/useFeatures.ts` | `isUseMediaFileTableEnabled` 默认值与持久化(用户当前分支) | -| `apps/ui/src/helpers/movie/MovieMediaMetadataUtils.ts` | 确认 `mediaFiles` 是否被正确填入(必要前提) | - -## Reproduction Hints - -最小复现场景(与用户描述一致): -- 文件夹路径:`/` -- 文件清单: - - `/Movie (2024).mkv`(video) - - `/poster.jpg` - - `/fanart.jpg` - - `/movie.nfo` -- 期望:episode row 的 `thumbnail` 列显示 poster(无论作为缩略图或勾标记);folder-level `poster` / `fanart` / `movie.nfo` 三行独立渲染;`subtitle` / `nfo` 列因不存在可为空。 -- 实际:episode row 的 `thumbnail / subtitle / nfo` 全空(MinusIcon)。 - -可补充复现场景: -- 同目录额外有 `Movie.srt`(与视频 stem 不同)→ 仍应进入 subtitle 列,但当前实现会漏掉,因为 stem 匹配不命中,且 `mediaFileMetadata.subtitleFilePaths` 仅在识别阶段由后端填入。 -- 同目录额外有 `Movie (2024).srt` → 应进入 subtitle 列(命中 stem 匹配),当前实现能正确显示。 diff --git a/docs/superpowers/design/tvshow-panel-ui-metadata-deprecation.md b/docs/superpowers/design/tvshow-panel-ui-metadata-deprecation.md deleted file mode 100644 index e554aeff..00000000 --- a/docs/superpowers/design/tvshow-panel-ui-metadata-deprecation.md +++ /dev/null @@ -1,310 +0,0 @@ -> **Scope addendum (mid-implementation)**: `buildTemporaryRecognitionPlanAsync` in `apps/ui/src/components/TvShowPanelUtils.ts` was discovered to take `UIMediaMetadata` but read no `status` fields, and is only called from `useRuleBasedRecognizeFlow` and its unit test. Narrowing its signature to `MediaMetadata` is necessary to keep the call from `useRuleBasedRecognizeFlow` type-correct after Task 3. Added to scope. - -# TvShowPanel UIMediaMetadata 废弃 - -## Checklist - -- [x] New UI component - 无 -- [x] New user config - 无 -- [x] Electron only - 否 -- [x] User document - 否 - -## Status - -**完成** — TvShowPanel 调用树内 11 个文件已彻底脱离 `UIMediaMetadata`: - -| 文件 | 变化 | -|------|------| -| `apps/ui/src/components/TvShowPanel.tsx` | `mediaMetadata` 收敛为 `MediaMetadata \| undefined`; `uiStatus` 单独 `useMemo` 合成; 渲染分支改用 `uiStatus === "initializing"`; 移除内联 `import("@core/types").*` | -| `apps/ui/src/hooks/useSubtitleFlow.ts` | options 增加 `uiStatus: UIMediaFolderStatus \| undefined`; 移除 `resolveOkMediaMetadata` 内 `"status" in mediaMetadata` 判断 | -| `apps/ui/src/components/hooks/useRuleBasedRecognizeFlow.ts` | options 增加 `uiStatus`; `okMediaMetadata = uiStatus === "ok" ? mediaMetadata : undefined` | -| `apps/ui/src/components/hooks/useRuleBasedRenameFilesFlow.ts` | `mediaMetadata: MediaMetadata \| undefined` | -| `apps/ui/src/components/hooks/useAiBasedRenameFilesFlow.ts` | `mediaMetadata: MediaMetadata \| undefined` | -| `apps/ui/src/components/hooks/useAiBasedRecognizeFlow.ts` | `mediaMetadata: MediaMetadata \| undefined` | -| `apps/ui/src/components/hooks/useSelectAndUnselectFileFlow.ts` | `mediaMetadata: MediaMetadata \| undefined`; `requireMediaMetadata` 返回 `MediaMetadata` | -| `apps/ui/src/components/hooks/useTvShowPanelState.ts` | `mediaMetadata: MediaMetadata \| undefined` | -| `apps/ui/src/components/TvShowHeaderV2.tsx` | `selectedMediaMetadata?: MediaMetadata`; `openScrape` 同步收紧 | -| `apps/ui/src/lib/buildTvShowEpisodeTableRows.ts` | 3 种空态 divider 改由 `uiStatus: UIMediaFolderStatus` 参数短路; 形参 `mm: MediaMetadata` | -| `apps/ui/src/helpers/TvShowPanel/handleEpisodeFileSelect.ts` | `mm: MediaMetadata`; 返回 `MediaMetadata` | -| `apps/ui/src/components/TvShowPanelUtils.ts` | `buildTemporaryRecognitionPlanAsync` 形参 `mm: MediaMetadata`(范围扩) | -| `apps/ui/src/lib/buildTvShowEpisodeTableRows.test.ts` | 所有调用点增加 `uiStatus` 参数; `UIMediaMetadata` 移除 | -| `apps/ui/src/components/TvShowHeaderV2.test.tsx` | `UIMediaMetadata` → `MediaMetadata`; 移除多余 `status: 'ok'` 字段 | -| `apps/ui/src/components/TvShowPanelUtils.test.ts` | `buildTemporaryRecognitionPlanAsync` 测试用 `MediaMetadata` | - -验证: - -- `pnpm run typecheck:ui` 通过 -- `pnpm run test:ui` 通过 (1374 tests passed, 23 skipped, 0 failed) -- `grep -rn "UIMediaMetadata"` 在 11 个 TvShowPanel 调用树文件内无输出 - - -## 范围 - -| 范围 | 入选 | -|------|------| -| TvShowPanel 调用树 | TvShowPanel.tsx + 8 处直接下游 | -| 类型 | `UIMediaMetadata` 保留并标记 deprecated(其他调用方继续使用) | -| 邻近 Panel(MoviePanel / Library / LocalFilePanel) | **不在本次范围** | - -## 1. Background - -`apps/ui/src/types/UIMediaMetadata.ts` 是 `MediaMetadata & { status: UIMediaFolderStatus, test? }` 的已废弃组合类型(源头标注 `@deprecated`),它把"领域元数据"与"UI 加载状态"混在同一个对象里。`UIMediaFolder`(来自 `uiMediaFolderStore`)已经独立承担 folder 维度的状态。 - -`TvShowPanel` 内部把 `useMediaMetadataQuery` 的结果与 `uiFolderRow.status` 拼装成 `UIMediaMetadata`,再向下游 8 处传入。下游中只有 `useSubtitleFlow` / `useRuleBasedRecognizeFlow` / `buildTvShowEpisodeTableRows*` 真正读取 `status`,其余都只用 `MediaMetadata` 字段。 - -本次目标: - -- TvShowPanel 内部停止构造 `UIMediaMetadata`。 -- `uiStatus` 改由 `uiFolderRow.status` + 局部 fetch 状态合成,保持现有 5 档优先级。 -- TvShowPanel 调用树内 8 处下游同步收紧到 `MediaMetadata` + `uiStatus`,使 TvShowPanel 不再 import `UIMediaMetadata`。 -- `UIMediaMetadata` 类型本身保留,其他调用方(MoviePanel / AppV2 / LocalFilePanel / 初始化流程)继续使用。 - -## 2. Project Level Architecture - -无架构变更。仅 `apps/ui` 内部类型与签名收敛。 - -```mermaid -flowchart LR - Q["useMediaMetadataQuery"] -->|"MediaMetadata"| TVP[TvShowPanel] - FS["uiMediaFolderStore.folders"] -->|"UIMediaFolder"| TVP - TVP -->|"uiStatus"| US[useSubtitleFlow] - TVP -->|"MediaMetadata"| RR[useRuleBasedRenameFilesFlow] - TVP -->|"MediaMetadata + uiStatus"| RC[useRuleBasedRecognizeFlow] - TVP -->|"MediaMetadata"| AR[useAiBasedRenameFilesFlow] - TVP -->|"MediaMetadata"| AC[useAiBasedRecognizeFlow] - TVP -->|"MediaMetadata"| SF[useSelectAndUnselectFileFlow] - TVP -->|"MediaMetadata"| ST[useTvShowPanelState] - TVP -->|"MediaMetadata + UIMediaFolder"| HD[TvShowHeaderV2] - TVP -->|"MediaMetadata + uiStatus"| BTR[buildTvShowEpisodeTableRows*] -``` - -## 3. App Level Architecture - -### 3.1 TvShowPanel 内部状态 - -`mediaMetadata` 收敛为 `MediaMetadata | undefined`(= `queriedMediaMetadata`),不再做"无 domain 时构造占位对象"的拼装。`uiStatus` 单独计算,沿用现有 5 档优先级: - -```ts -const uiStatus: UIMediaFolderStatus = (() => { - if (isMediaMetadataError) return "error_loading_metadata" - if (mediaMetadata) return "ok" - if (isMediaMetadataPending || mediaMetadataFetchStatus === "fetching") return "initializing" - return uiFolderRow?.status ?? "loading" -})() -``` - -### 3.2 渲染分支 - -- `{ uiStatus === "initializing" ? : }` -- `buildTvShowEpisodeTableRows(mediaMetadata, uiStatus, t)` 改为 `buildTvShowEpisodeTableRowsForPlan(mediaMetadata, uiStatus, plan, t)`,传入显式 `uiStatus` 而非从对象上读 `status`。 -- `useEffect` 依赖列表增加 `uiStatus`。 - -### 3.3 8 处下游签名变更 - -| 文件 | 现状 | 改后 | -|------|------|------| -| `apps/ui/src/hooks/useSubtitleFlow.ts` | `mediaMetadata: MediaMetadata \| UIMediaMetadata \| undefined`,用 `"status" in mediaMetadata && mediaMetadata.status !== "ok"` 判 ok | `mediaMetadata: MediaMetadata \| undefined` + `uiStatus: UIMediaFolderStatus \| undefined`,判 ok 改为 `uiStatus === "ok"` | -| `apps/ui/src/components/hooks/useRuleBasedRenameFilesFlow.ts` | `mediaMetadata: UIMediaMetadata \| undefined` | `mediaMetadata: MediaMetadata \| undefined` | -| `apps/ui/src/components/hooks/useRuleBasedRecognizeFlow.ts` | `mediaMetadata: UIMediaMetadata \| undefined`,`mediaMetadata?.status === "ok"` 判 ok | `mediaMetadata: MediaMetadata \| undefined` + `uiStatus: UIMediaFolderStatus \| undefined`;`okMediaMetadata` 改为 `uiStatus === "ok" ? mediaMetadata : undefined` | -| `apps/ui/src/components/hooks/useAiBasedRenameFilesFlow.ts` | `mediaMetadata: UIMediaMetadata \| undefined` | `mediaMetadata: MediaMetadata \| undefined` | -| `apps/ui/src/components/hooks/useAiBasedRecognizeFlow.ts` | `mediaMetadata: UIMediaMetadata \| undefined` | `mediaMetadata: MediaMetadata \| undefined` | -| `apps/ui/src/components/hooks/useSelectAndUnselectFileFlow.ts` | `mediaMetadata: UIMediaMetadata \| undefined`(含 `requireMediaMetadata` 返回 `UIMediaMetadata`) | `mediaMetadata: MediaMetadata \| undefined`,`requireMediaMetadata` 返回 `MediaMetadata` | -| `apps/ui/src/components/hooks/useTvShowPanelState.ts` | `mediaMetadata: UIMediaMetadata \| undefined` | `mediaMetadata: MediaMetadata \| undefined` | -| `apps/ui/src/components/TvShowHeaderV2.tsx` | `selectedMediaMetadata?: UIMediaMetadata`,`openScrape?: (params: { mediaMetadata: UIMediaMetadata })` | `selectedMediaMetadata?: MediaMetadata`,`openScrape?: (params: { mediaMetadata: MediaMetadata })` | - -### 3.4 lib 签名变更 - -`apps/ui/src/lib/buildTvShowEpisodeTableRows.ts`: - -| 旧签名 | 新签名 | -|--------|--------| -| `buildTvShowEpisodeTableRows(mm: UIMediaMetadata, t)` | `buildTvShowEpisodeTableRows(mm: MediaMetadata, uiStatus: UIMediaFolderStatus, t)` | -| `buildTvShowEpisodeTableRowsForPlan(mm: UIMediaMetadata, plan, t)` | `buildTvShowEpisodeTableRowsForPlan(mm: MediaMetadata, uiStatus: UIMediaFolderStatus, plan, t)` | -| `_buildTvShowEpisodeTableRowsFromTmdb(_in_mm: UIMediaMetadata)` | `_buildTvShowEpisodeTableRowsFromTmdb(_in_mm: MediaMetadata)` | -| `_buildTvShowEpisodeTableRowsFromTvdb(_in_mm: UIMediaMetadata)` | `_buildTvShowEpisodeTableRowsFromTvdb(_in_mm: MediaMetadata)` | - -3 种空态 divider(`initializing` / `folder_not_found` / `error_loading_metadata`)由调用方传入的 `uiStatus` 短路返回,不再从对象上读 `status`。`fillTvShowEpisodeTableRowByRecognizeMediaFilesPlan` 与 `fillTvShowEpisodeTableRowByRenameFilesPlan` 不读 `status`,签名不变。 - -### 3.5 helper 签名变更 - -`apps/ui/src/helpers/TvShowPanel/handleEpisodeFileSelect.ts`: - -- `handleEpisodeFileSelect(mm: UIMediaMetadata, ...): UIMediaMetadata` → `(mm: MediaMetadata, ...): MediaMetadata` -- 不读 `status`;`{ ...mm, mediaFiles: updatedMediaFiles }` 直接展开为 `MediaMetadata` 即可。 - -## 4. User Stories - -无新增用户故事。本次为内部类型清理,行为完全保持。 - -## 5. Tasks - -### 5.1 修改 TvShowPanel 内部状态合成 - -- [x] Task 1 — 修改 `apps/ui/src/components/TvShowPanel.tsx` - - [x] 删除 `import type { UIMediaMetadata } from "@/types/UIMediaMetadata"` - - [x] 删除构造 `UIMediaMetadata` 的 `useMemo`,改写为: - - [x] `mediaMetadata: MediaMetadata | undefined = queriedMediaMetadata` - - [x] `uiStatus: UIMediaFolderStatus` 按 3.1 合成 - - [x] 渲染分支改为 `uiStatus === "initializing"` - - [x] 所有下游 hook 调用传入 `(mediaMetadata, uiStatus)` 形式 - - [x] `useEffect` 依赖列表增加 `uiStatus` - - [x] 移除内联 `import("@core/types").*` 改用顶部 `import type { MediaMetadata, TMDBTVShow, TMDBTVShowDetails }` - -### 5.2 修改 useSubtitleFlow - -- [x] Task 2 — 修改 `apps/ui/src/hooks/useSubtitleFlow.ts` - - [x] `UseSubtitleFlowOptions` 增加 `uiStatus: UIMediaFolderStatus | undefined` - - [x] `mediaMetadata` 类型从 `MediaMetadata | UIMediaMetadata | undefined` 改为 `MediaMetadata | undefined` - - [x] 删除 `resolveOkMediaMetadata` 内 `"status" in mediaMetadata && mediaMetadata.status !== "ok"` 判断,改为 `uiStatus === "ok"` - - [x] `mediaMetadataForTranscribeRows` 不再需要"接受 UIMediaMetadata"分支,签名收敛 - -### 5.3 修改 useRuleBasedRecognizeFlow - -- [x] Task 3 — 修改 `apps/ui/src/components/hooks/useRuleBasedRecognizeFlow.ts` - - [x] `UseRuleBasedRecognizeFlowOptions` 增加 `uiStatus: UIMediaFolderStatus | undefined` - - [x] `mediaMetadata` 类型从 `UIMediaMetadata | undefined` 改为 `MediaMetadata | undefined` - - [x] `okMediaMetadata` 改为 `uiStatus === "ok" ? mediaMetadata : undefined` - - [x] 依赖列表增加 `uiStatus` - -### 5.4 修改其余无 status 依赖的下游 hook - -- [x] Task 4 — 修改 `apps/ui/src/components/hooks/useRuleBasedRenameFilesFlow.ts` - - [x] `mediaMetadata: UIMediaMetadata | undefined` → `mediaMetadata: MediaMetadata | undefined` -- [x] Task 5 — 修改 `apps/ui/src/components/hooks/useAiBasedRenameFilesFlow.ts` - - [x] `mediaMetadata: UIMediaMetadata | undefined` → `mediaMetadata: MediaMetadata | undefined` -- [x] Task 6 — 修改 `apps/ui/src/components/hooks/useAiBasedRecognizeFlow.ts` - - [x] `mediaMetadata: UIMediaMetadata | undefined` → `mediaMetadata: MediaMetadata | undefined` -- [x] Task 7 — 修改 `apps/ui/src/components/hooks/useSelectAndUnselectFileFlow.ts` - - [x] `mediaMetadata: UIMediaMetadata | undefined` → `mediaMetadata: MediaMetadata | undefined` - - [x] `requireMediaMetadata` 返回 `MediaMetadata | undefined` -- [x] Task 8 — 修改 `apps/ui/src/components/hooks/useTvShowPanelState.ts` - - [x] `mediaMetadata: UIMediaMetadata | undefined` → `mediaMetadata: MediaMetadata | undefined` - -### 5.5 修改 TvShowHeaderV2 - -- [x] Task 9 — 修改 `apps/ui/src/components/TvShowHeaderV2.tsx` - - [x] `selectedMediaMetadata?: UIMediaMetadata` → `selectedMediaMetadata?: MediaMetadata` - - [x] `openScrape?: (params: { mediaMetadata: UIMediaMetadata })` → `(params: { mediaMetadata: MediaMetadata })` - - [x] 删除 `import type { UIMediaMetadata }` - - [x] 增加 `import type { MediaMetadata } from "@core/types"` - -### 5.6 修改 buildTvShowEpisodeTableRows - -- [x] Task 10 — 修改 `apps/ui/src/lib/buildTvShowEpisodeTableRows.ts` - - [x] 删除 `import type { UIMediaMetadata }`,增加 `import type { MediaMetadata } from "@core/types"` 与 `import type { UIMediaFolderStatus } from "@/types/UIMediaFolder"` - - [x] `buildTvShowEpisodeTableRows(mm, t)` → `buildTvShowEpisodeTableRows(mm, uiStatus, t)`:3 种空态判断改为基于 `uiStatus` - - [x] `buildTvShowEpisodeTableRowsForPlan(mm, plan, t)` → `buildTvShowEpisodeTableRowsForPlan(mm, uiStatus, plan, t)`:3 种空态判断改为基于 `uiStatus` - - [x] `_buildTvShowEpisodeTableRowsFromTmdb` / `_buildTvShowEpisodeTableRowsFromTvdb` 形参从 `UIMediaMetadata` 改为 `MediaMetadata` - - [x] 保留 `FOLDER_FILE_IDS` 常量 - -### 5.7 修改 handleEpisodeFileSelect - -- [x] Task 11 — 修改 `apps/ui/src/helpers/TvShowPanel/handleEpisodeFileSelect.ts` - - [x] `(mm: UIMediaMetadata, ...): UIMediaMetadata` → `(mm: MediaMetadata, ...): MediaMetadata` - - [x] 删除 `import type { UIMediaMetadata }` - -### 5.8 修改相关单元测试 - -- [x] Task 12 — 修改 `apps/ui/src/lib/buildTvShowEpisodeTableRows.test.ts` - - [x] 调用处 `buildTvShowEpisodeTableRows(mm, t)` / `buildTvShowEpisodeTableRowsForPlan(mm, plan, t)` 增加 `uiStatus` 参数 - - [x] 形参为 `UIMediaMetadata` 的 mock 改为 `MediaMetadata`(删除 `status` 字段) -- [x] Task 13 — 修改 `apps/ui/src/components/TvShowHeaderV2.test.tsx` 与 `TvShowPanelUtils.test.ts` - - [x] `TvShowHeaderV2.test.tsx`:`UIMediaMetadata` → `MediaMetadata`;移除多余 `status: 'ok'` 字段 - - [x] `TvShowPanelUtils.test.ts`:`buildTemporaryRecognitionPlanAsync` 测试改用 `MediaMetadata` - - MovieHeaderV2 不在本次范围(未修改) - -### 5.9 验证 - -- [x] Task 14 — 类型检查 `pnpm run typecheck:ui` 通过(其他包预存在错误已通过 stash 验证未引入) -- [x] Task 15 — UI 单元测试 `pnpm test` 通过(1374 passed, 23 skipped, 0 failed) -- [x] Task 16 — 搜索 `apps/ui/src/components/TvShowPanel.tsx` 及 TvShowPanel 调用树 11 个文件,UIMediaMetadata 均无输出 - -## 6. Backward Compatibility - -- `UIMediaMetadata` 类型保留在 `apps/ui/src/types/UIMediaMetadata.ts`,`@deprecated` 标记维持。 -- 8 处下游签名为破坏性变更(仅影响 TvShowPanel 调用方,库 lib 调用方在 tvshow 调用树内)。 -- MoviePanel / AppV2 / LocalFilePanel / 初始化流程 / tests 不变;`UIMediaMetadata` 在 apps/ui 中仍有合法引用。 -- 用户视角行为完全保持:`uiStatus` 5 档优先级逐字还原旧 `useMemo`。 - -## 7. Documents - -实测: -- [x] `apps/ui/src/types/UIMediaMetadata.ts` 类型与 `extractUIMediaMetadataProps` 维持现状 -- [x] `docs/api/index.md` 不变更 -- [x] `docs/superpowers/design/episode-rename-recognize.md` 不变更 -- [x] `pnpm run typecheck:ui` 通过 -- [x] `pnpm test` 通过 (1374 passed, 23 skipped, 0 failed) -- [x] `grep -n "UIMediaMetadata" apps/ui/src/components/TvShowPanel.tsx` 应无输出 -- [x] `grep -n "UIMediaMetadata" apps/ui/src/components/TvShowHeaderV2.tsx` 应无输出 -- [x] `grep -rn "UIMediaMetadata" apps/ui/src/components/hooks/` 应无输出 -- [x] `grep -n "UIMediaMetadata" apps/ui/src/lib/buildTvShowEpisodeTableRows.ts` 应无输出 -- [x] `grep -n "UIMediaMetadata" apps/ui/src/helpers/TvShowPanel/handleEpisodeFileSelect.ts` 应无输出 -- [x] `grep -n "UIMediaMetadata" apps/ui/src/hooks/useSubtitleFlow.ts` 应无输出 - - -## 8. Post Verification - -实测: - -- [x] `pnpm run typecheck:ui` 通过(`apps/ui` 0 errors) -- [x] `pnpm build` 通过(cli + ui) -- [x] `pnpm test` 通过(1374 passed, 23 pre-existing skipped, 0 failed) -- [x] `pnpm run typecheck:core-routes` / `typecheck:e2e` 仍报 9 / 39 个 pre-existing errors(已通过 `git stash` 验证未引入新错误) -- [x] `grep -n "UIMediaMetadata" apps/ui/src/components/TvShowPanel.tsx` 无输出 -- [x] `grep -n "UIMediaMetadata" apps/ui/src/components/TvShowHeaderV2.tsx` 无输出 -- [x] `grep -rn "UIMediaMetadata" apps/ui/src/components/hooks/` 无输出 -- [x] `grep -n "UIMediaMetadata" apps/ui/src/lib/buildTvShowEpisodeTableRows.ts` 无输出 -- [x] `grep -n "UIMediaMetadata" apps/ui/src/helpers/TvShowPanel/handleEpisodeFileSelect.ts` 无输出 -- [x] `grep -n "UIMediaMetadata" apps/ui/src/hooks/useSubtitleFlow.ts` 无输出 - -## 9. 实施备注 -- `buildTemporaryRecognitionPlanAsync` 以 `UIMediaMetadata` 为形参但不读 `status` 字段,仅由 `useRuleBasedRecognizeFlow` 与其单元测试调用,已一并收敛到 `MediaMetadata`。 -- 实施中重新引入 `FOLDER_FILE_IDS` 常量(某次替换中被误删)。 -- 库外其他类型清理(`MoviePanel` / `AppV2` / `LocalFilePanel`)按设计不在本次范围,`UIMediaMetadata` 类型本身保留 `@deprecated` 标记。 - - -## 10. 实施阶段 — 构建错误修复(`pnpm build` 12 errors → 0) - -构建阶段发现 12 个类型错误,按错误类别分 7 组修复: - -1. **TvShowPanel 重复 import**:合并 `MediaMetadata` 重复声明为 1 处。 -2. **buildTvShowEpisodeTableRows `rows` undefined**:`_buildTvShowEpisodeTableRowsFromTvdb` 缺少 `const rows: TvShowEpisodeTableRow[] = []` 局部声明(与 `_buildTvShowEpisodeTableRowsFromTmdb` 对称),恢复。 -3. **out-of-scope 3 个 action/lib 实际不读 `status`,按 design 一致原则一并收敛到 `MediaMetadata`**: - - `apps/ui/src/actions/handleAiRecognizeConfirm.ts` — `mediaMetadata: MediaMetadata` - - `apps/ui/src/components/TvShowPanelUtils.ts` — `HandlePendingPlansParams.mediaMetadata: MediaMetadata | undefined` - - `apps/ui/src/actions/handleRenamePromptConfirmForTvShow.ts` — `mediaMetadata: MediaMetadata`(含 `selectedEpisodePaths: string[]` 重写回 options 类型) - - `apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.ts` — `metadata: MediaMetadata`(仅 spread + 2 字段重写,与 `MediaMetadata` 完全兼容) - - `applyRenamePairsToUIMediaMetadata.test.ts` — `UIMediaMetadata` 改 `MediaMetadata`,删除 `status: 'ok'` 字段 -4. **useAiBasedRenameFilesFlow** — `mediaMetadata?.status === "ok" ? (mediaMetadata as MediaMetadata) : undefined` 现在 `mediaMetadata` 已是 `MediaMetadata`(无 `status`),简化为直接传 `mediaMetadata`(`useTvShowWebSocketEvents` 签名已是 `MediaMetadata | undefined`)。 -5. **useSelectAndUnselectFileFlow** — `t` 类型不匹配 `unlinkEpisode` 的 `(key: string, options?: Record) => string`,改用既有 `castTranslationFn` helper(`apps/ui/src/lib/i18n.ts`)桥接。 -6. **MoviePanel** — 同名 `useSubtitleFlow` 调用补传 `uiStatus: rawMediaMetadata?.status`(最小改动:MoviePanel 整体不在本次范围,但 `useSubtitleFlow` 签名变更是其上游,必须联动)。 -7. **pkill / restore missing line**:在重构 `handleRenamePromptConfirmForTvShow` 签名时漏写 `selectedEpisodePaths: string[]`,已恢复。 - -`UIMediaMetadata` 类型本身保持 `@deprecated`,文件保留。 - -## 11. 实施后最终文件清单(含构建修复新增) - -| 文件 | 变化 | -|------|------| -| `apps/ui/src/components/TvShowPanel.tsx` | 修改 | -| `apps/ui/src/hooks/useSubtitleFlow.ts` | 修改 | -| `apps/ui/src/components/hooks/useRuleBasedRecognizeFlow.ts` | 修改 | -| `apps/ui/src/components/hooks/useRuleBasedRenameFilesFlow.ts` | 修改 | -| `apps/ui/src/components/hooks/useAiBasedRenameFilesFlow.ts` | 修改 | -| `apps/ui/src/components/hooks/useAiBasedRecognizeFlow.ts` | 修改 | -| `apps/ui/src/components/hooks/useSelectAndUnselectFileFlow.ts` | 修改 | -| `apps/ui/src/components/hooks/useTvShowPanelState.ts` | 修改 | -| `apps/ui/src/components/TvShowHeaderV2.tsx` | 修改 | -| `apps/ui/src/lib/buildTvShowEpisodeTableRows.ts` | 修改 | -| `apps/ui/src/helpers/TvShowPanel/handleEpisodeFileSelect.ts` | 修改 | -| `apps/ui/src/components/TvShowPanelUtils.ts` | 修改(`buildTemporaryRecognitionPlanAsync` 范围扩) | -| `apps/ui/src/lib/buildTvShowEpisodeTableRows.test.ts` | 修改 | -| `apps/ui/src/components/TvShowHeaderV2.test.tsx` | 修改 | -| `apps/ui/src/components/TvShowPanelUtils.test.ts` | 修改(`buildTemporaryRecognitionPlanAsync` 测试) | -| `apps/ui/src/actions/handleAiRecognizeConfirm.ts` | 修改(`mediaMetadata: MediaMetadata`,构建修复 3) | -| `apps/ui/src/actions/handleRenamePromptConfirmForTvShow.ts` | 修改(`mediaMetadata: MediaMetadata`,构建修复 3) | -| `apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.ts` | 修改(`metadata: MediaMetadata`,构建修复 3) | -| `apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.test.ts` | 修改(`MediaMetadata`,构建修复 3) | -| `apps/ui/src/components/MoviePanel.tsx` | 修改(`useSubtitleFlow` 补传 `uiStatus`,构建修复 6) | - -合计 20 个文件修改,无新增文件。`UIMediaMetadata` 类型未删除。 diff --git a/knip.json b/knip.json new file mode 100644 index 00000000..3eaa24e7 --- /dev/null +++ b/knip.json @@ -0,0 +1,94 @@ +{ + "$schema": "./node_modules/knip/schema.json", + "ignoreWorkspaces": [ + "apps/ohos" + ], + "ignore": [ + ".agents/**", + "apps/site/**", + "apps/cli/public/**", + "apps/electron/build/**", + "apps/ui/src/components/ui/**", + "packages/electron-common/dist/**", + "packages/electron-common/ohos/**" + ], + "ignoreBinaries": [ + "vitest", + "eslint", + "vite", + "electron-vite", + "pino-pretty", + "ffmpeg", + "hdc" + ], + "workspaces": { + ".": { + "entry": ["ci/**/*.ts", "scripts/**/*.ts"] + }, + "apps/cli": { + "entry": ["index.ts", "scripts/**/*.ts", "src/**/*.test.ts", "test/**/*.test.ts"] + }, + "apps/core": { + "entry": ["src/**/*.test.ts"] + }, + "apps/ui": { + "entry": [ + "index.html", + ".storybook/**/*.{ts,tsx}", + "src/**/*.stories.tsx", + "src/**/*.test.ts", + "src/**/*.test.tsx" + ] + }, + "apps/electron": { + "entry": ["src/main/index.ts", "src/preload/index.ts"] + }, + "apps/e2e": { + "entry": [ + "wdio.conf.ts", + "wdio.conf.test.ts", + "cli/**/*.test.ts", + "common/**/*.e2e.ts", + "scenarios/**/*.ts", + "test/**/*.ts", + "ohos/**/*.ts", + "electron/**/*.ts", + "docker/**/*.{ts,mjs}" + ], + "ignore": ["common/manual/**"], + "ignoreDependencies": [ + "test/pageobjects/page", + "test/lib/testbed", + "test/lib/env", + "test/actions/import-folders", + "test/componentobjects/Sidebar", + "@wdio/html-nice-reporter" + ] + }, + "apps/convex": { + "entry": ["convex/**/*.ts"] + }, + "apps/cicd": { + "entry": ["run.ts", "src/**/*.ts", "test/**/*.test.ts"] + }, + "apps/tools": { + "entry": ["**/*.ts"] + }, + "packages/types": { + "entry": ["**/*.test.ts"] + }, + "packages/utils": { + "entry": ["src/**/*.test.ts"] + }, + "packages/core-routes": { + "entry": ["src/**/*.test.ts"] + }, + "packages/tvdb4": { + "entry": ["src/**/*.test.ts", "test/**/*.ts"] + }, + "packages/electron-common": { + "entry": ["src/**/*.test.ts"] + }, + "packages/test": {} + } +} diff --git a/package.json b/package.json index 8c652dd0..7d59341b 100644 --- a/package.json +++ b/package.json @@ -77,13 +77,15 @@ "changeset": "changeset", "changeset:version": "changeset version && pnpm i && pnpm run build:cli", "changeset:publish": "changeset publish", - "query-network-log": "bun apps/tools/query-network-log.ts" + "query-network-log": "bun apps/tools/query-network-log.ts", + "knip": "knip" }, "devDependencies": { "@changesets/cli": "^2.29.8", "@modelcontextprotocol/inspector": "^2.3.0", "concurrently": "^9.2.1", "cross-env": "^7.0.3", + "knip": "^6.34.0", "npm-run-all2": "^8.0.4" } } diff --git a/packages/core-routes/src/auth.test.ts b/packages/core-routes/src/auth.test.ts index 313fa728..656ce56c 100644 --- a/packages/core-routes/src/auth.test.ts +++ b/packages/core-routes/src/auth.test.ts @@ -1,4 +1,3 @@ -import type { ServerResponse } from "node:http"; import { describe, expect, it, vi } from "vitest"; import { enforceCoreRoutesAuth, diff --git a/packages/core-routes/src/chat.ts b/packages/core-routes/src/chat.ts index a73076de..fe565ce4 100644 --- a/packages/core-routes/src/chat.ts +++ b/packages/core-routes/src/chat.ts @@ -24,7 +24,7 @@ import { CREATE_RECOGNIZE_EPISODE_PLAN } from "@smm/types/ai-tools/createRecogni import type { IncomingMessage, ServerResponse } from "node:http"; import { createChatTools, defaultChatFs } from "./tools/index.ts"; import { sendJson } from "./http.ts"; -import type { CoreRoutesConfig, RouteContext, RouteHandler } from "./types.ts"; +import type { RouteContext } from "./types.ts"; import type { ChatConfig, ChatFs, ChatRequestBody } from "./chatTypes.ts"; import type { ChatToolsExtraDeps } from "./tools/index.ts"; diff --git a/packages/core-routes/src/chatTypes.ts b/packages/core-routes/src/chatTypes.ts index f7cfc41d..361c7429 100644 --- a/packages/core-routes/src/chatTypes.ts +++ b/packages/core-routes/src/chatTypes.ts @@ -1,4 +1,3 @@ -import type { IncomingMessage, ServerResponse } from "node:http"; import type { UserConfig } from "@smm/types"; import type { WebSocketMessage } from "./socketIO/types.ts"; import type { CoreRoutesLogger } from "./types.ts"; diff --git a/packages/core-routes/src/downloadImageAsFile.test.ts b/packages/core-routes/src/downloadImageAsFile.test.ts index f1af829e..0252693b 100644 --- a/packages/core-routes/src/downloadImageAsFile.test.ts +++ b/packages/core-routes/src/downloadImageAsFile.test.ts @@ -1,4 +1,3 @@ -import { Buffer } from "node:buffer"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, sep } from "node:path"; diff --git a/packages/core-routes/src/mcp/toolHandlers/getEpisodes.ts b/packages/core-routes/src/mcp/toolHandlers/getEpisodes.ts index a2ba9943..3836dac4 100644 --- a/packages/core-routes/src/mcp/toolHandlers/getEpisodes.ts +++ b/packages/core-routes/src/mcp/toolHandlers/getEpisodes.ts @@ -44,7 +44,7 @@ export function registerGetEpisodesTool( ); } try { - const userConfig = await config.getUserConfig(); + await config.getUserConfig(); const syntheticConfig: CoreRoutesConfig = { allowlist: [], hello: { diff --git a/packages/core-routes/src/tools/createRecognizeEpisodePlan.ts b/packages/core-routes/src/tools/createRecognizeEpisodePlan.ts index a98fb08f..c67e76e2 100644 --- a/packages/core-routes/src/tools/createRecognizeEpisodePlan.ts +++ b/packages/core-routes/src/tools/createRecognizeEpisodePlan.ts @@ -1,5 +1,4 @@ import { createRecognizeEpisodePlanPipeline } from "@smm/core/createRecognizeEpisodePlan"; -import type { FsPort } from "@smm/core/FsPort"; import { Path } from "@smm/utils/path"; import { AI_AGENT_PERMISSIONS, diff --git a/packages/core-routes/src/tools/createRenameEpisodePlan.test.ts b/packages/core-routes/src/tools/createRenameEpisodePlan.test.ts index 937d9afd..86bc0284 100644 --- a/packages/core-routes/src/tools/createRenameEpisodePlan.test.ts +++ b/packages/core-routes/src/tools/createRenameEpisodePlan.test.ts @@ -3,7 +3,6 @@ import { CREATE_RENAME_EPISODE_PLAN } from "@smm/types/ai-tools/createRenameEpis import { AI_AGENT_PERMISSIONS, type UserConfig } from "@smm/types"; import { END_PLAN_TASK_SUCCESS_MESSAGE, - RENAME_PLAN_AUTO_APPLIED_MESSAGE, } from "@smm/types/ai-tools/planTaskMessages"; import { MEDIA_METADATA_UPDATED_EVENT, diff --git a/packages/core-routes/src/tools/createRenameEpisodePlan.ts b/packages/core-routes/src/tools/createRenameEpisodePlan.ts index f6a0c236..308d0834 100644 --- a/packages/core-routes/src/tools/createRenameEpisodePlan.ts +++ b/packages/core-routes/src/tools/createRenameEpisodePlan.ts @@ -1,5 +1,4 @@ import { createRenameEpisodePlanPipeline } from "@smm/core/createRenameEpisodePlan"; -import type { FsPort } from "@smm/core/FsPort"; import { Path } from "@smm/utils/path"; import { AI_AGENT_PERMISSIONS, diff --git a/packages/core-routes/src/tools/listFilesInMediaFolder.ts b/packages/core-routes/src/tools/listFilesInMediaFolder.ts index bfbf9ade..200425fd 100644 --- a/packages/core-routes/src/tools/listFilesInMediaFolder.ts +++ b/packages/core-routes/src/tools/listFilesInMediaFolder.ts @@ -15,7 +15,6 @@ import { } from "@smm/types/ai-tools/listFilesInMediaFolder"; import type { UserConfig } from "@smm/types"; import { doListFiles } from "../listFiles.ts"; -import { isMediaFolderManaged } from "../userConfig.ts"; /** * Pure execution of `listFilesInMediaFolder`. Mirrors the diff --git a/packages/core-routes/src/tools/plans.ts b/packages/core-routes/src/tools/plans.ts index 08565af0..2eaacba4 100644 --- a/packages/core-routes/src/tools/plans.ts +++ b/packages/core-routes/src/tools/plans.ts @@ -31,7 +31,6 @@ export function planFilePath(appDataDir: string, planId: string): string { async function ensurePlansDirExists( appDataDir: string, - fs: ChatFs, ): Promise { const dir = plansDir(appDataDir); try { @@ -168,7 +167,7 @@ export async function createPlan( input: CreatePlanInput, fs: ChatFs, ): Promise { - await ensurePlansDirExists(appDataDir, fs); + await ensurePlansDirExists(appDataDir); const id = input.id ?? randomUUID(); const mediaFolderPath = Path.posix(input.mediaFolderPath); const plan: AnyPlan = diff --git a/packages/core-routes/src/tools/renameFolder.ts b/packages/core-routes/src/tools/renameFolder.ts index 67bf63d6..ac9ce978 100644 --- a/packages/core-routes/src/tools/renameFolder.ts +++ b/packages/core-routes/src/tools/renameFolder.ts @@ -1,4 +1,3 @@ -import { Path } from "@smm/utils/path"; import { buildRenameFolderConfirmationMessage } from "@smm/core/ai-tool/renameFolderConfirm"; import { renameFolderCancelled, diff --git a/packages/core-routes/src/tools/tmdb.ts b/packages/core-routes/src/tools/tmdb.ts index bd02bce0..e98dbac2 100644 --- a/packages/core-routes/src/tools/tmdb.ts +++ b/packages/core-routes/src/tools/tmdb.ts @@ -3,7 +3,6 @@ import type { TmdbMovieDetails, TmdbSearchResponseBody, TmdbSeriesDetails } from import { formatTmdbToolError, toTmdbCoreOptions, - type TmdbToolHostOptions, } from "@smm/types/ai-tools/tmdbCommon"; import { TMDB_SEARCH, diff --git a/packages/core-routes/src/tools/tvdb.ts b/packages/core-routes/src/tools/tvdb.ts index d40062c6..44fd433e 100644 --- a/packages/core-routes/src/tools/tvdb.ts +++ b/packages/core-routes/src/tools/tvdb.ts @@ -2,7 +2,6 @@ import { requireNonEmptyString } from "@smm/core/ai-tool/toolResult"; import { formatTvdbToolError, toTvdbCoreOptions, - type TvdbToolHostOptions, } from "@smm/types/ai-tools/tvdbCommon"; import { TVDB_SEARCH, diff --git a/packages/core-routes/src/tools/types.ts b/packages/core-routes/src/tools/types.ts index 74347e4c..4f15bf55 100644 --- a/packages/core-routes/src/tools/types.ts +++ b/packages/core-routes/src/tools/types.ts @@ -1,5 +1,3 @@ -import type { z } from "zod"; - /** * Shape of an agent tool built for AI SDK's `streamText` `tools` map. * diff --git a/packages/core-routes/tsconfig.json b/packages/core-routes/tsconfig.json index 350431bf..e241e3dc 100644 --- a/packages/core-routes/tsconfig.json +++ b/packages/core-routes/tsconfig.json @@ -12,6 +12,8 @@ "skipLibCheck": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "baseUrl": ".", "paths": { "@smm/types": ["../types/types.ts"], diff --git a/packages/electron-common/tsconfig.json b/packages/electron-common/tsconfig.json index 4c336ba6..12b9b136 100644 --- a/packages/electron-common/tsconfig.json +++ b/packages/electron-common/tsconfig.json @@ -11,7 +11,9 @@ "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true }, "include": ["./src/**/*.ts"], "exclude": ["node_modules"] diff --git a/packages/test/tsconfig.json b/packages/test/tsconfig.json index 025022e0..53716eae 100644 --- a/packages/test/tsconfig.json +++ b/packages/test/tsconfig.json @@ -10,6 +10,8 @@ "esModuleInterop": true, "allowSyntheticDefaultImports": true, "forceConsistentCasingInFileNames": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "baseUrl": ".", "paths": { "@smm/types": ["../types/types.ts"], diff --git a/packages/tvdb4/src/client.test.ts b/packages/tvdb4/src/client.test.ts index a30183ce..f9ca4a3c 100644 --- a/packages/tvdb4/src/client.test.ts +++ b/packages/tvdb4/src/client.test.ts @@ -107,7 +107,7 @@ describe("TVDBv4 client", () => { const resp = await client.search({ query: "foo", - type: "tv", + type: "series", language: "eng", page: 2, limit: 10, @@ -194,7 +194,7 @@ describe("TVDBv4 client", () => { it("error propagation: throws TVDBv4Error on non-2xx responses", async () => { const baseUrl = "https://example.com/v4"; - const fetchImpl = vi.fn(async (input: string, init?: any) => { + const fetchImpl = vi.fn(async (input: string, _init?: any) => { if (input === `${baseUrl}/login`) { return makeFetchResponse({ ok: true, @@ -219,7 +219,7 @@ describe("TVDBv4 client", () => { const client = new TVDBv4({ apiKey: "api-key", baseUrl, fetchImpl }); try { - await client.search({ query: "foo", type: "tv" }); + await client.search({ query: "foo", type: "series" }); throw new Error("Expected search() to throw"); } catch (err) { expect(err).toBeInstanceOf(TVDBv4Error); diff --git a/packages/tvdb4/tsconfig.json b/packages/tvdb4/tsconfig.json index bb5b82b7..2ea41f0b 100644 --- a/packages/tvdb4/tsconfig.json +++ b/packages/tvdb4/tsconfig.json @@ -10,6 +10,8 @@ "esModuleInterop": true, "allowSyntheticDefaultImports": true, "forceConsistentCasingInFileNames": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "types": ["vitest/globals"], "baseUrl": "." }, diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json index 319e8c85..7f91f17f 100644 --- a/packages/types/tsconfig.json +++ b/packages/types/tsconfig.json @@ -11,6 +11,8 @@ "skipLibCheck": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "baseUrl": "." }, "include": ["./**/*.ts"], diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json index bd5f5e68..f0d8888d 100644 --- a/packages/utils/tsconfig.json +++ b/packages/utils/tsconfig.json @@ -11,6 +11,8 @@ "noEmit": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "types": [], "baseUrl": ".", "paths": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24eb3794..b403bb44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,13 +16,16 @@ importers: version: 2.29.8(@types/node@26.2.0) '@modelcontextprotocol/inspector': specifier: ^2.3.0 - version: 2.3.0(@modelcontextprotocol/sdk@1.27.0(zod@4.3.6))(@types/node@26.2.0)(@types/react@19.2.14)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(express@5.2.1)(jiti@2.6.1)(react-dom@19.2.4(react@19.2.4))(tsx@4.21.0) + version: 2.3.0(@modelcontextprotocol/sdk@1.27.0(zod@4.3.6))(@types/node@26.2.0)(@types/react@19.2.14)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(express@5.2.1)(jiti@2.7.0)(react-dom@19.2.4(react@19.2.4))(tsx@4.21.0) concurrently: specifier: ^9.2.1 version: 9.2.1 cross-env: specifier: ^7.0.3 version: 7.0.3 + knip: + specifier: ^6.34.0 + version: 6.34.0 npm-run-all2: specifier: ^8.0.4 version: 8.0.4 @@ -162,7 +165,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) apps/convex: dependencies: @@ -193,7 +196,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) apps/docker: devDependencies: @@ -303,10 +306,10 @@ importers: devDependencies: '@electron-toolkit/eslint-config-prettier': specifier: ^3.0.0 - version: 3.0.0(eslint@9.39.3(jiti@2.6.1))(prettier@3.8.1) + version: 3.0.0(eslint@9.39.3(jiti@2.7.0))(prettier@3.8.1) '@electron-toolkit/eslint-config-ts': specifier: ^3.1.0 - version: 3.1.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + version: 3.1.0(eslint@9.39.3(jiti@2.7.0))(typescript@5.9.3) '@electron-toolkit/tsconfig': specifier: ^2.0.0 version: 2.0.0(@types/node@22.19.11) @@ -321,10 +324,10 @@ importers: version: 26.8.1(electron-builder-squirrel-windows@26.8.1) electron-vite: specifier: ^5.0.0 - version: 5.0.0(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 5.0.0(vite@7.3.1(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) eslint: specifier: ^9.39.1 - version: 9.39.3(jiti@2.6.1) + version: 9.39.3(jiti@2.7.0) prettier: specifier: ^3.7.4 version: 3.8.1 @@ -333,10 +336,10 @@ importers: version: 5.9.3 vite: specifier: ^7.2.6 - version: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + version: 7.3.1(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) apps/ohos: dependencies: @@ -355,7 +358,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) apps/tools: devDependencies: @@ -472,7 +475,7 @@ importers: version: link:../../packages/utils '@tailwindcss/vite': specifier: ^4.1.17 - version: 4.2.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.2.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/react-query': specifier: ^5.96.2 version: 5.96.2(react@19.2.4) @@ -608,10 +611,10 @@ importers: version: 10.3.6(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) '@storybook/addon-docs': specifier: ^10.3.6 - version: 10.3.6(@types/react@19.2.14)(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 10.3.6(@types/react@19.2.14)(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) '@storybook/react-vite': specifier: ^10.3.6 - version: 10.3.6(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 10.3.6(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.9.1 @@ -632,7 +635,7 @@ importers: version: 15.5.13 '@vitejs/plugin-react': specifier: ^5.1.1 - version: 5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/coverage-istanbul': specifier: ^4.0.18 version: 4.0.18(vitest@4.0.18) @@ -647,13 +650,13 @@ importers: version: 1.0.0 eslint: specifier: ^9.39.1 - version: 9.39.3(jiti@2.6.1) + version: 9.39.3(jiti@2.7.0) eslint-plugin-react-hooks: specifier: ^7.0.1 - version: 7.0.1(eslint@9.39.3(jiti@2.6.1)) + version: 7.0.1(eslint@9.39.3(jiti@2.7.0)) eslint-plugin-react-refresh: specifier: ^0.4.24 - version: 0.4.26(eslint@9.39.3(jiti@2.6.1)) + version: 0.4.26(eslint@9.39.3(jiti@2.7.0)) fake-indexeddb: specifier: ^6.2.5 version: 6.2.5 @@ -674,13 +677,13 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.46.4 - version: 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + version: 8.56.1(eslint@9.39.3(jiti@2.7.0))(typescript@5.9.3) vite: specifier: ^7.2.4 - version: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + version: 7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) packages/core-routes: dependencies: @@ -732,7 +735,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) packages/e2e-test-base-image: {} @@ -749,7 +752,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) packages/test: dependencies: @@ -783,7 +786,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) packages/types: dependencies: @@ -796,7 +799,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) packages/utils: dependencies: @@ -821,7 +824,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) packages: @@ -1237,6 +1240,15 @@ packages: engines: {node: '>=14.14'} hasBin: true + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -2295,6 +2307,13 @@ packages: resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==} engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.2.3': + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2335,9 +2354,237 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@oxc-parser/binding-android-arm-eabi@0.147.0': + resolution: {integrity: sha512-fOtoGvIoirkvxQVw9J1WJPxz571XPgLsPf9uhRD+PJteUnvrJHMDmK9pw2yZEGGyismtRoEsp+JcXUdF/JDMDw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.147.0': + resolution: {integrity: sha512-emjQHOYJaomo4ykaXQ1EItunr/I94Nk01oqBmU4dSkKSTupIDx6OysVDf2e8Eytm77rb+4ZxzgElyWP7rcEX7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.147.0': + resolution: {integrity: sha512-kXvBPJL7RmDPJ2mze/vXPPVQimCDtFr9OFLjf7dyhV5Dx64cgcXh9KKrA1sMWvCObvJll9CZZUO0FBlFwD0l6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.147.0': + resolution: {integrity: sha512-mgFF8pLU6R64LbT27lSrtVRspVC/3IcZ0qyIikzmi78Y3Ik2OPnlAHHI0UEBRcC3qmNgtjaef7zkFt7/uPxIcw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.147.0': + resolution: {integrity: sha512-v38aiF11qufOTBcCAKL4skgQf0zJ4NEvRlivq7B5kHrlyvjCLjvNrMtNWDTz1SDUL6/xVsJRmLDxv2e+Cp4oWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.147.0': + resolution: {integrity: sha512-AeIiBbwUaP0H1+4/qGW9l5qHecS/+XA5iMuieVcGb1T+tyc2dVGspFW13BWk/XrLsiGP/CiDJTJqAPLLCzZHkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.147.0': + resolution: {integrity: sha512-/41MKPW4RgPY4DJco0NCF0RYX3IMZaVlRNMNzvhaxRavc7tN3Txm+qllZbh0aMRs0VHdgUlbI8TcAOiTai4TKg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.147.0': + resolution: {integrity: sha512-bmpw/RPhVXgZbtb3xBDuwW5s8+LvZYdqcDSX/sP2ltL77aTio3DP/B5ZTwwgoJ6Mr9vJs4RrmgEKW9XkLNUU1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.147.0': + resolution: {integrity: sha512-gd7VX/FDVOw6mjQcu45iIcp4QkgybgJwh3a0OFG2NxmPCj628mQWD96QGu1kK8+mZF9qK4b/gIEyC63vQoB7+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.147.0': + resolution: {integrity: sha512-HnAzcfki7dSUNHf510Q2NmbJlz8Ys7rn8l9l588Pkx0tYe1BHLZnmELIgqizJ4WPhHGSwN8Ce+B/menVxS3odA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.147.0': + resolution: {integrity: sha512-qlkOL6wT44U+fT5s/+sR6Shx0OdwvQF83JyIPZUxG/ovqZF5/7atOtjH+JPZ5/7ATQLbFBBSmghcy/+2NVB/ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.147.0': + resolution: {integrity: sha512-DlefD7L7sMXs/3hIBH23Egk0phj8kG0SA81dVGOQ3S1ekjOlmTLH2E+F2Thwfh1slKx6aH+lNc5fQYAw0GU7/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.147.0': + resolution: {integrity: sha512-Xqpagk/031IvZ4svrk2FF01YEqM/iN3MJV3SVZadKg/CsGlDCGoREqKHXYnoV5+8SfGe/m6RM1szXFLusTu/Uw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.147.0': + resolution: {integrity: sha512-QioQOeUbI4ATUr0S2z88uA3Cds2R3Mm5Ge7U8XNYtlTb2GJF3rWlcj70z0AJhhOlbdm0YgVjqPBldUNbFylDIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.147.0': + resolution: {integrity: sha512-NXy1tv/OdC+pPTwf9RiCZWPK53V/Xq/2cjSnjOSyKopajdaDqMIkgtDY+jXZemp2e8px5FeWfY2L2LwhKZQovg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.147.0': + resolution: {integrity: sha512-GpGWZ6oKz4bjCWW9Mz5pCaGPyk2Aaze6zEoaslIQqpSLtpx5pXj/ap5gUNb5Jn2LIbqWyjkGLX9yv3NMuNcBVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-win32-arm64-msvc@0.147.0': + resolution: {integrity: sha512-a8mlt7CC8z7LUdCfaxhff4kCd+vSjE+NEFL0cxA8ukfuSnvAto/pWTjytW4BuVLnQGcCVdHJwcRKOfs++H+tjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.147.0': + resolution: {integrity: sha512-M5ViVDBcFLnl2632AuuWuP35zEL5oikK1jTx8r3+902VEDeSNHxFZAB6RZyfZ7MU6Oi5QTOG8MmMkIeHsudSaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.147.0': + resolution: {integrity: sha512-DUaE13OwnUSlHpLZNcC/nuT10ivlWqc5EZgsfgXuAmWYw0r3nDxGeLD1zlGwYwIgVk1/ZMAxoXpV+05stvbHaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/types@0.146.0': resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==} + '@oxc-project/types@0.147.0': + resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + cpu: [x64] + os: [win32] + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -3670,6 +3917,9 @@ packages: '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -5463,6 +5713,9 @@ packages: fault@1.0.4: resolution: {integrity: sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==} + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} @@ -5569,6 +5822,11 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + formatly@0.7.0: + resolution: {integrity: sha512-7CXJtIIA0zy/u12StsYk25qVKxvdLA2ep2sTNxK3ov0mGNIIDqIvAXDSgTnAfDJFsPfWjuz0WjfYSdpvnLA5Tg==} + engines: {node: '>=18.3.0'} + hasBin: true + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -5678,6 +5936,9 @@ packages: get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + get-uri@6.0.5: resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} engines: {node: '>= 14'} @@ -6290,6 +6551,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} @@ -6389,6 +6654,11 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + knip@6.34.0: + resolution: {integrity: sha512-bbHIrnGspYwe4EBPjjx+lvkUor0F2qfKQc5BPzPI4SOAImYA+k2ueVpIcF7d/W1LEVT7XoJUPt6zELeGZhXBgA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -7191,6 +7461,13 @@ packages: outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + oxc-parser@0.147.0: + resolution: {integrity: sha512-5xaug6t7GfV3BO5Iv+xHW1rmQkDEQ3BEu3L8g3InsvWO5i8CYGc4tCZ2X985QcwWNycFJam+aOns6Nr2XAThTA==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -7277,6 +7554,9 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -7412,6 +7692,10 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pidtree@0.6.0: resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} engines: {node: '>=0.10'} @@ -8145,6 +8429,10 @@ packages: resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} engines: {node: '>= 18'} + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + engines: {node: '>= 18'} + socket.io-adapter@2.5.6: resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==} @@ -8649,6 +8937,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + unbash@4.0.11: + resolution: {integrity: sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ==} + engines: {node: '>=14'} + unbzip2-stream@1.4.3: resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} @@ -8977,6 +9269,10 @@ packages: engines: {node: '>=10'} hasBin: true + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -9757,21 +10053,21 @@ snapshots: ajv: 6.14.0 ajv-keywords: 3.5.2(ajv@6.14.0) - '@electron-toolkit/eslint-config-prettier@3.0.0(eslint@9.39.3(jiti@2.6.1))(prettier@3.8.1)': + '@electron-toolkit/eslint-config-prettier@3.0.0(eslint@9.39.3(jiti@2.7.0))(prettier@3.8.1)': dependencies: - eslint: 9.39.3(jiti@2.6.1) - eslint-config-prettier: 10.1.8(eslint@9.39.3(jiti@2.6.1)) - eslint-plugin-prettier: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1))(prettier@3.8.1) + eslint: 9.39.3(jiti@2.7.0) + eslint-config-prettier: 10.1.8(eslint@9.39.3(jiti@2.7.0)) + eslint-plugin-prettier: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.3(jiti@2.7.0)))(eslint@9.39.3(jiti@2.7.0))(prettier@3.8.1) prettier: 3.8.1 transitivePeerDependencies: - '@types/eslint' - '@electron-toolkit/eslint-config-ts@3.1.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + '@electron-toolkit/eslint-config-ts@3.1.0(eslint@9.39.3(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint/js': 9.39.3 - eslint: 9.39.3(jiti@2.6.1) + eslint: 9.39.3(jiti@2.7.0) globals: 16.5.0 - typescript-eslint: 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + typescript-eslint: 8.56.1(eslint@9.39.3(jiti@2.7.0))(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -9916,6 +10212,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -10228,9 +10540,9 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.3(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.3(jiti@2.7.0))': dependencies: - eslint: 9.39.3(jiti@2.6.1) + eslint: 9.39.3(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -10488,11 +10800,11 @@ snapshots: '@types/yargs': 17.0.35 chalk: 4.1.2 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@5.9.3) - vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) optionalDependencies: typescript: 5.9.3 @@ -10573,7 +10885,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@modelcontextprotocol/inspector@2.3.0(@modelcontextprotocol/sdk@1.27.0(zod@4.3.6))(@types/node@26.2.0)(@types/react@19.2.14)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(express@5.2.1)(jiti@2.6.1)(react-dom@19.2.4(react@19.2.4))(tsx@4.21.0)': + '@modelcontextprotocol/inspector@2.3.0(@modelcontextprotocol/sdk@1.27.0(zod@4.3.6))(@types/node@26.2.0)(@types/react@19.2.14)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(express@5.2.1)(jiti@2.7.0)(react-dom@19.2.4(react@19.2.4))(tsx@4.21.0)': dependencies: '@hono/node-server': 2.1.1(hono@4.13.3) '@modelcontextprotocol/client': 2.0.0 @@ -10582,7 +10894,7 @@ snapshots: '@modelcontextprotocol/server': 2.0.0 '@modelcontextprotocol/server-legacy': 2.0.0(express@5.2.1) '@napi-rs/keyring': 1.3.0 - '@vitejs/plugin-react': 6.1.0(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0)) + '@vitejs/plugin-react': 6.1.0(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) ajv: 8.18.0 atomically: 2.1.1 chokidar: 4.0.3 @@ -10593,7 +10905,7 @@ snapshots: pino: 9.14.0 react: 19.2.4 undici: 8.10.0 - vite: 8.2.2(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 8.2.2(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) yaml: 2.9.0 zod: 4.3.6 transitivePeerDependencies: @@ -10709,6 +11021,13 @@ snapshots: '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 '@napi-rs/keyring-win32-x64-msvc': 1.3.0 + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -10754,8 +11073,128 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@oxc-parser/binding-android-arm-eabi@0.147.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.147.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.147.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.147.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.147.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.147.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.147.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.147.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.147.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.147.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.147.0': + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.147.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.147.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.147.0': + optional: true + '@oxc-project/types@0.146.0': {} + '@oxc-project/types@0.147.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + optional: true + + '@oxc-resolver/binding-android-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + optional: true + '@pinojs/redact@0.4.0': {} '@pkgjs/parseargs@0.11.0': @@ -11808,10 +12247,10 @@ snapshots: axe-core: 4.11.4 storybook: 10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@storybook/addon-docs@10.3.6(@types/react@19.2.14)(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': + '@storybook/addon-docs@10.3.6(@types/react@19.2.14)(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.4) - '@storybook/csf-plugin': 10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) + '@storybook/csf-plugin': 10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) '@storybook/icons': 2.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@storybook/react-dom-shim': 10.3.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) react: 19.2.4 @@ -11825,25 +12264,25 @@ snapshots: - vite - webpack - '@storybook/builder-vite@10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': + '@storybook/builder-vite@10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: - '@storybook/csf-plugin': 10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) + '@storybook/csf-plugin': 10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) storybook: 10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ts-dedent: 2.2.0 - vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': + '@storybook/csf-plugin@10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: storybook: 10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) unplugin: 2.3.11 optionalDependencies: esbuild: 0.28.1 rollup: 4.59.0 - vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) '@storybook/global@5.0.0': {} @@ -11858,11 +12297,11 @@ snapshots: react-dom: 19.2.4(react@19.2.4) storybook: 10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@storybook/react-vite@10.3.6(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': + '@storybook/react-vite@10.3.6(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - '@storybook/builder-vite': 10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) + '@storybook/builder-vite': 10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) '@storybook/react': 10.3.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) empathic: 2.0.1 magic-string: 0.30.21 @@ -11872,7 +12311,7 @@ snapshots: resolve: 1.22.12 storybook: 10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tsconfig-paths: 4.2.0 - vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - esbuild - rollup @@ -11959,12 +12398,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 - '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': + '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.2.1 '@tailwindcss/oxide': 4.2.1 tailwindcss: 4.2.1 - vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) '@tanstack/query-core@5.96.2': {} @@ -12024,6 +12463,11 @@ snapshots: '@tootallnate/quickjs-emscripten@0.23.0': {} + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -12209,15 +12653,15 @@ snapshots: '@types/node': 22.19.11 optional: true - '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.3(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/type-utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.56.1(eslint@9.39.3(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.56.1 - eslint: 9.39.3(jiti@2.6.1) + eslint: 9.39.3(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -12225,14 +12669,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.56.1 '@typescript-eslint/types': 8.56.1 '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.56.1 debug: 4.4.3(supports-color@8.1.1) - eslint: 9.39.3(jiti@2.6.1) + eslint: 9.39.3(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -12255,13 +12699,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.56.1(eslint@9.39.3(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.56.1 '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.7.0))(typescript@5.9.3) debug: 4.4.3(supports-color@8.1.1) - eslint: 9.39.3(jiti@2.6.1) + eslint: 9.39.3(jiti@2.7.0) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -12284,13 +12728,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.56.1 '@typescript-eslint/types': 8.56.1 '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - eslint: 9.39.3(jiti@2.6.1) + eslint: 9.39.3(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -12304,7 +12748,7 @@ snapshots: '@vercel/oidc@3.1.0': {} - '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -12312,14 +12756,14 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@6.1.0(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0))': + '@vitejs/plugin-react@6.1.0(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.2(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 8.2.2(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) optionalDependencies: babel-plugin-react-compiler: 1.0.0 @@ -12335,7 +12779,7 @@ snapshots: magicast: 0.5.2 obug: 2.1.1 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -12351,7 +12795,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) '@vitest/expect@3.2.4': dependencies: @@ -12370,29 +12814,29 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@26.2.0)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@26.2.0)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) '@vitest/pretty-format@2.1.9': dependencies: @@ -12438,7 +12882,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) '@vitest/utils@3.2.4': dependencies: @@ -13764,7 +14208,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-vite@5.0.0(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)): + electron-vite@5.0.0(vite@7.3.1(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) @@ -13772,7 +14216,7 @@ snapshots: esbuild: 0.25.12 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -14063,33 +14507,33 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-prettier@10.1.8(eslint@9.39.3(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@9.39.3(jiti@2.7.0)): dependencies: - eslint: 9.39.3(jiti@2.6.1) + eslint: 9.39.3(jiti@2.7.0) - eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1))(prettier@3.8.1): + eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.3(jiti@2.7.0)))(eslint@9.39.3(jiti@2.7.0))(prettier@3.8.1): dependencies: - eslint: 9.39.3(jiti@2.6.1) + eslint: 9.39.3(jiti@2.7.0) prettier: 3.8.1 prettier-linter-helpers: 1.0.1 synckit: 0.11.12 optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@9.39.3(jiti@2.6.1)) + eslint-config-prettier: 10.1.8(eslint@9.39.3(jiti@2.7.0)) - eslint-plugin-react-hooks@7.0.1(eslint@9.39.3(jiti@2.6.1)): + eslint-plugin-react-hooks@7.0.1(eslint@9.39.3(jiti@2.7.0)): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.0 - eslint: 9.39.3(jiti@2.6.1) + eslint: 9.39.3(jiti@2.7.0) hermes-parser: 0.25.1 zod: 4.3.6 zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color - eslint-plugin-react-refresh@0.4.26(eslint@9.39.3(jiti@2.6.1)): + eslint-plugin-react-refresh@0.4.26(eslint@9.39.3(jiti@2.7.0)): dependencies: - eslint: 9.39.3(jiti@2.6.1) + eslint: 9.39.3(jiti@2.7.0) eslint-scope@8.4.0: dependencies: @@ -14102,9 +14546,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.3(jiti@2.6.1): + eslint@9.39.3(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 @@ -14139,7 +14583,7 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.6.1 + jiti: 2.7.0 transitivePeerDependencies: - supports-color @@ -14342,6 +14786,10 @@ snapshots: dependencies: format: 0.2.2 + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + fd-slicer@1.1.0: dependencies: pend: 1.2.0 @@ -14350,9 +14798,9 @@ snapshots: optionalDependencies: picomatch: 4.0.3 - fdir@6.5.0(picomatch@4.0.5): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.5 + picomatch: 4.0.7 fflate@0.8.2: {} @@ -14458,6 +14906,11 @@ snapshots: format@0.2.2: {} + formatly@0.7.0: + dependencies: + fd-package-json: 2.0.0 + package-manager-detector: 1.8.0 + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -14584,6 +15037,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + get-uri@6.0.5: dependencies: basic-ftp: 5.2.0 @@ -15297,6 +15754,8 @@ snapshots: jiti@2.6.1: {} + jiti@2.7.0: {} + jose@6.1.3: {} joycon@3.1.1: {} @@ -15398,6 +15857,22 @@ snapshots: dependencies: json-buffer: 3.0.1 + knip@6.34.0: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + formatly: 0.7.0 + get-tsconfig: 4.14.3 + jiti: 2.7.0 + oxc-parser: 0.147.0 + oxc-resolver: 11.24.2 + picomatch: 4.0.7 + smol-toml: 1.8.0 + strip-json-comments: 5.0.3 + tinyglobby: 0.2.17 + unbash: 4.0.11 + yaml: 2.9.0 + zod: 4.3.6 + lazy-val@1.0.5: {} lazystream@1.0.1: @@ -16219,7 +16694,7 @@ snapshots: proc-log: 5.0.0 semver: 7.7.4 tar: 7.5.9 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 which: 5.0.0 transitivePeerDependencies: - supports-color @@ -16378,6 +16853,52 @@ snapshots: outdent@0.5.0: {} + oxc-parser@0.147.0: + dependencies: + '@oxc-project/types': 0.147.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.147.0 + '@oxc-parser/binding-android-arm64': 0.147.0 + '@oxc-parser/binding-darwin-arm64': 0.147.0 + '@oxc-parser/binding-darwin-x64': 0.147.0 + '@oxc-parser/binding-freebsd-x64': 0.147.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.147.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.147.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.147.0 + '@oxc-parser/binding-linux-arm64-musl': 0.147.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.147.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.147.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.147.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.147.0 + '@oxc-parser/binding-linux-x64-gnu': 0.147.0 + '@oxc-parser/binding-linux-x64-musl': 0.147.0 + '@oxc-parser/binding-openharmony-arm64': 0.147.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.147.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.147.0 + '@oxc-parser/binding-win32-x64-msvc': 0.147.0 + + oxc-resolver@11.24.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + p-cancelable@2.1.1: {} p-event@6.0.1: @@ -16460,6 +16981,8 @@ snapshots: dependencies: quansync: 0.2.11 + package-manager-detector@1.8.0: {} + pako@1.0.11: {} parent-module@1.0.1: @@ -16573,6 +17096,8 @@ snapshots: picomatch@4.0.5: {} + picomatch@4.0.7: {} + pidtree@0.6.0: {} pify@2.3.0: {} @@ -17573,6 +18098,8 @@ snapshots: smol-toml@1.7.0: {} + smol-toml@1.8.0: {} + socket.io-adapter@2.5.6: dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -17988,8 +18515,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tinyrainbow@1.2.0: {} @@ -18096,13 +18623,13 @@ snapshots: typed-query-selector@2.12.2: optional: true - typescript-eslint@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.56.1(eslint@9.39.3(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.3(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.3(jiti@2.6.1) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.3(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -18111,6 +18638,8 @@ snapshots: typescript@5.9.3: {} + unbash@4.0.11: {} + unbzip2-stream@1.4.3: dependencies: buffer: 5.7.1 @@ -18298,7 +18827,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0): + vite@7.3.1(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -18309,12 +18838,12 @@ snapshots: optionalDependencies: '@types/node': 22.19.11 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 lightningcss: 1.33.0 tsx: 4.21.0 yaml: 2.9.0 - vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0): + vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -18325,12 +18854,12 @@ snapshots: optionalDependencies: '@types/node': 24.10.13 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 lightningcss: 1.33.0 tsx: 4.21.0 yaml: 2.9.0 - vite@7.3.1(@types/node@26.2.0)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0): + vite@7.3.1(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -18341,12 +18870,12 @@ snapshots: optionalDependencies: '@types/node': 26.2.0 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 lightningcss: 1.33.0 tsx: 4.21.0 yaml: 2.9.0 - vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0): + vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -18357,14 +18886,14 @@ snapshots: '@types/node': 26.2.0 esbuild: 0.28.1 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 tsx: 4.21.0 yaml: 2.9.0 - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -18381,7 +18910,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -18401,10 +18930,10 @@ snapshots: - tsx - yaml - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -18421,7 +18950,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -18441,10 +18970,10 @@ snapshots: - tsx - yaml - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@26.2.0)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -18461,7 +18990,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@26.2.0)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -18495,6 +19024,8 @@ snapshots: transitivePeerDependencies: - supports-color + walk-up-path@4.0.0: {} + wcwidth@1.0.1: dependencies: defaults: 1.0.4 From dbe918509b133b63da01d4bb4f00e0645f3eb8ad Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 8 Sep 2026 00:47:56 +0800 Subject: [PATCH 70/83] refactor: clean up unused methods --- apps/cicd/package.json | 2 +- apps/cli/lib/ai-provider.ts | 21 - apps/cli/lib/log.ts | 13 - apps/cli/lib/logger.ts | 67 -- apps/cli/package.json | 14 +- apps/cli/src/cli/addProgress.ts | 2 +- apps/cli/src/cli/folderDisplay.ts | 2 +- apps/cli/src/cli/scrapeJobFormat.ts | 2 +- apps/cli/src/events/index.ts | 5 - apps/cli/src/events/ping.ts | 10 - apps/cli/src/events/userConfigUpdatedEvent.ts | 9 +- apps/cli/src/i18n/helpers.ts | 4 +- apps/cli/src/mcp/bunMcpLifecycleManager.ts | 27 +- apps/cli/src/mcp/mcpServerManager.ts | 10 - apps/cli/src/mcp/tools/mcpToolBase.ts | 64 - apps/cli/src/route/CoreFetch.ts | 6 +- apps/cli/src/route/Debug.ts | 2 +- apps/cli/src/route/DeleteFile.ts | 2 +- apps/cli/src/route/DeleteFolder.ts | 2 +- apps/cli/src/route/DownloadImage.ts | 2 +- apps/cli/src/route/FolderMetadata.ts | 2 +- apps/cli/src/route/GetFolders.ts | 2 +- apps/cli/src/route/GetJob.ts | 2 +- apps/cli/src/route/ImportFolder.ts | 2 +- apps/cli/src/route/ImportLibrary.ts | 2 +- apps/cli/src/route/IsFolderAvailable.ts | 2 +- apps/cli/src/route/ListDrives.ts | 8 +- apps/cli/src/route/MoveFileToTrash.ts | 2 +- apps/cli/src/route/ReadFile.ts | 2 +- apps/cli/src/route/ReadImage.ts | 2 +- apps/cli/src/route/RecognizeEpisodesPlan.ts | 10 +- apps/cli/src/route/RecognizeFolder.ts | 8 +- apps/cli/src/route/RenameEpisodeFile.ts | 8 +- apps/cli/src/route/RenameEpisodesPlan.ts | 26 +- apps/cli/src/route/RenameFiles.ts | 2 +- apps/cli/src/route/RenameFolderV3.ts | 7 +- apps/cli/src/route/Scrape.ts | 2 +- apps/cli/src/route/SetWatchedFolder.ts | 2 +- apps/cli/src/route/ShowFolder.ts | 2 +- apps/cli/src/route/Tmdb.ts | 4 +- apps/cli/src/route/TryToRecognizeEpisodes.ts | 6 +- apps/cli/src/route/Tvdb.ts | 5 - apps/cli/src/route/UnimportFolder.ts | 2 +- apps/cli/src/route/WriteFile.ts | 3 +- apps/cli/src/route/ai.ts | 2 +- .../cli/src/route/commandExecutionRegistry.ts | 2 +- apps/cli/src/route/commandExecutionStatus.ts | 2 +- apps/cli/src/route/coreRoutesConfig.ts | 29 +- .../route/debug/debugGetApplicationContext.ts | 2 +- .../src/route/debug/debugGetEpisodesTool.ts | 2 +- apps/cli/src/route/debug/debugGetJobTool.ts | 2 +- .../src/route/debug/debugGetMediaFolders.ts | 2 +- .../src/route/debug/debugGetMediaMetadata.ts | 2 +- .../src/route/debug/debugIsFolderExistTool.ts | 2 +- .../cli/src/route/debug/debugListFilesTool.ts | 2 +- .../cli/src/route/debug/debugRecognizeTask.ts | 6 +- .../src/route/debug/debugRenameFolderTool.ts | 2 +- apps/cli/src/route/debug/debugScrapeTool.ts | 2 +- apps/cli/src/route/discover.ts | 17 +- apps/cli/src/route/discoverExecutables.ts | 4 +- apps/cli/src/route/executeCmd.ts | 4 - apps/cli/src/route/getEpisodes.ts | 2 +- apps/cli/src/route/listFilesInMediaFolder.ts | 2 +- .../cli/src/route/metadata/metadataSchemas.ts | 8 +- apps/cli/src/route/metadata/problemDetails.ts | 2 +- apps/cli/src/route/path-validator.ts | 20 - .../cli/src/route/validateRenameOperations.ts | 2 +- apps/cli/src/services/folderWatcher.ts | 12 - apps/cli/src/tools/askForConfirmation.ts | 49 - apps/cli/src/tools/getApplicationContext.ts | 28 - apps/cli/src/tools/getEpisode.ts | 270 ----- apps/cli/src/tools/getEpisodes.ts | 63 +- apps/cli/src/tools/getMediaFolders.ts | 55 +- apps/cli/src/tools/getMediaMetadata.ts | 58 +- .../cli/src/tools/getSelectedMediaMetadata.ts | 58 - .../tools/howToRecognizeEpisodeVideoFiles.ts | 48 - .../src/tools/howToRenameEpisodeVideoFiles.ts | 68 -- apps/cli/src/tools/index.ts | 61 +- apps/cli/src/tools/isFolderExist.ts | 49 +- apps/cli/src/tools/listFiles.ts | 126 -- apps/cli/src/tools/listFilesInMediaFolder.ts | 9 +- apps/cli/src/tools/matchEpisode.ts | 151 --- apps/cli/src/tools/matchEpisodesInBatch.ts | 471 -------- apps/cli/src/tools/readme.ts | 71 -- apps/cli/src/tools/recognizeMediaFilesTool.ts | 140 +-- apps/cli/src/tools/renameFilesInBatch.ts | 8 - apps/cli/src/tools/renameFolder.ts | 58 +- apps/cli/src/tools/types.ts | 18 - apps/cli/src/utils/Ffmpeg.ts | 321 +---- apps/cli/src/utils/QuickJS.ts | 26 +- apps/cli/src/utils/VideoCaptioner.ts | 38 +- apps/cli/src/utils/Ytdlp.ts | 264 +--- apps/cli/src/utils/cmd.ts | 3 +- apps/cli/src/utils/config.ts | 30 - apps/cli/src/utils/db.ts | 92 -- apps/cli/src/utils/files.ts | 98 +- apps/cli/src/utils/gracefulShutdown.ts | 4 - apps/cli/src/utils/mediaMetadata.ts | 70 +- apps/cli/src/utils/mediaMetadataUtils.ts | 78 -- apps/cli/src/utils/permission.ts | 5 - apps/cli/src/utils/pty.ts | 2 +- apps/cli/src/utils/renameFileUtils.ts | 162 +-- apps/cli/src/utils/socketIO.ts | 31 +- apps/cli/src/utils/tmdb.ts | 22 - apps/cli/src/utils/tmdbOutboundFetch.ts | 110 -- apps/cli/src/utils/toolExecutableDiscovery.ts | 12 +- apps/cli/src/utils/traceId.ts | 4 - .../validations/validateChainingConflicts.ts | 1 - .../validations/validateDestFileNotExist.ts | 56 - apps/cli/src/validations/validateFileName.ts | 8 - .../validations/validateNoAbnormalPaths.ts | 1 - .../validateNoDuplicatedDestFile.ts | 1 - .../validateNoDuplicatedSourceFile.ts | 14 - .../validateNoIdenticalSourceAndDestFile.ts | 3 - .../validatePathWithinMediaFolder.ts | 1 - .../validations/validateSourceFileExist.ts | 44 - apps/cli/test/helpers/loadEnvLocal.ts | 8 - apps/cli/test/helpers/testFolders.ts | 12 - apps/cli/test/scrape.e2e.ts | 9 +- apps/convex/package.json | 1 - apps/core/src/ai-tool/systemPrompt.ts | 7 - .../src/pipeline/recognizeMediaFolder.test.ts | 30 +- apps/docker/package.json | 4 - apps/e2e/cli/base.ts | 14 - apps/e2e/cli/import-library.test.ts | 6 +- apps/e2e/common/manual/CustomTmdbHost.e2e.ts | 2 +- apps/e2e/common/manual/CustomTvdbHost.e2e.ts | 2 +- apps/e2e/common/manual/Transcribe.e2e.ts | 2 +- .../mcp/McpOther-RecognizeTaskFlow.e2e.ts | 2 +- .../common/mcp/McpOther-RenameTaskFlow.e2e.ts | 2 +- apps/e2e/common/movie/SearchMovie.e2e.ts | 2 +- apps/e2e/common/other/App.e2e.ts | 2 +- .../common/tv/InitializeTvShowByTmdb.e2e.ts | 2 +- apps/e2e/package.json | 12 +- apps/e2e/test/actions/import-folders.ts | 8 +- .../test/componentobjects/MoviePanel.co.ts | 2 +- .../e2e/test/componentobjects/Searchbox.co.ts | 1 - .../test/componentobjects/TVShowPanel.co.ts | 6 +- apps/e2e/test/lib/ui-page-url.test.ts | 5 +- apps/e2e/test/lib/ui-page-url.ts | 2 - .../test/specs/ai/AiTool-RecognizeTool.e2e.ts | 2 +- .../test/specs/ai/AiTool-RenameTool.e2e.ts | 2 +- .../test/steps/searchbox-input-is-focused.ts | 2 +- apps/e2e/tsconfig.json | 4 + apps/electron/package.json | 3 +- apps/electron/src/main/startup/cliMonitor.ts | 2 +- .../electron/src/main/startup/startupError.ts | 4 +- apps/electron/src/main/startup/types.ts | 2 +- .../src/main/startup/waitForCliServerReady.ts | 2 +- apps/electron/src/main/types.ts | 9 - apps/ui/fix_test.js | 13 - apps/ui/package.json | 14 +- .../actions/handleAiRecognizeConfirm.test.ts | 6 +- apps/ui/src/ai/aiContextStore.ts | 17 +- apps/ui/src/ai/prompts.ts | 1 - .../ui/src/ai/tools/GetApplicationContext.tsx | 2 +- .../src/ai/tools/ListFilesInMediaFolder.tsx | 2 - apps/ui/src/ai/tools/index.ts | 2 +- apps/ui/src/api/chat.ts | 40 - apps/ui/src/api/cleanUp.ts | 23 - apps/ui/src/api/commandExecutionStatus.ts | 4 +- apps/ui/src/api/createPlan.ts | 63 - apps/ui/src/api/discover.ts | 6 +- apps/ui/src/api/executeCmd.ts | 4 +- apps/ui/src/api/ffmpeg.ts | 91 +- apps/ui/src/api/getJob.ts | 4 +- apps/ui/src/api/getPlanById.ts | 36 - apps/ui/src/api/importFolder.ts | 4 +- apps/ui/src/api/importLibrary.ts | 4 +- apps/ui/src/api/listFiles.ts | 65 - apps/ui/src/api/log.ts | 2 +- apps/ui/src/api/readFile.ts | 40 +- apps/ui/src/api/recognizeFolder.ts | 6 +- apps/ui/src/api/renameFile.ts | 52 - apps/ui/src/api/renameFilesInMediaMetadata.ts | 28 - apps/ui/src/api/renameFolder.ts | 11 - apps/ui/src/api/renameFolderV3.ts | 4 +- apps/ui/src/api/scrape.ts | 28 - apps/ui/src/api/showFolder.ts | 6 +- apps/ui/src/api/speedtest.ts | 2 +- apps/ui/src/api/tencentAsr.ts | 28 - apps/ui/src/api/tmdb.ts | 5 +- apps/ui/src/api/tmdbErrors.ts | 4 +- apps/ui/src/api/tmdbV3.ts | 2 +- apps/ui/src/api/tvdbV3.ts | 2 +- apps/ui/src/api/validateRenameOperations.ts | 32 - apps/ui/src/api/videocaptioner.ts | 4 +- apps/ui/src/api/ytdlp.ts | 160 +-- apps/ui/src/api/ytdlp/types.ts | 6 +- apps/ui/src/components/AiIcon.tsx | 100 -- .../DatabaseConnectionIndicator.tsx | 101 -- apps/ui/src/components/FileList.tsx | 43 - .../components/ImmersiveMovieSearchbox.tsx | 219 ---- apps/ui/src/components/ImmersiveSearchbox.tsx | 7 - apps/ui/src/components/LocalFileTableRow.tsx | 2 - apps/ui/src/components/LocalFilesPanel.tsx | 105 -- .../src/components/MediaDatabaseSearchbox.tsx | 9 +- apps/ui/src/components/MediaPlayer.tsx | 341 +----- .../src/components/MediaPlayerControlBar.tsx | 175 --- apps/ui/src/components/MediaPlayerToolbar.tsx | 92 -- .../ui/src/components/UILocalFileTableRow.tsx | 1 - apps/ui/src/components/app-sidebar.tsx | 215 ---- apps/ui/src/components/attachment.tsx | 238 ---- apps/ui/src/components/auth/LoginPanel.tsx | 2 +- .../components/dialogs/NewVersionDialog.tsx | 2 +- .../dialogs/download-video-dialog/index.tsx | 2 +- .../dialogs/hooks/use-ytdlp-download-flow.ts | 2 +- apps/ui/src/components/dialogs/index.ts | 62 +- .../dialogs/media-file-property-dialog.tsx | 2 +- apps/ui/src/components/dialogs/types/index.ts | 31 +- apps/ui/src/components/episode-file.tsx | 268 ----- apps/ui/src/components/episode-section.tsx | 390 ------ apps/ui/src/components/language-switcher.tsx | 54 - .../src/components/media-folder-content.tsx | 9 - .../components/media/MediaFileTableRow.tsx | 495 +------- .../src/components/media/UIMediaFileTable.tsx | 27 +- .../media/mediaFileTableColumns.tsx | 46 +- apps/ui/src/components/menu.tsx | 16 +- apps/ui/src/components/mobile/Navigation.tsx | 59 - apps/ui/src/components/mode-toggle.tsx | 38 - apps/ui/src/components/movie/MoviePanel.tsx | 1 - .../components/movie/movie-files-section.tsx | 138 --- .../components/movie/tmdb-movie-overview.tsx | 375 ------ .../ui/src/components/musicTableRowShared.tsx | 6 +- .../src/components/rename-rules-combobox.tsx | 84 -- apps/ui/src/components/sidebar/Sidebar.tsx | 5 +- apps/ui/src/components/thread-list.tsx | 80 -- .../ui/src/components/three-column-layout.tsx | 123 -- apps/ui/src/components/tv/TvShowPanel.tsx | 2 +- .../components/tv/TvShowPanelUtils.test.ts | 12 +- apps/ui/src/components/tv/TvShowPanelUtils.ts | 303 +---- apps/ui/src/components/tv/UseNfoPrompt.tsx | 83 -- apps/ui/src/components/version-switcher.tsx | 62 - apps/ui/src/components/welcome.tsx | 2 +- apps/ui/src/core/BrowserNetworkPort.ts | 6 +- apps/ui/src/helpers/loadNfo.ts | 130 -- .../helpers/movie/MovieMediaMetadataUtils.ts | 2 +- .../hooks/ffmpeg/useFfmpegEncodersQuery.ts | 2 +- apps/ui/src/hooks/folders/foldersQueryKeys.ts | 2 +- apps/ui/src/hooks/folders/index.ts | 4 +- ...useSyncUIMediaFolderStoreFromUserConfig.ts | 15 - apps/ui/src/hooks/mediaMetadata/index.ts | 3 - apps/ui/src/hooks/plans/index.ts | 14 +- .../hooks/tv/useTvShowFileNameGeneration.ts | 52 - apps/ui/src/hooks/tv/useTvShowRenaming.ts | 215 ---- .../src/hooks/useDatabaseConnectionStatus.ts | 1 - apps/ui/src/hooks/useFfmpegProgressQuery.ts | 11 - apps/ui/src/hooks/useGetTmdbMovieMutation.ts | 33 +- apps/ui/src/hooks/useGetTmdbTvShowMutation.ts | 61 +- apps/ui/src/hooks/useGetTvdbMovieMutation.ts | 32 - apps/ui/src/hooks/useGetTvdbTvShowMutation.ts | 25 - apps/ui/src/hooks/useJobOrchestrator.ts | 7 +- apps/ui/src/hooks/useJobQuery.ts | 2 +- apps/ui/src/hooks/useMcpServerStatus.ts | 4 +- .../hooks/useMusicFolderSubtitlePipeline.ts | 2 +- apps/ui/src/hooks/useOnFirstOpen.ts | 26 - apps/ui/src/hooks/useRenameVideoFileFlow.ts | 2 +- apps/ui/src/hooks/useResolvedLanguages.ts | 2 +- .../src/hooks/useScrapeTaskCompletionQuery.ts | 4 +- apps/ui/src/hooks/useTmdbLanguages.ts | 4 +- apps/ui/src/hooks/useTvShowPanel.ts | 8 +- apps/ui/src/hooks/useTvdbLanguages.ts | 2 +- apps/ui/src/hooks/useWebSocket.ts | 22 - apps/ui/src/hooks/userConfig/index.ts | 9 +- .../src/hooks/userConfig/userConfigHooks.ts | 15 - apps/ui/src/hooks/ytdlp/useYtdlpMutations.ts | 51 - apps/ui/src/lib/TmdbUtils.ts | 38 - apps/ui/src/lib/TvdbUtils.ts | 22 +- apps/ui/src/lib/ai-provider-presets.ts | 2 +- apps/ui/src/lib/ai-provider.ts | 45 - apps/ui/src/lib/ai.ts | 210 ---- apps/ui/src/lib/assetImageUrls.ts | 84 -- apps/ui/src/lib/assetImageUrlsUi.ts | 7 +- apps/ui/src/lib/associatedFilesUi.ts | 15 +- .../lib/buildTvShowRenamePlanFileEntries.ts | 58 - apps/ui/src/lib/downloadTaskDb.ts | 119 +- apps/ui/src/lib/downloadVideoJobFactory.ts | 22 +- apps/ui/src/lib/frontendLogFlusher.ts | 2 +- apps/ui/src/lib/harmonyOSDisabledFeatures.ts | 24 - .../isRuleBasedRecognizePlanComplete.test.ts | 4 +- apps/ui/src/lib/jobRecordMapper.ts | 17 +- apps/ui/src/lib/log.ts | 20 +- apps/ui/src/lib/mediaDatabaseAccess.ts | 10 +- apps/ui/src/lib/mediaFilePathEqual.ts | 12 - .../src/lib/mediaFolderRecognitionPipeline.ts | 35 - .../src/lib/mediaMetadataRefreshUtils.test.ts | 6 +- .../src/lib/mergeFolderPathsWithUiStatus.ts | 22 - apps/ui/src/lib/music.ts | 8 +- apps/ui/src/lib/musicEvents.ts | 8 +- apps/ui/src/lib/nfo.test.ts | 3 +- apps/ui/src/lib/nfo.ts | 1 - apps/ui/src/lib/nfo/index.ts | 1 - apps/ui/src/lib/nfo/movieNfo.ts | 257 +--- apps/ui/src/lib/nfo/tvshowNfo.ts | 6 +- apps/ui/src/lib/path.ts | 40 +- apps/ui/src/lib/recognizeEpisodes.ts | 338 ------ apps/ui/src/lib/recognizeEpisodes.worker.ts | 33 - apps/ui/src/lib/recognizeEpisodesUi.ts | 96 +- ...ecognizeMediaFolderByTvdbIdInFolderName.ts | 75 -- apps/ui/src/lib/recognizeMediaFolderTypes.ts | 10 - apps/ui/src/lib/renameRules.ts | 51 +- apps/ui/src/lib/scrapeDialog/index.ts | 6 +- apps/ui/src/lib/scrapeDialog/types.ts | 6 +- apps/ui/src/lib/scrapeError.ts | 2 +- .../lib/tvShowMediaMetadataFromTmdbDetails.ts | 32 - apps/ui/src/lib/utils.ts | 408 +------ apps/ui/src/lib/whitelistedCmd/index.ts | 14 +- apps/ui/src/lib/ytdlp/executeYtdlp.ts | 5 +- apps/ui/src/lib/ytdlpCookiesBrowsers.ts | 5 +- apps/ui/src/lib/ytdlpCookiesFile.ts | 3 - apps/ui/src/lib/ytdlpFormatCodes.ts | 2 +- apps/ui/src/lib/ytdlpFormatPresets.ts | 2 +- apps/ui/src/lib/ytdlpJsRuntimes.ts | 13 - apps/ui/src/providers/dialog-provider.tsx | 3 +- apps/ui/src/stores/statusbarStore.ts | 2 +- apps/ui/src/stores/tvShowPromptsStore.ts | 75 -- apps/ui/src/stores/uiMediaFolderStore.ts | 10 - .../ui/src/stores/uiMediaFolderStoreBridge.ts | 4 - apps/ui/src/types/background-jobs.ts | 2 +- apps/ui/src/types/eventTypes.ts | 7 - ci/run-e2e-test.ts | 3 +- knip.json | 103 +- mcp/index.ts | 174 --- package.json | 2 +- packages/core-routes/src/index.ts | 1 - packages/core-routes/src/mcp/index.ts | 20 - .../core-routes/src/mcp/mcpServerConfig.ts | 2 +- packages/core-routes/src/register.ts | 2 +- .../core-routes/src/renameFilesValidation.ts | 26 - packages/core-routes/src/routes/helloRoute.ts | 3 - .../src/tools/createRecognizeEpisodePlan.ts | 2 - .../src/tools/createRenameEpisodePlan.ts | 2 - .../src/tools/getApplicationContext.ts | 3 - packages/core-routes/src/tools/getEpisodes.ts | 2 - packages/core-routes/src/tools/getJob.ts | 2 - .../core-routes/src/tools/getMediaFolders.ts | 2 - .../core-routes/src/tools/getMediaMetadata.ts | 3 - .../core-routes/src/tools/isFolderExist.ts | 2 - .../src/tools/listFilesInMediaFolder.ts | 5 +- packages/core-routes/src/tools/plans.ts | 22 +- .../src/tools/renameEpisodeFile.ts | 3 - .../core-routes/src/tools/renameFolder.ts | 2 - packages/core-routes/src/tools/scrape.ts | 2 - packages/core-routes/src/tools/tmdb.ts | 4 - packages/core-routes/src/tools/tvdb.ts | 5 - packages/core-routes/src/tools/types.ts | 42 - packages/core-routes/src/userConfig.ts | 2 +- packages/test/src/index.ts | 2 - packages/test/src/testFolders.ts | 4 +- packages/tvdb4/src/types.ts | 6 +- packages/types/GetEpisodesToolTypes.ts | 12 - packages/types/YtdlpTypes.ts | 8 +- .../ai-tools/createRecognizeEpisodePlan.ts | 3 - .../types/ai-tools/createRenameEpisodePlan.ts | 3 - .../types/ai-tools/getApplicationContext.ts | 3 - packages/types/ai-tools/getEpisodes.ts | 5 +- packages/types/ai-tools/getJob.ts | 7 +- packages/types/ai-tools/getMediaFolders.ts | 3 +- packages/types/ai-tools/getMediaMetadata.ts | 35 +- packages/types/ai-tools/isFolderExist.ts | 1 - .../types/ai-tools/listFilesInMediaFolder.ts | 5 +- packages/types/ai-tools/planTaskMessages.ts | 9 - packages/types/ai-tools/renameEpisodeFile.ts | 1 - packages/types/ai-tools/renameFolder.ts | 1 - packages/types/ai-tools/scrape.ts | 1 - packages/types/event-types.ts | 21 - packages/types/planCommon.ts | 8 - packages/types/tmdbPrimaryTranslations.ts | 1 - packages/utils/src/locale.ts | 3 - pnpm-lock.yaml | 1072 +---------------- test/bin/setup.ts | 105 -- 371 files changed, 488 insertions(+), 13640 deletions(-) delete mode 100644 apps/cli/lib/log.ts delete mode 100644 apps/cli/src/events/index.ts delete mode 100644 apps/cli/src/events/ping.ts delete mode 100644 apps/cli/src/mcp/tools/mcpToolBase.ts delete mode 100644 apps/cli/src/route/path-validator.ts delete mode 100644 apps/cli/src/tools/askForConfirmation.ts delete mode 100644 apps/cli/src/tools/getEpisode.ts delete mode 100644 apps/cli/src/tools/getSelectedMediaMetadata.ts delete mode 100644 apps/cli/src/tools/howToRecognizeEpisodeVideoFiles.ts delete mode 100644 apps/cli/src/tools/howToRenameEpisodeVideoFiles.ts delete mode 100644 apps/cli/src/tools/listFiles.ts delete mode 100644 apps/cli/src/tools/matchEpisode.ts delete mode 100644 apps/cli/src/tools/matchEpisodesInBatch.ts delete mode 100644 apps/cli/src/tools/readme.ts delete mode 100644 apps/cli/src/tools/types.ts delete mode 100644 apps/cli/src/utils/db.ts delete mode 100644 apps/cli/src/utils/mediaMetadataUtils.ts delete mode 100644 apps/cli/src/utils/tmdb.ts delete mode 100644 apps/cli/src/utils/tmdbOutboundFetch.ts delete mode 100644 apps/cli/src/utils/traceId.ts delete mode 100644 apps/cli/src/validations/validateChainingConflicts.ts delete mode 100644 apps/cli/src/validations/validateDestFileNotExist.ts delete mode 100644 apps/cli/src/validations/validateFileName.ts delete mode 100644 apps/cli/src/validations/validateNoAbnormalPaths.ts delete mode 100644 apps/cli/src/validations/validateNoDuplicatedDestFile.ts delete mode 100644 apps/cli/src/validations/validateNoDuplicatedSourceFile.ts delete mode 100644 apps/cli/src/validations/validateNoIdenticalSourceAndDestFile.ts delete mode 100644 apps/cli/src/validations/validatePathWithinMediaFolder.ts delete mode 100644 apps/cli/src/validations/validateSourceFileExist.ts delete mode 100644 apps/electron/src/main/types.ts delete mode 100644 apps/ui/fix_test.js delete mode 100644 apps/ui/src/api/chat.ts delete mode 100644 apps/ui/src/api/cleanUp.ts delete mode 100644 apps/ui/src/api/createPlan.ts delete mode 100644 apps/ui/src/api/getPlanById.ts delete mode 100644 apps/ui/src/api/renameFile.ts delete mode 100644 apps/ui/src/api/renameFilesInMediaMetadata.ts delete mode 100644 apps/ui/src/api/scrape.ts delete mode 100644 apps/ui/src/api/tencentAsr.ts delete mode 100644 apps/ui/src/api/validateRenameOperations.ts delete mode 100644 apps/ui/src/components/AiIcon.tsx delete mode 100644 apps/ui/src/components/DatabaseConnectionIndicator.tsx delete mode 100644 apps/ui/src/components/FileList.tsx delete mode 100644 apps/ui/src/components/ImmersiveMovieSearchbox.tsx delete mode 100644 apps/ui/src/components/LocalFilesPanel.tsx delete mode 100644 apps/ui/src/components/MediaPlayerControlBar.tsx delete mode 100644 apps/ui/src/components/MediaPlayerToolbar.tsx delete mode 100644 apps/ui/src/components/app-sidebar.tsx delete mode 100644 apps/ui/src/components/attachment.tsx delete mode 100644 apps/ui/src/components/episode-file.tsx delete mode 100644 apps/ui/src/components/episode-section.tsx delete mode 100644 apps/ui/src/components/language-switcher.tsx delete mode 100644 apps/ui/src/components/media-folder-content.tsx delete mode 100644 apps/ui/src/components/mobile/Navigation.tsx delete mode 100644 apps/ui/src/components/mode-toggle.tsx delete mode 100644 apps/ui/src/components/movie/movie-files-section.tsx delete mode 100644 apps/ui/src/components/movie/tmdb-movie-overview.tsx delete mode 100644 apps/ui/src/components/rename-rules-combobox.tsx delete mode 100644 apps/ui/src/components/thread-list.tsx delete mode 100644 apps/ui/src/components/three-column-layout.tsx delete mode 100644 apps/ui/src/components/tv/UseNfoPrompt.tsx delete mode 100644 apps/ui/src/components/version-switcher.tsx delete mode 100644 apps/ui/src/helpers/loadNfo.ts delete mode 100644 apps/ui/src/hooks/initialization/useSyncUIMediaFolderStoreFromUserConfig.ts delete mode 100644 apps/ui/src/hooks/tv/useTvShowFileNameGeneration.ts delete mode 100644 apps/ui/src/hooks/tv/useTvShowRenaming.ts delete mode 100644 apps/ui/src/hooks/useGetTvdbMovieMutation.ts delete mode 100644 apps/ui/src/hooks/useGetTvdbTvShowMutation.ts delete mode 100644 apps/ui/src/hooks/useOnFirstOpen.ts delete mode 100644 apps/ui/src/hooks/userConfig/userConfigHooks.ts delete mode 100644 apps/ui/src/hooks/ytdlp/useYtdlpMutations.ts delete mode 100644 apps/ui/src/lib/TmdbUtils.ts delete mode 100644 apps/ui/src/lib/ai-provider.ts delete mode 100644 apps/ui/src/lib/ai.ts delete mode 100644 apps/ui/src/lib/assetImageUrls.ts delete mode 100644 apps/ui/src/lib/buildTvShowRenamePlanFileEntries.ts delete mode 100644 apps/ui/src/lib/harmonyOSDisabledFeatures.ts delete mode 100644 apps/ui/src/lib/mediaFilePathEqual.ts delete mode 100644 apps/ui/src/lib/mediaFolderRecognitionPipeline.ts delete mode 100644 apps/ui/src/lib/mergeFolderPathsWithUiStatus.ts delete mode 100644 apps/ui/src/lib/recognizeEpisodes.ts delete mode 100644 apps/ui/src/lib/recognizeEpisodes.worker.ts delete mode 100644 apps/ui/src/lib/recognizeMediaFolderByTvdbIdInFolderName.ts delete mode 100644 apps/ui/src/lib/recognizeMediaFolderTypes.ts delete mode 100644 apps/ui/src/lib/tvShowMediaMetadataFromTmdbDetails.ts delete mode 100644 mcp/index.ts delete mode 100644 packages/core-routes/src/renameFilesValidation.ts delete mode 100644 packages/core-routes/src/tools/types.ts delete mode 100644 packages/types/GetEpisodesToolTypes.ts delete mode 100644 test/bin/setup.ts diff --git a/apps/cicd/package.json b/apps/cicd/package.json index bbbdfba3..31d82ef1 100644 --- a/apps/cicd/package.json +++ b/apps/cicd/package.json @@ -1,6 +1,6 @@ { "name": "@smm/cicd", - "module": "index.ts", + "module": "run.ts", "type": "module", "private": true, "scripts": { diff --git a/apps/cli/lib/ai-provider.ts b/apps/cli/lib/ai-provider.ts index 8490e2d0..7206e663 100644 --- a/apps/cli/lib/ai-provider.ts +++ b/apps/cli/lib/ai-provider.ts @@ -1,27 +1,6 @@ import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import type { UserConfig } from '@smm/types'; -export const DEEPSEEK_MODEL = 'deepseek-v4-flash'; - -// Get API key from environment -function getApiKey(): string { - return process.env.VITE_DEEPSEEK_API_KEY || process.env.DEEPSEEK_API_KEY || 'sk-ce25f3132fbc4b599f0f26eede96d390'; -} - -// Create DeepSeek provider configuration (lazy initialization) -let _deepseekProvider: ReturnType | null = null; - -export function getDeepseekProvider() { - if (!_deepseekProvider) { - _deepseekProvider = createOpenAICompatible({ - name: 'DeepSeek', - baseURL: 'https://api.deepseek.com/v1', - apiKey: getApiKey(), - }); - } - return _deepseekProvider; -} - /** * Creates an AI provider based on the user's selected AI configuration. * Looks up the provider by name in the aiProviders array. diff --git a/apps/cli/lib/log.ts b/apps/cli/lib/log.ts deleted file mode 100644 index c9246ccc..00000000 --- a/apps/cli/lib/log.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { MediaMetadata } from "@smm/types"; - -export function mediaMetadataToString(mediaMetadata: MediaMetadata): string { - const obj: Record = structuredClone(mediaMetadata) as Record; - obj.mediaFiles = mediaMetadata.mediaFiles === undefined ? undefined : `<${mediaMetadata.mediaFiles?.length ?? 0} media files>`; - if (mediaMetadata.tvShow !== undefined) { - obj.tvShow = `<${mediaMetadata.tvShow.name}>`; - } - if (mediaMetadata.movie !== undefined) { - obj.movie = `<${mediaMetadata.movie.name}>`; - } - return JSON.stringify(obj); -} diff --git a/apps/cli/lib/logger.ts b/apps/cli/lib/logger.ts index 513ffe31..4bde18e6 100644 --- a/apps/cli/lib/logger.ts +++ b/apps/cli/lib/logger.ts @@ -106,47 +106,6 @@ export function logHttpRespOut(c: Context, body: unknown, statusCode: number = 2 } } -/** - * Logs outgoing HTTP request to external API. - * In debug mode, includes the request body; otherwise only logs method and URL. - */ -export function logHttpReqOut(url: string, method: string = 'GET', body?: unknown) { - const logData: Record = { - method, - url, - target: 'external', - }; - - if (logger.isLevelEnabled('debug') && body !== undefined) { - logData.body = body; - } - - logger.info(logData, 'HTTP request sent to external API'); -} - -/** - * Logs incoming HTTP response from external API. - * In debug mode, includes the response body; otherwise only logs method, URL, and status code. - * Automatically determines error state by checking status code >= 400. - */ -export function logHttpRespIn(url: string, statusCode: number, body?: unknown) { - const logData: Record = { - url, - statusCode, - target: 'external', - }; - - if (logger.isLevelEnabled('debug') && body !== undefined) { - logData.body = body; - } - - if (statusCode >= 400) { - logger.error(logData, 'HTTP response received from external API (error)'); - } else { - logger.info(logData, 'HTTP response received from external API'); - } -} - await initSensitiveStrings(); // Create and export the logger instance @@ -170,29 +129,3 @@ export const frontendLogger = pino( ); // Export a default as well for convenience -export default logger; - -/** - * Log with trace ID context - * @param level Log level ('info', 'warn', 'error', 'debug') - * @param traceId Trace ID for request correlation - * @param message Log message - * @param data Additional data to log - */ -export function logWithTrace( - level: 'info' | 'warn' | 'error' | 'debug', - traceId: number, - message: string, - data?: Record -) { - logger[level]({ traceId, ...data }, message); -} - -/** - * Create a child logger with trace ID bound - * @param traceId Trace ID to bind to the logger - * @returns A child logger instance with trace ID in all log entries - */ -export function createTraceLogger(traceId: number) { - return logger.child({ traceId }); -} diff --git a/apps/cli/package.json b/apps/cli/package.json index 60896a53..3473209f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -9,27 +9,22 @@ "@types/bun": "latest", "@vitest/coverage-v8": "^4.0.18", "proxy-chain": "^3.0.0", - "socket.io-client": "^4.8.3", "typescript": "^5", "vitest": "^4.0.18" }, "dependencies": { - "@ai-sdk/openai": "^3.0.11", "@ai-sdk/openai-compatible": "^2.0.11", - "@assistant-ui/react-ai-sdk": "^1.3.7", "@hono/node-server": "^1.19.0", - "@modelcontextprotocol/sdk": "^1.25.3", + "@smm/core": "workspace:*", "@smm/core-routes": "workspace:*", - "@smm/types": "workspace:*", - "@smm/utils": "workspace:*", "@smm/test": "workspace:*", "@smm/tvdb4": "workspace:*", + "@smm/types": "workspace:*", + "@smm/utils": "workspace:*", "@types/shelljs": "^0.10.0", "ai": "^6.0.36", "commander": "^15.0.0", - "@smm/core": "workspace:*", "dotenv": "^17.2.3", - "es-toolkit": "^1.43.0", "hono": "^4.10.8", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", @@ -37,14 +32,11 @@ "i18next-fs-backend": "^2.6.1", "node-pty": "^1.1.0", "pino": "^10.1.0", - "pino-roll": "^1.3.0", "rotating-file-stream": "^3.2.9", - "sanitize-filename": "^1.6.3", "shelljs": "^0.10.0", "socket.io": "^4.8.3", "socks-proxy-agent": "^8.0.5", "strip-ansi": "^7.2.0", - "xmlbuilder2": "^4.0.3", "zod": "^4.1.8" }, "scripts": { diff --git a/apps/cli/src/cli/addProgress.ts b/apps/cli/src/cli/addProgress.ts index d56db120..f459692a 100644 --- a/apps/cli/src/cli/addProgress.ts +++ b/apps/cli/src/cli/addProgress.ts @@ -1,6 +1,6 @@ import type { Core, FolderType, ImportJob, JobStage } from '@smm/core' -export type AddProgressKind = 'tvshow' | 'movie' +type AddProgressKind = 'tvshow' | 'movie' function mediaKind(type: FolderType): AddProgressKind | null { if (type === 'tvshow') return 'tvshow' diff --git a/apps/cli/src/cli/folderDisplay.ts b/apps/cli/src/cli/folderDisplay.ts index d8f0ea05..b8d5d6ef 100644 --- a/apps/cli/src/cli/folderDisplay.ts +++ b/apps/cli/src/cli/folderDisplay.ts @@ -30,7 +30,7 @@ export async function isFolderImported(folder: string): Promise { }) } -export type ShowFolderStatus = +type ShowFolderStatus = | 'ok' | 'folder_not_found' | 'error_loading_metadata' diff --git a/apps/cli/src/cli/scrapeJobFormat.ts b/apps/cli/src/cli/scrapeJobFormat.ts index 396027a4..d063e8e1 100644 --- a/apps/cli/src/cli/scrapeJobFormat.ts +++ b/apps/cli/src/cli/scrapeJobFormat.ts @@ -1,7 +1,7 @@ import type { ScrapeJob, ScrapeTaskRuntimeStatus } from '@smm/core' /** CLI display order; Core task id `thumbnails` is shown as `thumbnail`. */ -export const SCRAPE_TASK_LINES = [ +const SCRAPE_TASK_LINES = [ { taskId: 'poster', label: 'poster' }, { taskId: 'fanart', label: 'fanart' }, { taskId: 'thumbnails', label: 'thumbnail' }, diff --git a/apps/cli/src/events/index.ts b/apps/cli/src/events/index.ts deleted file mode 100644 index 15b4d377..00000000 --- a/apps/cli/src/events/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * This folder holds socket.io events to UI. - */ - -export * from './userConfigUpdatedEvent'; \ No newline at end of file diff --git a/apps/cli/src/events/ping.ts b/apps/cli/src/events/ping.ts deleted file mode 100644 index 45cbeff3..00000000 --- a/apps/cli/src/events/ping.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { broadcast } from "../utils/socketIO"; - -export function ping() { - broadcast({ - event: 'ping', - data: { - timestamp: Date.now() - } - }); -} diff --git a/apps/cli/src/events/userConfigUpdatedEvent.ts b/apps/cli/src/events/userConfigUpdatedEvent.ts index 74a4696b..a581c66d 100644 --- a/apps/cli/src/events/userConfigUpdatedEvent.ts +++ b/apps/cli/src/events/userConfigUpdatedEvent.ts @@ -1,12 +1,5 @@ import { broadcast } from "../utils/socketIO"; -import { USER_CONFIG_FOLDER_RENAMED_EVENT, USER_CONFIG_UPDATED_EVENT, type UserConfigFolderRenamedEventData, type UserConfigUpdatedEventData } from "@smm/types/event-types"; - -export function broadcastUserConfigUpdatedEvent(data: UserConfigUpdatedEventData) { - broadcast({ - event: USER_CONFIG_UPDATED_EVENT, - data: data - }); -} +import { USER_CONFIG_FOLDER_RENAMED_EVENT, type UserConfigFolderRenamedEventData } from "@smm/types/event-types"; export function broadcastUserConfigFolderRenamedEvent(data: UserConfigFolderRenamedEventData) { broadcast({ diff --git a/apps/cli/src/i18n/helpers.ts b/apps/cli/src/i18n/helpers.ts index f9c8fdd8..70e0679d 100644 --- a/apps/cli/src/i18n/helpers.ts +++ b/apps/cli/src/i18n/helpers.ts @@ -1,7 +1,7 @@ import { resolveAppLanguage, detectOsLocale } from '@smm/utils/locale'; import { getUserConfig } from '@/utils/config'; import { getI18n } from './config'; -import logger from '../../lib/logger'; +import { logger } from '../../lib/logger'; /** * Gets the user's preferred language for tool descriptions from global user config. @@ -18,7 +18,7 @@ import logger from '../../lib/logger'; * // Returns: 'zh-CN' or 'en' based on global user config * ``` */ -export async function getToolLanguage(): Promise { +async function getToolLanguage(): Promise { try { // Get user config to retrieve language preference diff --git a/apps/cli/src/mcp/bunMcpLifecycleManager.ts b/apps/cli/src/mcp/bunMcpLifecycleManager.ts index ebead778..e523b58d 100644 --- a/apps/cli/src/mcp/bunMcpLifecycleManager.ts +++ b/apps/cli/src/mcp/bunMcpLifecycleManager.ts @@ -1,10 +1,5 @@ import type { McpLifecycleManager } from "@smm/core-routes"; -import { getBunMcpServerPort, setBunMcpServerError } from "./BunMcpServerPort"; - -export type { McpServerState } from "@smm/core"; -export type McpServerStatus = "running" | "stopped" | "error"; - -export { getBunMcpServerPort, setBunMcpServerError }; +import { getBunMcpServerPort } from "./BunMcpServerPort"; /** * MCP lifecycle manager for core-routes HTTP handlers and legacy callers. @@ -28,23 +23,3 @@ export function getBunMcpLifecycleManager(): McpLifecycleManager { }, }; } - -/** @deprecated Use {@link getBunMcpLifecycleManager}.getState() via Core */ -export function getMcpServerState() { - return getBunMcpServerPort().getState(); -} - -/** @deprecated Use Core.startMcpServer */ -export async function startMcpServer(options?: { - hostname?: string; - port?: number; -}): Promise { - const { getCore } = await import("@/core/getCore"); - await getCore().startMcpServer(options, { persistUserConfig: true }); -} - -/** @deprecated Use Core.stopMcpServer */ -export async function stopMcpServer(): Promise { - const { getCore } = await import("@/core/getCore"); - await getCore().stopMcpServer({ persistUserConfig: true }); -} diff --git a/apps/cli/src/mcp/mcpServerManager.ts b/apps/cli/src/mcp/mcpServerManager.ts index 6ee9e754..cb91149d 100644 --- a/apps/cli/src/mcp/mcpServerManager.ts +++ b/apps/cli/src/mcp/mcpServerManager.ts @@ -2,16 +2,6 @@ import { getCore } from "@/core/getCore"; import { logger } from "../../lib/logger"; import { setBunMcpServerError } from "./BunMcpServerPort"; -export type { McpServerState, McpServerStatus } from "./bunMcpLifecycleManager"; -export { - getMcpServerState, - startMcpServer, - stopMcpServer, - getBunMcpLifecycleManager, - getBunMcpServerPort, - setBunMcpServerError, -} from "./bunMcpLifecycleManager"; - /** * Reads user config and starts or stops the MCP server accordingly. * Used at CLI HTTP server startup to honour the persisted enableMcpServer setting. diff --git a/apps/cli/src/mcp/tools/mcpToolBase.ts b/apps/cli/src/mcp/tools/mcpToolBase.ts deleted file mode 100644 index bfcae70a..00000000 --- a/apps/cli/src/mcp/tools/mcpToolBase.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Path } from "@smm/utils/path"; - -/** - * Standard MCP tool response interface. - * - * Mirrors the `McpToolResponse` shape used by the MCP server - * factory in `@smm/core-routes/src/mcp/`. Lives in `apps/cli` - * because the agent tool wrappers in `apps/cli/src/tools/*` still - * return this shape for the chat pipeline and debug routes. - */ -export interface McpToolResponse { - content: Array<{ - type: "text"; - text: string; - annotations?: { - audience?: ("user" | "assistant")[]; - priority?: number; - lastModified?: string; - }; - _meta?: { [key: string]: unknown }; - }>; - structuredContent?: { [x: string]: unknown }; - isError?: boolean; - _meta?: { [key: string]: unknown }; - [key: string]: unknown; -} - -/** - * Create a success MCP tool response. The payload is serialised to - * JSON inside `content[0].text` and exposed via `structuredContent` - * for clients that prefer the structured form. - */ -export function createSuccessResponse(data: { [x: string]: unknown }): McpToolResponse { - return { - content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], - structuredContent: data, - }; -} - -/** - * Create an error MCP tool response. The error is flagged via - * `isError: true` so the MCP client can surface it as a tool - * failure. - */ -export function createErrorResponse(message: string): McpToolResponse { - return { - content: [{ type: "text" as const, text: message }], - isError: true, - }; -} - -/** - * Normalize a path to platform-specific format. - */ -export function normalizePath(path: string): string { - return Path.toPlatformPath(path); -} - -/** - * Convert a path to POSIX format for internal operations. - */ -export function toPosixPath(path: string): string { - return Path.posix(path); -} diff --git a/apps/cli/src/route/CoreFetch.ts b/apps/cli/src/route/CoreFetch.ts index deb40de3..e1397205 100644 --- a/apps/cli/src/route/CoreFetch.ts +++ b/apps/cli/src/route/CoreFetch.ts @@ -2,7 +2,7 @@ import type { Hono } from 'hono' import { NodejsNetworkPort } from '../core/NodejsNetworkPort' import { logger } from '../../lib/logger' -export interface CoreFetchRequestBody { +interface CoreFetchRequestBody { url?: unknown method?: unknown headers?: unknown @@ -10,7 +10,7 @@ export interface CoreFetchRequestBody { proxy?: unknown } -export interface CoreFetchResponseData { +interface CoreFetchResponseData { ok: boolean status: number statusText: string @@ -18,7 +18,7 @@ export interface CoreFetchResponseData { bodyBase64: string } -export interface CoreFetchResponseBody { +interface CoreFetchResponseBody { data?: CoreFetchResponseData error?: string } diff --git a/apps/cli/src/route/Debug.ts b/apps/cli/src/route/Debug.ts index f332fbe4..9282d92f 100644 --- a/apps/cli/src/route/Debug.ts +++ b/apps/cli/src/route/Debug.ts @@ -58,7 +58,7 @@ const debugRequestSchema = z.discriminatedUnion('name', [ // Add more schemas here as new debug functions are added ]); -export async function processDebugRequest(body: any): Promise { +async function processDebugRequest(body: any): Promise { try { console.log(`[DebugAPI] Received debug request:`, body); diff --git a/apps/cli/src/route/DeleteFile.ts b/apps/cli/src/route/DeleteFile.ts index 3c19cb47..6ec930a7 100644 --- a/apps/cli/src/route/DeleteFile.ts +++ b/apps/cli/src/route/DeleteFile.ts @@ -23,7 +23,7 @@ const coreRoutesLogger: CoreRoutesLogger = { * previously restricted to `{userDataDir}/temp/ytdlp-cookies-*.txt` * via `isManagedYtdlpCookiesPath`). */ -export async function processDeleteFile( +async function processDeleteFile( body: DeleteFileRequestBody, ): Promise { const allowlist = await buildAllowlist(); diff --git a/apps/cli/src/route/DeleteFolder.ts b/apps/cli/src/route/DeleteFolder.ts index 86d47813..c88e0678 100644 --- a/apps/cli/src/route/DeleteFolder.ts +++ b/apps/cli/src/route/DeleteFolder.ts @@ -21,7 +21,7 @@ const coreRoutesLogger: CoreRoutesLogger = { * Delegates to `doDeleteFolder` in `@smm/core-routes`. Path validation * is allowlist-based (any directory inside the allowlist is deletable). */ -export async function processDeleteFolder( +async function processDeleteFolder( body: DeleteFolderRequestBody, ): Promise { const allowlist = await buildAllowlist(); diff --git a/apps/cli/src/route/DownloadImage.ts b/apps/cli/src/route/DownloadImage.ts index 34316458..50773e13 100644 --- a/apps/cli/src/route/DownloadImage.ts +++ b/apps/cli/src/route/DownloadImage.ts @@ -25,7 +25,7 @@ const coreRoutesLogger = { * - writing the binary response with the right headers, * - mapping thrown errors to `500 { error }` JSON. */ -export async function processDownloadImage(url: string): Promise { +async function processDownloadImage(url: string): Promise { const allowlist = await buildAllowlist(); return doDownloadImageCore(url, { allowlist, logger: coreRoutesLogger }); } diff --git a/apps/cli/src/route/FolderMetadata.ts b/apps/cli/src/route/FolderMetadata.ts index 3527f5cd..fa49fdfc 100644 --- a/apps/cli/src/route/FolderMetadata.ts +++ b/apps/cli/src/route/FolderMetadata.ts @@ -4,7 +4,7 @@ import { getCore } from '../core/getCore' import { logger } from '../../lib/logger' import { isFolderImported } from '../cli/folderDisplay' -export interface FolderMetadataResponseBody { +interface FolderMetadataResponseBody { data?: Omit error?: string } diff --git a/apps/cli/src/route/GetFolders.ts b/apps/cli/src/route/GetFolders.ts index d82f4e79..83d4bf66 100644 --- a/apps/cli/src/route/GetFolders.ts +++ b/apps/cli/src/route/GetFolders.ts @@ -2,7 +2,7 @@ import type { Hono } from 'hono' import { getCore } from '../core/getCore' import { logger } from '../../lib/logger' -export interface GetFoldersResponseBody { +interface GetFoldersResponseBody { data?: { folders: string[] } error?: string } diff --git a/apps/cli/src/route/GetJob.ts b/apps/cli/src/route/GetJob.ts index cc8dc264..33551e56 100644 --- a/apps/cli/src/route/GetJob.ts +++ b/apps/cli/src/route/GetJob.ts @@ -3,7 +3,7 @@ import type { Job } from '@smm/core' import { getCore } from '../core/getCore' import { logger } from '../../lib/logger' -export interface GetJobResponseBody { +interface GetJobResponseBody { data?: Job error?: string } diff --git a/apps/cli/src/route/ImportFolder.ts b/apps/cli/src/route/ImportFolder.ts index e4047dfc..70618085 100644 --- a/apps/cli/src/route/ImportFolder.ts +++ b/apps/cli/src/route/ImportFolder.ts @@ -5,7 +5,7 @@ import { logger } from '../../lib/logger' const FOLDER_TYPES: readonly FolderType[] = ['tvshow', 'movie', 'music'] -export interface ImportFolderResponseBody { +interface ImportFolderResponseBody { data?: { id: string } error?: string } diff --git a/apps/cli/src/route/ImportLibrary.ts b/apps/cli/src/route/ImportLibrary.ts index 79e63a27..f7ceeae1 100644 --- a/apps/cli/src/route/ImportLibrary.ts +++ b/apps/cli/src/route/ImportLibrary.ts @@ -5,7 +5,7 @@ import { logger } from '../../lib/logger' const FOLDER_TYPES: readonly FolderType[] = ['tvshow', 'movie', 'music'] -export interface ImportLibraryResponseBody { +interface ImportLibraryResponseBody { data?: { id: string } error?: string } diff --git a/apps/cli/src/route/IsFolderAvailable.ts b/apps/cli/src/route/IsFolderAvailable.ts index f77316c1..501d17e9 100644 --- a/apps/cli/src/route/IsFolderAvailable.ts +++ b/apps/cli/src/route/IsFolderAvailable.ts @@ -15,7 +15,7 @@ const coreRoutesLogger = { error: (obj: Record, msg?: string) => logger.error(obj, msg), }; -export async function processIsFolderAvailable(body: IsFolderAvailableRequestBody): Promise { +async function processIsFolderAvailable(body: IsFolderAvailableRequestBody): Promise { const allowlist = await buildAllowlist(); return doIsFolderAvailableCore(body, { allowlist, logger: coreRoutesLogger }); } diff --git a/apps/cli/src/route/ListDrives.ts b/apps/cli/src/route/ListDrives.ts index 5a6483ad..112fc12e 100644 --- a/apps/cli/src/route/ListDrives.ts +++ b/apps/cli/src/route/ListDrives.ts @@ -4,7 +4,7 @@ import { logger } from '../../lib/logger'; // eslint-disable-next-line @typescript-eslint/no-require-imports const shell = require('shelljs'); -export interface ListDrivesResponseBody { +interface ListDrivesResponseBody { data: string[]; error?: string; } @@ -63,7 +63,7 @@ function isAdministrativeShare(path: string): boolean { * Media Disk * Photos Disk */ -export function _parseNetViewOutput(output: string): string[] { +function _parseNetViewOutput(output: string): string[] { try { const shares: string[] = []; const lines = output.split('\n'); @@ -390,7 +390,7 @@ function networkDrives(): string[] { } -export function _parseLocalDrivesOutput(output: string): string[] { +function _parseLocalDrivesOutput(output: string): string[] { /** * Example output: * C:\ @@ -407,7 +407,7 @@ export function _parseLocalDrivesOutput(output: string): string[] { * Always assume it's in Windows * @returns return the drive paths. If the command fail or timeout, return an empty array. */ -export function localDrives(): string[] { +function localDrives(): string[] { try { // Use -NoProfile to speed up PowerShell startup (skips loading profile, faster execution) diff --git a/apps/cli/src/route/MoveFileToTrash.ts b/apps/cli/src/route/MoveFileToTrash.ts index d03a39d2..caed460d 100644 --- a/apps/cli/src/route/MoveFileToTrash.ts +++ b/apps/cli/src/route/MoveFileToTrash.ts @@ -10,7 +10,7 @@ const moveFileToTrashRequestSchema = z.object({ path: z.string().min(1, 'Path is required'), }); -export async function doMoveFileToTrash( +async function doMoveFileToTrash( body: MoveFileToTrashRequestBody, ): Promise { try { diff --git a/apps/cli/src/route/ReadFile.ts b/apps/cli/src/route/ReadFile.ts index cd53ec95..a017fb4b 100644 --- a/apps/cli/src/route/ReadFile.ts +++ b/apps/cli/src/route/ReadFile.ts @@ -12,7 +12,7 @@ const coreRoutesLogger = { error: (obj: Record, msg?: string) => logger.error(obj, msg), }; -export async function processReadFile(body: ReadFileRequestBody): Promise { +async function processReadFile(body: ReadFileRequestBody): Promise { const allowlist = await buildAllowlist(); return doReadFileCore(body, { allowlist, logger: coreRoutesLogger }); } diff --git a/apps/cli/src/route/ReadImage.ts b/apps/cli/src/route/ReadImage.ts index ac72dcf8..38bf4e59 100644 --- a/apps/cli/src/route/ReadImage.ts +++ b/apps/cli/src/route/ReadImage.ts @@ -24,7 +24,7 @@ const coreRoutesLogger = { * / allowlist / I/O failures. This matches the original Hono * handler contract so the UI does not need to change. */ -export async function processReadImage( +async function processReadImage( body: ReadImageRequestBody, ): Promise { const allowlist = await buildAllowlist(); diff --git a/apps/cli/src/route/RecognizeEpisodesPlan.ts b/apps/cli/src/route/RecognizeEpisodesPlan.ts index 8c27af62..d9f7a820 100644 --- a/apps/cli/src/route/RecognizeEpisodesPlan.ts +++ b/apps/cli/src/route/RecognizeEpisodesPlan.ts @@ -11,13 +11,7 @@ import { broadcast } from '@/utils/socketIO' import { getAppDataDir } from '@/utils/config' import { logger } from '../../lib/logger' -export interface CreateRecognizeEpisodePlanRequestBody { - mediaFolderPath: string - files: Array<{ season: number; episode: number; path: string }> - creator?: 'ai' | 'app' -} - -export interface CreateRecognizeEpisodePlanResponseBody { +interface CreateRecognizeEpisodePlanResponseBody { data?: { plan: RecognizeMediaFilePlan } error?: string } @@ -49,7 +43,7 @@ function readRecognizeFiles( return files as Array<{ season: number; episode: number; path: string }> } -export async function createRecognizeEpisodePlanFromBody( +async function createRecognizeEpisodePlanFromBody( body: unknown, ): Promise { const mediaFolderPath = readStringField(body, 'mediaFolderPath') diff --git a/apps/cli/src/route/RecognizeFolder.ts b/apps/cli/src/route/RecognizeFolder.ts index ed233f3d..e13ec9e7 100644 --- a/apps/cli/src/route/RecognizeFolder.ts +++ b/apps/cli/src/route/RecognizeFolder.ts @@ -3,13 +3,7 @@ import type { RecognizeFolderDb } from '@smm/core' import { getCore } from '../core/getCore' import { logger } from '../../lib/logger' -export interface RecognizeFolderRequestBody { - path: string - db: RecognizeFolderDb - id: string -} - -export interface RecognizeFolderResponseBody { +interface RecognizeFolderResponseBody { data?: { path: string } error?: string } diff --git a/apps/cli/src/route/RenameEpisodeFile.ts b/apps/cli/src/route/RenameEpisodeFile.ts index 54d529b7..6e807687 100644 --- a/apps/cli/src/route/RenameEpisodeFile.ts +++ b/apps/cli/src/route/RenameEpisodeFile.ts @@ -4,13 +4,7 @@ import { getCore } from '../core/getCore' import { broadcast } from '@/utils/socketIO' import { logger } from '../../lib/logger' -export interface RenameEpisodeFileRequestBody { - mediaFolder: string - from: string - to: string -} - -export interface RenameEpisodeFileResponseBody { +interface RenameEpisodeFileResponseBody { data?: { succeeded: Array<{ from: string; to: string }> failed: Array<{ path: string; error: string }> diff --git a/apps/cli/src/route/RenameEpisodesPlan.ts b/apps/cli/src/route/RenameEpisodesPlan.ts index b6c4e935..98d339fd 100644 --- a/apps/cli/src/route/RenameEpisodesPlan.ts +++ b/apps/cli/src/route/RenameEpisodesPlan.ts @@ -15,42 +15,22 @@ import { broadcast } from '@/utils/socketIO' import { getAppDataDir } from '@/utils/config' import { logger } from '../../lib/logger' -export interface TryToRenameEpisodesRequestBody { - mediaFolderPath: string - rule?: 'plex' | 'emby' -} - -export interface TryToRenameEpisodesResponseBody { +interface TryToRenameEpisodesResponseBody { data?: { plan: RenameFilesPlan } error?: string } -export interface CreateRenameEpisodePlanRequestBody { - mediaFolderPath: string - files: Array<{ from: string; to: string }> - creator?: 'ai' | 'app' -} - export interface CreateRenameEpisodePlanResponseBody { data?: { plan: RenameFilesPlan } error?: string } -export interface ApplyPlanRequestBody { - id: string - data?: { files?: string[] } -} - -export interface ApplyPlanResponseBody { +interface ApplyPlanResponseBody { data?: { id: string } error?: string } -export interface RejectPlanRequestBody { - id: string -} - -export interface RejectPlanResponseBody { +interface RejectPlanResponseBody { data?: { plan: Plan } error?: string } diff --git a/apps/cli/src/route/RenameFiles.ts b/apps/cli/src/route/RenameFiles.ts index dc152632..5896350f 100644 --- a/apps/cli/src/route/RenameFiles.ts +++ b/apps/cli/src/route/RenameFiles.ts @@ -12,7 +12,7 @@ const coreRoutesLogger: CoreRoutesLogger = { error: (obj, msg) => logger.error(obj, msg), }; -export async function processRenameFiles( +async function processRenameFiles( body: RenameFilesRequestBody, clientId?: string, ): Promise { diff --git a/apps/cli/src/route/RenameFolderV3.ts b/apps/cli/src/route/RenameFolderV3.ts index adba9808..6dd93de8 100644 --- a/apps/cli/src/route/RenameFolderV3.ts +++ b/apps/cli/src/route/RenameFolderV3.ts @@ -5,12 +5,7 @@ import { broadcastUserConfigFolderRenamedEvent } from '@/events/userConfigUpdate import { broadcast } from '@/utils/socketIO' import { logger } from '../../lib/logger' -export interface RenameFolderV3RequestBody { - from: string - to: string -} - -export interface RenameFolderV3ResponseBody { +interface RenameFolderV3ResponseBody { data?: { from: string; to: string } error?: string } diff --git a/apps/cli/src/route/Scrape.ts b/apps/cli/src/route/Scrape.ts index 784a2d55..257a2379 100644 --- a/apps/cli/src/route/Scrape.ts +++ b/apps/cli/src/route/Scrape.ts @@ -2,7 +2,7 @@ import type { Hono } from 'hono' import { getCore } from '../core/getCore' import { logger } from '../../lib/logger' -export interface ScrapeResponseBody { +interface ScrapeResponseBody { data?: { id: string } error?: string } diff --git a/apps/cli/src/route/SetWatchedFolder.ts b/apps/cli/src/route/SetWatchedFolder.ts index 18f917e7..0c27fb30 100644 --- a/apps/cli/src/route/SetWatchedFolder.ts +++ b/apps/cli/src/route/SetWatchedFolder.ts @@ -6,7 +6,7 @@ import type { import { getFolderWatcher } from '../services/folderWatcher'; import { logger, logHttpReqIn, logHttpRespOut } from '../../lib/logger'; -export async function processSetWatchedFolder( +async function processSetWatchedFolder( body: SetWatchedFolderRequestBody, ): Promise { const folderPath = diff --git a/apps/cli/src/route/ShowFolder.ts b/apps/cli/src/route/ShowFolder.ts index 387f20cb..73527d7f 100644 --- a/apps/cli/src/route/ShowFolder.ts +++ b/apps/cli/src/route/ShowFolder.ts @@ -6,7 +6,7 @@ import { type ShowFolderResult, } from '../cli/folderDisplay' -export interface ShowFolderResponseBody { +interface ShowFolderResponseBody { data?: ShowFolderResult error?: string } diff --git a/apps/cli/src/route/Tmdb.ts b/apps/cli/src/route/Tmdb.ts index e0df056d..d9d6afbb 100644 --- a/apps/cli/src/route/Tmdb.ts +++ b/apps/cli/src/route/Tmdb.ts @@ -2,12 +2,12 @@ import type { Hono } from 'hono' import { getCore } from '../core/getCore' import { logger } from '../../lib/logger' -export interface TmdbSearchHttpResponseBody { +interface TmdbSearchHttpResponseBody { data?: unknown error?: string } -export interface TmdbDetailsHttpResponseBody { +interface TmdbDetailsHttpResponseBody { data?: unknown error?: string } diff --git a/apps/cli/src/route/TryToRecognizeEpisodes.ts b/apps/cli/src/route/TryToRecognizeEpisodes.ts index 5dbc6e5c..794e42e2 100644 --- a/apps/cli/src/route/TryToRecognizeEpisodes.ts +++ b/apps/cli/src/route/TryToRecognizeEpisodes.ts @@ -3,11 +3,7 @@ import type { RecognizeMediaFilePlan } from '@smm/types/RecognizeMediaFilePlan' import { getCore } from '../core/getCore' import { logger } from '../../lib/logger' -export interface TryToRecognizeEpisodesRequestBody { - mediaFolderPath: string -} - -export interface TryToRecognizeEpisodesResponseBody { +interface TryToRecognizeEpisodesResponseBody { data?: { plan: RecognizeMediaFilePlan } error?: string } diff --git a/apps/cli/src/route/Tvdb.ts b/apps/cli/src/route/Tvdb.ts index 95ed78fa..f598f6e3 100644 --- a/apps/cli/src/route/Tvdb.ts +++ b/apps/cli/src/route/Tvdb.ts @@ -2,11 +2,6 @@ import type { Hono } from 'hono' import { getCore } from '../core/getCore' import { logger } from '../../lib/logger' -export interface TvdbHttpResponseBody { - data?: unknown - error?: string -} - function optionalString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined } diff --git a/apps/cli/src/route/UnimportFolder.ts b/apps/cli/src/route/UnimportFolder.ts index 25074489..68291603 100644 --- a/apps/cli/src/route/UnimportFolder.ts +++ b/apps/cli/src/route/UnimportFolder.ts @@ -2,7 +2,7 @@ import type { Hono } from 'hono' import { getCore } from '../core/getCore' import { logger } from '../../lib/logger' -export interface UnimportFolderResponseBody { +interface UnimportFolderResponseBody { data?: { path: string } error?: string } diff --git a/apps/cli/src/route/WriteFile.ts b/apps/cli/src/route/WriteFile.ts index ec884cbf..c32d2db7 100644 --- a/apps/cli/src/route/WriteFile.ts +++ b/apps/cli/src/route/WriteFile.ts @@ -12,7 +12,7 @@ const coreRoutesLogger = { error: (obj: Record, msg?: string) => logger.error(obj, msg), }; -export async function doWriteFile(body: WriteFileRequestBody, traceId: string = ''): Promise { +async function doWriteFile(body: WriteFileRequestBody, traceId: string = ''): Promise { const allowlist = await buildAllowlist(); return doWriteFileCore(body, { allowlist, logger: coreRoutesLogger }, traceId); } @@ -48,4 +48,3 @@ export function handleWriteFile(app: Hono) { }); } -export { isError, ExistedFileError }; diff --git a/apps/cli/src/route/ai.ts b/apps/cli/src/route/ai.ts index b1cb47d1..92b48a48 100644 --- a/apps/cli/src/route/ai.ts +++ b/apps/cli/src/route/ai.ts @@ -23,7 +23,7 @@ const schema = z.object({ ).describe('Array of matched media files with their corresponding season and episode numbers'), }); -export async function matchMediaFilesToEpisode(config: OpenAICompatibleConfig, prompt: string): Promise { +async function matchMediaFilesToEpisode(config: OpenAICompatibleConfig, prompt: string): Promise { // Validate required config values if (!config.baseURL) { throw new Error('baseURL is required in OpenAICompatibleConfig'); diff --git a/apps/cli/src/route/commandExecutionRegistry.ts b/apps/cli/src/route/commandExecutionRegistry.ts index db0bce13..51481e7e 100644 --- a/apps/cli/src/route/commandExecutionRegistry.ts +++ b/apps/cli/src/route/commandExecutionRegistry.ts @@ -1,4 +1,4 @@ -export type CommandExecutionPhase = 'unknown' | 'running' | 'finished'; +type CommandExecutionPhase = 'unknown' | 'running' | 'finished'; export type CommandExecutionOutcome = 'success' | 'failure'; diff --git a/apps/cli/src/route/commandExecutionStatus.ts b/apps/cli/src/route/commandExecutionStatus.ts index ec28d9b8..e072adcb 100644 --- a/apps/cli/src/route/commandExecutionStatus.ts +++ b/apps/cli/src/route/commandExecutionStatus.ts @@ -6,7 +6,7 @@ import { } from './commandExecutionRegistry'; import { readCommandExecutionStatusFromLog } from './commandExecutionLogStatus'; -export async function resolveCommandExecutionStatus( +async function resolveCommandExecutionStatus( executionId: string, ): Promise { const fromRegistry = getCommandExecutionRegistryStatus(executionId); diff --git a/apps/cli/src/route/coreRoutesConfig.ts b/apps/cli/src/route/coreRoutesConfig.ts index f3f57f3a..83c2876c 100644 --- a/apps/cli/src/route/coreRoutesConfig.ts +++ b/apps/cli/src/route/coreRoutesConfig.ts @@ -1,10 +1,7 @@ -import type { ChatConfig, CoreRoutesConfig, CoreRoutesLogger } from '@smm/core-routes' +import type { CoreRoutesConfig, CoreRoutesLogger } from '@smm/core-routes' import { buildAllowlist } from '@/utils/buildAllowlist' -import { getAppDataDir, getUserDataDir } from '@/utils/config' +import { getAppDataDir } from '@/utils/config' import { buildHelloOptions } from '../../tasks/HelloTask' -import { createAIProvider } from '../../lib/ai-provider' -import { getUserConfig } from '@/utils/config' -import { acknowledge as socketAcknowledge, broadcast as socketBroadcast } from '@/utils/socketIO' export async function buildCoreRoutesConfig( logger: CoreRoutesLogger, @@ -19,26 +16,4 @@ export async function buildCoreRoutesConfig( } } -/** - * Build the {@link ChatConfig} that drives `POST /api/chat` in - * `apps/cli`. The chat pipeline lives in `@smm/core-routes`; this - * module is the Bun-specific wiring (provider factory, user-config - * reader, Socket.IO acknowledge helper, plan-rename deps). - */ -export function buildChatConfig( - logger: CoreRoutesLogger, - appDataDir: string, - userDataDir: string = getUserDataDir(), -): ChatConfig { - return { - appDataDir, - userDataDir, - logger, - createAIProvider: (userConfig) => createAIProvider(userConfig), - getUserConfig: () => getUserConfig(), - acknowledge: (message, timeoutMs) => socketAcknowledge(message as never, timeoutMs), - broadcast: (message) => socketBroadcast(message as never), - } -} - diff --git a/apps/cli/src/route/debug/debugGetApplicationContext.ts b/apps/cli/src/route/debug/debugGetApplicationContext.ts index 9ac86447..10d8c37b 100644 --- a/apps/cli/src/route/debug/debugGetApplicationContext.ts +++ b/apps/cli/src/route/debug/debugGetApplicationContext.ts @@ -14,7 +14,7 @@ const getApplicationContextSchema = z.object({ clientId: z.string().optional(), }); -export async function processGetApplicationContext( +async function processGetApplicationContext( body: unknown, ): Promise { try { diff --git a/apps/cli/src/route/debug/debugGetEpisodesTool.ts b/apps/cli/src/route/debug/debugGetEpisodesTool.ts index efb82eda..d11d150c 100644 --- a/apps/cli/src/route/debug/debugGetEpisodesTool.ts +++ b/apps/cli/src/route/debug/debugGetEpisodesTool.ts @@ -15,7 +15,7 @@ const getEpisodesToolSchema = z.object({ clientId: z.string().optional(), }) -export async function processGetEpisodesTool( +async function processGetEpisodesTool( body: unknown, ): Promise { try { diff --git a/apps/cli/src/route/debug/debugGetJobTool.ts b/apps/cli/src/route/debug/debugGetJobTool.ts index 306cf968..2c3106d8 100644 --- a/apps/cli/src/route/debug/debugGetJobTool.ts +++ b/apps/cli/src/route/debug/debugGetJobTool.ts @@ -15,7 +15,7 @@ const getJobToolSchema = z.object({ id: z.string().min(1, 'id is required'), }) -export async function processGetJobTool( +async function processGetJobTool( body: unknown, ): Promise { try { diff --git a/apps/cli/src/route/debug/debugGetMediaFolders.ts b/apps/cli/src/route/debug/debugGetMediaFolders.ts index 42e2314a..51caf21a 100644 --- a/apps/cli/src/route/debug/debugGetMediaFolders.ts +++ b/apps/cli/src/route/debug/debugGetMediaFolders.ts @@ -14,7 +14,7 @@ const getMediaFoldersSchema = z.object({ clientId: z.string().optional(), }) -export async function processGetMediaFolders( +async function processGetMediaFolders( body: unknown, ): Promise { try { diff --git a/apps/cli/src/route/debug/debugGetMediaMetadata.ts b/apps/cli/src/route/debug/debugGetMediaMetadata.ts index 81f4f4a8..8b30c37c 100644 --- a/apps/cli/src/route/debug/debugGetMediaMetadata.ts +++ b/apps/cli/src/route/debug/debugGetMediaMetadata.ts @@ -25,7 +25,7 @@ const getMediaMetadataSchema = z.object({ clientId: z.string().optional(), }); -export async function processGetMediaMetadata(body: unknown): Promise { +async function processGetMediaMetadata(body: unknown): Promise { try { console.log('[DebugAPI] Received getMediaMetadata request:', body); diff --git a/apps/cli/src/route/debug/debugIsFolderExistTool.ts b/apps/cli/src/route/debug/debugIsFolderExistTool.ts index 0c3f377a..0b43ab40 100644 --- a/apps/cli/src/route/debug/debugIsFolderExistTool.ts +++ b/apps/cli/src/route/debug/debugIsFolderExistTool.ts @@ -17,7 +17,7 @@ const isFolderExistToolSchema = z.object({ clientId: z.string().optional(), }); -export async function processIsFolderExistTool(body: unknown): Promise { +async function processIsFolderExistTool(body: unknown): Promise { try { const validationResult = isFolderExistToolSchema.safeParse(body ?? {}); if (!validationResult.success) { diff --git a/apps/cli/src/route/debug/debugListFilesTool.ts b/apps/cli/src/route/debug/debugListFilesTool.ts index 786aa438..3283d06d 100644 --- a/apps/cli/src/route/debug/debugListFilesTool.ts +++ b/apps/cli/src/route/debug/debugListFilesTool.ts @@ -17,7 +17,7 @@ const listFilesToolSchema = z.object({ clientId: z.string().optional(), }) -export async function processListFilesTool( +async function processListFilesTool( body: unknown, ): Promise { try { diff --git a/apps/cli/src/route/debug/debugRecognizeTask.ts b/apps/cli/src/route/debug/debugRecognizeTask.ts index 0677895f..43d2c8d7 100644 --- a/apps/cli/src/route/debug/debugRecognizeTask.ts +++ b/apps/cli/src/route/debug/debugRecognizeTask.ts @@ -43,7 +43,7 @@ const endRecognizeTaskSchema = z.object({ clientId: z.string().optional(), }); -export async function processStartRecognizeTask(body: any): Promise> { +async function processStartRecognizeTask(body: any): Promise> { try { console.log(`[DebugAPI] Received startRecognizeTask request:`, body); @@ -85,7 +85,7 @@ export async function processStartRecognizeTask(body: any): Promise> { +async function processAddFileToRecognizeTask(body: any): Promise> { try { console.log(`[DebugAPI] Received addFileToRecognizeTask request:`, body); @@ -130,7 +130,7 @@ export async function processAddFileToRecognizeTask(body: any): Promise> { +async function processEndRecognizeTask(body: any): Promise> { try { console.log(`[DebugAPI] Received endRecognizeTask request:`, body); diff --git a/apps/cli/src/route/debug/debugRenameFolderTool.ts b/apps/cli/src/route/debug/debugRenameFolderTool.ts index 3fdc48b5..e8d54a4c 100644 --- a/apps/cli/src/route/debug/debugRenameFolderTool.ts +++ b/apps/cli/src/route/debug/debugRenameFolderTool.ts @@ -17,7 +17,7 @@ const renameFolderToolSchema = z.object({ to: z.string().min(1, 'Destination folder path is required'), }); -export async function processRenameFolderTool(body: unknown): Promise { +async function processRenameFolderTool(body: unknown): Promise { try { console.log('[DebugAPI] Received renameFolderTool request:', body); diff --git a/apps/cli/src/route/debug/debugScrapeTool.ts b/apps/cli/src/route/debug/debugScrapeTool.ts index b876a10c..e78539e8 100644 --- a/apps/cli/src/route/debug/debugScrapeTool.ts +++ b/apps/cli/src/route/debug/debugScrapeTool.ts @@ -16,7 +16,7 @@ const scrapeToolSchema = z.object({ language: z.string().optional(), }) -export async function processScrapeTool( +async function processScrapeTool( body: unknown, ): Promise { try { diff --git a/apps/cli/src/route/discover.ts b/apps/cli/src/route/discover.ts index 832c646b..b938e929 100644 --- a/apps/cli/src/route/discover.ts +++ b/apps/cli/src/route/discover.ts @@ -4,12 +4,7 @@ import { doFetchDiscoveredMediaDatabases, EMPTY_DISCOVER_CONFIG, type DiscoverConfig, - type DiscoverResponseBody, type MediaDatabaseEntry, - type MediaDatabaseType, - type MediaDatabaseAuthorizationMethod, - type ReverseProxyEntry, - type ReverseProxyType, } from '@smm/core-routes/discover'; import { logger } from '../../lib/logger'; @@ -20,21 +15,11 @@ const coreRoutesLogger = { error: (obj: Record, msg?: string) => logger.error(obj, msg), }; -export type { - DiscoverConfig, - DiscoverResponseBody, - MediaDatabaseEntry, - MediaDatabaseType, - MediaDatabaseAuthorizationMethod, - ReverseProxyEntry, - ReverseProxyType, -}; - /** * Fetch and normalize the remote discovery config. * On error, returns hardcoded fallback mediaDatabases so TMDB/TVDB hosts remain available. */ -export async function fetchDiscoverConfig(): Promise { +async function fetchDiscoverConfig(): Promise { return doFetchDiscoverConfig({ logger: coreRoutesLogger }); } diff --git a/apps/cli/src/route/discoverExecutables.ts b/apps/cli/src/route/discoverExecutables.ts index 5e7b3a69..38aaf76a 100644 --- a/apps/cli/src/route/discoverExecutables.ts +++ b/apps/cli/src/route/discoverExecutables.ts @@ -4,12 +4,12 @@ import { resolveYtdlpPathInfo } from '../utils/Ytdlp'; import { resolveVideoCaptionerPathInfo } from '../utils/VideoCaptioner'; import { resolveQuickjsPathInfo } from '../utils/QuickJS'; -export interface ExecutablePathInfo { +interface ExecutablePathInfo { configuredPath: string | null; discoveredPath: string | null; } -export interface DiscoverExecutablesData { +interface DiscoverExecutablesData { ffmpeg: ExecutablePathInfo; ytdlp: ExecutablePathInfo; videocaptioner: ExecutablePathInfo; diff --git a/apps/cli/src/route/executeCmd.ts b/apps/cli/src/route/executeCmd.ts index 64dab8a2..d2b6021e 100644 --- a/apps/cli/src/route/executeCmd.ts +++ b/apps/cli/src/route/executeCmd.ts @@ -12,7 +12,6 @@ import { z } from 'zod/v3'; import { logger } from '../../lib/logger'; import { COMMAND_WHITELIST, - type WhitelistedCommand, type YtdlpProgressData, type SystemEvent, enqueueYtDlpExecuteCmd, @@ -20,7 +19,6 @@ import { resolveSpawnArgsAndEnv, runCommand, runWhitelistedCommandSync, - type VideoCaptionerTranscribeResult, } from '../utils/cmd'; import { createCommandExecutionLogWriter } from './commandExecutionLog'; import { parseOptionalXCommandExecutionId } from './commandLog'; @@ -268,5 +266,3 @@ export function handleExecuteCmd(app: Hono) { // ─── Re-exports for downstream callers ────────────────────────────────────── export { runWhitelistedCommandSync }; -export type { VideoCaptionerTranscribeResult }; -export type { WhitelistedCommand }; diff --git a/apps/cli/src/route/getEpisodes.ts b/apps/cli/src/route/getEpisodes.ts index cd15a297..8bc32c43 100644 --- a/apps/cli/src/route/getEpisodes.ts +++ b/apps/cli/src/route/getEpisodes.ts @@ -14,7 +14,7 @@ const coreRoutesLogger: CoreRoutesLogger = { error: (obj, msg) => logger.error(obj, msg), } -export async function processGetEpisodes(body: unknown, _abortSignal?: AbortSignal) { +async function processGetEpisodes(body: unknown, _abortSignal?: AbortSignal) { const config = await buildCoreRoutesConfig(coreRoutesLogger) return doGetEpisodes(body, config) } diff --git a/apps/cli/src/route/listFilesInMediaFolder.ts b/apps/cli/src/route/listFilesInMediaFolder.ts index 51f83f48..7962969b 100644 --- a/apps/cli/src/route/listFilesInMediaFolder.ts +++ b/apps/cli/src/route/listFilesInMediaFolder.ts @@ -14,7 +14,7 @@ const coreRoutesLogger: CoreRoutesLogger = { error: (obj, msg) => logger.error(obj, msg), } -export async function processListFilesInMediaFolder( +async function processListFilesInMediaFolder( body: unknown, _abortSignal?: AbortSignal, ) { diff --git a/apps/cli/src/route/metadata/metadataSchemas.ts b/apps/cli/src/route/metadata/metadataSchemas.ts index e601dd84..e1517321 100644 --- a/apps/cli/src/route/metadata/metadataSchemas.ts +++ b/apps/cli/src/route/metadata/metadataSchemas.ts @@ -5,7 +5,7 @@ import type { } from '@smm/types' import { z } from 'zod' -export const metadataFolderTypeSchema = z.enum([ +const metadataFolderTypeSchema = z.enum([ 'music-folder', 'tvshow-folder', 'movie-folder', @@ -19,7 +19,7 @@ const mediaFileMetadataSchema: z.ZodType = z.object({ audioFilePaths: z.array(z.string()).optional(), }) -export const metadataMediaFilesSchema: z.ZodType = z.array( +const metadataMediaFilesSchema: z.ZodType = z.array( mediaFileMetadataSchema, ) @@ -27,12 +27,12 @@ function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } -export const tvShowMediaMetadataSchema: z.ZodType = z.custom( +const tvShowMediaMetadataSchema: z.ZodType = z.custom( (val) => isPlainObject(val), { message: 'tvShow must be an object' }, ) -export const movieMediaMetadataSchema: z.ZodType = z.custom( +const movieMediaMetadataSchema: z.ZodType = z.custom( (val) => isPlainObject(val), { message: 'movie must be an object' }, ) diff --git a/apps/cli/src/route/metadata/problemDetails.ts b/apps/cli/src/route/metadata/problemDetails.ts index 6c97f692..e29f8788 100644 --- a/apps/cli/src/route/metadata/problemDetails.ts +++ b/apps/cli/src/route/metadata/problemDetails.ts @@ -7,7 +7,7 @@ import { } from '@smm/core' import { ZodError } from 'zod' -export function problemJson( +function problemJson( c: Context, status: 400 | 404 | 409 | 500, type: string, diff --git a/apps/cli/src/route/path-validator.ts b/apps/cli/src/route/path-validator.ts deleted file mode 100644 index 34d6d55c..00000000 --- a/apps/cli/src/route/path-validator.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { validatePathIsInAllowlist as validatePathInAllowlistCore } from '@smm/core-routes'; -import { buildAllowlist } from '@/utils/buildAllowlist'; -import logger from '../../lib/logger'; - -/** - * @param filePath path in POSIX format - */ -export async function validatePathIsInAllowlist(filePath: string): Promise { - const allowlist = await buildAllowlist(); - - // The pino destination is wrapped with wrapWithMasking, which replaces - // the OS username (and other sensitive strings) with '******' before any - // line reaches disk or stdout. No call-site masking is needed. - logger.debug({ - allowlist, - filePath, - }, 'Validating path is in allowlist'); - - return validatePathInAllowlistCore(filePath, allowlist); -} diff --git a/apps/cli/src/route/validateRenameOperations.ts b/apps/cli/src/route/validateRenameOperations.ts index 756f907f..810eec98 100644 --- a/apps/cli/src/route/validateRenameOperations.ts +++ b/apps/cli/src/route/validateRenameOperations.ts @@ -22,7 +22,7 @@ export interface ValidateRenameOperationsResponseBody { error: string | null } -export async function processValidateRenameOperations( +async function processValidateRenameOperations( body: unknown, ): Promise { const parsed = requestSchema.safeParse(body) diff --git a/apps/cli/src/services/folderWatcher.ts b/apps/cli/src/services/folderWatcher.ts index 299214d5..1f3c2f88 100644 --- a/apps/cli/src/services/folderWatcher.ts +++ b/apps/cli/src/services/folderWatcher.ts @@ -259,15 +259,3 @@ export function resetFolderWatcherForTests(): void { instance = null; } } - -/** - * Initialize folder watching from a list of folder paths. - * Called during server startup. - */ -export function initializeFolderWatcher(folderPaths: string[], debounceMs?: number): FolderWatcher { - const watcher = getFolderWatcher(debounceMs); - for (const fp of folderPaths) { - watcher.startWatching(fp); - } - return watcher; -} diff --git a/apps/cli/src/tools/askForConfirmation.ts b/apps/cli/src/tools/askForConfirmation.ts deleted file mode 100644 index f59f6d0f..00000000 --- a/apps/cli/src/tools/askForConfirmation.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { z } from 'zod/v3'; -import { acknowledge } from '../utils/socketIO'; -import pino from "pino" -const logger = pino() - -export const createAskForConfirmationTool = (clientId: string, abortSignal?: AbortSignal) => ({ - description: `Ask user for confirmation. - This tool accepts "message" parameter which will be shown to user. - This tool return "yes" and "no" according to user's confirmation`, - toolName: 'askForConfirmation', - inputSchema: z.object({ - message: z.string().describe("The confirmation message to show to the user"), - }), - execute: async ({ message }: { message: string }) => { - // TODO: Implement abort handling - check abortSignal and cancel ongoing operations - if (abortSignal?.aborted) { - throw new Error('Request was aborted'); - } - logger.info(`[tool][askForConfirmation] clientId: ${clientId}, message: ${message}`); - - try { - // TODO: Check abortSignal during acknowledgement wait - // Send Socket.IO event to frontend and wait for acknowledgement response - const responseData = await acknowledge( - { - event: 'askForConfirmation', - data: { - message, - }, - clientId: clientId, - }, - 30000, // 30 second timeout - ); - - logger.info(`[tool][askForConfirmation] responseData: ${JSON.stringify(responseData)}`); - - // Extract the response from the acknowledgement data - const confirmed = responseData?.confirmed ?? responseData?.response === 'yes'; - const result = confirmed ? 'yes' : 'no'; - - console.log(`[tool][askForConfirmation] User response: ${result}`); - return result; - } catch (error) { - console.error('[tool][askForConfirmation] Error:', error); - // On timeout or error, throw error instead of defaulting to "no" - throw new Error(`Failed to get user confirmation: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - }, -}); diff --git a/apps/cli/src/tools/getApplicationContext.ts b/apps/cli/src/tools/getApplicationContext.ts index 7661dd7e..a5aff887 100644 --- a/apps/cli/src/tools/getApplicationContext.ts +++ b/apps/cli/src/tools/getApplicationContext.ts @@ -1,12 +1,8 @@ import { acknowledge, getFirstAvailableSocket } from '@/utils/socketIO' -import type { ToolDefinition } from './types' -import { createSuccessResponse, createErrorResponse } from '@/mcp/tools/mcpToolBase' import { resolveAppLanguage, detectOsLocale } from '@smm/utils/locale' import { getUserConfig } from '@/utils/config' -import { getLocalizedToolDescription } from '@/i18n/helpers' import { toolOk } from '@smm/core/ai-tool/toolResult' import { - GET_APPLICATION_CONTEXT, GET_APPLICATION_CONTEXT_DESCRIPTION, getApplicationContextInputSchema, getApplicationContextOutputSchema, @@ -73,27 +69,3 @@ export function getApplicationContextAgentTool(clientId: string) { } // ─── MCP tool (localised description) ─────────────────────────── - -export async function getApplicationContextMcpTool(): Promise { - const description = await getLocalizedToolDescription(GET_APPLICATION_CONTEXT) - - return { - toolName: GET_APPLICATION_CONTEXT, - description, - inputSchema: getApplicationContextInputSchema, - outputSchema: getApplicationContextOutputSchema, - execute: async () => { - try { - const result = await executeGetApplicationContext() - return createSuccessResponse( - result as unknown as { [x: string]: unknown }, - ) - } catch (error) { - console.error('[getApplicationContext] MCP tool error:', error) - return createErrorResponse( - error instanceof Error ? error.message : 'Unknown error', - ) - } - }, - } -} diff --git a/apps/cli/src/tools/getEpisode.ts b/apps/cli/src/tools/getEpisode.ts deleted file mode 100644 index 53a01c5e..00000000 --- a/apps/cli/src/tools/getEpisode.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { z } from "zod"; -import type { ToolDefinition } from "./types"; -import { createSuccessResponse, createErrorResponse } from "@/mcp/tools/mcpToolBase"; -import { findMediaMetadata } from "@/utils/mediaMetadata"; -import { Path } from "@smm/utils/path"; -import logger from "../../lib/logger"; -import { getLocalizedToolDescription } from '@/i18n/helpers'; - -export interface GetEpisodeParams { - mediaFolderPath: string; - season: number; - episode: number; -} - -export async function handleGetEpisode( - params: GetEpisodeParams, - abortSignal?: AbortSignal -): Promise | ReturnType> { - logger.info({ - params, - file: "tools/getEpisode.ts" - }, "[MCP] get-episode tool started") - - const { mediaFolderPath, season, episode } = params; - const traceId = `get-episode-${Date.now()}`; - - if (abortSignal?.aborted) { - logger.info({ - traceId, - file: "tools/getEpisode.ts" - }, "[MCP] get-episode tool aborted: abort signal detected") - return createErrorResponse("Request was aborted"); - } - - if (!mediaFolderPath || typeof mediaFolderPath !== "string" || mediaFolderPath.trim() === "") { - logger.warn({ - traceId, - mediaFolderPath, - reason: "media folder path is empty or invalid", - file: "tools/getEpisode.ts" - }, "[MCP] get-episode tool validation failed: invalid media folder path") - return createSuccessResponse({ - videoFilePath: "", - season: 0, - episode: 0, - message: "Invalid path: 'mediaFolderPath' must be a non-empty string" - }); - } - - if (typeof season !== "number" || season < 0) { - logger.warn({ - traceId, - season, - reason: "season number is invalid", - file: "tools/getEpisode.ts" - }, "[MCP] get-episode tool validation failed: invalid season number") - return createSuccessResponse({ - videoFilePath: "", - season: 0, - episode: 0, - message: "Invalid season: 'season' must be a non-negative number" - }); - } - - if (typeof episode !== "number" || episode < 0) { - logger.warn({ - traceId, - episode, - reason: "episode number is invalid", - file: "tools/getEpisode.ts" - }, "[MCP] get-episode tool validation failed: invalid episode number") - return createSuccessResponse({ - videoFilePath: "", - season: 0, - episode: 0, - message: "Invalid episode: 'episode' must be a non-negative number" - }); - } - - logger.info({ - traceId, - mediaFolderPath, - season, - episode, - file: "tools/getEpisode.ts" - }, "[MCP] get-episode tool parameters validated, proceeding to find media metadata") - - try { - const metadata = await findMediaMetadata(mediaFolderPath); - - if (!metadata) { - logger.warn({ - traceId, - mediaFolderPath: mediaFolderPath, - reason: "media metadata not found", - file: "tools/getEpisode.ts" - }, "[MCP] get-episode tool failed: media metadata not found") - return createSuccessResponse({ - videoFilePath: "", - season, - episode, - message: "Media metadata not found. Please ensure the media folder is opened in SMM." - }); - } - - logger.info({ - traceId, - mediaFolderPath: mediaFolderPath, - mediaFilesCount: metadata.mediaFiles?.length || 0, - file: "tools/getEpisode.ts" - }, "[MCP] get-episode tool: found media metadata, searching for episode") - - if (!metadata.mediaFiles || metadata.mediaFiles.length === 0) { - logger.warn({ - traceId, - mediaFolderPath: mediaFolderPath, - reason: "no media files in metadata", - file: "tools/getEpisode.ts" - }, "[MCP] get-episode tool failed: no media files found in metadata") - return createSuccessResponse({ - videoFilePath: "", - season, - episode, - message: "No media files found in the media folder metadata." - }); - } - - const matchingEpisode = metadata.mediaFiles.find( - mediaFile => mediaFile.seasonNumber === season && mediaFile.episodeNumber === episode - ); - - if (!matchingEpisode) { - logger.warn({ - traceId, - mediaFolderPath: mediaFolderPath, - season, - episode, - reason: "episode not found in media files", - file: "tools/getEpisode.ts" - }, `[MCP] get-episode tool: episode S${season}E${episode} not found in media files`) - return createSuccessResponse({ - videoFilePath: "", - season, - episode, - message: `Episode S${season}E${episode} not found in the media folder.` - }); - } - - const absolutePath = matchingEpisode.absolutePath; - - // Convert POSIX path to platform-specific path for MCP client - const platformPath = Path.toPlatformPath(absolutePath); - - logger.info({ - traceId, - mediaFolderPath: mediaFolderPath, - season, - episode, - videoFilePath: platformPath, - file: "tools/getEpisode.ts" - }, `[MCP] get-episode tool: found episode S${season}E${episode}`) - - const response = createSuccessResponse({ - videoFilePath: platformPath, - season, - episode, - message: "succeeded" - }); - - logger.info({ - params, - file: "tools/getEpisode.ts", - response, - }, "[MCP] get-episode tool ended") - - return response; - } catch (error) { - logger.error({ - params, - file: "tools/getEpisode.ts", - error: error instanceof Error ? error.message : String(error), - }, "[MCP] get-episode tool ended with error") - const message = error instanceof Error ? error.message : String(error); - return createSuccessResponse({ - videoFilePath: "", - season, - episode, - message: `Error getting episode: ${message}` - }); - } -} - -export async function getTool(abortSignal?: AbortSignal): Promise { - const description = await getLocalizedToolDescription('get-episode'); - - return { - toolName: "get-episode", - description: description, - inputSchema: z.object({ - mediaFolderPath: z.string().describe("The absolute path of the media folder, in POSIX or Windows format"), - season: z.number().describe("The season number of the episode"), - episode: z.number().describe("The episode number"), - }), - outputSchema: z.object({ - videoFilePath: z.string().describe("The absolute path of the video file in platform-specific format (Windows or POSIX)"), - season: z.number().describe("The season number"), - episode: z.number().describe("The episode number"), - message: z.string().describe("Status message: 'succeeded' or error message"), - }).strict(), - execute: async (args: { mediaFolderPath: string; season: number; episode: number }) => { - return handleGetEpisode(args, abortSignal); - }, - }; -} - -export async function getEpisodeAgentTool(_clientId: string, abortSignal?: AbortSignal) { - const tool = await getTool(abortSignal); - return { - description: tool.description, - inputSchema: tool.inputSchema, - outputSchema: tool.outputSchema, - execute: async (args: { mediaFolderPath: string; season: number; episode: number }) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - return handleGetEpisode(args, abortSignal); - }, - }; -} - -export async function getEpisodeMcpTool() { - return getTool(); -} - -export const createGetEpisodeTool = (_clientId: string, abortSignal?: AbortSignal) => ({ - description: `Get episode information from a media folder in SMM. -This tool accepts the media folder path, season number, and episode number. -It returns the absolute video file path for the specified episode. - -Example: Get episode S1E5 from folder "/path/to/TV Show".`, - inputSchema: z.object({ - mediaFolderPath: z.string().describe("The absolute path of the media folder, in POSIX or Windows format"), - season: z.number().describe("The season number of the episode"), - episode: z.number().describe("The episode number"), - }), - execute: async ({ mediaFolderPath, season, episode }: { mediaFolderPath: string; season: number; episode: number }) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - - const result = await handleGetEpisode({ mediaFolderPath, season, episode }, abortSignal); - - if (result.isError) { - return { - videoFilePath: "", - season, - episode, - message: result.content[0]?.text || "Unknown error" - }; - } - - const content = result.structuredContent as { videoFilePath: string; season: number; episode: number; message: string }; - return { - videoFilePath: content.videoFilePath || "", - season: content.season ?? season, - episode: content.episode ?? episode, - message: content.message || "Unknown error" - }; - }, -}); diff --git a/apps/cli/src/tools/getEpisodes.ts b/apps/cli/src/tools/getEpisodes.ts index e5e5d5ed..313222b8 100644 --- a/apps/cli/src/tools/getEpisodes.ts +++ b/apps/cli/src/tools/getEpisodes.ts @@ -5,7 +5,6 @@ import { } from '@smm/core/ai-tool/buildGetEpisodesResponse' import { requireNonEmptyString, toolOk } from '@smm/core/ai-tool/toolResult' import { - GET_EPISODES, GET_EPISODES_DESCRIPTION, GET_EPISODES_INVALID_PATH, GET_EPISODES_NO_CACHE, @@ -15,30 +14,11 @@ import { getEpisodesToolOutputSchema, type GetEpisodesToolOutput, } from '@smm/types/ai-tools/getEpisodes' -import type { ToolDefinition } from './types' -import { - createSuccessResponse, - createErrorResponse, -} from '@/mcp/tools/mcpToolBase' -import { getLocalizedToolDescription } from '@/i18n/helpers' import { findMediaMetadata } from '@/utils/mediaMetadata' import { getUserConfig } from '@/utils/config' -import logger from '../../lib/logger' +import { logger } from '../../lib/logger' export type { GetEpisodesToolOutput } -export { - buildGetEpisodesResponse, - createEmptyGetEpisodesData, -} from '@smm/core/ai-tool/buildGetEpisodesResponse' -export type { - GetEpisodesResponseData, - GetEpisodesEpisode, -} from '@smm/types/ai-tools/getEpisodes' - -/** @deprecated Use GetEpisodesInput from @smm/types/ai-tools/getEpisodes */ -export interface GetEpisodesParams { - mediaFolderPath: string -} async function isMediaFolderManaged(mediaFolderPath: string): Promise { const userConfig = await getUserConfig() @@ -52,7 +32,7 @@ async function isMediaFolderManaged(mediaFolderPath: string): Promise { }) } -export async function executeGetEpisodes( +async function executeGetEpisodes( params: { mediaFolderPath: string }, abortSignal?: AbortSignal, ): Promise { @@ -97,25 +77,6 @@ export async function executeGetEpisodes( return toolOk(buildGetEpisodesResponse(metadata)) } -export async function handleGetEpisodes( - params: GetEpisodesParams, - abortSignal?: AbortSignal, -): Promise< - ReturnType | ReturnType -> { - try { - const result = await executeGetEpisodes(params, abortSignal) - if (result.error) { - return createErrorResponse(result.error) - } - const { error: _error, ...data } = result - return createSuccessResponse(data) - } catch (error) { - return createErrorResponse( - error instanceof Error ? error.message : 'Request was aborted', - ) - } -} export function getEpisodesAgentTool(_clientId: string, abortSignal?: AbortSignal) { return { @@ -128,23 +89,3 @@ export function getEpisodesAgentTool(_clientId: string, abortSignal?: AbortSigna } } -/** @deprecated Use getEpisodesAgentTool */ -export const createGetEpisodesTool = getEpisodesAgentTool - -export async function getTool(): Promise { - const description = await getLocalizedToolDescription(GET_EPISODES) - - return { - toolName: GET_EPISODES, - description, - inputSchema: getEpisodesInputSchema, - outputSchema: getEpisodesToolOutputSchema, - execute: async (args: { mediaFolderPath: string }) => { - return handleGetEpisodes(args) - }, - } -} - -export async function getEpisodesMcpTool() { - return getTool() -} diff --git a/apps/cli/src/tools/getMediaFolders.ts b/apps/cli/src/tools/getMediaFolders.ts index cb50d0af..041a1c04 100644 --- a/apps/cli/src/tools/getMediaFolders.ts +++ b/apps/cli/src/tools/getMediaFolders.ts @@ -1,9 +1,3 @@ -import type { ToolDefinition } from './types' -import { - createSuccessResponse, - createErrorResponse, -} from '@/mcp/tools/mcpToolBase' -import { getLocalizedToolDescription } from '@/i18n/helpers' import { getUserConfig } from '@/utils/config' import { buildGetMediaFoldersResponse, @@ -11,21 +5,15 @@ import { } from '@smm/core/ai-tool/buildGetMediaFoldersResponse' import { formatToolError, toolOk } from '@smm/core/ai-tool/toolResult' import { - GET_MEDIA_FOLDERS, GET_MEDIA_FOLDERS_DESCRIPTION, - getMediaFoldersDataSchema, getMediaFoldersInputSchema, getMediaFoldersOutputSchema, type GetMediaFoldersToolOutput, } from '@smm/types/ai-tools/getMediaFolders' export type { GetMediaFoldersToolOutput } -export { - buildGetMediaFoldersResponse, - createEmptyGetMediaFoldersData, -} from '@smm/core/ai-tool/buildGetMediaFoldersResponse' -export async function executeGetMediaFolders( +async function executeGetMediaFolders( abortSignal?: AbortSignal, ): Promise { if (abortSignal?.aborted) { @@ -43,24 +31,6 @@ export async function executeGetMediaFolders( } } -async function handleGetMediaFolders( - abortSignal?: AbortSignal, -): Promise< - ReturnType | ReturnType -> { - try { - const result = await executeGetMediaFolders(abortSignal) - if (result.error) { - return createErrorResponse(result.error) - } - const { error: _error, ...data } = result - return createSuccessResponse(data) - } catch (error) { - return createErrorResponse( - error instanceof Error ? error.message : 'Request was aborted', - ) - } -} export function getMediaFoldersAgentTool(_clientId: string) { return { @@ -71,27 +41,4 @@ export function getMediaFoldersAgentTool(_clientId: string) { } } -export async function getMediaFoldersMcpTool(): Promise { - const description = await getLocalizedToolDescription(GET_MEDIA_FOLDERS) - - return { - toolName: GET_MEDIA_FOLDERS, - description, - inputSchema: getMediaFoldersInputSchema, - outputSchema: getMediaFoldersDataSchema, - execute: async () => handleGetMediaFolders(), - } -} -/** @deprecated Use executeGetMediaFolders; returns bare folder paths for legacy callers */ -export const getMediaFoldersTool = { - description: GET_MEDIA_FOLDERS_DESCRIPTION, - inputSchema: getMediaFoldersInputSchema, - execute: async (_args: Record, abortSignal?: AbortSignal) => { - const result = await executeGetMediaFolders(abortSignal) - if (result.error) { - throw new Error(result.error) - } - return result.folders - }, -} diff --git a/apps/cli/src/tools/getMediaMetadata.ts b/apps/cli/src/tools/getMediaMetadata.ts index be4ef11b..eb8a3d68 100644 --- a/apps/cli/src/tools/getMediaMetadata.ts +++ b/apps/cli/src/tools/getMediaMetadata.ts @@ -6,7 +6,6 @@ import { } from '@smm/core/ai-tool/getMediaMetadataResponse' import { requireNonEmptyString } from '@smm/core/ai-tool/toolResult' import { - GET_MEDIA_METADATA, GET_MEDIA_METADATA_DESCRIPTION, GET_MEDIA_METADATA_FOLDER_NOT_FOUND, GET_MEDIA_METADATA_NOT_DIRECTORY, @@ -16,26 +15,10 @@ import { getMediaMetadataToolOutputSchema, type GetMediaMetadataToolOutput, } from '@smm/types/ai-tools/getMediaMetadata' -import type { ToolDefinition } from './types' -import { - createSuccessResponse, - createErrorResponse, -} from '@/mcp/tools/mcpToolBase' -import { getLocalizedToolDescription } from '@/i18n/helpers' import { findMediaMetadata } from '@/utils/mediaMetadata' import { getUserConfig } from '@/utils/config' export type { GetMediaMetadataToolOutput } -export { - fillMediaMetadataResponseData, - createBaseGetMediaMetadataData, -} from '@smm/core/ai-tool/getMediaMetadataResponse' -export type { GetMediaMetadataResponseData } from '@smm/types/ai-tools/getMediaMetadata' - -/** @deprecated Use GetMediaMetadataInput from @smm/types/ai-tools/getMediaMetadata */ -export interface GetMediaMetadataParams { - mediaFolderPath: string -} async function isMediaFolderManaged(mediaFolderPath: string): Promise { const userConfig = await getUserConfig() @@ -49,7 +32,7 @@ async function isMediaFolderManaged(mediaFolderPath: string): Promise { }) } -export async function executeGetMediaMetadata( +async function executeGetMediaMetadata( params: { mediaFolderPath: string }, abortSignal?: AbortSignal, ): Promise { @@ -103,25 +86,6 @@ export async function executeGetMediaMetadata( } } -export async function handleGetMediaMetadata( - params: { mediaFolderPath: string }, - abortSignal?: AbortSignal, -): Promise< - ReturnType | ReturnType -> { - try { - const result = await executeGetMediaMetadata(params, abortSignal) - if (result.error) { - const { error, ...data } = result - return createSuccessResponse({ data, error }) - } - return createSuccessResponse({ data: result }) - } catch (error) { - return createErrorResponse( - error instanceof Error ? error.message : 'Request was aborted', - ) - } -} export function getMediaMetadataAgentTool( _clientId: string, @@ -136,23 +100,3 @@ export function getMediaMetadataAgentTool( }, } } - -export const getTool = async function ( - abortSignal?: AbortSignal, -): Promise { - const description = await getLocalizedToolDescription(GET_MEDIA_METADATA) - - return { - toolName: GET_MEDIA_METADATA, - description, - inputSchema: getMediaMetadataInputSchema, - outputSchema: getMediaMetadataToolOutputSchema, - execute: async (args: { mediaFolderPath: string }) => { - return handleGetMediaMetadata(args, abortSignal) - }, - } -} - -export async function getMediaMetadataMcpTool() { - return getTool() -} diff --git a/apps/cli/src/tools/getSelectedMediaMetadata.ts b/apps/cli/src/tools/getSelectedMediaMetadata.ts deleted file mode 100644 index 261e505c..00000000 --- a/apps/cli/src/tools/getSelectedMediaMetadata.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { z } from 'zod/v3'; -import { executeGetSelectedMediaMetadataTask } from '../../tasks/GetSelectedMediaMetadataTask'; -import type { MediaMetadata } from '@smm/types'; - -export const createGetSelectedMediaMetadataTool = (clientId: string, abortSignal?: AbortSignal) => ({ - description: `Get the user selected folder(media) in SMM. - This tool returns data: - * The path of the selected folder - * The type of the selected folder (TV Show, Movie, Music) - * The TMDB ID of selected folder (if available) - * The name of TV Show or Movie (only available for TV Show and Movie) - `, - inputSchema: z.object({}), - outputSchema: z.object({ - mediaFolderPath: z.string().describe("The path of the selected folder"), - type: z.enum(["tvshow", "movie", "music"]).describe("The type of the selected folder. Anime is a type of tvshow."), - tmdbId: z.number().describe("The TMDB ID of selected folder"), - name: z.string().describe("The name of TV Show or Movie"), - - }), - execute: async () => { - // TODO: Implement abort handling - check abortSignal and cancel ongoing operations - if (abortSignal?.aborted) { - throw new Error('Request was aborted'); - } - console.log(`[tool][getSelectedMediaMetadata] clientId: ${clientId}`); - - // Use the clientId from the request body if available, otherwise use first connection as fallback - const result = await executeGetSelectedMediaMetadataTask(clientId); - - if (!result.success) { - throw new Error(result.error || 'Failed to get selected media metadata'); - } - - console.log(`[tool][getSelectedMediaMetadata] media metadata: ${result.data.mediaFolderPath}`); - const mm = result.data as MediaMetadata; - const tmdbId = - mm.type === "tvshow-folder" && mm.tvShow?.database === "TMDB" - ? Number.parseInt(mm.tvShow.id, 10) || 0 - : mm.type === "movie-folder" && mm.movie?.database === "TMDB" - ? Number.parseInt(mm.movie.id, 10) || 0 - : 0; - const name = mm.tvShow?.name ?? mm.movie?.name ?? ""; - const toolType = - mm.type === "tvshow-folder" - ? ("tvshow" as const) - : mm.type === "movie-folder" - ? ("movie" as const) - : ("music" as const); - return { - mediaFolderPath: mm.mediaFolderPath, - type: toolType, - tmdbId, - name, - }; - }, -}); - diff --git a/apps/cli/src/tools/howToRecognizeEpisodeVideoFiles.ts b/apps/cli/src/tools/howToRecognizeEpisodeVideoFiles.ts deleted file mode 100644 index 6960f87d..00000000 --- a/apps/cli/src/tools/howToRecognizeEpisodeVideoFiles.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { z } from "zod"; -import type { ToolDefinition } from "./types"; -import { createSuccessResponse } from "@/mcp/tools/mcpToolBase"; -import { getLocalizedToolDescription } from '@/i18n/helpers'; - -const guidelines = `# 如何使用 SMM MCP tool 识别季集视频文件 - -媒体文件件包含了多个视频文件. SMM可能无法识别或错误识别每一集对应的视频文件. -此操作用于在SMM中维护本地视频文件和季集的对应关系。 -此操作只适用于电视剧(TV Show)类型的媒体文件夹。 - -当用户要求 -1. 识别本地视频文件 -2. 识别季集视频文件 -3. 关联/链接/匹配 xxx.mp4 视频文件为第x季第y集时 -表示用户想执行此操作. - - -AI助手应该参考以下步骤: - -1. 当用户没有指定媒体目录时, 使用 "get-app-context" 工具获取 SMM 软件中用户当前选中的目录 -2. 使用 get-metadata 工具获得媒体文件夹的媒体元数据. 该工具返回该文件夹的媒体类型, TMDB ID, 季集信息等. - 你需要为每一季每一集识别对应的本地视频文件 -3. 使用 list-files 工具(设置 videoFileOnly=true)并查询媒体文件夹中的所有视频文件 -4. 使用 "create-recognize-episode-plan" 工具一次性提交识别计划, 指定媒体文件夹路径和所有视频文件的 season/episode/path 映射 - -**NOTE** 识别任务完成后, SMM 会在后台处理识别计划, 用户可以在 SMM UI 中查看和确认识别结果. -`; - -export async function getTool(): Promise { - const description = await getLocalizedToolDescription('how-to-recognize-episode-video-files'); - - return { - toolName: "how-to-recognize-episode-video-files", - description: description, - inputSchema: z.object({}), - outputSchema: z.object({ - text: z.string().describe("Markdown content"), - }), - execute: async () => { - return createSuccessResponse({ text: guidelines }); - }, - }; -} - -export async function howToRecognizeEpisodeVideoFilesMcpTool() { - return getTool(); -} diff --git a/apps/cli/src/tools/howToRenameEpisodeVideoFiles.ts b/apps/cli/src/tools/howToRenameEpisodeVideoFiles.ts deleted file mode 100644 index 0d117251..00000000 --- a/apps/cli/src/tools/howToRenameEpisodeVideoFiles.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { z } from "zod"; -import type { ToolDefinition } from "./types"; -import { createSuccessResponse } from "@/mcp/tools/mcpToolBase"; -import { getLocalizedToolDescription } from '@/i18n/helpers'; - -const guidelines = `# 如何使用 SMM MCP tool 重命名媒体文件 - -当用户要求重命名视频文件或媒体文件, 或整理视频文件的目录结构, 表示用户想执行此操作. - -视频文件或媒体文件, 指的是SMM已经识别的视频文件, 即"get-episodes"工具返回的视频文件. -媒体文件夹内的其他视频文件**不是**本操作的目标 - - -AI助手应该参考一下步骤: - -1. 当用户没有指定媒体目录时, 使用 "get-app-context" 工具获取 SMM 软件中用户当前选中的目录 -2. 使用 "get-media-metadata" 工具获取媒体目录的媒体元数据, 主要关注电视剧的季集信息 -3. 使用 "get-episodes" 工具获取需要季集视频文件 -4. 思考重命名命名方案 -5. 使用 "begin-rename-episode-video-file-task" 工具开始重命名任务 -6. 使用 "add-rename-episode-video-file-to-task" 工具添加需要重命名的文件 -7. 使用 "end-rename-episode-video-file-task" 工具结束重命名任务 - -## 文件命名规则 - -多媒体服务器(如Plex, Jellyfin, Emby) 要求视频文件已特定格式命名, 否则无法识别。 -当用户没有指定文件命名规则时, AI助手应该询问用户是否使用Plex命名规则。 -下面列出了每个多媒体服务器要求的视频文件命名规则: - -### Plex - -{FolderName}/{TVShowName} - S{SeasonNumber}E{EpisodeNumber} - {EpisodeName}.{Extension} - -FolderName: 季集文件夹名称, 如 "Season 01", "Season 02", "Season 03".特别地, 第0季的文件夹名称是 "Specials". - -### Jellyfin - -{FolderName}/{TVShowName} - S{SeasonNumber}E{EpisodeNumber} - {EpisodeName}.{Extension} - -FolderName: 季集文件夹名称, 如 "Season 01", "Season 02", "Season 03".特别地, 第0季的文件夹名称是 "Specials". - -**NOTE** AI助手不需要重命名视频文件的关联文件(如字幕,音频,海报等), SMM 会在内部自动重命名关联文件 - -**NOTE** SMM只支持重命名已识别的季集视频文件. "已识别"表示SMM知道该视频文件是哪一季的哪一集. -请使用 "get-episode" 工具来获得已识别的视频文件, **不要**使用 "list-files" 工具来获得所有视频文件. -当"get-episode"没有返回视频文件路径时, 表示SMM不知道该季该集对应的视频文件, AI助手可以跳过该季该集的视频文件重命名. - -`; - -export async function getTool(): Promise { - const description = await getLocalizedToolDescription('how-to-rename-episode-video-files'); - - return { - toolName: "how-to-rename-episode-video-files", - description: description, - inputSchema: z.object({}), - outputSchema: z.object({ - text: z.string().describe("Markdown content"), - }), - execute: async () => { - return createSuccessResponse({ text: guidelines }); - }, - }; -} - -export async function howToRenameEpisodeVideoFilesMcpTool() { - return getTool(); -} diff --git a/apps/cli/src/tools/index.ts b/apps/cli/src/tools/index.ts index 9f9a7f7c..10a874fb 100644 --- a/apps/cli/src/tools/index.ts +++ b/apps/cli/src/tools/index.ts @@ -1,46 +1,9 @@ -import { isFolderExistTool, isFolderExistAgentTool, isFolderExistMcpTool } from './isFolderExist'; -import { createGetSelectedMediaMetadataTool } from './getSelectedMediaMetadata'; -import { getMediaFoldersTool, getMediaFoldersAgentTool, getMediaFoldersMcpTool } from './getMediaFolders'; -import { listFilesTool, listFilesMcpTool } from './listFiles'; +import { isFolderExistAgentTool } from './isFolderExist'; +import { getMediaFoldersAgentTool } from './getMediaFolders'; import { listFilesInMediaFolderAgentTool } from './listFilesInMediaFolder'; -import { getMediaMetadataAgentTool, getMediaMetadataMcpTool } from './getMediaMetadata'; -import { createRenameFolderTool, renameFolderAgentTool, renameFolderMcpTool } from './renameFolder'; -import { matchEpisodeTool } from './matchEpisode'; -import { createMatchEpisodesInBatchTool } from './matchEpisodesInBatch'; -import { createRenameFilesInBatchTool } from './renameFilesInBatch'; -import { createAskForConfirmationTool } from './askForConfirmation'; -import { getApplicationContextAgentTool, getApplicationContextMcpTool } from './getApplicationContext'; -import { - createBeginRecognizeTaskTool, - createAddRecognizedMediaFileTool, - createEndRecognizeTaskTool, -} from './recognizeMediaFilesTask'; -import { getEpisodesAgentTool, createGetEpisodesTool, getEpisodesMcpTool } from './getEpisodes'; -import { createGetEpisodeTool, getEpisodeMcpTool } from './getEpisode'; -import { howToRenameEpisodeVideoFilesMcpTool } from './howToRenameEpisodeVideoFiles'; -import { readmeMcpTool } from './readme'; -import { howToRecognizeEpisodeVideoFilesMcpTool } from './howToRecognizeEpisodeVideoFiles'; -export { - isFolderExistTool, - createGetSelectedMediaMetadataTool, - getMediaFoldersTool, - listFilesTool, - listFilesInMediaFolderAgentTool, - matchEpisodeTool, - createMatchEpisodesInBatchTool, - createRenameFilesInBatchTool, - createRenameFolderTool, - createAskForConfirmationTool, - createBeginRecognizeTaskTool, - createAddRecognizedMediaFileTool, - createEndRecognizeTaskTool, - createGetEpisodesTool, - getEpisodesAgentTool, - createGetEpisodeTool, - howToRenameEpisodeVideoFilesMcpTool, - readmeMcpTool, - howToRecognizeEpisodeVideoFilesMcpTool, -}; +import { getMediaMetadataAgentTool } from './getMediaMetadata'; +import { renameFolderAgentTool } from './renameFolder'; +import { getApplicationContextAgentTool } from './getApplicationContext'; export const agentTools = { getApplicationContext: getApplicationContextAgentTool, @@ -50,17 +13,3 @@ export const agentTools = { getMediaMetadata: getMediaMetadataAgentTool, renameFolder: renameFolderAgentTool, } - -export const mcpTools = { - getApplicationContext: getApplicationContextMcpTool, - getMediaFolders: getMediaFoldersMcpTool, - isFolderExist: isFolderExistMcpTool, - listFiles: listFilesMcpTool, - getMediaMetadata: getMediaMetadataMcpTool, - renameFolder: renameFolderMcpTool, - getEpisode: getEpisodeMcpTool, - getEpisodes: getEpisodesMcpTool, - howToRenameEpisodeVideoFiles: howToRenameEpisodeVideoFilesMcpTool, - readme: readmeMcpTool, - howToRecognizeEpisodeVideoFiles: howToRecognizeEpisodeVideoFilesMcpTool -} diff --git a/apps/cli/src/tools/isFolderExist.ts b/apps/cli/src/tools/isFolderExist.ts index cad7bbd0..b2e5e0ef 100644 --- a/apps/cli/src/tools/isFolderExist.ts +++ b/apps/cli/src/tools/isFolderExist.ts @@ -1,12 +1,5 @@ -import type { ToolDefinition } from './types' -import { - createSuccessResponse, - createErrorResponse, -} from '@/mcp/tools/mcpToolBase' -import { getLocalizedToolDescription } from '@/i18n/helpers' import { resolveFolderExistence } from '@smm/core-routes' import { - IS_FOLDER_EXIST, IS_FOLDER_EXIST_DESCRIPTION, isFolderExistInputSchema, isFolderExistOutputSchema, @@ -20,7 +13,7 @@ export type { IsFolderExistOutput } /** * Core is-folder-exist execution (no MCP wrapping). Used by agent tools. */ -export async function executeIsFolderExist( +async function executeIsFolderExist( path: string, ): Promise { const pathCheck = requireNonEmptyString(path, 'path') @@ -52,43 +45,3 @@ function createAgentIsFolderExistTool() { export function isFolderExistAgentTool(_clientId: string) { return createAgentIsFolderExistTool() } - -export const getTool = async function (): Promise { - const description = await getLocalizedToolDescription(IS_FOLDER_EXIST) - - return { - toolName: IS_FOLDER_EXIST, - description, - inputSchema: isFolderExistInputSchema, - outputSchema: isFolderExistOutputSchema, - execute: async ({ path }: { path: string }) => { - try { - const result = await executeIsFolderExist(path) - return createSuccessResponse( - result as unknown as { [x: string]: unknown }, - ) - } catch (error) { - console.error('[isFolderExist] Error:', error) - return createErrorResponse( - error instanceof Error ? error.message : 'Unknown error', - ) - } - }, - } -} - -export async function isFolderExistMcpTool() { - return getTool() -} - -/** @deprecated Alias — use isFolderExistAgentTool */ -export const isFolderExistTool = { - description: IS_FOLDER_EXIST_DESCRIPTION, - inputSchema: isFolderExistInputSchema, - execute: async ({ path }: { path: string }, abortSignal?: AbortSignal) => { - if (abortSignal?.aborted) { - throw new Error('Request was aborted') - } - return executeIsFolderExist(path) - }, -} diff --git a/apps/cli/src/tools/listFiles.ts b/apps/cli/src/tools/listFiles.ts deleted file mode 100644 index e07c8c5d..00000000 --- a/apps/cli/src/tools/listFiles.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { z } from 'zod' -import type { ToolDefinition } from './types' -import { - createSuccessResponse, - createErrorResponse, -} from '@/mcp/tools/mcpToolBase' -import { getLocalizedToolDescription } from '@/i18n/helpers' -import { doListFiles } from '@/route/ListFiles' -import { - buildListFilesInMediaFolderResponse, -} from '@smm/core/ai-tool/buildListFilesInMediaFolderResponse' -import { formatToolError } from '@smm/core/ai-tool/toolResult' - -export interface ListFilesMcpParams { - folderPath: string - recursive?: boolean - filter?: string - videoFileOnly?: boolean -} - -/** - * Generic filesystem listing for MCP `list-files` (no managed-folder check). - * Prefer `list-files-in-media-folder` for AI Assistant media workflows. - */ -export async function executeListFilesMcp( - params: ListFilesMcpParams, - abortSignal?: AbortSignal, -): Promise< - ReturnType | ReturnType -> { - if (abortSignal?.aborted) { - return createErrorResponse('Request was aborted') - } - - const { folderPath, recursive, videoFileOnly } = params - - if (!folderPath || typeof folderPath !== 'string' || folderPath.trim() === '') { - return createErrorResponse( - 'Invalid path: path must be a non-empty string', - ) - } - - try { - const listResult = await doListFiles({ - path: folderPath, - recursively: recursive ?? false, - onlyFiles: true, - }) - - if (listResult.error) { - return createErrorResponse(listResult.error) - } - - const filePaths = - listResult.data?.items - .filter((item) => !item.isDirectory) - .map((item) => item.path) ?? [] - - const data = buildListFilesInMediaFolderResponse( - filePaths, - videoFileOnly ?? false, - ) - return createSuccessResponse(data) - } catch (error) { - const message = formatToolError(error).error - return createErrorResponse(message) - } -} - -const listFilesMcpInputSchema = z.object({ - folderPath: z - .string() - .describe('The absolute path of the folder to list files from'), - recursive: z - .boolean() - .optional() - .default(false) - .describe('Whether to list files recursively (default: false)'), - filter: z - .string() - .optional() - .describe('Filter pattern for files/folders (supports wildcards)'), - videoFileOnly: z - .boolean() - .optional() - .default(false) - .describe('Whether to return only video files (default: false)'), -}) - -export async function listFilesMcpTool(): Promise { - const description = await getLocalizedToolDescription('list-files') - - return { - toolName: 'list-files', - description, - inputSchema: listFilesMcpInputSchema, - outputSchema: z.object({ - files: z.array(z.string()).describe('Array of file paths'), - count: z.number().describe('Number of files listed'), - }), - execute: async (args: ListFilesMcpParams) => executeListFilesMcp(args), - } -} - -/** @deprecated Use executeListFilesInMediaFolder from listFilesInMediaFolder.ts */ -export const listFilesTool = { - description: - 'List all files in a folder recursively. Accepts paths in POSIX or Windows format.', - inputSchema: listFilesMcpInputSchema, - execute: async ( - { folderPath, recursive, videoFileOnly }: ListFilesMcpParams, - abortSignal?: AbortSignal, - ) => { - const result = await executeListFilesMcp( - { folderPath, recursive, videoFileOnly }, - abortSignal, - ) - if (result.isError) { - throw new Error(result.content[0]?.text ?? 'Unknown error') - } - return result.structuredContent as { - files: string[] - count: number - } - }, -} diff --git a/apps/cli/src/tools/listFilesInMediaFolder.ts b/apps/cli/src/tools/listFilesInMediaFolder.ts index 3194bd24..77fdba6a 100644 --- a/apps/cli/src/tools/listFilesInMediaFolder.ts +++ b/apps/cli/src/tools/listFilesInMediaFolder.ts @@ -16,10 +16,6 @@ import { doListFiles } from '@/route/ListFiles' import { getUserConfig } from '@/utils/config' export type { ListFilesInMediaFolderToolOutput } -export { - buildListFilesInMediaFolderResponse, - createEmptyListFilesInMediaFolderData, -} from '@smm/core/ai-tool/buildListFilesInMediaFolderResponse' async function isMediaFolderManaged(mediaFolderPath: string): Promise { const userConfig = await getUserConfig() @@ -33,7 +29,7 @@ async function isMediaFolderManaged(mediaFolderPath: string): Promise { }) } -export async function executeListFilesInMediaFolder( +async function executeListFilesInMediaFolder( params: { mediaFolderPath: string recursively?: boolean @@ -104,6 +100,3 @@ export function listFilesInMediaFolderAgentTool(_clientId: string) { }) => executeListFilesInMediaFolder(args), } } - -/** @deprecated Use listFilesInMediaFolderAgentTool */ -export const listFilesAgentTool = listFilesInMediaFolderAgentTool diff --git a/apps/cli/src/tools/matchEpisode.ts b/apps/cli/src/tools/matchEpisode.ts deleted file mode 100644 index a96e96d9..00000000 --- a/apps/cli/src/tools/matchEpisode.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { z } from 'zod/v3'; -import { Path } from '@smm/utils/path'; -import type { MediaFileMetadata, MediaMetadata } from '@smm/types'; -import { metadataCacheFilePath, mediaMetadataDir } from '../route/mediaMetadata/utils'; -import { doListFiles } from '../route/ListFiles'; -import { mkdir } from 'fs/promises'; -import { broadcast } from '../utils/socketIO'; - -/** - * Update media file metadata array by adding or updating an entry for a video file. - * If the video file already exists in the array, update its season/episode numbers. - * Otherwise, add a new entry. - * @return new array of media files - */ -export function updateMediaFileMetadatas( - mediaFiles: MediaFileMetadata[], - videoFilePath: string, - seasonNumber: number, - episodeNumber: number -): MediaFileMetadata[] { - - // There are two possible cases: - // 1. The video file has been assigned to one episode - // 2. The episode has been assigned by another video file - - const newMediaFiles: MediaFileMetadata[] = mediaFiles - .filter(mediaFile => mediaFile.seasonNumber !== seasonNumber || mediaFile.episodeNumber !== episodeNumber) - .filter(mediaFile => mediaFile.absolutePath !== videoFilePath); - - newMediaFiles.push({ - absolutePath: videoFilePath, - seasonNumber, - episodeNumber - }); - - return newMediaFiles; - -} - -export const matchEpisodeTool = { - description: `Match local file to episodes of a TV show. -For example, use this tool to indicate "/path/to/media/folder/Episode 1/file1.mp4" in folder "/path/to/media/folder" is for episode 1 of season 1. -This tool return JSON response with the following format: -\`\`\`typescript -interface ToolResponse { - // error message - error?: string; -} -\`\`\` - -Please **ensure** you call "ask-for-confirmation" to get user confirmation before you call this tool. -`, - inputSchema: z.object({ - folderPath: z.string().describe("The absolute path of the media folder, it can be POSIX format or Windows format"), - path: z.string().describe("The absolute path of the video file, it can be POSIX format or Windows format"), - seasonNumber: z.number().describe("The season number"), - episodeNumber: z.number().describe("The episode number"), - }), - execute: async ({ folderPath, path, seasonNumber, episodeNumber }: { - folderPath: string; - path: string; - seasonNumber: number; - episodeNumber: number; - }, abortSignal?: AbortSignal) => { - // TODO: Implement abort handling - check abortSignal and cancel ongoing operations - if (abortSignal?.aborted) { - throw new Error('Request was aborted'); - } - console.log(`[tool][matchEpisode] Match episode ${seasonNumber} ${episodeNumber} in folder "${folderPath}" with file "${path}"`); - const folderPathInPosix = Path.posix(folderPath); - - // 1. Read media metadata from cache file - const metadataFilePath = metadataCacheFilePath(folderPathInPosix); - const metadataExists = await Bun.file(metadataFilePath).exists(); - - if (!metadataExists) { - return { error: `Error Reason: folderPath "${folderPathInPosix}" is not opened in SMM` }; - } - - let mediaMetadata: MediaMetadata; - try { - mediaMetadata = await Bun.file(metadataFilePath).json() as MediaMetadata; - } catch (error) { - return { error: `Error Reason: Failed to read media metadata: ${error instanceof Error ? error.message : 'Unknown error'}` }; - } - - // 2. Check if folderPathInPosix matches - if (mediaMetadata.mediaFolderPath !== folderPathInPosix) { - return { error: `Error Reason: folderPath "${folderPathInPosix}" does not match metadata folder path "${mediaMetadata.mediaFolderPath}"` }; - } - - // 3. Check if path is valid (live folder listing, not persisted metadata) - const pathInPosix = Path.posix(path); - const listResult = await doListFiles({ - path: folderPathInPosix, - recursively: true, - onlyFiles: true, - }); - if (listResult.error) { - return { error: `Error Reason: Failed to list files in media folder: ${listResult.error}` }; - } - const files = listResult.data?.items.map((item) => Path.posix(item.path)) ?? []; - if (!files.includes(pathInPosix)) { - return { error: `Error Reason: path "${pathInPosix}" is not a file in the media folder` }; - } - - // 4. Check episodeNumber and seasonNumber is valid - const tvShow = mediaMetadata.tvShow; - if (!tvShow) { - return { error: `Error Reason: TV show data is not available for this media folder` }; - } - - const season = tvShow.seasons?.find(s => s.season === seasonNumber); - if (!season) { - return { error: `Error Reason: season ${seasonNumber} does not exist in TV show metadata` }; - } - - const episode = season.episodes?.find(e => e.episode === episodeNumber); - if (!episode) { - return { error: `Error Reason: episode ${episodeNumber} does not exist in season ${seasonNumber}` }; - } - - // 5. Update media metadata - const updatedMediaMetadata: MediaMetadata = { - ...mediaMetadata, - mediaFiles: updateMediaFileMetadatas(mediaMetadata.mediaFiles ?? [], pathInPosix, seasonNumber, episodeNumber) - }; - - // 6. Write updated metadata back to file - try { - await mkdir(mediaMetadataDir, { recursive: true }); - await Bun.write(metadataFilePath, JSON.stringify(updatedMediaMetadata, null, 2)); - console.log(`[tool][matchEpisode] Successfully updated media metadata for folder "${folderPathInPosix}"`); - - // 7. Notify all connected clients via WebSocket - broadcast({ - event: 'mediaMetadataUpdated', - data: { - folderPath: folderPathInPosix - } - }); - } catch (error) { - return { error: `Error Reason: Failed to write media metadata: ${error instanceof Error ? error.message : 'Unknown error'}` }; - } - - return { - error: undefined - }; - }, -}; - diff --git a/apps/cli/src/tools/matchEpisodesInBatch.ts b/apps/cli/src/tools/matchEpisodesInBatch.ts deleted file mode 100644 index a0cd7eb7..00000000 --- a/apps/cli/src/tools/matchEpisodesInBatch.ts +++ /dev/null @@ -1,471 +0,0 @@ -import { z } from 'zod/v3'; -import { Path } from '@smm/utils/path'; -import type { MediaFileMetadata, MediaMetadata, TvShowMediaMetadata } from '@smm/types'; -import { metadataCacheFilePath, mediaMetadataDir } from '../route/mediaMetadata/utils'; -import { mkdir } from 'fs/promises'; -import { acknowledge, broadcast } from '../utils/socketIO'; -import { listFiles } from '../utils/files'; -import pino from 'pino'; - -const logger = pino(); - -interface MatchFile { - season: number; - episode: number; - path: string; -} - -interface ValidationResult { - isValid: boolean; - error?: string; -} - -interface FileValidationResult { - isValid: boolean; - error?: string; - validatedFile?: MatchFile; -} - -export function updateMediaFileMetadatas( - _mediaFiles: MediaFileMetadata[], - videoFilePath: string, - seasonNumber: number, - episodeNumber: number -): MediaFileMetadata[] { - - let mediaFiles = _mediaFiles; - - // remove all media files for given season and episode - mediaFiles = mediaFiles.filter(file => file.seasonNumber !== seasonNumber || file.episodeNumber !== episodeNumber); - - // remove all media files for given path - mediaFiles = mediaFiles.filter(file => file.absolutePath !== videoFilePath); - - logger.info(`Add media file "${videoFilePath}" season ${seasonNumber} episode ${episodeNumber}`); - return [ - ...mediaFiles, - { - absolutePath: videoFilePath, - seasonNumber, - episodeNumber - } - ]; -} - -/** - * Validates that the metadata file exists - */ -export async function validateMetadataExists(folderPath: string): Promise { - const folderPathInPosix = Path.posix(folderPath); - const metadataFilePath = metadataCacheFilePath(folderPathInPosix); - const metadataExists = await Bun.file(metadataFilePath).exists(); - - if (!metadataExists) { - return { - isValid: false, - error: `Error Reason: folderPath "${folderPathInPosix}" is not opened in SMM` - }; - } - - return { isValid: true }; -} - -/** - * Validates and loads media metadata from cache file - */ -export async function validateAndLoadMetadata(folderPath: string): Promise { - const folderPathInPosix = Path.posix(folderPath); - const metadataFilePath = metadataCacheFilePath(folderPathInPosix); - - try { - const metadata = await Bun.file(metadataFilePath).json() as MediaMetadata; - return { isValid: true, metadata }; - } catch (error) { - return { - isValid: false, - error: `Error Reason: Failed to read media metadata: ${error instanceof Error ? error.message : 'Unknown error'}` - }; - } -} - -/** - * Validates that the folder path matches the metadata folder path - */ -export function validateFolderPathMatch(folderPath: string, mediaMetadata: MediaMetadata): ValidationResult { - const folderPathInPosix = Path.posix(folderPath); - - if (mediaMetadata.mediaFolderPath !== folderPathInPosix) { - return { - isValid: false, - error: `Error Reason: folderPath "${folderPathInPosix}" does not match metadata folder path "${mediaMetadata.mediaFolderPath}"` - }; - } - - return { isValid: true }; -} - -/** - * Validates that TMDB TV show data exists in metadata - */ -export function validateTmdbTvShowExists(mediaMetadata: MediaMetadata): ValidationResult { - if (!mediaMetadata.tvShow) { - return { - isValid: false, - error: `Error Reason: TV show data is not available for this media folder` - }; - } - - return { isValid: true }; -} - -/** - * Validates that a file exists in the filesystem - */ -export function validateFileExists(filePath: string, filesystemFiles: string[]): ValidationResult { - const pathInPosix = Path.posix(filePath); - - const fileExists = filesystemFiles.some(f => { - const normalizedFile = Path.posix(f); - logger.info({ - normalizedFile, - pathInPosix, - f, - }) - return normalizedFile === pathInPosix || f === pathInPosix; - }); - - - if (!fileExists) { - return { - isValid: false, - error: `Path "${pathInPosix}" is not a file in the media folder` - }; - } - - return { isValid: true }; -} - -/** - * Validates that a season exists in TMDB TV show data - */ -export function validateSeasonExists( - seasonNumber: number, - tvShow: TvShowMediaMetadata, - filePath: string -): ValidationResult { - const season = tvShow.seasons?.find(s => s.season === seasonNumber); - - if (!season) { - return { - isValid: false, - error: `Season ${seasonNumber} does not exist in TV show metadata for file "${Path.posix(filePath)}"` - }; - } - - return { isValid: true }; -} - -/** - * Validates that an episode exists in a season - */ -export function validateEpisodeExists( - seasonNumber: number, - episodeNumber: number, - tvShow: TvShowMediaMetadata, - filePath: string -): ValidationResult { - const season = tvShow.seasons?.find(s => s.season === seasonNumber); - - if (!season) { - return { - isValid: false, - error: `Season ${seasonNumber} does not exist in TV show metadata for file "${Path.posix(filePath)}"` - }; - } - - const episode = season.episodes?.find(e => e.episode === episodeNumber); - - if (!episode) { - return { - isValid: false, - error: `Episode ${episodeNumber} does not exist in season ${seasonNumber} for file "${Path.posix(filePath)}"` - }; - } - - return { isValid: true }; -} - -/** - * Validates a single file against filesystem and TMDB data - */ -export function validateFile( - file: MatchFile, - filesystemFiles: string[], - tvShow: TvShowMediaMetadata -): FileValidationResult { - const pathInPosix = Path.posix(file.path); - - // Validate file exists in filesystem - const fileExistsResult = validateFileExists(file.path, filesystemFiles); - if (!fileExistsResult.isValid) { - logger.warn({ - path: pathInPosix, - totalFilesInMediaFolder: filesystemFiles.length, - sampleFiles: filesystemFiles.slice(0, 3) - }, '[tool][matchEpisodesInBatch] File not found in media folder'); - return { isValid: false, error: fileExistsResult.error }; - } - - // Validate season exists - const seasonResult = validateSeasonExists(file.season, tvShow, file.path); - if (!seasonResult.isValid) { - logger.warn({ - path: pathInPosix, - season: file.season, - availableSeasons: tvShow.seasons?.map(s => s.season) ?? [] - }, '[tool][matchEpisodesInBatch] Season not found in TV show metadata'); - return { isValid: false, error: seasonResult.error }; - } - - // Validate episode exists - const episodeResult = validateEpisodeExists(file.season, file.episode, tvShow, file.path); - if (!episodeResult.isValid) { - const season = tvShow.seasons?.find(s => s.season === file.season); - logger.warn({ - path: pathInPosix, - season: file.season, - episode: file.episode, - availableEpisodes: season?.episodes?.map(e => e.episode) ?? [] - }, '[tool][matchEpisodesInBatch] Episode not found in season'); - return { isValid: false, error: episodeResult.error }; - } - - logger.debug({ - path: pathInPosix, - season: file.season, - episode: file.episode - }, '[tool][matchEpisodesInBatch] File validation passed'); - - return { isValid: true, validatedFile: file }; -} - -/** - * Validates all files and returns validated files and errors - */ -export function validateAllFiles( - files: MatchFile[], - filesystemFiles: string[], - tvShow: TvShowMediaMetadata -): { validatedFiles: MatchFile[]; validationErrors: string[] } { - const validationErrors: string[] = []; - const validatedFiles: MatchFile[] = []; - - for (const file of files) { - const pathInPosix = Path.posix(file.path); - - logger.debug({ - originalPath: file.path, - normalizedPath: pathInPosix, - season: file.season, - episode: file.episode - }, '[tool][matchEpisodesInBatch] Validating file'); - - const result = validateFile(file, filesystemFiles, tvShow); - - if (!result.isValid) { - validationErrors.push(result.error!); - continue; - } - - if (result.validatedFile) { - validatedFiles.push(result.validatedFile); - } - } - - return { validatedFiles, validationErrors }; -} - -export const createMatchEpisodesInBatchTool = (clientId: string, abortSignal?: AbortSignal) => ({ - description: `Match multiple local files to episodes of a TV show in batch. -This tool accepts an array of files to match and will ask for user confirmation before updating. -Once confirmed, it will update all the files in the media metadata. - -Example: Match multiple files in folder "/path/to/media/folder" to various episodes. -This tool return JSON response with the following format: -\`\`\`typescript -interface ToolResponse { - // error message - error?: string; -} -\`\`\` -`, - inputSchema: z.object({ - folderPath: z.string().describe("The absolute path of the media folder, it can be POSIX format or Windows format"), - files: z.array(z.object({ - season: z.number().describe("The season number"), - episode: z.number().describe("The episode number"), - path: z.string().describe("The absolute path of the video file, it can be POSIX format or Windows format"), - })).describe("Array of files to match"), - }), - execute: async ({ folderPath, files }: { - folderPath: string; - files: MatchFile[]; - }) => { - // TODO: Implement abort handling - check abortSignal and cancel ongoing operations - if (abortSignal?.aborted) { - throw new Error('Request was aborted'); - } - logger.info(`[tool][matchEpisodesInBatch] Matching ${files.length} files in folder "${folderPath}"`); - const folderPathInPosix = Path.posix(folderPath); - const metadataFilePath = metadataCacheFilePath(folderPathInPosix); - - // 1. Validate metadata exists - const metadataExistsResult = await validateMetadataExists(folderPath); - if (!metadataExistsResult.isValid) { - return { error: metadataExistsResult.error }; - } - - // 2. Validate and load media metadata - const metadataLoadResult = await validateAndLoadMetadata(folderPath); - if (!metadataLoadResult.isValid || !metadataLoadResult.metadata) { - return { error: metadataLoadResult.error }; - } - const mediaMetadata = metadataLoadResult.metadata; - - // 3. Validate folder path matches - const folderPathMatchResult = validateFolderPathMatch(folderPath, mediaMetadata); - if (!folderPathMatchResult.isValid) { - return { error: folderPathMatchResult.error }; - } - - // 4. Validate TMDB TV show exists - const tmdbTvShowResult = validateTmdbTvShowExists(mediaMetadata); - if (!tmdbTvShowResult.isValid) { - return { error: tmdbTvShowResult.error }; - } - const tvShow = mediaMetadata.tvShow!; - - // 5. List files from filesystem - const folderPathObj = new Path(folderPathInPosix); - let filesystemFiles: string[]; - try { - filesystemFiles = await listFiles(folderPathObj, true); - logger.info({ - folderPath: folderPathInPosix, - filesystemFilesCount: filesystemFiles.length - }, '[tool][matchEpisodesInBatch] Listed files from filesystem'); - } catch (error) { - logger.error({ - folderPath: folderPathInPosix, - error: error instanceof Error ? error.message : String(error) - }, '[tool][matchEpisodesInBatch] Failed to list files from filesystem'); - return { error: `Error Reason: Failed to list files from folder: ${error instanceof Error ? error.message : 'Unknown error'}` }; - } - - // 6. Validate all files - logger.info({ - folderPath: folderPathInPosix, - totalFiles: files.length, - filesystemFilesCount: filesystemFiles.length - }, '[tool][matchEpisodesInBatch] Starting validation'); - - const { validatedFiles, validationErrors } = validateAllFiles(files, filesystemFiles, tvShow); - - logger.info({ - totalFiles: files.length, - validatedFiles: validatedFiles.length, - validationErrors: validationErrors.length - }, '[tool][matchEpisodesInBatch] Validation complete'); - - // If there are validation errors, return them - if (validationErrors.length > 0) { - logger.error({ - validationErrors, - totalErrors: validationErrors.length - }, '[tool][matchEpisodesInBatch] Validation failed'); - return { error: `Error Reason: Validation failed:\n${validationErrors.join('\n')}` }; - } - - if (validatedFiles.length === 0) { - logger.warn('[tool][matchEpisodesInBatch] No valid files to match'); - return { error: `Error Reason: No valid files to match` }; - } - - - // 4. Ask for user confirmation - const getFilename = (path: string) => { - const pathInPosix = Path.posix(path); - const parts = pathInPosix.split('/').filter(p => p); - return parts[parts.length - 1] || pathInPosix; - }; - - const confirmationMessage = `Match ${validatedFiles.length} file(s) to episodes?\n\n${validatedFiles.map(f => ` • ${getFilename(f.path)} → S${f.season}E${f.episode}`).join('\n')}`; - - try { - - logger.info({ - clientId, - confirmationMessage, - }, '[tool][matchEpisodesInBatch] Sending askForConfirmation event'); - const responseData = await acknowledge( - { - event: 'askForConfirmation', - data: { - message: confirmationMessage, - }, - clientId: clientId, - }, - ); - - logger.info({ - responseData, - }, '[tool][matchEpisodesInBatch] User confirmation received'); - - const confirmed = responseData?.confirmed ?? responseData?.response === 'yes'; - - if (!confirmed) { - return { error: 'User cancelled the operation' }; - } - } catch (error) { - logger.error({ - error: error instanceof Error ? error.message : String(error) - }, '[tool][matchEpisodesInBatch] Error getting confirmation'); - return { error: `Error Reason: Failed to get user confirmation: ${error instanceof Error ? error.message : 'Unknown error'}` }; - } - - // 5. Update media metadata for all files - let updatedMediaFiles = mediaMetadata.mediaFiles ?? []; - - for (const file of validatedFiles) { - const pathInPosix = Path.posix(file.path); - updatedMediaFiles = updateMediaFileMetadatas(updatedMediaFiles, pathInPosix, file.season, file.episode); - } - - const updatedMediaMetadata: MediaMetadata = { - ...mediaMetadata, - mediaFiles: updatedMediaFiles - }; - - // 6. Write updated metadata back to file - try { - await mkdir(mediaMetadataDir, { recursive: true }); - await Bun.write(metadataFilePath, JSON.stringify(updatedMediaMetadata, null, 2)); - logger.info(`[tool][matchEpisodesInBatch] Successfully updated media metadata for ${validatedFiles.length} file(s) in folder "${folderPathInPosix}"`); - - // 7. Notify all connected clients via Socket.IO - broadcast({ - event: 'mediaMetadataUpdated', - data: { - folderPath: folderPathInPosix - } - }); - } catch (error) { - return { error: `Error Reason: Failed to write media metadata: ${error instanceof Error ? error.message : 'Unknown error'}` }; - } - - return { - error: undefined - }; - }, -}); - diff --git a/apps/cli/src/tools/readme.ts b/apps/cli/src/tools/readme.ts deleted file mode 100644 index 11265642..00000000 --- a/apps/cli/src/tools/readme.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { z } from "zod"; -import type { ToolDefinition } from "./types"; -import { createSuccessResponse } from "@/mcp/tools/mcpToolBase"; -import { getLocalizedToolDescription } from '@/i18n/helpers'; - -const readmeContent = `# Simple Media Manager (SMM) - -SMM 用于管理电视剧、电影或音乐的本地文件夹。 -当用户导入文件夹时,SMM可能无法自动识别该文件夹属于哪部电视剧或电影, 也无法识别电视剧每一集对应的本地视频文件。 -识别多媒体文件夹和识别季集视频文件操作用于告知SMM该文件夹属于哪部电视剧或电影和视频文件和季集的对应关系。 - -本文描述了 SMM 的使用说明 - -## 核心概念 - -**多媒体文件夹(Media Folder)**: 多媒体文件夹, 保存了电视剧、电影或音乐文件 -**媒体库(Media Library)**: 媒体库, 保存了多个多媒体文件夹 -**识别多媒体文件夹(Recognize Media Folder)**: 该操作用于指定文件夹保存的是哪一部电视剧或电影的视频文件 -**识别季集视频文件(Recognize Episode Video File)**: 该操作用于指定电视剧每一集对应的本地视频文件 -**元数据(Media Metadata)**: 元数据, 保存了文件夹对应的电视剧或电影的信息,以及本地视频文件和季集的对应关系 - -## 常用操作 - -### 识别文件夹 - -识别文件夹对应的是哪一步电视剧, 动画或电影 - -### 识别季集视频文件 - -识别 episode 对应的本地视频文件. -或电影的本地视频文件. - -### 重命名 - -该操作只适用于已识别的季集视频文件。 -多媒体文件夹下可能有其他视频文件(例如 OP, ED, 预告片, 花絮等),这些文件不属于季集视频文件,不支持被重命名。 -执行该操作前,使用 "how-to-rename-episode-video-files" 工具获取操作说明。 - -** Goal ** -1. 重命名季集视频文件名 -2. 移动季集视频文件名 - -** Non Goal ** -1. 修改文件夹下的其他文件 - -### 整理文件夹 - -依次完成识别文件夹, 识别季集视频文件, 重命名季集视频文件. - - -`; - -export async function getTool(): Promise { - const description = await getLocalizedToolDescription('readme'); - - return { - toolName: "readme", - description: description, - inputSchema: z.object({}), - outputSchema: z.object({ - text: z.string().describe("Markdown content"), - }), - execute: async () => { - return createSuccessResponse({ text: readmeContent }); - }, - }; -} - -export async function readmeMcpTool() { - return getTool(); -} diff --git a/apps/cli/src/tools/recognizeMediaFilesTool.ts b/apps/cli/src/tools/recognizeMediaFilesTool.ts index 28dfa32d..af3beaaf 100644 --- a/apps/cli/src/tools/recognizeMediaFilesTool.ts +++ b/apps/cli/src/tools/recognizeMediaFilesTool.ts @@ -3,11 +3,9 @@ import { RecognizeMediaFilePlanReady, type RecognizeMediaFilePlanReadyRequestDat import type { RecognizeMediaFilePlan, RecognizedFile } from "@smm/types/RecognizeMediaFilePlan"; import { getAppDataDir } from "@/utils/config"; import path from "path"; -import { mkdir, readdir, stat } from "fs/promises"; +import { mkdir } from "fs/promises"; import { Path } from "@smm/utils/path"; -import pino from "pino"; -const logger = pino(); /** * Get the path to the plans directory @@ -156,140 +154,4 @@ export async function endRecognizeTask(taskId: string): Promise { }); } -export type UpdatePlanStatus = 'rejected' | 'completed'; -/** - * Update a recognition plan's status (reject or complete). - * Only plans in "pending" status can be updated. - * @param planId The plan ID (UUID) - * @param status The new status: "rejected" or "completed" - */ -export async function updatePlanStatus(planId: string, status: UpdatePlanStatus): Promise { - const plansDir = getPlansDir(); - - try { - try { - const stats = await stat(plansDir); - if (!stats.isDirectory()) { - throw new Error(`Plans path exists but is not a directory`); - } - } catch (error) { - throw new Error(`Plans directory does not exist`); - } - - const files = await readdir(plansDir); - - for (const file of files) { - if (!file.endsWith('.plan.json')) { - continue; - } - - const planFilePath = path.join(plansDir, file); - - try { - const fileContent = Bun.file(planFilePath); - - if (!(await fileContent.exists())) { - continue; - } - - const plan = await fileContent.json() as RecognizeMediaFilePlan; - - if (plan.id === planId) { - if (plan.status !== 'pending') { - throw new Error(`Plan cannot be updated: plan has status "${plan.status}"`); - } - - plan.status = status; - await Bun.write(planFilePath, JSON.stringify(plan, null, 2)); - - logger.info({ planId, planFilePath, status }, 'Plan status updated successfully'); - return; - } - } catch (error) { - logger.warn( - { planFilePath, error: error instanceof Error ? error.message : String(error) }, - 'Failed to parse plan file, skipping' - ); - } - } - - throw new Error(`Plan with id "${planId}" not found`); - } catch (error) { - logger.error( - { planId, plansDir, status, error: error instanceof Error ? error.message : String(error) }, - 'Failed to update plan status' - ); - throw error; - } -} - -/** - * Get all pending tasks from the plans directory - * @return Array of pending RecognizeMediaFilePlan tasks - */ -export async function getAllPendingTasks(): Promise { - const plansDir = getPlansDir(); - const pendingTasks: RecognizeMediaFilePlan[] = []; - - try { - // Check if plans directory exists - try { - const stats = await stat(plansDir); - if (!stats.isDirectory()) { - logger.warn({ plansDir }, 'Plans path exists but is not a directory'); - return []; - } - } catch (error) { - // Directory doesn't exist, return empty array - return []; - } - - // Read all files in the plans directory - const files = await readdir(plansDir); - - // Filter for .plan.json files and parse them - for (const file of files) { - if (!file.endsWith('.plan.json')) { - continue; - } - - const planFilePath = path.join(plansDir, file); - - try { - const fileContent = Bun.file(planFilePath); - - if (!(await fileContent.exists())) { - continue; - } - - const content = await fileContent.json(); - - // Validate that it's a RecognizeMediaFilePlan with pending status - if ( - typeof content === 'object' && - content !== null && - content.task === 'recognize-media-file' && - content.status === 'pending' - ) { - pendingTasks.push(content as RecognizeMediaFilePlan); - } - } catch (error) { - // Log warning for invalid JSON files but continue processing others - logger.warn( - { planFilePath, error: error instanceof Error ? error.message : String(error) }, - 'Failed to parse plan file, skipping' - ); - } - } - - return pendingTasks; - } catch (error) { - logger.error( - { plansDir, error: error instanceof Error ? error.message : String(error) }, - 'Failed to read plans directory' - ); - // Return empty array on error rather than throwing - return []; - } -} diff --git a/apps/cli/src/tools/renameFilesInBatch.ts b/apps/cli/src/tools/renameFilesInBatch.ts index b88dcaab..550bfbab 100644 --- a/apps/cli/src/tools/renameFilesInBatch.ts +++ b/apps/cli/src/tools/renameFilesInBatch.ts @@ -2,7 +2,6 @@ import { z } from 'zod/v3'; import { stat } from 'node:fs/promises'; import { Path } from '@smm/utils/path'; import type { MediaMetadata, RenameValidationResult } from '@smm/types'; -import { updateMediaMetadataAfterRename } from '@smm/core/mediaMetadata'; import { validateRenameOperations as validateRenameOperationsShared } from '@smm/core/validations/rename/validateRenameOperations'; import type { RenameFileExistenceProbe } from '@smm/core/validations/rename/validateRenameFileExistence'; import { metadataCacheFilePath } from '../route/mediaMetadata/utils'; @@ -17,12 +16,6 @@ interface RenameFile { to: string; } -/** @deprecated Use RenameValidationResult from @smm/types instead */ -export interface ValidationResult { - validationErrors: string[]; - validatedRenames: RenameFile[]; -} - function createCliRenameFileExistenceProbe(): RenameFileExistenceProbe { return { async isFile(posixPath: string): Promise { @@ -80,7 +73,6 @@ export async function validateRenameOperations( return result; } -export { updateMediaMetadataAfterRename }; export const createRenameFilesInBatchTool = (clientId: string, abortSignal?: AbortSignal) => ({ description: `Rename multiple files in a media folder in batch. diff --git a/apps/cli/src/tools/renameFolder.ts b/apps/cli/src/tools/renameFolder.ts index b8fc0f0a..07fde924 100644 --- a/apps/cli/src/tools/renameFolder.ts +++ b/apps/cli/src/tools/renameFolder.ts @@ -14,14 +14,8 @@ import { renameFolderOutputSchema, type RenameFolderOutput, } from '@smm/types/ai-tools/renameFolder' -import type { ToolDefinition } from './types' -import { - createSuccessResponse, - createErrorResponse, -} from '@/mcp/tools/mcpToolBase' import { acknowledge } from '@/utils/socketIO' -import logger from '../../lib/logger' -import { getLocalizedToolDescription } from '@/i18n/helpers' +import { logger } from '../../lib/logger' import { doRenameFolder } from '@/route/RenameFolder' export interface RenameFolderParams { @@ -29,16 +23,6 @@ export interface RenameFolderParams { to: string } -function toMcpResponse(result: RenameFolderOutput) { - if (result.error && !result.renamed) { - return createSuccessResponse(result) - } - if (result.renamed) { - return createSuccessResponse(result) - } - return createSuccessResponse(result) -} - /** * Core rename-folder execution (no confirmation). Used by MCP and agent after confirm. */ @@ -99,23 +83,6 @@ export async function executeRenameFolder( } } -/** @deprecated Use executeRenameFolder — kept for MCP registration */ -export async function handleRenameFolder( - params: RenameFolderParams, - abortSignal?: AbortSignal, -): Promise< - ReturnType | ReturnType -> { - try { - const result = await executeRenameFolder(params, abortSignal) - return toMcpResponse(result) - } catch (error) { - return createErrorResponse( - error instanceof Error ? error.message : 'Request was aborted', - ) - } -} - async function confirmRenameFolderViaSocket( clientId: string, from: string, @@ -197,26 +164,3 @@ export function renameFolderAgentTool( ) { return createAgentRenameFolderTool(clientId, abortSignal) } - -/** @deprecated Alias of renameFolderAgentTool */ -export const createRenameFolderTool = renameFolderAgentTool - -export const getTool = async function ( - abortSignal?: AbortSignal, -): Promise { - const description = await getLocalizedToolDescription(RENAME_FOLDER) - - return { - toolName: RENAME_FOLDER, - description, - inputSchema: renameFolderInputSchema, - outputSchema: renameFolderOutputSchema, - execute: async (args: RenameFolderParams) => { - return handleRenameFolder(args, abortSignal) - }, - } -} - -export async function renameFolderMcpTool() { - return getTool() -} diff --git a/apps/cli/src/tools/types.ts b/apps/cli/src/tools/types.ts deleted file mode 100644 index 4e2228c9..00000000 --- a/apps/cli/src/tools/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type z from "zod"; -import type { McpToolResponse } from "@/mcp/tools/mcpToolBase"; - -/** - * This interface defines the structure of MCP Server Tool and AI Client Tool. - * SMM acts as: - * * MCP Server that allow external AI assistants to connect to. - * * AI Agent that user can chat with AI assistant builtin in SMM - */ -export interface ToolDefinition { - - description: string; - toolName: string; - inputSchema: z.ZodSchema; - outputSchema: z.ZodSchema; - execute: (args: any) => Promise; - -} \ No newline at end of file diff --git a/apps/cli/src/utils/Ffmpeg.ts b/apps/cli/src/utils/Ffmpeg.ts index 8d757291..e9ec9bc9 100644 --- a/apps/cli/src/utils/Ffmpeg.ts +++ b/apps/cli/src/utils/Ffmpeg.ts @@ -1,15 +1,7 @@ import { getUserConfig } from "./config"; -import path from "path"; import os from "os"; -import fs from "fs"; -import { execSync, spawn } from "child_process"; -import { Path } from "@smm/utils/path"; import { logger } from "../../lib/logger"; -/** Escape a string for use inside double quotes in shell (e.g. paths with " in filename). */ -function escapeForDoubleQuotedShell(s: string): string { - return s.replace(/"/g, '""'); -} import { getCliProjectRoot, @@ -46,7 +38,7 @@ async function readFfprobeConfiguredPath(): Promise { } /** App auto-discovery (no user config): bundled → project bin → install dir → PATH. */ -export function discoverFfmpegAuto(): string | undefined { +function discoverFfmpegAuto(): string | undefined { const resolved = resolveAutoToolPath("ffmpeg", ffmpegExeName()); if (resolved) { logger.info({ resolved }, "discoverFfmpegAuto: resolved ffmpeg"); @@ -85,319 +77,8 @@ export async function resolveFfmpegPathInfo(): Promise<{ return { configuredPath: configured, discoveredPath: discovered }; } -export interface FfmpegVersionResult { - version?: string; - error?: string; -} - -export async function getFfmpegVersion(): Promise { - const ffmpegPath = await discoverFfmpeg(); - - if (!ffmpegPath) { - return { error: "ffmpeg executable not found" }; - } - - try { - const output = execSync(`"${ffmpegPath}" -version`, { - encoding: "utf-8", - timeout: 10000, - }); - - const lines = output.trim().split("\n"); - const firstLine = lines[0]; - if (!firstLine) { - return { error: "failed to parse ffmpeg version" }; - } - const match = firstLine.match(/ffmpeg version (.+)/); - - if (match && match[1]) { - return { version: match[1] }; - } - - return { error: "failed to parse ffmpeg version" }; - } catch { - return { error: "failed to execute ffmpeg" }; - } -} - -// --- Format conversion --- - -export type ConvertFormat = "mp4h264" | "mp4h265" | "webm" | "mkv"; -export type ConvertPreset = "quality" | "balanced" | "speed"; - -export interface ConvertVideoOptions { - format: ConvertFormat; - preset: ConvertPreset; -} - -export interface ConvertVideoResult { - error?: string; -} - -function buildConvertArgs( - inputPath: string, - outputPath: string, - options: ConvertVideoOptions -): string[] { - const { format, preset } = options; - const args: string[] = ["-i", inputPath]; - - switch (format) { - case "mp4h264": { - const crf = preset === "quality" ? "18" : preset === "balanced" ? "23" : "23"; - const x264Preset = - preset === "quality" ? "slow" : preset === "balanced" ? "medium" : "veryfast"; - args.push("-c:v", "libx264", "-crf", crf, "-preset", x264Preset); - args.push("-c:a", "copy"); - break; - } - case "mp4h265": { - const crf = preset === "quality" ? "20" : preset === "balanced" ? "26" : "28"; - const x265Preset = - preset === "quality" ? "slow" : preset === "balanced" ? "medium" : "fast"; - args.push("-c:v", "libx265", "-crf", crf, "-preset", x265Preset); - args.push("-c:a", "copy"); - break; - } - case "webm": { - const crf = preset === "quality" ? "30" : preset === "balanced" ? "35" : "40"; - args.push("-c:v", "libvpx-vp9", "-crf", crf, "-b:v", "0"); - if (preset === "speed") { - args.push("-deadline", "realtime"); - } else if (preset === "balanced") { - args.push("-deadline", "good"); - } - args.push("-c:a", "libopus", "-b:a", "128k"); - break; - } - case "mkv": { - const crf = preset === "quality" ? "18" : preset === "balanced" ? "23" : "23"; - const x264Preset = - preset === "quality" ? "slow" : preset === "balanced" ? "medium" : "veryfast"; - args.push("-c:v", "libx264", "-crf", crf, "-preset", x264Preset); - args.push("-c:a", "copy"); - break; - } - default: - throw new Error(`Unsupported format: ${format}`); - } - - args.push("-y", outputPath); - return args; -} - -function runFfmpegConvert( - ffmpegPath: string, - inputPath: string, - outputPath: string, - options: ConvertVideoOptions -): Promise<{ error?: string }> { - return new Promise((resolve) => { - const args = buildConvertArgs(inputPath, outputPath, options); - const child = spawn(ffmpegPath, args, { - stdio: ["ignore", "pipe", "pipe"], - }); - - let stderr = ""; - child.stderr?.on("data", (data) => { - stderr += data.toString(); - }); - - child.on("close", (code) => { - if (code === 0) { - resolve({}); - return; - } - const lastLines = stderr.trim().split("\n").slice(-5).join(" "); - resolve({ - error: `ffmpeg exited with code ${code}${lastLines ? `: ${lastLines}` : ""}`, - }); - }); - - child.on("error", (err) => { - resolve({ - error: `ffmpeg spawn error: ${err instanceof Error ? err.message : "unknown error"}`, - }); - }); - }); -} - -export async function convertVideo( - inputPath: string, - outputPath: string, - options: ConvertVideoOptions -): Promise { - if (!inputPath) { - return { error: "input path is required" }; - } - if (!outputPath) { - return { error: "output path is required" }; - } - - const ffmpegPath = await discoverFfmpeg(); - if (!ffmpegPath) { - return { error: "ffmpeg executable not found" }; - } - - const inputPathObj = new Path(inputPath); - const outputPathObj = new Path(outputPath); - const absInput = inputPathObj.platformAbsPath(); - const absOutput = outputPathObj.platformAbsPath(); - - if (!fs.existsSync(absInput)) { - return { error: "input file not found" }; - } - - const outputDir = path.dirname(absOutput); - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - return runFfmpegConvert(ffmpegPath, absInput, absOutput, options); -} export async function discoverFfprobe(): Promise { const configured = await readFfprobeConfiguredPath(); return resolveEffectiveToolPath("ffmpeg", ffprobeExeName(), configured); } - -export interface MediaTagsResult { - tags?: Record; - /** Duration in seconds; from format.duration or first stream with duration when format lacks it. */ - duration?: number; - error?: string; -} - -export async function getMediaTags(filePath: string): Promise { - if (!filePath) { - return { error: "file path is required" }; - } - - const ffprobePath = await discoverFfprobe(); - if (!ffprobePath) { - return { error: "ffprobe executable not found" }; - } - - if (!fs.existsSync(filePath)) { - return { error: `File not found: ${filePath}` }; - } - - try { - const output = execSync( - `"${escapeForDoubleQuotedShell(ffprobePath)}" -v quiet -print_format json -show_format -show_streams "${escapeForDoubleQuotedShell(filePath)}"`, - { - encoding: "utf-8", - timeout: 30000, - stdio: ["ignore", "pipe", "pipe"], - } - ); - - const result = JSON.parse(output); - const format = result.format; - if (!format) { - return { tags: {} }; - } - const tags = format.tags ?? {}; - let durationRaw = - format.duration != null && format.duration !== "" - ? Number.parseFloat(String(format.duration)) - : NaN; - if (!Number.isFinite(durationRaw) && Array.isArray(result.streams)) { - for (const stream of result.streams) { - const d = - stream.duration != null && stream.duration !== "" - ? Number.parseFloat(String(stream.duration)) - : NaN; - if (Number.isFinite(d)) { - durationRaw = d; - break; - } - } - } - const duration = - Number.isFinite(durationRaw) && durationRaw >= 0 ? durationRaw : undefined; - return { tags, ...(duration !== undefined && { duration }) }; - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - logger.error({ err, filePath }, 'Unable to read tags by ffprobe'); - if (error instanceof Error) { - if (error.message.includes("timeout")) { - return { error: "request timed out" }; - } - if (error.message.includes("Invalid data")) { - return { error: "invalid media file format" }; - } - return { error: `ffprobe failed: ${error.message}` }; - } - return { error: "unknown error occurred while reading media tags" }; - } -} - -export interface WriteMediaTagsResult { - success?: boolean; - error?: string; -} - -export async function writeMediaTags( - filePath: string, - tags: Record -): Promise { - if (!filePath) { - return { error: "file path is required" }; - } - - if (!tags || Object.keys(tags).length === 0) { - return { error: "tags are required" }; - } - - const ffmpegPath = await discoverFfmpeg(); - if (!ffmpegPath) { - return { error: "ffmpeg executable not found" }; - } - - if (!fs.existsSync(filePath)) { - return { error: `File not found: ${filePath}` }; - } - - try { - const parsedPath = path.parse(filePath); - const tempFilePath = path.join(parsedPath.dir, `${parsedPath.name}.temp${parsedPath.ext}`); - - const args = ["-i", filePath, "-c", "copy"]; - - for (const [key, value] of Object.entries(tags)) { - args.push("-metadata", `${key}=${value}`); - } - - args.push("-y", tempFilePath); - - execSync( - `"${escapeForDoubleQuotedShell(ffmpegPath)}" ${args.map((arg) => `"${escapeForDoubleQuotedShell(arg)}"`).join(" ")}`, - { - encoding: "utf-8", - timeout: 60000, - stdio: ["ignore", "pipe", "pipe"], - } - ); - - if (!fs.existsSync(tempFilePath)) { - return { error: "failed to create temporary file with new tags" }; - } - - fs.unlinkSync(filePath); - fs.renameSync(tempFilePath, filePath); - - return { success: true }; - } catch (error) { - if (error instanceof Error) { - if (error.message.includes("timeout")) { - return { error: "request timed out" }; - } - if (error.message.includes("Permission denied")) { - return { error: "permission denied to write file" }; - } - return { error: `ffmpeg failed: ${error.message}` }; - } - return { error: "unknown error occurred while writing media tags" }; - } -} \ No newline at end of file diff --git a/apps/cli/src/utils/QuickJS.ts b/apps/cli/src/utils/QuickJS.ts index 81ec4c20..cabac244 100644 --- a/apps/cli/src/utils/QuickJS.ts +++ b/apps/cli/src/utils/QuickJS.ts @@ -1,6 +1,5 @@ import { getUserConfig } from "./config"; import os from "os"; -import { execSync } from "child_process"; import { logger } from "../../lib/logger"; import { readConfiguredToolPath, @@ -22,7 +21,7 @@ async function readQuickJSConfiguredPath(): Promise { } /** App auto-discovery (no user config): bundled -> project bin -> install dir -> PATH. */ -export function discoverQuickjsAuto(): string | undefined { +function discoverQuickjsAuto(): string | undefined { const resolved = resolveAutoToolPath("quickjs", quickjsExeName()); if (resolved) { quickjsLog.debug({ resolved }, "resolved QuickJS via app auto-discovery"); @@ -50,26 +49,3 @@ export async function resolveQuickjsPathInfo(): Promise<{ const discovered = discoverQuickjsAuto() ?? null; return { configuredPath: configured, discoveredPath: discovered }; } - -export interface QuickjsVersionResult { - version?: string; - error?: string; -} - -export async function getQuickjsVersion(): Promise { - const quickjsPath = await discoverQuickjs(); - - if (!quickjsPath) { - return { error: "QuickJS executable not found" }; - } - - try { - const version = execSync(`"${quickjsPath}" --version`, { - encoding: "utf-8", - timeout: 10000, - }); - return { version: version.trim() }; - } catch { - return { error: "failed to execute QuickJS" }; - } -} diff --git a/apps/cli/src/utils/VideoCaptioner.ts b/apps/cli/src/utils/VideoCaptioner.ts index 10e3ed4e..5bbac0b6 100644 --- a/apps/cli/src/utils/VideoCaptioner.ts +++ b/apps/cli/src/utils/VideoCaptioner.ts @@ -14,12 +14,12 @@ import { } from "./toolExecutableDiscovery"; const videoCaptionerLog = logger.child({ module: "videocaptioner" }); -export const TRANSCRIBE_TIMEOUT_MS = 10 * 60 * 1000; +const TRANSCRIBE_TIMEOUT_MS = 10 * 60 * 1000; /** Subtitle mux/burn can exceed transcribe duration. */ -export const SYNTHESIZE_TIMEOUT_MS = 60 * 60 * 1000; +const SYNTHESIZE_TIMEOUT_MS = 60 * 60 * 1000; /** Full `videocaptioner process` (transcribe → subtitle → optional synthesize) can run much longer. */ -export const PROCESS_TIMEOUT_MS = 2 * 60 * 60 * 1000; +const PROCESS_TIMEOUT_MS = 2 * 60 * 60 * 1000; import { buildVideoCaptionerProcessArgs, @@ -136,7 +136,7 @@ export function getPythonScriptsCandidatePaths(exeName: string): string[] { } /** App auto-discovery (no user config): bundled → project bin → install dir → PATH → Python Scripts. */ -export function discoverVideoCaptionerAuto(): string | undefined { +function discoverVideoCaptionerAuto(): string | undefined { const exeName = videoCaptionerExeName(); return resolveAutoToolPathWithExtras( "videocaptioner", @@ -303,31 +303,31 @@ async function runVideocaptionerSpawnWithCommandLog(input: { } } -export const VIDEOCAPTIONER_ASR_ENGINES = ["bijian", "jianying", "whisper-cpp"] as const; -export type VideoCaptionerAsrEngine = (typeof VIDEOCAPTIONER_ASR_ENGINES)[number]; +const VIDEOCAPTIONER_ASR_ENGINES = ["bijian", "jianying", "whisper-cpp"] as const; +type VideoCaptionerAsrEngine = (typeof VIDEOCAPTIONER_ASR_ENGINES)[number]; -export const VIDEOCAPTIONER_TRANSCRIBE_FORMATS = ["srt", "ass", "txt", "json"] as const; -export type VideoCaptionerTranscribeFormat = (typeof VIDEOCAPTIONER_TRANSCRIBE_FORMATS)[number]; +const VIDEOCAPTIONER_TRANSCRIBE_FORMATS = ["srt", "ass", "txt", "json"] as const; +type VideoCaptionerTranscribeFormat = (typeof VIDEOCAPTIONER_TRANSCRIBE_FORMATS)[number]; -export const VIDEOCAPTIONER_TRANSLATORS = ["bing", "google", "llm"] as const; -export type VideoCaptionerTranslator = (typeof VIDEOCAPTIONER_TRANSLATORS)[number]; +const VIDEOCAPTIONER_TRANSLATORS = ["bing", "google", "llm"] as const; +type VideoCaptionerTranslator = (typeof VIDEOCAPTIONER_TRANSLATORS)[number]; -export const VIDEOCAPTIONER_SUBTITLE_LAYOUTS = [ +const VIDEOCAPTIONER_SUBTITLE_LAYOUTS = [ "target-above", "source-above", "target-only", "source-only", ] as const; -export type VideoCaptionerSubtitleLayout = (typeof VIDEOCAPTIONER_SUBTITLE_LAYOUTS)[number]; +type VideoCaptionerSubtitleLayout = (typeof VIDEOCAPTIONER_SUBTITLE_LAYOUTS)[number]; -export const VIDEOCAPTIONER_SYNTHESIZE_SUBTITLE_MODES = ["soft", "hard"] as const; -export type VideoCaptionerSynthesizeSubtitleMode = (typeof VIDEOCAPTIONER_SYNTHESIZE_SUBTITLE_MODES)[number]; +const VIDEOCAPTIONER_SYNTHESIZE_SUBTITLE_MODES = ["soft", "hard"] as const; +type VideoCaptionerSynthesizeSubtitleMode = (typeof VIDEOCAPTIONER_SYNTHESIZE_SUBTITLE_MODES)[number]; -export const VIDEOCAPTIONER_SYNTHESIZE_QUALITY = ["ultra", "high", "medium", "low"] as const; -export type VideoCaptionerSynthesizeQuality = (typeof VIDEOCAPTIONER_SYNTHESIZE_QUALITY)[number]; +const VIDEOCAPTIONER_SYNTHESIZE_QUALITY = ["ultra", "high", "medium", "low"] as const; +type VideoCaptionerSynthesizeQuality = (typeof VIDEOCAPTIONER_SYNTHESIZE_QUALITY)[number]; -export const VIDEOCAPTIONER_SYNTHESIZE_RENDER_MODES = ["ass", "rounded"] as const; -export type VideoCaptionerSynthesizeRenderMode = (typeof VIDEOCAPTIONER_SYNTHESIZE_RENDER_MODES)[number]; +const VIDEOCAPTIONER_SYNTHESIZE_RENDER_MODES = ["ass", "rounded"] as const; +type VideoCaptionerSynthesizeRenderMode = (typeof VIDEOCAPTIONER_SYNTHESIZE_RENDER_MODES)[number]; export interface VideoCaptionerSynthesizeCliOptions { subtitleMode?: VideoCaptionerSynthesizeSubtitleMode; @@ -570,7 +570,7 @@ export async function synthesizeWithVideoCaptioner( } /** argv for `videocaptioner` executable (subcommand `process` + flags). */ -export function buildProcessVideoCaptionerArgs( +function buildProcessVideoCaptionerArgs( mediaPath: string, options?: VideoCaptionerProcessCliOptions, ): string[] { diff --git a/apps/cli/src/utils/Ytdlp.ts b/apps/cli/src/utils/Ytdlp.ts index 21185f74..d25f213c 100644 --- a/apps/cli/src/utils/Ytdlp.ts +++ b/apps/cli/src/utils/Ytdlp.ts @@ -1,10 +1,7 @@ -import { getUserConfig, getTmpDir } from "./config"; -import path from "path"; +import { getUserConfig } from "./config"; import os from "os"; -import { mkdir, rename, rm, readdir } from "fs/promises"; import { spawn, execSync } from "child_process"; import { logger } from "../../lib/logger"; -import { discoverFfmpeg } from "./Ffmpeg"; import { readConfiguredToolPath, resolveAutoToolPath, @@ -25,7 +22,7 @@ async function readYtdlpConfiguredPath(): Promise { } /** App auto-discovery (no user config): bundled → project bin → install dir → PATH. */ -export function discoverYtdlpAuto(): string | undefined { +function discoverYtdlpAuto(): string | undefined { const resolved = resolveAutoToolPath("yt-dlp", ytdlpExeName()); if (resolved) { ytdlpLog.debug({ resolved }, "resolved yt-dlp via app auto-discovery"); @@ -54,61 +51,6 @@ export async function resolveYtdlpPathInfo(): Promise<{ return { configuredPath: configured, discoveredPath: discovered }; } -/** - * Result of getting yt-dlp version - */ -export interface YtdlpVersionResult { - version?: string; - error?: string; -} - -/** - * Gets the yt-dlp version by executing yt-dlp --version - * @returns The version string if successful, or error message if failed - */ -export async function getYtdlpVersion(): Promise { - const ytdlpPath = await discoverYtdlp(); - - if (!ytdlpPath) { - return { error: "yt-dlp executable not found" }; - } - - try { - const version = execSync(`"${ytdlpPath}" --version`, { - encoding: "utf-8", - timeout: 10000, - }); - return { version: version.trim() }; - } catch { - return { error: "failed to execute yt-dlp" }; - } -} - -/** - * Allowed yt-dlp arguments for download - */ -const ALLOWED_ARGS = ["--write-thumbnail", "--embed-thumbnail", "--embed-metadata"]; - -/** - * Request data for yt-dlp download - */ -export interface YtdlpDownloadRequestData { - url: string; - args?: string[]; - folder?: string; - /** yt-dlp `-f` format selector (e.g. `137`, `best`). */ - format?: string; -} - -/** - * Result of yt-dlp download - */ -export interface YtdlpDownloadResult { - success?: boolean; - error?: string; - path?: string; -} - /** * Result of extracting video data */ @@ -202,208 +144,6 @@ export async function runYtdlpPlaylistDump( * @param args - Array of command-line arguments * @returns true if all args are allowed, false otherwise */ -function validateArgs(args?: string[]): boolean { - if (!args || args.length === 0) { - return true; - } - return args.every((arg) => ALLOWED_ARGS.includes(arg)); -} - -/** - * Downloads a video using yt-dlp - * @param request - Download request containing url and optional args - * @returns Result with success or error - */ -export async function downloadYtdlpVideo( - request: YtdlpDownloadRequestData, - signal?: AbortSignal -): Promise { - if (!request.url) { - return { error: "url is required" }; - } - - if (request.args && !validateArgs(request.args)) { - return { - error: `Only allowed args are: ${ALLOWED_ARGS.join(", ")}`, - }; - } - - const ytdlpPath = await discoverYtdlp(); - if (!ytdlpPath) { - return { error: "yt-dlp executable not found" }; - } - - const ffmpegPath = await discoverFfmpeg(); - - const finalDir = request.folder || path.join(os.homedir(), "Downloads"); - const tmpBase = getTmpDir(); - const tempDir = path.join(tmpBase, `ytdlp-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`); - - try { - await mkdir(tempDir, { recursive: true }); - } catch { - return { error: "failed to create temp directory for download" }; - } - - const cmdArgs = [ytdlpPath]; - const tempOutputTemplate = path.join(tempDir, "%(title)s [%(id)s].%(ext)s"); - cmdArgs.push("--output", tempOutputTemplate); - cmdArgs.push("--print", "after_move:filepath"); - - if (ffmpegPath) { - cmdArgs.push("--ffmpeg-location", ffmpegPath); - ytdlpLog.debug({ ffmpegPath }, "download: passing --ffmpeg-location"); - } else { - ytdlpLog.debug( - {}, - "download: ffmpeg not discovered; yt-dlp merge/postprocess may fail" - ); - } - - const format = request.format?.trim(); - if (format) { - cmdArgs.push("-f", format); - } - - cmdArgs.push(request.url); - if (request.args && request.args.length > 0) { - cmdArgs.push(...request.args); - } - - const spawnArgs = cmdArgs.slice(1); - ytdlpLog.debug( - { - ytdlpPath, - ffmpegPath: ffmpegPath ?? null, - tempDir, - finalDir, - tempOutputTemplate, - format: format ?? null, - extraArgs: request.args ?? [], - spawnArgs, - url: request.url, - }, - "download: spawning yt-dlp with temp directory" - ); - - let downloadedPath = ""; - try { - await new Promise((resolve, reject) => { - const child = spawn(ytdlpPath, spawnArgs, { - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - - const onAbort = () => { - ytdlpLog.warn({ ytdlpPath, spawnArgs }, "download: abort signal received, killing yt-dlp"); - child.kill("SIGTERM"); - }; - signal?.addEventListener("abort", onAbort, { once: true }); - - child.stdout?.on("data", (data) => { - stdout += data.toString(); - }); - child.stderr?.on("data", (data) => { - stderr += data.toString(); - process.stderr.write(data); - }); - child.on("close", (code) => { - signal?.removeEventListener("abort", onAbort); - if (code === 0) { - const lines = stdout.trim().split("\n").filter((l) => l.trim()); - downloadedPath = lines[lines.length - 1]?.trim() || ""; - ytdlpLog.debug( - { - exitCode: code, - downloadedPath, - stdoutLineCount: lines.length, - stderrByteLength: stderr.length, - }, - "download: yt-dlp finished successfully" - ); - if (stderr.trim()) { - ytdlpLog.debug({ stderr }, "download: yt-dlp stderr (non-fatal)"); - } - resolve(); - } else { - ytdlpLog.debug( - { - exitCode: code, - stdout, - stderr, - }, - "download: yt-dlp exited with error" - ); - reject(new Error(`yt-dlp exited with code ${code}`)); - } - }); - child.on("error", (err) => { - signal?.removeEventListener("abort", onAbort); - ytdlpLog.debug( - { err, ytdlpPath, spawnArgs }, - "download: failed to spawn yt-dlp" - ); - reject(err); - }); - }); - - const tempFiles = await readdir(tempDir); - await mkdir(finalDir, { recursive: true }); - - let movedMainFile = ""; - try { - for (const file of tempFiles) { - const tempFilePath = path.join(tempDir, file); - const finalFilePath = path.join(finalDir, file); - await rename(tempFilePath, finalFilePath); - if (downloadedPath && path.resolve(tempFilePath) === path.resolve(downloadedPath)) { - movedMainFile = finalFilePath; - } - } - } catch (moveError) { - ytdlpLog.error( - { err: moveError instanceof Error ? moveError.message : moveError, tempDir, finalDir }, - "download: failed to move files from temp to final directory, keeping temp files" - ); - return { - error: `Failed to move downloaded file to destination: ${ - moveError instanceof Error ? moveError.message : "Unknown error" - }`, - }; - } - - if (!movedMainFile && downloadedPath) { - movedMainFile = path.join(finalDir, path.basename(downloadedPath)); - } - - ytdlpLog.debug( - { tempFiles, finalDir, movedMainFile }, - "download: moved files from temp to final directory" - ); - - try { - await rm(tempDir, { recursive: true, force: true }); - } catch {} - - return { success: true, path: movedMainFile || downloadedPath }; - } catch (error) { - ytdlpLog.debug( - { - err: error instanceof Error ? error.message : error, - }, - "download: caught error after spawn" - ); - try { - await rm(tempDir, { recursive: true, force: true }); - } catch {} - return { - error: `yt-dlp download failed: ${ - error instanceof Error ? error.message : "Unknown error" - }`, - }; - } -} /** * Extracts video metadata (title and artist) using yt-dlp diff --git a/apps/cli/src/utils/cmd.ts b/apps/cli/src/utils/cmd.ts index d8c82c4c..b0d5fdef 100644 --- a/apps/cli/src/utils/cmd.ts +++ b/apps/cli/src/utils/cmd.ts @@ -35,7 +35,6 @@ import { type VideoCaptionerTranscribeResult, } from './VideoCaptioner'; -export type { VideoCaptionerTranscribeResult } from './VideoCaptioner'; import { discoverQuickjs } from './QuickJS'; import { createCommandExecutionLogWriter, @@ -60,7 +59,7 @@ export type ResolvedCommand = | { kind: 'not-found'; command: WhitelistedCommand }; /** Reason why the requested PTY mode could not be honored. */ -export type PtyFallbackReason = +type PtyFallbackReason = | 'not-yt-dlp' | 'pty-unavailable' | { reason: string }; diff --git a/apps/cli/src/utils/config.ts b/apps/cli/src/utils/config.ts index e86fa215..6dc5c03d 100644 --- a/apps/cli/src/utils/config.ts +++ b/apps/cli/src/utils/config.ts @@ -1,15 +1,10 @@ import type { UserConfig } from "@smm/types"; import { RenameRules } from "@smm/types"; import { migrateAIConfig } from "@smm/core/configMigration"; -import { renameFolderInUserConfig } from "@smm/core/userConfig"; import path from "path"; import os from "os"; -import { Mutex } from 'es-toolkit'; -import { withTimeout } from 'es-toolkit/promise'; -const updateMutex = new Mutex(); -export { renameFolderInUserConfig }; const DEFAULT_USER_CONFIG: UserConfig = { tmdb: { @@ -171,28 +166,3 @@ export async function getUserConfig(): Promise { } } -async function writeUserConfigUnderMutex(userConfig: UserConfig): Promise { - const configPath = getUserConfigPath(); - const file = Bun.file(configPath); - await file.write(JSON.stringify(userConfig, null, 2)); -} - -export async function writeUserConfig(userConfig: UserConfig): Promise { - try { - await updateMutex.acquire(); - await writeUserConfigUnderMutex(userConfig); - } finally { - updateMutex.release(); - } -} - -export async function safeUpdateUserConfig(userConfig: UserConfig): Promise { - await withTimeout(async () => { - try { - await updateMutex.acquire(); - await writeUserConfigUnderMutex(userConfig); - } finally { - updateMutex.release(); - } - }, 10000); -} diff --git a/apps/cli/src/utils/db.ts b/apps/cli/src/utils/db.ts deleted file mode 100644 index 09dfa99c..00000000 --- a/apps/cli/src/utils/db.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Simple in-memory key-value database with localStorage-like interface - */ -export class Database { - private store: Map; - - constructor() { - this.store = new Map(); - } - - /** - * Set a key-value pair - */ - setItem(key: string, value: any): void { - this.store.set(key, value); - } - - /** - * Get a value by key - */ - getItem(key: string): any | null { - const value = this.store.get(key); - return value !== undefined ? value : null; - } - - /** - * Remove a key-value pair - */ - removeItem(key: string): void { - this.store.delete(key); - } - - /** - * Clear all key-value pairs - */ - clear(): void { - this.store.clear(); - } - - /** - * Get the number of key-value pairs - */ - get length(): number { - return this.store.size; - } - - /** - * Get the key at a specific index - */ - key(index: number): string | null { - const keys = Array.from(this.store.keys()); - return index >= 0 && index < keys.length ? (keys[index] ?? null) : null; - } - - /** - * Check if a key exists - */ - hasItem(key: string): boolean { - return this.store.has(key); - } - - /** - * Get all keys - */ - keys(): string[] { - return Array.from(this.store.keys()); - } - - /** - * Get all values - */ - values(): any[] { - return Array.from(this.store.values()); - } - - /** - * Get all entries as key-value pairs - */ - entries(): [string, any][] { - return Array.from(this.store.entries()); - } - - /** - * Get a typed value - */ - getTyped(key: string): T | null { - return this.getItem(key) as T | null; - } -} - -// Singleton instance -export const db = new Database(); diff --git a/apps/cli/src/utils/files.ts b/apps/cli/src/utils/files.ts index 7d087e14..11608b0f 100644 --- a/apps/cli/src/utils/files.ts +++ b/apps/cli/src/utils/files.ts @@ -1,104 +1,8 @@ -import { Path } from "@smm/utils/path"; -import { readdir, stat, unlink, access, constants, mkdir, cp } from "node:fs/promises"; +import { stat, unlink, access, constants, mkdir, cp } from "node:fs/promises"; import path from "path"; import { isDesktopEnv } from "./os"; import { logger } from "../../lib/logger"; -/** - * Lists files in a directory with optional recursive scanning and hidden file filtering - * @param folderPath - * @param recursively - Whether to scan subdirectories recursively - * @param ignoreHiddenFiles - Whether to filter out hidden files and system files - * @returns Promise - List of file absolute paths in POSIX format - */ -export async function listFiles(_folderPath: Path, recursively: boolean = false, ignoreHiddenFiles: boolean = true): Promise { - let files: string[] = [] - - const folderPlatformPath = _folderPath.platformAbsPath(); - - async function scanDirectory(dirPath: string): Promise { - const items = await readdir(dirPath) - - for (const item of items) { - const fullPath = path.join(dirPath, item) - const stats = await stat(fullPath) - - if (stats.isFile()) { - files.push(Path.posix(fullPath)) - } else if (recursively && stats.isDirectory()) { - await scanDirectory(fullPath) - } - } - } - - await scanDirectory(folderPlatformPath) - - if (ignoreHiddenFiles) { - files = files - .map(file => { - return { - path: file, - filename: path.basename(file), - dirname: path.dirname(file) - } - }) - .filter(file => { - const { filename, dirname } = file - - // Unix/Linux/macOS hidden files (starting with .) - if (filename.startsWith('.')) return false - - // Windows system files - if (filename === 'Thumbs.db' || filename === 'desktop.ini') return false - - // macOS system files - if (filename === '.DS_Store' || filename === '.Spotlight-V100' || filename === '.Trashes') return false - if (filename === '._.DS_Store' || filename === '.fseventsd') return false - - // Linux system files - if (filename === '.Trash-1000' || filename === '.nfs') return false - - // Temporary and cache files - if (filename.endsWith('.tmp') || filename.endsWith('.temp')) return false - if (filename.endsWith('.cache') || filename.endsWith('.bak')) return false - if (filename.endsWith('.swp') || filename.endsWith('.swo')) return false - if (filename.endsWith('.lock') || filename.endsWith('.pid')) return false - - // BitComet padding files (already present in original code) - if (filename.startsWith('_____padding_file') && filename.endsWith('____')) return false - - // uTorrent/BitTorrent files - if (filename.endsWith('.torrent')) return false - if (filename.endsWith('.part') || filename.endsWith('.part.1')) return false - - // Media player cache files - // if (filename.endsWith('.nfo') && filename !== 'tvshow.nfo' && filename !== 'movie.nfo') return false - // if (filename.endsWith('.srt') || filename.endsWith('.ass') || filename.endsWith('.ssa')) return false - // if (filename.endsWith('.idx') || filename.endsWith('.sub')) return false - - // Archive and compression files (often temporary) - if (filename.endsWith('.zip.tmp') || filename.endsWith('.rar.tmp')) return false - if (filename.endsWith('.7z.tmp') || filename.endsWith('.tar.tmp')) return false - - // Log files - if (filename.endsWith('.log') || filename.endsWith('.log.1')) return false - - // Backup files - if (filename.endsWith('.backup') || filename.endsWith('.old')) return false - - // Hidden files in subdirectories (check if any parent directory is hidden) - const pathParts = dirname.split(path.sep) - for (const part of pathParts) { - if (part.startsWith('.') && part !== '.') return false - } - - return true - }) - .map(file => file.path) - } - - return files - } /** * Move file to trash or delete it based on the current environment diff --git a/apps/cli/src/utils/gracefulShutdown.ts b/apps/cli/src/utils/gracefulShutdown.ts index 3d207683..54f95797 100644 --- a/apps/cli/src/utils/gracefulShutdown.ts +++ b/apps/cli/src/utils/gracefulShutdown.ts @@ -16,10 +16,6 @@ export function registerGracefulShutdown(options: { process.once('SIGTERM', onSignal); } -export function isShutdownInProgress(): boolean { - return shutdownInProgress; -} - export async function runGracefulShutdown(options?: { signal?: string; exitProcess?: boolean; diff --git a/apps/cli/src/utils/mediaMetadata.ts b/apps/cli/src/utils/mediaMetadata.ts index bb44c38c..3fbd9009 100644 --- a/apps/cli/src/utils/mediaMetadata.ts +++ b/apps/cli/src/utils/mediaMetadata.ts @@ -1,9 +1,6 @@ import type { MediaMetadata } from "@smm/types" import { Path } from "@smm/utils/path" import { metadataCacheFilePath } from "../route/mediaMetadata/utils" -import { unlink } from "fs/promises" -import { logger } from "../../lib/logger" -import { rename } from "fs/promises" /** * Find media metadata by the media folder path. @@ -15,14 +12,14 @@ export async function findMediaMetadata(mediaFolderPath: string): Promise { - if(!mediaMetadata.mediaFolderPath) { - throw new Error('Media folder path is required') - } - const metadataFilePath = metadataCacheFilePath(mediaMetadata.mediaFolderPath) - logger.info({ - metadataFilePath, - mediaMetadata, - }, '[writeMediaMetadata] Writing media metadata to file'); - await Bun.write(metadataFilePath, JSON.stringify(mediaMetadata, null, 2)) -} - -export async function deleteMediaMetadataFile(mediaFolderPathInPosix: string): Promise { - const metadataFilePath = metadataCacheFilePath(mediaFolderPathInPosix) - logger.info({ - metadataFilePath, - }, '[deleteMediaMetadataFile] Deleting media metadata file'); - await unlink(metadataFilePath) -} - -export async function renameMediaMetadataCacheFile( - fromInPosix: string, - toInPosix: string, - traceId: { traceId: string }): Promise { - - const fromFilePath = metadataCacheFilePath(fromInPosix) - const toFilePath = metadataCacheFilePath(toInPosix) - const fromExists = await Bun.file(fromFilePath).exists() - const toExists = await Bun.file(toFilePath).exists() - const fromFolderPlatform = Path.toPlatformPath(fromInPosix) - const toFolderPlatform = Path.toPlatformPath(toInPosix) - logger.info({ - fromFolder: fromFolderPlatform, - toFolder: toFolderPlatform, - fromFilePath, - toFilePath, - fromExists, - toExists, - traceId, - file: "utils/mediaMetadata.ts" - }, 'renameMediaMetadataCacheFile: before fs.rename') - if (!fromExists) { - logger.warn({ - fromFolder: fromFolderPlatform, - toFolder: toFolderPlatform, - fromFilePath, - toFilePath, - traceId, - file: "utils/mediaMetadata.ts" - }, 'renameMediaMetadataCacheFile: source cache file missing (ENOENT on rename); ensure metadata file name matches metadataCacheFilePath for this media folder') - } - await rename(fromFilePath, toFilePath) - - logger.info({ - fromFolder: fromFolderPlatform, - toFolder: toFolderPlatform, - traceId, - file: "utils/mediaMetadata.ts" - }, 'renamed media metadata cache file'); -} diff --git a/apps/cli/src/utils/mediaMetadataUtils.ts b/apps/cli/src/utils/mediaMetadataUtils.ts deleted file mode 100644 index b0be6c29..00000000 --- a/apps/cli/src/utils/mediaMetadataUtils.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { MediaMetadata } from "@smm/types"; -import { Path } from "@smm/utils/path"; -import pino from "pino"; - -const logger = pino(); - - -/** - * Rename media folder in media metadata, which means: - * 1. Rename folder in mediaMetadata.mediaFiles.absolutePath - * - * This method assumes the "from" and "to" path are valid. - * Devleoper need to do validation before calling this method. - * - * @param mediaMetadata - * @param from - the origin folder absolute path in POSIX format - * @param to - the new folder absolute path in POSIX format - * @returns new media metadata with the folder renamed - */ -export function renameMediaFolderInMediaMetadata(mediaMetadata: MediaMetadata, from: string, to: string): MediaMetadata { - logger.info({ - from, - to, - mediaMetadata - }, `Renaming folder in media metadata: ${from} to ${to}`); - - const clone = structuredClone(mediaMetadata); - - // Helper function to replace folder prefix in a path - const replaceFolderPrefix = (path: string): string => { - const normalizedPath = Path.posix(path); - - // Check if path starts with the folder path (with trailing slash or exact match) - if (normalizedPath === from || normalizedPath.startsWith(from + '/')) { - // Replace the folder prefix - return normalizedPath.replace(from, to); - } - return path; - }; - - // Update mediaFolderPath if it matches - if (clone.mediaFolderPath) { - const normalizedMediaFolderPath = Path.posix(clone.mediaFolderPath); - if (normalizedMediaFolderPath === from) { - clone.mediaFolderPath = to; - } - } - - // Update mediaFiles array - clone.mediaFiles = clone.mediaFiles?.map(mediaFile => { - const updatedAbsolutePath = replaceFolderPrefix(mediaFile.absolutePath); - - // Update subtitleFilePaths if they exist - const updatedSubtitlePaths = mediaFile.subtitleFilePaths?.map(path => replaceFolderPrefix(path)); - - // Update audioFilePaths if they exist - const updatedAudioPaths = mediaFile.audioFilePaths?.map(path => replaceFolderPrefix(path)); - - // Only create a new object if something changed - if (updatedAbsolutePath !== mediaFile.absolutePath || - updatedSubtitlePaths !== mediaFile.subtitleFilePaths || - updatedAudioPaths !== mediaFile.audioFilePaths) { - return { - ...mediaFile, - absolutePath: updatedAbsolutePath, - subtitleFilePaths: updatedSubtitlePaths, - audioFilePaths: updatedAudioPaths - }; - } - return mediaFile; - }); - - logger.info({ - clone, - }, `Updated media metadata after folder rename`); - return clone; -} - diff --git a/apps/cli/src/utils/permission.ts b/apps/cli/src/utils/permission.ts index 4a07e54e..2dfbc353 100644 --- a/apps/cli/src/utils/permission.ts +++ b/apps/cli/src/utils/permission.ts @@ -49,8 +49,3 @@ export async function allowRead(path: string): Promise { return false; } - -export async function allowWrite(path: string): Promise { - // so far, file allowed to read is allow to write - return await allowRead(path); -} \ No newline at end of file diff --git a/apps/cli/src/utils/pty.ts b/apps/cli/src/utils/pty.ts index 362c6e28..369fe836 100644 --- a/apps/cli/src/utils/pty.ts +++ b/apps/cli/src/utils/pty.ts @@ -21,7 +21,7 @@ export interface IPty { kill: (signal?: string) => void; } -export interface PtySpawnOptions { +interface PtySpawnOptions { name?: string; cols?: number; rows?: number; diff --git a/apps/cli/src/utils/renameFileUtils.ts b/apps/cli/src/utils/renameFileUtils.ts index 68859697..24e4cb55 100644 --- a/apps/cli/src/utils/renameFileUtils.ts +++ b/apps/cli/src/utils/renameFileUtils.ts @@ -3,7 +3,6 @@ import { Path } from '@smm/utils/path'; import type { MediaMetadata } from '@smm/types'; import { broadcast } from './socketIO'; import { metadataCacheFilePath, mediaMetadataDir } from '../route/mediaMetadata/utils'; -import { renameMediaFolderInMediaMetadata } from './mediaMetadataUtils'; import { updateMediaMetadataAfterRename } from '@smm/core/mediaMetadata'; import pino from 'pino'; import { dirname } from 'path'; @@ -103,7 +102,7 @@ export async function executeBatchRenameOperations( * @param options Execution options * @returns Result with success status and optional error message */ -export async function executeRenameOperation( +async function executeRenameOperation( from: string, to: string, options: { @@ -341,162 +340,3 @@ export async function updateMediaMetadataAndBroadcast( } } -/** - * Update media metadata after folder rename and broadcast the update event - * Handles the case where the media folder itself is being renamed - * @param mediaFolder Media folder path (POSIX format) - the folder being renamed or its parent - * @param from Source folder path (POSIX format) - * @param to Destination folder path (POSIX format) - * @param options Update options - * @returns Result with success status and optional error message - */ -export async function updateMediaMetadataAfterFolderRename( - mediaFolder: string, - from: string, - to: string, - options: { - dryRun?: boolean; - clientId?: string; - logPrefix?: string; - } = {} -): Promise<{ success: boolean; error?: string }> { - const { dryRun = false, clientId, logPrefix = '[updateMediaMetadataAfterFolderRename]' } = options; - - if (dryRun) { - logger.info({ - mediaFolder, - from, - to, - clientId - }, `${logPrefix} Dry run: Would update media metadata after folder rename`); - return { success: true }; - } - - const fromNormalized = Path.posix(from); - const toNormalized = Path.posix(to); - const mediaFolderNormalized = Path.posix(mediaFolder); - - // Determine if the media folder itself is being renamed - const isMediaFolderRename = fromNormalized === mediaFolderNormalized; - - // Find the metadata file - use the original mediaFolder path - const metadataFilePath = metadataCacheFilePath(mediaFolderNormalized); - const metadataExists = await Bun.file(metadataFilePath).exists(); - - if (!metadataExists) { - logger.debug({ - mediaFolder: mediaFolderNormalized, - clientId - }, `${logPrefix} Media metadata file does not exist, skipping update`); - return { success: true }; // Not an error if metadata doesn't exist - } - - try { - const mediaMetadata = await Bun.file(metadataFilePath).json() as MediaMetadata; - - // Verify the mediaFolder path matches - if (mediaMetadata.mediaFolderPath !== mediaFolderNormalized) { - logger.warn({ - providedPath: mediaFolderNormalized, - metadataPath: mediaMetadata.mediaFolderPath, - clientId - }, `${logPrefix} Folder path mismatch, skipping metadata update`); - return { - success: false, - error: `Folder path mismatch: provided "${mediaFolderNormalized}" but metadata has "${mediaMetadata.mediaFolderPath}"`, - }; - } - - // Update metadata with renamed folder - logger.info({ - from: fromNormalized, - to: toNormalized, - isMediaFolderRename, - clientId - }, `${logPrefix} Renaming folder in media metadata: ${fromNormalized} to ${toNormalized}`); - - const updatedMediaMetadata = renameMediaFolderInMediaMetadata( - mediaMetadata, - fromNormalized, - toNormalized - ); - - logger.info({ - updatedMediaMetadata, - clientId - }, `${logPrefix} Updated media metadata after folder rename`); - - // If the media folder itself is being renamed, we need to move the metadata file - if (isMediaFolderRename) { - const newMetadataFilePath = metadataCacheFilePath(toNormalized); - - // Write updated metadata to new location - await mkdir(mediaMetadataDir, { recursive: true }); - await Bun.write(newMetadataFilePath, JSON.stringify(updatedMediaMetadata, null, 2)); - - // Remove old metadata file - try { - await Bun.file(metadataFilePath).unlink(); - logger.info({ - oldPath: metadataFilePath, - newPath: newMetadataFilePath, - clientId - }, `${logPrefix} Moved metadata file to new location`); - } catch (error) { - logger.warn({ - oldPath: metadataFilePath, - error: error instanceof Error ? error.message : String(error), - clientId - }, `${logPrefix} Failed to remove old metadata file (non-critical)`); - } - - // Broadcast with new folder path - broadcast({ - event: 'mediaMetadataUpdated', - data: { - folderPath: toNormalized - } - }); - - logger.info({ - from: fromNormalized, - to: toNormalized, - clientId - }, `${logPrefix} Broadcasted mediaMetadataUpdated event for renamed media folder`); - } else { - // Write updated metadata back to same file - await mkdir(mediaMetadataDir, { recursive: true }); - await Bun.write(metadataFilePath, JSON.stringify(updatedMediaMetadata, null, 2)); - - // Broadcast with original media folder path - broadcast({ - event: 'mediaMetadataUpdated', - data: { - folderPath: mediaFolderNormalized - } - }); - - logger.info({ - mediaFolder: mediaFolderNormalized, - clientId - }, `${logPrefix} Broadcasted mediaMetadataUpdated event`); - } - - return { success: true }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.error({ - mediaFolder: mediaFolderNormalized, - from: fromNormalized, - to: toNormalized, - error: errorMessage, - clientId - }, `${logPrefix} Failed to update media metadata after folder rename`); - - return { - success: false, - error: errorMessage, - }; - } -} - diff --git a/apps/cli/src/utils/socketIO.ts b/apps/cli/src/utils/socketIO.ts index cb75e57c..7154f318 100644 --- a/apps/cli/src/utils/socketIO.ts +++ b/apps/cli/src/utils/socketIO.ts @@ -1,4 +1,4 @@ -import type { Server, Socket } from "socket.io"; +import type { Socket } from "socket.io"; import type { SocketIOManager, WebSocketMessage, @@ -13,25 +13,13 @@ export function setSocketIOManager(socketManager: SocketIOManager): void { manager = socketManager; } -export function getSocketIOManager(): SocketIOManager { +function getSocketIOManager(): SocketIOManager { if (!manager) { throw new Error("Socket.IO manager not initialized. Call setSocketIOManager first."); } return manager; } -export function initializeSocketIO(_io: Server): void { - throw new Error("initializeSocketIO is deprecated; use setSocketIOManager(createSocketIOManager(...))"); -} - -export function setSocketIOInstance(_socketIO: Server): void { - throw new Error("setSocketIOInstance is deprecated; use setSocketIOManager(createSocketIOManager(...))"); -} - -export function getSocketIOInstance(): Server { - return getSocketIOManager().getSocketIOInstance(); -} - export function broadcast(message: WebSocketMessage): void { if (!manager) { logger.error("Socket.IO instance not initialized"); @@ -44,10 +32,6 @@ export function getFirstAvailableSocket(): { socket: Socket; clientId: string } return manager?.getFirstAvailableSocket() ?? null; } -export function findSocketByClientId(clientId?: string): { socket: Socket; clientId: string } { - return getSocketIOManager().findSocketByClientId(clientId); -} - export async function acknowledge( message: WebSocketMessage, timeoutMs?: number, @@ -55,14 +39,3 @@ export async function acknowledge( return getSocketIOManager().acknowledge(message, timeoutMs); } -export function getFirstActiveConnection(): string | null { - return manager?.getFirstActiveConnection() ?? null; -} - -export function isClientConnected(clientId: string): boolean { - return manager?.isClientConnected(clientId) ?? false; -} - -export function getConnectedClientIds(): string[] { - return manager?.getConnectedClientIds() ?? []; -} diff --git a/apps/cli/src/utils/tmdb.ts b/apps/cli/src/utils/tmdb.ts deleted file mode 100644 index f17012c9..00000000 --- a/apps/cli/src/utils/tmdb.ts +++ /dev/null @@ -1,22 +0,0 @@ - -/** - * Get backdrop image URL - * @param backdropPath - Backdrop path from TMDB response - * @param size - Image size (default: 'w1280') - * @returns Full backdrop URL - */ -export function getBackdropUrl(backdropPath: string | null, size: string = 'w1280'): string | null { - if (!backdropPath) return null - return `https://image.tmdb.org/t/p/${size}${backdropPath}` -} - -/** - * Get poster image URL - * @param posterPath - Poster path from TMDB response - * @param size - Image size (default: 'w500') - * @returns Full poster URL - */ -export function getPosterUrl(posterPath: string | null, size: string = 'w500'): string | null { - if (!posterPath) return null - return `https://image.tmdb.org/t/p/${size}${posterPath}` -} diff --git a/apps/cli/src/utils/tmdbOutboundFetch.ts b/apps/cli/src/utils/tmdbOutboundFetch.ts deleted file mode 100644 index 324e6b27..00000000 --- a/apps/cli/src/utils/tmdbOutboundFetch.ts +++ /dev/null @@ -1,110 +0,0 @@ -import * as http from 'node:http'; -import * as https from 'node:https'; -import { trustAllTmdbCertEnabled } from './tmdbTls'; - -const hopByHop = new Set([ - 'connection', - 'keep-alive', - 'proxy-authenticate', - 'proxy-authorization', - 'te', - 'trailer', - 'transfer-encoding', - 'upgrade', - 'host', -]); - -/** - * Bun's fetch uses its own TLS stack and often still fails on some Windows / custom-cert - * hosts even with TRUST_ALL_TMDB_CERT and NODE_TLS_REJECT_UNAUTHORIZED=0. - * Node's https.request(res rejectUnauthorized: false) honors the flag reliably. - */ -function fetchHttpsOrHttpViaNode(request: Request): Promise { - const url = new URL(request.url); - const isHttps = url.protocol === 'https:'; - - const headers: http.OutgoingHttpHeaders = {}; - request.headers.forEach((value, key) => { - if (hopByHop.has(key.toLowerCase())) return; - headers[key] = value; - }); - - const method = request.method.toUpperCase(); - const port = url.port ? Number(url.port) : isHttps ? 443 : 80; - const pathWithQuery = `${url.pathname}${url.search}`; - - return new Promise((resolve, reject) => { - const onResponse = (res: http.IncomingMessage) => { - const chunks: Buffer[] = []; - res.on('data', (chunk: Buffer) => chunks.push(chunk)); - res.on('end', () => { - const buf = Buffer.concat(chunks); - const resHeaders = new Headers(); - for (const [key, val] of Object.entries(res.headers)) { - if (val === undefined) continue; - if (Array.isArray(val)) { - for (const v of val) resHeaders.append(key, v); - } else { - resHeaders.append(key, val); - } - } - resolve( - new Response(buf, { - status: res.statusCode ?? 502, - statusText: res.statusMessage ?? '', - headers: resHeaders, - }), - ); - }); - }; - - const start = async () => { - let body: Buffer | undefined; - if (method !== 'GET' && method !== 'HEAD') { - body = Buffer.from(await request.arrayBuffer()); - } - - if (isHttps) { - const req = https.request( - { - hostname: url.hostname, - port, - path: pathWithQuery, - method: request.method, - headers, - rejectUnauthorized: false, - }, - onResponse, - ); - req.on('error', reject); - if (body !== undefined && body.length > 0) req.write(body); - req.end(); - } else { - const req = http.request( - { - hostname: url.hostname, - port, - path: pathWithQuery, - method: request.method, - headers, - }, - onResponse, - ); - req.on('error', reject); - if (body !== undefined && body.length > 0) req.write(body); - req.end(); - } - }; - - void start().catch(reject); - }); -} - -/** Outbound TMDB (proxy or API): uses Bun fetch unless TRUST_ALL_TMDB_CERT requests Node TLS bypass. */ -export async function tmdbOutboundFetch(input: string | Request, init?: RequestInit): Promise { - const request = typeof input === 'string' ? new Request(input, init) : input; - if (!trustAllTmdbCertEnabled()) { - return fetch(request); - } - return fetchHttpsOrHttpViaNode(request); -} diff --git a/apps/cli/src/utils/toolExecutableDiscovery.ts b/apps/cli/src/utils/toolExecutableDiscovery.ts index 647b878f..a002f920 100644 --- a/apps/cli/src/utils/toolExecutableDiscovery.ts +++ b/apps/cli/src/utils/toolExecutableDiscovery.ts @@ -26,11 +26,11 @@ export function getSmmDataDir(): string { } } -export function existingPathIfFile(filePath: string): string | undefined { +function existingPathIfFile(filePath: string): string | undefined { return fs.existsSync(filePath) ? filePath : undefined; } -export function getSystemPathEnv(): string | undefined { +function getSystemPathEnv(): string | undefined { return process.env.PATH ?? process.env.Path; } @@ -58,7 +58,7 @@ export function findExecutableOnSystemPath( return undefined; } -export function bundledToolPath( +function bundledToolPath( binSubdir: string, exeName: string ): string | undefined { @@ -69,16 +69,16 @@ export function bundledToolPath( return existingPathIfFile(path.join(resourcesPath, "bin", binSubdir, exeName)); } -export function projectToolPath(binSubdir: string, exeName: string): string | undefined { +function projectToolPath(binSubdir: string, exeName: string): string | undefined { return existingPathIfFile(path.join(getCliProjectRoot(), "bin", binSubdir, exeName)); } -export function installToolPath(binSubdir: string, exeName: string): string | undefined { +function installToolPath(binSubdir: string, exeName: string): string | undefined { return existingPathIfFile(path.join(getSmmDataDir(), "bin", binSubdir, exeName)); } /** First existing path in extraCandidates (tool-specific, e.g. Python Scripts). */ -export function firstExistingCandidate( +function firstExistingCandidate( candidates: readonly string[] ): string | undefined { for (const candidate of candidates) { diff --git a/apps/cli/src/utils/traceId.ts b/apps/cli/src/utils/traceId.ts deleted file mode 100644 index d4e0fc38..00000000 --- a/apps/cli/src/utils/traceId.ts +++ /dev/null @@ -1,4 +0,0 @@ -let counter = 1; -export function nextTraceId(): string { - return `${counter++}`; -} \ No newline at end of file diff --git a/apps/cli/src/validations/validateChainingConflicts.ts b/apps/cli/src/validations/validateChainingConflicts.ts deleted file mode 100644 index fd5a24cd..00000000 --- a/apps/cli/src/validations/validateChainingConflicts.ts +++ /dev/null @@ -1 +0,0 @@ -export { validateChainingConflicts } from '@smm/core/validations/rename/validateChainingConflicts' diff --git a/apps/cli/src/validations/validateDestFileNotExist.ts b/apps/cli/src/validations/validateDestFileNotExist.ts deleted file mode 100644 index f11be361..00000000 --- a/apps/cli/src/validations/validateDestFileNotExist.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { stat } from 'node:fs/promises'; -import { Path } from '@smm/utils/path'; - -/** - * Wrapper for stat with timeout to prevent hanging on invalid paths - */ -async function statWithTimeout(path: string, timeoutMs: number = 1000): Promise> { - return Promise.race([ - stat(path), - new Promise((_, reject) => - setTimeout(() => reject(new Error(`stat timeout for path: ${path}`)), timeoutMs) - ), - ]); -} - -/** - * Validate that all destination files do not exist in the filesystem. - * @param tasks Array of rename operations - * @returns Object containing isValid flag and existing destination file paths if any - */ -export async function validateDestFileNotExist( - tasks: { - /** - * absolute path of file to be renamed from - */ - from: string; - /** - * absolute path of file to be renamed to - */ - to: string; - }[], -): Promise<{ isValid: boolean; existingFiles: string[] }> { - const existingFiles: string[] = []; - - for (const task of tasks) { - if (!task) continue; - - try { - const platformPath = Path.toPlatformPath(task.to); - const stats = await statWithTimeout(platformPath); - // Only check for files, not directories - if (stats?.isFile()) { - existingFiles.push(task.to); - } - } catch (error) { - // File doesn't exist or timeout occurred, which is what we want - continue - continue; - } - } - - return { - isValid: existingFiles.length === 0, - existingFiles, - }; -} - diff --git a/apps/cli/src/validations/validateFileName.ts b/apps/cli/src/validations/validateFileName.ts deleted file mode 100644 index dcbf2410..00000000 --- a/apps/cli/src/validations/validateFileName.ts +++ /dev/null @@ -1,8 +0,0 @@ -import sanitizeFileName from 'sanitize-filename'; -export function validateFileName(fileName: string): boolean { - if (!fileName || fileName.trim().length === 0) { - return false; - } - const sanitizedFileName = sanitizeFileName(fileName); - return sanitizedFileName === fileName; -} \ No newline at end of file diff --git a/apps/cli/src/validations/validateNoAbnormalPaths.ts b/apps/cli/src/validations/validateNoAbnormalPaths.ts deleted file mode 100644 index 445645bd..00000000 --- a/apps/cli/src/validations/validateNoAbnormalPaths.ts +++ /dev/null @@ -1 +0,0 @@ -export { validateNoAbnormalPaths } from '@smm/core/validations/rename/validateNoAbnormalPaths' diff --git a/apps/cli/src/validations/validateNoDuplicatedDestFile.ts b/apps/cli/src/validations/validateNoDuplicatedDestFile.ts deleted file mode 100644 index 2ac397a8..00000000 --- a/apps/cli/src/validations/validateNoDuplicatedDestFile.ts +++ /dev/null @@ -1 +0,0 @@ -export { validateNoDuplicatedDestFile } from '@smm/core/validations/rename/validateNoDuplicatedDestFile' diff --git a/apps/cli/src/validations/validateNoDuplicatedSourceFile.ts b/apps/cli/src/validations/validateNoDuplicatedSourceFile.ts deleted file mode 100644 index a2d4c50a..00000000 --- a/apps/cli/src/validations/validateNoDuplicatedSourceFile.ts +++ /dev/null @@ -1,14 +0,0 @@ -export { - validateNoDuplicatedSourceFile, -} from '@smm/core/validations/rename/validateNoDuplicatedSourceFile' -export { - validateNoDuplicatedDestFile, -} from '@smm/core/validations/rename/validateNoDuplicatedDestFile' -export { - validateNoIdenticalSourceAndDestFile, -} from '@smm/core/validations/rename/validateNoIdenticalSourceAndDestFile' -export { validateChainingConflicts } from '@smm/core/validations/rename/validateChainingConflicts' -export { - validatePathWithinMediaFolder, -} from '@smm/core/validations/rename/validatePathWithinMediaFolder' -export { validateNoAbnormalPaths } from '@smm/core/validations/rename/validateNoAbnormalPaths' diff --git a/apps/cli/src/validations/validateNoIdenticalSourceAndDestFile.ts b/apps/cli/src/validations/validateNoIdenticalSourceAndDestFile.ts deleted file mode 100644 index 751e22d6..00000000 --- a/apps/cli/src/validations/validateNoIdenticalSourceAndDestFile.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - validateNoIdenticalSourceAndDestFile, -} from '@smm/core/validations/rename/validateNoIdenticalSourceAndDestFile' diff --git a/apps/cli/src/validations/validatePathWithinMediaFolder.ts b/apps/cli/src/validations/validatePathWithinMediaFolder.ts deleted file mode 100644 index 2e51cee0..00000000 --- a/apps/cli/src/validations/validatePathWithinMediaFolder.ts +++ /dev/null @@ -1 +0,0 @@ -export { validatePathWithinMediaFolder } from '@smm/core/validations/rename/validatePathWithinMediaFolder' diff --git a/apps/cli/src/validations/validateSourceFileExist.ts b/apps/cli/src/validations/validateSourceFileExist.ts deleted file mode 100644 index bec445df..00000000 --- a/apps/cli/src/validations/validateSourceFileExist.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { stat } from 'node:fs/promises'; -import { Path } from '@smm/utils/path'; - -/** - * Validate that all source files exist in the filesystem. - * @param tasks Array of rename operations - * @returns Object containing isValid flag and missing source file paths if any - */ -export async function validateSourceFileExist( - tasks: { - /** - * absolute path of file to be renamed from - */ - from: string; - /** - * absolute path of file to be renamed to - */ - to: string; - }[], -): Promise<{ isValid: boolean; missingFiles: string[] }> { - const missingFiles: string[] = []; - - for (const task of tasks) { - if (!task) continue; - - try { - const platformPath = Path.toPlatformPath(task.from); - const stats = await stat(platformPath); - // Only accept files, not directories - if (!stats.isFile()) { - missingFiles.push(task.from); - } - } catch (error) { - // File doesn't exist or can't be accessed - missingFiles.push(task.from); - } - } - - return { - isValid: missingFiles.length === 0, - missingFiles, - }; -} - diff --git a/apps/cli/test/helpers/loadEnvLocal.ts b/apps/cli/test/helpers/loadEnvLocal.ts index 9d2bb92a..d010b68b 100644 --- a/apps/cli/test/helpers/loadEnvLocal.ts +++ b/apps/cli/test/helpers/loadEnvLocal.ts @@ -35,11 +35,3 @@ export function loadEnvLocal(startDir: string = process.cwd()): Record { { timeout: SCRAPE_TIMEOUT_MS }, async () => { const testFolder: TestFolder = { - ...folder1, + ...tvShowFolder, folderName: 'Scrape 123123', files: [], } @@ -105,7 +104,7 @@ describe('smm scrape CLI e2e', () => { { timeout: SCRAPE_TIMEOUT_MS }, async () => { const testFolder: TestFolder = { - ...folder1, + ...tvShowFolder, folderName: 'ScrapeSkipAll 123123', files: [], } @@ -131,7 +130,7 @@ describe('smm scrape CLI e2e', () => { { timeout: SCRAPE_TIMEOUT_MS }, async () => { const testFolder: TestFolder = { - ...folder1, + ...tvShowFolder, folderName: 'ScrapePartial 123123', files: [], } diff --git a/apps/convex/package.json b/apps/convex/package.json index ccb1b296..991b0af1 100644 --- a/apps/convex/package.json +++ b/apps/convex/package.json @@ -2,7 +2,6 @@ "name": "convex", "version": "1.1.19", "description": "", - "main": "index.js", "scripts": { "dev": "convex dev", "deploy": "convex deploy -y", diff --git a/apps/core/src/ai-tool/systemPrompt.ts b/apps/core/src/ai-tool/systemPrompt.ts index 259dd568..cfce9ada 100644 --- a/apps/core/src/ai-tool/systemPrompt.ts +++ b/apps/core/src/ai-tool/systemPrompt.ts @@ -114,10 +114,3 @@ EpisodeName: The episode name Extension: The file extension, such as "mp4", "mkv", "avi", ... ` -/** - * Legacy alias kept for backward compatibility with existing imports - * of `prompts.system` from `apps/ui/src/ai/prompts.ts`. - * - * @deprecated Import `SYSTEM_PROMPT` from `@smm/core/ai-tool/systemPrompt` instead. - */ -export const systemPrompt = SYSTEM_PROMPT diff --git a/apps/core/src/pipeline/recognizeMediaFolder.test.ts b/apps/core/src/pipeline/recognizeMediaFolder.test.ts index f2cae730..95f19d81 100644 --- a/apps/core/src/pipeline/recognizeMediaFolder.test.ts +++ b/apps/core/src/pipeline/recognizeMediaFolder.test.ts @@ -6,11 +6,11 @@ import { Path } from "@smm/utils/path"; import type { MediaMetadata, MovieMediaMetadata, TvShowMediaMetadata } from "@smm/types"; import { createFolderInTestFolder, - folder1, + tvShowFolder, folder2, folder3, folder4, - folder5, + movieFolder, type TestFolder, } from "@smm/test"; import { NodejsFsAdapter } from "../adapters/node/NodejsFsAdapter"; @@ -86,9 +86,9 @@ async function mediaMetadataFrom(created: TestFolder): Promise { describe("getTmdbIdFromFolderName / getTvdbIdFromFolderName", () => { it("parses ids from shared folder fixtures", () => { - expect(getTmdbIdFromFolderName(folder1.folderName)).toBe("84666"); + expect(getTmdbIdFromFolderName(tvShowFolder.folderName)).toBe("84666"); expect(getTvdbIdFromFolderName(folder4.folderName)).toBe("421069"); - expect(getTvdbIdFromFolderName(folder5.folderName)).toBe("116"); + expect(getTvdbIdFromFolderName(movieFolder.folderName)).toBe("116"); expect(getTmdbIdFromFolderName(folder3.folderName)).toBeNull(); }); }); @@ -102,11 +102,11 @@ describe("recognizeMediaFolder", () => { rmSync(mediaDir, { recursive: true, force: true }); }); - it("recognizes TV show via tmdbid in folder name (folder1 fixture)", async () => { + it("recognizes TV show via tmdbid in folder name (tvShowFolder fixture)", async () => { const d = deps(); (d.tmdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue(tvShowFixture); - const created = createFolderInTestFolder(mediaDir, folder1); + const created = createFolderInTestFolder(mediaDir, tvShowFolder); const mm = await mediaMetadataFrom(created); const result = await recognizeMediaFolder(mm, d); @@ -137,7 +137,7 @@ describe("recognizeMediaFolder", () => { (d.tmdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue(tvShowFixture); const created = createFolderInTestFolder(mediaDir, { - ...folder1, + ...tvShowFolder, folderName: "FolderContainsTvShowNfo", }); writeNfo( @@ -162,7 +162,7 @@ describe("recognizeMediaFolder", () => { }); const created = createFolderInTestFolder(mediaDir, { - ...folder1, + ...tvShowFolder, folderName: "FolderWithTvdbNfo", }); writeNfo( @@ -182,7 +182,7 @@ describe("recognizeMediaFolder", () => { (d.tmdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue(tvShowFixture); const created = createFolderInTestFolder(mediaDir, { - ...folder1, + ...tvShowFolder, folderName: "NfoOverridesFolderName {tmdbid=84666}", }); writeNfo(created.path!, "tvshow.nfo", `7`); @@ -234,7 +234,7 @@ describe("recognizeMediaFolder", () => { (d.tvdb.searchSeries as ReturnType).mockResolvedValue([]); const created = createFolderInTestFolder(mediaDir, { - ...folder1, + ...tvShowFolder, folderName: `Unknown-${Date.now()}`, }); const mm = await mediaMetadataFrom(created); @@ -260,11 +260,11 @@ describe("recognizeMediaFolder", () => { expect(d.tmdb.search).not.toHaveBeenCalled(); }); - it("recognizes movie via tvdbid in folder name (folder5 fixture)", async () => { + it("recognizes movie via tvdbid in folder name (movieFolder fixture)", async () => { const d = deps(); (d.tvdb.getMovieMediaMetadata as ReturnType).mockResolvedValue(darkKnightMovie); - const created = createFolderInTestFolder(mediaDir, folder5); + const created = createFolderInTestFolder(mediaDir, movieFolder); const mm = await mediaMetadataFrom(created); const result = await recognizeMediaFolder(mm, d); @@ -298,7 +298,7 @@ describe("recognizeMediaFolder", () => { (d.tvdb.getMovieMediaMetadata as ReturnType).mockResolvedValue(darkKnightMovie); const created = createFolderInTestFolder(mediaDir, { - ...folder5, + ...movieFolder, folderName: "The Dark Knight", }); const mm = await mediaMetadataFrom(created); @@ -388,10 +388,10 @@ describe("recognizeMediaFolder", () => { return tvShowFixture; }); (d.tmdb.search as ReturnType).mockResolvedValue({ - results: [{ id: 9, name: folder1.mediaName }], + results: [{ id: 9, name: tvShowFolder.mediaName }], }); - const created = createFolderInTestFolder(mediaDir, folder1); + const created = createFolderInTestFolder(mediaDir, tvShowFolder); const mm = await mediaMetadataFrom(created); const result = await recognizeMediaFolder(mm, d); diff --git a/apps/docker/package.json b/apps/docker/package.json index 3d2fc874..72bff8a5 100644 --- a/apps/docker/package.json +++ b/apps/docker/package.json @@ -2,10 +2,6 @@ "name": "docker", "private": true, "description": "Docker image build for SMM (CLI + UI + bin)", - "devDependencies": { - "cli": "workspace:*", - "ui": "workspace:*" - }, "scripts": { "test": "bun test", "build": "docker buildx build --progress=plain -t smm:latest -f Dockerfile ../..", diff --git a/apps/e2e/cli/base.ts b/apps/e2e/cli/base.ts index 83d13a24..17d9ed65 100644 --- a/apps/e2e/cli/base.ts +++ b/apps/e2e/cli/base.ts @@ -7,18 +7,14 @@ import { cleanupCore, setupCore, - updateUserConfig as updateUserConfigCore, type HelloPathsResolver, - type ResetUserConfigOption, type TestBedCoreCleanupOptions, type TestBedCoreSetupOptions, - type UserConfigUpdater, } from '../test/lib/testbed-core' import { runCliHello } from '../test/lib/cli-hello' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' -export type { ResetUserConfigOption, UserConfigUpdater } export type CliTestBedCleanupOptions = Omit & { /** Path to the `smm` CLI executable. */ @@ -54,16 +50,6 @@ export async function setup(options: CliTestBedSetupOptions): Promise { }) } -export async function updateUserConfig( - updateFn: UserConfigUpdater, - options: { binary: string }, -): Promise { - await updateUserConfigCore(updateFn, { - resolveHelloPaths: cliHelloResolver(options.binary), - }) -} - -export { runCliHello } const isWindows = process.platform === 'win32' diff --git a/apps/e2e/cli/import-library.test.ts b/apps/e2e/cli/import-library.test.ts index 352aa723..6013c3bd 100644 --- a/apps/e2e/cli/import-library.test.ts +++ b/apps/e2e/cli/import-library.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test' import { mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { createFolderInTestFolder, folder1, folder2, musicFolder } from '@smm/test' +import { createFolderInTestFolder, tvShowFolder, folder2, musicFolder } from '@smm/test' import { setup, cleanup, bin } from './base' import { $ } from 'bun' @@ -34,9 +34,9 @@ describe('import library', () => { }) it('import TV show library', async () => { - const show1 = createFolderInTestFolder(libraryPath, folder1) + const show1 = createFolderInTestFolder(libraryPath, tvShowFolder) const show2 = createFolderInTestFolder(libraryPath, { - ...folder1, + ...tvShowFolder, folderName: 'UnknownFolder', files: ['S01E01.mkv'], }) diff --git a/apps/e2e/common/manual/CustomTmdbHost.e2e.ts b/apps/e2e/common/manual/CustomTmdbHost.e2e.ts index 0dac719f..5d28c20a 100644 --- a/apps/e2e/common/manual/CustomTmdbHost.e2e.ts +++ b/apps/e2e/common/manual/CustomTmdbHost.e2e.ts @@ -1,7 +1,7 @@ import { browser } from '@wdio/globals' import { setup, cleanup, isOfficialTmdbHostAccessible, isReverseProxyAccessible } from 'test/lib/testbed' import type { UserConfig } from '@smm/types' -import TVShowPanel from 'test/componentobjects/TVShowPanel.co' +import { TvShowPanelCO as TVShowPanel } from 'test/componentobjects/TVShowPanel.co' import env from 'test/lib/env' import { given, when, then, resetStepContext } from 'test/lib/gherkin' import 'test/steps' diff --git a/apps/e2e/common/manual/CustomTvdbHost.e2e.ts b/apps/e2e/common/manual/CustomTvdbHost.e2e.ts index 494edaea..bafcc7c7 100644 --- a/apps/e2e/common/manual/CustomTvdbHost.e2e.ts +++ b/apps/e2e/common/manual/CustomTvdbHost.e2e.ts @@ -1,7 +1,7 @@ import { browser } from '@wdio/globals' import { setup, cleanup, isOfficialTvdbHostAccessible, isReverseProxyAccessible } from 'test/lib/testbed' import type { UserConfig } from '@smm/types' -import TVShowPanel from 'test/componentobjects/TVShowPanel.co' +import { TvShowPanelCO as TVShowPanel } from 'test/componentobjects/TVShowPanel.co' import env from 'test/lib/env' import { given, when, then, resetStepContext } from 'test/lib/gherkin' import 'test/steps' diff --git a/apps/e2e/common/manual/Transcribe.e2e.ts b/apps/e2e/common/manual/Transcribe.e2e.ts index e9fe3836..d28b53e0 100644 --- a/apps/e2e/common/manual/Transcribe.e2e.ts +++ b/apps/e2e/common/manual/Transcribe.e2e.ts @@ -7,7 +7,7 @@ import { setup, cleanup, importFolderWithMediaMetadata } from 'test/lib/testbed' import { createFolderInTestFolder, folder1, folder5 } from 'test/actions/import-folders' import MoviePanelCO from 'test/componentobjects/MoviePanel.co' import TranscribeDialogCO from 'test/componentobjects/TranscribeDialog.co' -import TvShowPanelCO from 'test/componentobjects/TVShowPanel.co' +import { TvShowPanelCO } from 'test/componentobjects/TVShowPanel.co' import { isDockerE2e, skipIfOhos, testbedOs } from 'test/lib/e2e-platform' import { createTestFolderViaBrowser, diff --git a/apps/e2e/common/mcp/McpOther-RecognizeTaskFlow.e2e.ts b/apps/e2e/common/mcp/McpOther-RecognizeTaskFlow.e2e.ts index e970b558..d4edab86 100644 --- a/apps/e2e/common/mcp/McpOther-RecognizeTaskFlow.e2e.ts +++ b/apps/e2e/common/mcp/McpOther-RecognizeTaskFlow.e2e.ts @@ -3,7 +3,7 @@ import type { MediaMetadata } from '@smm/types' import { Path } from '@smm/utils/path' import mcpClient from 'test/lib/McpClient' import Prompts from 'test/componentobjects/Prompts' -import TVShowPanel from 'test/componentobjects/TVShowPanel.co' +import { TvShowPanelCO as TVShowPanel } from 'test/componentobjects/TVShowPanel.co' import { testbedOs } from 'test/lib/e2e-platform' import { expectMediaMetadataViaBrowser, diff --git a/apps/e2e/common/mcp/McpOther-RenameTaskFlow.e2e.ts b/apps/e2e/common/mcp/McpOther-RenameTaskFlow.e2e.ts index 79405c21..1c28e1fd 100644 --- a/apps/e2e/common/mcp/McpOther-RenameTaskFlow.e2e.ts +++ b/apps/e2e/common/mcp/McpOther-RenameTaskFlow.e2e.ts @@ -3,7 +3,7 @@ import type { MediaMetadata } from '@smm/types' import { Path } from '@smm/utils/path' import mcpClient from 'test/lib/McpClient' import Prompts from 'test/componentobjects/Prompts' -import TVShowPanel from 'test/componentobjects/TVShowPanel.co' +import { TvShowPanelCO as TVShowPanel } from 'test/componentobjects/TVShowPanel.co' import { testbedOs } from 'test/lib/e2e-platform' import { expectMediaMetadataViaBrowser, diff --git a/apps/e2e/common/movie/SearchMovie.e2e.ts b/apps/e2e/common/movie/SearchMovie.e2e.ts index 3a6266a9..5dff2031 100644 --- a/apps/e2e/common/movie/SearchMovie.e2e.ts +++ b/apps/e2e/common/movie/SearchMovie.e2e.ts @@ -8,7 +8,7 @@ import { import { delay } from 'es-toolkit' import { folder2 } from 'test/actions/import-folders' import Sidebar from 'test/componentobjects/Sidebar' -import SearchboxCO from 'test/componentobjects/Searchbox.co' +import { SearchboxCO } from 'test/componentobjects/Searchbox.co' import env from 'test/lib/env' import type { UserConfig } from '@smm/types' diff --git a/apps/e2e/common/other/App.e2e.ts b/apps/e2e/common/other/App.e2e.ts index fbdce364..adcd4981 100644 --- a/apps/e2e/common/other/App.e2e.ts +++ b/apps/e2e/common/other/App.e2e.ts @@ -7,7 +7,7 @@ import { } from 'test/lib/testbed' import { folder1, folder2 } from 'test/actions/import-folders' import Sidebar from 'test/componentobjects/Sidebar' -import TvShowPanelCO from 'test/componentobjects/TVShowPanel.co' +import { TvShowPanelCO } from 'test/componentobjects/TVShowPanel.co' import env from 'test/lib/env' import MoviePanelCO from 'test/componentobjects/MoviePanel.co' import MusicPanelCO from 'test/componentobjects/MusicPanel.co' diff --git a/apps/e2e/common/tv/InitializeTvShowByTmdb.e2e.ts b/apps/e2e/common/tv/InitializeTvShowByTmdb.e2e.ts index 1c07e6c2..eb0f1bb7 100644 --- a/apps/e2e/common/tv/InitializeTvShowByTmdb.e2e.ts +++ b/apps/e2e/common/tv/InitializeTvShowByTmdb.e2e.ts @@ -8,7 +8,7 @@ import { clearFolderViaBrowser, resolveSmmTestFolderViaBrowser, } from 'test/lib/browser-fs' -import TvShowPanel from 'test/componentobjects/TVShowPanel.co' +import { TvShowPanelCO as TvShowPanel } from 'test/componentobjects/TVShowPanel.co' import { delay } from 'es-toolkit' import { given, then, resetStepContext, getStepContext } from 'test/lib/gherkin' import 'test/steps' diff --git a/apps/e2e/package.json b/apps/e2e/package.json index eb65a77d..324a24af 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -4,24 +4,18 @@ "type": "module", "private": true, "devDependencies": { - "@testing-library/webdriverio": "^3.2.1", "@types/bun": "latest", - "@types/chai": "^5.2.3", "@types/mocha": "^10.0.10", "@types/node": "^25.0.6", "@types/shelljs": "^0.10.0", "@wdio/cli": "^9.23.0", "@wdio/globals": "^9.23.0", - "@wdio/local-runner": "^9.23.0", "@wdio/mocha-framework": "^9.23.0", "@wdio/spec-reporter": "^9.20.0", - "cross-env": "^7.0.3", "expect-webdriverio": "^5.6.1", "shelljs": "^0.10.0", "typescript": "^5.0.0", - "wdio-electron-service": "9.2.1", - "wdio-html-nice-reporter": "^8.1.7", - "wdio-wait-for": "^3.1.1" + "wdio-html-nice-reporter": "^8.1.7" }, "scripts": { "typecheck": "tsc --noEmit", @@ -46,11 +40,9 @@ "wdio:mcp": "wdio run ./wdio.conf.ts --spec \"./common/mcp/*.e2e.ts\"" }, "dependencies": { - "@smm/core": "workspace:*", + "@smm/test": "workspace:*", "@smm/types": "workspace:*", "@smm/utils": "workspace:*", - "@smm/test": "workspace:*", - "chai": "^6.2.2", "dotenv": "^17.3.1", "es-toolkit": "^1.44.0", "proxy-chain": "^3.0.0" diff --git a/apps/e2e/test/actions/import-folders.ts b/apps/e2e/test/actions/import-folders.ts index a7ae0c39..5a169eeb 100644 --- a/apps/e2e/test/actions/import-folders.ts +++ b/apps/e2e/test/actions/import-folders.ts @@ -10,15 +10,15 @@ import { export { type LangCode, type TestFolder, - folder1, + tvShowFolder, + tvShowFolder as folder1, folder2, folder3, folder4, - folder5, + movieFolder, + movieFolder as folder5, folder6, musicFolder, - tvShowFolder, - movieFolder, } from '@smm/test' const tmpMediaRoot = path.join(os.tmpdir(), 'smm-test-media') diff --git a/apps/e2e/test/componentobjects/MoviePanel.co.ts b/apps/e2e/test/componentobjects/MoviePanel.co.ts index 4fbe6353..2e2ec458 100644 --- a/apps/e2e/test/componentobjects/MoviePanel.co.ts +++ b/apps/e2e/test/componentobjects/MoviePanel.co.ts @@ -1,7 +1,7 @@ /// import { browser } from '@wdio/globals' -import SearchboxCO from './Searchbox.co' +import { SearchboxCO } from './Searchbox.co' class MoviePanelComponentObject { diff --git a/apps/e2e/test/componentobjects/Searchbox.co.ts b/apps/e2e/test/componentobjects/Searchbox.co.ts index 966a7e70..07723f64 100644 --- a/apps/e2e/test/componentobjects/Searchbox.co.ts +++ b/apps/e2e/test/componentobjects/Searchbox.co.ts @@ -219,4 +219,3 @@ class SearchboxComponentObject { } export const SearchboxCO = new SearchboxComponentObject() -export default SearchboxCO diff --git a/apps/e2e/test/componentobjects/TVShowPanel.co.ts b/apps/e2e/test/componentobjects/TVShowPanel.co.ts index a5a3cd63..3cff5a87 100644 --- a/apps/e2e/test/componentobjects/TVShowPanel.co.ts +++ b/apps/e2e/test/componentobjects/TVShowPanel.co.ts @@ -2,7 +2,7 @@ import { browser } from '@wdio/globals' import { clickContextMenuItem, rightClickElement } from '../lib/context-menu' -import SearchboxCO from './Searchbox.co' +import { SearchboxCO } from './Searchbox.co' /** Confirm button labels (en and zh-CN). */ const CONFIRM_LABELS = ['Confirm', '确认'] @@ -560,8 +560,4 @@ class TVShowPanel { } -/** - * Keep both default export and named export for backwards compatibility. - */ export const TvShowPanelCO = new TVShowPanel() -export default TvShowPanelCO diff --git a/apps/e2e/test/lib/ui-page-url.test.ts b/apps/e2e/test/lib/ui-page-url.test.ts index 632acb9e..f976177a 100644 --- a/apps/e2e/test/lib/ui-page-url.test.ts +++ b/apps/e2e/test/lib/ui-page-url.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from 'bun:test'; import { DEFAULT_DOCKER_UI_ORIGIN, - DOCKER_UI_ORIGIN, HARMONYOS_UI_ORIGIN, resolveDockerUiOrigin, resolveUiPageUrl, @@ -91,7 +90,7 @@ describe('resolveUiPageUrl', () => { withEnv( { SMM_AUTH_TOKEN: undefined, E2E_PLATFORM: 'docker', E2E_DOCKER_UI_ORIGIN: undefined }, () => { - expect(resolveUiPageUrl()).toBe(DOCKER_UI_ORIGIN); + expect(resolveUiPageUrl()).toBe(DEFAULT_DOCKER_UI_ORIGIN); expect(resolveUiPageUrl(undefined, 'general')).toBe(DEFAULT_DOCKER_UI_ORIGIN); }, ); @@ -115,7 +114,7 @@ describe('resolveUiPageUrl', () => { withEnv( { SMM_AUTH_TOKEN: 'ChangeMe123', E2E_PLATFORM: 'docker', E2E_DOCKER_UI_ORIGIN: undefined }, () => { - expect(resolveUiPageUrl()).toBe(`${DOCKER_UI_ORIGIN}?token=ChangeMe123`); + expect(resolveUiPageUrl()).toBe(`${DEFAULT_DOCKER_UI_ORIGIN}?token=ChangeMe123`); }, ); }); diff --git a/apps/e2e/test/lib/ui-page-url.ts b/apps/e2e/test/lib/ui-page-url.ts index 1140bcca..cdce7834 100644 --- a/apps/e2e/test/lib/ui-page-url.ts +++ b/apps/e2e/test/lib/ui-page-url.ts @@ -17,8 +17,6 @@ export const HARMONYOS_UI_ORIGIN = 'http://127.0.0.1:18081/' /** Default docker UI origin (host-mapped port 30000). Override with `E2E_DOCKER_UI_ORIGIN`. */ export const DEFAULT_DOCKER_UI_ORIGIN = 'http://localhost:30000/' -/** @deprecated Prefer resolveDockerUiOrigin() — kept for existing imports/tests. */ -export const DOCKER_UI_ORIGIN = DEFAULT_DOCKER_UI_ORIGIN /** * Docker UI origin for Host Runner WDIO / wait-ready. diff --git a/apps/e2e/test/specs/ai/AiTool-RecognizeTool.e2e.ts b/apps/e2e/test/specs/ai/AiTool-RecognizeTool.e2e.ts index 4787f9c2..635cc36a 100644 --- a/apps/e2e/test/specs/ai/AiTool-RecognizeTool.e2e.ts +++ b/apps/e2e/test/specs/ai/AiTool-RecognizeTool.e2e.ts @@ -4,7 +4,7 @@ import * as path from 'node:path' import * as os from 'node:os' import Menu from '../../componentobjects/Menu' import { createBeforeHook, expectMediaMetadataToBe } from '../../lib/testbed' -import TVShowPanel from 'test/componentobjects/TVShowPanel.co' +import { TvShowPanelCO as TVShowPanel } from 'test/componentobjects/TVShowPanel.co' import env from 'test/lib/env' import { type MediaMetadata } from '@smm/types' import { createFolderInTestFolder, folder1 } from 'test/actions/import-folders' diff --git a/apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts b/apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts index 9686e3d5..f978e702 100644 --- a/apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts +++ b/apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts @@ -4,7 +4,7 @@ import * as path from 'node:path' import * as os from 'node:os' import Menu from '../../componentobjects/Menu' import { createBeforeHook, expectMediaMetadataToBe } from '../../lib/testbed' -import TVShowPanel from 'test/componentobjects/TVShowPanel.co' +import { TvShowPanelCO as TVShowPanel } from 'test/componentobjects/TVShowPanel.co' import env from 'test/lib/env' import { type MediaMetadata } from '@smm/types' import { createFolderInTestFolder, folder1 } from 'test/actions/import-folders' diff --git a/apps/e2e/test/steps/searchbox-input-is-focused.ts b/apps/e2e/test/steps/searchbox-input-is-focused.ts index 26ffa87e..e27488d8 100644 --- a/apps/e2e/test/steps/searchbox-input-is-focused.ts +++ b/apps/e2e/test/steps/searchbox-input-is-focused.ts @@ -1,5 +1,5 @@ import { registerStep } from '../lib/gherkin' -import TVShowPanel from '../componentobjects/TVShowPanel.co' +import { TvShowPanelCO as TVShowPanel } from '../componentobjects/TVShowPanel.co' registerStep('searchbox input is focused', async () => { await TVShowPanel.searchbox.input.waitForDisplayed() diff --git a/apps/e2e/tsconfig.json b/apps/e2e/tsconfig.json index c4c896d0..0968bcf1 100644 --- a/apps/e2e/tsconfig.json +++ b/apps/e2e/tsconfig.json @@ -42,6 +42,7 @@ ], "@smm/core/FsPort": ["../core/src/ports/FsPort.ts"], "@smm/core/*": ["../core/src/*"], + "test/*": ["test/*"], "@smm/test": ["../../packages/test/src/index.ts"], "@smm/test/*": ["../../packages/test/*"] } @@ -51,6 +52,9 @@ "common/**/*.ts", "cli/**/*.ts", "scenarios/**/*.ts", + "ohos/**/*.ts", + "electron/**/*.ts", + "docker/**/*.{ts,mjs}", "wdio.conf.ts", "wdio.conf.test.ts" ], diff --git a/apps/electron/package.json b/apps/electron/package.json index 8ce73507..3fc8e6e9 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -24,8 +24,7 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", - "@smm/electron-common": "workspace:*", - "electron-updater": "^6.3.9" + "@smm/electron-common": "workspace:*" }, "devDependencies": { "@electron-toolkit/eslint-config-prettier": "^3.0.0", diff --git a/apps/electron/src/main/startup/cliMonitor.ts b/apps/electron/src/main/startup/cliMonitor.ts index c41a1d03..81a45825 100644 --- a/apps/electron/src/main/startup/cliMonitor.ts +++ b/apps/electron/src/main/startup/cliMonitor.ts @@ -160,7 +160,7 @@ export function buildMissingBinaryFailure(executablePath: string): CliStartupFai } } -export function buildSpawnFailure( +function buildSpawnFailure( executablePath: string, error: NodeJS.ErrnoException, ): CliStartupFailure { diff --git a/apps/electron/src/main/startup/startupError.ts b/apps/electron/src/main/startup/startupError.ts index f14b8d06..27f3b1e7 100644 --- a/apps/electron/src/main/startup/startupError.ts +++ b/apps/electron/src/main/startup/startupError.ts @@ -14,7 +14,7 @@ export function escapeHtml(value: string): string { .replace(/'/g, "'") } -export function readFileTail(filePath: string, maxBytes: number): string | null { +function readFileTail(filePath: string, maxBytes: number): string | null { if (!existsSync(filePath)) { return null } @@ -105,7 +105,7 @@ export function toCliStartupFailure(error: unknown): CliStartupFailure { } } -export function buildCopyText(diagnostics: StartupDiagnostics): string { +function buildCopyText(diagnostics: StartupDiagnostics): string { const { failure, cliExecutable, cliPort, processOutput, smmLogPath, smmLogTail } = diagnostics return [ failure.title, diff --git a/apps/electron/src/main/startup/types.ts b/apps/electron/src/main/startup/types.ts index e650bb7f..806e3385 100644 --- a/apps/electron/src/main/startup/types.ts +++ b/apps/electron/src/main/startup/types.ts @@ -1,4 +1,4 @@ -export type CliStartupFailureKind = +type CliStartupFailureKind = | "missing-binary" | "spawn-failed" | "exited" diff --git a/apps/electron/src/main/startup/waitForCliServerReady.ts b/apps/electron/src/main/startup/waitForCliServerReady.ts index 0a2cd40e..a5735780 100644 --- a/apps/electron/src/main/startup/waitForCliServerReady.ts +++ b/apps/electron/src/main/startup/waitForCliServerReady.ts @@ -2,7 +2,7 @@ import type { CliProcessMonitor } from "./cliMonitor" import { buildTimeoutFailure } from "./cliMonitor" import { CliStartupError } from "./types" -export async function isServerServingHtml(port: number): Promise { +async function isServerServingHtml(port: number): Promise { try { const res = await fetch(`http://127.0.0.1:${port}`, { method: "GET" }) const contentType = res.headers.get("content-type") ?? "" diff --git a/apps/electron/src/main/types.ts b/apps/electron/src/main/types.ts deleted file mode 100644 index b2481954..00000000 --- a/apps/electron/src/main/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -interface ExecuteChannelRequest { - name: string - data: any - } - - interface ExecuteChannelResponse { - name: string - data: any - } \ No newline at end of file diff --git a/apps/ui/fix_test.js b/apps/ui/fix_test.js deleted file mode 100644 index fbf9f77c..00000000 --- a/apps/ui/fix_test.js +++ /dev/null @@ -1,13 +0,0 @@ -const fs = require('fs'); -const content = fs.readFileSync('src/components/TvShowPanelUtils.test.ts', 'utf8'); - -// Fix the createMockMediaMetadata function -let fixed = content.replace( - 'const createMockMediaMetadata = (overrides?: Partial): UIMediaMetadata => ({', - 'const createMockMediaMetadata = (overrides?: Partial): UIMediaMetadata => ({\n status: \'ok\',' -); - -// Fix all MediaMetadata object literals -fixed = fixed.replace(/const mm: UIMediaMetadata = \{/g, 'const mm: UIMediaMetadata = {\n status: \'ok\','); - -fs.writeFileSync('src/components/TvShowPanelUtils.test.ts', fixed); diff --git a/apps/ui/package.json b/apps/ui/package.json index 0a81ef57..e9524660 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -17,9 +17,7 @@ "build-storybook": "storybook build -o storybook-static" }, "dependencies": { - "@ai-sdk/openai": "^3.0.11", "@ai-sdk/openai-compatible": "^2.0.11", - "@ai-sdk/react": "^3.0.38", "@assistant-ui/react": "^0.12.10", "@assistant-ui/react-ai-sdk": "^1.1.20", "@assistant-ui/react-markdown": "^0.11.9", @@ -33,7 +31,6 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", - "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-menubar": "^1.1.16", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", @@ -46,13 +43,12 @@ "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-use-controllable-state": "^1.2.2", "@shikijs/transformers": "^3.19.0", - "@smm/types": "workspace:*", - "@smm/utils": "workspace:*", "@smm/core": "workspace:*", "@smm/tvdb4": "workspace:*", + "@smm/types": "workspace:*", + "@smm/utils": "workspace:*", "@tailwindcss/vite": "^4.1.17", "@tanstack/react-query": "^5.96.2", - "@tanstack/react-table": "^8.21.3", "@types/debug": "^4.1.12", "ai": "^6.0.57", "class-variance-authority": "^0.7.1", @@ -61,15 +57,12 @@ "debug": "^4.4.3", "embla-carousel-react": "^8.6.0", "es-toolkit": "^1.42.0", - "filenamify": "^7.0.1", "harden-react-markdown": "^1.1.7", "i18next": "^25.7.3", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", "katex": "^0.16.27", "lucide-react": "^0.562.0", - "p-limit": "^7.2.0", - "path-browserify": "^1.0.1", "path-browserify-esm": "^1.0.6", "pino": "^10.3.1", "radix-ui": "^1.4.3", @@ -84,12 +77,10 @@ "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "shiki": "^3.20.0", - "slash": "^5.1.0", "socket.io-client": "^4.8.3", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.17", - "url-join": "^5.0.0", "use-stick-to-bottom": "^1.1.1", "zod": "^4.1.8", "zustand": "^5.0.9" @@ -106,7 +97,6 @@ "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^5.1.1", - "@vitest/coverage-istanbul": "^4.0.18", "@vitest/coverage-v8": "^4.0.18", "@vitest/ui": "^4.0.18", "babel-plugin-react-compiler": "^1.0.0", diff --git a/apps/ui/src/actions/handleAiRecognizeConfirm.test.ts b/apps/ui/src/actions/handleAiRecognizeConfirm.test.ts index 97ab20a5..4dd39ec7 100644 --- a/apps/ui/src/actions/handleAiRecognizeConfirm.test.ts +++ b/apps/ui/src/actions/handleAiRecognizeConfirm.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { handleAiRecognizeConfirm, type SetPlanByIdFn } from './handleAiRecognizeConfirm' import type { PersistUIMediaMetadataFn } from '@/types/persistUIMediaMetadata' import type { RecognizeMediaFilePlan } from '@smm/types/RecognizeMediaFilePlan' -import type { UIMediaMetadata } from '@/types/UIMediaMetadata' +import type { MediaMetadata } from '@smm/types' import { applyRecognizeMediaFilePlan } from '@/components/tv/TvShowPanelUtils' vi.mock('@/components/tv/TvShowPanelUtils', () => ({ @@ -30,13 +30,13 @@ describe('handleAiRecognizeConfirm', () => { ], } - const mediaMetadata: UIMediaMetadata = { + const mediaMetadata: MediaMetadata = { mediaFolderPath, type: 'tvshow-folder', status: 'ok', files: ['/media/show/ep1.mkv'], mediaFiles: [], - } as UIMediaMetadata + } as MediaMetadata let persist: ReturnType let setPlanById: ReturnType diff --git a/apps/ui/src/ai/aiContextStore.ts b/apps/ui/src/ai/aiContextStore.ts index 75b24f79..1992e4fe 100644 --- a/apps/ui/src/ai/aiContextStore.ts +++ b/apps/ui/src/ai/aiContextStore.ts @@ -32,7 +32,7 @@ import { create } from "zustand" import type { LanguageCode } from "@smm/types" -export interface AiContextSnapshot { +interface AiContextSnapshot { /** * The currently selected media folder path (POSIX form when * available, otherwise platform-native). Empty string when @@ -77,18 +77,3 @@ export const useAiContextStore = create((set) => ({ setOsLocale: (locale) => set({ osLocale: locale ?? "" }), setSnapshot: (snapshot) => set((prev) => ({ ...prev, ...snapshot })), })) - -/** - * Synchronous snapshot accessor for use inside tool `execute` - * functions (which run outside React). Always reflects the most - * recent commit; never returns a partial update because callers - * should use `setSnapshot` for multi-field writes. - */ -export function readAiContext(): AiContextSnapshot { - const s = useAiContextStore.getState() - return { - selectedMediaFolder: s.selectedMediaFolder, - applicationLanguage: s.applicationLanguage, - osLocale: s.osLocale, - } -} diff --git a/apps/ui/src/ai/prompts.ts b/apps/ui/src/ai/prompts.ts index d5494b66..8d3e3a8c 100644 --- a/apps/ui/src/ai/prompts.ts +++ b/apps/ui/src/ai/prompts.ts @@ -9,7 +9,6 @@ */ import { SYSTEM_PROMPT } from '@smm/core/ai-tool/systemPrompt' -export { SYSTEM_PROMPT } /** * @deprecated Kept for backward compatibility — legacy import sites diff --git a/apps/ui/src/ai/tools/GetApplicationContext.tsx b/apps/ui/src/ai/tools/GetApplicationContext.tsx index 063a9c41..18999b21 100644 --- a/apps/ui/src/ai/tools/GetApplicationContext.tsx +++ b/apps/ui/src/ai/tools/GetApplicationContext.tsx @@ -13,7 +13,7 @@ import { type GetApplicationContextOutput, } from "@smm/types/ai-tools/getApplicationContext" -export type ApplicationContextData = GetApplicationContextOutput +type ApplicationContextData = GetApplicationContextOutput const getApplicationContextTool = tool({ description: GET_APPLICATION_CONTEXT_DESCRIPTION, diff --git a/apps/ui/src/ai/tools/ListFilesInMediaFolder.tsx b/apps/ui/src/ai/tools/ListFilesInMediaFolder.tsx index ba7a1911..3d8493a7 100644 --- a/apps/ui/src/ai/tools/ListFilesInMediaFolder.tsx +++ b/apps/ui/src/ai/tools/ListFilesInMediaFolder.tsx @@ -34,5 +34,3 @@ export const GetFilesInMediaFolderTool = makeAssistantTool({ toolName: LIST_FILES_IN_MEDIA_FOLDER, }) -/** @deprecated Use GetFilesInMediaFolderTool (tool name is list-files-in-media-folder) */ -export const ListFilesInMediaFolderTool = GetFilesInMediaFolderTool diff --git a/apps/ui/src/ai/tools/index.ts b/apps/ui/src/ai/tools/index.ts index 86ad80fc..e8653b16 100644 --- a/apps/ui/src/ai/tools/index.ts +++ b/apps/ui/src/ai/tools/index.ts @@ -1,7 +1,7 @@ export { GetMediaFoldersTool } from './GetMediaFolders'; export { GetFilesInMediaFolderTool } from './ListFilesInMediaFolder'; export { GetMediaMetadataTool } from './GetMediaMetadata'; -export { GetApplicationContextTool, type ApplicationContextData } from './GetApplicationContext'; +export { GetApplicationContextTool} from './GetApplicationContext'; export { IsFolderExistTool } from './IsFolderExist'; export { GetEpisodesTool } from './GetEpisodes'; export { RenameFolderTool } from './RenameFolder'; diff --git a/apps/ui/src/api/chat.ts b/apps/ui/src/api/chat.ts deleted file mode 100644 index 8c3b007a..00000000 --- a/apps/ui/src/api/chat.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { streamText, convertToModelMessages, type UIMessage } from 'ai'; -import { getDeepseekProvider, DEEPSEEK_MODEL } from '../lib/ai-provider'; - -interface ChatRequest { - messages?: UIMessage[]; - model?: string; -} - -export async function handleChatRequest(request: Request): Promise { - try { - const body = await request.json() as ChatRequest; - const { messages, model } = body; - console.log('Received chat request:', { messageCount: messages?.length, model }); - - // Convert UI messages to model messages format - const modelMessages = await convertToModelMessages(messages || []); - console.log('Converted to model messages:', modelMessages.length); - - const deepseekProvider = getDeepseekProvider(); - const result = streamText({ - model: deepseekProvider(model || DEEPSEEK_MODEL), - messages: modelMessages, - }); - - // Use toUIMessageStreamResponse for useChat compatibility - const response = result.toUIMessageStreamResponse(); - console.log('Streaming response created'); - return response; - } catch (error) { - console.error('Chat API error:', error); - return new Response( - JSON.stringify({ error: 'Failed to process chat request', details: error instanceof Error ? error.message : String(error) }), - { - status: 500, - headers: { 'Content-Type': 'application/json' }, - } - ); - } -} - diff --git a/apps/ui/src/api/cleanUp.ts b/apps/ui/src/api/cleanUp.ts deleted file mode 100644 index 6807122b..00000000 --- a/apps/ui/src/api/cleanUp.ts +++ /dev/null @@ -1,23 +0,0 @@ -interface CleanUpResponse { - success: boolean - data?: { - configDeleted?: boolean - metadataDeleted?: boolean - } - error?: string -} - -export async function cleanUp(): Promise { - const resp = await fetch('/debug', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: 'cleanUp', - }), - }) - - const body = await resp.json() as CleanUpResponse - return body -} diff --git a/apps/ui/src/api/commandExecutionStatus.ts b/apps/ui/src/api/commandExecutionStatus.ts index cd0deed9..4a67a6b1 100644 --- a/apps/ui/src/api/commandExecutionStatus.ts +++ b/apps/ui/src/api/commandExecutionStatus.ts @@ -1,9 +1,9 @@ import { withDevApiUrl } from '@/api/executeCmd' import { apiFetch } from '@/lib/apiFetch' -export type CommandExecutionPhase = 'unknown' | 'running' | 'finished' +type CommandExecutionPhase = 'unknown' | 'running' | 'finished' -export type CommandExecutionOutcome = 'success' | 'failure' +type CommandExecutionOutcome = 'success' | 'failure' export interface CommandExecutionStatusResponse { executionId: string diff --git a/apps/ui/src/api/createPlan.ts b/apps/ui/src/api/createPlan.ts deleted file mode 100644 index bb8ac9c0..00000000 --- a/apps/ui/src/api/createPlan.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { PlanCreator } from '@smm/types/planCommon'; -import type { Plan } from './getPlans'; -import { apiFetch } from '@/lib/apiFetch'; - -export interface CreatePlanRequest { - /** Optional client-supplied UUID so the caller can reference the plan immediately. */ - id?: string; - task: 'recognize-media-file' | 'rename-files'; - mediaFolderPath: string; - creator: PlanCreator; -} - -export interface CreatePlanResponseBody { - data?: { plan: Plan }; - error?: string; -} - -/** - * Create a new plan in `preparing` status. - */ -export async function createPlan( - request: CreatePlanRequest, - signal?: AbortSignal, -): Promise { - console.log(`[createPlan] request sent`, { - planId: request.id, - task: request.task, - creator: request.creator, - mediaFolderPath: request.mediaFolderPath, - }); - const resp = await apiFetch('/api/createPlan', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(request), - signal, - }); - console.log(`[createPlan] response received`, { - planId: request.id, - status: resp.status, - ok: resp.ok, - }); - - if (!resp.ok) { - console.error(`[createPlan] unexpected HTTP status`, { - url: resp.url, - status: resp.status, - statusText: resp.statusText, - }); - throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`); - } - - const data: CreatePlanResponseBody = await resp.json(); - if (data.error) { - console.error(`[createPlan] unexpected response body`, { - url: resp.url, - response: data, - }); - } - - return data; -} diff --git a/apps/ui/src/api/discover.ts b/apps/ui/src/api/discover.ts index f8aad241..ae1ff8f3 100644 --- a/apps/ui/src/api/discover.ts +++ b/apps/ui/src/api/discover.ts @@ -1,10 +1,10 @@ import { z } from 'zod' import { apiFetch } from '@/lib/apiFetch'; -export type MediaDatabaseType = 'tmdb' | 'tvdb' | 'tmdb-asset' | 'tvdb-asset' -export type MediaDatabaseAuthorizationMethod = 'date-token' | 'none' +type MediaDatabaseType = 'tmdb' | 'tvdb' | 'tmdb-asset' | 'tvdb-asset' +type MediaDatabaseAuthorizationMethod = 'date-token' | 'none' -export type ReverseProxyType = 'general' +type ReverseProxyType = 'general' /** * Normalized media database entry returned by the CLI's `/api/discover` diff --git a/apps/ui/src/api/executeCmd.ts b/apps/ui/src/api/executeCmd.ts index bcc93e35..1b7f166d 100644 --- a/apps/ui/src/api/executeCmd.ts +++ b/apps/ui/src/api/executeCmd.ts @@ -12,12 +12,12 @@ export interface ExecuteCmdRequest { tty?: boolean; } -export interface ExecuteCmdStdoutStderrMessage { +interface ExecuteCmdStdoutStderrMessage { type: 'stdout' | 'stderr'; data: string; } -export interface ExecuteCmdSystemMessage { +interface ExecuteCmdSystemMessage { type: 'system'; data: { event: 'exit' | 'error' | 'timeout'; diff --git a/apps/ui/src/api/ffmpeg.ts b/apps/ui/src/api/ffmpeg.ts index 6d0bbd21..a652741f 100644 --- a/apps/ui/src/api/ffmpeg.ts +++ b/apps/ui/src/api/ffmpeg.ts @@ -1,14 +1,5 @@ import { Path } from "@smm/utils/path"; -import { - buildFfmpegConvertArgs, - buildFfmpegWriteTagsArgs, - buildFfprobeReadTagsArgs, - parseFfprobeTagsJson, - probeWhitelistedCommand, - type FfmpegConvertFormat, - type FfmpegConvertPreset, -} from "@/lib/whitelistedCmd"; -import { apiFetch } from '@/lib/apiFetch'; +import { buildFfmpegConvertArgs, buildFfprobeReadTagsArgs, parseFfprobeTagsJson, probeWhitelistedCommand, type FfmpegConvertFormat, type FfmpegConvertPreset } from "@/lib/whitelistedCmd"; import { executeCmdToCompletion } from "@/lib/whitelistedCmd/executeCmdToCompletion"; import { classifyFfmpegConvertError, @@ -168,7 +159,6 @@ export async function generateFfmpegScreenshots( return { screenshots: outputPaths }; } -export type { FfmpegConvertFormat, FfmpegConvertPreset }; export interface FfmpegConvertRequest { inputPath: string; @@ -254,85 +244,6 @@ export async function getMediaTags(params: FfmpegTagsRequest): Promise; -} - -export interface FfmpegWriteTagsResponse { - success?: boolean; - error?: string; - executionId?: string; -} - -export async function writeMediaTags( - params: FfmpegWriteTagsRequest -): Promise { - const pathObj = new Path(params.path); - const absolutePath = pathObj.platformAbsPath(); - - // Preserve the original file extension so ffmpeg can auto-detect the output - // format (e.g. test.mp4 → test.smm-temp.mp4 instead of test.mp4.smm-temp). - const extIdx = absolutePath.lastIndexOf('.'); - const ext = extIdx >= 0 ? absolutePath.slice(extIdx) : ''; - const base = ext ? absolutePath.slice(0, extIdx) : absolutePath; - const tempFilePath = `${base}.smm-temp${ext}`; - - const args = buildFfmpegWriteTagsArgs(absolutePath, tempFilePath, params.tags); - const result = await executeCmdToCompletion( - { command: "ffmpeg", args }, - { timeoutMs: FFMPEG_CONVERT_TIMEOUT_MS } - ); - const ffmpegExecutionId = result.executionId; - - if (!result.success) { - return { error: result.error, executionId: ffmpegExecutionId }; - } - - try { - await (await import("@/api/moveFileToTrash")).moveFileToTrash( - new Path(absolutePath).platformAbsPath(), - ); - } catch (error) { - return { - error: error instanceof Error ? error.message : "Failed to move original file to trash", - executionId: ffmpegExecutionId, - }; - } - - const renameResp = await apiFetch("/api/renameFiles", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - files: [{ from: tempFilePath, to: absolutePath }], - }), - }); - const renameBody = (await renameResp.json()) as { error?: string }; - if (!renameResp.ok || renameBody.error) { - return { error: renameBody.error ?? `rename failed: HTTP ${renameResp.status}`, executionId: ffmpegExecutionId }; - } - - return { success: true, executionId: ffmpegExecutionId }; -} - -export async function discoverFfmpeg(): Promise<{ path?: string; error?: string }> { - try { - const { fetchDiscoverExecutables } = await import("@/api/discoverExecutables"); - const { ffmpeg } = await fetchDiscoverExecutables(); - const path = ffmpeg.configuredPath ?? ffmpeg.discoveredPath; - if (path) { - return { path }; - } - } catch { - /* fall through to probe */ - } - const probe = await probeWhitelistedCommand("ffmpeg"); - if (probe.available) { - return { path: probe.resolvedPath ?? "ffmpeg" }; - } - return { error: probe.error ?? "ffmpeg not found" }; -} - export async function getFfmpegVersion(): Promise<{ version?: string; error?: string }> { const probe = await probeWhitelistedCommand("ffmpeg"); if (!probe.available) { diff --git a/apps/ui/src/api/getJob.ts b/apps/ui/src/api/getJob.ts index 9e1b09d4..f108dd0a 100644 --- a/apps/ui/src/api/getJob.ts +++ b/apps/ui/src/api/getJob.ts @@ -15,7 +15,7 @@ export type ScrapeTaskRuntimeStatus = | 'completed' | 'failed' -export interface ScrapeJobTask { +interface ScrapeJobTask { status: ScrapeTaskRuntimeStatus error?: string } @@ -31,7 +31,7 @@ export interface ScrapeJob { updatedAt: number } -export interface ImportJob { +interface ImportJob { kind: 'import' id: string folderPath: string diff --git a/apps/ui/src/api/getPlanById.ts b/apps/ui/src/api/getPlanById.ts deleted file mode 100644 index 526eafaa..00000000 --- a/apps/ui/src/api/getPlanById.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Plan } from './getPlans'; -import { apiFetch } from '@/lib/apiFetch'; - -export interface GetPlanByIdResponseBody { - data?: { plan: Plan }; - error?: string; -} - -/** - * Load a plan file from disk by id. Used when the in-memory AI - * draft was lost (page refresh) but the plan file still exists. - */ -export async function getPlanById( - id: string, - signal?: AbortSignal, -): Promise { - const resp = await apiFetch('/api/getPlanById', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ id: id.trim() }), - signal, - }); - - if (!resp.ok) { - console.error(`[getPlanById] unexpected HTTP status`, { - url: resp.url, - status: resp.status, - statusText: resp.statusText, - }); - throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`); - } - - return (await resp.json()) as GetPlanByIdResponseBody; -} diff --git a/apps/ui/src/api/importFolder.ts b/apps/ui/src/api/importFolder.ts index 2b8b1ccf..197a8f66 100644 --- a/apps/ui/src/api/importFolder.ts +++ b/apps/ui/src/api/importFolder.ts @@ -9,13 +9,13 @@ export interface ImportFolderParams { traceId?: string } -export interface ImportFolderResponseBody { +interface ImportFolderResponseBody { data?: { id: string } error?: string } /** Layer-2 import folder via Core (`POST /api/import-folder`). */ -export async function importFolder( +async function importFolder( params: ImportFolderParams, signal?: AbortSignal, ): Promise { diff --git a/apps/ui/src/api/importLibrary.ts b/apps/ui/src/api/importLibrary.ts index 880f0518..d8e4f1fa 100644 --- a/apps/ui/src/api/importLibrary.ts +++ b/apps/ui/src/api/importLibrary.ts @@ -9,13 +9,13 @@ export interface ImportLibraryParams { traceId?: string } -export interface ImportLibraryResponseBody { +interface ImportLibraryResponseBody { data?: { id: string } error?: string } /** Layer-2 import library via Core (`POST /api/import-library`). */ -export async function importLibrary( +async function importLibrary( params: ImportLibraryParams, signal?: AbortSignal, ): Promise { diff --git a/apps/ui/src/api/listFiles.ts b/apps/ui/src/api/listFiles.ts index d6b9e61c..0047b04c 100644 --- a/apps/ui/src/api/listFiles.ts +++ b/apps/ui/src/api/listFiles.ts @@ -46,68 +46,3 @@ export async function listFiles(req: ListFilesRequestBody, signal?: AbortSignal) return data; } - -/** - * List files and folders in a directory - * @deprecated - * @param path platform-specific path (supports "~" for home directory) - * @param options optional filters - */ -export async function listFilesApi( - path: string, - options?: { - onlyFiles?: boolean; - onlyFolders?: boolean; - includeHiddenFiles?: boolean; - } -): Promise { - const req: ListFilesRequestBody = { - path: path, - onlyFiles: options?.onlyFiles, - onlyFolders: options?.onlyFolders, - includeHiddenFiles: options?.includeHiddenFiles, - }; - - const resp = await apiFetch('/api/listFiles', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(req), - }); - - if (!resp.ok) { - console.error(`[listFilesApi] unexpected HTTP status`, { - url: resp.url, - status: resp.status, - statusText: resp.statusText, - request: req, - response: resp.text(), - }); - throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`); - } - - const data: ListFilesResponseBody = await resp.json(); - if (data.error) { - console.error(`[listFilesApi] unexpected response body`, { - url: resp.url, - status: resp.status, - statusText: resp.statusText, - request: req, - response: data, - }); - } - - if (!data.data) { - console.error(`[listFilesApi] unexpected response body: no data`, { - url: resp.url, - status: resp.status, - statusText: resp.statusText, - request: req, - response: data, - }); - } - - return data; -} - diff --git a/apps/ui/src/api/log.ts b/apps/ui/src/api/log.ts index d4e516f6..391be8f5 100644 --- a/apps/ui/src/api/log.ts +++ b/apps/ui/src/api/log.ts @@ -1,5 +1,5 @@ import { apiFetch } from '@/lib/apiFetch'; -export type FrontendLogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal" +type FrontendLogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal" export interface FrontendLogPayload { level?: FrontendLogLevel diff --git a/apps/ui/src/api/readFile.ts b/apps/ui/src/api/readFile.ts index 41a1ccc6..cb37c6a8 100644 --- a/apps/ui/src/api/readFile.ts +++ b/apps/ui/src/api/readFile.ts @@ -1,4 +1,4 @@ -import type { ReadFileRequestBody, ReadFileResponseBody, UserConfig } from '@smm/types'; +import type { ReadFileRequestBody, ReadFileResponseBody } from '@smm/types'; import { apiFetch } from '@/lib/apiFetch'; @@ -53,41 +53,3 @@ export async function readFile(path: string, signal?: AbortSignal, options?: {re return data; } -/** - * TODO: deprecate this method, I can't understand why it return UserConfig - * @deprecated - * @param path - * @returns - */ -async function readFileApi(path: string): Promise { - - const req: ReadFileRequestBody = { - path: path, - } - - const resp = await apiFetch('/api/readFile', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(req), - }) - - if (!resp.ok) { - throw new Error(`Failed to read file: ${resp.statusText}`); - } - - const data: ReadFileResponseBody = await resp.json(); - if (data.error) { - throw new Error(`Failed to read file: ${data.error}`); - } - - if(!data.data) { - throw new Error('Failed to read file: no data'); - } - - return JSON.parse(data.data) as UserConfig; - -} - -export { readFileApi }; \ No newline at end of file diff --git a/apps/ui/src/api/recognizeFolder.ts b/apps/ui/src/api/recognizeFolder.ts index f8a4ba77..87ad67f7 100644 --- a/apps/ui/src/api/recognizeFolder.ts +++ b/apps/ui/src/api/recognizeFolder.ts @@ -1,6 +1,6 @@ import { apiFetch } from '@/lib/apiFetch' -export type RecognizeFolderDb = 'tmdb' | 'tvdb' +type RecognizeFolderDb = 'tmdb' | 'tvdb' export interface RecognizeFolderParams { path: string @@ -8,13 +8,13 @@ export interface RecognizeFolderParams { id: string } -export interface RecognizeFolderResponseBody { +interface RecognizeFolderResponseBody { data?: { path: string } error?: string } /** `POST /api/recognize-folder` → `Core.recognizeFolder`. */ -export async function recognizeFolder( +async function recognizeFolder( params: RecognizeFolderParams, signal?: AbortSignal, ): Promise { diff --git a/apps/ui/src/api/renameFile.ts b/apps/ui/src/api/renameFile.ts deleted file mode 100644 index 7b5246f6..00000000 --- a/apps/ui/src/api/renameFile.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { apiFetch } from '@/lib/apiFetch'; -/** - * Local types for legacy renameFile client. - * The /api/renameFile and /api/renameFileInBatch endpoints have been removed. - * Migrate to POST /api/renameFiles and use ui/src/api/renameFiles.ts when available. - */ -export interface RenameFileParams { - /** - * Absolute path of media folder - */ - mediaFolder: string; - /** - * Absolute path of source file - */ - from: string; - /** - * Absolute path of destination file - */ - to: string; -} - -interface RenameFileResponseBody { - error?: string; -} - -export async function renameFile(params: RenameFileParams): Promise { - const req = { - mediaFolder: params.mediaFolder, - from: params.from, - to: params.to, - }; - - const resp = await apiFetch('/api/renameFile', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(req), - }); - - if (!resp.ok) { - throw new Error(`Failed to rename file: ${resp.statusText}`); - } - - const data: RenameFileResponseBody = await resp.json(); - if (data.error) { - throw new Error(data.error); - } - - return data; -} - diff --git a/apps/ui/src/api/renameFilesInMediaMetadata.ts b/apps/ui/src/api/renameFilesInMediaMetadata.ts deleted file mode 100644 index f83b9217..00000000 --- a/apps/ui/src/api/renameFilesInMediaMetadata.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { RenameFilesInMediaMetadataRequestBody, RenameFilesInMediaMetadataResponseBody } from "@smm/types" -import { apiFetch } from '@/lib/apiFetch'; - -/** - * @deprecated Use renameFiles() with the `mediaFolder` field instead. - * The /api/renameFiles endpoint now automatically updates media metadata and - * broadcasts the change when `mediaFolder` is provided. - */ -export async function renameFilesInMediaMetadata(params: RenameFilesInMediaMetadataRequestBody): Promise { - const resp = await apiFetch("/api/renameFilesInMediaMetadata", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(params), - }) - - if (!resp.ok) { - throw new Error(`Failed to rename files in media metadata: ${resp.statusText}`) - } - - const data: RenameFilesInMediaMetadataResponseBody = await resp.json() - if (data.error) { - throw new Error(data.error) - } - - return data -} diff --git a/apps/ui/src/api/renameFolder.ts b/apps/ui/src/api/renameFolder.ts index 46281378..3201451f 100644 --- a/apps/ui/src/api/renameFolder.ts +++ b/apps/ui/src/api/renameFolder.ts @@ -30,14 +30,3 @@ export async function postRenameFolder( return (await resp.json()) as FolderRenameResponseBody } - -/** Throws on HTTP or business error — for mutations and dialogs. */ -export async function renameFolder( - params: RenameFolderParams, -): Promise { - const data = await postRenameFolder(params) - if (data.error) { - throw new Error(data.error) - } - return data -} diff --git a/apps/ui/src/api/renameFolderV3.ts b/apps/ui/src/api/renameFolderV3.ts index b6c280e3..1aebd0f1 100644 --- a/apps/ui/src/api/renameFolderV3.ts +++ b/apps/ui/src/api/renameFolderV3.ts @@ -5,13 +5,13 @@ export interface RenameFolderV3Params { to: string } -export interface RenameFolderV3ResponseBody { +interface RenameFolderV3ResponseBody { data?: { from: string; to: string } error?: string } /** Layer-2 rename via Core (`POST /api/rename-folder`). Used when SMM v3 is enabled. */ -export async function renameFolderV3( +async function renameFolderV3( params: RenameFolderV3Params, signal?: AbortSignal, ): Promise { diff --git a/apps/ui/src/api/scrape.ts b/apps/ui/src/api/scrape.ts deleted file mode 100644 index f7e7d3a9..00000000 --- a/apps/ui/src/api/scrape.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ScrapeRequestBody, ScrapeResponseBody } from "@smm/types"; -import { apiFetch } from '@/lib/apiFetch'; - -/** - * Scrape media files (poster, thumbnails, nfo) for a media folder - * @param mediaFolderPath - The absolute path to the media folder (in POSIX format) - */ -export async function scrapeApi(mediaFolderPath: string): Promise { - const req: ScrapeRequestBody = { - mediaFolderPath: mediaFolderPath, - } - - const resp = await apiFetch('/api/scrape', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(req), - }); - - if (!resp.ok) { - throw new Error(`Failed to scrape media: ${resp.statusText}`); - } - - const data: ScrapeResponseBody = await resp.json(); - return data; -} - diff --git a/apps/ui/src/api/showFolder.ts b/apps/ui/src/api/showFolder.ts index 0d7862d0..2b330479 100644 --- a/apps/ui/src/api/showFolder.ts +++ b/apps/ui/src/api/showFolder.ts @@ -2,7 +2,7 @@ import { apiFetch } from '@/lib/apiFetch' import type { MediaMetadata } from '@smm/types' import type { UIMediaFolderStatus } from '@/types/UIMediaFolder' -export type ShowFolderStatus = Extract< +type ShowFolderStatus = Extract< UIMediaFolderStatus, 'ok' | 'folder_not_found' | 'error_loading_metadata' > @@ -14,13 +14,13 @@ export interface ShowFolderResult { title?: string } -export interface ShowFolderResponseBody { +interface ShowFolderResponseBody { data?: ShowFolderResult error?: string } /** Resolve folder display status via Core (`POST /api/show-folder`). */ -export async function showFolder(path: string, signal?: AbortSignal): Promise { +async function showFolder(path: string, signal?: AbortSignal): Promise { const resp = await apiFetch('/api/show-folder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/apps/ui/src/api/speedtest.ts b/apps/ui/src/api/speedtest.ts index 31201fe0..253b6f8d 100644 --- a/apps/ui/src/api/speedtest.ts +++ b/apps/ui/src/api/speedtest.ts @@ -1,5 +1,5 @@ import { apiFetch } from '@/lib/apiFetch'; -export interface SpeedtestResult { +interface SpeedtestResult { url: string; timeMs: number | null; error?: string; diff --git a/apps/ui/src/api/tencentAsr.ts b/apps/ui/src/api/tencentAsr.ts deleted file mode 100644 index e213b8ca..00000000 --- a/apps/ui/src/api/tencentAsr.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { apiFetch } from '@/lib/apiFetch'; -export interface TencentAsrTranscribeRequest { - mediaPath: string - baseUrl: string - apiKey: string -} - -export interface TencentAsrTranscribeResponse { - success?: boolean - error?: string -} - -export async function transcribeWithTencentAsr( - request: TencentAsrTranscribeRequest, -): Promise { - const resp = await apiFetch("/api/tencent-asr/transcribe", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - mediaPath: request.mediaPath, - baseUrl: request.baseUrl, - apiKey: request.apiKey, - }), - }) - return (await resp.json()) as TencentAsrTranscribeResponse -} diff --git a/apps/ui/src/api/tmdb.ts b/apps/ui/src/api/tmdb.ts index 48907247..e03008fa 100644 --- a/apps/ui/src/api/tmdb.ts +++ b/apps/ui/src/api/tmdb.ts @@ -17,7 +17,6 @@ import { getMovieInTmdb, getTvShowInTmdb, searchInTmdb } from './tmdbV3' export const SMM_TMDB_DEFAULT_UPSTREAM = 'https://mediadb.vercel.app/api/tmdb' export type { - TmdbTvSeasonDetails, TmdbSeriesDetails, TmdbSeasonDetails, TmdbMovieDetails, @@ -52,8 +51,6 @@ export function clearDisabledDomains(domains: string[]): void { localStorages.disabledDomains = next } -export { fetchByInternalReverseProxy } from './fetchByInternalReverseProxy' - export async function fetchTmdb(urlPath: string, options?: { disabledDomains?: Set config?: DiscoverConfig @@ -262,5 +259,5 @@ export async function getSeason( return resp.json() as Promise } -export { TmdbFetchError, classifyTmdbError, formatTmdbErrorForDisplay, buildTmdbErrorFromResponse } from './tmdbErrors' +export { TmdbFetchError, classifyTmdbError, formatTmdbErrorForDisplay} from './tmdbErrors' diff --git a/apps/ui/src/api/tmdbErrors.ts b/apps/ui/src/api/tmdbErrors.ts index 428b9ce2..8934e8ec 100644 --- a/apps/ui/src/api/tmdbErrors.ts +++ b/apps/ui/src/api/tmdbErrors.ts @@ -3,7 +3,7 @@ import { SMM_TMDB_DEFAULT_UPSTREAM } from "./tmdb" /** * High-level classification of a TMDB fetch error. */ -export type TmdbErrorKind = "no-response" | "unauthorized" | "reverse-proxy" | "upstream" +type TmdbErrorKind = "no-response" | "unauthorized" | "reverse-proxy" | "upstream" interface TmdbErrorInfo { kind: TmdbErrorKind @@ -188,7 +188,7 @@ export function classifyTmdbError( * If the body is valid JSON, return a pretty-printed version; otherwise * return the raw text, truncated to 2000 characters. */ -export function formatResponseBodyText(bodyText: string): string { +function formatResponseBodyText(bodyText: string): string { if (!bodyText) return "" try { const parsed = JSON.parse(bodyText) diff --git a/apps/ui/src/api/tmdbV3.ts b/apps/ui/src/api/tmdbV3.ts index 4b6d8a32..3a5120d8 100644 --- a/apps/ui/src/api/tmdbV3.ts +++ b/apps/ui/src/api/tmdbV3.ts @@ -5,7 +5,7 @@ import type { TmdbSeriesDetails, } from '@smm/types' -export interface TmdbCoreRequestOptions { +interface TmdbCoreRequestOptions { language?: string host?: string password?: string diff --git a/apps/ui/src/api/tvdbV3.ts b/apps/ui/src/api/tvdbV3.ts index 0934b5d5..65be3aa1 100644 --- a/apps/ui/src/api/tvdbV3.ts +++ b/apps/ui/src/api/tvdbV3.ts @@ -15,7 +15,7 @@ export function toTvdbApiLanguage(language?: string): string | undefined { return trimmed } -export interface TvdbCoreRequestOptions { +interface TvdbCoreRequestOptions { language?: string host?: string password?: string diff --git a/apps/ui/src/api/validateRenameOperations.ts b/apps/ui/src/api/validateRenameOperations.ts deleted file mode 100644 index 70c3c347..00000000 --- a/apps/ui/src/api/validateRenameOperations.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { RenameValidationResult } from '@smm/types' -import { apiFetch } from '@/lib/apiFetch'; - -export interface ValidateRenameOperationsRequestBody { - mediaFolderPath: string - files: Array<{ from: string; to: string }> - filesystemCheck?: boolean -} - -export interface ValidateRenameOperationsResponseBody { - data: RenameValidationResult | null - error: string | null -} - -export async function validateRenameOperationsApi( - body: ValidateRenameOperationsRequestBody, -): Promise { - const response = await apiFetch('/api/validateRenameOperations', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - - if (!response.ok) { - return { - data: null, - error: `HTTP ${response.status}: ${response.statusText}`, - } - } - - return (await response.json()) as ValidateRenameOperationsResponseBody -} diff --git a/apps/ui/src/api/videocaptioner.ts b/apps/ui/src/api/videocaptioner.ts index 5962fe7f..c9c46204 100644 --- a/apps/ui/src/api/videocaptioner.ts +++ b/apps/ui/src/api/videocaptioner.ts @@ -12,9 +12,9 @@ export interface VideoCaptionerDiscoverResponse { error?: string; } -export type VideoCaptionerTranscribeAsr = "bijian" | "jianying" | "whisper-cpp"; +type VideoCaptionerTranscribeAsr = "bijian" | "jianying" | "whisper-cpp"; -export type VideoCaptionerTranscribeFormat = "srt" | "ass" | "txt" | "json"; +type VideoCaptionerTranscribeFormat = "srt" | "ass" | "txt" | "json"; export interface VideoCaptionerTranscribeRequest { mediaPath: string; diff --git a/apps/ui/src/api/ytdlp.ts b/apps/ui/src/api/ytdlp.ts index 2d4867d6..4a69d37d 100644 --- a/apps/ui/src/api/ytdlp.ts +++ b/apps/ui/src/api/ytdlp.ts @@ -1,93 +1,11 @@ import { validateDownloadUrl } from "@smm/core/download-video-validators"; -import { - buildYtdlpDownloadArgs, - buildYtdlpInspectArgs, - parseYtdlpDownloadStdout, - validateYtdlpDownloadExtraArgs, -} from "@smm/core/whitelistedCmd/ytdlp"; +import { buildYtdlpInspectArgs } from "@smm/core/whitelistedCmd/ytdlp"; import { probeWhitelistedCommand } from "@/lib/whitelistedCmd/probeWhitelistedCommand"; import { executeYtdlp } from "@/lib/ytdlp/executeYtdlp"; import { parse, videoMetadataForFormatsListing } from "@/api/ytdlp/parse"; -import type { PlaylistMetadata, Thumbnail, Version, VideoMetadata } from "@/api/ytdlp/types"; - -export interface YtdlpDownloadRequest { - url: string; - args?: string[]; - folder?: string; - /** yt-dlp `-f` format selector; omit for yt-dlp default. */ - format?: string; - /** - * When set, `--print ` is passed to yt-dlp. - * Default is undefined (no --print) so progress JSON stays on stdout. - */ - printArg?: string; -} - -export interface YtdlpDownloadResponse { - success?: boolean; - error?: string; - path?: string; -} - -const YTDLP_DOWNLOAD_TIMEOUT_MS = 60 * 60 * 1000; - -export async function downloadYtdlpVideo( - request: YtdlpDownloadRequest -): Promise { - const validation = validateDownloadUrl(request.url ?? ""); - if (!validation.valid) { - return { error: validation.error }; - } - - const argsError = validateYtdlpDownloadExtraArgs(request.args); - if (argsError) { - return { error: argsError }; - } - - const folder = request.folder ?? ""; - if (!folder) { - return { error: "folder is required" }; - } - - const args = buildYtdlpDownloadArgs({ - url: request.url, - folder, - args: request.args, - format: request.format, - printArg: request.printArg, - }); - - const result = await executeYtdlp(args, { - timeoutMs: YTDLP_DOWNLOAD_TIMEOUT_MS, - }); - - if (!result.success) { - return { error: result.error }; - } - - const path = parseYtdlpDownloadStdout(result.stdout); - return { success: true, path }; -} - -export async function discoverYtdlp(): Promise<{ path?: string; error?: string }> { - try { - const { fetchDiscoverExecutables } = await import("@/api/discoverExecutables"); - const { ytdlp } = await fetchDiscoverExecutables(); - const path = ytdlp.configuredPath ?? ytdlp.discoveredPath; - if (path) { - return { path }; - } - } catch { - /* fall through to probe */ - } - const probe = await probeWhitelistedCommand("yt-dlp"); - if (probe.available) { - return { path: probe.resolvedPath ?? "yt-dlp" }; - } - return { error: probe.error ?? "yt-dlp not found" }; -} +import type { PlaylistMetadata, VideoMetadata } from "@/api/ytdlp/types"; export async function getYtdlpVersion(): Promise<{ version?: string; error?: string }> { const probe = await probeWhitelistedCommand("yt-dlp"); @@ -101,50 +19,6 @@ export async function getYtdlpVersion(): Promise<{ version?: string; error?: str return { version: result.stdout.trim().split("\n")[0] }; } -export interface YtdlpExtractDataResponse { - title?: string; - artist?: string; - error?: string; -} - -export async function extractYtdlpVideoData(url: string): Promise { - if (!url) { - return { error: "url is required" }; - } - - const result = await executeYtdlp( - ["--skip-download", "--print", "title=%(title)s ___ artist=%(uploader)s", url], - { timeoutMs: 60_000 }, - ); - - if (!result.success) { - return { error: result.error }; - } - - const lines = result.stdout.trim().split("\n"); - const dataLine = lines.find((line) => line.includes("title=") && line.includes("___ artist=")); - if (!dataLine) { - return { error: "failed to parse video data from output" }; - } - - const parts = dataLine.split("___"); - let title: string | undefined; - let artist: string | undefined; - for (const part of parts) { - const trimmedPart = part.trim(); - if (trimmedPart.startsWith("title=")) { - title = trimmedPart.substring(6).trim(); - } else if (trimmedPart.startsWith("artist=")) { - artist = trimmedPart.substring(7).trim(); - } - } - - if (!title) { - return { error: "title not found in yt-dlp output" }; - } - return { title, artist }; -} - export interface YtdlpListFormatsRequest { url: string; /** Absolute path to a Netscape-format cookies file. */ @@ -169,10 +43,10 @@ export interface ListFormatsResult { } /** E2E: when set, `listYtdlpFormats` throws this yt-dlp-style error without running yt-dlp. */ -export const TEST_MOCK_LIST_FORMATS_ERROR_KEY = "test.mockYtdlpListFormatsError"; +const TEST_MOCK_LIST_FORMATS_ERROR_KEY = "test.mockYtdlpListFormatsError"; /** E2E: when set, `listYtdlpFormats` parses this `yt-dlp -J` stdout without running yt-dlp. */ -export const TEST_MOCK_LIST_FORMATS_JSON_KEY = "test.mockYtdlpListFormatsJson"; +const TEST_MOCK_LIST_FORMATS_JSON_KEY = "test.mockYtdlpListFormatsJson"; /** * Runs `yt-dlp -J` and returns the parsed format list. Supports `--cookies` (manual file), @@ -258,32 +132,6 @@ export interface BilibiliVideoMetadata { extractor_key?: string; } -/** Parsed stdout from `yt-dlp --flat-playlist -J` on a Bilibili collection list URL. */ -export interface BilibiliCollectionMetadata { - uploader: string; - title: string; - description: string; - uploader_id: string; - timestamp: number; - thumbnail: string; - id: string; - _type: string; - entries: VideoMetadata[]; - webpage_url: string; - original_url: string; - webpage_url_basename: string; - webpage_url_domain: string; - extractor: string; - extractor_key: string; - upload_date: string; - release_year: number | null; - thumbnails: Thumbnail[]; - playlist_count: number; - epoch: number; - __files_to_move?: Record; - _version?: Version; -} - async function collectExecuteCmdOutput( request: { command: "yt-dlp"; args: string[] }, timeoutMs?: number diff --git a/apps/ui/src/api/ytdlp/types.ts b/apps/ui/src/api/ytdlp/types.ts index d5cf874e..bd50ad60 100644 --- a/apps/ui/src/api/ytdlp/types.ts +++ b/apps/ui/src/api/ytdlp/types.ts @@ -23,12 +23,12 @@ export interface Format { http_headers: Record } -export interface Thumbnail { +interface Thumbnail { url: string id: string } -export interface RequestedDownload { +interface RequestedDownload { requested_formats: Format[] format: string format_id: string @@ -48,7 +48,7 @@ export interface RequestedDownload { abr: number | null } -export interface Version { +interface Version { version: string current_git_head: string | null release_git_head: string | null diff --git a/apps/ui/src/components/AiIcon.tsx b/apps/ui/src/components/AiIcon.tsx deleted file mode 100644 index 8927afee..00000000 --- a/apps/ui/src/components/AiIcon.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { cn } from "@/lib/utils" -import { Bot } from "lucide-react" - -export interface AiIconProps { - /** Size of the icon */ - size?: number - /** Additional className */ - className?: string -} - -/** - * A beautiful holographic AI icon component with animated rainbow rings, - * energy pulses, and a glowing center. Perfect for indicating AI activity. - * - * @example - * ```tsx - * - * ``` - */ -export function AiIcon({ size = 80, className }: AiIconProps) { - return ( -
- {/* Rainbow pulsing rings */} -
- - {/* Outer energy ring */} -
- - {/* Middle holographic ring */} -
- - {/* Inner core with Bot icon */} -
- -
- - {/* Holographic overlay */} -
-
- ) -} - diff --git a/apps/ui/src/components/DatabaseConnectionIndicator.tsx b/apps/ui/src/components/DatabaseConnectionIndicator.tsx deleted file mode 100644 index 88b8347b..00000000 --- a/apps/ui/src/components/DatabaseConnectionIndicator.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { TriangleAlert } from "lucide-react" -import { useTranslation } from "@/lib/i18n" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" -import { Separator } from "@/components/ui/separator" -import { cn } from "@/lib/utils" -import { useDatabaseConnectionStatus } from "@/hooks/useDatabaseConnectionStatus" -import type { DatabaseConnectionStatus } from "@/lib/databaseConnectionCheck" - -function StatusDot({ status }: { status: DatabaseConnectionStatus }) { - return ( - - ) -} - -function databaseConnectionStatusText( - status: DatabaseConnectionStatus, - t: ReturnType>["t"], -) { - switch (status) { - case "connected": - return t("statusBar.database.connected") - case "disconnected": - return t("statusBar.database.disconnected") - case "checkFailed": - return t("statusBar.database.checkFailed") - case "checking": - return t("statusBar.database.checking") - } -} - -export function DatabaseConnectionIndicator() { - const { tmdbStatus, tvdbStatus, hasWarning } = useDatabaseConnectionStatus() - const { t } = useTranslation("components") - - if (!hasWarning) return null - - return ( - - - - - -
-
-
- -
-
-

- {t("statusBar.database.title")} -

-

- {t("statusBar.database.subtitle")} -

-
-
-
- -
-
- - {t("statusBar.database.tmdb")} - - {databaseConnectionStatusText(tmdbStatus, t)} - -
-
- - {t("statusBar.database.tvdb")} - - {databaseConnectionStatusText(tvdbStatus, t)} - -
-
-
-
- ) -} diff --git a/apps/ui/src/components/FileList.tsx b/apps/ui/src/components/FileList.tsx deleted file mode 100644 index 8561d39a..00000000 --- a/apps/ui/src/components/FileList.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "@/components/ui/context-menu" - -interface FileListProps { - files: string[] -} - -/** - * @deprecated("Use LocalFilesPanel instead") - * @param param0 - * @returns - */ -export function FileList({ files }: FileListProps) { - - return ( -
- { - files.map((file) => ( -
- - -
- {file} -
-
- - Profile - Billing - Team - Subscription - -
-
- )) - } -
- ) - -} \ No newline at end of file diff --git a/apps/ui/src/components/ImmersiveMovieSearchbox.tsx b/apps/ui/src/components/ImmersiveMovieSearchbox.tsx deleted file mode 100644 index bd187d9c..00000000 --- a/apps/ui/src/components/ImmersiveMovieSearchbox.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import * as React from "react" -import { Star } from "lucide-react" -import { cn } from "@/lib/utils" -import { ImmersiveInput } from "./ImmersiveInput" -import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover" -import { ScrollArea } from "@/components/ui/scroll-area" -import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card" -import { getTMDBImageUrl } from "@/api/tmdb" -import type { TMDBMovie } from "@smm/types" - -// Helper function to format date -function formatDate(dateString: string): string { - if (!dateString) return "N/A" - try { - const date = new Date(dateString) - return date.toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric" - }) - } catch { - return dateString - } -} - -interface ImmersiveMovieSearchboxProps { - value: string - onChange: (value: string) => void - onSearch: () => void - onSelect: (result: TMDBMovie) => void - searchResults: TMDBMovie[] - isSearching: boolean - searchError: string | null - className?: string - placeholder?: string - inputClassName?: string - /** When set, a Hover Card with this hint is always shown (not triggered by hover). */ - unrecognizedHint?: string -} - -/** - * @deprecated - * @param param0 - * @returns - */ -export function ImmersiveMovieSearchbox({ - value, - onChange, - onSearch, - onSelect, - searchResults, - isSearching, - searchError, - className, - placeholder = "Enter movie name", - inputClassName, - unrecognizedHint, -}: ImmersiveMovieSearchboxProps) { - const [isSearchOpen, setIsSearchOpen] = React.useState(false) - const [popoverWidth, setPopoverWidth] = React.useState(undefined) - const inputContainerRef = React.useRef(null) - - // Measure input width when popover opens - React.useEffect(() => { - if (isSearchOpen && inputContainerRef.current) { - const width = inputContainerRef.current.offsetWidth - setPopoverWidth(width) - } - }, [isSearchOpen]) - - const handleSearchButtonClick = React.useCallback(() => { - const wasOpen = isSearchOpen - if (!wasOpen) { - setIsSearchOpen(true) - } - - // Keep input focused - setTimeout(() => { - inputContainerRef.current?.querySelector('input')?.focus() - }, 0) - - onSearch() - }, [isSearchOpen, onSearch]) - - const handleSelectResult = React.useCallback((result: TMDBMovie) => { - setIsSearchOpen(false) - onSelect(result) - }, [onSelect]) - - return ( -
- - -
- {unrecognizedHint ? ( - // onOpenChange no-op keeps the card always open (hint is persistent when folder unrecognized) - {}}> - -
- onChange(e.target.value)} - onSearch={handleSearchButtonClick} - isOpen={isSearchOpen} - className={inputClassName} - placeholder={placeholder} - /> -
-
- - {unrecognizedHint} - -
- ) : ( - onChange(e.target.value)} - onSearch={handleSearchButtonClick} - isOpen={isSearchOpen} - className={inputClassName} - placeholder={placeholder} - /> - )} -
-
- { - // Prevent closing when clicking the search button - const target = e.target as HTMLElement - if (inputContainerRef.current?.contains(target)) { - e.preventDefault() - } - }} - > - -
- {isSearching ? ( -
-
Searching...
-
- ) : searchError ? ( -
- {searchError} -
- ) : searchResults.length > 0 ? ( - searchResults.map((result) => { - const resultPosterUrl = getTMDBImageUrl(result.poster_path, "w200") - return ( -
handleSelectResult(result)} - className="flex gap-3 p-3 rounded-md cursor-pointer hover:bg-accent transition-colors" - > - {resultPosterUrl && ( -
- {result.title} { - const target = e.target as HTMLImageElement - target.style.display = "none" - }} - /> -
- )} -
-

- {result.title} -

- {result.original_title !== result.title && ( -

- {result.original_title} -

- )} -

- {result.overview || 'No overview available'} -

- {(result.release_date || result.vote_average) && ( -
- {result.release_date && ( - {formatDate(result.release_date)} - )} - {result.release_date && result.vote_average && ( - - )} - {result.vote_average > 0 && ( - - - {result.vote_average.toFixed(1)} - - )} -
- )} -
-
- ) - }) - ) : ( -
- {value.trim() ? 'No results found' : 'Enter a search query and click search'} -
- )} -
-
-
-
-
- ) -} diff --git a/apps/ui/src/components/ImmersiveSearchbox.tsx b/apps/ui/src/components/ImmersiveSearchbox.tsx index bd34c30e..f3d3b362 100644 --- a/apps/ui/src/components/ImmersiveSearchbox.tsx +++ b/apps/ui/src/components/ImmersiveSearchbox.tsx @@ -15,13 +15,6 @@ export interface SearchLanguageOption { name: string } -/** - * Maximum number of "priority" language options shown in the default (collapsed) - * view of the language dropdown. Anything beyond this is reachable via the - * "Show all languages" toggle. - */ -export const PRIORITY_LANGUAGE_OPTION_LIMIT = 3 - // Helper function to format date function formatDate(dateString: string): string { if (!dateString) return "N/A" diff --git a/apps/ui/src/components/LocalFileTableRow.tsx b/apps/ui/src/components/LocalFileTableRow.tsx index 8b8b7cc4..aa2d8a17 100644 --- a/apps/ui/src/components/LocalFileTableRow.tsx +++ b/apps/ui/src/components/LocalFileTableRow.tsx @@ -26,8 +26,6 @@ import { Path } from "@smm/utils/path" import { toast } from "sonner" import { useTranslation, castTranslationFn } from "@/lib/i18n" -export type { LocalFileTableRowData } from "./MusicFileTable" -export type { MusicTableSelection, LocalFileTableRowFileMenu } from "@/types/music-table" export interface LocalFileTableRowProps { row: LocalFileTableRowData diff --git a/apps/ui/src/components/LocalFilesPanel.tsx b/apps/ui/src/components/LocalFilesPanel.tsx deleted file mode 100644 index f4612770..00000000 --- a/apps/ui/src/components/LocalFilesPanel.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@/components/ui/context-menu" -import { ScrollArea } from "@/components/ui/scroll-area" -import { FileIcon } from "lucide-react" -import { useMemo } from "react" - -interface File { - /** - * The relative path of the file, in POSIX format. Relative to the media folder. - */ - path: string; -} - -interface LocalFilesPanelProps { - files: File[]; - /** - * Absolute path in POSIX format - */ - mediaFolderPath: string; - onFileAction?: (action: string, file: File) => void; -} - -interface FileProps { - /** - * Relative path of this file, in POSIX format. Relative to the media folder. - */ - path: string; - icon: React.ReactNode; -} - -function FileItem({path, icon}: FileProps) { - - return
-
- {icon} -
-
- {path} -
-
- -} - - -function LocalFilesPanel({ files, onFileAction, mediaFolderPath }: LocalFilesPanelProps) { - - const filesProps: FileProps[] = useMemo(() => { - - return files.map((file) => { - return { - path: file.path.replace(mediaFolderPath + "/", ""), - icon: , - } - }) - - }, [files, mediaFolderPath]) - - const handleAction = (action: string, file: File) => { - if (onFileAction) { - onFileAction(action, file); - } - }; - - return ( -
- - { - filesProps.map((file) => ( - - - - - - handleAction("open", file)}> - Open in Explorer - - - handleAction("rename", file)}> - Rename - - handleAction("delete", file)} - > - Delete - - - handleAction("properties", file)}> - Properties - - - - )) - } - -
- ) -} - -export default LocalFilesPanel \ No newline at end of file diff --git a/apps/ui/src/components/MediaDatabaseSearchbox.tsx b/apps/ui/src/components/MediaDatabaseSearchbox.tsx index 58a3ab36..2d5df382 100644 --- a/apps/ui/src/components/MediaDatabaseSearchbox.tsx +++ b/apps/ui/src/components/MediaDatabaseSearchbox.tsx @@ -38,13 +38,7 @@ import { } from "@/hooks/useTvdbLanguages" import { getLanguageDisplayName } from "@/lib/languageNativeNames" import localStorages from "@/lib/localStorages" -import { - preferMediaLanguageToTvdbCode, - type TmdbSearchLanguage, - type TvdbSearchLanguage, - DEFAULT_TMDB_SEARCH_LANGUAGE, - DEFAULT_TVDB_SEARCH_LANGUAGE, -} from "@/lib/searchLanguage" +import { preferMediaLanguageToTvdbCode, type TmdbSearchLanguage, type TvdbSearchLanguage, DEFAULT_TVDB_SEARCH_LANGUAGE } from "@/lib/searchLanguage" /** * The current search language. Format depends on `database`: @@ -415,4 +409,3 @@ function resolveInitialSearchLanguage( return preferMediaLanguageToTvdbCode(resolvedMediaLanguage) || DEFAULT_TVDB_SEARCH_LANGUAGE } -export { DEFAULT_TMDB_SEARCH_LANGUAGE, DEFAULT_TVDB_SEARCH_LANGUAGE } diff --git a/apps/ui/src/components/MediaPlayer.tsx b/apps/ui/src/components/MediaPlayer.tsx index 679a8087..e7c71d2e 100644 --- a/apps/ui/src/components/MediaPlayer.tsx +++ b/apps/ui/src/components/MediaPlayer.tsx @@ -1,9 +1,6 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { MediaPlayerToolbar, type SortBy } from './MediaPlayerToolbar'; -import { MediaPlayerTrackList } from './MediaPlayerTrackList'; -import { MediaPlayerControlBar } from './MediaPlayerControlBar'; -export interface DownloadingTrack { + +interface DownloadingTrack { url?: string status?: "pending" | "downloading" | "completed" | "failed" | "stopped"; /** When set, this row is backed by a {@link import('@/types/background-jobs').DownloadVideoBackgroundJob} */ @@ -33,337 +30,3 @@ export interface Track extends DownloadingTrack { */ path?: string; } - -export type MediaPlayerMode = 'view' | 'player'; - -export interface MediaPlayerProps { - tracks?: Track[]; - className?: string; - mode?: MediaPlayerMode; - onDownloadClick?: () => void; -} - -const DEFAULT_TRACKS: Track[] = [ - { id: 1, title: "Midnight Dreams", artist: "Luna Nova", duration: 234, thumbnail: "https://picsum.photos/seed/music1/200", addedDate: new Date('2024-01-15'), path: undefined }, - { id: 2, title: "Electric Pulse", artist: "Neon Waves", duration: 198, thumbnail: "https://picsum.photos/seed/music2/200", addedDate: new Date('2024-02-01'), path: undefined }, - { id: 3, title: "Sunset Boulevard", artist: "The Wanderers", duration: 267, thumbnail: "https://picsum.photos/seed/music3/200", addedDate: new Date('2024-01-20'), path: undefined }, - { id: 4, title: "Crystal Clear", artist: "Aurora Skies", duration: 312, thumbnail: "https://picsum.photos/seed/music4/200", addedDate: new Date('2024-02-10'), path: undefined }, - { - id: 5, - title: "https://www.example.com/video1", - artist: "", - duration: 285, - thumbnail: "https://picsum.photos/seed/music5/200", - addedDate: new Date('2024-01-05'), - path: undefined, - url: 'https://www.example.com/video1', - status: 'pending', - }, - { - id: 6, - title: "https://www.example.com/video2", - artist: "", - duration: 285, - thumbnail: "https://picsum.photos/seed/music5/200", - addedDate: new Date('2024-01-05'), - path: undefined, - url: 'https://www.example.com/video2', - status: 'downloading', - }, - { - id: 7, - title: "https://www.example.com/video3", - artist: "", - duration: 285, - thumbnail: "https://picsum.photos/seed/music5/200", - addedDate: new Date('2024-01-05'), - path: undefined, - url: 'https://www.example.com/video3', - status: 'completed', - }, - { - id: 8, - title: "https://www.example.com/video4", - artist: "", - duration: 285, - thumbnail: "https://picsum.photos/seed/music5/200", - addedDate: new Date('2024-01-05'), - path: undefined, - url: 'https://www.example.com/video4', - status: 'failed', - } - -]; - -export function MediaPlayer({ tracks = DEFAULT_TRACKS, className = '', mode = 'view', onDownloadClick }: MediaPlayerProps) { - const [searchQuery, setSearchQuery] = useState(''); - const [sortBy, setSortBy] = useState('recent'); - - const [currentTrack, setCurrentTrack] = useState(null); - const [isPlaying, setIsPlaying] = useState(false); - const [progress, setProgress] = useState(0); - const [volume, setVolume] = useState(70); - const [isMuted, setIsMuted] = useState(false); - const [shuffle, setShuffle] = useState(false); - const [repeat, setRepeat] = useState(false); - const [isLoading, setIsLoading] = useState(false); - - const progressIntervalRef = useRef(null); - - const formatTime = useCallback((seconds: number): string => { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${mins}:${secs.toString().padStart(2, '0')}`; - }, []); - - const getFilteredAndSortedTracks = useCallback((): Track[] => { - let filtered = [...tracks]; - - if (searchQuery) { - const query = searchQuery.toLowerCase(); - filtered = filtered.filter(track => - track.title.toLowerCase().includes(query) || - track.artist.toLowerCase().includes(query) - ); - } - - switch (sortBy) { - case 'title': - filtered.sort((a, b) => a.title.localeCompare(b.title)); - break; - case 'title-desc': - filtered.sort((a, b) => b.title.localeCompare(a.title)); - break; - case 'artist': - filtered.sort((a, b) => a.artist.localeCompare(b.artist)); - break; - case 'duration': - filtered.sort((a, b) => a.duration - b.duration); - break; - case 'recent': - filtered.sort((a, b) => b.addedDate.getTime() - a.addedDate.getTime()); - break; - } - - return filtered; - }, [tracks, searchQuery, sortBy]); - - const filteredTracks = getFilteredAndSortedTracks(); - - const playTrack = useCallback(async (track: Track) => { - setIsLoading(true); - - if (currentTrack?.id === track.id) { - setIsPlaying(!isPlaying); - setIsLoading(false); - return; - } - - setCurrentTrack(track); - setProgress(0); - setIsPlaying(true); - setIsLoading(false); - }, [currentTrack, isPlaying]); - - const togglePlay = useCallback(() => { - if (!currentTrack && filteredTracks.length > 0) { - playTrack(filteredTracks[0]); - return; - } - setIsPlaying(prev => !prev); - }, [currentTrack, filteredTracks, playTrack]); - - const playNext = useCallback(() => { - if (!currentTrack || filteredTracks.length === 0) return; - - const currentIndex = filteredTracks.findIndex(t => t.id === currentTrack.id); - let nextIndex; - - if (shuffle) { - nextIndex = Math.floor(Math.random() * filteredTracks.length); - } else { - nextIndex = (currentIndex + 1) % filteredTracks.length; - } - - playTrack(filteredTracks[nextIndex]); - }, [currentTrack, filteredTracks, shuffle, playTrack]); - - const playPrevious = useCallback(() => { - if (!currentTrack || filteredTracks.length === 0) return; - - const currentProgressSeconds = (progress / 100) * (currentTrack.duration || 0); - - if (currentProgressSeconds > 5) { - setProgress(0); - return; - } - - const currentIndex = filteredTracks.findIndex(t => t.id === currentTrack.id); - let prevIndex; - - if (shuffle) { - prevIndex = Math.floor(Math.random() * filteredTracks.length); - } else { - prevIndex = (currentIndex - 1 + filteredTracks.length) % filteredTracks.length; - } - - playTrack(filteredTracks[prevIndex]); - }, [currentTrack, filteredTracks, shuffle, progress, playTrack]); - - const handleProgressChange = useCallback((value: number) => { - setProgress(value); - }, []); - - const handleVolumeChange = useCallback((value: number) => { - setVolume(value); - setIsMuted(value === 0); - }, []); - - const toggleMute = useCallback(() => { - setIsMuted(prev => !prev); - }, []); - - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (mode === 'view') return; - if (e.target instanceof HTMLInputElement) return; - - switch (e.key) { - case ' ': - e.preventDefault(); - togglePlay(); - break; - case 'ArrowRight': - if (e.ctrlKey) { - e.preventDefault(); - playNext(); - } - break; - case 'ArrowLeft': - if (e.ctrlKey) { - e.preventDefault(); - playPrevious(); - } - break; - case 'm': - e.preventDefault(); - toggleMute(); - break; - } - }, [mode, togglePlay, playNext, playPrevious, toggleMute]); - - useEffect(() => { - if (progressIntervalRef.current) { - clearInterval(progressIntervalRef.current); - } - - if (isPlaying && currentTrack) { - progressIntervalRef.current = window.setInterval(() => { - setProgress(prev => { - const increment = 100 / currentTrack.duration; - const newProgress = Math.min(100, prev + increment); - - if (newProgress >= 100) { - if (repeat) { - return 0; - } else { - playNext(); - return 0; - } - } - - return newProgress; - }); - }, 1000); - } - - return () => { - if (progressIntervalRef.current) { - clearInterval(progressIntervalRef.current); - } - }; - }, [isPlaying, currentTrack, repeat, playNext]); - - useEffect(() => { - if (mode === 'view') return; - - const handleGlobalKeyDown = (e: KeyboardEvent) => { - if (e.target instanceof HTMLInputElement) return; - - switch (e.key) { - case ' ': - e.preventDefault(); - togglePlay(); - break; - case 'ArrowRight': - if (e.ctrlKey) { - e.preventDefault(); - playNext(); - } - break; - case 'ArrowLeft': - if (e.ctrlKey) { - e.preventDefault(); - playPrevious(); - } - break; - case 'm': - e.preventDefault(); - toggleMute(); - break; - } - }; - - window.addEventListener('keydown', handleGlobalKeyDown); - return () => window.removeEventListener('keydown', handleGlobalKeyDown); - }, [mode, togglePlay, playNext, playPrevious, toggleMute]); - - return ( -
- - -
- -
- - {mode === 'player' && ( - setShuffle(!shuffle)} - onToggleRepeat={() => setRepeat(!repeat)} - formatTime={formatTime} - /> - )} -
- ); -} diff --git a/apps/ui/src/components/MediaPlayerControlBar.tsx b/apps/ui/src/components/MediaPlayerControlBar.tsx deleted file mode 100644 index b7c3d246..00000000 --- a/apps/ui/src/components/MediaPlayerControlBar.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { - Play, - Pause, - SkipBack, - SkipForward, - Shuffle, - Repeat, - Volume2, - VolumeX, - Music -} from 'lucide-react'; -import type { Track } from './MediaPlayer'; - -export interface MediaPlayerControlBarProps { - currentTrack: Track | null; - isPlaying: boolean; - isLoading: boolean; - progress: number; - volume: number; - isMuted: boolean; - shuffle: boolean; - repeat: boolean; - onTogglePlay: () => void; - onPlayNext: () => void; - onPlayPrevious: () => void; - onProgressChange: (value: number) => void; - onVolumeChange: (value: number) => void; - onToggleMute: () => void; - onToggleShuffle: () => void; - onToggleRepeat: () => void; - formatTime: (seconds: number) => string; -} - -export function MediaPlayerControlBar({ - currentTrack, - isPlaying, - isLoading, - progress, - volume, - isMuted, - shuffle, - repeat, - onTogglePlay, - onPlayNext, - onPlayPrevious, - onProgressChange, - onVolumeChange, - onToggleMute, - onToggleShuffle, - onToggleRepeat, - formatTime -}: MediaPlayerControlBarProps) { - const currentProgressTime = currentTrack ? (progress / 100) * currentTrack.duration : 0; - const displayVolume = isMuted ? 0 : volume; - - return ( -
-
-
-
- {currentTrack ? ( - {`${currentTrack.title} - ) : ( - - )} -
-
-

- {currentTrack?.title || 'Select a track'} -

-

- {currentTrack?.artist || '--'} -

-
-
- -
-
- - - - - -
- -
- - {formatTime(currentProgressTime)} - - onProgressChange(parseFloat(e.target.value))} - className="flex-1 h-1 bg-border rounded-lg appearance-none cursor-pointer accent-green-500 hover:accent-green-400 transition-colors" - aria-label="Track progress" - /> - - {currentTrack ? formatTime(currentTrack.duration) : '0:00'} - -
-
- -
- - onVolumeChange(parseFloat(e.target.value))} - className="flex-1 h-1 bg-border rounded-lg appearance-none cursor-pointer accent-green-500 hover:accent-green-400 transition-colors" - aria-label="Volume" - /> -
-
-
- ); -} diff --git a/apps/ui/src/components/MediaPlayerToolbar.tsx b/apps/ui/src/components/MediaPlayerToolbar.tsx deleted file mode 100644 index c6f17fb6..00000000 --- a/apps/ui/src/components/MediaPlayerToolbar.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { SearchForm } from './search-form'; -import { FilterButton } from './shared/FilterButton'; -import { SortingButton } from './shared/SortingButton'; -import { Download } from 'lucide-react'; -import { Button } from '@/components/ui/button'; - -const GENRES = ['all', 'pop', 'rock', 'electronic', 'jazz', 'classical'] as const; -const SORT_OPTIONS = [ - { value: 'title', label: 'Title (A-Z)' }, - { value: 'title-desc', label: 'Title (Z-A)' }, - { value: 'artist', label: 'Artist (A-Z)' }, - { value: 'duration', label: 'Duration' }, - { value: 'recent', label: 'Recently Added' } -] as const; - -type Genre = typeof GENRES[number]; -type SortBy = typeof SORT_OPTIONS[number]['value']; - -export interface MediaPlayerToolbarProps { - searchQuery: string; - onSearchChange: (query: string) => void; - onFilterChange?: (genre: Genre) => void; - onSortChange: (sortBy: SortBy) => void; - filterValue?: Genre; - sortValue: SortBy; - onDownloadClick?: () => void; -} - -export function MediaPlayerToolbar({ - searchQuery, - onSearchChange, - onFilterChange, - onSortChange, - filterValue, - sortValue, - onDownloadClick -}: MediaPlayerToolbarProps) { - const filterOptions = GENRES.map(genre => ({ - value: genre, - label: genre === 'all' ? 'All Genres' : genre - })); - - const sortOptions = SORT_OPTIONS.map(option => ({ ...option })); - - return ( -
-
-
- -
- -
- {onDownloadClick && ( - - )} - {onFilterChange && filterValue !== undefined && ( - - )} - -
-
-
- ); -} - -export { GENRES, SORT_OPTIONS }; -export type { Genre, SortBy }; diff --git a/apps/ui/src/components/UILocalFileTableRow.tsx b/apps/ui/src/components/UILocalFileTableRow.tsx index b84fb047..79542097 100644 --- a/apps/ui/src/components/UILocalFileTableRow.tsx +++ b/apps/ui/src/components/UILocalFileTableRow.tsx @@ -12,7 +12,6 @@ import { AssociatedFileRow } from "./AssociatedFileRow" import { EmptyAssociatedFileRow } from "./EmptyAssociatedFileRow" import { JobRow } from "./JobRow" -export type { MusicTableSelection, LocalFileTableRowFileMenu, LocalFileTableRowSubtitleActions } export interface UILocalFileTableRowProps { row: LocalFileTableRowData diff --git a/apps/ui/src/components/app-sidebar.tsx b/apps/ui/src/components/app-sidebar.tsx deleted file mode 100644 index 888eb29d..00000000 --- a/apps/ui/src/components/app-sidebar.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import * as React from "react" -import { ChevronRight } from "lucide-react" - -import { SearchForm } from "@/components/search-form" -import { VersionSwitcher } from "@/components/version-switcher" -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible" -import { - Sidebar, - SidebarContent, - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarRail, -} from "@/components/ui/sidebar" - -// This is sample data. -const data = { - versions: ["1.0.1", "1.1.0-alpha", "2.0.0-beta1"], - navMain: [ - { - title: "Getting Started", - url: "#", - items: [ - { - title: "Installation", - url: "#", - }, - { - title: "Project Structure", - url: "#", - }, - ], - }, - { - title: "Building Your Application", - url: "#", - items: [ - { - title: "Routing", - url: "#", - }, - { - title: "Data Fetching", - url: "#", - isActive: true, - }, - { - title: "Rendering", - url: "#", - }, - { - title: "Caching", - url: "#", - }, - { - title: "Styling", - url: "#", - }, - { - title: "Optimizing", - url: "#", - }, - { - title: "Configuring", - url: "#", - }, - { - title: "Testing", - url: "#", - }, - { - title: "Authentication", - url: "#", - }, - { - title: "Deploying", - url: "#", - }, - { - title: "Upgrading", - url: "#", - }, - { - title: "Examples", - url: "#", - }, - ], - }, - { - title: "API Reference", - url: "#", - items: [ - { - title: "Components", - url: "#", - }, - { - title: "File Conventions", - url: "#", - }, - { - title: "Functions", - url: "#", - }, - { - title: "next.config.js Options", - url: "#", - }, - { - title: "CLI", - url: "#", - }, - { - title: "Edge Runtime", - url: "#", - }, - ], - }, - { - title: "Architecture", - url: "#", - items: [ - { - title: "Accessibility", - url: "#", - }, - { - title: "Fast Refresh", - url: "#", - }, - { - title: "Next.js Compiler", - url: "#", - }, - { - title: "Supported Browsers", - url: "#", - }, - { - title: "Turbopack", - url: "#", - }, - ], - }, - { - title: "Community", - url: "#", - items: [ - { - title: "Contribution Guide", - url: "#", - }, - ], - }, - ], -} - -export function AppSidebar({ ...props }: React.ComponentProps) { - return ( - - - - - - - {/* We create a collapsible SidebarGroup for each parent. */} - {data.navMain.map((item) => ( - - - - - {item.title}{" "} - - - - - - - {item.items.map((item) => ( - - - {item.title} - - - ))} - - - - - - ))} - - - - ) -} diff --git a/apps/ui/src/components/attachment.tsx b/apps/ui/src/components/attachment.tsx deleted file mode 100644 index 0b468c67..00000000 --- a/apps/ui/src/components/attachment.tsx +++ /dev/null @@ -1,238 +0,0 @@ -"use client"; - -import { type PropsWithChildren, useEffect, useState, type FC } from "react"; -import { XIcon, PlusIcon, FileText } from "lucide-react"; -import { - AttachmentPrimitive, - ComposerPrimitive, - MessagePrimitive, - useAssistantState, - useAssistantApi, -} from "@assistant-ui/react"; -import { useShallow } from "zustand/shallow"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { - Dialog, - DialogTitle, - DialogContent, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; -import { TooltipIconButton } from "@/components/tooltip-icon-button"; -import { cn } from "@/lib/utils"; - -const useFileSrc = (file: File | undefined) => { - const [src, setSrc] = useState(undefined); - - useEffect(() => { - if (!file) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setSrc(undefined); - return; - } - - const objectUrl = URL.createObjectURL(file); - - setSrc(objectUrl); - - return () => { - URL.revokeObjectURL(objectUrl); - }; - }, [file]); - - return src; -}; - -const useAttachmentSrc = () => { - const { file, src } = useAssistantState( - useShallow(({ attachment }): { file?: File; src?: string } => { - if (attachment.type !== "image") return {}; - if (attachment.file) return { file: attachment.file }; - const src = attachment.content?.filter((c) => c.type === "image")[0] - ?.image; - if (!src) return {}; - return { src }; - }), - ); - - return useFileSrc(file) ?? src; -}; - -type AttachmentPreviewProps = { - src: string; -}; - -const AttachmentPreview: FC = () => { - // const [isLoaded, setIsLoaded] = useState(false); - return ( - // Image Preview setIsLoaded(true)} - // priority={false} - // /> -
Hello
- ); -}; - -const AttachmentPreviewDialog: FC = ({ children }) => { - const src = useAttachmentSrc(); - - if (!src) return children; - - return ( - - - {children} - - - - Image Attachment Preview - -
- -
-
-
- ); -}; - -const AttachmentThumb: FC = () => { - const isImage = useAssistantState( - ({ attachment }) => attachment.type === "image", - ); - const src = useAttachmentSrc(); - - return ( - - - - - - - ); -}; - -const AttachmentUI: FC = () => { - const api = useAssistantApi(); - const isComposer = api.attachment.source === "composer"; - - const isImage = useAssistantState( - ({ attachment }) => attachment.type === "image", - ); - const typeLabel = useAssistantState(({ attachment }) => { - const type = attachment.type; - switch (type) { - case "image": - return "Image"; - case "document": - return "Document"; - case "file": - return "File"; - default: { - const _exhaustiveCheck: never = type; - throw new Error(`Unknown attachment type: ${_exhaustiveCheck}`); - } - } - }); - - return ( - - #attachment-tile]:size-24", - )} - > - - -
- -
-
-
- {isComposer && } -
- - - -
- ); -}; - -const AttachmentRemove: FC = () => { - return ( - - - - - - ); -}; - -export const UserMessageAttachments: FC = () => { - return ( -
- -
- ); -}; - -export const ComposerAttachments: FC = () => { - return ( -
- -
- ); -}; - -export const ComposerAddAttachment: FC = () => { - return ( - - - - - - ); -}; diff --git a/apps/ui/src/components/auth/LoginPanel.tsx b/apps/ui/src/components/auth/LoginPanel.tsx index 36a7b4d1..73bd79ef 100644 --- a/apps/ui/src/components/auth/LoginPanel.tsx +++ b/apps/ui/src/components/auth/LoginPanel.tsx @@ -10,7 +10,7 @@ import { setAuthLoginRequired } from '@/lib/authSession'; import { useReloadAppConfig } from '@/hooks/userConfig/useReloadAppConfig'; import { useAuthLoginRequired } from '@/hooks/useAuthLoginRequired'; -export function LoginPanel() { +function LoginPanel() { const { t } = useTranslation('common'); const { reload } = useReloadAppConfig(); const [token, setToken] = useState(''); diff --git a/apps/ui/src/components/dialogs/NewVersionDialog.tsx b/apps/ui/src/components/dialogs/NewVersionDialog.tsx index aa0b5a96..6abc58fb 100644 --- a/apps/ui/src/components/dialogs/NewVersionDialog.tsx +++ b/apps/ui/src/components/dialogs/NewVersionDialog.tsx @@ -9,7 +9,7 @@ import { import { Button } from "@/components/ui/button" import { useTranslation } from "@/lib/i18n" -export const SMM_RELEASES_URL = "https://github.com/lawrenceching/fanclub/releases" +const SMM_RELEASES_URL = "https://github.com/lawrenceching/fanclub/releases" interface NewVersionDialogProps { open: boolean diff --git a/apps/ui/src/components/dialogs/download-video-dialog/index.tsx b/apps/ui/src/components/dialogs/download-video-dialog/index.tsx index 83599eb5..10b80af3 100644 --- a/apps/ui/src/components/dialogs/download-video-dialog/index.tsx +++ b/apps/ui/src/components/dialogs/download-video-dialog/index.tsx @@ -6,7 +6,7 @@ import { useDownloadVideoForm } from "../hooks/use-download-video-form" import { useYtdlpDownloadFlow } from "../hooks/use-ytdlp-download-flow" import { UIDownloadVideoDialogContent } from "../UIDownloadVideoDialogContent" -export function DownloadVideoDialogContent({ +function DownloadVideoDialogContent({ isOpen: _isOpen, onClose, onOpenFilePicker, diff --git a/apps/ui/src/components/dialogs/hooks/use-ytdlp-download-flow.ts b/apps/ui/src/components/dialogs/hooks/use-ytdlp-download-flow.ts index aef661b0..38ed3402 100644 --- a/apps/ui/src/components/dialogs/hooks/use-ytdlp-download-flow.ts +++ b/apps/ui/src/components/dialogs/hooks/use-ytdlp-download-flow.ts @@ -20,7 +20,7 @@ import type { YtdlpCookiesBrowserId } from "@/lib/ytdlpCookiesBrowsers" import { setCachedCookies, extractHostname } from "@/lib/ytdlpCookiesCache" import { validateDownloadUrl } from "@smm/core/download-video-validators" -export interface VideoListItem { +interface VideoListItem { title: string artist: string url: string diff --git a/apps/ui/src/components/dialogs/index.ts b/apps/ui/src/components/dialogs/index.ts index 7ffae32c..80cd50cf 100644 --- a/apps/ui/src/components/dialogs/index.ts +++ b/apps/ui/src/components/dialogs/index.ts @@ -1,87 +1,33 @@ -export { AnonymousTelemetryConsentDialog } from "./AnonymousTelemetryConsentDialog" + export { ConfirmationDialog } from "./confirmation-dialog" export { SpinnerDialog } from "./spinner-dialog" export { ConfigDialog } from "./config-dialog" export { FilePickerDialog } from "./file-picker-dialog" -export { DownloadVideoDialog, DownloadVideoDialogContent } from "./download-video-dialog" -export { UIDownloadVideoDialogContent } from "./UIDownloadVideoDialogContent" +export { DownloadVideoDialog} from "./download-video-dialog" export { MediaSearchDialog } from "./media-search-dialog" export { RenameFileDialog } from "./rename-file-dialog" export { TextDialog } from "./text-dialog" export { RenameFolderDialog } from "./rename-folder-dialog" export { OpenFolderDialog } from "./open-folder-dialog" export { UIScrapeDialog } from "./UIScrapeDialog" -export { UIScrapeDialogTable } from "./UIScrapeDialogTable" export { useScrapeDialog } from "./useScrapeDialog" export { FormatConverterDialog } from "./format-converter-dialog" export { VideoCompressionDialog } from "./video-compression-dialog" export { DeleteTrackDialog } from "./delete-track-dialog" export { MediaFilePropertyDialog } from "./media-file-property-dialog" export { TranscribeDialog } from "./TranscribeDialog" -export { UITranscribeDialog } from "./UITranscribeDialog" export { SubtitleTranslationDialog } from "./SubtitleTranslationDialog" -export { UISubtitleTranslationDialog } from "./UISubtitleTranslationDialog" export { SynthesizeSubtitleDialog } from "./SynthesizeSubtitleDialog" -export { UISynthesizeSubtitleDialog } from "./UISynthesizeSubtitleDialog" export { ProcessPipelineDialog } from "./ProcessPipelineDialog" -export { UIProcessPipelineDialog } from "./UIProcessPipelineDialog" export { ExecuteCmdDialog } from "./ExecuteCmdDialog" export { AddTestBackgroundJobDialog } from "./AddTestBackgroundJobDialog" export { FunctionCheckDialog } from "./FunctionCheckDialog" export { LogDialog } from "./LogDialog" - -export type { AnonymousTelemetryConsentDialogProps } from "./AnonymousTelemetryConsentDialog" export type { DialogConfig, FolderType, FileItem, - Task, - ConfirmationDialogProps, - SpinnerDialogProps, - ConfigDialogProps, - FilePickerDialogProps, - DownloadVideoDialogProps, - MediaSearchDialogProps, - RenameFileDialogProps, - TextDialogProps, - RenameFolderDialogProps, - OpenFolderDialogProps, - UIScrapeDialogProps, - UseScrapeDialogInput, - UseScrapeDialogResult, - ScrapeTaskView, - ScrapeTaskId, - ScrapeTaskStatus, + TrackProperties, - MediaFilePropertyDialogProps, - FormatConverterDialogProps, - VideoCompressionDialogProps, - DeleteTrackDialogProps, - TranscribeAsrEngine, - TranscribeProvider, - TranscribeOutputFormat, - TranscribeDialogConfirmPayload, - TranscribeDialogRow, - TranscribeDialogProps, - UITranscribeDialogProps, - SubtitleTranslationDialogRow, - SubtitleTranslationConfirmPayload, - SubtitleTranslateTranslator, - SubtitleTranslateLayout, - SubtitleTranslationDialogProps, - UISubtitleTranslationDialogProps, - SynthesizeSubtitleDialogRow, - SynthesizeSubtitleConfirmPayload, - SynthesizeSubtitleDialogProps, - UISynthesizeSubtitleDialogProps, - ProcessPipelineDialogRow, - ProcessPipelineConfirmPayload, - ProcessPipelineDialogProps, - UIProcessPipelineDialogProps, - ExecuteCmdDialogProps, - ExecuteCmdLogEntry, - ExecuteCmdType, - AddTestBackgroundJobDialogProps, -} from "./types" -export type { UIDownloadVideoDialogContentProps } from "./UIDownloadVideoDialogContent" + ExecuteCmdType} from "./types" diff --git a/apps/ui/src/components/dialogs/media-file-property-dialog.tsx b/apps/ui/src/components/dialogs/media-file-property-dialog.tsx index 52ea8e7d..6849054f 100644 --- a/apps/ui/src/components/dialogs/media-file-property-dialog.tsx +++ b/apps/ui/src/components/dialogs/media-file-property-dialog.tsx @@ -39,7 +39,7 @@ import { useFailedCommandLogsStore } from "@/stores/failedCommandLogsStore" import { useJobManager } from "@/hooks/useJobManager" import { buildFfmpegWriteTagsJob } from "@/lib/ffmpegWriteTagsJobFactory" -export interface TrackProperties { +interface TrackProperties { id: number title?: string artist?: string diff --git a/apps/ui/src/components/dialogs/types/index.ts b/apps/ui/src/components/dialogs/types/index.ts index 20406c6c..57337283 100644 --- a/apps/ui/src/components/dialogs/types/index.ts +++ b/apps/ui/src/components/dialogs/types/index.ts @@ -75,22 +75,6 @@ export interface OpenFolderDialogProps { folderPath?: string } -export interface DeleteTrackDialogProps { - /** File path relative to the media folder when possible. */ - displayPath: string - onConfirm: () => void - onCancel: () => void -} - -export interface Task { - name: string - status: "pending" | "running" | "completed" | "failed" - subTasks?: Task[] -} - -export type { ScrapeTaskId, ScrapeTaskStatus, ScrapeTaskView } from "@/lib/scrapeDialog" -export type { UseScrapeDialogInput, UseScrapeDialogResult } from "../useScrapeDialog" - export interface UIScrapeDialogProps { isOpen: boolean onClose: () => void @@ -176,7 +160,7 @@ export type SubtitleTranslateTranslator = "bing" | "google" | "llm" export type SubtitleTranslateLayout = "target-above" | "source-above" | "target-only" | "source-only" /** Known i18n keys for ineligible subtitle translation rows (`components` namespace). */ -export type SubtitleTranslationDisabledReasonKey = "subtitleTranslationDialog.noSubtitleFile" +type SubtitleTranslationDisabledReasonKey = "subtitleTranslationDialog.noSubtitleFile" export interface SubtitleTranslationDialogRow { id: string @@ -224,7 +208,7 @@ export interface UISubtitleTranslationDialogProps { export type SubtitleTranslationDialogProps = Omit /** Known i18n keys for ineligible synthesize rows (`components` namespace). */ -export type SynthesizeSubtitleDisabledReasonKey = +type SynthesizeSubtitleDisabledReasonKey = | "synthesizeSubtitleDialog.noSubtitleFile" | "synthesizeSubtitleDialog.notVideoFile" @@ -273,7 +257,7 @@ export interface UISynthesizeSubtitleDialogProps { export type SynthesizeSubtitleDialogProps = Omit /** Rows for {@link UIProcessPipelineDialog} / {@link ProcessPipelineDialog}. */ -export type ProcessPipelineDisabledReasonKey = "processPipelineDialog.noMediaPath" +type ProcessPipelineDisabledReasonKey = "processPipelineDialog.noMediaPath" export interface ProcessPipelineDialogRow { id: string @@ -335,15 +319,6 @@ export interface TrackProperties { path?: string } -export interface MediaFilePropertyDialogProps { - isOpen: boolean - onClose: () => void - /** Absolute file path for reading / writing media tags. */ - filePath: string - /** Optional track metadata for read-only property display. */ - track?: TrackProperties -} - export interface FormatConverterDialogProps { isOpen: boolean onClose: () => void diff --git a/apps/ui/src/components/episode-file.tsx b/apps/ui/src/components/episode-file.tsx deleted file mode 100644 index 3367195e..00000000 --- a/apps/ui/src/components/episode-file.tsx +++ /dev/null @@ -1,268 +0,0 @@ -import { XCircle } from "lucide-react" -import type { LucideIcon } from "lucide-react" -import { cn } from "@/lib/utils" -import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu" -import { askForRenameFile } from "@/lib/dialogRequestEvents" -import { useUIMediaFolderStoreState } from "@/stores/uiMediaFolderStore"; -import { Path } from "@smm/utils/path" -import { relative, join, basename, dirname, extname } from "@/lib/path" -import { renameFiles } from "@/api/renameFiles" -import { toast } from "sonner" -import type { FileProps } from "@/lib/types" -import { useTranslation } from "@/lib/i18n" -import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" -import { useMediaMetadataQuery } from "@/hooks/mediaMetadata/useMediaMetadataQuery"; -import { useMediaFolderFilesQuery } from "@/hooks/useMediaFolderFilesQuery"; - -interface EpisodeFileProps { - file: FileProps - icon: LucideIcon - label?: string - iconColor?: string - isPreviewingForRename: boolean - /** True when user is reviewing match between local video file and episode; UI highlights the video file path. */ - isPreviewingForRecognize?: boolean - showRenameMenu?: boolean - /** - * Callback when "Select File" is clicked from context menu - */ - onFileSelectButtonClick?: (file: FileProps) => void -} - -/** - * @deprecated This method cannot handle associated files in season folders. - * If video file is expected to `Season 01/S01E01.mp4` - * And subtitle should be renamed to `Season 01/S01E01.en.srt` - * But this method return `S01E01.en.srt`, missing season folder on the front - * - * - * Computes rename entries for all files that share the same stem as the video - * being renamed. Operates directly on the raw file list from media metadata so - * it is not affected by how the UI has classified/grouped those files. - * - * Example: renaming "S01E01.mkv" → "Show.S01E01.Title.mkv" will also rename - * "S01E01.srt" → "Show.S01E01.Title.srt" and "S01E01.en.srt" → "Show.S01E01.Title.en.srt" - * - * @param videoOldPath Absolute POSIX path of the video before rename - * @param videoNewPath Absolute POSIX path of the video after rename - * @param allMediaFiles All file paths in the media folder (from MediaMetadata.files) - */ -// eslint-disable-next-line react-refresh/only-export-components -export function computeAssociatedFileRenames( - videoOldPath: string, - videoNewPath: string, - allMediaFiles: string[] -): Array<{ from: string; to: string }> { - const oldBasename = basename(videoOldPath) ?? '' - const oldExt = extname(oldBasename) - const oldStem = oldBasename.slice(0, oldBasename.length - oldExt.length) - - const newBasename = basename(videoNewPath) ?? '' - const newExt = extname(newBasename) - const newStem = newBasename.slice(0, newBasename.length - newExt.length) - - if (!oldStem || !newStem || oldStem === newStem) return [] - - const renames: Array<{ from: string; to: string }> = [] - for (const filePath of allMediaFiles) { - if (filePath === videoOldPath) continue // skip the video file itself - - const assocBasename = basename(filePath) ?? '' - // Match files whose name is exactly the old stem or starts with "oldStem." - // e.g. "S01E01.srt", "S01E01.en.srt", "S01E01.ass" - if (assocBasename === oldStem || assocBasename.startsWith(oldStem + '.')) { - const suffix = assocBasename.slice(oldStem.length) // e.g. ".en.srt" or ".srt" - const newAssocBasename = newStem + suffix - const assocDir = dirname(filePath) - const newAssocPath = join(assocDir, newAssocBasename) - renames.push({ from: filePath, to: newAssocPath }) - } - } - return renames -} - -// Helper function to get relative path from media folder -function getRelativePath(mediaFolderPath: string | undefined, filePath: string): string { - if (!mediaFolderPath) { - // Fallback to filename if media folder path is not available - const parts = filePath.split(/[/\\]/) - return parts[parts.length - 1] || filePath - } - - try { - return relative(mediaFolderPath, filePath) - } catch { - // If relative path calculation fails, fallback to filename - const parts = filePath.split(/[/\\]/) - return parts[parts.length - 1] || filePath - } -} - -export function EpisodeFile({ - file, - icon: Icon, - iconColor = "text-muted-foreground", - isPreviewingForRename, - isPreviewingForRecognize = false, - showRenameMenu = false, - onFileSelectButtonClick, -}: EpisodeFileProps) { - const { t } = useTranslation(['components', 'dialogs']) - const { selectedFolder } = useUIMediaFolderStoreState() - const { data: selectedMediaMetadata } = useMediaMetadataQuery(selectedFolder || undefined) - const { data: allMediaFiles = [] } = useMediaFolderFilesQuery(selectedFolder || undefined) - const { mutate: fetchMediaMetadata } = useFetchMediaMetadataMutation(); - const mediaFolderPath = selectedMediaMetadata?.mediaFolderPath - const relativePath = getRelativePath(mediaFolderPath, file.path) - const newRelativePath = file.newPath ? getRelativePath(mediaFolderPath, file.newPath) : null - const hasPreview = isPreviewingForRename && file.newPath - const isDeleted = file.isDeleted ?? false - const highlightPath = isPreviewingForRecognize && !hasPreview - - // Debug logging - if (!relativePath || relativePath === file.path) { - console.log('[EpisodeFile] Path calculation issue:', { - filePath: file.path, - mediaFolderPath, - relativePath, - calculated: getRelativePath(mediaFolderPath, file.path) - }) - } - - const fileContent = ( -
- -
- {hasPreview ? ( -
-
- Preview - {isDeleted && ( - Deleted - )} -
-

- {relativePath} -

-

- {newRelativePath} -

-
- ) : ( -
-

- {relativePath} -

- {isDeleted && ( - - )} -
- )} -
-
- ) - - if (showRenameMenu) { - return ( - - - {fileContent} - - - { - if (!file.path || file.isDeleted) return - - // Calculate relative path from media folder - const mediaFolderPath = selectedMediaMetadata?.mediaFolderPath - let relativePath: string - - if (mediaFolderPath) { - try { - relativePath = relative(mediaFolderPath, file.path) - } catch { - // If relative path calculation fails, use absolute path - relativePath = file.path - } - } else { - relativePath = file.path - } - - askForRenameFile( - async (newRelativePath: string) => { - if (!selectedMediaMetadata?.mediaFolderPath || !file.path) { - console.error("Missing required paths for rename") - return - } - - try { - // Convert relative path to absolute path - const newAbsolutePath = join(selectedMediaMetadata.mediaFolderPath, newRelativePath) - - // All files in the media folder (absolute POSIX paths from metadata) - const assocRenames = computeAssociatedFileRenames(file.path, newAbsolutePath, allMediaFiles) - - // Call renameFiles API with video + all associated files in one batch - await renameFiles({ - files: [ - { from: file.path, to: newAbsolutePath }, - ...assocRenames, - ], - mediaFolder: Path.posix(selectedMediaMetadata.mediaFolderPath), - }) - - // Refresh media metadata to reflect the rename - fetchMediaMetadata({ path: selectedMediaMetadata.mediaFolderPath }) - - console.log("File renamed successfully:", file.path, "->", newAbsolutePath) - if (assocRenames.length > 0) { - console.log("Associated files renamed:", assocRenames) - } - toast.success(t('episodeFile.renameSuccess', { ns: 'components' })) - } catch (error) { - console.error("Failed to rename file:", error) - const errorMessage = error instanceof Error ? error.message : t('episodeFile.renameFailed', { ns: 'components' }) - toast.error(t('episodeFile.renameFailed', { ns: 'components' }), { - description: errorMessage - }) - throw error // Re-throw to let dialog handle it - } - }, - { - initialValue: relativePath, - title: t('dialogs:rename.title'), - description: t('dialogs:rename.fileDescription') - } - ) - }} - > - {t('episodeFile.rename', { ns: 'components' })} - - { - if (onFileSelectButtonClick) { - onFileSelectButtonClick(file) - } - }} - > - {t('episodeFile.selectFile', { ns: 'components' })} - - - - ) - } - - return fileContent -} - diff --git a/apps/ui/src/components/episode-section.tsx b/apps/ui/src/components/episode-section.tsx deleted file mode 100644 index 98063a4f..00000000 --- a/apps/ui/src/components/episode-section.tsx +++ /dev/null @@ -1,390 +0,0 @@ -import { useMediaFolderFilesQuery } from "@/hooks/useMediaFolderFilesQuery" -import type { TMDBTVShowDetails } from "@smm/types" -import { ChevronDown, Play, FileVideo, FileText, Music, Image as ImageIcon, Star, XCircle } from "lucide-react" -import { cn } from "@/lib/utils" -import type { FileProps } from "@/lib/types" -import { EpisodeFile } from "./episode-file" -import { useUIMediaFolderStoreState } from "@/stores/uiMediaFolderStore" -import { useMediaMetadataQuery } from "@/hooks/mediaMetadata" -import { relative } from "@/lib/path" -import { useMemo, useCallback } from "react" -import React from "react" -import { useTranslation } from "@/lib/i18n" -import type { TFunction } from "i18next" - -// Helper function to format date -function formatDate(dateString: string, t: TFunction): string { - if (!dateString) return t("episodeSection.notAvailable") - try { - const date = new Date(dateString) - return date.toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric" - }) - } catch { - return dateString - } -} - -// Helper function to get TMDB image URL -function getTMDBImageUrl(path: string | null, size: "w200" | "w300" | "w500" | "w780" | "original" = "w500"): string | null { - if (!path) return null - const baseUrl = "https://image.tmdb.org/t/p" - return `${baseUrl}/${size}${path}` -} - -// Helper function to get file icon based on extension -function getFileIcon(path: string) { - const ext = path.split('.').pop()?.toLowerCase() - if (['srt', 'vtt', 'ass', 'ssa'].includes(ext || '')) return FileText - if (['mp3', 'aac', 'flac', 'wav'].includes(ext || '')) return Music - if (['jpg', 'jpeg', 'png', 'webp'].includes(ext || '')) return ImageIcon - return FileVideo -} - -// Helper function to get icon and label for file type -function getFileTypeConfig(type: FileProps['type'], t: TFunction): { icon: typeof FileVideo, label: string, iconColor: string, bgColor: string } { - switch (type) { - case "video": - return { icon: FileVideo, label: t("episodeSection.fileTypes.video"), iconColor: "text-primary", bgColor: "bg-primary/10" } - case "subtitle": - return { icon: FileText, label: t("episodeSection.fileTypes.subtitle"), iconColor: "text-blue-600 dark:text-blue-400", bgColor: "bg-blue-50 dark:bg-blue-950/30" } - case "audio": - return { icon: Music, label: t("episodeSection.fileTypes.audio"), iconColor: "text-green-600 dark:text-green-400", bgColor: "bg-green-50 dark:bg-green-950/30" } - case "nfo": - return { icon: FileText, label: t("episodeSection.fileTypes.nfo"), iconColor: "text-muted-foreground", bgColor: "bg-muted/50" } - case "poster": - return { icon: ImageIcon, label: t("episodeSection.fileTypes.poster"), iconColor: "text-muted-foreground", bgColor: "bg-muted/50" } - case "file": - default: - return { icon: FileVideo, label: t("episodeSection.fileTypes.file"), iconColor: "text-muted-foreground", bgColor: "bg-muted/50" } - } -} - -// Helper function to get relative path from media folder -function getRelativePath(mediaFolderPath: string | undefined, filePath: string): string { - if (!mediaFolderPath) { - // Fallback to filename if media folder path is not available - const parts = filePath.split(/[/\\]/) - return parts[parts.length - 1] || filePath - } - - try { - return relative(mediaFolderPath, filePath) - } catch { - // If relative path calculation fails, fallback to filename - const parts = filePath.split(/[/\\]/) - return parts[parts.length - 1] || filePath - } -} - - - -export interface EpisodeSectionProps { - episode: NonNullable[number]['episodes']>[number] - expandedEpisodeIds: Set - setExpandedEpisodeIds: React.Dispatch>> - /** - * files of the episode, which holds: - * * video file - * * subtitle files - * * audio files - * * nfo files - * * poster files - * * other files that may supported in the future - */ - files: FileProps[] - - /** - * If true, the episode section display the new path which user to preview - */ - isPreviewingForRename: boolean - /** - * True when user is reviewing match between local video file and episode; UI should highlight the video file path. - */ - isPreviewingForRecognize?: boolean - /** - * Optional episode ID to scroll to (for programmatic scrolling) - */ - scrollToEpisodeId?: number | null - /** - * Callback to handle file selection for this episode - * @param episode - The episode to select file for - * @param file - The file that was clicked (for context menu) or undefined (for no-files area) - */ - onEpisodeFileSelect?: (episode: NonNullable[number]['episodes']>[number], file?: { path: string; isDirectory?: boolean }) => void -} - -export function EpisodeSection({ - episode, - expandedEpisodeIds, - setExpandedEpisodeIds, - files, - isPreviewingForRename, - isPreviewingForRecognize = false, - onEpisodeFileSelect, -}: EpisodeSectionProps) { - const { t } = useTranslation(['components']) - const { selectedFolder } = useUIMediaFolderStoreState() - const { data: selectedMediaMetadata } = useMediaMetadataQuery(selectedFolder || undefined) - const { data: localFiles = [] } = useMediaFolderFilesQuery(selectedFolder || undefined) - const episodeStillUrl = getTMDBImageUrl(episode.still_path, "w300") - const isEpisodeExpanded = expandedEpisodeIds.has(episode.id) - - // Handle click on "noFiles" div to open file picker - const handleNoFilesClick = useCallback(() => { - // Don't allow file selection in preview mode - if (isPreviewingForRename) { - return - } - - // Call the handler passed from parent component - if (onEpisodeFileSelect) { - onEpisodeFileSelect(episode) - } - }, [isPreviewingForRename, onEpisodeFileSelect, episode]) - - // Group files by type - const filesByType = useMemo(() => { - const grouped = new Map() - files.forEach(file => { - const existing = grouped.get(file.type) || [] - grouped.set(file.type, [...existing, file]) - }) - return grouped - }, [files]) - - // Convert Map to array of entries for rendering - const filesByTypeArray = useMemo(() => { - - // TODO: unable to calculate in preview match episode mode - - if(selectedMediaMetadata === undefined || selectedMediaMetadata === null) { - return []; - } - - if (localFiles.length === 0) { - return []; - } - - // Create a Set for fast lookup of existing files - const localFilesSet = new Set(localFiles); - - // Convert Map to array and mark files as deleted if they don't exist in localFiles - const result = Array.from(filesByType.entries()).map(([type, typeFiles]) => { - const markedFiles = typeFiles.map(file => { - // Check if file path exists in localFiles - if (!localFilesSet.has(file.path)) { - return { - ...file, - isDeleted: true - }; - } - return file; - }); - return [type, markedFiles] as [FileProps['type'], FileProps[]]; - }); - - return result; - }, [filesByType, selectedMediaMetadata, localFiles]) - - return ( -
-
{ - if (isPreviewingForRename) return // Don't allow collapse in preview mode - setExpandedEpisodeIds(prev => { - const newSet = new Set(prev) - if (isEpisodeExpanded) { - newSet.delete(episode.id) - } else { - newSet.add(episode.id) - } - return newSet - }) - }} - className="flex gap-3 p-3 hover:bg-accent/50 transition-colors cursor-pointer" - > - {episodeStillUrl ? ( -
- {episode.name} { - const target = e.target as HTMLImageElement - target.style.display = "none" - }} - /> -
- ) : ( -
- -
- )} -
-
-
- - E{episode.episode_number.toString().padStart(2, '0')} - -

- {episode.name} -

-
-
- {episode.vote_average > 0 && ( -
- - - {episode.vote_average.toFixed(1)} - -
- )} - -
-
- {episode.air_date && ( -

- {formatDate(episode.air_date, t)} -

- )} - {episode.runtime > 0 && ( -

- {episode.runtime} {t("episodeSection.minutes")} -

- )} - {episode.overview && ( -

- {episode.overview} -

- )} -
-
- - {/* Files - Expandable */} - {isEpisodeExpanded && ( -
- {files.length === 0 ? ( -
- {t("episodeSection.noFiles")} -
- ) : ( -
- {/* Video Files - Main (Emphasized) */} - {filesByTypeArray.map(([type, typeFiles]) => { - if (type !== "video") return null - const { icon: TypeIcon, iconColor } = getFileTypeConfig(type, t) - - return typeFiles.map((file, index) => ( - { - if (onEpisodeFileSelect) { - onEpisodeFileSelect(episode, file) - } - }} - /> - )) - })} - - {/* Associated Files - Subtitle, Audio, NFO, Poster, etc. (Subtle) */} - {filesByTypeArray.map(([type, typeFiles]) => { - if (type === "video") return null - const { label, iconColor } = getFileTypeConfig(type, t) - const mediaFolderPath = selectedMediaMetadata?.mediaFolderPath - - return typeFiles.map((file, index) => { - const Icon = getFileIcon(file.path) - const relativePath = getRelativePath(mediaFolderPath, file.path) - const newRelativePath = file.newPath ? getRelativePath(mediaFolderPath, file.newPath) : null - const hasPreview = isPreviewingForRename && file.newPath - const isDeleted = file.isDeleted ?? false - - return ( -
- -
- {hasPreview ? ( -
-

- {relativePath} -

-

- {newRelativePath} -

-
- ) : ( -
-

- {relativePath} -

- {isDeleted && ( - - )} -
- )} -
-
- - {label} - - {isDeleted && ( - - {t("episodeSection.deleted")} - - )} -
-
- ) - }) - })} -
- )} -
- )} -
- ) -} - diff --git a/apps/ui/src/components/language-switcher.tsx b/apps/ui/src/components/language-switcher.tsx deleted file mode 100644 index 407c762c..00000000 --- a/apps/ui/src/components/language-switcher.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Check } from "lucide-react" - -import { Button } from "@/components/ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { useConfig } from "@/hooks/userConfig" -import { useResolvedLanguages } from "@/hooks/useResolvedLanguages" -import { SUPPORTED_APP_LANGUAGES, changeLanguage, type SupportedLanguage } from "@/lib/i18n" -import { nextTraceId } from "@/lib/utils" - -export function LanguageSwitcher() { - const { userConfig, setAndSaveUserConfig } = useConfig() - const { appLanguage: currentLanguage } = useResolvedLanguages() - - const handleLanguageChange = async (lang: SupportedLanguage) => { - await changeLanguage(lang) - // Update user config - const traceId = `LanguageSwitcher-${nextTraceId()}` - setAndSaveUserConfig(traceId, { - ...userConfig, - applicationLanguage: lang, - }) - } - - return ( - - - - - - {SUPPORTED_APP_LANGUAGES.map((lang) => ( - handleLanguageChange(lang.code)} - className="flex items-center justify-between" - > - {lang.name} - {currentLanguage === lang.code && ( - - )} - - ))} - - - ) -} - diff --git a/apps/ui/src/components/media-folder-content.tsx b/apps/ui/src/components/media-folder-content.tsx deleted file mode 100644 index 62d36a75..00000000 --- a/apps/ui/src/components/media-folder-content.tsx +++ /dev/null @@ -1,9 +0,0 @@ -function MediaFolderContent() { - return ( -
-

Media Folder Content

-
- ) -} - -export default MediaFolderContent \ No newline at end of file diff --git a/apps/ui/src/components/media/MediaFileTableRow.tsx b/apps/ui/src/components/media/MediaFileTableRow.tsx index d483814a..dce5270a 100644 --- a/apps/ui/src/components/media/MediaFileTableRow.tsx +++ b/apps/ui/src/components/media/MediaFileTableRow.tsx @@ -1,8 +1,7 @@ -import { Spinner } from "@/components/ui/spinner" -import { isAbsPath, join, relative } from "@/lib/path" +import { isAbsPath, join } from "@/lib/path" import { Path } from "@smm/utils/path" import { pathToFileURL } from "@smm/utils/url" -import { TableBody, TableCell } from "@/components/ui/table" +import { TableCell } from "@/components/ui/table" import { ContextMenu, ContextMenuContent, @@ -18,10 +17,8 @@ import type { MediaFileTableEpisodeData, UIMediaFileDataRow, UIMediaFileFolderRow, - UIMediaFileTableContextMenuConfig, } from "./UIMediaFileTable" import { - MediaFileTableColGroup, MediaFileTableRowCells, buildMediaFileTableColumnLayout, type MediaFileTableColumnLayout, @@ -29,32 +26,6 @@ import { export type MediaFileTableColumnKey = "video" | "thumbnail" | "subtitle" | "nfo" -/** Layout/column context shared by folder-file and episode rows. */ -export interface MediaFileTableRowContext { - mediaFolderPath?: string - contextMenuConfig?: UIMediaFileTableContextMenuConfig - preview?: "rename" | "recognize" - previewStatus?: "loading" | "ok" - layout: "simple" | "detail" | "preview" - onCheck?: (row: UIMediaFileDataRow, checked: boolean) => void - /** Whether the given episode row is currently checked (selection membership). */ - isSelected: (row: UIMediaFileDataRow) => boolean - renderPreviewContent?: (row: UIMediaFileDataRow) => ReactNode - onDoubleClick?: (row: UIMediaFileDataRow | UIMediaFileFolderRow) => void - isSimpleLayout: boolean - isPreviewLayout: boolean - showThumbnailColumn: boolean - showIdColumn: boolean - showCheckboxColumn: boolean - columnVisibility: Record - columnLayout: MediaFileTableColumnLayout - t: (key: string, options?: Record) => string -} - -export type MediaFileTableBodyRow = - | { row: UIMediaFileFolderRow; index: number } - | { row: UIMediaFileDataRow; index: number } - function resolveDisabled( rule: boolean | ((row: R) => boolean) | undefined, row: R, @@ -64,16 +35,7 @@ function resolveDisabled } -export function getMediaFileTableRowKey( - row: UIMediaFileFolderRow | UIMediaFileDataRow, - index: number, -): string { - if (row.type === "folderFile") return `${row.id}-${index}` - return `${row.season}-${row.episode}-${index}` -} - /** * Native `` with the same styling as shadcn `TableRow`, plus ref forwarding * for Radix `ContextMenuTrigger asChild`. Kept in the media module so shadcn @@ -143,95 +97,7 @@ export const MediaFileTableTr = forwardRef< ) }) -export function withContextMenu( - rowKey: string, - row: R, - items: Array<{ id: string; label: string; onClick?: (row: R) => void; disabled?: boolean | ((row: R) => boolean) }>, - inner: ReactNode, -): ReactNode { - const hasMenu = items.some((item) => item.onClick) - if (!hasMenu) return inner - return ( - - {inner} - - {items.map((item) => { - if (!item.onClick) return null - return ( - item.onClick?.(row)} - > - {item.label} - - ) - })} - - - ) -} - -export function MediaFileTableFolderFileRow({ - ctx, - row, - index, -}: { - ctx: MediaFileTableRowContext - row: UIMediaFileFolderRow - index: number -}) { - const rowKey = getMediaFileTableRowKey(row, index) - const displayPath = getDisplayPath(row.path, ctx.mediaFolderPath) - const { columnLayout } = ctx - - const videoContent = - columnLayout.isSimpleLayout ? ( - {displayPath} - ) : columnLayout.isPreviewLayout || columnLayout.layout === "detail" ? ( -
-
- {row.id} -
-
- {displayPath} -
-
- ) : ( -
- {displayPath} -
- ) - - const emptyIconCell = columnLayout.isSimpleLayout ? ( - - ) : ( - - - ) - - const inner = ( - ctx.onDoubleClick?.(row) : undefined} - > - } - nfoContent={} - /> - - ) - - return withContextMenu( - rowKey, - row, - ctx.contextMenuConfig?.folderFileRowItems ?? [], - inner, - ) -} /** * Name/value row whose cells are rendered exactly like @@ -284,359 +150,6 @@ const nameValueRowSimpleLayout: MediaFileTableColumnLayout = buildMediaFileTable columnVisibility: { video: true, thumbnail: true, subtitle: true, nfo: true }, }) -function renderEpisodeSimpleVideoContent( - ctx: MediaFileTableRowContext, - row: UIMediaFileDataRow, - isRowDisabled: boolean, -): ReactNode { - if (row.videoFile) { - if (ctx.preview === "rename" && !row.newVideoFile && ctx.isSelected(row)) { - return ( -
- {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} -
- ) - } - if ( - ctx.preview === "rename" && - row.newVideoFile && - row.videoFile !== row.newVideoFile - ) { - return ( -
-
- {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} -
-
- {getDisplayPath(row.newVideoFile, ctx.mediaFolderPath)} -
-
- ) - } - return ( -
- {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} -
- ) - } - if (ctx.preview === "recognize") { - return ctx.previewStatus === "loading" ? ( - - - - ) : ( - - {ctx.t("mediaFileTable.unrecognizedVideoFile", { - defaultValue: "Cannot recognize video file", - })} - - ) - } - return - -} - -function renderEpisodeThumbnailContent( - ctx: MediaFileTableRowContext, - row: UIMediaFileDataRow, -): ReactNode { - const { columnLayout } = ctx - - if (columnLayout.isPreviewLayout) { - return row.thumbnail ? ( - - ) : ( - - - ) - } - - if (columnLayout.layout === "detail") { - return row.thumbnail ? ( - - ) : ( - - - ) - } - - if (!row.thumbnail) { - return - } - - return ( - - -
- -
-
- - - -
- ) -} - -function renderEpisodeDetailVideoContent( - ctx: MediaFileTableRowContext, - row: UIMediaFileDataRow, - isRowDisabled: boolean, -): ReactNode { - const { columnLayout } = ctx - - if (columnLayout.isPreviewLayout) { - return ( -
-
- {`S${String(row.season).padStart(2, "0")}E${String(row.episode).padStart(2, "0")}`}{" "} - {row.episodeTitle ? `· ${row.episodeTitle}` : ""} -
- {row.videoFile ? ( - <> -
- {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} -
- {!isRowDisabled && ctx.renderPreviewContent?.(row)} - - ) : ( - - - )} -
- ) - } - - if (columnLayout.layout === "detail") { - return ( -
-
- {row.episodeTitle || - `S${String(row.season).padStart(2, "0")}E${String(row.episode).padStart(2, "0")}` || - "-"} -
- {row.videoFile ? ( - ctx.preview === "rename" && - row.newVideoFile && - row.videoFile !== row.newVideoFile ? ( - <> -
- {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} -
-
- {getDisplayPath(row.newVideoFile, ctx.mediaFolderPath)} -
- - ) : ( -
- {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} -
- ) - ) : ( - - - )} -
- ) - } - - if (row.videoFile) { - if ( - ctx.preview === "rename" && - row.newVideoFile && - row.videoFile !== row.newVideoFile - ) { - return ( -
-
- {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} -
-
- {getDisplayPath(row.newVideoFile, ctx.mediaFolderPath)} -
-
- ) - } - return ( -
- {getDisplayPath(row.videoFile, ctx.mediaFolderPath)} -
- ) - } - - return - -} - -function MediaFileTableEpisodeRow({ - ctx, - row, - index, -}: { - ctx: MediaFileTableRowContext - row: UIMediaFileDataRow - index: number -}) { - const isRowDisabled = row.disabled === true - const rowKey = getMediaFileTableRowKey(row, index) - - const inner = ( - ctx.onDoubleClick?.(row) : undefined} - > - { - if (isRowDisabled) return - ctx.onCheck?.(row, e.target.checked) - }} - /> - } - videoContent={ - ctx.columnLayout.isSimpleLayout - ? renderEpisodeSimpleVideoContent(ctx, row, isRowDisabled) - : renderEpisodeDetailVideoContent(ctx, row, isRowDisabled) - } - thumbnailContent={renderEpisodeThumbnailContent(ctx, row)} - subtitleContent={} - nfoContent={} - /> - - ) - - return withContextMenu( - rowKey, - row, - ctx.contextMenuConfig?.dataRowItems ?? [], - inner, - ) -} - -/** - * Renders a folder-file or episode row with shared table chrome (border, hover). - */ -export function MediaFileTableRow({ - ctx, - row, - index, -}: { - ctx: MediaFileTableRowContext - row: UIMediaFileFolderRow | UIMediaFileDataRow - index: number -}) { - if (row.type === "folderFile") { - return - } - return -} - -/** - * Renders multiple data rows inside one ``. - * Used for standalone folder files and collapsible section content alike. - */ -export function MediaFileTableRowsBody({ - ctx, - rows, - className, - preserveLastRowBorder = false, -}: { - ctx: MediaFileTableRowContext - rows: MediaFileTableBodyRow[] - className?: string - /** Keep bottom border on the last row (for nested section tables). */ - preserveLastRowBorder?: boolean -}) { - return ( - - {rows.map(({ row, index }) => ( - - ))} - - ) -} - -/** - * Nested table used inside collapsible sections so column widths match the outer table. - */ -export function MediaFileTableSectionRows({ - columnLayout, - ctx, - rows, -}: { - columnLayout: MediaFileTableColumnLayout - ctx: MediaFileTableRowContext - rows: MediaFileTableBodyRow[] -}) { - return ( - - - -
- ) -} - /** * A single item in an episode row's right-click menu. * Callbacks receive the episode's data (`MediaFileTableEpisodeData`). diff --git a/apps/ui/src/components/media/UIMediaFileTable.tsx b/apps/ui/src/components/media/UIMediaFileTable.tsx index 5c5f806d..d7ea163d 100644 --- a/apps/ui/src/components/media/UIMediaFileTable.tsx +++ b/apps/ui/src/components/media/UIMediaFileTable.tsx @@ -60,7 +60,7 @@ export interface UIMediaFileDataRow { disabled?: boolean } -export type FolderFileId = "clearlogo" | "fanart" | "poster" | "theme" | "nfo" +type FolderFileId = "clearlogo" | "fanart" | "poster" | "theme" | "nfo" /** A folder-level asset (poster, fanart, nfo, etc.) — not playable. */ export interface UIMediaFileFolderRow { @@ -90,7 +90,7 @@ export type UIMediaFileTableRow = UIMediaFileDividerRow | UIMediaFileDataRow | U // ======================================================================== /** A single context menu item for data rows (type === "episode"). */ -export interface UIMediaFileDataContextMenuItem { +interface UIMediaFileDataContextMenuItem { /** Unique id. */ id: string /** Display label (already translated). */ @@ -102,7 +102,7 @@ export interface UIMediaFileDataContextMenuItem { } /** A single context menu item for folder file rows (type === "folderFile"). */ -export interface UIMediaFileFolderContextMenuItem { +interface UIMediaFileFolderContextMenuItem { id: string label: string onClick?: (row: UIMediaFileFolderRow) => void @@ -390,27 +390,6 @@ export function UIMediaFileTable(props: UIMediaFileTableProps) { // Season / episode content blocks (moved to MediaFileTableBlocks) // ======================================================================== -export { - UIMediaFileTableSeasonBlock, - UIMediaFileTableEpisodeBlock, - UIMediaFileTableEpisodeDetailBlock, - UIMediaFileTableEpisodePreviewBlock, - type UIMediaFileTableEpisodeBlockProps, -} from "./MediaFileTableBlocks" - -export { - MediaFileTableSimpleLayout, - type MediaFileTableSimpleLayoutProps, -} from "./MediaFileTableSimpleLayout" -export { - MediaFileTableDetailLayout, - type MediaFileTableDetailLayoutProps, -} from "./MediaFileTableDetailLayout" -export { - MediaFileTablePreviewLayout, - type MediaFileTablePreviewLayoutProps, -} from "./MediaFileTablePreviewLayout" - /** * Adapts the deprecated `UIMediaFileDataRow`-based episode menu items * (`UIMediaFileTableContextMenuConfig.dataRowItems`) to the new diff --git a/apps/ui/src/components/media/mediaFileTableColumns.tsx b/apps/ui/src/components/media/mediaFileTableColumns.tsx index eca7cc54..34445cc4 100644 --- a/apps/ui/src/components/media/mediaFileTableColumns.tsx +++ b/apps/ui/src/components/media/mediaFileTableColumns.tsx @@ -14,48 +14,6 @@ export interface MediaFileTableColumnLayout { columnVisibility: Record } -const CHECKBOX_COL_CLASS = "w-10" -const ID_COL_CLASS = "w-[100px]" -const ICON_COL_CLASS = "w-10" -const THUMB_DETAIL_COL_CLASS = "w-[100px]" -const THUMB_PREVIEW_COL_CLASS = "w-[160px]" - -function Col({ className }: { className?: string }) { - return -} - -/** Shared `` for outer and nested tables (`table-fixed`). */ -export function MediaFileTableColGroup({ layout }: { layout: MediaFileTableColumnLayout }) { - return ( - - {layout.showCheckboxColumn && } - {layout.showIdColumn && } - {layout.isSimpleLayout ? ( - <> - {layout.columnVisibility.video && } - {layout.showThumbnailColumn && } - - ) : ( - <> - {layout.showThumbnailColumn && ( - - )} - {layout.columnVisibility.video && } - - )} - {layout.columnVisibility.subtitle && } - {layout.columnVisibility.nfo && } - - ) -} const idCellClassName = "px-2 py-1 font-mono w-[100px]" const checkboxCellClassName = "w-10 shrink-0 px-0 py-1 text-center align-middle" @@ -63,7 +21,7 @@ const checkboxCellEmptyClassName = "w-10 shrink-0 px-0 py-1" const videoCellClassName = "max-w-px px-2 py-1" const iconCellClassName = "w-10 shrink-0 px-0 py-1 text-center" -export function thumbnailCellClassName(layout: MediaFileTableColumnLayout): string { +function thumbnailCellClassName(layout: MediaFileTableColumnLayout): string { return cn( layout.isPreviewLayout && "w-[160px] min-w-[160px] px-1 py-1 align-top", layout.layout === "detail" && @@ -75,7 +33,7 @@ export function thumbnailCellClassName(layout: MediaFileTableColumnLayout): stri /** * Renders table cells in the same column order for folder-file and episode rows. - * Widths are enforced by `MediaFileTableColGroup`; cell classes mirror the header row. + * Column widths are fixed; cell classes mirror the header row. */ export function MediaFileTableRowCells({ layout, diff --git a/apps/ui/src/components/menu.tsx b/apps/ui/src/components/menu.tsx index be0bf4be..5abe818b 100644 --- a/apps/ui/src/components/menu.tsx +++ b/apps/ui/src/components/menu.tsx @@ -33,7 +33,7 @@ import { } from "@/types/eventTypes" import { writeFrontendLog } from "@/api/log" -export interface MenuItem { +interface MenuItem { name: string /** Unique identifier used for id and data-testid attributes */ id?: string @@ -45,18 +45,18 @@ export interface MenuItem { variant?: "default" | "destructive" } -export interface MenuSeparator { +interface MenuSeparator { type: "separator" } -export interface MenuCheckboxItem { +interface MenuCheckboxItem { type: "checkbox" name: string checked?: boolean onCheckedChange?: (checked: boolean) => void } -export interface MenuRadioGroup { +interface MenuRadioGroup { type: "radio-group" value?: string onValueChange?: (value: string) => void @@ -66,17 +66,17 @@ export interface MenuRadioGroup { }> } -export interface MenuSubmenu { +interface MenuSubmenu { type: "submenu" name: string items: MenuSubmenuItem[] } -export type MenuSubmenuItem = MenuItem | MenuSeparator +type MenuSubmenuItem = MenuItem | MenuSeparator -export type MenuContentItem = MenuItem | MenuSeparator | MenuCheckboxItem | MenuRadioGroup | MenuSubmenu +type MenuContentItem = MenuItem | MenuSeparator | MenuCheckboxItem | MenuRadioGroup | MenuSubmenu -export interface MenuTemplate { +interface MenuTemplate { label: string submenu: MenuContentItem[] } diff --git a/apps/ui/src/components/mobile/Navigation.tsx b/apps/ui/src/components/mobile/Navigation.tsx deleted file mode 100644 index 062a7584..00000000 --- a/apps/ui/src/components/mobile/Navigation.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { FolderListItem, type FolderListItemProps } from "@/components/sidebar/FolderListItem" -import { useTranslation } from "@/lib/i18n" - -export interface NavigationProps { - folders: FolderListItemProps[] - handleMediaFolderListItemClick: (path: string) => void -} - -export function Navigation({ - folders, - handleMediaFolderListItemClick, -}: NavigationProps) { - const { t } = useTranslation(["components"]) - return ( - <> - -
- {folders.length === 0 ? ( -
- {t("sidebar.emptyState")} -
- ) : ( - folders.map((folder) => ( -
- handleMediaFolderListItemClick(folder.path)} - /> -
- )) - )} -
- - ) -} - diff --git a/apps/ui/src/components/mode-toggle.tsx b/apps/ui/src/components/mode-toggle.tsx deleted file mode 100644 index 2e98d1d3..00000000 --- a/apps/ui/src/components/mode-toggle.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Moon, Sun } from "lucide-react" - -import { Button } from "@/components/ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { useTheme } from "@/providers/theme-provider" - -export function ModeToggle() { - const { setTheme } = useTheme() - - return ( - - - - - - setTheme("light")}> - Light - - setTheme("dark")}> - Dark - - setTheme("system")}> - System - - - - ) -} - diff --git a/apps/ui/src/components/movie/MoviePanel.tsx b/apps/ui/src/components/movie/MoviePanel.tsx index 95afe218..e58a0f63 100644 --- a/apps/ui/src/components/movie/MoviePanel.tsx +++ b/apps/ui/src/components/movie/MoviePanel.tsx @@ -41,7 +41,6 @@ import { useSubtitleFlow } from "@/hooks/useSubtitleFlow" import { useTranslation } from "react-i18next" import Debug from 'debug' const debug = Debug('MoviePanel') -export type { MovieFileModel } from "@/helpers/movie/buildMovieFilesFromMediaMetadata" interface ToolbarOption { value: "plex" | "emby", diff --git a/apps/ui/src/components/movie/movie-files-section.tsx b/apps/ui/src/components/movie/movie-files-section.tsx deleted file mode 100644 index 606b61b0..00000000 --- a/apps/ui/src/components/movie/movie-files-section.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import type { TMDBMovie } from "@smm/types" -import { FileVideo, FileText, Music, Image as ImageIcon } from "lucide-react" -import { cn } from "@/lib/utils" -import { Skeleton } from "@/components/ui/skeleton" -import type { FileProps } from "@/lib/types" -import { EpisodeFile } from "../episode-file" -import { useMemo } from "react" -import type { MovieFileModel } from "./MoviePanel" - -// Helper function to get file icon based on extension -function getFileIcon(path: string) { - const ext = path.split('.').pop()?.toLowerCase() - if (['srt', 'vtt', 'ass', 'ssa'].includes(ext || '')) return FileText - if (['mp3', 'aac', 'flac', 'wav'].includes(ext || '')) return Music - if (['jpg', 'jpeg', 'png', 'webp'].includes(ext || '')) return ImageIcon - return FileVideo -} - -// Helper function to get icon and label for file type -function getFileTypeConfig(type: FileProps['type']): { icon: typeof FileVideo, label: string, iconColor: string } { - switch (type) { - case "video": - return { icon: FileVideo, label: "Video File", iconColor: "text-primary" } - case "subtitle": - return { icon: FileText, label: "Subtitle Files", iconColor: "text-blue-500" } - case "audio": - return { icon: Music, label: "Audio Files", iconColor: "text-green-500" } - case "nfo": - return { icon: FileText, label: "NFO Files", iconColor: "text-muted-foreground" } - case "poster": - return { icon: ImageIcon, label: "Poster Files", iconColor: "text-muted-foreground" } - case "file": - default: - return { icon: FileVideo, label: "Files", iconColor: "text-muted-foreground" } - } -} - -interface MovieFilesSectionProps { - movie?: TMDBMovie - isUpdatingMovie: boolean - isPreviewingForRename: boolean - movieFiles: MovieFileModel -} - -export function MovieFilesSection({ - movie, - isUpdatingMovie, - isPreviewingForRename, - movieFiles, -}: MovieFilesSectionProps) { - // Group files by type - const filesByType = useMemo(() => { - const grouped = new Map() - movieFiles.files.forEach(file => { - const existing = grouped.get(file.type) || [] - grouped.set(file.type, [...existing, file]) - }) - return grouped - }, [movieFiles.files]) - - // Convert Map to array of entries for rendering - const filesByTypeArray = useMemo(() => { - return Array.from(filesByType.entries()) as Array<[FileProps['type'], FileProps[]]> - }, [filesByType]) - - if (isUpdatingMovie) { - return ( -
- -
- -
-
- ) - } - - if (!movie || movieFiles.files.length === 0) { - return null - } - - return ( -
-

- - Files ({movieFiles.files.length}) -

-
-
-
-
- {filesByTypeArray.map(([type, typeFiles]) => { - const { icon: TypeIcon, label, iconColor } = getFileTypeConfig(type) - const isVideo = type === "video" - const isMultiple = typeFiles.length > 1 - - return ( -
- {isMultiple && ( -
- - {label} ({typeFiles.length}) -
- )} - {typeFiles.map((file, index) => { - // Use TypeIcon for video files, or getFileIcon for others based on extension - const Icon = isVideo ? TypeIcon : getFileIcon(file.path) - // Use the type-specific iconColor for video, or muted for others - const fileIconColor = isVideo ? iconColor : "text-muted-foreground" - - return ( - - ) - })} -
- ) - })} - - {/* No files message */} - {movieFiles.files.length === 0 && ( -
- No files associated with this movie -
- )} -
-
-
-
-
- ) -} diff --git a/apps/ui/src/components/movie/tmdb-movie-overview.tsx b/apps/ui/src/components/movie/tmdb-movie-overview.tsx deleted file mode 100644 index 18b5d659..00000000 --- a/apps/ui/src/components/movie/tmdb-movie-overview.tsx +++ /dev/null @@ -1,375 +0,0 @@ -import type { TMDBMovie } from "@smm/types" -import { Badge } from "@/components/ui/badge" -import { Calendar, Star, TrendingUp, FileEdit, Download } from "lucide-react" -import { cn, nextTraceId } from "@/lib/utils" -import { ImmersiveMovieSearchbox } from "../ImmersiveMovieSearchbox" -import { useCallback, useState, useEffect } from "react" -import { Skeleton } from "@/components/ui/skeleton" -import { useResolvedLanguages } from "@/hooks/useResolvedLanguages" -import { useUIMediaFolderStoreState } from "@/stores/uiMediaFolderStore"; -import { useMediaMetadataQuery } from "@/hooks/mediaMetadata/useMediaMetadataQuery"; -import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation"; -import { useUpdateMediaMetadataMutation } from "@/hooks/mediaMetadata/useUpdateMediaMetadataMutation"; -import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys"; -import { Button } from "../ui/button" -import { askForScrape } from "@/lib/dialogRequestEvents" -import type { MovieFileModel } from "./MoviePanel" -import { MovieFilesSection } from "./movie-files-section" -import { useTranslation } from "@/lib/i18n" -import type { TFunction } from "i18next" -import type { MediaMetadata } from "@smm/types" -import { useTmdbQueries } from "@/hooks/useTmdbQueries" - -interface TMDBMovieOverviewProps { - movie?: TMDBMovie - className?: string - onRenameClick?: () => void - ruleName?: "plex" | "emby" - movieFiles: MovieFileModel - isPreviewingForRename: boolean - -} - -// Helper function to format date -function formatDate(dateString: string, t: TFunction): string { - if (!dateString) return t("movie.notAvailable") - try { - const date = new Date(dateString) - return date.toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric" - }) - } catch { - return dateString - } -} - -// Helper function to get TMDB image URL -function getTMDBImageUrl(path: string | null, size: "w200" | "w300" | "w500" | "w780" | "original" = "w500"): string | null { - if (!path) return null - const baseUrl = "https://image.tmdb.org/t/p" - return `${baseUrl}/${size}${path}` -} - -/** - * @deprecated - * @param param0 - * @returns - */ -export function TMDBMovieOverview({ movie, className, onRenameClick, movieFiles, isPreviewingForRename }: TMDBMovieOverviewProps) { - const { t } = useTranslation('components') - const { selectedFolder } = useUIMediaFolderStoreState() - const mediaMetadataQuery = useMediaMetadataQuery(selectedFolder || undefined) - const selectedMediaMetadata = mediaMetadataQuery.data - const isMediaMetadataOk = !mediaMetadataQuery.isError && selectedMediaMetadata !== undefined - const { mutateAsync: fetchMediaMetadata } = useFetchMediaMetadataMutation() - const { mutateAsync: saveMediaMetadata } = useUpdateMediaMetadataMutation() - const updateMediaMetadata = useCallback(async ( - path: string, - updaterOrMetadata: MediaMetadata | ((current: MediaMetadata) => MediaMetadata), - options?: { traceId?: string }, - ) => { - const pathPosix = normalizeMediaFolderPathForQuery(path) - if (!pathPosix) return - const current = await fetchMediaMetadata({ path: pathPosix, traceId: options?.traceId }) - const next = typeof updaterOrMetadata === "function" ? updaterOrMetadata(current) : updaterOrMetadata - await saveMediaMetadata({ pathPosix, metadata: next, traceId: options?.traceId }) - }, [fetchMediaMetadata, saveMediaMetadata]) - const [searchResults, setSearchResults] = useState([]) - const [isSearching, setIsSearching] = useState(false) - const [searchError, setSearchError] = useState(null) - const [searchQuery, setSearchQuery] = useState("") - const [isUpdatingMovie, setIsUpdatingMovie] = useState(false) - const { mediaLanguage } = useResolvedLanguages() - const { search: searchTmdb } = useTmdbQueries() - const posterUrl = movie ? getTMDBImageUrl(movie.poster_path, "w500") : null - const backdropUrl = movie ? getTMDBImageUrl(movie.backdrop_path, "w780") : null - const formattedDate = movie ? formatDate(movie.release_date, t) : t("movie.notAvailable") - - // Update search query when movie title changes - useEffect(() => { - if (movie?.title) { - setSearchQuery(movie.title) - } else { - setSearchQuery("") - } - }, [movie?.title]) - - const handleSearch = useCallback(async () => { - // Perform search if there's a query - if (!searchQuery.trim()) { - setSearchResults([]) - setSearchError(null) - return - } - - setIsSearching(true) - setSearchError(null) - setSearchResults([]) - - try { - const response = await searchTmdb(searchQuery.trim(), 'movie', mediaLanguage) - - if (response.error) { - setSearchError(response.error) - setSearchResults([]) - return - } - - // Filter to only movies and map results - const movies = response.results.filter((item): item is TMDBMovie => 'title' in item) - setSearchResults(movies) - - if (movies.length === 0) { - setSearchError(t("movie.searchNoResults")) - } - } catch (error) { - console.error('Search failed:', error) - const errorMessage = error instanceof Error ? error.message : t("movie.searchFailed") - setSearchError(errorMessage) - setSearchResults([]) - } finally { - setIsSearching(false) - } - }, [searchQuery, mediaLanguage, searchTmdb, t]) - - const handleSelectResult = useCallback(async (result: TMDBMovie) => { - if (Number(selectedMediaMetadata?.movie?.id) === result.id) { - return - } - - if (!selectedMediaMetadata?.mediaFolderPath) { - console.error("No media metadata path available") - return - } - - setIsUpdatingMovie(true) - - try { - // For now, just update with the search result - // In a real implementation, you might want to fetch full movie details - const traceId = `tmdb-movie-overview-handleSelectResult-${nextTraceId()}` - updateMediaMetadata(selectedMediaMetadata.mediaFolderPath, { - ...selectedMediaMetadata, - movie: { - id: String(result.id), - name: result.title, - database: 'TMDB', - airDate: result.release_date, - }, - type: 'movie-folder', - }, { traceId }) - - setIsUpdatingMovie(false) - } catch (error) { - console.error("Failed to update media metadata:", error) - setIsUpdatingMovie(false) - } - }, [selectedMediaMetadata, updateMediaMetadata]) - - // When movie is undefined, show only ImmersiveMovieSearchbox - if (!movie && !isUpdatingMovie) { - return ( -
-
-
-
- -
-
-
-
- ) - } - - return ( -
- {/* Backdrop Image */} - {backdropUrl && ( -
- )} - - {/* Content Container */} -
-
- {/* Poster */} - {isUpdatingMovie ? ( -
- -
- ) : posterUrl ? ( -
- {movie?.title} -
- ) : null} - - {/* Details */} -
- {/* Title */} -
-
- {isUpdatingMovie ? ( -
- - -
- ) : ( - <> - - {movie?.original_title !== movie?.title && ( -

{movie?.original_title}

- )} - - )} -
-
- - {/* Metadata Badges */} - {isUpdatingMovie ? ( -
- - - -
- ) : ( -
- - - {formattedDate} - - - - - {movie?.vote_average.toFixed(1)} - - ({movie?.vote_count.toLocaleString()}) - - - - - - {movie?.popularity.toFixed(0)} - -
- )} - - {/* Overview */} - {isUpdatingMovie ? ( -
- - - - -
- ) : movie?.overview && ( -
-

{t("movie.overview")}

-

{movie?.overview}

-
- )} - - {/* Genre IDs - Display as badges */} - {isUpdatingMovie ? ( -
- -
- - - -
-
- ) : movie?.genre_ids && movie?.genre_ids.length > 0 && ( -
-

{t("movie.genres")}

-
- {movie?.genre_ids.map((genreId) => ( - - {t("movie.genreLabel", { genreId })} - - ))} -
-
- )} - - {/* Action Buttons */} - {isUpdatingMovie ? ( -
- - -
- ) : ( -
- - -
- )} - -
-
- - {/* Movie Files */} - -
-
- ) -} diff --git a/apps/ui/src/components/musicTableRowShared.tsx b/apps/ui/src/components/musicTableRowShared.tsx index 8652df27..92bb618d 100644 --- a/apps/ui/src/components/musicTableRowShared.tsx +++ b/apps/ui/src/components/musicTableRowShared.tsx @@ -8,7 +8,7 @@ import { cn } from "@/lib/utils" import Image from "@/components/Image" // eslint-disable-next-line react-refresh/only-export-components -export function formatDuration(seconds: number): string { +function formatDuration(seconds: number): string { const mins = Math.floor(seconds / 60) const secs = Math.floor(seconds % 60) return `${mins}:${secs.toString().padStart(2, "0")}` @@ -16,7 +16,7 @@ export function formatDuration(seconds: number): string { /** Builds a file:// URL for the thumbnail that the backend can resolve. */ // eslint-disable-next-line react-refresh/only-export-components -export function getThumbnailImageUrl( +function getThumbnailImageUrl( thumbnailPath: string, mediaFolderPath: string | undefined, ): string { @@ -36,7 +36,7 @@ export function getThumbnailImageUrl( return pathToFileURL(platformPath) } -export function ThumbnailPreview({ +function ThumbnailPreview({ thumbnailPath, mediaFolderPath, }: { diff --git a/apps/ui/src/components/rename-rules-combobox.tsx b/apps/ui/src/components/rename-rules-combobox.tsx deleted file mode 100644 index 48f10880..00000000 --- a/apps/ui/src/components/rename-rules-combobox.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import * as React from "react" -import { Check, ChevronsUpDown } from "lucide-react" - -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import { useEffect, useMemo } from "react" -import { RenameRules } from "@smm/types" - -interface RenameRuleComboboxProps { - className?: string - onRenameRuleChange?: (renameRuleName: string) => void -} - -export function RenameRuleCombobox({ onRenameRuleChange }: RenameRuleComboboxProps) { - const [open, setOpen] = React.useState(false) - const [value, setValue] = React.useState("") - - const options: string[] = useMemo(() => { - return Object.values(RenameRules).map((rule) => rule.name) - }, []) - - useEffect(() => { - if(value && value.trim().length > 0) { - onRenameRuleChange?.(value) - } - }, [value, onRenameRuleChange]) - - return ( - - - - - - - - - No framework found. - - {options.map((renameRuleName) => ( - { - setValue(currentValue === value ? "" : currentValue) - setOpen(false) - }} - > - {renameRuleName} - - - ))} - - - - - - ) -} diff --git a/apps/ui/src/components/sidebar/Sidebar.tsx b/apps/ui/src/components/sidebar/Sidebar.tsx index 087331db..833dd81d 100644 --- a/apps/ui/src/components/sidebar/Sidebar.tsx +++ b/apps/ui/src/components/sidebar/Sidebar.tsx @@ -2,13 +2,12 @@ import { useCallback, useMemo, useState, type ComponentType, type KeyboardEvent, import { lazy, Suspense } from "react" import { Loader2 } from "lucide-react" import { SearchForm } from "@/components/search-form" -import { MediaFolderToolbar, type SortOrder, type FilterType } from "@/components/shared/MediaFolderToolbar" +import { MediaFolderToolbar } from "@/components/shared/MediaFolderToolbar" import { useSidebar } from "@/hooks/useSidebar" import { useTranslation } from "@/lib/i18n" import { isPathInSelection, nextFolderSelection } from "@/lib/sidebarFolderSelection" import type { FolderListItemContainerProps } from "./FolderListItemContainer" -export type { SortOrder, FilterType } const DefaultFolderListItemContainer = lazy(() => import("./FolderListItemContainer").then((m) => ({ @@ -30,7 +29,7 @@ export interface SidebarSelectionChange { multi: boolean } -export type FolderListItemSlot = ComponentType +type FolderListItemSlot = ComponentType export interface SidebarProps { onDeleteSelected?: (paths: string[]) => void diff --git a/apps/ui/src/components/thread-list.tsx b/apps/ui/src/components/thread-list.tsx deleted file mode 100644 index 68702915..00000000 --- a/apps/ui/src/components/thread-list.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { TooltipIconButton } from "@/components/tooltip-icon-button"; -import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - AssistantIf, - ThreadListItemPrimitive, - ThreadListPrimitive, -} from "@assistant-ui/react"; -import { ArchiveIcon, PlusIcon } from "lucide-react"; -import type { FC } from "react"; - -export const ThreadList: FC = () => { - return ( - - - threads.isLoading}> - - - !threads.isLoading}> - - - - ); -}; - -const ThreadListNew: FC = () => { - return ( - - - - ); -}; - -const ThreadListSkeleton: FC = () => { - return ( -
- {Array.from({ length: 5 }, (_, i) => ( -
- -
- ))} -
- ); -}; - -const ThreadListItem: FC = () => { - return ( - - - - - - - ); -}; - -const ThreadListItemArchive: FC = () => { - return ( - - - - - - ); -}; diff --git a/apps/ui/src/components/three-column-layout.tsx b/apps/ui/src/components/three-column-layout.tsx deleted file mode 100644 index 425cf8dc..00000000 --- a/apps/ui/src/components/three-column-layout.tsx +++ /dev/null @@ -1,123 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "@/components/ui/resizable" -import { cn } from "@/lib/utils" -import React from "react" -import type { ReactNode } from "react" - -interface ThreeColumnLayoutProps { - children: ReactNode - className?: string -} - -interface SlotProps { - slot?: "left-sidebar-content" | "right-sidebar-content" | "sidebar-content" - children?: ReactNode -} - -export function ThreeColumnLayout({ children, className }: ThreeColumnLayoutProps) { - // Filter children by slot prop - const leftSidebarContent = getChildrenBySlot(children, "left-sidebar-content") - const rightSidebarContent = getChildrenBySlot(children, "right-sidebar-content") - const sidebarContent = getChildrenBySlot(children, "sidebar-content") - - return ( - <> - - - {/* left sidebar */} -
- {leftSidebarContent} -
-
- - - -{ - (rightSidebarContent as any).length > 0 && ( - - {/* sidebar content */} - - {sidebarContent} - - - - {/* right sidebar */} -
- {rightSidebarContent} -
-
-
- ) -} - -{ - (rightSidebarContent as any).length === 0 && ( - - {/* sidebar content */} - - {sidebarContent} - - - ) -} - - -
-
- - ) -} - -// Helper function to extract children by slot prop -function getChildrenBySlot( - children: ReactNode, - slot: "left-sidebar-content" | "right-sidebar-content" | "sidebar-content" -): ReactNode { - const slotChildren = React.Children.toArray(children).filter((child) => { - if (React.isValidElement(child)) { - // Check explicit slot prop first - if (child.props.slot === slot) { - return true - } - // Check if it's one of our slot wrapper components - const childType = child.type as any - if ( - (childType === LeftSidebarContent && slot === "left-sidebar-content") || - (childType === RightSidebarContent && slot === "right-sidebar-content") || - (childType === SidebarContent && slot === "sidebar-content") - ) { - return true - } - } - return false - }) - - // Extract and return the children from the slot wrappers - return slotChildren.map((child) => { - if (React.isValidElement(child)) { - return child.props.children - } - return child - }) -} - -// Slot wrapper components for better developer experience -export function LeftSidebarContent({ children }: { children?: ReactNode }) { - return <>{children} -} - -export function RightSidebarContent({ children }: { children?: ReactNode }) { - return <>{children} -} - -export function SidebarContent({ children }: { children?: ReactNode }) { - return <>{children} -} - diff --git a/apps/ui/src/components/tv/TvShowPanel.tsx b/apps/ui/src/components/tv/TvShowPanel.tsx index 83ab6fa2..da17104f 100644 --- a/apps/ui/src/components/tv/TvShowPanel.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.tsx @@ -47,7 +47,7 @@ import { AiBasedRecognizeEpisodePrompt } from "./AiBasedRecognizeEpisodePrompt" import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan" -export function buildMediaFileTableSeasonData(m: MediaMetadata): MediaFileTableSeasonData[] { +function buildMediaFileTableSeasonData(m: MediaMetadata): MediaFileTableSeasonData[] { if(m.type === 'tvshow-folder' || m.type === 'movie-folder') { const seasons: MediaFileTableSeasonData[] = m.tvShow?.seasons?.map(s => { diff --git a/apps/ui/src/components/tv/TvShowPanelUtils.test.ts b/apps/ui/src/components/tv/TvShowPanelUtils.test.ts index dee453f4..72acccc3 100644 --- a/apps/ui/src/components/tv/TvShowPanelUtils.test.ts +++ b/apps/ui/src/components/tv/TvShowPanelUtils.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { mapTagToFileType, newPath, buildFileProps, renameFiles, updateMediaFileMetadatas, buildTvShowMediaMetadataByNFO, buildTmdbEpisodeByNFO, tryToRecognizeTvShowFolderByNFO, unlinkEpisode, buildRenameApplySelectedFiles, buildRecognizeApplySelectedFiles } from './TvShowPanelUtils' import type { FileProps } from '@/lib/types' import type { MediaMetadata, MediaFileMetadata } from '@smm/types' -import type { UIMediaMetadata } from '@/types/UIMediaMetadata' +import type { MediaMetadata } from '@smm/types' import type { RecognizeMediaFilePlan } from '@smm/types/RecognizeMediaFilePlan' import { readFile } from '@/api/readFile' import { parseEpisodeNfo } from '@/lib/nfo' @@ -122,7 +122,7 @@ describe('newPath', () => { }) describe('buildFileProps', () => { - const createMockMediaMetadata = (overrides?: Partial): UIMediaMetadata => ({ + const createMockMediaMetadata = (overrides?: Partial): MediaMetadata => ({ mediaFolderPath: '/media/tvshow', mediaFiles: [ { @@ -138,7 +138,7 @@ describe('buildFileProps', () => { ], status: 'ok', ...overrides, - } as UIMediaMetadata) + } as MediaMetadata) it('should build file props for valid media metadata', () => { const mm = createMockMediaMetadata() @@ -1072,7 +1072,7 @@ describe('tryToRecognizeTvShowFolderByNFO', () => { episode1.mkv ` - const mediaMetadata: UIMediaMetadata = { + const mediaMetadata: MediaMetadata = { mediaFolderPath: '/media/testshow', files: [ '/media/testshow/tvshow.nfo', @@ -1120,7 +1120,7 @@ describe('unlinkEpisode', () => { episodeNumber: mf.episodeNumber, absolutePath: mf.absolutePath ?? `/show/season1/ep${i + 1}.mkv`, })), - } as UIMediaMetadata) + } as MediaMetadata) it('does nothing when mediaMetadata is undefined', () => { unlinkEpisode({ @@ -1148,7 +1148,7 @@ describe('unlinkEpisode', () => { unlinkEpisode({ season: 1, episode: 1, - mediaMetadata: { mediaFolderPath: '/show', status: 'ok', mediaFiles: undefined } as UIMediaMetadata, + mediaMetadata: { mediaFolderPath: '/show', status: 'ok', mediaFiles: undefined } as MediaMetadata, updateMediaMetadata: mockUpdateMediaMetadata, t: mockT, }) diff --git a/apps/ui/src/components/tv/TvShowPanelUtils.ts b/apps/ui/src/components/tv/TvShowPanelUtils.ts index facf97ff..94b17cbf 100644 --- a/apps/ui/src/components/tv/TvShowPanelUtils.ts +++ b/apps/ui/src/components/tv/TvShowPanelUtils.ts @@ -1,5 +1,5 @@ import { listMediaFolderFilePaths } from "@/lib/mediaFolderFiles"; -import type { MediaFileMetadata, MediaMetadata, PrimaryDatabase, TMDBEpisode, TMDBTVShowDetails, TvShowMediaMetadata } from "@smm/types"; +import type { MediaFileMetadata, MediaMetadata, TMDBEpisode, TvShowMediaMetadata } from "@smm/types"; import { extname, join } from "@/lib/path"; import { Path } from "@smm/utils/path"; import { getFullExtensionForAssociatedFile } from "@smm/types/mediaFileExtensions"; @@ -21,9 +21,7 @@ export function mediaFolderPathEqual(a: string | undefined, b: string | undefine import type { FileProps } from "@/lib/types"; import { readFile } from "@/api/readFile"; import { parseEpisodeNfo } from "@/lib/nfo"; -import { renameFiles as renameFilesApi } from "@/api/renameFiles"; import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan"; -import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan"; import { toast } from "sonner"; import type { PersistUIMediaMetadataFn } from "@/types/persistUIMediaMetadata"; @@ -52,28 +50,6 @@ export function newPath(mediaFolderPath: string, videoFilePath: string, associat return join(mediaFolderPath, associatedRelativePath) } -/** - * Build FileProps[] for an episode from a video path: video file plus associated files (subtitle, nfo, poster, etc.). - * Used when building SeasonModel preview from recognized paths (rule-based or plan-based). - */ -export function buildFilePropsForVideoPath( - mediaFolderPath: string, - fileList: string[], - videoFilePath: string -): FileProps[] { - if (!mediaFolderPath || fileList.length === 0) { - return [{ type: "video", path: videoFilePath }] - } - const associatedFiles = findAssociatedFiles(mediaFolderPath, fileList, videoFilePath) - return [ - { type: "video", path: videoFilePath }, - ...associatedFiles.map((file) => ({ - type: mapTagToFileType(file.tag), - path: join(mediaFolderPath, file.path), - })), - ] -} - export function buildFileProps(mm: MediaMetadata, seasonNumber: number, episodeNumber: number, folderFiles: string[]): FileProps[] { if(mm.mediaFolderPath === undefined) { console.error(`Media folder path is undefined`) @@ -248,20 +224,6 @@ export function rebuildPlanWithSelectedEpisodes( } } -export function rebuildRenamePlanWithSelectedEpisodes( - originalPlan: RenameFilesPlan, - selectedEpisodePaths: string[] -): RenameFilesPlan { - - return { - ...originalPlan, - files: originalPlan.files.filter(file => { - return selectedEpisodePaths.some(path => path === file.from) - }) - } - -} - /** * RENAME flow: build the apply-plan `data.files` payload from the episode table. * A rename plan only touches files that are already linked in metadata, so the @@ -725,256 +687,6 @@ export function buildTvShowMediaMetadataByNFO(tvshowNfoXml: string): TvShowMedia } } -/** - * @deprecated, use buildTvShowMediaMetadataByNFO instead - * @param tvshowNfoXml - * @returns - */ -export function buildTmdbTVShowDetailsByNFO(tvshowNfoXml: string): TMDBTVShowDetails | undefined { - const parser = new DOMParser() - const doc = parser.parseFromString(tvshowNfoXml, 'text/xml') - - // Check for parsing errors - const parseError = doc.querySelector('parsererror') - if (parseError) { - console.error(`[buildTmdbTVShowDetailsByNFO] Failed to parse XML: ${parseError.textContent}`) - return undefined - } - - const tvshow = doc.querySelector('tvshow') - if (!tvshow) { - return undefined - } - - // Helper function to get text content from an element - const getTextContent = (selector: string): string | undefined => { - const element = tvshow.querySelector(selector) - return element?.textContent?.trim() || undefined - } - - // Extract ID - prefer uniqueid with type="tmdb", fallback to tmdbid, then id - let id = 0 - const tmdbUniqueId = tvshow.querySelector('uniqueid[type="tmdb"]') - if (tmdbUniqueId) { - const idText = tmdbUniqueId.textContent?.trim() - if (idText) { - const parsedId = parseInt(idText, 10) - if (!isNaN(parsedId)) { - id = parsedId - } - } - } - - // Fallback to tmdbid element - if (id === 0) { - const tmdbidText = getTextContent('tmdbid') - if (tmdbidText) { - const parsedId = parseInt(tmdbidText, 10) - if (!isNaN(parsedId)) { - id = parsedId - } - } - } - - // Fallback to id element - if (id === 0) { - const idText = getTextContent('id') - if (idText) { - const parsedId = parseInt(idText, 10) - if (!isNaN(parsedId)) { - id = parsedId - } - } - } - - // Extract name (title) - const name = getTextContent('title') || '' - - // Extract original_name (originaltitle) - const original_name = getTextContent('originaltitle') || '' - - // Extract overview (plot) - const overview = getTextContent('plot') || '' - - // Extract poster_path from thumb with aspect="poster" and no season attribute - let poster_path: string | null = null - const posterThumbs = tvshow.querySelectorAll('thumb[aspect="poster"]') - for (const thumb of Array.from(posterThumbs)) { - const seasonAttr = thumb.getAttribute('season') - if (!seasonAttr) { - const thumbUrl = thumb.textContent?.trim() - if (thumbUrl) { - poster_path = extractTmdbImagePath(thumbUrl) - break - } - } - } - - // Extract backdrop_path from fanart - let backdrop_path: string | null = null - const fanart = tvshow.querySelector('fanart') - if (fanart) { - const fanartThumb = fanart.querySelector('thumb') - if (fanartThumb) { - const fanartUrl = fanartThumb.textContent?.trim() - if (fanartUrl) { - backdrop_path = extractTmdbImagePath(fanartUrl) - } - } - } - - // Fallback to fanart element if fanart/thumb structure not found - if (!backdrop_path) { - const fanartUrl = getTextContent('fanart') - backdrop_path = extractTmdbImagePath(fanartUrl) - } - - // Extract first_air_date (premiered) - const first_air_date = getTextContent('premiered') || '' - - // Extract vote_average and vote_count from ratings - let vote_average = 0 - let vote_count = 0 - const rating = tvshow.querySelector('ratings > rating[name="themoviedb"]') - if (rating) { - const valueElement = rating.querySelector('value') - const votesElement = rating.querySelector('votes') - - if (valueElement) { - const valueText = valueElement.textContent?.trim() - if (valueText) { - const parsedValue = parseFloat(valueText) - if (!isNaN(parsedValue)) { - vote_average = parsedValue - } - } - } - - if (votesElement) { - const votesText = votesElement.textContent?.trim() - if (votesText) { - const parsedVotes = parseInt(votesText, 10) - if (!isNaN(parsedVotes)) { - vote_count = parsedVotes - } - } - } - } - - // Extract origin_country from country elements - const origin_country: string[] = [] - const countryElements = tvshow.querySelectorAll('country') - for (const country of Array.from(countryElements)) { - const countryText = country.textContent?.trim() - if (countryText) { - origin_country.push(countryText) - } - } - - // Extract status - const status = getTextContent('status') || '' - - // Extract last_air_date (not directly in NFO, but we can try to infer from status or leave empty) - const last_air_date = '' - - // Build and return TMDBTVShowDetails - const tvShowDetails: TMDBTVShowDetails = { - // Base TMDBTVShow fields - id, - name, - original_name, - overview, - poster_path, - backdrop_path, - first_air_date, - vote_average, - vote_count, - popularity: 0, // Not available in NFO - genre_ids: [], // Genre names in NFO, can't map to IDs without API - origin_country, - media_type: 'tv', - - // TMDBTVShowDetails specific fields - number_of_seasons: 0, // Not available in NFO - number_of_episodes: 0, // Not available in NFO - seasons: [], // Not available in NFO - status, - type: '', // Not available in NFO - in_production: status.toLowerCase() !== 'ended', // Infer from status - last_air_date, - networks: [], // Not available in NFO - production_companies: [], // Not available in NFO - } - - return tvShowDetails -} - - -/** - * @deprecated, use startToRenameFiles in useTvShowRenaming instead - * @param plan - * @param mediaMetadata - * @returns - */ -export async function executeRenamePlan( - plan: RenameFilesPlan, - mediaMetadata: MediaMetadata, -): Promise { - if (!mediaMetadata || !mediaFolderPathEqual(plan.mediaFolderPath, mediaMetadata.mediaFolderPath)) { - toast.error("Plan does not match current media folder") - return - } - const mediaFolderPath = mediaMetadata.mediaFolderPath - if (!mediaFolderPath) { - toast.error("Media folder path is not available") - return - } - const traceId = `TvShowPanel-executeRenamePlan-${nextTraceId()}` - - if (plan.files.length === 0) { - console.warn(`empty RenameFilesPlan, do nothing but mark plan as completed`) - } - - try { - // Pass platform-specific paths so the backend can perform file system operations correctly. - // mediaFolder is used for metadata update and broadcast; files.from/to must be platform format. - const response = await renameFilesApi({ - files: plan.files.map(({ from, to }) => ({ - from: Path.toPlatformPath(from), - to: Path.toPlatformPath(to), - })), - traceId, - mediaFolder: Path.toPlatformPath(mediaFolderPath), - }) - - if (response.error) { - toast.error(response.error) - return - } - - const succeededPaths = response.data?.succeeded ?? [] - const failedPaths = response.data?.failed ?? [] - - if (succeededPaths.length === 0) { - toast.error(`Failed to rename ${failedPaths.length} file(s)`) - return - } - - const successCount = succeededPaths.length - const errorCount = failedPaths.length - - if (errorCount === 0) { - toast.success(`Successfully renamed ${successCount} file(s)`) - } else { - toast.warning(`Renamed ${successCount} file(s), ${errorCount} failed`) - } - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error" - toast.error(`Failed to rename files: ${errorMessage}`) - } -} - /** * Build a temporary recognition plan from the media metadata. * This is used for rule-based recognition where the frontend creates @@ -990,19 +702,6 @@ export async function executeRenamePlan( * The caller (addTmpPlan) will add id, task, status, and tmp fields */ -export interface OnMediaFolderSelectedParams { - mediaMetadata: MediaMetadata - /** Mirrors user config; undefined means try TMDB then TVDB in recognizeMediaFolder. */ - primaryDatabase?: PrimaryDatabase - openRuleBasedRecognizePrompt: (options: { - tvShowTitle: string - tvShowTmdbId: number - onConfirm?: () => void - onCancel?: () => void - }) => void - updateMediaMetadata: (path: string, metadata: MediaMetadata | ((current: MediaMetadata) => MediaMetadata), options?: { traceId?: string }) => void -} - export interface UnlinkEpisodeParams { season: number, episode: number, diff --git a/apps/ui/src/components/tv/UseNfoPrompt.tsx b/apps/ui/src/components/tv/UseNfoPrompt.tsx deleted file mode 100644 index 77cf735f..00000000 --- a/apps/ui/src/components/tv/UseNfoPrompt.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { FloatingPrompt, type FloatingPromptProps } from "../FloatingPrompt" -import { cn } from "@/lib/utils" -import { useTranslation } from "@/lib/i18n" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" - -export interface UseNfoPromptProps extends Omit { - /** - * Callback when the user confirms to use NFO metadata - */ - onConfirm?: () => void - /** - * Callback when the user cancels - */ - onCancel?: () => void - /** - * Whether the prompt is open - */ - isOpen?: boolean - /** - * Additional CSS classes - */ - className?: string - /** - * Media name from NFO file - */ - mediaName?: string - /** - * TMDB ID from NFO file - */ - tmdbid?: number -} - -/** - * UseNfoPrompt component built on top of FloatingPrompt. - * Asks the user to confirm whether to use media metadata from an NFO file. - */ -export function UseNfoPrompt({ - onConfirm, - onCancel, - isOpen = false, - className, - mediaName, - tmdbid, - ...promptProps -}: UseNfoPromptProps) { - const { t } = useTranslation('components') - - return ( - -
- {t('toolbar.useNfoMetadata')} - {(mediaName || tmdbid !== undefined) && ( -
- {mediaName && {mediaName}} - {tmdbid !== undefined && ( - - - - {tmdbid} - - - -

TMDB ID

-
-
- )} -
- )} -
-
- ) -} diff --git a/apps/ui/src/components/version-switcher.tsx b/apps/ui/src/components/version-switcher.tsx deleted file mode 100644 index 0fce3f5c..00000000 --- a/apps/ui/src/components/version-switcher.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import * as React from "react" -import { Check, ChevronsUpDown, GalleryVerticalEnd } from "lucide-react" - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, -} from "@/components/ui/sidebar" - -export function VersionSwitcher({ - versions, - defaultVersion, -}: { - versions: string[] - defaultVersion: string -}) { - const [selectedVersion, setSelectedVersion] = React.useState(defaultVersion) - - return ( - - - - - -
- -
-
- Documentation - v{selectedVersion} -
- -
-
- - {versions.map((version) => ( - setSelectedVersion(version)} - > - v{version}{" "} - {version === selectedVersion && } - - ))} - -
-
-
- ) -} diff --git a/apps/ui/src/components/welcome.tsx b/apps/ui/src/components/welcome.tsx index 3ae01b91..12e75e98 100644 --- a/apps/ui/src/components/welcome.tsx +++ b/apps/ui/src/components/welcome.tsx @@ -10,7 +10,7 @@ import { cn } from "@/lib/utils" const GITHUB_REPO_URL = "https://github.com/lawrenceching/SMM" const GITCODE_REPO_URL = "https://gitcode.com/lawrenceching/SMM" -export interface WelcomeProps { +interface WelcomeProps { /** * Triggered when the user clicks the "Import Folder" card. * Should match the behavior of `SMM → Open Folder` in the top-left menu diff --git a/apps/ui/src/core/BrowserNetworkPort.ts b/apps/ui/src/core/BrowserNetworkPort.ts index 478c8e2b..8afe1da9 100644 --- a/apps/ui/src/core/BrowserNetworkPort.ts +++ b/apps/ui/src/core/BrowserNetworkPort.ts @@ -2,7 +2,7 @@ import type { FetchInit, HttpResponse, NetworkPort } from '../../../core/src/por import { apiFetch } from '@/lib/apiFetch' /** Request body for `POST /api/core/fetch`. */ -export interface CoreFetchRequestBody { +interface CoreFetchRequestBody { url: string method?: string headers?: Record @@ -11,7 +11,7 @@ export interface CoreFetchRequestBody { } /** Upstream response payload inside API `data`. */ -export interface CoreFetchResponseData { +interface CoreFetchResponseData { ok: boolean status: number statusText: string @@ -20,7 +20,7 @@ export interface CoreFetchResponseData { bodyBase64: string } -export interface CoreFetchResponseBody { +interface CoreFetchResponseBody { data?: CoreFetchResponseData error?: string } diff --git a/apps/ui/src/helpers/loadNfo.ts b/apps/ui/src/helpers/loadNfo.ts deleted file mode 100644 index 780a60a3..00000000 --- a/apps/ui/src/helpers/loadNfo.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { readFile } from "@/api/readFile" -import NFO from "@/lib/nfo" -import type { MediaMetadata } from "@/lib/mediaFolderFiles" -import { listMediaFolderFilePaths } from "@/lib/mediaFolderFiles" -import type { TMDBTVShowDetails } from "@smm/types" - -/** - * Extracts the path portion from a TMDB image URL. - * Handles both full URLs (https://image.tmdb.org/t/p/{size}{path}) and paths that are already just the path. - * - * @param urlOrPath - Full TMDB image URL or just the path - * @returns The path portion (e.g., "/mNuV7Jti0jYQh34OP2WdmhflTDQ.jpg") or null if invalid - */ -function extractTmdbImagePath(urlOrPath: string | undefined | null): string | null { - if (!urlOrPath) return null - - // If it's already just a path (starts with /), return it - if (urlOrPath.startsWith('/')) { - return urlOrPath - } - - // If it's a full URL, extract the path portion - // Pattern: https://image.tmdb.org/t/p/{size}{path} - const tmdbImagePattern = /^https?:\/\/image\.tmdb\.org\/t\/p\/[^/]+(\/.+)$/ - const match = urlOrPath.match(tmdbImagePattern) - - if (match && match[1]) { - return match[1] - } - - // If it doesn't match the pattern, try to extract path from URL - try { - const url = new URL(urlOrPath) - return url.pathname - } catch { - // If URL parsing fails, return null - return null - } -} - -export function nfoToTmdbTVShowDetails(nfo: NFO): TMDBTVShowDetails { - // Parse TMDB ID - const id = nfo.tmdbid ? parseInt(nfo.tmdbid, 10) : 0 - if (isNaN(id)) { - console.warn(`[_nfoToTmdbTVShowDetails] Invalid tmdbid: ${nfo.tmdbid}, defaulting to 0`) - } - - // Extract poster path from thumbs array (poster aspect, no season) - let posterPath: string | null = null - if (nfo.thumbs && nfo.thumbs.length > 0) { - const posterThumb = nfo.thumbs.find( - thumb => thumb.aspect === "poster" && thumb.season === undefined - ) - if (posterThumb) { - posterPath = extractTmdbImagePath(posterThumb.url) - } - } - - // Extract backdrop path from fanart - const backdropPath = extractTmdbImagePath(nfo.fanart) - - // Build TMDBTVShowDetails object - const tvShowDetails: TMDBTVShowDetails = { - // Base TMDBTVShow fields - id: isNaN(id) ? 0 : id, - name: nfo.title || "", - original_name: nfo.originalTitle || "", - overview: nfo.plot || "", - poster_path: posterPath, - backdrop_path: backdropPath, - first_air_date: "", - vote_average: 0, - vote_count: 0, - popularity: 0, - genre_ids: [], - origin_country: [], - media_type: 'tv', - - // TMDBTVShowDetails specific fields - number_of_seasons: 0, - number_of_episodes: 0, - seasons: [], - status: "", - type: "", - in_production: false, - last_air_date: "", - networks: [], - production_companies: [] - } - - return tvShowDetails -} - -export async function loadNfo(mediaMetadata: MediaMetadata): Promise { - if (!mediaMetadata.mediaFolderPath) { - console.log(`[loadNfo] no media folder path in metadata`) - return undefined - } - - let folderFiles: string[] - try { - folderFiles = await listMediaFolderFilePaths(mediaMetadata.mediaFolderPath) - } catch { - console.log(`[loadNfo] failed to list folder files`) - return undefined - } - - const nfoFilePath = folderFiles.find((file: string) => file.endsWith('/tvshow.nfo')) - - if(nfoFilePath === undefined) { - console.log(`[loadNfo] no nfo file found in media metadata`) - return undefined - } - - const resp = await readFile(nfoFilePath) - - if(resp.error) { - console.error(`[loadNfo] Unable to read NFO file by calling readFile API`, resp) - return undefined - } - - if(resp.data === undefined) { - console.error(`[loadNfo] Unexpected response body: no data`, resp) - return undefined - } - - const nfo = await NFO.fromXml(resp.data) - - return nfoToTmdbTVShowDetails(nfo) -} \ No newline at end of file diff --git a/apps/ui/src/helpers/movie/MovieMediaMetadataUtils.ts b/apps/ui/src/helpers/movie/MovieMediaMetadataUtils.ts index e2d13129..b11f8a91 100644 --- a/apps/ui/src/helpers/movie/MovieMediaMetadataUtils.ts +++ b/apps/ui/src/helpers/movie/MovieMediaMetadataUtils.ts @@ -35,7 +35,7 @@ export function findMediaFilesForMovieMediaMetadata( } } -export function findVideoFiles(paths: string[]): string[] { +function findVideoFiles(paths: string[]): string[] { return paths.filter(path => { return videoFileExtensions.includes(extname(path).toLowerCase()); }) diff --git a/apps/ui/src/hooks/ffmpeg/useFfmpegEncodersQuery.ts b/apps/ui/src/hooks/ffmpeg/useFfmpegEncodersQuery.ts index ecf8e1bc..987697bb 100644 --- a/apps/ui/src/hooks/ffmpeg/useFfmpegEncodersQuery.ts +++ b/apps/ui/src/hooks/ffmpeg/useFfmpegEncodersQuery.ts @@ -18,7 +18,7 @@ export interface FfmpegEncodersResult { const FFMPEG_ENCODERS_TIMEOUT_MS = 15_000; const STALE_TIME_MS = 60 * 60 * 1000; // 1 hour -export const ffmpegEncodersQueryKey = ["ffmpeg-encoders"] as const; +const ffmpegEncodersQueryKey = ["ffmpeg-encoders"] as const; async function fetchFfmpegEncoders(): Promise { const result = await executeCmdToCompletion( diff --git a/apps/ui/src/hooks/folders/foldersQueryKeys.ts b/apps/ui/src/hooks/folders/foldersQueryKeys.ts index a0a6c7f6..030cbded 100644 --- a/apps/ui/src/hooks/folders/foldersQueryKeys.ts +++ b/apps/ui/src/hooks/folders/foldersQueryKeys.ts @@ -1,2 +1,2 @@ -export const FOLDERS_QUERY_ROOT = 'folders' as const +const FOLDERS_QUERY_ROOT = 'folders' as const export const foldersQueryKey = [FOLDERS_QUERY_ROOT] as const diff --git a/apps/ui/src/hooks/folders/index.ts b/apps/ui/src/hooks/folders/index.ts index 1411c4b8..08ba32cf 100644 --- a/apps/ui/src/hooks/folders/index.ts +++ b/apps/ui/src/hooks/folders/index.ts @@ -1,5 +1,5 @@ export { useFoldersQuery } from './useFoldersQuery' -export { foldersQueryKey, FOLDERS_QUERY_ROOT } from './foldersQueryKeys' + export { invalidateFoldersQueryIfV3 } from './invalidateFoldersQuery' export { useUnimportFolderMutation } from './useUnimportFolderMutation' -export { useImportFolderMutation } from './useImportFolderMutation' + diff --git a/apps/ui/src/hooks/initialization/useSyncUIMediaFolderStoreFromUserConfig.ts b/apps/ui/src/hooks/initialization/useSyncUIMediaFolderStoreFromUserConfig.ts deleted file mode 100644 index b58e8980..00000000 --- a/apps/ui/src/hooks/initialization/useSyncUIMediaFolderStoreFromUserConfig.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useEffect } from "react" -import { useConfig } from "@/hooks/userConfig" -import { uiMediaFoldersFromPaths, useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" - -/** - * Keeps {@link uiMediaFoldersFromPaths} rows in sync with persisted `UserConfig.folders`. - */ -export function useSyncUIMediaFolderStoreFromUserConfig() { - const { userConfig } = useConfig() - const setFolders = useUIMediaFolderStore((s) => s.setFolders) - - useEffect(() => { - setFolders(uiMediaFoldersFromPaths(userConfig.folders)) - }, [setFolders, userConfig.folders]) -} diff --git a/apps/ui/src/hooks/mediaMetadata/index.ts b/apps/ui/src/hooks/mediaMetadata/index.ts index 1e7be18c..b2aece89 100644 --- a/apps/ui/src/hooks/mediaMetadata/index.ts +++ b/apps/ui/src/hooks/mediaMetadata/index.ts @@ -1,9 +1,6 @@ export { mediaMetadataQueryKey, - mediaMetadataReadQueryOptions, normalizeMediaFolderPathForQuery, } from "@/lib/mediaMetadataQueryKeys" export { useFetchMediaMetadataMutation } from "./useFetchMediaMetadataMutation" -export { useMediaMetadataMutation } from "./useMediaMetadataMutation" export { useMediaMetadataQuery } from "./useMediaMetadataQuery" -export { useUpdateMediaMetadataMutation } from "./useUpdateMediaMetadataMutation" diff --git a/apps/ui/src/hooks/plans/index.ts b/apps/ui/src/hooks/plans/index.ts index e4124055..ed29a110 100644 --- a/apps/ui/src/hooks/plans/index.ts +++ b/apps/ui/src/hooks/plans/index.ts @@ -1,14 +1,10 @@ -export { PLANS_QUERY_ROOT, plansQueryKey } from "./plansQueryKeys" +export { PLANS_QUERY_ROOT } from "./plansQueryKeys"; export { usePlansPullOnVisible } from "./usePlansPullOnVisible" export { usePlansQuery } from "./usePlansQuery" export { useUpdatePlanMutation, toUpdatePlanPatch, - type UpdatePlanVariables, -} from "./useUpdatePlanMutation" -export { useRejectPlanMutation, type RejectPlanVariables } from "./useRejectPlanMutation" -export { useApplyPlanMutation, type ApplyPlanVariables } from "./useApplyPlanMutation" -export { - useTryToRenameEpisodesMutation, - type TryToRenameEpisodesVariables, -} from "./useTryToRenameEpisodesMutation" +} from "./useUpdatePlanMutation"; + +export { useApplyPlanMutation } from "./useApplyPlanMutation"; + diff --git a/apps/ui/src/hooks/tv/useTvShowFileNameGeneration.ts b/apps/ui/src/hooks/tv/useTvShowFileNameGeneration.ts deleted file mode 100644 index 2e8f13a7..00000000 --- a/apps/ui/src/hooks/tv/useTvShowFileNameGeneration.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useCallback } from "react" -import type { MediaMetadata } from "@smm/types" -import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan" -import { buildTvShowRenamePlanFileEntries } from "@smm/core/pipeline/buildTvShowRenamePlanFileEntries" - -interface UseTvShowFileNameGenerationParams { - mediaMetadata: MediaMetadata | undefined - selectedNamingRule: "plex" | "emby" -} - -export function useTvShowFileNameGeneration({ - mediaMetadata, -}: UseTvShowFileNameGenerationParams) { - const generateNewFileNames = useCallback( - (selectedNamingRule: "plex" | "emby"): RenameFilesPlan | null => { - if (!selectedNamingRule) { - return null - } - - if (mediaMetadata === undefined || mediaMetadata.mediaFolderPath === undefined) { - return null - } - - const tvShow = mediaMetadata.tvShow - if (!tvShow) { - return null - } - - const files = buildTvShowRenamePlanFileEntries(mediaMetadata, selectedNamingRule) - - if (files.length === 0) { - return null - } - - const renamePlan: RenameFilesPlan = { - id: crypto.randomUUID(), - task: "rename-files", - status: "pending", - creator: "app", - mediaFolderPath: mediaMetadata.mediaFolderPath, - files, - } - - return renamePlan - }, - [mediaMetadata], - ) - - return { - generateNewFileNames, - } -} diff --git a/apps/ui/src/hooks/tv/useTvShowRenaming.ts b/apps/ui/src/hooks/tv/useTvShowRenaming.ts deleted file mode 100644 index 6ade5e16..00000000 --- a/apps/ui/src/hooks/tv/useTvShowRenaming.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { useCallback } from "react" -import { toast } from "sonner" -import type { MediaMetadata } from "@smm/types" -import { Path } from "@smm/utils/path" -import { basename, extname } from "@/lib/path" -import { renameFiles } from "@/api/renameFiles" -import type { RenameFilesPlan } from "@smm/types/RenameFilesPlan" - -interface UseTvShowRenamingParams { - mediaMetadata: MediaMetadata | undefined - refreshMediaMetadata: (mediaFolderPath: string) => void - setIsRenaming: (renaming: boolean) => void -} - -/** - * Execute a batch of file renames via the unified /api/renameFiles endpoint. - * Passing `mediaFolder` causes the backend to update media metadata and broadcast - * the change in the same request, so no separate metadata API call is needed. - */ -async function renameBatch( - files: Array<{ from: string; to: string }>, - mediaFolderPath: string, -): Promise<{ successCount: number; errorCount: number; errors: string[] }> { - const result = await renameFiles({ - files: files.map(({ from, to }) => ({ - from: Path.toPlatformPath(from), - to: Path.toPlatformPath(to), - })), - mediaFolder: Path.posix(mediaFolderPath), - }) - - const successCount = result.data?.succeeded.length ?? 0 - const errorCount = result.data?.failed.length ?? 0 - const errors = (result.data?.failed ?? []).map( - ({ path, error: err }) => `${Path.toPlatformPath(path)}: ${err}` - ) - - return { successCount, errorCount, errors } -} - -export function useTvShowRenaming({ - mediaMetadata, - refreshMediaMetadata, - setIsRenaming, -}: UseTvShowRenamingParams) { - - const startToRenameFiles = useCallback(async (renamePlan: RenameFilesPlan): Promise => { - - if (mediaMetadata === undefined || mediaMetadata.mediaFolderPath === undefined) { - return false - } - - if(renamePlan.files.length === 0) { - console.warn(`skip RenameFilesPlan because no files to rename`) - return false - } - - // Collect files that need renaming, keeping video and associated separate - // so video files are always renamed before their associated files. - // When selectedEpisodeIds is provided and non-empty, only include episodes whose id (SxxEyy) is in the set. - const videoFilesToRename: Array<{ from: string; to: string }> = [] - const associatedFilesToRename: Array<{ from: string; to: string }> = [] - - if (renamePlan && renamePlan.files) { - // Use files from the rename plan - for (const { from, to } of renamePlan.files) { - // Find the media file for this path to get season/episode info - const mediaFile = mediaMetadata.mediaFiles?.find( - file => file.absolutePath === from - ); - - // Add to appropriate rename list - videoFilesToRename.push({ from, to }) - - // Process associated files (subtitles, audio, etc.) - if (mediaFile) { - if (mediaFile.subtitleFilePaths) { - for (const subtitlePath of mediaFile.subtitleFilePaths) { - // Generate new path for subtitle file - const newSubtitlePath = generateNewPathForAssociatedFile(from, to, subtitlePath); - if (subtitlePath !== newSubtitlePath) { - associatedFilesToRename.push({ from: subtitlePath, to: newSubtitlePath }); - } - } - } - - if (mediaFile.audioFilePaths) { - for (const audioPath of mediaFile.audioFilePaths) { - // Generate new path for audio file - const newAudioPath = generateNewPathForAssociatedFile(from, to, audioPath); - if (audioPath !== newAudioPath) { - associatedFilesToRename.push({ from: audioPath, to: newAudioPath }); - } - } - } - } - } - } - - const filteredVideoFiles = videoFilesToRename.filter(({ from, to }) => from !== to) - const filteredAssociatedFiles = associatedFilesToRename.filter(({ from, to }) => from !== to) - const totalFilesToRename = filteredVideoFiles.length + filteredAssociatedFiles.length - const skippedCount = - videoFilesToRename.length - filteredVideoFiles.length + - associatedFilesToRename.length - filteredAssociatedFiles.length - - if (totalFilesToRename === 0) { - if (skippedCount > 0) { - toast.info(`No files to rename (${skippedCount} file${skippedCount !== 1 ? 's' : ''} already have correct names)`) - } else { - toast.info("No files to rename") - } - setIsRenaming(false) - return false - } - - console.log( - `Starting rename: ${filteredVideoFiles.length} video file(s) and ${filteredAssociatedFiles.length} associated file(s)` + - (skippedCount > 0 ? ` (${skippedCount} skipped - identical paths)` : '') - ) - - let totalSuccess = 0 - let totalErrors = 0 - const allErrors: string[] = [] - - // Rename video files first (metadata update happens server-side via mediaFolder param) - if (filteredVideoFiles.length > 0) { - try { - const { successCount, errorCount, errors } = await renameBatch( - filteredVideoFiles, - mediaMetadata.mediaFolderPath, - ) - totalSuccess += successCount - totalErrors += errorCount - allErrors.push(...errors) - filteredVideoFiles.slice(0, successCount).forEach(({ from }) => - console.log(`✓ Renamed video file: ${Path.toPlatformPath(from)}`) - ) - errors.forEach(e => console.error(`✗ ${e}`)) - } catch (error) { - const msg = error instanceof Error ? error.message : "Unknown error" - totalErrors += filteredVideoFiles.length - filteredVideoFiles.forEach(({ from }) => { - allErrors.push(`video file ${Path.toPlatformPath(from)}: ${msg}`) - console.error(`✗ Failed to rename video file ${Path.toPlatformPath(from)}:`, error) - }) - } - } - - // Then rename associated files (subtitle, audio, nfo, poster, etc.) - if (filteredAssociatedFiles.length > 0) { - try { - const { successCount, errorCount, errors } = await renameBatch( - filteredAssociatedFiles, - mediaMetadata.mediaFolderPath, - ) - totalSuccess += successCount - totalErrors += errorCount - allErrors.push(...errors) - filteredAssociatedFiles.slice(0, successCount).forEach(({ from }) => - console.log(`✓ Renamed associated file: ${Path.toPlatformPath(from)}`) - ) - errors.forEach(e => console.error(`✗ ${e}`)) - } catch (error) { - const msg = error instanceof Error ? error.message : "Unknown error" - totalErrors += filteredAssociatedFiles.length - filteredAssociatedFiles.forEach(({ from }) => { - allErrors.push(`associated file ${Path.toPlatformPath(from)}: ${msg}`) - console.error(`✗ Failed to rename associated file ${Path.toPlatformPath(from)}:`, error) - }) - } - } - - // Backend already updated metadata via the mediaFolder param; just refresh UI state - if (totalSuccess > 0) { - refreshMediaMetadata(mediaMetadata.mediaFolderPath) - } - - const skippedMessage = skippedCount > 0 ? ` (${skippedCount} skipped)` : '' - if (totalErrors === 0) { - toast.success( - `Successfully renamed ${totalSuccess} file${totalSuccess !== 1 ? 's' : ''} ` + - `(${filteredVideoFiles.length} video, ${filteredAssociatedFiles.length} associated)${skippedMessage}` - ) - return true - } - if (totalSuccess > 0) { - toast.warning(`Renamed ${totalSuccess} file${totalSuccess !== 1 ? 's' : ''}, ${totalErrors} failed${skippedMessage}`) - console.error("Rename errors:", allErrors) - } else { - toast.error(`Failed to rename ${totalErrors} file${totalErrors !== 1 ? 's' : ''}${skippedMessage}`) - console.error("All rename operations failed:", allErrors) - } - return false - }, [mediaMetadata, refreshMediaMetadata, setIsRenaming]) - - return { - startToRenameFiles, - } -} - -/** - * Generate new path for associated files (subtitles, audio) based on the new video file path - */ -function generateNewPathForAssociatedFile(videoPath: string, newVideoPath: string, associatedPath: string): string { - // Get the base name of the video file without extension - const videoBase = basename(videoPath) ?? ''; - const videoExt = extname(videoPath); - const videoBaseName = videoExt ? videoBase.slice(0, -videoExt.length) : videoBase; - const newVideoBase = basename(newVideoPath) ?? ''; - const newVideoExt = extname(newVideoPath); - const newVideoBaseName = newVideoExt ? newVideoBase.slice(0, -newVideoExt.length) : newVideoBase; - // Replace the video base name in the associated file path - return associatedPath.replace(videoBaseName, newVideoBaseName); -} diff --git a/apps/ui/src/hooks/useDatabaseConnectionStatus.ts b/apps/ui/src/hooks/useDatabaseConnectionStatus.ts index 4950bc64..0e010014 100644 --- a/apps/ui/src/hooks/useDatabaseConnectionStatus.ts +++ b/apps/ui/src/hooks/useDatabaseConnectionStatus.ts @@ -8,7 +8,6 @@ import { type DatabaseConnectionStatus, } from "@/lib/databaseConnectionCheck" -export type { DatabaseConnectionStatus } const DATABASE_CONNECTION_CHECK_INTERVAL_MS = 60 * 1000 const DATABASE_CONNECTION_STALE_MS = 50 * 1000 diff --git a/apps/ui/src/hooks/useFfmpegProgressQuery.ts b/apps/ui/src/hooks/useFfmpegProgressQuery.ts index 13b1f8b3..faa096ab 100644 --- a/apps/ui/src/hooks/useFfmpegProgressQuery.ts +++ b/apps/ui/src/hooks/useFfmpegProgressQuery.ts @@ -43,16 +43,6 @@ export function parseHmsTime(value: string): number | null { return hours * 3600 + minutes * 60 + seconds + (Number.isFinite(fraction) ? fraction : 0) } -/** - * Match an `HH:MM:SS[.xx]` token inside a free-form line. Returns the raw - * substring (without leading/trailing whitespace) or null when no such token - * is present. - */ -function findHmsToken(line: string): string | null { - const match = /\b(\d{1,2}:\d{2}:\d{2}(?:\.\d+)?)\b/.exec(line) - return match ? match[1] : null -} - /** * Strip the CLI log line prefix `${ISO timestamp} [KIND] ` from a single * line. Returns the unprefixed payload, or the original line if the prefix @@ -231,4 +221,3 @@ export function useFfmpegProgressQuery({ // Re-export the helper used by BackgroundJobItem.tsx to find HMS tokens // inside free-form log segments (e.g. for the Log dialog "Duration" callout). -export { findHmsToken } \ No newline at end of file diff --git a/apps/ui/src/hooks/useGetTmdbMovieMutation.ts b/apps/ui/src/hooks/useGetTmdbMovieMutation.ts index 02d45c40..4b05f5c7 100644 --- a/apps/ui/src/hooks/useGetTmdbMovieMutation.ts +++ b/apps/ui/src/hooks/useGetTmdbMovieMutation.ts @@ -1,36 +1,5 @@ -import { useMutation, type UseMutationOptions } from "@tanstack/react-query" -import { useTmdbQueries } from "@/hooks/useTmdbQueries" -import type { MovieMediaMetadata, TmdbMovieDetails } from "@smm/types" -import type { TmdbRequestOptions } from "@/api/tmdb" - -/** - * Fetches TMDB movie details via cached HTTP (`fetchQuery`) and maps to {@link MovieMediaMetadata}. - * Pass `onMutate` / `onSuccess` / `onError` (and optional `meta` via variables) for component-specific UI updates. - */ -export function useGetTmdbMovieMutation< - TVariables extends { id: number; language?: string; tmdb?: TmdbRequestOptions }, - TContext = unknown, ->( - options?: Omit< - UseMutationOptions, - "mutationFn" - > -) { - const { getMovieById } = useTmdbQueries() - return useMutation({ - ...options, - mutationFn: async (variables: TVariables) => { - console.log(`useGetTmdbMovieMutation CALLED`, { ...variables }) - const details: TmdbMovieDetails = await getMovieById( - variables.id, - variables.language, - variables.tmdb - ) - return buildMovieMediaMetadata(details) - }, - }) -} +import type { MovieMediaMetadata, TmdbMovieDetails } from "@smm/types" export function buildMovieMediaMetadata(tmdbMovieDetails: TmdbMovieDetails): MovieMediaMetadata { const name = diff --git a/apps/ui/src/hooks/useGetTmdbTvShowMutation.ts b/apps/ui/src/hooks/useGetTmdbTvShowMutation.ts index 6a0f5b7f..dd6d7361 100644 --- a/apps/ui/src/hooks/useGetTmdbTvShowMutation.ts +++ b/apps/ui/src/hooks/useGetTmdbTvShowMutation.ts @@ -1,64 +1,5 @@ -import { useMutation, type UseMutationOptions } from "@tanstack/react-query" -import { useTmdbQueries } from "@/hooks/useTmdbQueries" -import type { TmdbSeriesDetails, TmdbSeasonDetails, TvShowMediaMetadata, TvShowSeasonMetadata, TvShowEpisodeMetadata } from "@smm/types" -import type { TmdbRequestOptions } from "@/api/tmdb" - -/** - * Fetches TMDB TV details via cached HTTP (`fetchQuery`) and maps to {@link TvShowMediaMetadata}. - * Pass `onMutate` / `onSuccess` / `onError` (and optional `meta` via variables) for component-specific UI updates. - */ -export function useGetTmdbTvShowMutation< - TVariables extends { id: number; language?: string; tmdb?: TmdbRequestOptions }, - TContext = unknown, ->( - options?: Omit< - UseMutationOptions, - "mutationFn" - > -) { - const { getTvShowById, getTvShowSeasonDetails } = useTmdbQueries() - - return useMutation({ - ...options, - mutationFn: async (variables: TVariables) => { - const startedAt = Date.now() - console.log(`useGetTmdbTvShowMutation CALLED`, { ...variables }) - const tmdbTvSeriesDetails: TmdbSeriesDetails = await getTvShowById( - variables.id, - variables.language, - variables.tmdb - ) - console.log(`useGetTmdbTvShowMutation series ok`, { - id: variables.id, - seasonCount: tmdbTvSeriesDetails.seasons?.length ?? 0, - durationMs: Date.now() - startedAt, - }) - const seasonDetails: TmdbSeasonDetails[] = [] - for (const season of tmdbTvSeriesDetails.seasons) { - const seasonStartedAt = Date.now() - const tmdbTvShowSeasonDetails: TmdbSeasonDetails = await getTvShowSeasonDetails( - variables.id, - season.season_number, - variables.language, - variables.tmdb - ) - console.log(`useGetTmdbTvShowMutation season ok`, { - id: variables.id, - season: season.season_number, - durationMs: Date.now() - seasonStartedAt, - }) - seasonDetails.push(tmdbTvShowSeasonDetails) - } - - console.log(`useGetTmdbTvShowMutation done`, { - id: variables.id, - durationMs: Date.now() - startedAt, - }) - return buildTvShowMediaMetadata(tmdbTvSeriesDetails, seasonDetails) - }, - }) -} +import type { TmdbSeriesDetails, TmdbSeasonDetails, TvShowMediaMetadata, TvShowSeasonMetadata, TvShowEpisodeMetadata } from "@smm/types" export function buildTvShowMediaMetadata( tmdbTvSeriesDetails: TmdbSeriesDetails, diff --git a/apps/ui/src/hooks/useGetTvdbMovieMutation.ts b/apps/ui/src/hooks/useGetTvdbMovieMutation.ts deleted file mode 100644 index fed3f494..00000000 --- a/apps/ui/src/hooks/useGetTvdbMovieMutation.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useMutation, type UseMutationOptions } from "@tanstack/react-query" -import { useTvdbQueries } from "@/hooks/useTvdbQueries" -import type { MovieMediaMetadata } from "@smm/types" - -/** - * Fetches TVDB movie details via cached HTTP (`fetchQuery`) and maps to {@link MovieMediaMetadata}. - * Pass `onMutate` / `onSuccess` / `onError` (and optional `meta` via variables) for component-specific UI updates. - */ -export function useGetTvdbMovieMutation< - TVariables extends { movieId: number; language?: string }, - TContext = unknown, ->( - options?: Omit< - UseMutationOptions, - "mutationFn" - > -) { - const { getMovieMediaMetadata } = useTvdbQueries() - - return useMutation({ - ...options, - mutationFn: async (variables: TVariables) => { - if (!Number.isFinite(variables.movieId) || variables.movieId <= 0) { - const errMsg = - `[useGetTvdbMovieMutation] Invalid movieId: ${String(variables.movieId)}` - console.error(errMsg, { variables }) - throw new Error(errMsg) - } - return getMovieMediaMetadata(variables.movieId, variables.language) - }, - }) -} diff --git a/apps/ui/src/hooks/useGetTvdbTvShowMutation.ts b/apps/ui/src/hooks/useGetTvdbTvShowMutation.ts deleted file mode 100644 index 6933ba67..00000000 --- a/apps/ui/src/hooks/useGetTvdbTvShowMutation.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useMutation, type UseMutationOptions } from "@tanstack/react-query" -import { useTvdbQueries } from "@/hooks/useTvdbQueries" -import type { TvShowMediaMetadata } from "@smm/types" - -/** - * Fetches TVDB TV details via cached HTTP (`fetchQuery`) and maps to {@link TvShowMediaMetadata}. - * Pass `onMutate` / `onSuccess` / `onError` (and optional `meta` via variables) for component-specific UI updates. - */ -export function useGetTvdbTvShowMutation< - TVariables extends { seriesId: number; language?: string }, - TContext = unknown, ->( - options?: Omit< - UseMutationOptions, - "mutationFn" - > -) { - const { getTvShowMediaMetadata } = useTvdbQueries() - - return useMutation({ - ...options, - mutationFn: async (variables: TVariables) => - getTvShowMediaMetadata(variables.seriesId, variables.language), - }) -} diff --git a/apps/ui/src/hooks/useJobOrchestrator.ts b/apps/ui/src/hooks/useJobOrchestrator.ts index d72618fa..6cecec95 100644 --- a/apps/ui/src/hooks/useJobOrchestrator.ts +++ b/apps/ui/src/hooks/useJobOrchestrator.ts @@ -6,11 +6,8 @@ * ``` */ export { useJobManager } from './useJobManager' -export type { UseJobManagerResult } from './useJobManager' + export { - useJobOrchestratorContext as useJobOrchestrator, useFileStatuses, useJobs, - type JobOrchestratorContextValue, - type StartJobResult, -} from '@/components/JobOrchestratorProvider' +} from '@/components/JobOrchestratorProvider'; diff --git a/apps/ui/src/hooks/useJobQuery.ts b/apps/ui/src/hooks/useJobQuery.ts index 6200afeb..0b80a4fa 100644 --- a/apps/ui/src/hooks/useJobQuery.ts +++ b/apps/ui/src/hooks/useJobQuery.ts @@ -3,7 +3,7 @@ import { getJobViaCore, type Job, type JobStatus } from "@/api/getJob" const DEFAULT_POLL_INTERVAL_MS = 1000 -export function jobQueryKey(jobId: string) { +function jobQueryKey(jobId: string) { return ["job", jobId] as const } diff --git a/apps/ui/src/hooks/useMcpServerStatus.ts b/apps/ui/src/hooks/useMcpServerStatus.ts index 0ff89d67..6bbe17bd 100644 --- a/apps/ui/src/hooks/useMcpServerStatus.ts +++ b/apps/ui/src/hooks/useMcpServerStatus.ts @@ -3,7 +3,7 @@ import { getMcpServerStatus, startMcpServer, stopMcpServer } from "@/api/mcp"; import type { McpServerState } from "@/api/mcp"; import { useRefreshUserConfig } from "@/hooks/userConfig/useRefreshUserConfig"; -export const mcpServerStatusQueryKey = ["mcp", "serverStatus"] as const; +const mcpServerStatusQueryKey = ["mcp", "serverStatus"] as const; /** * Fetches the MCP server runtime state from the backend. @@ -18,8 +18,6 @@ export function useMcpServerStatusQuery() { }); } -/** @deprecated Use {@link useMcpServerStatusQuery} */ -export const useMcpServerStatus = useMcpServerStatusQuery; function useInvalidateMcpCaches() { const queryClient = useQueryClient(); diff --git a/apps/ui/src/hooks/useMusicFolderSubtitlePipeline.ts b/apps/ui/src/hooks/useMusicFolderSubtitlePipeline.ts index 5b578392..bebc16fe 100644 --- a/apps/ui/src/hooks/useMusicFolderSubtitlePipeline.ts +++ b/apps/ui/src/hooks/useMusicFolderSubtitlePipeline.ts @@ -16,7 +16,7 @@ import { processPipelineDialogRowsFromMusicFileRows } from "@/lib/processPipelin export type SubtitlePipelineType = "transcribe" | "translate" | "synthesize" | "process" -export type SubtitleIndexColumnVariant = "index" | "checkbox" | "spinner" | "failed" +type SubtitleIndexColumnVariant = "index" | "checkbox" | "spinner" | "failed" export interface RowSubtitlePipelineState { transcribeStatus?: "running" | "failed" diff --git a/apps/ui/src/hooks/useOnFirstOpen.ts b/apps/ui/src/hooks/useOnFirstOpen.ts deleted file mode 100644 index 85916966..00000000 --- a/apps/ui/src/hooks/useOnFirstOpen.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect, useRef, type DependencyList } from "react" - -/** - * Runs `effect` once each time `isOpen` transitions to true. - * Resets when `isOpen` becomes false so a subsequent open can run again. - */ -export function useOnFirstOpen( - effect: () => void, - isOpen: boolean, - deps: DependencyList, -): void { - const hasRunRef = useRef(false) - - useEffect(() => { - if (!isOpen) { - hasRunRef.current = false - return - } - if (hasRunRef.current) { - return - } - hasRunRef.current = true - effect() - // eslint-disable-next-line react-hooks/exhaustive-deps -- deps are the caller's open-scoped inputs - }, [isOpen, ...deps]) -} diff --git a/apps/ui/src/hooks/useRenameVideoFileFlow.ts b/apps/ui/src/hooks/useRenameVideoFileFlow.ts index 2d29a7c8..8f0471eb 100644 --- a/apps/ui/src/hooks/useRenameVideoFileFlow.ts +++ b/apps/ui/src/hooks/useRenameVideoFileFlow.ts @@ -7,7 +7,7 @@ import { renameEpisodeFileViaCore } from "@/api/renameEpisodeFile" import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" import type { UIMediaFileDataRow } from "@/components/media/UIMediaFileTable" -export interface RenameFileDialogOptions { +interface RenameFileDialogOptions { initialValue?: string title?: string description?: string diff --git a/apps/ui/src/hooks/useResolvedLanguages.ts b/apps/ui/src/hooks/useResolvedLanguages.ts index ad15dba4..66bc6de7 100644 --- a/apps/ui/src/hooks/useResolvedLanguages.ts +++ b/apps/ui/src/hooks/useResolvedLanguages.ts @@ -4,7 +4,7 @@ import { resolveAppLanguage, resolveMediaLanguage, type ResolveAppLanguageOption import { useConfig } from "@/hooks/userConfig" import { useHelloQuery } from "@/hooks/userConfig/useHelloQuery" -export function getBrowserLocale(): string { +function getBrowserLocale(): string { if (typeof navigator !== "undefined") { return navigator.language } diff --git a/apps/ui/src/hooks/useScrapeTaskCompletionQuery.ts b/apps/ui/src/hooks/useScrapeTaskCompletionQuery.ts index f744da38..8d200481 100644 --- a/apps/ui/src/hooks/useScrapeTaskCompletionQuery.ts +++ b/apps/ui/src/hooks/useScrapeTaskCompletionQuery.ts @@ -1,9 +1,8 @@ import { useQuery } from "@tanstack/react-query" import type { MediaMetadata } from "@smm/types" import { checkTaskCompletion } from "@/lib/scrapeDialog/checkTaskCompletion" -import type { ScrapeTaskId } from "@/lib/scrapeDialog" -export function scrapeTaskCompletionQueryKey(mediaFolderPath: string) { +function scrapeTaskCompletionQueryKey(mediaFolderPath: string) { return ["scrape-task-completion", mediaFolderPath] as const } @@ -20,4 +19,3 @@ export function useScrapeTaskCompletionQuery( }) } -export type ScrapeTaskCompletion = Record diff --git a/apps/ui/src/hooks/useTmdbLanguages.ts b/apps/ui/src/hooks/useTmdbLanguages.ts index 22bdc99d..493d6cbc 100644 --- a/apps/ui/src/hooks/useTmdbLanguages.ts +++ b/apps/ui/src/hooks/useTmdbLanguages.ts @@ -13,7 +13,7 @@ const STALE_MS = 24 * 60 * 60 * 1000 * Fetch TMDB's primary translation list (IETF tags, e.g. ["zh-CN", "en-US"]). * Cached for 24h. */ -export function useTmdbPrimaryTranslations() { +function useTmdbPrimaryTranslations() { return useQuery({ queryKey: ["tmdb", "primaryTranslations"], queryFn: () => getTmdbPrimaryTranslations(), @@ -26,7 +26,7 @@ export function useTmdbPrimaryTranslations() { * Fetch TMDB's ISO 639-1 language list with English and native names. * Cached for 24h. */ -export function useTmdbLanguagesRaw() { +function useTmdbLanguagesRaw() { return useQuery({ queryKey: ["tmdb", "languages"], queryFn: () => getTmdbLanguages(), diff --git a/apps/ui/src/hooks/useTvShowPanel.ts b/apps/ui/src/hooks/useTvShowPanel.ts index c204717e..b036f9d8 100644 --- a/apps/ui/src/hooks/useTvShowPanel.ts +++ b/apps/ui/src/hooks/useTvShowPanel.ts @@ -17,7 +17,7 @@ const INIT_METADATA_FILES: MetadataFiles = { themePath: undefined, }; -export function findMetadataFiles(metadata: MediaMetadata, files: string[]) { +function findMetadataFiles(metadata: MediaMetadata, files: string[]) { const images = findFilesByExtensions(files, extensions.imageFileExtensions) return { @@ -40,19 +40,19 @@ export function findMetadataFiles(metadata: MediaMetadata, files: string[]) { } } -export function findThumbnails(files: string[], videoFile: string): string[] { +function findThumbnails(files: string[], videoFile: string): string[] { const videoFileExt = extname(videoFile) const possibleThumbnailFilePaths = imageFileExtensions.map(ext => `${videoFile.replace(videoFileExt, ext)}`) return files.filter(file => possibleThumbnailFilePaths.includes(file)) } -export function findSubtitles(files: string[], videoFile: string): string[] { +function findSubtitles(files: string[], videoFile: string): string[] { const videoFileExt = extname(videoFile) const possibleSubtitleFilePaths = subtitleFileExtensions.map(ext => `${videoFile.replace(videoFileExt, ext)}`) return files.filter(file => possibleSubtitleFilePaths.includes(file)) } -export function findNfos(files: string[], videoFile: string): string[] { +function findNfos(files: string[], videoFile: string): string[] { const videoFileExt = extname(videoFile) const nfoFilePath = `${videoFile.replace(videoFileExt, '.nfo')}` return files.filter(file => file === nfoFilePath) diff --git a/apps/ui/src/hooks/useTvdbLanguages.ts b/apps/ui/src/hooks/useTvdbLanguages.ts index d4fd6bc4..d56bb064 100644 --- a/apps/ui/src/hooks/useTvdbLanguages.ts +++ b/apps/ui/src/hooks/useTvdbLanguages.ts @@ -27,7 +27,7 @@ function useTvdbRequestOptions(): GetTVDBv4ClientOverrides { * Fetch TVDB's full list of supported languages (ISO 639-3 records). * Cached for 24h. */ -export function useTvdbLanguages() { +function useTvdbLanguages() { const options = useTvdbRequestOptions() return useQuery({ queryKey: ["tvdb", "languages"], diff --git a/apps/ui/src/hooks/useWebSocket.ts b/apps/ui/src/hooks/useWebSocket.ts index fe8fb400..c51f1699 100644 --- a/apps/ui/src/hooks/useWebSocket.ts +++ b/apps/ui/src/hooks/useWebSocket.ts @@ -22,19 +22,6 @@ type WebSocketEventListener = (message: WebSocketMessage) => void; const webSocketEventListeners = new Set(); let activeSocket: Socket | null = null; -export function sendWebSocketMessage(message: WebSocketMessage): void { - if (activeSocket?.connected) { - try { - activeSocket.emit(message.event, message.data); - } catch (error) { - console.error('[Socket.IO] Error sending message:', error); - } - return; - } - - console.warn('[Socket.IO] Cannot send message: Socket is not connected'); -} - /** * Register a Socket.IO event handler. * @@ -56,15 +43,6 @@ export function useWebSocketEvent(handler: (message: WebSocketMessage) => void): }, []); } -/** - * React hook that returns a stable send function for the active Socket.IO connection. - */ -export function useWebSocketSend() { - return useCallback((message: WebSocketMessage) => { - sendWebSocketMessage(message); - }, []); -} - /** * Helper to send acknowledgement for Socket.IO events * This should be called by event handlers that receive events with acknowledgements diff --git a/apps/ui/src/hooks/userConfig/index.ts b/apps/ui/src/hooks/userConfig/index.ts index 20db7de9..4bec92e8 100644 --- a/apps/ui/src/hooks/userConfig/index.ts +++ b/apps/ui/src/hooks/userConfig/index.ts @@ -1,11 +1,4 @@ export { AppLanguageSync } from "./AppLanguageSync" -export { useAddMediaFolderMutation } from "./useAddMediaFolderMutation" -export { useConfig, type UseConfigResult } from "./useConfig" -export type { ReloadCallback } from "./useReloadAppConfig" -export { useHelloQuery } from "./useHelloQuery" +export { useConfig} from "./useConfig" export { useRefreshUserConfig } from "./useRefreshUserConfig" -export { useReloadAppConfig } from "./useReloadAppConfig" export { useSaveUserConfigMutation } from "./useSaveUserConfigMutation" -export { useSetUserConfigInCache } from "./useSetUserConfigInCache" -export { useUserConfigQuery } from "./useUserConfigQuery" -export { useUserConfig } from "./userConfigHooks" diff --git a/apps/ui/src/hooks/userConfig/userConfigHooks.ts b/apps/ui/src/hooks/userConfig/userConfigHooks.ts deleted file mode 100644 index e553e175..00000000 --- a/apps/ui/src/hooks/userConfig/userConfigHooks.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useConfig } from "./useConfig" - -/** Subscribe to persisted user settings (`smm.json`) via TanStack Query cache. */ -export function useUserConfig() { - const { userConfig, isLoading, error } = useConfig() - return { - data: userConfig, - isLoading, - isPending: isLoading, - error, - } -} - -export { useAddMediaFolderMutation } from "./useAddMediaFolderMutation" -export { useSaveUserConfigMutation } from "./useSaveUserConfigMutation" diff --git a/apps/ui/src/hooks/ytdlp/useYtdlpMutations.ts b/apps/ui/src/hooks/ytdlp/useYtdlpMutations.ts deleted file mode 100644 index 90ae43e2..00000000 --- a/apps/ui/src/hooks/ytdlp/useYtdlpMutations.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { - downloadYtdlpVideo, - extractYtdlpVideoData, - listYtdlpFormats, - type YtdlpListFormatsRequest, -} from "@/api/ytdlp"; -import { useFeatures } from "@/hooks/useFeatures"; - -export function useExtractYtdlpVideoDataMutation() { - return useMutation({ - mutationFn: (url: string) => extractYtdlpVideoData(url), - }); -} - -export function useListYtdlpFormatsMutation() { - return useMutation({ - mutationFn: (req: YtdlpListFormatsRequest) => listYtdlpFormats(req), - }); -} - -export interface DownloadYtdlpVideosInput { - urls: string[]; - folder: string; - args: string[]; -} - -/** - * Runs yt-dlp downloads sequentially for each URL (same folder and args). - */ -export function useDownloadYtdlpVideosMutation() { - const { enablePrintArgInYtdlpCommand } = useFeatures() - const printArg = enablePrintArgInYtdlpCommand ? 'after_move:filepath' : undefined - - return useMutation({ - mutationFn: async (input: DownloadYtdlpVideosInput) => { - const results: Awaited>[] = []; - for (const url of input.urls) { - results.push( - await downloadYtdlpVideo({ - url, - folder: input.folder, - args: input.args, - printArg, - }) - ); - } - return results; - }, - }); -} diff --git a/apps/ui/src/lib/TmdbUtils.ts b/apps/ui/src/lib/TmdbUtils.ts deleted file mode 100644 index 0d0ea215..00000000 --- a/apps/ui/src/lib/TmdbUtils.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { getMovieById } from "@/api/tmdb"; -import { tvShowMediaMetadataFromTmdbDetails } from "./tvShowMediaMetadataFromTmdbDetails"; -import type { - MovieMediaMetadata, - TmdbMovieDetails, - TmdbSeriesDetails, - TvShowMediaMetadata, -} from "@smm/types"; - -function movieMediaMetadataFromTmdbMovie(movie: TmdbMovieDetails): MovieMediaMetadata { - const name = movie.title?.trim() || movie.original_title?.trim() || ""; - return { - id: String(movie.id), - name, - airDate: movie.release_date, - database: "TMDB", - }; -} - -/** - * @deprecated - * @param details - * @param id - * @returns - */ -export function buildTvShowMediaMetadataFromTmdbSeriesDetails( - details: TmdbSeriesDetails, -): TvShowMediaMetadata { - return tvShowMediaMetadataFromTmdbDetails(details); -} - -export async function getMovieByIdFromTMDB( - id: number, - language?: string, - signal?: AbortSignal): Promise { - const movie = await getMovieById(id, language, { signal }); - return movieMediaMetadataFromTmdbMovie(movie); -} \ No newline at end of file diff --git a/apps/ui/src/lib/TvdbUtils.ts b/apps/ui/src/lib/TvdbUtils.ts index 7232e398..86bc8abd 100644 --- a/apps/ui/src/lib/TvdbUtils.ts +++ b/apps/ui/src/lib/TvdbUtils.ts @@ -10,7 +10,7 @@ import { isCustomUpstream } from "@/lib/mediaDatabaseAccess" export const SMM_TVDB_DEFAULT_UPSTREAM = 'https://mediadb.vercel.app/api/tvdb' -export interface TvdbUpstream { +interface TvdbUpstream { reverseProxyUrl: string | null upstreamBaseURL: string apiKey?: string @@ -28,24 +28,6 @@ function isParallelTranslationEnabled(): boolean { } } -/** - * The TVDB search API return object id in form "series-421069". - * This function return the number id extracted from the object id. - */ -export function extractSeriesId(objectId: string): number { - const str = objectId.replace('series-', '').trim() - return parseInt(str, 10) -} - -/** - * TVDB search returns movie `objectID` / `id` like "movie-15778". - */ -export function extractMovieId(objectId: string): number { - const str = objectId.replace(/^movie-/i, '').trim() - const n = parseInt(str, 10) - return Number.isFinite(n) && n > 0 ? n : NaN -} - export interface GetTVDBv4ClientOverrides { reverseProxyUrl?: string | null upstreamBaseURL?: string @@ -454,7 +436,7 @@ export async function fetchTvdbAndBuildMovieMediaMetadata( * Map IETF BCP 47 / RFC 5646 lang code to ISO 639 lang code(which is used by TVDB) * For example, zh-CN -> zho */ -export function mapToTvdbLangCode(lang: "zh-CN" | "en-US" | "ja-JP"): string { +function mapToTvdbLangCode(lang: "zh-CN" | "en-US" | "ja-JP"): string { switch(lang) { case "zh-CN": return "zho" diff --git a/apps/ui/src/lib/ai-provider-presets.ts b/apps/ui/src/lib/ai-provider-presets.ts index 5a73df79..144a0d63 100644 --- a/apps/ui/src/lib/ai-provider-presets.ts +++ b/apps/ui/src/lib/ai-provider-presets.ts @@ -8,7 +8,7 @@ export interface AiProvider { * Common OpenAI-compatible providers for the AI settings UI. * Not persisted to user config; used only for combobox suggestions and defaults. */ -export const COMMON_AI_PROVIDERS: AiProvider[] = [ +const COMMON_AI_PROVIDERS: AiProvider[] = [ { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com', diff --git a/apps/ui/src/lib/ai-provider.ts b/apps/ui/src/lib/ai-provider.ts deleted file mode 100644 index 8352bccb..00000000 --- a/apps/ui/src/lib/ai-provider.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; - -export const DEEPSEEK_MODEL = 'deepseek-v4-flash'; - -// Get API key from environment (works in both client and server contexts) -function getApiKey(): string { - // In server context (Node.js), use process.env - try { - - const nodeProcess = (globalThis as any).process; - if (nodeProcess?.env) { - return nodeProcess.env.VITE_DEEPSEEK_API_KEY || nodeProcess.env.DEEPSEEK_API_KEY || 'sk-ce25f3132fbc4b599f0f26eede96d390'; - } - } catch { - // process not available - } - - // In client context, use import.meta.env - if (typeof import.meta !== 'undefined') { - - const metaEnv = (import.meta as any).env; - if (metaEnv) { - return metaEnv.VITE_DEEPSEEK_API_KEY || 'sk-ce25f3132fbc4b599f0f26eede96d390'; - } - } - - // Fallback (for development - should use environment variables in production) - return 'sk-ce25f3132fbc4b599f0f26eede96d390'; -} - -// Create DeepSeek provider configuration (lazy initialization) -let _deepseekProvider: ReturnType | null = null; - -export function getDeepseekProvider() { - if (!_deepseekProvider) { - _deepseekProvider = createOpenAICompatible({ - name: 'DeepSeek', - baseURL: 'https://api.deepseek.com/v1', - apiKey: getApiKey(), - }); - } - return _deepseekProvider; -} - diff --git a/apps/ui/src/lib/ai.ts b/apps/ui/src/lib/ai.ts deleted file mode 100644 index c7adf40c..00000000 --- a/apps/ui/src/lib/ai.ts +++ /dev/null @@ -1,210 +0,0 @@ -import type { MediaFileMatchResult, MediaFileMetadata, TMDBTVShowDetails } from "@smm/types" - -function toMarkdown(tvShow: TMDBTVShowDetails) { - const lines: string[] = [] - - // Title - lines.push(`# ${tvShow.name}`) - lines.push('') - - // Original name if different - if (tvShow.original_name && tvShow.original_name !== tvShow.name) { - lines.push(`**Original Name:** ${tvShow.original_name}`) - lines.push('') - } - - // Overview - if (tvShow.overview) { - lines.push(`## Overview`) - lines.push('') - lines.push(tvShow.overview) - lines.push('') - } - - // Basic Information - lines.push(`## Information`) - lines.push('') - if (tvShow.first_air_date) { - lines.push(`- **First Air Date:** ${tvShow.first_air_date}`) - } - if (tvShow.last_air_date) { - lines.push(`- **Last Air Date:** ${tvShow.last_air_date}`) - } - if (tvShow.status) { - lines.push(`- **Status:** ${tvShow.status}`) - } - if (tvShow.type) { - lines.push(`- **Type:** ${tvShow.type}`) - } - lines.push(`- **Number of Seasons:** ${tvShow.number_of_seasons}`) - lines.push(`- **Number of Episodes:** ${tvShow.number_of_episodes}`) - lines.push(`- **In Production:** ${tvShow.in_production ? 'Yes' : 'No'}`) - if (tvShow.origin_country && tvShow.origin_country.length > 0) { - lines.push(`- **Origin Country:** ${tvShow.origin_country.join(', ')}`) - } - lines.push('') - - // Ratings - if (tvShow.vote_average > 0) { - lines.push(`## Ratings`) - lines.push('') - lines.push(`- **Average Rating:** ${tvShow.vote_average.toFixed(1)}/10`) - lines.push(`- **Vote Count:** ${tvShow.vote_count.toLocaleString()}`) - lines.push(`- **Popularity:** ${tvShow.popularity.toFixed(1)}`) - lines.push('') - } - - // Networks - if (tvShow.networks && tvShow.networks.length > 0) { - lines.push(`## Networks`) - lines.push('') - tvShow.networks.forEach(network => { - lines.push(`- ${network.name}`) - }) - lines.push('') - } - - // Production Companies - if (tvShow.production_companies && tvShow.production_companies.length > 0) { - lines.push(`## Production Companies`) - lines.push('') - tvShow.production_companies.forEach(company => { - lines.push(`- ${company.name}`) - }) - lines.push('') - } - - // Seasons - if (tvShow.seasons && tvShow.seasons.length > 0) { - lines.push(`## Seasons`) - lines.push('') - tvShow.seasons.forEach(season => { - lines.push(`### ${season.name || `Season ${season.season_number}`}`) - lines.push('') - if (season.overview) { - lines.push(season.overview) - lines.push('') - } - lines.push(`- **Season Number:** ${season.season_number}`) - lines.push(`- **Episode Count:** ${season.episode_count}`) - if (season.air_date) { - lines.push(`- **Air Date:** ${season.air_date}`) - } - lines.push('') - - // Episodes - if (season.episodes && season.episodes.length > 0) { - lines.push(`#### Episodes`) - lines.push('') - season.episodes.forEach(episode => { - lines.push(`**Episode ${episode.episode_number}: ${episode.name || 'Untitled'}**`) - if (episode.air_date) { - lines.push(`- Air Date: ${episode.air_date}`) - } - if (episode.runtime) { - lines.push(`- Runtime: ${episode.runtime} minutes`) - } - if (episode.vote_average > 0) { - lines.push(`- Rating: ${episode.vote_average.toFixed(1)}/10 (${episode.vote_count} votes)`) - } - if (episode.overview) { - lines.push(`- ${episode.overview}`) - } - lines.push('') - }) - } - }) - } - - return lines.join('\n') -} - -function toDisplayString(tvShow: TMDBTVShowDetails) { - return toMarkdown(tvShow) -} - -export const templates = { - /** - * Ask AI to guess the media name from the folder name - * @param folderName - */ - mediaName: function(folderName: string) { - return `This is a media folder name: -${folderName} -Tell me the possible TVShow name in TMDB in zh-CN. -You should answer ONLY the name, no other text. -If no possible TVShow name is found, answer empty text. - ` - }, - - matchFilesToEpisode: function(files: string[], tvShow: TMDBTVShowDetails) { - return `You're an assistant for a media manager. - You will be given a list of local files and media information. - - The files are: - ${files.join("\n")} - - The media is: - ${toDisplayString(tvShow)} - - You need to find the local file for each episode given above. - You should answer for each file in below format, which represents the season number and episode number, followed by file name. - {SXXEXX}:{file} - For example: - S01E01:/path/to/file1.mp4 - S01E02:/path/to/file2.mp4 - S01E03:/path/to/file3.mp4 - or in Windows platform: - S01E04:C:\\path\\to\\file4.mp4 - S01E05:\\\\NetworkVolumn\\to\\file5.mp4 - - You should answer ONLY the matches, no other text. - If one file does not match any episode, ignore it.` - }, - - matchFilesToEpisodeForGeneratingObject: function(files: string[], tvShow: TMDBTVShowDetails) { -return `You're an assistant for a media manager. -You will be given a list of local files and media information. - -The files are: -${files.join("\n")} - -The media is: -${toDisplayString(tvShow)} - -You need to match the local files to each episodes and return the result in JSON format. -` - } -} - -/** - * - * @param tvshow - * @param files File paths in POSIX format - * @param matches - */ -export function generateMediaFileMetadatas(files: string[], matches: MediaFileMatchResult[]) { - const mediaFiles: MediaFileMetadata[] = [] - - matches.forEach(match => { - const file = files.find(file => file === match.path) - - if(!file) { - console.error(`[generateMediaFileMetadatas] File not found: ${match.path}`) - return; - } - - const mediaFile: MediaFileMetadata = { - absolutePath: file, - seasonNumber: parseInt(match.seasonNumber), - episodeNumber: parseInt(match.episodeNumber), - } - - mediaFiles.push(mediaFile) - }) - - console.log(`[generateMediaFileMetadatas] generated media files: `, mediaFiles) - - return mediaFiles; - -} \ No newline at end of file diff --git a/apps/ui/src/lib/assetImageUrls.ts b/apps/ui/src/lib/assetImageUrls.ts deleted file mode 100644 index cdf19c36..00000000 --- a/apps/ui/src/lib/assetImageUrls.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { DiscoverConfig, MediaDatabaseType } from "@/api/discover" - -export const TMDB_IMAGE_HOSTS = new Set(["image.tmdb.org"]) -export const TVDB_ARTWORK_HOSTS = new Set(["artworks.thetvdb.com"]) - -function normalizeImageUrl(url: string): string { - if (url.startsWith("//")) return `https:${url}` - return url -} - -function hostSwap(originalUrl: string, assetBaseUrl: string): string | null { - try { - const original = new URL(originalUrl) - const base = new URL(assetBaseUrl) - const swapped = new URL(original.href) - swapped.protocol = base.protocol - swapped.host = base.host - // Keep pathname/search/hash from the original CDN URL. - // If asset base includes a path prefix, join it in front of original pathname. - const basePath = base.pathname.replace(/\/$/, "") - if (basePath && basePath !== "") { - swapped.pathname = `${basePath}${original.pathname}` - } - return swapped.href - } catch { - return null - } -} - -function getOverrideDefaultTmdbAssetServerHost(): string | null { - if (typeof localStorage === 'undefined') return null - try { - return localStorage.getItem('debug.overrideDefaultTmdbAssetServerHost') - } catch { - return null - } -} - -function assetTypeForHost(hostname: string): MediaDatabaseType | null { - if (TMDB_IMAGE_HOSTS.has(hostname)) return "tmdb-asset" - if (TVDB_ARTWORK_HOSTS.has(hostname)) return "tvdb-asset" - return null -} - -/** - * Build ordered image URL candidates: official CDN first, then discover asset mirrors (host-swap). - */ -export function buildAssetUrlCandidates( - url: string, - config: DiscoverConfig, -): string[] { - const normalized = normalizeImageUrl(url) - let hostname = "" - try { - hostname = new URL(normalized).hostname - } catch { - return [url] - } - - const assetType = assetTypeForHost(hostname) - const candidates: string[] = [normalized] - if (!assetType) return candidates - - for (const entry of config.mediaDatabases) { - if (entry.type !== assetType) continue - const swapped = hostSwap(normalized, entry.url) - if (!swapped) continue - if (!candidates.includes(swapped)) candidates.push(swapped) - } - // Debug override: replace first candidate's host to simulate CDN failure - // for testing failover to discover asset mirrors. - const overrideHost = getOverrideDefaultTmdbAssetServerHost() - if (overrideHost && assetType === 'tmdb-asset' && candidates.length > 0) { - try { - const overridden = new URL(candidates[0]) - overridden.host = overrideHost - candidates[0] = overridden.href - } catch { - // ignore invalid override host - } - } - - return candidates -} diff --git a/apps/ui/src/lib/assetImageUrlsUi.ts b/apps/ui/src/lib/assetImageUrlsUi.ts index 04d56097..af012d6a 100644 --- a/apps/ui/src/lib/assetImageUrlsUi.ts +++ b/apps/ui/src/lib/assetImageUrlsUi.ts @@ -4,12 +4,7 @@ import { } from "@smm/core/pipeline/scrape/assetImageUrls"; import type { DiscoverConfig } from "@/api/discover"; -export { - TMDB_IMAGE_HOSTS, - TVDB_ARTWORK_HOSTS, - hostSwap, - assetTypeForHost, -} from "@smm/core/pipeline/scrape/assetImageUrls"; + function readDebugOverrideHost(): string | null { if (typeof localStorage === "undefined") return null; diff --git a/apps/ui/src/lib/associatedFilesUi.ts b/apps/ui/src/lib/associatedFilesUi.ts index 63db6c7d..7f437260 100644 --- a/apps/ui/src/lib/associatedFilesUi.ts +++ b/apps/ui/src/lib/associatedFilesUi.ts @@ -1,4 +1,4 @@ -import { Path } from "@smm/utils/path"; + import { findAssociatedFiles as findAssociatedPaths } from "@smm/core/pipeline/findAssociatedFiles"; import { extensions, @@ -7,7 +7,7 @@ import { } from "@smm/types/mediaFileExtensions"; import { basename, relative } from "@/lib/path"; -export type AssociatedFileTag = "SUB" | "AUD" | "NFO" | "POSTER" | "VID"; +type AssociatedFileTag = "SUB" | "AUD" | "NFO" | "POSTER" | "VID"; export interface TaggedAssociatedFile { path: string; @@ -51,14 +51,3 @@ export function findAssociatedFiles( newPath: "N/A", })); } - -/** Absolute POSIX paths (same as pure). */ -export function findAssociatedFilePaths( - mediaFolderPath: string, - filePaths: string[], - videoFilePath: string, -): string[] { - return findAssociatedPaths(mediaFolderPath, filePaths, videoFilePath).map((p) => - Path.posix(p), - ); -} diff --git a/apps/ui/src/lib/buildTvShowRenamePlanFileEntries.ts b/apps/ui/src/lib/buildTvShowRenamePlanFileEntries.ts deleted file mode 100644 index 5588da20..00000000 --- a/apps/ui/src/lib/buildTvShowRenamePlanFileEntries.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { MediaMetadata } from "@smm/types" -import { generateNewFileName } from "@/lib/renameRules" -import { join } from "@/lib/path" -import { mediaFilePathEqual } from "@/lib/mediaFilePathEqual" - -/** - * Build rename plan entries for a TV show using the selected naming rule. - * Omits episodes whose generated path equals the current video path. - */ -export function buildTvShowRenamePlanFileEntries( - mediaMetadata: MediaMetadata, - selectedNamingRule: "plex" | "emby", -): Array<{ from: string; to: string }> { - const files: Array<{ from: string; to: string }> = [] - const tvShow = mediaMetadata.tvShow - const mediaFolderPath = mediaMetadata.mediaFolderPath - - if (!tvShow || !mediaFolderPath) { - return files - } - - for (const season of tvShow.seasons) { - if (!season.episodes) continue - - for (const episode of season.episodes) { - const mediaFile = mediaMetadata.mediaFiles?.find( - (file) => - file.seasonNumber === season.season && file.episodeNumber === episode.episode, - ) - - if (!mediaFile) continue - - const relativePath = generateNewFileName(selectedNamingRule, { - type: "tv", - seasonNumber: season.season, - episodeNumber: episode.episode, - episodeName: episode.name || "", - tvshowName: tvShow.name || "", - file: mediaFile.absolutePath, - tmdbId: tvShow.id?.toString() || "", - releaseYear: tvShow.airDate ?? "", - }) - - const absolutePath = join(mediaFolderPath, relativePath) - - if (mediaFilePathEqual(mediaFile.absolutePath, absolutePath)) { - continue - } - - files.push({ - from: mediaFile.absolutePath, - to: absolutePath, - }) - } - } - - return files -} diff --git a/apps/ui/src/lib/downloadTaskDb.ts b/apps/ui/src/lib/downloadTaskDb.ts index d8628228..6085eff2 100644 --- a/apps/ui/src/lib/downloadTaskDb.ts +++ b/apps/ui/src/lib/downloadTaskDb.ts @@ -1,10 +1,4 @@ -import type { - DownloadVideoBackgroundJob, - ProcessBackgroundJob, - SynthesizeBackgroundJob, - TranscribeBackgroundJob, - TranslateBackgroundJob, -} from '@/types/background-jobs' + const DB_NAME = 'DownloadTaskDatabase' const DB_VERSION = 1 @@ -27,7 +21,7 @@ export interface TaskJobRecord { /** @deprecated Use {@link TaskJobRecord} */ export type DownloadJobRecord = TaskJobRecord -export function openDownloadTaskDB(): Promise { +function openDownloadTaskDB(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION) request.onupgradeneeded = () => { @@ -88,7 +82,7 @@ export function isWithinOneHour(createdAt: number): boolean { return Date.now() - createdAt < ONE_HOUR_MS } -export function jobRecordActivityTime(record: TaskJobRecord): number { +function jobRecordActivityTime(record: TaskJobRecord): number { return record.updatedAt || record.createdAt || 0 } @@ -107,117 +101,10 @@ export function selectRecordsForBackgroundJobsUi(records: TaskJobRecord[]): Task .slice(0, MAX_BACKGROUND_JOBS_UI) } -export interface GetJobsByTypeAndFolderOptions { - /** When true (default), omit records with status `succeeded`. */ - excludeSucceeded?: boolean - /** When true (default), only include jobs created within the last hour. */ - withinOneHour?: boolean -} - -export async function getJobsByTypeAndFolder( - jobType: string, - folder: string, - options: GetJobsByTypeAndFolderOptions = {}, -): Promise { - const { excludeSucceeded = true, withinOneHour = true } = options - const records = await getAllJobs() - return records.filter((r) => { - if (r.type !== jobType || r.folder !== folder) return false - if (withinOneHour && !isWithinOneHour(r.createdAt)) return false - if (excludeSucceeded && r.status === 'succeeded') return false - return true - }) -} - export function notifyIndexedDbUpdated(): void { window.dispatchEvent(new CustomEvent('indexed-updated')) } -export async function saveDownloadVideoJob(job: DownloadVideoBackgroundJob): Promise { - const now = Date.now() - const record: TaskJobRecord = { - id: job.id, - name: job.name, - status: job.status, - progress: job.progress, - type: job.type, - folder: job.data.folder, - data: JSON.stringify(job.data), - createdAt: now, - updatedAt: now, - } - await putJob(record) - notifyIndexedDbUpdated() -} - -export async function saveTranslateJob(job: TranslateBackgroundJob): Promise { - const now = Date.now() - const record: TaskJobRecord = { - id: job.id, - name: job.name, - status: job.status, - progress: job.progress, - type: job.type, - folder: job.data.folder, - data: JSON.stringify(job.data), - createdAt: now, - updatedAt: now, - } - await putJob(record) - notifyIndexedDbUpdated() -} - -export async function saveSynthesizeJob(job: SynthesizeBackgroundJob): Promise { - const now = Date.now() - const record: TaskJobRecord = { - id: job.id, - name: job.name, - status: job.status, - progress: job.progress, - type: job.type, - folder: job.data.folder, - data: JSON.stringify(job.data), - createdAt: now, - updatedAt: now, - } - await putJob(record) - notifyIndexedDbUpdated() -} - -export async function saveProcessJob(job: ProcessBackgroundJob): Promise { - const now = Date.now() - const record: TaskJobRecord = { - id: job.id, - name: job.name, - status: job.status, - progress: job.progress, - type: job.type, - folder: job.data.folder, - data: JSON.stringify(job.data), - createdAt: now, - updatedAt: now, - } - await putJob(record) - notifyIndexedDbUpdated() -} - -export async function saveTranscribeJob(job: TranscribeBackgroundJob): Promise { - const now = Date.now() - const record: TaskJobRecord = { - id: job.id, - name: job.name, - status: job.status, - progress: job.progress, - type: job.type, - folder: job.data.folder, - data: JSON.stringify(job.data), - createdAt: now, - updatedAt: now, - } - await putJob(record) - notifyIndexedDbUpdated() -} - /** * Find all pending jobs that share the given parentId and mark them as * aborted. Used when a batch job fails so remaining queued siblings are diff --git a/apps/ui/src/lib/downloadVideoJobFactory.ts b/apps/ui/src/lib/downloadVideoJobFactory.ts index 2054e077..423a9557 100644 --- a/apps/ui/src/lib/downloadVideoJobFactory.ts +++ b/apps/ui/src/lib/downloadVideoJobFactory.ts @@ -1,9 +1,4 @@ -import type { - DownloadVideoBackgroundJob, - DownloadVideoBackgroundJobData, - DownloadVideoJobVideo, - JobStatus, -} from '@/types/background-jobs' +import type { DownloadVideoBackgroundJob, DownloadVideoBackgroundJobData, DownloadVideoJobVideo } from '@/types/background-jobs' export function createDownloadVideoJobId(): string { return `job-${Date.now()}-${Math.random().toString(36).slice(2, 11)}` @@ -76,18 +71,3 @@ export function buildDownloadVideoJob(input: CreateDownloadVideoJobInput): Downl parentId: input.parentId, } } - -export function recomputeDownloadVideoJobProgress(data: DownloadVideoBackgroundJobData): number { - if (data.videos.length === 0) return 0 - const done = data.videos.filter((i) => i.status === 'succeeded' || i.status === 'failed').length - return (done / data.videos.length) * 100 -} - -export function deriveDownloadVideoJobStatus(data: DownloadVideoBackgroundJobData): JobStatus { - if (data.videos.length === 0) return 'pending' - if (data.videos.some((i) => i.status === 'failed')) return 'failed' - if (data.videos.every((i) => i.status === 'succeeded')) return 'succeeded' - if (data.videos.some((i) => i.status === 'downloading')) return 'running' - if (data.videos.some((i) => i.status === 'pending')) return 'pending' - return 'succeeded' -} diff --git a/apps/ui/src/lib/frontendLogFlusher.ts b/apps/ui/src/lib/frontendLogFlusher.ts index 9b50b438..79675fd1 100644 --- a/apps/ui/src/lib/frontendLogFlusher.ts +++ b/apps/ui/src/lib/frontendLogFlusher.ts @@ -3,7 +3,7 @@ import { FrontendLogBuffer } from "./frontendLogBuffer"; import { getAuthToken, buildAuthorizationHeader } from "@/lib/authToken"; const FLUSH_INTERVAL_MS = 2_000; -export const FLUSH_THRESHOLD = 50; +const FLUSH_THRESHOLD = 50; const ENDPOINT = "/api/log"; const BLOB_TYPE = "application/json"; diff --git a/apps/ui/src/lib/harmonyOSDisabledFeatures.ts b/apps/ui/src/lib/harmonyOSDisabledFeatures.ts deleted file mode 100644 index 1c47214e..00000000 --- a/apps/ui/src/lib/harmonyOSDisabledFeatures.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * HarmonyOS-disabled UI features. Keep in sync with - * `docs/superpowers/design/harmonyos-integration.md` §6 Disabled Features. - * - * The AI Summary (MusicPanel right-click → Summarize) flow is gated via the - * master `isAiFeatureEnabled` flag, which defaults to `false` on HarmonyOS - * (see `apps/ui/src/hooks/useFeatures.ts` `readAiFeatureEnabled`). MCP/backend - * plan prompts (`AiBasedRecognizeEpisodePrompt`, `AiBasedRenameEpisodePrompt`) are not - * gated by that flag — pending `creator: "ai"` plans must always be confirmable. - */ -export const HARMONYOS_DISABLED_FEATURE_IDS = [ - /** Transcribe / translate / synthesize / process pipeline (字幕) */ - "subtitle", - /** yt-dlp download video dialog and music panel download */ - "downloadVideo", - /** FFmpeg format converter dialog (视频转码) */ - "formatConverter", - /** Video compression dialog (视频压缩) */ - "videoCompression", - /** Music folder type in OpenFolderDialog (导入音乐文件夹) */ - "musicFolderImport", -] as const - -export type HarmonyOSDisabledFeatureId = (typeof HARMONYOS_DISABLED_FEATURE_IDS)[number] diff --git a/apps/ui/src/lib/isRuleBasedRecognizePlanComplete.test.ts b/apps/ui/src/lib/isRuleBasedRecognizePlanComplete.test.ts index 501de51f..dc53d96d 100644 --- a/apps/ui/src/lib/isRuleBasedRecognizePlanComplete.test.ts +++ b/apps/ui/src/lib/isRuleBasedRecognizePlanComplete.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { isRuleBasedRecognizePlanComplete, isRuleBasedRecognizePlanFullyUnchanged } from './isRuleBasedRecognizePlanComplete' -import type { UIMediaMetadata } from '@/types/UIMediaMetadata' +import type { MediaMetadata } from '@smm/types' const tvShowTwoEpisodes = { id: '1', @@ -18,7 +18,7 @@ const tvShowTwoEpisodes = { ], } -function mediaMetadata(overrides: Partial = {}): UIMediaMetadata { +function mediaMetadata(overrides: Partial = {}): MediaMetadata { return { status: 'ok', mediaFolderPath: '/media/show', diff --git a/apps/ui/src/lib/jobRecordMapper.ts b/apps/ui/src/lib/jobRecordMapper.ts index 90320df2..941ced49 100644 --- a/apps/ui/src/lib/jobRecordMapper.ts +++ b/apps/ui/src/lib/jobRecordMapper.ts @@ -26,11 +26,7 @@ import { isTranscribeBackgroundJob, isTranslateBackgroundJob, } from '@/types/background-jobs' -import { - getAllJobs, - selectRecordsForBackgroundJobsUi, - type TaskJobRecord, -} from '@/lib/downloadTaskDb' +import { type TaskJobRecord } from '@/lib/downloadTaskDb' import { useBackgroundJobsStore } from '@/stores/backgroundJobsStore' function applyCommandLogCorrelation( @@ -481,14 +477,3 @@ export function syncJobRecordsToStore(records: TaskJobRecord[]): void { jobs: [...state.jobs.filter((j) => !isPersistedFromIdbJob(j)), ...mapped], })) } - -/** - * Load all jobs from IndexedDB, filter to within-one-hour, and return. - * Also syncs to the Zustand store as a side-effect. - */ -export async function loadAndSyncJobs(): Promise { - const records = await getAllJobs() - const filtered = selectRecordsForBackgroundJobsUi(records) - syncJobRecordsToStore(filtered) - return filtered -} diff --git a/apps/ui/src/lib/log.ts b/apps/ui/src/lib/log.ts index e13a77bc..305f30eb 100644 --- a/apps/ui/src/lib/log.ts +++ b/apps/ui/src/lib/log.ts @@ -1,23 +1,5 @@ -import type { MediaMetadata } from "@smm/types"; -import pino from 'pino' -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function minimize(mm: MediaMetadata): any { - return { - mediaFolderPath: mm.mediaFolderPath, - type: mm.type, - name: mm.tvShow?.name, - mediaFileCount: mm.mediaFiles?.length ?? 0, - tvShow: { - id: mm.tvShow?.id, - name: mm.tvShow?.name, - }, - movie: { - id: mm.movie?.id, - name: mm.movie?.name, - }, - } -} +import pino from 'pino' diff --git a/apps/ui/src/lib/mediaDatabaseAccess.ts b/apps/ui/src/lib/mediaDatabaseAccess.ts index 276c6fb6..c18e09bd 100644 --- a/apps/ui/src/lib/mediaDatabaseAccess.ts +++ b/apps/ui/src/lib/mediaDatabaseAccess.ts @@ -3,13 +3,11 @@ import type { MediaMetadata, UserConfig } from "@smm/types" import type { ReverseProxyCandidate } from "@/hooks/useReverseProxyBaseUrls" import localStorages from "@/lib/localStorages" -export const MEDIA_DATABASE_DEFAULT_HOST = "mediadb.vercel.app" - export function normalizeUpstreamBaseUrl(url: string): string { return url.trim().replace(/\/+$/, "") } -export function hostnameFromUrl(url: string): string | null { +function hostnameFromUrl(url: string): string | null { try { return new URL(url).hostname } catch { @@ -17,15 +15,15 @@ export function hostnameFromUrl(url: string): string | null { } } -export function readDisabledDomains(): Set { +function readDisabledDomains(): Set { return localStorages.disabledDomains } -export function isDomainDisabled(domain: string): boolean { +function isDomainDisabled(domain: string): boolean { return readDisabledDomains().has(domain) } -export function isUpstreamDirectDisabled(upstreamBaseUrl: string): boolean { +function isUpstreamDirectDisabled(upstreamBaseUrl: string): boolean { const host = hostnameFromUrl(upstreamBaseUrl) return host !== null && isDomainDisabled(host) } diff --git a/apps/ui/src/lib/mediaFilePathEqual.ts b/apps/ui/src/lib/mediaFilePathEqual.ts deleted file mode 100644 index 3186170b..00000000 --- a/apps/ui/src/lib/mediaFilePathEqual.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Path } from '@smm/utils/path' - -export function mediaFilePathEqual(a: string | undefined, b: string | undefined): boolean { - if (a == null || b == null) { - return false - } - try { - return Path.posix(a) === Path.posix(b) - } catch { - return a === b - } -} diff --git a/apps/ui/src/lib/mediaFolderRecognitionPipeline.ts b/apps/ui/src/lib/mediaFolderRecognitionPipeline.ts deleted file mode 100644 index 4dceff7a..00000000 --- a/apps/ui/src/lib/mediaFolderRecognitionPipeline.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { PrimaryDatabase } from '@smm/types' - -export interface RecognitionStep { - logLabel: string - tryRecognize: () => Promise -} - -/** TMDB first when primary is default or TMDB; TVDB first when primary is TVDB. */ -export function searchOrderForPrimaryDb( - primaryDatabase: PrimaryDatabase | undefined -): Array<'TMDB' | 'TVDB'> { - return primaryDatabase === 'TVDB' ? ['TVDB', 'TMDB'] : ['TMDB', 'TVDB'] -} - -/** Runs steps in order; returns the first defined result, or undefined if all miss. */ -export async function runRecognitionSteps( - traceId: string, - steps: RecognitionStep[] -): Promise { - const startedAt = performance.now() - console.log(`[${traceId}] runRecognitionSteps start: ${steps.length} steps`) - for (const step of steps) { - const result = await step.tryRecognize() - if (result !== undefined) { - console.log(`[${traceId}] HIT: ${step.logLabel}`) - const durationMs = Math.round(performance.now() - startedAt) - console.log(`[${traceId}] runRecognitionSteps done in ${durationMs}ms, hit=true`) - return result - } - console.log(`[${traceId}] MISS: ${step.logLabel}`) - } - const durationMs = Math.round(performance.now() - startedAt) - console.log(`[${traceId}] runRecognitionSteps done in ${durationMs}ms, hit=false`) - return undefined -} diff --git a/apps/ui/src/lib/mediaMetadataRefreshUtils.test.ts b/apps/ui/src/lib/mediaMetadataRefreshUtils.test.ts index 49399e8e..5c524fca 100644 --- a/apps/ui/src/lib/mediaMetadataRefreshUtils.test.ts +++ b/apps/ui/src/lib/mediaMetadataRefreshUtils.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { mergeRefreshedMetadata } from './mediaMetadataRefreshUtils' -import type { UIMediaMetadata } from '@/types/UIMediaMetadata' +import type { MediaMetadata } from '@smm/types' import type { MediaMetadata, TvShowMediaMetadata } from '@smm/types' const defaultTvShow = (name: string): TvShowMediaMetadata => ({ @@ -17,13 +17,13 @@ const createMockMediaMetadata = (overrides?: Partial): MediaMetad ...overrides, }) -const createMockUIMediaMetadata = (overrides?: Partial): UIMediaMetadata => ({ +const createMockUIMediaMetadata = (overrides?: Partial): MediaMetadata => ({ mediaFolderPath: '/media/show1', type: 'tvshow-folder', status: 'ok', tvShow: defaultTvShow('Show 1'), ...overrides, -} as UIMediaMetadata) +} as MediaMetadata) describe('mergeRefreshedMetadata', () => { it('should return response with idle status when no current metadata exists', () => { diff --git a/apps/ui/src/lib/mergeFolderPathsWithUiStatus.ts b/apps/ui/src/lib/mergeFolderPathsWithUiStatus.ts deleted file mode 100644 index ea0c3fb6..00000000 --- a/apps/ui/src/lib/mergeFolderPathsWithUiStatus.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Path } from "@smm/utils/path" -import type { UIMediaFolder } from "@/types/UIMediaFolder" - -export function mergeFolderPathsWithUiStatus( - paths: string[], - zustandFolders: UIMediaFolder[], -): UIMediaFolder[] { - const byPosix = new Map( - zustandFolders.map((f) => [Path.posix(f.path), f] as const), - ) - return paths.map((p) => { - const posix = Path.posix(p) - const existing = byPosix.get(posix) - const platform = Path.toPlatformPath(p) - return { - path: platform, - status: existing?.status ?? "ok", - test: existing?.test, - type: existing?.type, - } - }) -} diff --git a/apps/ui/src/lib/music.ts b/apps/ui/src/lib/music.ts index 3a30dde2..2bbe0250 100644 --- a/apps/ui/src/lib/music.ts +++ b/apps/ui/src/lib/music.ts @@ -12,7 +12,7 @@ export function newMusicMediaMetadata(mm: MediaMetadata, folderFiles: string[]): } } -export function buildMusicFilePropsArray(files: string[]): MusicFileProps[] { +function buildMusicFilePropsArray(files: string[]): MusicFileProps[] { const propsArray: MusicFileProps[] = []; const videoFiles = findFilesByExtensions(files, extensions.videoFileExtensions); const audioFiles = findFilesByExtensions(files, extensions.musicFileExtensions); @@ -22,7 +22,7 @@ export function buildMusicFilePropsArray(files: string[]): MusicFileProps[] { return propsArray; } -export function buildMusicFileProps(files: string[], file: string, type: "audio" | "video"): MusicFileProps { +function buildMusicFileProps(files: string[], file: string, type: "audio" | "video"): MusicFileProps { const filename = new Path(file).name() const filenameWithoutExt = filename.lastIndexOf('.') !== -1 ? filename.substring(0, filename.lastIndexOf('.')) : filename; @@ -55,7 +55,7 @@ export function buildMusicFileProps(files: string[], file: string, type: "audio" * * @param associatedFiles Absolute path for associated files */ -export function findThumbnail(associatedFiles: string[]): string | undefined { +function findThumbnail(associatedFiles: string[]): string | undefined { const imageExts = extensions.imageFileExtensions; const ret = findFilesByExtensions(associatedFiles, imageExts); return ret[0] @@ -83,7 +83,7 @@ export function findFilesByExtensions(files: string[], extensions: string[]): st * @param files file paths in POSIX format * @param filenameWithoutExt */ -export function findFilesByFileName(files: string[], filenameWithoutExt: string): string[] { +function findFilesByFileName(files: string[], filenameWithoutExt: string): string[] { return files.filter(file => { const filename = new Path(file).name(); const lastDotIndex = filename.lastIndexOf('.'); diff --git a/apps/ui/src/lib/musicEvents.ts b/apps/ui/src/lib/musicEvents.ts index 2ce7a825..fb61919e 100644 --- a/apps/ui/src/lib/musicEvents.ts +++ b/apps/ui/src/lib/musicEvents.ts @@ -10,7 +10,7 @@ export const MUSIC_EVENT_NAMES: Record = { 'track:videoCompress': 'track:videoCompress', }; -export interface BaseMusicEventDetail { +interface BaseMusicEventDetail { trackId: number; timestamp: number; } @@ -87,7 +87,7 @@ export function createTrackPropertiesEvent(track: Track): CustomEvent): void { +function emitMusicEvent(event: CustomEvent): void { document.dispatchEvent(event); } @@ -106,7 +106,7 @@ export function emitTrackPropertiesEvent(track: Track): void { emitMusicEvent(event); } -export function createTrackFormatConvertEvent(track: Track): CustomEvent { +function createTrackFormatConvertEvent(track: Track): CustomEvent { return new CustomEvent(MUSIC_EVENT_NAMES['track:formatConvert'], { bubbles: true, composed: true, @@ -124,7 +124,7 @@ export function emitTrackFormatConvertEvent(track: Track): void { emitMusicEvent(event); } -export function createTrackVideoCompressEvent(track: Track): CustomEvent { +function createTrackVideoCompressEvent(track: Track): CustomEvent { return new CustomEvent(MUSIC_EVENT_NAMES['track:videoCompress'], { bubbles: true, composed: true, diff --git a/apps/ui/src/lib/nfo.test.ts b/apps/ui/src/lib/nfo.test.ts index 4dec1804..ff012581 100644 --- a/apps/ui/src/lib/nfo.test.ts +++ b/apps/ui/src/lib/nfo.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest" -import NFO, { +import { + NFO, buildEpisodeNfoXml, convertTvShowEpisodeNfoToXml, convertTvShowNfoToXml, diff --git a/apps/ui/src/lib/nfo.ts b/apps/ui/src/lib/nfo.ts index 3432a33e..e41964a2 100644 --- a/apps/ui/src/lib/nfo.ts +++ b/apps/ui/src/lib/nfo.ts @@ -1,2 +1 @@ export * from "./nfo/index" -export { default } from "./nfo/index" diff --git a/apps/ui/src/lib/nfo/index.ts b/apps/ui/src/lib/nfo/index.ts index 5af01166..04864aa1 100644 --- a/apps/ui/src/lib/nfo/index.ts +++ b/apps/ui/src/lib/nfo/index.ts @@ -1,4 +1,3 @@ export * from "./tvshowNfo" export * from "./tvshowEpisodeNfo" export * from "./movieNfo" -export { default } from "./tvshowNfo" diff --git a/apps/ui/src/lib/nfo/movieNfo.ts b/apps/ui/src/lib/nfo/movieNfo.ts index 468c97d9..3364d90f 100644 --- a/apps/ui/src/lib/nfo/movieNfo.ts +++ b/apps/ui/src/lib/nfo/movieNfo.ts @@ -1,31 +1,31 @@ import type { NfoThumb, ThumbAspect, TvShowNFOActor, TvShowNFORating, TvShowNFOUniqueId } from "./tvshowNfo" /** @see TvShowNFORating — same `…` shape as tvshow NFO */ -export type MovieNFORating = TvShowNFORating +type MovieNFORating = TvShowNFORating -export type MovieNFOUniqueId = TvShowNFOUniqueId +type MovieNFOUniqueId = TvShowNFOUniqueId -export type MovieNFOActor = TvShowNFOActor +type MovieNFOActor = TvShowNFOActor -export interface MovieNFOSet { +interface MovieNFOSet { name?: string overview?: string } /** `` / ``: optional `tmdbid` attribute + text name */ -export interface MovieNFOTextCredit { +interface MovieNFOTextCredit { tmdbid?: string name?: string } -export interface MovieNFOProducer { +interface MovieNFOProducer { tmdbid?: string name?: string role?: string profile?: string } -export interface MovieNFOVideoStream { +interface MovieNFOVideoStream { codec?: string aspect?: number width?: number @@ -33,11 +33,11 @@ export interface MovieNFOVideoStream { durationInSeconds?: number } -export interface MovieNFOStreamDetails { +interface MovieNFOStreamDetails { videos?: MovieNFOVideoStream[] } -export interface MovieNFOFileInfo { +interface MovieNFOFileInfo { streamDetails?: MovieNFOStreamDetails } @@ -107,27 +107,6 @@ function parseBooleanField(value: string | undefined): boolean | undefined { return undefined } -function formatXml(xml: string): string { - const PADDING = " " - const reg = /(>)(<)(\/*)/g - let formatted = "" - xml = xml.replace(reg, "$1\n$2$3") - let pad = 0 - xml.split("\n").forEach((node) => { - let indent = 0 - if (node.match(/.+<\/\w[^>]*>$/)) { - indent = 0 - } else if (node.match(/^<\/\w/)) { - if (pad > 0) pad -= 1 - } else if (node.match(/^<\w([^>]*[^/])?>.*$/)) { - indent = 1 - } - formatted += PADDING.repeat(pad) + node + "\n" - pad += indent - }) - return formatted.trim() -} - export async function parseMovieNfo(xml: string): Promise { const parser = new DOMParser() const doc = parser.parseFromString(xml, "text/xml") @@ -292,221 +271,3 @@ export async function parseMovieNfo(xml: string): Promise return movieNfo } - -export function convertMovieNfoToXml(nfo: MovieNFO): string { - const doc = document.implementation.createDocument(null, "movie", null) - const root = doc.documentElement - const addElement = (name: string, value: string) => { - const el = doc.createElement(name) - el.textContent = value - root.appendChild(el) - } - const addOptionalText = (name: string, value: string | undefined) => { - if (value !== undefined) addElement(name, value) - } - const addOptionalNumber = (name: string, value: number | undefined) => { - if (value !== undefined) addElement(name, String(value)) - } - - addOptionalText("title", nfo.title) - addOptionalText("originaltitle", nfo.originalTitle) - addOptionalText("sorttitle", nfo.sortTitle) - addOptionalText("epbookmark", nfo.epbookmark) - addOptionalNumber("year", nfo.year) - if (nfo.ratings?.length) { - const ratingsEl = doc.createElement("ratings") - for (const r of nfo.ratings) { - const ratingEl = doc.createElement("rating") - if (r.default !== undefined) ratingEl.setAttribute("default", String(r.default)) - if (r.max !== undefined) ratingEl.setAttribute("max", String(r.max)) - if (r.name) ratingEl.setAttribute("name", r.name) - if (r.value !== undefined) { - const valueEl = doc.createElement("value") - valueEl.textContent = String(r.value) - ratingEl.appendChild(valueEl) - } - if (r.votes !== undefined) { - const votesEl = doc.createElement("votes") - votesEl.textContent = String(r.votes) - ratingEl.appendChild(votesEl) - } - ratingsEl.appendChild(ratingEl) - } - root.appendChild(ratingsEl) - } - addOptionalNumber("userrating", nfo.userRating) - addOptionalNumber("top250", nfo.top250) - if (nfo.set && (nfo.set.name !== undefined || nfo.set.overview !== undefined)) { - const setEl = doc.createElement("set") - if (nfo.set.name !== undefined) { - const nameEl = doc.createElement("name") - nameEl.textContent = nfo.set.name - setEl.appendChild(nameEl) - } - if (nfo.set.overview !== undefined) { - const overviewEl = doc.createElement("overview") - overviewEl.textContent = nfo.set.overview - setEl.appendChild(overviewEl) - } - root.appendChild(setEl) - } - addOptionalText("plot", nfo.plot) - addOptionalText("outline", nfo.outline) - addOptionalText("tagline", nfo.tagline) - addOptionalNumber("runtime", nfo.runtime) - - nfo.thumbs?.forEach((thumb) => { - if (!thumb.url) return - const thumbEl = doc.createElement("thumb") - thumbEl.textContent = thumb.url - if (thumb.aspect) thumbEl.setAttribute("aspect", thumb.aspect) - if (thumb.season !== undefined) thumbEl.setAttribute("season", String(thumb.season)) - if (thumb.type) thumbEl.setAttribute("type", thumb.type) - root.appendChild(thumbEl) - }) - if (nfo.fanartThumbs?.length) { - const fanartEl = doc.createElement("fanart") - nfo.fanartThumbs.forEach((thumbUrl) => { - if (!thumbUrl) return - const thumbEl = doc.createElement("thumb") - thumbEl.textContent = thumbUrl - fanartEl.appendChild(thumbEl) - }) - root.appendChild(fanartEl) - } - - addOptionalText("mpaa", nfo.mpaa) - addOptionalText("certification", nfo.certification) - addOptionalText("id", nfo.id) - addOptionalText("imdbid", nfo.imdbid) - addOptionalText("tmdbid", nfo.tmdbid) - addOptionalText("tvdbid", nfo.tvdbid) - nfo.uniqueIds?.forEach((uniqueId) => { - const uniqueIdEl = doc.createElement("uniqueid") - if (uniqueId.default !== undefined) uniqueIdEl.setAttribute("default", String(uniqueId.default)) - if (uniqueId.type) uniqueIdEl.setAttribute("type", uniqueId.type) - if (uniqueId.value !== undefined) uniqueIdEl.textContent = uniqueId.value - root.appendChild(uniqueIdEl) - }) - - nfo.countries?.forEach((country) => country && addElement("country", country)) - addOptionalText("status", nfo.status) - addOptionalText("code", nfo.code) - addOptionalText("premiered", nfo.premiered) - if (nfo.watched !== undefined) addElement("watched", String(nfo.watched)) - addOptionalNumber("playcount", nfo.playcount) - nfo.genres?.forEach((genre) => genre && addElement("genre", genre)) - nfo.studios?.forEach((studio) => studio && addElement("studio", studio)) - - nfo.credits?.forEach((credit) => { - if (credit.name === undefined) return - const el = doc.createElement("credits") - if (credit.tmdbid) el.setAttribute("tmdbid", credit.tmdbid) - el.textContent = credit.name - root.appendChild(el) - }) - nfo.directors?.forEach((director) => { - if (director.name === undefined) return - const el = doc.createElement("director") - if (director.tmdbid) el.setAttribute("tmdbid", director.tmdbid) - el.textContent = director.name - root.appendChild(el) - }) - - nfo.actors?.forEach((actor) => { - const actorEl = doc.createElement("actor") - if (actor.name !== undefined) { - const el = doc.createElement("name") - el.textContent = actor.name - actorEl.appendChild(el) - } - if (actor.role !== undefined) { - const el = doc.createElement("role") - el.textContent = actor.role - actorEl.appendChild(el) - } - if (actor.thumb !== undefined) { - const el = doc.createElement("thumb") - el.textContent = actor.thumb - actorEl.appendChild(el) - } - if (actor.profile !== undefined) { - const el = doc.createElement("profile") - el.textContent = actor.profile - actorEl.appendChild(el) - } - if (actor.tmdbid !== undefined) { - const el = doc.createElement("tmdbid") - el.textContent = actor.tmdbid - actorEl.appendChild(el) - } - root.appendChild(actorEl) - }) - nfo.producers?.forEach((producer) => { - const producerEl = doc.createElement("producer") - if (producer.tmdbid) producerEl.setAttribute("tmdbid", producer.tmdbid) - if (producer.name !== undefined) { - const el = doc.createElement("name") - el.textContent = producer.name - producerEl.appendChild(el) - } - if (producer.role !== undefined) { - const el = doc.createElement("role") - el.textContent = producer.role - producerEl.appendChild(el) - } - if (producer.profile !== undefined) { - const el = doc.createElement("profile") - el.textContent = producer.profile - producerEl.appendChild(el) - } - root.appendChild(producerEl) - }) - - addOptionalText("trailer", nfo.trailer) - addOptionalText("languages", nfo.languages) - addOptionalText("dateadded", nfo.dateadded) - addOptionalText("source", nfo.source) - addOptionalText("edition", nfo.edition) - addOptionalText("original_filename", nfo.originalFilename) - addOptionalText("user_note", nfo.userNote) - - if (nfo.fileInfo?.streamDetails?.videos?.length) { - const fileInfoEl = doc.createElement("fileinfo") - const streamDetailsEl = doc.createElement("streamdetails") - nfo.fileInfo.streamDetails.videos.forEach((video) => { - const videoEl = doc.createElement("video") - if (video.codec !== undefined) { - const codecEl = doc.createElement("codec") - codecEl.textContent = video.codec - videoEl.appendChild(codecEl) - } - if (video.aspect !== undefined) { - const aspectEl = doc.createElement("aspect") - aspectEl.textContent = String(video.aspect) - videoEl.appendChild(aspectEl) - } - if (video.width !== undefined) { - const widthEl = doc.createElement("width") - widthEl.textContent = String(video.width) - videoEl.appendChild(widthEl) - } - if (video.height !== undefined) { - const heightEl = doc.createElement("height") - heightEl.textContent = String(video.height) - videoEl.appendChild(heightEl) - } - if (video.durationInSeconds !== undefined) { - const dEl = doc.createElement("durationinseconds") - dEl.textContent = String(video.durationInSeconds) - videoEl.appendChild(dEl) - } - streamDetailsEl.appendChild(videoEl) - }) - fileInfoEl.appendChild(streamDetailsEl) - root.appendChild(fileInfoEl) - } - - const serializer = new XMLSerializer() - return '\n' + formatXml(serializer.serializeToString(doc)) -} diff --git a/apps/ui/src/lib/nfo/tvshowNfo.ts b/apps/ui/src/lib/nfo/tvshowNfo.ts index ce615997..f9c2af29 100644 --- a/apps/ui/src/lib/nfo/tvshowNfo.ts +++ b/apps/ui/src/lib/nfo/tvshowNfo.ts @@ -30,7 +30,7 @@ export interface TvShowNFOActor { tmdbid?: string } -export interface TvShowNFONamedSeason { +interface TvShowNFONamedSeason { number?: number name?: string } @@ -199,7 +199,7 @@ export class NFO { } } -export async function parseTvShowNfo(xml: string): Promise { +async function parseTvShowNfo(xml: string): Promise { const parser = new DOMParser() const doc = parser.parseFromString(xml, "text/xml") const parseError = doc.querySelector("parsererror") @@ -442,4 +442,4 @@ export function convertTvShowNfoToXml(nfo: TvShowNFO): string { return '\n' + formatXml(serializer.serializeToString(doc)) } -export default NFO + diff --git a/apps/ui/src/lib/path.ts b/apps/ui/src/lib/path.ts index a4ff5b9e..2b1cbb8f 100644 --- a/apps/ui/src/lib/path.ts +++ b/apps/ui/src/lib/path.ts @@ -1,10 +1,10 @@ import pathBrowserify from 'path-browserify-esm' -export const WindowsPathSeparator = '\\' -export const UnixPathSeparator = '/' +const WindowsPathSeparator = '\\' +const UnixPathSeparator = '/' -export function isWindowsPath(path: string) { +function isWindowsPath(path: string) { return path.includes(':\\'); } @@ -98,37 +98,3 @@ export function extname(path: string) { } return "." + words.pop() } - -/** - - * @param filepath Absolute file path - */ -export function parse(filepath: string): { - name: string; - ext: string; - dir: string; -} { - const name = basename(filepath)! - const ext = extname(filepath) - const dir = dirname(filepath) - return { - name, - ext, - dir, - } -} - - -/** - * Generate file path by new extension - * For example - * * Input: "/S01E01.mkv" - * * Output: "/S01E01.png" - * @param sourceFilePath absolute file path - * @param targetExt target extension - * @returns absolute file path with new extension - */ -export function newFilePathWithExt(sourceFilePath: string, targetExt: string) { - const src = parse(sourceFilePath); - return join(src.dir, src.name.replace(src.ext, '') + targetExt) -} diff --git a/apps/ui/src/lib/recognizeEpisodes.ts b/apps/ui/src/lib/recognizeEpisodes.ts deleted file mode 100644 index d7812416..00000000 --- a/apps/ui/src/lib/recognizeEpisodes.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { uniq } from 'es-toolkit'; -import { extname, basename } from './path'; -import { videoFileExtensions } from './utils'; -import type { MediaMetadataWithFolderFiles } from '@/lib/mediaFolderFiles'; - -export interface RecognizedEpisode { - season: number, - episode: number, - file: string, -} - -export function isVideoFile(file: string) { - const ext = extname(file).toLowerCase(); - return videoFileExtensions.includes(ext); -} - - -export function pattern1( - episodes: { season: number, episode: number }[], - videoFiles: string[] -): RecognizedEpisode[] { - - const ret: RecognizedEpisode[] = []; - - for(const { season, episode } of episodes) { - const sn = season; - const en = episode; - - const pattern = `S${sn.toString().padStart(2, '0')}E${en.toString().padStart(2, '0')}`; - const target = videoFiles.find(file => file.includes(pattern)); - if(target) { - ret.push({ season: sn, episode: en, file: target }); - } - } - - if(ret.length > 0) { - console.log('[recognize] matched episodes by pattern SXXEYY', ret) - } else { - console.log('[recognize] pattern1 miss: SXXEYY', { episodes, videoFiles }) - } - - return ret; -} - -export function pattern2( - episodes: { season: number, episode: number }[], - videoFiles: string[] -): RecognizedEpisode[] { - // Pattern 2: Chinese format 第X季第Y集 (e.g., 第1季第5集) - const ret: RecognizedEpisode[] = []; - - for (const { season, episode } of episodes) { - const pattern = `第${season}季第${episode}集`; - const target = videoFiles.find(file => file.includes(pattern)); - if (target) { - ret.push({ season, episode, file: target }); - } - } - - if (ret.length > 0) { - console.log('[recognize] matched episodes by pattern 第X季第Y集', ret); - } else { - console.log('[recognize] pattern2 miss: 第X季第Y集', { episodes, videoFiles }); - } - - return ret; -} - -export function pattern3( - episodes: { season: number, episode: number }[], - videoFiles: string[] -): RecognizedEpisode[] { - // Pattern 3: Chinese format with zero-padding (e.g., 第01季第05集) - const ret: RecognizedEpisode[] = []; - - for (const { season, episode } of episodes) { - const pattern = `第${season.toString().padStart(2, '0')}季第${episode.toString().padStart(2, '0')}集`; - const target = videoFiles.find(file => file.includes(pattern)); - if (target) { - ret.push({ season, episode, file: target }); - } - } - - if (ret.length > 0) { - console.log('[recognize] matched episodes by pattern 第XX季第YY集', ret); - } else { - console.log('[recognize] pattern3 miss: 第XX季第YY集', { episodes, videoFiles }); - } - - return ret; -} - -export function pattern4( - episodes: { season: number, episode: number }[], - videoFiles: string[] -): RecognizedEpisode[] { - // Pattern 4: If there is only 1 season, match episode by episode number: - // xxx - 1.mp4, xxx.1.mp4, xxx_1.mp4, xxx 1.mp4, etc. - - const numberOfSeasons = uniq(episodes.map(i => i.season)) - if(numberOfSeasons.length !== 1) { - // where there are multiple seasons - // I don't know if "xxx - 1.mp4" is for season 1 or season 2 - return []; - } - - const ret: RecognizedEpisode[] = []; - - for (const { season, episode } of episodes) { - // Match basename ending with ".ext" - // Divider: one or more of space, hyphen, dot, underscore (e.g. " - 1", ".1", "_1", " 1") - const regex = new RegExp(`[\\s.\\-_]+${episode}\\.\\w+$`, 'i'); - const target = videoFiles.find((file) => { - const name = basename(file) ?? ''; - return regex.test(name); - }); - if (target) { - ret.push({ season, episode, file: target }); - } - } - - if (ret.length > 0) { - console.log('[recognize] matched episodes by pattern xxxN.ext', ret); - } else { - console.log('[recognize] pattern4 miss: xxxN.ext', { episodes, videoFiles }); - } - - return ret; -} - -export function buildEpisodes(mm: MediaMetadataWithFolderFiles): { season: number, episode: number }[] { - if(mm.tvShow === undefined - || mm.tvShow.seasons === undefined - || mm.tvShow.seasons.length === 0 - || mm.tvShow.seasons[0].episodes === undefined - || mm.tvShow.seasons[0].episodes.length === 0 - ) { - return []; - } - - const ret: { season: number, episode: number }[] = []; - - for(const season of mm.tvShow.seasons) { - if(season.episodes === undefined || season.episodes.length === 0) { - continue; - } - for(const episode of season.episodes) { - ret.push({ season: episode.season, episode: episode.episode }); - } - } - - return ret; -} - -export function preciselyRecognizeEpisodes( - episodes: { season: number, episode: number }[], - videoFiles: string[] -): RecognizedEpisode[] { - - let ret: RecognizedEpisode[] = []; - - ret = pattern1(episodes, videoFiles); - if (ret.length > 0) { - return ret; - } - - ret = pattern2(episodes, videoFiles); - if (ret.length > 0) { - return ret; - } - - ret = pattern3(episodes, videoFiles); - if (ret.length > 0) { - return ret; - } - - ret = pattern4(episodes, videoFiles); - if (ret.length > 0) { - return ret; - } - - return ret; -} - -export function fuzzyRecognizeEpisodes( - _episodes: { season: number, episode: number }[], - _videoFiles: string[] -): RecognizedEpisode[] { - // TODO: no solution yet - console.log('[recognize] fuzzyRecognizeEpisodes invoked (no implementation)', { - episodes: _episodes, - videoFiles: _videoFiles, - }) - return []; -} - - -const ExcludesFolders = [ - '/Extras/', - '/EXTRAS/', - '/Subtitles/', -] - -export function excludeFiles(files: string[]) { - return files.filter(file => !ExcludesFolders.some(folder => file.includes(folder))) -} - -/** - * There are two caller of this method: - * 1. Media Folder Initialization - * 2. User Triggered Media Folder Recognition - * @param mm - * @returns - */ -export function recognizeEpisodes( - mm: MediaMetadataWithFolderFiles, - folderFiles: string[], -): RecognizedEpisode[] { - - const startTime = performance.now(); - console.log('[recognize] start episode matching', { - mediaFolderPath: mm.mediaFolderPath, - fileCount: folderFiles.length, - }) - - if( folderFiles.length === 0 - || mm.tvShow === undefined - || mm.tvShow.seasons === undefined - || mm.tvShow.seasons.length === 0 - || mm.tvShow.seasons[0].episodes === undefined - || mm.tvShow.seasons[0].episodes.length === 0 - ) { - return []; - } - - try { - - let videoFiles = folderFiles.filter(isVideoFile); - videoFiles = excludeFiles(videoFiles); - - if(videoFiles.length === 0) { - console.log('[recognize] no video files found') - return []; - } - - const episodes = buildEpisodes(mm); - - let ret: RecognizedEpisode[] = []; - - ret = preciselyRecognizeEpisodes(episodes, videoFiles); - if (ret.length > 0) { - return ret; - } - - ret = fuzzyRecognizeEpisodes(episodes, videoFiles); - if (ret.length > 0) { - return ret; - } - - return ret; - } catch (error) { - console.error('[recognize] episode matching error', error) - return []; - } finally { - const endTime = performance.now(); - console.log(`[recognize] episode matching finished in ${endTime - startTime}ms`) - } - - return []; -} - -/** Request id for matching worker responses when using a singleton worker */ -let nextRequestId = 0; - -type WorkerMessage = { type: 'result'; id: number; payload: RecognizedEpisode[] } | { type: 'error'; id: number; message: string }; - -/** - * Run recognizeEpisodes in a Web Worker to avoid blocking the main thread. - * Uses a singleton worker; concurrent calls are serialized. - */ -export function recognizeEpisodesAsync( - mm: MediaMetadataWithFolderFiles, - folderFiles: string[], -): Promise { - console.log('[recognize] recognizeEpisodesAsync started', { - mediaFolderPath: mm.mediaFolderPath, - fileCount: folderFiles.length, - }) - return new Promise((resolve, reject) => { - const id = nextRequestId++; - const worker = getRecognizeEpisodesWorker(); - - const onMessage = (e: MessageEvent) => { - const msg = e.data; - if (msg?.id !== id) return; - worker.removeEventListener('message', onMessage); - worker.removeEventListener('error', onError); - if (msg.type === 'result') { - console.log('[recognize] recognizeEpisodesAsync completed', { - mediaFolderPath: mm.mediaFolderPath, - recognizedCount: msg.payload.length, - requestId: id, - }) - resolve(msg.payload); - } else { - console.error('[recognize] recognizeEpisodesAsync worker error', { - mediaFolderPath: mm.mediaFolderPath, - requestId: id, - message: msg.message, - }) - reject(new Error(msg.message)); - } - }; - - const onError = (err: ErrorEvent) => { - worker.removeEventListener('message', onMessage); - worker.removeEventListener('error', onError); - console.error('[recognize] recognizeEpisodesAsync worker crashed', { - mediaFolderPath: mm.mediaFolderPath, - requestId: id, - error: err.message, - }) - reject(err.message ? new Error(err.message) : new Error('RecognizeEpisodes worker error')); - }; - - worker.addEventListener('message', onMessage); - worker.addEventListener('error', onError); - worker.postMessage({ type: 'recognize', id, payload: { mm, folderFiles } }); - }); -} - -let workerInstance: Worker | null = null; - -function getRecognizeEpisodesWorker(): Worker { - if (workerInstance) return workerInstance; - workerInstance = new Worker(new URL('./recognizeEpisodes.worker.ts', import.meta.url), { type: 'module' }); - return workerInstance; -} \ No newline at end of file diff --git a/apps/ui/src/lib/recognizeEpisodes.worker.ts b/apps/ui/src/lib/recognizeEpisodes.worker.ts deleted file mode 100644 index cd15871e..00000000 --- a/apps/ui/src/lib/recognizeEpisodes.worker.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Web Worker entry for recognizeEpisodes. - * Receives MediaMetadata + folder file paths via postMessage, runs recognition in this thread, posts back result. - */ -import { recognizeEpisodes, type RecognizedEpisode } from "./recognizeEpisodesUi"; -import type { MediaMetadata } from "@smm/types"; - -export type WorkerRequest = { - type: "recognize"; - id: number; - payload: { mm: MediaMetadata; folderFiles: string[] }; -}; -export type WorkerResult = { type: "result"; id: number; payload: RecognizedEpisode[] }; -export type WorkerError = { type: "error"; id: number; message: string }; - -self.onmessage = (e: MessageEvent) => { - const msg = e.data; - if (msg?.type !== "recognize") { - return; - } - const { id, payload } = msg; - try { - const result = recognizeEpisodes(payload.mm, payload.folderFiles); - (self as unknown as Worker).postMessage({ - type: "result", - id, - payload: result, - } satisfies WorkerResult); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - (self as unknown as Worker).postMessage({ type: "error", id, message } satisfies WorkerError); - } -}; diff --git a/apps/ui/src/lib/recognizeEpisodesUi.ts b/apps/ui/src/lib/recognizeEpisodesUi.ts index 600bdaed..8fe31cae 100644 --- a/apps/ui/src/lib/recognizeEpisodesUi.ts +++ b/apps/ui/src/lib/recognizeEpisodesUi.ts @@ -5,54 +5,13 @@ import { recognizeEpisodes as recognizeEpisodesPure, isVideoFile, - excludeFiles, - pattern1, - pattern2, - pattern3, pattern4, - preciselyRecognizeEpisodes, type RecognizedEpisode, } from "@smm/core/pipeline/recognizeEpisodes"; import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles"; export type { RecognizedEpisode }; -export { - isVideoFile, - excludeFiles, - pattern1, - pattern2, - pattern3, - pattern4, - preciselyRecognizeEpisodes, -}; - -export function buildEpisodes( - mm: MediaMetadataWithFolderFiles, -): { season: number; episode: number }[] { - if ( - mm.tvShow === undefined || - mm.tvShow.seasons === undefined || - mm.tvShow.seasons.length === 0 - ) { - return []; - } - - const ret: { season: number; episode: number }[] = []; - for (const season of mm.tvShow.seasons) { - if (season.episodes === undefined || season.episodes.length === 0) continue; - for (const episode of season.episodes) { - ret.push({ season: episode.season, episode: episode.episode }); - } - } - return ret; -} - -export function fuzzyRecognizeEpisodes( - _episodes: { season: number; episode: number }[], - _videoFiles: string[], -): RecognizedEpisode[] { - return []; -} +export { isVideoFile, pattern4 }; /** * Sync recognition using folder files from UI metadata. @@ -63,56 +22,3 @@ export function recognizeEpisodes( ): RecognizedEpisode[] { return recognizeEpisodesPure(mm, folderFiles); } - -/** Request id for matching worker responses when using a singleton worker */ -let nextRequestId = 0; - -type WorkerMessage = - | { type: "result"; id: number; payload: RecognizedEpisode[] } - | { type: "error"; id: number; message: string }; - -/** - * Run recognizeEpisodes in a Web Worker to avoid blocking the main thread. - * Uses a singleton worker; concurrent calls are serialized. - */ -export function recognizeEpisodesAsync( - mm: MediaMetadataWithFolderFiles, - folderFiles: string[], -): Promise { - return new Promise((resolve, reject) => { - const id = nextRequestId++; - const worker = getRecognizeEpisodesWorker(); - - const onMessage = (e: MessageEvent) => { - const msg = e.data; - if (msg?.id !== id) return; - worker.removeEventListener("message", onMessage); - worker.removeEventListener("error", onError); - if (msg.type === "result") { - resolve(msg.payload); - } else { - reject(new Error(msg.message)); - } - }; - - const onError = (err: ErrorEvent) => { - worker.removeEventListener("message", onMessage); - worker.removeEventListener("error", onError); - reject(err.message ? new Error(err.message) : new Error("RecognizeEpisodes worker error")); - }; - - worker.addEventListener("message", onMessage); - worker.addEventListener("error", onError); - worker.postMessage({ type: "recognize", id, payload: { mm, folderFiles } }); - }); -} - -let workerInstance: Worker | null = null; - -function getRecognizeEpisodesWorker(): Worker { - if (workerInstance) return workerInstance; - workerInstance = new Worker(new URL("./recognizeEpisodes.worker.ts", import.meta.url), { - type: "module", - }); - return workerInstance; -} diff --git a/apps/ui/src/lib/recognizeMediaFolderByTvdbIdInFolderName.ts b/apps/ui/src/lib/recognizeMediaFolderByTvdbIdInFolderName.ts deleted file mode 100644 index d67ed05c..00000000 --- a/apps/ui/src/lib/recognizeMediaFolderByTvdbIdInFolderName.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { basename } from "./path"; -import type { MovieMediaMetadata, PreferMediaLanguage, TvShowMediaMetadata } from "@smm/types"; -import { fetchTvdbAndBuildMovieMediaMetadata } from "./TvdbUtils"; -import Debug from "debug"; - -const debug = Debug("recognizeMediaFolderByTvdbIdInFolderName"); - -export function getTvdbIdFromFolderName(folderName: string): string | null { - // Match patterns like (tmdbid=123456), {tmdbid=123456}, or [tmdbid=123456] - const match = folderName.match(/(?:[[({])\s*tvdbid\s*=\s*(\d+)\s*[\])}]/i); - return match ? match[1] : null; -} - -export async function tryToRecognizeMediaFolderByTvdbIdInFolderName( - folderPath: string, - type: 'tvshow' | 'movie', - preferLanguage: PreferMediaLanguage, - getTvShowByIdFromTvdbFn: ( - seriesId: number, - language?: PreferMediaLanguage - ) => Promise, - _signal?: AbortSignal): Promise<{ - tvdbTvShow?: TvShowMediaMetadata; - tvdbMovie?: MovieMediaMetadata; -}> { - debug(`tryToRecognizeMediaFolderByTvdbIdInFolderName called: folderPath=${folderPath}, type=${type}, preferLanguage=${preferLanguage}`) - const folderName = basename(folderPath); - if(folderName === undefined) { - console.error('[preprocessMediaFolder] folder name is undefined') - return { } - } - const tvdbId = getTvdbIdFromFolderName(folderName); - if(tvdbId === null) { - console.error('[preprocessMediaFolder] TMDB ID is null') - return { } - } - debug(`Extract tvdbId from folder name: ${tvdbId}`) - - const tvdbIdNumber = parseInt(tvdbId, 10); - if(isNaN(tvdbIdNumber) || tvdbIdNumber <= 0) { - console.error('[preprocessMediaFolder] TMDB ID is not a valid number') - return { } - } - - let tvdbTvShow: TvShowMediaMetadata | undefined = undefined; - let tvdbMovie: MovieMediaMetadata | undefined = undefined; - - - if(type === 'tvshow') { - try { - tvdbTvShow = await getTvShowByIdFromTvdbFn(tvdbIdNumber, preferLanguage) - } catch (error) { - console.error('[preprocessMediaFolder] failed to get TV show by ID:', error) - } - } else { - try { - tvdbMovie = await fetchTvdbAndBuildMovieMediaMetadata(tvdbIdNumber, preferLanguage, { - onMovieAPIError: (error: Error) => { - console.error('[preprocessMediaFolder] failed to get TVDB movie by ID:', error) - }, - }) - } catch (error) { - console.error('[preprocessMediaFolder] failed to get movie by ID:', error) - } - } - - - - - return { - tvdbTvShow, - tvdbMovie, - } - -} diff --git a/apps/ui/src/lib/recognizeMediaFolderTypes.ts b/apps/ui/src/lib/recognizeMediaFolderTypes.ts deleted file mode 100644 index 8ae4b8ec..00000000 --- a/apps/ui/src/lib/recognizeMediaFolderTypes.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { MovieMediaMetadata, TvShowMediaMetadata } from "@smm/types"; - -export interface RecognizeMediaFolderResult { - success: boolean; - type?: 'tv' | 'movie' | null; - tmdbTvShow?: TvShowMediaMetadata; - tmdbMovie?: MovieMediaMetadata; - tvdbTvShow?: TvShowMediaMetadata; - tvdbMovie?: MovieMediaMetadata; -} diff --git a/apps/ui/src/lib/renameRules.ts b/apps/ui/src/lib/renameRules.ts index 9f290087..f8c33667 100644 --- a/apps/ui/src/lib/renameRules.ts +++ b/apps/ui/src/lib/renameRules.ts @@ -1,52 +1,3 @@ -import { extname } from "@/lib/path" -export type RenameRuleName = "plex" | "emby" - -export interface NewFileNameContext { - type: "tv" | "movie" - seasonNumber: number - episodeNumber: number - episodeName?: string - tvshowName?: string - movieName?: string - file: string - tmdbId?: string - releaseYear: string -} - -function generateMovieFileName(context: NewFileNameContext, ext: string): string { - const year = context.releaseYear || "" - const name = context.movieName ?? "" - return `${name}${year ? ` (${year})` : ""}${ext}` -} - -function generatePlexTvFileName(context: NewFileNameContext, ext: string): string { - const season = context.seasonNumber.toString().padStart(2, "0") - const episode = context.episodeNumber.toString().padStart(2, "0") - const folder = `Season ${season}` - return `${folder}/${context.tvshowName} - S${season}E${episode} - ${context.episodeName}${ext}` -} - -function generateEmbyTvFileName(context: NewFileNameContext, ext: string): string { - const season = context.seasonNumber.toString() - const episode = context.episodeNumber.toString() - const folder = `Season ${season}` - return `${folder}/${context.tvshowName} S${season}E${episode} ${context.episodeName}${ext}` -} -export function generateNewFileName( - ruleName: RenameRuleName, - context: NewFileNameContext, -): string { - const ext = extname(context.file) - - if (context.type === "movie") { - return generateMovieFileName(context, ext) - } - - if (ruleName === "plex") { - return generatePlexTvFileName(context, ext) - } - - return generateEmbyTvFileName(context, ext) -} +export type RenameRuleName = "plex" | "emby" diff --git a/apps/ui/src/lib/scrapeDialog/index.ts b/apps/ui/src/lib/scrapeDialog/index.ts index a9818525..e244f725 100644 --- a/apps/ui/src/lib/scrapeDialog/index.ts +++ b/apps/ui/src/lib/scrapeDialog/index.ts @@ -1,12 +1,8 @@ export { - SCRAPE_TASK_IDS, - createInitialScrapeTasks, - createInitialScrapeTasksForMedia, getScrapeTaskIdsForMedia, type ScrapeTaskId, - type ScrapeTaskStatus, type ScrapeTaskView, } from "./types" export { areAllTasksDone } from "./selectors" export { checkTaskCompletion } from "./checkTaskCompletion" -export { deriveScrapeTasks, type DeriveScrapeTasksInput } from "./deriveScrapeTasks" +export { deriveScrapeTasks} from "./deriveScrapeTasks" diff --git a/apps/ui/src/lib/scrapeDialog/types.ts b/apps/ui/src/lib/scrapeDialog/types.ts index 08fc0bf0..02efc14c 100644 --- a/apps/ui/src/lib/scrapeDialog/types.ts +++ b/apps/ui/src/lib/scrapeDialog/types.ts @@ -10,7 +10,7 @@ export interface ScrapeTaskView { failedReason?: string } -export const SCRAPE_TASK_IDS: ScrapeTaskId[] = ["poster", "fanart", "thumbnails", "nfo"] +const SCRAPE_TASK_IDS: ScrapeTaskId[] = ["poster", "fanart", "thumbnails", "nfo"] export function getScrapeTaskIdsForMedia( mediaMetadata: Pick | undefined, @@ -21,10 +21,6 @@ export function getScrapeTaskIdsForMedia( return [...SCRAPE_TASK_IDS] } -export function createInitialScrapeTasks(): ScrapeTaskView[] { - return SCRAPE_TASK_IDS.map((id) => ({ id, status: "pending" })) -} - export function createInitialScrapeTasksForMedia( mediaMetadata: Pick | undefined, ): ScrapeTaskView[] { diff --git a/apps/ui/src/lib/scrapeError.ts b/apps/ui/src/lib/scrapeError.ts index 66b1540c..90d13f17 100644 --- a/apps/ui/src/lib/scrapeError.ts +++ b/apps/ui/src/lib/scrapeError.ts @@ -3,7 +3,7 @@ import { TmdbFetchError } from "@/api/tmdb" import { HttpFailoverExhaustedError } from "@/lib/http" import { TVDBv4Error } from "@smm/tvdb4" -export type ScrapeErrorKey = +type ScrapeErrorKey = | "scrape.errors.imageUrlTimeout" | "scrape.errors.imageUrlNotFound" | "scrape.errors.imageUrlConnectionRefused" diff --git a/apps/ui/src/lib/tvShowMediaMetadataFromTmdbDetails.ts b/apps/ui/src/lib/tvShowMediaMetadataFromTmdbDetails.ts deleted file mode 100644 index 2752a688..00000000 --- a/apps/ui/src/lib/tvShowMediaMetadataFromTmdbDetails.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { - TMDBTVShowDetails, - TvShowEpisodeMetadata, - TvShowMediaMetadata, - TvShowSeasonMetadata, -} from "@smm/types"; - -/** TMDB details → unified `tvShow` shape (same as TVDB-backed metadata). */ -export function tvShowMediaMetadataFromTmdbDetails( - details: TMDBTVShowDetails -): TvShowMediaMetadata { - const seasons: TvShowSeasonMetadata[] = (details.seasons ?? []).map((season) => { - const episodes: TvShowEpisodeMetadata[] = (season.episodes ?? []).map((ep) => ({ - season: ep.season_number, - episode: ep.episode_number, - name: ep.name ?? "", - })); - return { - season: season.season_number, - name: season.name ?? "", - episodes, - }; - }); - - return { - id: String(details.id), - name: details.name, - database: "TMDB", - airDate: details.first_air_date, - seasons, - }; -} diff --git a/apps/ui/src/lib/utils.ts b/apps/ui/src/lib/utils.ts index d72c6deb..fa2933eb 100644 --- a/apps/ui/src/lib/utils.ts +++ b/apps/ui/src/lib/utils.ts @@ -1,47 +1,14 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" -import { type MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" -import type { MediaMetadata } from "@smm/types" -import { type MediaFileMetadata, RenameRuleVariables, type RenameRule, type TMDBSeason } from "@smm/types" -import { basename, relative, join, dirname } from "@/lib/path" -import { Path } from "@smm/utils/path" -import { listFilesApi } from "@/api/listFiles" -import { extensions, videoFileExtensions, imageFileExtensions } from "@smm/types/mediaFileExtensions" +import { videoFileExtensions, imageFileExtensions } from "@smm/types/mediaFileExtensions" -export { extensions, videoFileExtensions, imageFileExtensions } +export { videoFileExtensions, imageFileExtensions } -import { findAssociatedFiles, type TaggedAssociatedFile } from "@/lib/associatedFilesUi" +import { findAssociatedFiles } from "@/lib/associatedFilesUi" export { findAssociatedFiles } -// Local type definitions for buildTvShowEpisodesPropsFromMediaMetadata -type File = TaggedAssociatedFile - -interface Episode { - name: string - seasonNumber: number - episodeNumber: number - thumbnail?: string - videoFilePath?: File - associatedFiles: File[] -} - -interface Season { - name: string - seasonNumber: number - episodes: Episode[] -} - -interface TvShowEpisodesProps { - seasons: Season[] - isEditing: boolean -} -import { getTMDBImageUrl } from "@/api/tmdb" -import filenamify from 'filenamify'; -import { downloadImageWithFailover } from "@/api/downloadImageWithFailover" -import { isError, ExistedFileError } from "@smm/utils/errors" - export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -72,375 +39,6 @@ export function nextTraceId(): number { } } - - - -/** - * Convert absolute path to relative path (relative to media folder) - */ -function getRelativePath(absolutePath: string, mediaFolderPath: string | undefined): string { - if (!mediaFolderPath) { - return absolutePath; - } - try { - return relative(mediaFolderPath, absolutePath); - } catch { - // If relative path calculation fails, return the absolute path - return absolutePath; - } -} - -/** - * No one is using this method anymore - * @deprecated - * Build TvShowEpisodesProps from MediaMetadata - */ -export function buildTvShowEpisodesPropsFromMediaMetadata( - mediaMetadata: MediaMetadataWithFolderFiles | null | undefined, - renameRule: RenameRule | undefined -): TvShowEpisodesProps { - if (!mediaMetadata) { - return { seasons: [], isEditing: false }; - } - - if(!mediaMetadata.tvShow) { - return { seasons: [], isEditing: false }; - } - - const mediaFolderPath = mediaMetadata.mediaFolderPath; - const tvShow = mediaMetadata.tvShow; - - const props: TvShowEpisodesProps = { - seasons: [], - isEditing: false, - } - - - tvShow?.seasons.forEach((season) => { - const episodes = season.episodes?.map((episode) => { - - const videoFilePath = mediaMetadata.mediaFiles?.find(file => file.seasonNumber === episode.season && file.episodeNumber === episode.episode) - - - const episodeProps: Episode = { - name: episode.name, - seasonNumber: episode.season, - episodeNumber: episode.episode, - // hold this TODO, this method was deprecated. - // TODO: add still_path field in TvShowEpisodeMetadata, - // and need to figure out how to store thumbnail URL that support both TVDB and TMDB - // thumbnail: episode.still_path ? getTMDBImageUrl(episode.still_path, 'w300') ?? undefined : undefined, - thumbnail: undefined, - associatedFiles: [], - }; - - if(videoFilePath) { - episodeProps.videoFilePath = { - path: getRelativePath(videoFilePath.absolutePath, mediaFolderPath), - tag: "VID", - newPath: '' - } - // Deprecated: folder file listing is no longer stored on metadata. - episodeProps.associatedFiles = findAssociatedFiles(mediaFolderPath!, [], videoFilePath.absolutePath); - if(renameRule) { - episodeProps.videoFilePath.newPath = generateNameByRenameRule(mediaMetadata, renameRule, videoFilePath) - } - } - - return episodeProps; - }); - props.seasons.push({ - name: season.name, - seasonNumber: season.season, - episodes: episodes ?? [], - }); - }); - - return props; -} - - -/** - * Generates a name from a rename rule template. - * Can be used for both file names and folder names. - * - * @param mediaMetadata The media metadata containing TV show/movie information - * @param renameRule The rename rule with template to use - * @param mediaFileMetadata Optional media file metadata. If provided, all RenameRuleVariables are used. - * If not provided, only MediaMetadata-only variables are used (TV_SHOW_NAME, TMDB_ID, RELEASE_YEAR). - * @returns The generated name (sanitized with filenamify). For paths with '/', only the filename part is sanitized. - */ -export function generateNameByRenameRule( - mediaMetadata: MediaMetadata, - renameRule: RenameRule, - mediaFileMetadata?: MediaFileMetadata, -): string { - const variables: Record = {} - - RenameRuleVariables.forEach(variable => { - if(variable.type === "buildin" && !!variable.fn) { - variables[variable.name] = variable.fn(mediaMetadata, mediaFileMetadata) - } else { - console.error(`Unsupported variable type: ${variable.type}`) - return ''; - } - }) - - let generatedName = renameRule.template - - // Replace all occurrences of each variable in the template - Object.keys(variables).forEach(key => { - // Use global regex to replace all occurrences - const regex = new RegExp(`\\{${key}\\}`, 'g') - generatedName = generatedName.replace(regex, variables[key]) - }) - - // Handle paths with '/' separators (for file paths with season folders) - const parts = generatedName.split('/') - - if(parts.length === 1) { - return filenamify(generatedName) - } else { - const filename = parts[parts.length - 1] - const validFilename = filenamify(filename) - return [...parts.slice(0, -1), validFilename].join('/') - } -} - - -/** - * Check if a file exists by listing files in its directory - * @param filePath The file path in POSIX format - * @returns true if file exists, false otherwise - */ -export async function checkFileExists(filePath: string): Promise { - try { - const directoryPath = dirname(filePath) - const fileName = basename(filePath) - - if (!fileName) { - console.error(`[checkFileExists] Invalid file path: ${filePath}`) - return false - } - - // Get all files in the directory - const response = await listFilesApi(Path.toPlatformPath(directoryPath), { - onlyFiles: true, - }) - - if (!response.data?.items) { - console.error(`[checkFileExists] Failed to get files from directory: ${directoryPath}`) - return false - } - - const files = response.data.items - - // Check if the filename exists in the file list (case-sensitive match) - const fileExists = files.some((file) => { - const fileBasename = basename(file.path) - return fileBasename === fileName - }) - - return fileExists - } catch (error) { - console.error(`[checkFileExists] Error checking file existence for ${filePath}:`, error) - // Return false on error to allow download to proceed (graceful degradation) - return false - } -} - -// /** -// * @deprecated will be removed -// * @param mediaMetadata -// * @param mediaFileMetadata -// * @returns -// */ -// export async function downloadThumbnail(mediaMetadata: MediaMetadata, mediaFileMetadata: MediaFileMetadata) { -// return await limit(() => _downloadThumbnail(mediaMetadata, mediaFileMetadata)); -// } - -// export async function _downloadThumbnail(mediaMetadata: MediaMetadata, mediaFileMetadata: MediaFileMetadata) { - -// const seasonNumber = mediaFileMetadata.seasonNumber; -// const episodeNumber = mediaFileMetadata.episodeNumber; -// const episode = mediaMetadata.tvShow?.seasons.find(season => season.season === seasonNumber)?.episodes?.find(episode => episode.episode === episodeNumber); -// if(!episode) { -// return; -// } -// // TODO: add still_path field in TvShowEpisodeMetadata -// // const thumbnailUrl = episode.still_path ? getTMDBImageUrl(episode.still_path, 'w780') ?? undefined : undefined; -// const thumbnailUrl: string | undefined = undefined; -// if(!thumbnailUrl) { -// console.error(`[downloadThumbnail] Failed to get thumbnail URL for episode ${seasonNumber} ${episodeNumber}`); -// return; -// } -// console.log(`[downloadThumbnail] Downloading thumbnail for media file: `, mediaFileMetadata.absolutePath); -// console.log(`[downloadThumbnail] Downloading thumbnail for episode ${seasonNumber} ${episodeNumber} from ${thumbnailUrl}`); - -// const videoFileName = basename(mediaFileMetadata.absolutePath)!; -// const videoFileNameExt = extname(videoFileName); -// const videoFileNameWithoutExt = videoFileName.replace(videoFileNameExt, ''); - -// const thumbnailExt = thumbnailUrl?.split('.').pop(); -// if(!thumbnailExt) { -// console.error(`[downloadThumbnail] Failed to get thumbnail extension from ${thumbnailUrl}`); -// return; -// } - -// const thumbnailFileName = `${videoFileNameWithoutExt}.${thumbnailExt}`; -// const thumbnailFilePath = mediaFileMetadata.absolutePath.replace(videoFileName, thumbnailFileName); -// console.log(`[downloadThumbnail] Checking if thumbnail exists: ${thumbnailFilePath}`); - -// const fileExists = await checkFileExists(thumbnailFilePath); -// if (fileExists) { -// console.log(`[downloadThumbnail] Thumbnail already exists, skipping download: ${thumbnailFilePath}`); -// return; -// } - -// console.log(`[downloadThumbnail] Downloading thumbnail to ${thumbnailFilePath}`); -// const resp = await downloadImageApi(thumbnailUrl, thumbnailFilePath); -// if(resp.error) { -// if(isError(resp.error, ExistedFileError)) { -// console.log(`[downloadThumbnail] Thumbnail already exists: ${thumbnailFilePath}`); -// } else { -// console.error(`[downloadThumbnail] Failed to download thumbnail: ${resp.error}`); -// } -// return; -// } -// } - -/** - * Find the season folder path for a given season number - * @param mediaFolderPath The media folder path in POSIX format - * @param seasonNumber The season number - * @returns The season folder path in POSIX format, or null if not found - */ -async function findSeasonFolder(mediaFolderPath: string, seasonNumber: number): Promise { - const possibleFolderNames: string[] = [] - - if (seasonNumber === 0) { - possibleFolderNames.push('Specials') - } else { - // Try without padding (e.g., "Season 1") - possibleFolderNames.push(`Season ${seasonNumber}`) - // Try with 2-digit padding (e.g., "Season 01") - possibleFolderNames.push(`Season ${seasonNumber.toString().padStart(2, '0')}`) - } - - try { - // Get all folders in the media folder - const response = await listFilesApi(Path.toPlatformPath(mediaFolderPath), { - onlyFolders: true, - }) - - if (!response.data?.items) { - return null - } - - const folders = response.data.items - - // Check each possible folder name - for (const folderName of possibleFolderNames) { - const matchingFolder = folders.find((folder) => { - const folderBasename = basename(folder.path) - return folderBasename === folderName - }) - - if (matchingFolder) { - return matchingFolder.path - } - } - - return null - } catch (error) { - console.error(`[findSeasonFolder] Error finding season folder for season ${seasonNumber}:`, error) - return null - } -} - -/** - * Download season poster image - * @param mediaMetadata The media metadata - * @param season The TMDB season object - */ -export async function downloadSeasonPoster( - mediaMetadata: MediaMetadata, - season: TMDBSeason -): Promise { - console.log(`[downloadSeasonPoster] Starting download for season ${season.season_number}`) - - // Check if season has poster_path - if (!season.poster_path) { - console.log(`[downloadSeasonPoster] ⏭️ No poster found for season ${season.season_number}`) - return - } - - // Validate media folder path exists - if (!mediaMetadata.mediaFolderPath) { - console.error('[downloadSeasonPoster] mediaFolderPath is undefined') - return - } - - console.log(`[downloadSeasonPoster] Looking for season folder for season ${season.season_number} in ${mediaMetadata.mediaFolderPath}`) - - // Find the season folder - const seasonFolderPath = await findSeasonFolder(mediaMetadata.mediaFolderPath, season.season_number) - - if (!seasonFolderPath) { - console.log(`[downloadSeasonPoster] ⏭️ No folder found for season ${season.season_number}, skipping download`) - return - } - - console.log(`[downloadSeasonPoster] Found season folder: ${seasonFolderPath}`) - - try { - // Get the season poster URL - const posterUrl = getTMDBImageUrl(season.poster_path, 'original') - if (!posterUrl) { - console.error(`[downloadSeasonPoster] Failed to get poster URL for season ${season.season_number}`) - return - } - - // Extract file extension from URL - const thumbnailExt = posterUrl.split('.').pop() - if (!thumbnailExt) { - console.error(`[downloadSeasonPoster] Failed to get extension from ${posterUrl}`) - return - } - - // Build filename: season{number}-poster.{extension} (e.g., season01-poster.jpg, season00-poster.jpg for Specials) - const seasonNumberPadded = season.season_number.toString().padStart(2, '0') - const seasonPosterFileName = `season${seasonNumberPadded}-poster.${thumbnailExt}` - const seasonPosterPath = join(seasonFolderPath, seasonPosterFileName) - - console.log(`[downloadSeasonPoster] Checking if season poster exists: ${seasonPosterPath}`) - - const fileExists = await checkFileExists(seasonPosterPath) - if (fileExists) { - console.log(`[downloadSeasonPoster] Season poster already exists, skipping download: ${seasonPosterPath}`) - return - } - - console.log(`[downloadSeasonPoster] Downloading season poster for season ${season.season_number} to ${seasonPosterPath}`) - - // Download the image - const resp = await downloadImageWithFailover(posterUrl, seasonPosterPath) - if (resp.error) { - if (isError(resp.error, ExistedFileError)) { - console.log(`[downloadSeasonPoster] Season poster already exists: ${seasonPosterPath}`) - } else { - console.error(`[downloadSeasonPoster] Failed to download season poster: ${resp.error}`) - } - return - } - - console.log(`✅ Season poster downloaded to: ${seasonPosterPath}`) - } catch (error) { - console.error(`[downloadSeasonPoster] Error downloading season poster for season ${season.season_number}:`, error) - // Don't throw - errors are handled internally - } -} - /** * Validates that specified fields in an object are not undefined. * @param obj The object to validate diff --git a/apps/ui/src/lib/whitelistedCmd/index.ts b/apps/ui/src/lib/whitelistedCmd/index.ts index eb672a6d..a5abbd6e 100644 --- a/apps/ui/src/lib/whitelistedCmd/index.ts +++ b/apps/ui/src/lib/whitelistedCmd/index.ts @@ -1,13 +1,3 @@ export * from "@smm/core/whitelistedCmd"; -export { - executeCmdToCompletion, - executeCmdToCompletionWithHeaders, - formatExecuteCmdFailure, - truncateStderr, - type ExecuteCmdCompletionResult, -} from "./executeCmdToCompletion"; -export { - probeWhitelistedCommand, - versionProbeArgs, - type ProbeWhitelistedCommandResult, -} from "./probeWhitelistedCommand"; + +export { probeWhitelistedCommand } from "./probeWhitelistedCommand"; diff --git a/apps/ui/src/lib/ytdlp/executeYtdlp.ts b/apps/ui/src/lib/ytdlp/executeYtdlp.ts index 194f6293..5105a76b 100644 --- a/apps/ui/src/lib/ytdlp/executeYtdlp.ts +++ b/apps/ui/src/lib/ytdlp/executeYtdlp.ts @@ -1,6 +1,5 @@ import { parseYtdlpCookiesFileArg, - isManagedYtdlpCookiesPath, } from '@smm/core/whitelistedCmd/ytdlpCookies'; import { executeCmdToCompletion, @@ -10,7 +9,7 @@ import { } from '@/lib/whitelistedCmd/executeCmdToCompletion'; import { permanentlyDeleteYtdlpCookiesFile } from '@/lib/ytdlpCookiesFile'; -export type YtdlpCleanupPolicy = +type YtdlpCleanupPolicy = /** Delete managed cookies file after this command completes (success or failure). */ | 'managed-cookies-after-run' /** Caller is responsible (e.g. batch download job finally block). */ @@ -67,4 +66,4 @@ export async function executeYtdlp( } } -export { isManagedYtdlpCookiesPath, parseYtdlpCookiesFileArg }; + diff --git a/apps/ui/src/lib/ytdlpCookiesBrowsers.ts b/apps/ui/src/lib/ytdlpCookiesBrowsers.ts index 05f7a4ce..4cd1a602 100644 --- a/apps/ui/src/lib/ytdlpCookiesBrowsers.ts +++ b/apps/ui/src/lib/ytdlpCookiesBrowsers.ts @@ -1,11 +1,8 @@ /** All yt-dlp `--cookies-from-browser` profile names (lowercase). */ -export const YTDLP_COOKIES_BROWSER_IDS_ALL = ["chrome", "edge", "firefox"] as const +const YTDLP_COOKIES_BROWSER_IDS_ALL = ["chrome", "edge", "firefox"] as const export type YtdlpCookiesBrowserId = (typeof YTDLP_COOKIES_BROWSER_IDS_ALL)[number] -/** @deprecated Use `getCookiesBrowserIds(platform)` for platform-aware filtering. */ -export const YTDLP_COOKIES_BROWSER_IDS = YTDLP_COOKIES_BROWSER_IDS_ALL - /** * Returns available browsers for `--cookies-from-browser` on the given platform. * On Windows, Chrome and Edge are excluded because yt-dlp cannot decrypt their cookie stores. diff --git a/apps/ui/src/lib/ytdlpCookiesFile.ts b/apps/ui/src/lib/ytdlpCookiesFile.ts index cc7c7211..30265c67 100644 --- a/apps/ui/src/lib/ytdlpCookiesFile.ts +++ b/apps/ui/src/lib/ytdlpCookiesFile.ts @@ -41,6 +41,3 @@ export async function permanentlyDeleteYtdlpCookiesFile( console.warn('[ytdlpCookies] failed to permanently delete cookies file:', error); } } - -/** @deprecated Use {@link permanentlyDeleteYtdlpCookiesFile} */ -export const deleteYtdlpCookiesFile = permanentlyDeleteYtdlpCookiesFile; diff --git a/apps/ui/src/lib/ytdlpFormatCodes.ts b/apps/ui/src/lib/ytdlpFormatCodes.ts index bfe1c132..4fcd0af0 100644 --- a/apps/ui/src/lib/ytdlpFormatCodes.ts +++ b/apps/ui/src/lib/ytdlpFormatCodes.ts @@ -1,6 +1,6 @@ import type { Format } from "@/api/ytdlp/types" -export type YtdlpFormatCodeCategory = "audio-only" | "video-only" | "combined" +type YtdlpFormatCodeCategory = "audio-only" | "video-only" | "combined" export interface YtdlpFormatCodeEntry { id: string diff --git a/apps/ui/src/lib/ytdlpFormatPresets.ts b/apps/ui/src/lib/ytdlpFormatPresets.ts index 71d54842..70f34877 100644 --- a/apps/ui/src/lib/ytdlpFormatPresets.ts +++ b/apps/ui/src/lib/ytdlpFormatPresets.ts @@ -1,4 +1,4 @@ -export const YTDLP_FORMAT_PRESET_IDS = [ +const YTDLP_FORMAT_PRESET_IDS = [ "default", "best", "1080p", diff --git a/apps/ui/src/lib/ytdlpJsRuntimes.ts b/apps/ui/src/lib/ytdlpJsRuntimes.ts index 1b323130..2a1eaeee 100644 --- a/apps/ui/src/lib/ytdlpJsRuntimes.ts +++ b/apps/ui/src/lib/ytdlpJsRuntimes.ts @@ -4,19 +4,6 @@ export type YtdlpJsRuntimeId = (typeof YTDLP_JS_RUNTIME_IDS)[number] export const DEFAULT_YTDLP_JS_RUNTIME_ID: YtdlpJsRuntimeId = "quickjs" -export interface YtdlpJsRuntime { - id: YtdlpJsRuntimeId - /** Runtime name passed to `--js-runtimes`. */ - name: string -} - -export const YTDLP_JS_RUNTIMES: readonly YtdlpJsRuntime[] = [ - { id: "deno", name: "deno" }, - { id: "node", name: "node" }, - { id: "bun", name: "bun" }, - { id: "quickjs", name: "quickjs" }, -] - export type YtdlpJsRuntimeLabelKey = | "downloadVideo.jsRuntimeDeno" | "downloadVideo.jsRuntimeNode" diff --git a/apps/ui/src/providers/dialog-provider.tsx b/apps/ui/src/providers/dialog-provider.tsx index 0d72278f..1d6532c1 100644 --- a/apps/ui/src/providers/dialog-provider.tsx +++ b/apps/ui/src/providers/dialog-provider.tsx @@ -19,14 +19,13 @@ import { type DialogConfig, type FolderType, type FileItem, - type Task, type TrackProperties, type ExecuteCmdType, } from "@/components/dialogs" import type { SettingsTab } from "@/components/ui/config-panel" // Re-export types for backward compatibility -export type { FolderType, FileItem, Task } +export type { FolderType, FileItem } interface DialogContextValue { confirmationDialog: [ diff --git a/apps/ui/src/stores/statusbarStore.ts b/apps/ui/src/stores/statusbarStore.ts index 84dc0576..f267ae69 100644 --- a/apps/ui/src/stores/statusbarStore.ts +++ b/apps/ui/src/stores/statusbarStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' /** App bootstrap phase — StatusBar translates this at render time. */ -export type BootstrapStatus = +type BootstrapStatus = | { status: 'initializing' } | { status: 'ready' } | { status: 'error'; message: string } diff --git a/apps/ui/src/stores/tvShowPromptsStore.ts b/apps/ui/src/stores/tvShowPromptsStore.ts index 6342bdf7..8cf38406 100644 --- a/apps/ui/src/stores/tvShowPromptsStore.ts +++ b/apps/ui/src/stores/tvShowPromptsStore.ts @@ -189,78 +189,3 @@ export const useTvShowPromptsStore = create()( { name: 'TvShowPromptsStore' } ) ) - -export const useUseNfoPrompt = () => useTvShowPromptsStore((state) => state.useNfoPrompt) -export const useRuleBasedRenameFilePrompt = () => useTvShowPromptsStore((state) => state.ruleBasedRenameFilePrompt) -export const useRuleBasedRecognizePrompt = () => useTvShowPromptsStore((state) => state.ruleBasedRecognizePrompt) - -// Unified control hooks for prompts (legacy callers) -export const useRuleBasedRenameFilePromptControl = () => { - const state = useTvShowPromptsStore((state) => state.ruleBasedRenameFilePrompt) - const open = useTvShowPromptsStore((state) => state.openRuleBasedRenameFilePrompt) - const close = useTvShowPromptsStore((state) => state.closeRuleBasedRenameFilePrompt) - const updateSelectedRule = useTvShowPromptsStore((state) => state.updateRuleBasedRenameFilePromptSelectedRule) - - return { - states: state, - setState: (config: { open?: boolean; planId?: string; toolbarOptions?: ToolbarOption[]; selectedNamingRule?: "plex" | "emby" | undefined; setSelectedNamingRule?: ((rule: "plex" | "emby") => void) | undefined; onConfirm?: (planId: string) => void; onCancel?: () => void; onNamingRulesSelected?: (rule: "plex" | "emby") => void }) => { - if (config.open === false) { - close() - } else if (config.open === true) { - open({ - toolbarOptions: config.toolbarOptions || [], - selectedNamingRule: config.selectedNamingRule, - setSelectedNamingRule: config.setSelectedNamingRule || (() => {}), - planId: config.planId ?? '', - onConfirm: config.onConfirm, - onCancel: config.onCancel, - onNamingRulesSelected: config.onNamingRulesSelected, - }) - } - }, - updateSelectedRule, - } -} - -export const useRuleBasedRecognizePromptControl = () => { - const state = useTvShowPromptsStore((state) => state.ruleBasedRecognizePrompt) - const open = useTvShowPromptsStore((state) => state.openRuleBasedRecognizePrompt) - const close = useTvShowPromptsStore((state) => state.closeRuleBasedRecognizePrompt) - - return { - states: state, - setState: (config: { open?: boolean; tvShowTitle?: string; tvShowTmdbId?: number; onConfirm?: () => void; onCancel?: () => void }) => { - if (config.open === false) { - close() - } else if (config.open === true) { - open({ - tvShowTitle: config.tvShowTitle!, - tvShowTmdbId: config.tvShowTmdbId!, - onConfirm: config.onConfirm, - onCancel: config.onCancel, - }) - } - } - } -} - -export const useUseNfoPromptControl = () => { - const state = useTvShowPromptsStore((state) => state.useNfoPrompt) - const open = useTvShowPromptsStore((state) => state.openUseNfoPrompt) - const close = useTvShowPromptsStore((state) => state.closeUseNfoPrompt) - - return { - states: state, - setState: (config: { open?: boolean; nfoData?: TMDBTVShowDetails; onConfirm?: (tmdbTvShow: TMDBTVShow) => void; onCancel?: () => void }) => { - if (config.open === false) { - close() - } else if (config.open === true) { - open({ - nfoData: config.nfoData!, - onConfirm: config.onConfirm, - onCancel: config.onCancel, - }) - } - } - } -} diff --git a/apps/ui/src/stores/uiMediaFolderStore.ts b/apps/ui/src/stores/uiMediaFolderStore.ts index 7e1e1363..466031df 100644 --- a/apps/ui/src/stores/uiMediaFolderStore.ts +++ b/apps/ui/src/stores/uiMediaFolderStore.ts @@ -1,7 +1,6 @@ import { useMemo } from "react" import { create } from "zustand" import { useShallow } from "zustand/shallow" -import { Path } from "@smm/utils/path" import type { UIMediaFolder, UIMediaFolderStatus } from "@/types/UIMediaFolder" import { installUIMediaFolderStoreBridge } from "./uiMediaFolderStoreBridge" import { queryClient } from "@/lib/queryClient" @@ -117,15 +116,6 @@ installUIMediaFolderStoreBridge(() => { return { folders, selectedFolder } }) -/** Pure helper for later integration: map `UserConfig.folders` to {@link UIMediaFolder} rows. */ -export function uiMediaFoldersFromPaths(paths: string[]): UIMediaFolder[] { - return paths.map((path) => ({ - path: Path.toPlatformPath(path), - status: "idle", - test: false, - })) -} - export const useUIMediaFolderStoreState = () => useUIMediaFolderStore( useShallow((s) => ({ diff --git a/apps/ui/src/stores/uiMediaFolderStoreBridge.ts b/apps/ui/src/stores/uiMediaFolderStoreBridge.ts index c7d6a9c4..6ca8f976 100644 --- a/apps/ui/src/stores/uiMediaFolderStoreBridge.ts +++ b/apps/ui/src/stores/uiMediaFolderStoreBridge.ts @@ -21,10 +21,6 @@ export function selectSelectedFolderSnapshot( return { path: folder.path, status: folder.status } } -export type UIMediaFolderStoreBridge = { - getSelectedFolderSnapshot: () => UIMediaFolderStoreBridgeSnapshot | null -} - export function installUIMediaFolderStoreBridge( getState: () => UIMediaFolderStoreBridgeState, ): void { diff --git a/apps/ui/src/types/background-jobs.ts b/apps/ui/src/types/background-jobs.ts index 1d9574e5..67e23aab 100644 --- a/apps/ui/src/types/background-jobs.ts +++ b/apps/ui/src/types/background-jobs.ts @@ -11,7 +11,7 @@ export type JobStatus = 'pending' | 'running' | 'failed' | 'succeeded' | 'aborte /** Per-video row in a download-video job (see docs/design/download-bilibili-videos.md) */ export type DownloadVideoItemStatus = 'pending' | 'downloading' | 'succeeded' | 'failed'; -export interface BackgroundJobBase { +interface BackgroundJobBase { /** Unique identifier for the job */ id: string; diff --git a/apps/ui/src/types/eventTypes.ts b/apps/ui/src/types/eventTypes.ts index 683f967b..8e30468b 100644 --- a/apps/ui/src/types/eventTypes.ts +++ b/apps/ui/src/types/eventTypes.ts @@ -39,13 +39,6 @@ export interface OnFixedDelayBackgroundJobEventData { outcome?: FixedDelayBackgroundJobOutcome; } -/** Fired when a download-video background job finishes an item; MusicPanel may refresh metadata for `folder`. */ -export const UI_DownloadVideoJobFolderRefreshEvent = 'ui.downloadVideoJobFolderRefresh' - -export interface OnDownloadVideoJobFolderRefreshEventData { - folder: string -} - /** * Fired when the user asks to compress a video. * Dispatched by TvShowPanel / MoviePanel / MusicPanel / the app menu; diff --git a/ci/run-e2e-test.ts b/ci/run-e2e-test.ts index e94b910d..b5a1e2cd 100644 --- a/ci/run-e2e-test.ts +++ b/ci/run-e2e-test.ts @@ -59,7 +59,8 @@ async function main(): Promise { fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)); try { - const result = await $`bun apps/cicd/run.ts -f ${CONFIG_REL_PATH} --cwd ${ROOT}` + const runner = path.join('apps', 'cicd', 'run.ts'); + const result = await $`bun ${runner} -f ${CONFIG_REL_PATH} --cwd ${ROOT}` .cwd(ROOT) .env(process.env) .nothrow(); diff --git a/knip.json b/knip.json index 3eaa24e7..d384afbc 100644 --- a/knip.json +++ b/knip.json @@ -13,23 +13,37 @@ "packages/electron-common/ohos/**" ], "ignoreBinaries": [ - "vitest", - "eslint", - "vite", - "electron-vite", - "pino-pretty", - "ffmpeg", - "hdc" + "hdc", + "pino-pretty" + ], + "ignoreDependencies": [ + "@ai-sdk/mcp" ], "workspaces": { ".": { - "entry": ["ci/**/*.ts", "scripts/**/*.ts"] + "entry": [ + "ci/**/*.ts", + "scripts/**/*.ts", + "test/bin/*.ts", + "test/mcp-test-client/*.ts", + "test/utils/*.ts", + "test/mcp/**/*.test.ts" + ], + "ignore": [ + "test/mcp/lib/**" + ] }, "apps/cli": { - "entry": ["index.ts", "scripts/**/*.ts", "src/**/*.test.ts", "test/**/*.test.ts"] + "entry": [ + "scripts/**/*.ts", + "src/**/*.test.ts", + "test/**/*.test.ts" + ] }, "apps/core": { - "entry": ["src/**/*.test.ts"] + "entry": [ + "src/**/*.test.ts" + ] }, "apps/ui": { "entry": [ @@ -38,57 +52,86 @@ "src/**/*.stories.tsx", "src/**/*.test.ts", "src/**/*.test.tsx" + ], + "ignoreDependencies": [ + "@icons-pack/react-simple-icons", + "@radix-ui/react-accordion", + "@radix-ui/react-avatar", + "@radix-ui/react-use-controllable-state", + "@shikijs/transformers", + "@types/react-syntax-highlighter", + "cmdk", + "embla-carousel-react", + "harden-react-markdown", + "katex", + "react-markdown", + "react-syntax-highlighter", + "rehype-katex", + "remark-math", + "shiki", + "use-stick-to-bottom" ] }, - "apps/electron": { - "entry": ["src/main/index.ts", "src/preload/index.ts"] - }, "apps/e2e": { "entry": [ - "wdio.conf.ts", "wdio.conf.test.ts", "cli/**/*.test.ts", "common/**/*.e2e.ts", - "scenarios/**/*.ts", + "common/**/*.template.ts", "test/**/*.ts", "ohos/**/*.ts", "electron/**/*.ts", "docker/**/*.{ts,mjs}" ], - "ignore": ["common/manual/**"], + "ignore": [ + "common/manual/**" + ], "ignoreDependencies": [ - "test/pageobjects/page", - "test/lib/testbed", - "test/lib/env", - "test/actions/import-folders", - "test/componentobjects/Sidebar", "@wdio/html-nice-reporter" ] }, "apps/convex": { - "entry": ["convex/**/*.ts"] + "entry": [ + "convex/**/*.ts" + ] }, "apps/cicd": { - "entry": ["run.ts", "src/**/*.ts", "test/**/*.test.ts"] + "entry": [ + "src/**/*.ts", + "test/**/*.test.ts" + ] }, "apps/tools": { - "entry": ["**/*.ts"] + "entry": [ + "**/*.ts" + ] }, "packages/types": { - "entry": ["**/*.test.ts"] + "entry": [ + "**/*.test.ts" + ] }, "packages/utils": { - "entry": ["src/**/*.test.ts"] + "entry": [ + "src/**/*.test.ts" + ] }, "packages/core-routes": { - "entry": ["src/**/*.test.ts"] + "entry": [ + "src/**/*.test.ts" + ] }, "packages/tvdb4": { - "entry": ["src/**/*.test.ts", "test/**/*.ts"] + "entry": [ + "src/**/*.test.ts", + "test/**/*.ts" + ] }, "packages/electron-common": { - "entry": ["src/**/*.test.ts"] + "entry": [ + "src/**/*.test.ts" + ] }, "packages/test": {} } -} +} \ No newline at end of file diff --git a/mcp/index.ts b/mcp/index.ts deleted file mode 100644 index ea6b163b..00000000 --- a/mcp/index.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; -import { z } from "zod"; - -// Create server instance -const server = new McpServer({ - name: "demo-mcp-server", - version: "1.0.0", -}); - -// ============================================ -// 1. TOOL: Calculator -// ============================================ -server.registerTool( - "calculate", - { - description: "Perform basic arithmetic calculations", - inputSchema: { - expression: z - .string() - .describe("Mathematical expression to evaluate (e.g., '2 + 2', '10 * 5')"), - }, - } as any, - async (args: any) => { - const { expression } = args; - try { - // Safe evaluation of simple math expressions - const sanitized = expression.replace(/[^0-9+\-*/().\s]/g, ""); - const result = Function(`"use strict"; return (${sanitized})`)(); - - return { - content: [ - { - type: "text", - text: `Result: ${expression} = ${result}`, - }, - ], - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error: Invalid expression "${expression}"`, - }, - ], - isError: true, - }; - } - } -); - -// ============================================ -// 2. RESOURCE: Server Information (using URI string) -// ============================================ -server.registerResource( - "server-info", - "info://server", - { - description: "Server information and status", - mimeType: "application/json", - }, - async () => { - const info = { - name: "Demo MCP Server", - version: "1.0.0", - uptime: process.uptime(), - timestamp: new Date().toISOString(), - capabilities: { - tools: ["calculate"], - resources: ["server-info"], - prompts: ["greeting"], - }, - }; - - return { - contents: [ - { - uri: "info://server", - mimeType: "application/json", - text: JSON.stringify(info, null, 2), - }, - ], - }; - } -); - -// ============================================ -// 3. PROMPT: Greeting Template -// ============================================ -server.registerPrompt( - "greeting", - { - description: "Generate a personalized greeting message", - argsSchema: { - name: z.string().describe("Name of the person to greet"), - tone: z - .enum(["formal", "casual", "friendly"]) - .default("friendly") - .describe("Tone of the greeting"), - }, - } as any, - async (args: any) => { - const { name, tone = "friendly" } = args; - const greetings = { - formal: `Good day, ${name}. I hope this message finds you well. How may I assist you today?`, - casual: `Hey ${name}! What's up?`, - friendly: `Hello ${name}! 😊 Great to see you! How can I help you today?`, - }; - - return { - messages: [ - { - role: "user", - content: { - type: "text", - text: greetings[tone as keyof typeof greetings], - }, - }, - ], - }; - } -); - -// ============================================ -// Start Server with Streamable HTTP Transport -// ============================================ -const PORT = parseInt(process.env.PORT || "3000"); - -async function main() { - const transport = new WebStandardStreamableHTTPServerTransport({ - // sessionIdGenerator: () => crypto.randomUUID(), - }); - - await server.connect(transport); - - console.log(`Demo MCP Server running on http://localhost:${PORT}`); - console.log(`MCP endpoint: http://localhost:${PORT}/mcp`); - console.log("\nAvailable capabilities:"); - console.log(" Tool: calculate - Perform arithmetic calculations"); - console.log(" Resource: server-info - Server information"); - console.log(" Prompt: greeting - Generate greeting messages"); - - // Start Bun HTTP server - Bun.serve({ - port: PORT, - fetch: async (req) => { - // Route MCP requests to the transport handler - const url = new URL(req.url); - if (url.pathname === "/mcp") { - return transport.handleRequest(req); - } - - // Health check endpoint - return new Response( - JSON.stringify({ - name: "Demo MCP Server", - version: "1.0.0", - mcpEndpoint: `/mcp`, - }), - { - headers: { "Content-Type": "application/json" }, - } - ); - }, - }); - - console.log(`\nServer is now listening for requests...`); -} - -main().catch((error) => { - console.error("Failed to start server:", error); - process.exit(1); -}); diff --git a/package.json b/package.json index 7d59341b..3dc84a89 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "devDependencies": { "@changesets/cli": "^2.29.8", "@modelcontextprotocol/inspector": "^2.3.0", - "concurrently": "^9.2.1", + "@modelcontextprotocol/sdk": "^1.27.0", "cross-env": "^7.0.3", "knip": "^6.34.0", "npm-run-all2": "^8.0.4" diff --git a/packages/core-routes/src/index.ts b/packages/core-routes/src/index.ts index 114bdeec..edcf223a 100644 --- a/packages/core-routes/src/index.ts +++ b/packages/core-routes/src/index.ts @@ -150,7 +150,6 @@ export { handleListFilesPost, handleWriteFilePost, handleHelloGet, - handleHelloPost, handleIsFolderAvailablePost, handleGetEpisodesPost, handleListFilesInMediaFolderPost, diff --git a/packages/core-routes/src/mcp/index.ts b/packages/core-routes/src/mcp/index.ts index d79fe09e..46706a1e 100644 --- a/packages/core-routes/src/mcp/index.ts +++ b/packages/core-routes/src/mcp/index.ts @@ -29,26 +29,6 @@ export { type McpLifecycleResult, type McpStartRequestBody, } from "./lifecycle.ts"; -export { - getMcpServerStatusWithUserConfig, - startMcpServerWithUserConfig, - stopMcpServerWithUserConfig, - type McpServerStateResponse, - type McpServerOperationOptions, - DEFAULT_MCP_HOST, - DEFAULT_MCP_PORT, -} from "./mcpServerConfig.ts"; - -/** - * Re-exported tool-name constants so consumers that already depend - * on `@smm/core-routes` (e.g. `apps/cli`) can reference them - * without resolving `@smm/core` directly. - */ -export { RENAME_FOLDER }; -export { RENAME_EPISODE_FILE }; -export { SCRAPE }; -export { GET_JOB }; -export { TMDB_SEARCH, TMDB_GET_MOVIE, TMDB_GET_TV_SHOW }; /** * Constants exposed to hosts that load `core-routes.js` as a diff --git a/packages/core-routes/src/mcp/mcpServerConfig.ts b/packages/core-routes/src/mcp/mcpServerConfig.ts index 83154ea7..d5512679 100644 --- a/packages/core-routes/src/mcp/mcpServerConfig.ts +++ b/packages/core-routes/src/mcp/mcpServerConfig.ts @@ -21,7 +21,7 @@ function mcpErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -export function resolveMcpStartOptions( +function resolveMcpStartOptions( config: UserConfig, options?: StartMcpOptions, ): { hostname: string; port: number } { diff --git a/packages/core-routes/src/register.ts b/packages/core-routes/src/register.ts index 9e33f6f6..4eb83132 100644 --- a/packages/core-routes/src/register.ts +++ b/packages/core-routes/src/register.ts @@ -110,7 +110,7 @@ export function registerCoreRoutes(server: http.Server, config: CoreRoutesConfig export { handleListFilesGet, handleListFilesPost } from "./routes/listFilesRoute.ts"; export { handleWriteFilePost } from "./routes/writeFileRoute.ts"; -export { handleHelloGet, handleHelloPost } from "./routes/helloRoute.ts"; +export { handleHelloGet } from "./routes/helloRoute.ts"; export { handleIsFolderAvailablePost } from "./routes/isFolderAvailableRoute.ts"; export { handleReadFilePost } from "./routes/readFileRoute.ts"; export { handleDeleteFilePost } from "./routes/deleteFileRoute.ts"; diff --git a/packages/core-routes/src/renameFilesValidation.ts b/packages/core-routes/src/renameFilesValidation.ts deleted file mode 100644 index 6aefd622..00000000 --- a/packages/core-routes/src/renameFilesValidation.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { validateRenameOperations as validateRenameOperationsShared } from "@smm/core/validations/rename/validateRenameOperations"; -import type { RenameValidationResult } from "@smm/types"; -import { createNodeRenameFileExistenceProbe } from "./nodeRenameFileExistenceProbe.ts"; - -/** - * A single rename operation: source path and target path. - */ -export interface RenameFile { - from: string; - to: string; -} - -/** - * Validate a batch of rename operations. Self-contained for Bun (`apps/cli`) - * and Node (`apps/ohos` / Electron main): shared path rules + `node:fs` probe. - */ -export async function validateRenameOperations( - files: RenameFile[], - folderPathInPosix: string, -): Promise { - return validateRenameOperationsShared( - files, - folderPathInPosix, - createNodeRenameFileExistenceProbe(), - ); -} diff --git a/packages/core-routes/src/routes/helloRoute.ts b/packages/core-routes/src/routes/helloRoute.ts index 4633c6c8..d24ee3ae 100644 --- a/packages/core-routes/src/routes/helloRoute.ts +++ b/packages/core-routes/src/routes/helloRoute.ts @@ -27,6 +27,3 @@ export async function handleHelloGet( sendJson(res, 200, result satisfies HelloHttpResponseBody); return true; } - -/** @deprecated Use handleHelloGet */ -export const handleHelloPost = handleHelloGet; diff --git a/packages/core-routes/src/tools/createRecognizeEpisodePlan.ts b/packages/core-routes/src/tools/createRecognizeEpisodePlan.ts index c67e76e2..48b1d2f8 100644 --- a/packages/core-routes/src/tools/createRecognizeEpisodePlan.ts +++ b/packages/core-routes/src/tools/createRecognizeEpisodePlan.ts @@ -134,5 +134,3 @@ export function buildCreateRecognizeEpisodePlanTool( }; } -export const CREATE_RECOGNIZE_EPISODE_PLAN_TOOL_NAME = - CREATE_RECOGNIZE_EPISODE_PLAN; diff --git a/packages/core-routes/src/tools/createRenameEpisodePlan.ts b/packages/core-routes/src/tools/createRenameEpisodePlan.ts index 308d0834..8a0eb089 100644 --- a/packages/core-routes/src/tools/createRenameEpisodePlan.ts +++ b/packages/core-routes/src/tools/createRenameEpisodePlan.ts @@ -141,5 +141,3 @@ export function buildCreateRenameEpisodePlanTool( }; } -export const CREATE_RENAME_EPISODE_PLAN_TOOL_NAME = - CREATE_RENAME_EPISODE_PLAN; diff --git a/packages/core-routes/src/tools/getApplicationContext.ts b/packages/core-routes/src/tools/getApplicationContext.ts index 7d3bcf60..18f62fd1 100644 --- a/packages/core-routes/src/tools/getApplicationContext.ts +++ b/packages/core-routes/src/tools/getApplicationContext.ts @@ -1,5 +1,4 @@ import { - GET_APPLICATION_CONTEXT, GET_APPLICATION_CONTEXT_DESCRIPTION, getApplicationContextInputSchema, getApplicationContextOutputSchema, @@ -80,5 +79,3 @@ async function resolveSelectedMediaFolder( return responseData?.selectedMediaMetadata?.mediaFolderPath ?? ""; } -/** Re-exported tool name constant for the tools registry. */ -export const GET_APPLICATION_CONTEXT_TOOL_NAME = GET_APPLICATION_CONTEXT; diff --git a/packages/core-routes/src/tools/getEpisodes.ts b/packages/core-routes/src/tools/getEpisodes.ts index 5f63a1f7..00a9bd10 100644 --- a/packages/core-routes/src/tools/getEpisodes.ts +++ b/packages/core-routes/src/tools/getEpisodes.ts @@ -1,5 +1,4 @@ import { - GET_EPISODES, GET_EPISODES_DESCRIPTION, getEpisodesInputSchema, getEpisodesToolOutputSchema, @@ -32,4 +31,3 @@ export function buildGetEpisodesTool( } /** Re-exported tool name constant for the tools registry. */ -export const GET_EPISODES_TOOL_NAME = GET_EPISODES; diff --git a/packages/core-routes/src/tools/getJob.ts b/packages/core-routes/src/tools/getJob.ts index 1ad8b8db..895fd2e6 100644 --- a/packages/core-routes/src/tools/getJob.ts +++ b/packages/core-routes/src/tools/getJob.ts @@ -1,7 +1,6 @@ import { getJobFailed, getJobSucceeded } from "@smm/core/ai-tool/getJobResult"; import { requireNonEmptyString } from "@smm/core/ai-tool/toolResult"; import { - GET_JOB, GET_JOB_DESCRIPTION, getJobInputSchema, getJobOutputSchema, @@ -65,4 +64,3 @@ export function buildGetJobTool( }; } -export { GET_JOB }; diff --git a/packages/core-routes/src/tools/getMediaFolders.ts b/packages/core-routes/src/tools/getMediaFolders.ts index de55736b..7d8730e9 100644 --- a/packages/core-routes/src/tools/getMediaFolders.ts +++ b/packages/core-routes/src/tools/getMediaFolders.ts @@ -1,5 +1,4 @@ import { - GET_MEDIA_FOLDERS, GET_MEDIA_FOLDERS_DESCRIPTION, getMediaFoldersInputSchema, getMediaFoldersOutputSchema, @@ -50,4 +49,3 @@ export function buildGetMediaFoldersTool( } /** Re-exported tool name constant for the tools registry. */ -export const GET_MEDIA_FOLDERS_TOOL_NAME = GET_MEDIA_FOLDERS; diff --git a/packages/core-routes/src/tools/getMediaMetadata.ts b/packages/core-routes/src/tools/getMediaMetadata.ts index 17bfbcf2..d0a86808 100644 --- a/packages/core-routes/src/tools/getMediaMetadata.ts +++ b/packages/core-routes/src/tools/getMediaMetadata.ts @@ -6,7 +6,6 @@ import { } from "@smm/core/ai-tool/getMediaMetadataResponse"; import { requireNonEmptyString } from "@smm/core/ai-tool/toolResult"; import { - GET_MEDIA_METADATA, GET_MEDIA_METADATA_DESCRIPTION, GET_MEDIA_METADATA_FOLDER_NOT_FOUND, GET_MEDIA_METADATA_NOT_DIRECTORY, @@ -117,5 +116,3 @@ export function buildGetMediaMetadataTool( }; } -/** Re-exported tool name constant for the tools registry. */ -export const GET_MEDIA_METADATA_TOOL_NAME = GET_MEDIA_METADATA; diff --git a/packages/core-routes/src/tools/isFolderExist.ts b/packages/core-routes/src/tools/isFolderExist.ts index 87dc5156..0f77e8b9 100644 --- a/packages/core-routes/src/tools/isFolderExist.ts +++ b/packages/core-routes/src/tools/isFolderExist.ts @@ -1,5 +1,4 @@ import { - IS_FOLDER_EXIST, IS_FOLDER_EXIST_DESCRIPTION, isFolderExistInputSchema, isFolderExistOutputSchema, @@ -58,4 +57,3 @@ export function buildIsFolderExistTool() { } /** Re-exported tool name constant for the tools registry. */ -export const IS_FOLDER_EXIST_TOOL_NAME = IS_FOLDER_EXIST; diff --git a/packages/core-routes/src/tools/listFilesInMediaFolder.ts b/packages/core-routes/src/tools/listFilesInMediaFolder.ts index 200425fd..bdd08f93 100644 --- a/packages/core-routes/src/tools/listFilesInMediaFolder.ts +++ b/packages/core-routes/src/tools/listFilesInMediaFolder.ts @@ -5,7 +5,6 @@ import { } from "@smm/core/ai-tool/buildListFilesInMediaFolderResponse"; import { formatToolError, requireNonEmptyString, toolOk } from "@smm/core/ai-tool/toolResult"; import { - LIST_FILES_IN_MEDIA_FOLDER, LIST_FILES_IN_MEDIA_FOLDER_DESCRIPTION, LIST_FILES_IN_MEDIA_FOLDER_INVALID_PATH, LIST_FILES_IN_MEDIA_FOLDER_NOT_MANAGED, @@ -23,7 +22,7 @@ import { doListFiles } from "../listFiles.ts"; * pre-resolved `UserConfig` snapshot so the tool does not need to * touch the filesystem-bound config reader. */ -export async function executeListFilesInMediaFolder( +async function executeListFilesInMediaFolder( params: { mediaFolderPath: string; recursively?: boolean; @@ -132,5 +131,3 @@ export function buildListFilesInMediaFolderTool( }; } -/** Re-exported tool name constant for the tools registry. */ -export const LIST_FILES_IN_MEDIA_FOLDER_TOOL_NAME = LIST_FILES_IN_MEDIA_FOLDER; diff --git a/packages/core-routes/src/tools/plans.ts b/packages/core-routes/src/tools/plans.ts index 2eaacba4..3de7769e 100644 --- a/packages/core-routes/src/tools/plans.ts +++ b/packages/core-routes/src/tools/plans.ts @@ -21,11 +21,11 @@ export type AnyPlan = RecognizeMediaFilePlan | RenameFilesPlan; * runtime-neutral {@link ChatFs} abstraction so the same code works * for both Node (OHOS) and Bun (cli). */ -export function plansDir(appDataDir: string): string { +function plansDir(appDataDir: string): string { return path.join(appDataDir, "plans"); } -export function planFilePath(appDataDir: string, planId: string): string { +function planFilePath(appDataDir: string, planId: string): string { return path.join(plansDir(appDataDir), `${planId}.plan.json`); } @@ -49,22 +49,6 @@ async function ensurePlansDirExists( // ─── Rename-files plan ─────────────────────────────────────────── -/** - * Read a rename plan by id. Returns `null` if the file does not - * exist. - */ -export async function readRenamePlan( - appDataDir: string, - planId: string, - fs: ChatFs, -): Promise { - const plan = await readPlanById(appDataDir, planId, fs); - if (!plan || plan.task !== "rename-files") { - return null; - } - return plan as RenameFilesPlan; -} - /** * Read any plan file by id. Returns `null` when the file does not * exist. @@ -240,7 +224,7 @@ export async function updatePlanContent( /** * Delete a plan file by id. No-op if the file does not exist. */ -export async function deletePlan( +async function deletePlan( appDataDir: string, id: string, ): Promise { diff --git a/packages/core-routes/src/tools/renameEpisodeFile.ts b/packages/core-routes/src/tools/renameEpisodeFile.ts index 32ef7d8b..8bae263b 100644 --- a/packages/core-routes/src/tools/renameEpisodeFile.ts +++ b/packages/core-routes/src/tools/renameEpisodeFile.ts @@ -7,7 +7,6 @@ import { } from "@smm/core/ai-tool/renameEpisodeFileResult"; import { requireNonEmptyString } from "@smm/core/ai-tool/toolResult"; import { - RENAME_EPISODE_FILE, RENAME_EPISODE_FILE_DESCRIPTION, renameEpisodeFileInputSchema, renameEpisodeFileOutputSchema, @@ -192,5 +191,3 @@ export function buildRenameEpisodeFileTool( }; } -/** Re-exported tool name constant for the tools registry. */ -export const RENAME_EPISODE_FILE_TOOL_NAME = RENAME_EPISODE_FILE; diff --git a/packages/core-routes/src/tools/renameFolder.ts b/packages/core-routes/src/tools/renameFolder.ts index ac9ce978..65656370 100644 --- a/packages/core-routes/src/tools/renameFolder.ts +++ b/packages/core-routes/src/tools/renameFolder.ts @@ -6,7 +6,6 @@ import { } from "@smm/core/ai-tool/renameFolderResult"; import { requireNonEmptyString } from "@smm/core/ai-tool/toolResult"; import { - RENAME_FOLDER, RENAME_FOLDER_DESCRIPTION, renameFolderInputSchema, renameFolderOutputSchema, @@ -157,4 +156,3 @@ export function buildRenameFolderTool( } /** Re-exported tool name constant for the tools registry. */ -export const RENAME_FOLDER_TOOL_NAME = RENAME_FOLDER; diff --git a/packages/core-routes/src/tools/scrape.ts b/packages/core-routes/src/tools/scrape.ts index c008da1e..934b9cf8 100644 --- a/packages/core-routes/src/tools/scrape.ts +++ b/packages/core-routes/src/tools/scrape.ts @@ -1,7 +1,6 @@ import { requireNonEmptyString } from "@smm/core/ai-tool/toolResult"; import { scrapeFailed, scrapeSucceeded } from "@smm/core/ai-tool/scrapeResult"; import { - SCRAPE, SCRAPE_DESCRIPTION, scrapeInputSchema, scrapeOutputSchema, @@ -83,4 +82,3 @@ export function buildScrapeTool( }; } -export { SCRAPE }; diff --git a/packages/core-routes/src/tools/tmdb.ts b/packages/core-routes/src/tools/tmdb.ts index e98dbac2..36d99579 100644 --- a/packages/core-routes/src/tools/tmdb.ts +++ b/packages/core-routes/src/tools/tmdb.ts @@ -5,7 +5,6 @@ import { toTmdbCoreOptions, } from "@smm/types/ai-tools/tmdbCommon"; import { - TMDB_SEARCH, TMDB_SEARCH_DESCRIPTION, tmdbSearchInputSchema, tmdbSearchOutputSchema, @@ -13,7 +12,6 @@ import { type TmdbSearchOutput, } from "@smm/types/ai-tools/tmdbSearch"; import { - TMDB_GET_MOVIE, TMDB_GET_MOVIE_DESCRIPTION, tmdbGetMovieInputSchema, tmdbGetMovieOutputSchema, @@ -21,7 +19,6 @@ import { type TmdbGetMovieOutput, } from "@smm/types/ai-tools/tmdbGetMovie"; import { - TMDB_GET_TV_SHOW, TMDB_GET_TV_SHOW_DESCRIPTION, tmdbGetTvShowInputSchema, tmdbGetTvShowOutputSchema, @@ -197,4 +194,3 @@ export function buildTmdbGetTvShowTool( }; } -export { TMDB_SEARCH, TMDB_GET_MOVIE, TMDB_GET_TV_SHOW }; diff --git a/packages/core-routes/src/tools/tvdb.ts b/packages/core-routes/src/tools/tvdb.ts index 44fd433e..58199142 100644 --- a/packages/core-routes/src/tools/tvdb.ts +++ b/packages/core-routes/src/tools/tvdb.ts @@ -4,7 +4,6 @@ import { toTvdbCoreOptions, } from "@smm/types/ai-tools/tvdbCommon"; import { - TVDB_SEARCH, TVDB_SEARCH_DESCRIPTION, tvdbSearchInputSchema, tvdbSearchOutputSchema, @@ -12,7 +11,6 @@ import { type TvdbSearchOutput, } from "@smm/types/ai-tools/tvdbSearch"; import { - TVDB_GET_MOVIE, TVDB_GET_MOVIE_DESCRIPTION, tvdbGetMovieInputSchema, tvdbGetMovieOutputSchema, @@ -20,7 +18,6 @@ import { type TvdbGetMovieOutput, } from "@smm/types/ai-tools/tvdbGetMovie"; import { - TVDB_GET_TV_SHOW, TVDB_GET_TV_SHOW_DESCRIPTION, tvdbGetTvShowInputSchema, tvdbGetTvShowOutputSchema, @@ -28,7 +25,6 @@ import { type TvdbGetTvShowOutput, } from "@smm/types/ai-tools/tvdbGetTvShow"; import { - TVDB_GET_LANGUAGES, TVDB_GET_LANGUAGES_DESCRIPTION, tvdbGetLanguagesInputSchema, tvdbGetLanguagesOutputSchema, @@ -230,4 +226,3 @@ export function buildTvdbGetLanguagesTool( }; } -export { TVDB_SEARCH, TVDB_GET_MOVIE, TVDB_GET_TV_SHOW, TVDB_GET_LANGUAGES }; diff --git a/packages/core-routes/src/tools/types.ts b/packages/core-routes/src/tools/types.ts deleted file mode 100644 index 4f15bf55..00000000 --- a/packages/core-routes/src/tools/types.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Shape of an agent tool built for AI SDK's `streamText` `tools` map. - * - * `build` is a factory that takes the per-request context (clientId, - * abortSignal, etc.) and returns the AI-SDK-compatible tool object. - * The {@link ToolDescriptor} is the registered, framework-neutral - * description of a tool. - */ -export interface AgentTool { - description?: string; - inputSchema: unknown; - outputSchema?: unknown; - execute: (args: unknown) => Promise; -} - -/** - * Factory for an {@link AgentTool}. The factory receives the per- - * request context (clientId, abortSignal) so each request can build - * a tool bound to its own socket / cancellation. - */ -export type AgentToolFactory = (ctx: AgentToolContext) => AgentTool; - -export interface AgentToolContext { - /** UI-side socket id; tools that need to ask the UI for input use this. */ - clientId: string; - /** Abort signal from the chat request; tools that fetch I/O honor it. */ - abortSignal: AbortSignal | undefined; -} - -/** - * Registry entry for a single agent tool. `inputSchema` and - * `outputSchema` come from `@smm/types/ai-tools/*` and are reused by - * MCP tools, agent tools, and tests. - */ -export interface ToolDescriptor { - /** AI tool name constant from `@smm/types/ai-tools/*`. */ - toolName: string; - description: string; - inputSchema: unknown; - outputSchema?: unknown; - build: AgentToolFactory; -} diff --git a/packages/core-routes/src/userConfig.ts b/packages/core-routes/src/userConfig.ts index bf130fa4..0760f469 100644 --- a/packages/core-routes/src/userConfig.ts +++ b/packages/core-routes/src/userConfig.ts @@ -13,7 +13,7 @@ const DEFAULT_USER_CONFIG: UserConfig = { selectedRenameRule: "plex", }; -export function resolveUserDataDir(config: CoreRoutesConfig): string | undefined { +function resolveUserDataDir(config: CoreRoutesConfig): string | undefined { return config.hello?.userDataDir ?? config.appDataDir; } diff --git a/packages/test/src/index.ts b/packages/test/src/index.ts index ac3fec0c..49341d73 100644 --- a/packages/test/src/index.ts +++ b/packages/test/src/index.ts @@ -6,11 +6,9 @@ export { type LangCode, type TestFolder, - folder1, folder2, folder3, folder4, - folder5, folder6, musicFolder, tvShowFolder, diff --git a/packages/test/src/testFolders.ts b/packages/test/src/testFolders.ts index 47300da8..ef1bb36d 100644 --- a/packages/test/src/testFolders.ts +++ b/packages/test/src/testFolders.ts @@ -17,7 +17,7 @@ export interface TestFolder { } /** TMDB-tagged TV show (天使降临到我身边). */ -export const folder1: TestFolder = { +const folder1: TestFolder = { folderName: '天使降临到我身边! (2019) {tmdbid=84666}', mediaName: '天使降临到我身边!', translations: { @@ -77,7 +77,7 @@ export const folder4: TestFolder = { } /** TVDB-tagged movie. */ -export const folder5: TestFolder = { +const folder5: TestFolder = { folderName: 'The Dark Knight {tvdbid=116}', mediaName: '蝙蝠侠:黑暗骑士', translations: { diff --git a/packages/tvdb4/src/types.ts b/packages/tvdb4/src/types.ts index 013558bd..ab4f3e43 100644 --- a/packages/tvdb4/src/types.ts +++ b/packages/tvdb4/src/types.ts @@ -1,4 +1,4 @@ -export interface TVDBv4Links { +interface TVDBv4Links { prev?: string | null; self?: string | null; next?: string | null; @@ -30,7 +30,7 @@ export type TVDBv4MovieBaseRecord = Record & { id?: number }; export type TVDBv4SeasonBaseRecord = Record & { id?: number }; export type TVDBv4SeriesBaseRecord = Record & { id?: number }; -export interface TVDBv4SeriesSeasonsExtendedResponseEpisode { +interface TVDBv4SeriesSeasonsExtendedResponseEpisode { id: number; seriesId: number; name: string; @@ -186,7 +186,7 @@ export interface TVDBv4ListMoviesParams extends TVDBv4ListParams {} export interface TVDBv4ListSeasonsParams extends TVDBv4ListParams {} export interface TVDBv4ListSeriesParams extends TVDBv4ListParams {} -export type TVDBv4SearchType = "series" | "movie"; +type TVDBv4SearchType = "series" | "movie"; export interface TVDBv4SearchParams extends TVDBv4ListParams { query: string; diff --git a/packages/types/GetEpisodesToolTypes.ts b/packages/types/GetEpisodesToolTypes.ts deleted file mode 100644 index 906f7e61..00000000 --- a/packages/types/GetEpisodesToolTypes.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * @deprecated Import from `@smm/types/ai-tools/getEpisodes` instead. - */ -export type { - GetEpisodesInput as GetEpisodesToolRequest, - GetEpisodesToolOutput as GetEpisodesToolResponse, -} from './ai-tools/getEpisodes' - -export { - GET_EPISODES_NO_CACHE as MSG_FOLDER_NOT_FOUND, - GET_EPISODES_NOT_TV_SHOW as MSG_UNKNOWN_TV_SHOW, -} from './ai-tools/getEpisodes' diff --git a/packages/types/YtdlpTypes.ts b/packages/types/YtdlpTypes.ts index e3f2a386..e2766ea9 100644 --- a/packages/types/YtdlpTypes.ts +++ b/packages/types/YtdlpTypes.ts @@ -1,8 +1,8 @@ /** HTTP headers object as emitted by yt-dlp (keys vary by context). */ -export type YtdlpHttpHeaders = Record; +type YtdlpHttpHeaders = Record; /** Single stream / merged format entry from yt-dlp `-J` output. */ -export interface YtdlpFormat { +interface YtdlpFormat { url: string; ext: string; acodec: string; @@ -28,12 +28,12 @@ export interface YtdlpFormat { quality?: number; } -export interface YtdlpThumbnail { +interface YtdlpThumbnail { url: string; id: string; } -export interface YtdlpVersionInfo { +interface YtdlpVersionInfo { version: string; current_git_head: string | null; release_git_head: string; diff --git a/packages/types/ai-tools/createRecognizeEpisodePlan.ts b/packages/types/ai-tools/createRecognizeEpisodePlan.ts index 5eabda7d..52615116 100644 --- a/packages/types/ai-tools/createRecognizeEpisodePlan.ts +++ b/packages/types/ai-tools/createRecognizeEpisodePlan.ts @@ -24,6 +24,3 @@ export const createRecognizeEpisodePlanInputSchema = z.object({ .min(1), }) -export type CreateRecognizeEpisodePlanInput = z.infer< - typeof createRecognizeEpisodePlanInputSchema -> diff --git a/packages/types/ai-tools/createRenameEpisodePlan.ts b/packages/types/ai-tools/createRenameEpisodePlan.ts index 8dbfd07c..0c5ad269 100644 --- a/packages/types/ai-tools/createRenameEpisodePlan.ts +++ b/packages/types/ai-tools/createRenameEpisodePlan.ts @@ -20,6 +20,3 @@ export const createRenameEpisodePlanInputSchema = z.object({ .min(1), }) -export type CreateRenameEpisodePlanInput = z.infer< - typeof createRenameEpisodePlanInputSchema -> diff --git a/packages/types/ai-tools/getApplicationContext.ts b/packages/types/ai-tools/getApplicationContext.ts index df5fd06d..da799538 100644 --- a/packages/types/ai-tools/getApplicationContext.ts +++ b/packages/types/ai-tools/getApplicationContext.ts @@ -22,9 +22,6 @@ export const getApplicationContextOutputSchema = z.object({ .describe('Error message if the operation failed'), }) -export type GetApplicationContextInput = z.infer< - typeof getApplicationContextInputSchema -> export type GetApplicationContextOutput = z.infer< typeof getApplicationContextOutputSchema > diff --git a/packages/types/ai-tools/getEpisodes.ts b/packages/types/ai-tools/getEpisodes.ts index 281bfab9..8e46ad43 100644 --- a/packages/types/ai-tools/getEpisodes.ts +++ b/packages/types/ai-tools/getEpisodes.ts @@ -28,7 +28,7 @@ export const getEpisodesInputSchema = z.object({ ), }) -export const getEpisodesEpisodeSchema = z.object({ +const getEpisodesEpisodeSchema = z.object({ season: z.number().describe('The season number'), episode: z.number().describe('The episode number'), videoFilePath: z @@ -39,7 +39,7 @@ export const getEpisodesEpisodeSchema = z.object({ ), }) -export const getEpisodesDataSchema = z.object({ +const getEpisodesDataSchema = z.object({ episodes: z .array(getEpisodesEpisodeSchema) .describe('Array of all episodes with their video file paths'), @@ -52,7 +52,6 @@ export const getEpisodesToolOutputSchema = getEpisodesDataSchema.extend({ error: z.string().optional(), }) -export type GetEpisodesInput = z.infer export type GetEpisodesEpisode = z.infer export type GetEpisodesResponseData = z.infer export type GetEpisodesToolOutput = z.infer diff --git a/packages/types/ai-tools/getJob.ts b/packages/types/ai-tools/getJob.ts index 9e4e7bf0..162ec7e0 100644 --- a/packages/types/ai-tools/getJob.ts +++ b/packages/types/ai-tools/getJob.ts @@ -30,7 +30,7 @@ const scrapeJobTaskSchema = z.object({ error: z.string().optional(), }) -export const scrapeJobSchema = z.object({ +const scrapeJobSchema = z.object({ kind: z.literal('scrape'), id: z.string(), folderPath: z.string(), @@ -46,7 +46,7 @@ export const scrapeJobSchema = z.object({ updatedAt: z.number(), }) -export const importJobSchema = z.object({ +const importJobSchema = z.object({ kind: z.literal('import'), id: z.string(), folderPath: z.string(), @@ -60,7 +60,7 @@ export const importJobSchema = z.object({ updatedAt: z.number(), }) -export const jobSchema = z.discriminatedUnion('kind', [ +const jobSchema = z.discriminatedUnion('kind', [ scrapeJobSchema, importJobSchema, ]) @@ -77,6 +77,5 @@ export const getJobOutputSchema = z.object({ .describe('Error message when the job could not be loaded'), }) -export type GetJobInput = z.infer export type GetJobOutput = z.infer export type JobToolPayload = z.infer diff --git a/packages/types/ai-tools/getMediaFolders.ts b/packages/types/ai-tools/getMediaFolders.ts index 889f032e..bb33cbd9 100644 --- a/packages/types/ai-tools/getMediaFolders.ts +++ b/packages/types/ai-tools/getMediaFolders.ts @@ -7,7 +7,7 @@ export const GET_MEDIA_FOLDERS_DESCRIPTION = export const getMediaFoldersInputSchema = z.object({}) -export const getMediaFoldersDataSchema = z.object({ +const getMediaFoldersDataSchema = z.object({ folders: z .array(z.string()) .describe('Array of media folder paths managed by SMM'), @@ -17,7 +17,6 @@ export const getMediaFoldersOutputSchema = getMediaFoldersDataSchema.extend({ error: z.string().optional(), }) -export type GetMediaFoldersInput = z.infer export type GetMediaFoldersResponseData = z.infer< typeof getMediaFoldersDataSchema > diff --git a/packages/types/ai-tools/getMediaMetadata.ts b/packages/types/ai-tools/getMediaMetadata.ts index 327eddf5..4bd865f5 100644 --- a/packages/types/ai-tools/getMediaMetadata.ts +++ b/packages/types/ai-tools/getMediaMetadata.ts @@ -38,7 +38,7 @@ const seasonEpisodeSchema = z.object({ ), }) -export const getMediaMetadataDataSchema = z.object({ +const getMediaMetadataDataSchema = z.object({ mediaFolderPath: z.string().describe('The path of the media folder'), type: z .enum(['tvshow-folder', 'movie-folder', 'music-folder']) @@ -86,42 +86,9 @@ export const getMediaMetadataToolOutputSchema = getMediaMetadataDataSchema.exten error: z.string().optional().describe('Error message when lookup failed'), }) -export type GetMediaMetadataInput = z.infer export type GetMediaMetadataResponseData = z.infer export type GetMediaMetadataToolOutput = z.infer< typeof getMediaMetadataToolOutputSchema > -export interface GetMediaMetadataResponseTvShowEpisodeData { - seasonNumber: number - episodeNumber: number - episodeName: string -} -export interface GetMediaMetadataResponseTvShowSeasonData { - seasonNumber: number - seasonName: string - episodes: GetMediaMetadataResponseTvShowEpisodeData[] -} - -export interface GetMediaMetadataResponseTvShowData { - source: 'TMDB' | 'TVDB' - id: number - name: string - seasons: GetMediaMetadataResponseTvShowSeasonData[] -} - -export interface GetMediaMetadataResponseTmdbMovieData { - tmdbId: number - title: string - originalTitle: string - overview: string - releaseDate: string - posterPath: string | null -} - -export interface GetMediaMetadataResponseTvdbMovieData { - tvdbId: number - name: string - database: 'TMDB' | 'TVDB' -} diff --git a/packages/types/ai-tools/isFolderExist.ts b/packages/types/ai-tools/isFolderExist.ts index 57b6ccf8..13c9aa1e 100644 --- a/packages/types/ai-tools/isFolderExist.ts +++ b/packages/types/ai-tools/isFolderExist.ts @@ -32,5 +32,4 @@ export const isFolderExistOutputSchema = z.object({ .describe('Reason for non-existence or non-directory'), }) -export type IsFolderExistInput = z.infer export type IsFolderExistOutput = z.infer diff --git a/packages/types/ai-tools/listFilesInMediaFolder.ts b/packages/types/ai-tools/listFilesInMediaFolder.ts index b36e683c..0195d085 100644 --- a/packages/types/ai-tools/listFilesInMediaFolder.ts +++ b/packages/types/ai-tools/listFilesInMediaFolder.ts @@ -30,7 +30,7 @@ export const listFilesInMediaFolderInputSchema = z.object({ .describe('Whether to return only video files (default: false)'), }) -export const listFilesInMediaFolderDataSchema = z.object({ +const listFilesInMediaFolderDataSchema = z.object({ files: z.array(z.string()).describe('Array of file paths'), count: z.number().describe('Number of files listed'), }) @@ -40,9 +40,6 @@ export const listFilesInMediaFolderOutputSchema = error: z.string().optional(), }) -export type ListFilesInMediaFolderInput = z.infer< - typeof listFilesInMediaFolderInputSchema -> export type ListFilesInMediaFolderResponseData = z.infer< typeof listFilesInMediaFolderDataSchema > diff --git a/packages/types/ai-tools/planTaskMessages.ts b/packages/types/ai-tools/planTaskMessages.ts index 6a19c101..7c191022 100644 --- a/packages/types/ai-tools/planTaskMessages.ts +++ b/packages/types/ai-tools/planTaskMessages.ts @@ -4,15 +4,6 @@ export const END_PLAN_TASK_SUCCESS_MESSAGE = "Task is created successfuly. User need to go to SMM, review and approve the task."; -/** - * Returned to the AI when `add-*-file` or `end-*-task` is called for a - * plan that the user has already cancelled (status === "rejected"). - * Tells the AI to stop the in-flight workflow instead of queueing - * more file entries or finalising the plan. - */ -export const PLAN_CANCELLED_BY_USER_MESSAGE = - "该任务已被用户取消, 请停止后续操作"; - /** * Returned to the AI when the rename plan was applied automatically * because the user granted the `metadata.write` permission. diff --git a/packages/types/ai-tools/renameEpisodeFile.ts b/packages/types/ai-tools/renameEpisodeFile.ts index 6eef3406..f85e4154 100644 --- a/packages/types/ai-tools/renameEpisodeFile.ts +++ b/packages/types/ai-tools/renameEpisodeFile.ts @@ -53,7 +53,6 @@ export const renameEpisodeFileOutputSchema = z.object({ error: z.string().optional().describe('Error or cancellation message when rename did not fully succeed'), }) -export type RenameEpisodeFileInput = z.infer export type RenameEpisodeFileOutput = z.infer export const RENAME_EPISODE_FILE_CANCELLED = 'User cancelled the operation' diff --git a/packages/types/ai-tools/renameFolder.ts b/packages/types/ai-tools/renameFolder.ts index 1294f1d4..b5f31e2e 100644 --- a/packages/types/ai-tools/renameFolder.ts +++ b/packages/types/ai-tools/renameFolder.ts @@ -29,7 +29,6 @@ export const renameFolderOutputSchema = z.object({ error: z.string().optional().describe('Error message if rename failed'), }) -export type RenameFolderInput = z.infer export type RenameFolderOutput = z.infer export const RENAME_FOLDER_CANCELLED = 'User cancelled the operation' diff --git a/packages/types/ai-tools/scrape.ts b/packages/types/ai-tools/scrape.ts index d2addec9..0bddeaef 100644 --- a/packages/types/ai-tools/scrape.ts +++ b/packages/types/ai-tools/scrape.ts @@ -38,5 +38,4 @@ export const scrapeOutputSchema = z.object({ .describe('Error message when the scrape job could not be started'), }) -export type ScrapeInput = z.infer export type ScrapeOutput = z.infer diff --git a/packages/types/event-types.ts b/packages/types/event-types.ts index 8a689b29..d067bf0d 100644 --- a/packages/types/event-types.ts +++ b/packages/types/event-types.ts @@ -1,4 +1,3 @@ -import type { UserConfig } from "./types"; export const AskForRenameFilesConfirmation = { event: 'askForRenameFilesConfirmation', @@ -28,21 +27,6 @@ export interface AskForRenameFilesConfirmationBeginRequestData { mediaFolderPath: string, } -export interface AskForRenameFilesConfirmationEndRequestData { - mediaFolderPath: string, -} - -export interface AskForRenameFilesConfirmationAddFileResponseData { - /** - * Absolute path in POSIX format - */ - from: string, - /** - * Absolute path in POSIX format - */ - to: string, -} - export const RecognizeMediaFilePlanReady = { event: 'recognizeMediaFilePlanReady', } as const; @@ -106,11 +90,6 @@ export interface FolderContentChangedEventData { export const USER_CONFIG_UPDATED_EVENT = 'userConfigUpdated' -export interface UserConfigUpdatedEventData { - property: keyof UserConfig, - old: any - new: any -} export const USER_CONFIG_FOLDER_RENAMED_EVENT = 'userConfig.folderRenamed' diff --git a/packages/types/planCommon.ts b/packages/types/planCommon.ts index 30b046fb..6c76cc61 100644 --- a/packages/types/planCommon.ts +++ b/packages/types/planCommon.ts @@ -21,14 +21,6 @@ export type PlanStatus = "preparing" | "pending" | "completed" | "rejected"; */ export type PlanCreator = "app" | "ai"; -/** - * Statuses for which a plan is still "active" (visible to the UI). - * `completed` plans have their file deleted; `rejected` plans are kept - * (with `status: "rejected"`) so a still-in-flight AI workflow can - * detect the cancellation — see `updatePlanContent` in core-routes. - */ -export const ACTIVE_PLAN_STATUSES: readonly PlanStatus[] = ["preparing", "pending"]; - export function isActivePlanStatus(status: PlanStatus): boolean { return status === "preparing" || status === "pending"; } diff --git a/packages/types/tmdbPrimaryTranslations.ts b/packages/types/tmdbPrimaryTranslations.ts index 1f172ec0..679795f6 100644 --- a/packages/types/tmdbPrimaryTranslations.ts +++ b/packages/types/tmdbPrimaryTranslations.ts @@ -89,4 +89,3 @@ export const TMDB_PRIMARY_TRANSLATIONS = [ 'zu-ZA', ] as const -export type TmdbPrimaryTranslation = (typeof TMDB_PRIMARY_TRANSLATIONS)[number] diff --git a/packages/utils/src/locale.ts b/packages/utils/src/locale.ts index 3b8faa55..6159d499 100644 --- a/packages/utils/src/locale.ts +++ b/packages/utils/src/locale.ts @@ -99,9 +99,6 @@ function suggestTmdbSearchLanguage( return undefined } -/** @deprecated Use {@link parseTmdbSearchLanguage} */ -export const matchTmdbPrimaryTranslation = parseTmdbSearchLanguage - /** * Maps an arbitrary locale tag to a supported app language code. * Returns null when the tag cannot be mapped. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b403bb44..fe07f7eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,9 @@ importers: '@modelcontextprotocol/inspector': specifier: ^2.3.0 version: 2.3.0(@modelcontextprotocol/sdk@1.27.0(zod@4.3.6))(@types/node@26.2.0)(@types/react@19.2.14)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(express@5.2.1)(jiti@2.7.0)(react-dom@19.2.4(react@19.2.4))(tsx@4.21.0) - concurrently: - specifier: ^9.2.1 - version: 9.2.1 + '@modelcontextprotocol/sdk': + specifier: ^1.27.0 + version: 1.27.0(zod@4.3.6) cross-env: specifier: ^7.0.3 version: 7.0.3 @@ -48,21 +48,12 @@ importers: apps/cli: dependencies: - '@ai-sdk/openai': - specifier: ^3.0.11 - version: 3.0.33(zod@4.3.6) '@ai-sdk/openai-compatible': specifier: ^2.0.11 version: 2.0.30(zod@4.3.6) - '@assistant-ui/react-ai-sdk': - specifier: ^1.3.7 - version: 1.3.8(@assistant-ui/react@0.12.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react@19.2.14)(assistant-cloud@0.1.18)(react@19.2.4) '@hono/node-server': specifier: ^1.19.0 version: 1.19.9(hono@4.12.2) - '@modelcontextprotocol/sdk': - specifier: ^1.25.3 - version: 1.27.0(zod@4.3.6) '@smm/core': specifier: workspace:* version: link:../core @@ -93,9 +84,6 @@ importers: dotenv: specifier: ^17.2.3 version: 17.3.1 - es-toolkit: - specifier: ^1.43.0 - version: 1.44.0 hono: specifier: ^4.10.8 version: 4.12.2 @@ -117,15 +105,9 @@ importers: pino: specifier: ^10.1.0 version: 10.3.1 - pino-roll: - specifier: ^1.3.0 - version: 1.3.0 rotating-file-stream: specifier: ^3.2.9 version: 3.2.9 - sanitize-filename: - specifier: ^1.6.3 - version: 1.6.3 shelljs: specifier: ^0.10.0 version: 0.10.0 @@ -138,9 +120,6 @@ importers: strip-ansi: specifier: ^7.2.0 version: 7.2.0 - xmlbuilder2: - specifier: ^4.0.3 - version: 4.0.3 zod: specifier: 4.3.6 version: 4.3.6 @@ -157,9 +136,6 @@ importers: proxy-chain: specifier: ^3.0.0 version: 3.0.0 - socket.io-client: - specifier: ^4.8.3 - version: 4.8.3 typescript: specifier: ^5 version: 5.9.3 @@ -198,20 +174,10 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) - apps/docker: - devDependencies: - cli: - specifier: workspace:* - version: link:../cli - ui: - specifier: workspace:* - version: link:../ui + apps/docker: {} apps/e2e: dependencies: - '@smm/core': - specifier: workspace:* - version: link:../core '@smm/test': specifier: workspace:* version: link:../../packages/test @@ -221,9 +187,6 @@ importers: '@smm/utils': specifier: workspace:* version: link:../../packages/utils - chai: - specifier: ^6.2.2 - version: 6.2.2 dotenv: specifier: ^17.3.1 version: 17.3.1 @@ -234,15 +197,9 @@ importers: specifier: ^3.0.0 version: 3.0.0 devDependencies: - '@testing-library/webdriverio': - specifier: ^3.2.1 - version: 3.2.1(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) '@types/bun': specifier: latest version: 1.3.9 - '@types/chai': - specifier: ^5.2.3 - version: 5.2.3 '@types/mocha': specifier: ^10.0.10 version: 10.0.10 @@ -258,18 +215,12 @@ importers: '@wdio/globals': specifier: ^9.23.0 version: 9.23.0(expect-webdriverio@5.6.4)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) - '@wdio/local-runner': - specifier: ^9.23.0 - version: 9.24.0(@wdio/globals@9.23.0)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) '@wdio/mocha-framework': specifier: ^9.23.0 version: 9.24.0 '@wdio/spec-reporter': specifier: ^9.20.0 version: 9.24.0 - cross-env: - specifier: ^7.0.3 - version: 7.0.3 expect-webdriverio: specifier: ^5.6.1 version: 5.6.4(@wdio/globals@9.23.0)(@wdio/logger@9.18.0)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) @@ -279,15 +230,9 @@ importers: typescript: specifier: ^5.0.0 version: 5.9.3 - wdio-electron-service: - specifier: 9.2.1 - version: 9.2.1(electron@39.6.1)(expect-webdriverio@5.6.4)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) wdio-html-nice-reporter: specifier: ^8.1.7 version: 8.1.7(chokidar@3.6.0)(encoding@0.1.13) - wdio-wait-for: - specifier: ^3.1.1 - version: 3.1.1 apps/electron: dependencies: @@ -300,9 +245,6 @@ importers: '@smm/electron-common': specifier: workspace:* version: link:../../packages/electron-common - electron-updater: - specifier: ^6.3.9 - version: 6.8.3 devDependencies: '@electron-toolkit/eslint-config-prettier': specifier: ^3.0.0 @@ -374,15 +316,9 @@ importers: apps/ui: dependencies: - '@ai-sdk/openai': - specifier: ^3.0.11 - version: 3.0.33(zod@4.3.6) '@ai-sdk/openai-compatible': specifier: ^2.0.11 version: 2.0.30(zod@4.3.6) - '@ai-sdk/react': - specifier: ^3.0.38 - version: 3.0.100(react@19.2.4)(zod@4.3.6) '@assistant-ui/react': specifier: ^0.12.10 version: 0.12.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) @@ -422,9 +358,6 @@ importers: '@radix-ui/react-hover-card': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-label': - specifier: ^2.1.8 - version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-menubar': specifier: ^1.1.16 version: 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -479,9 +412,6 @@ importers: '@tanstack/react-query': specifier: ^5.96.2 version: 5.96.2(react@19.2.4) - '@tanstack/react-table': - specifier: ^8.21.3 - version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/debug': specifier: ^4.1.12 version: 4.1.12 @@ -506,9 +436,6 @@ importers: es-toolkit: specifier: ^1.42.0 version: 1.44.0 - filenamify: - specifier: ^7.0.1 - version: 7.0.1 harden-react-markdown: specifier: ^1.1.7 version: 1.1.8(react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) @@ -527,12 +454,6 @@ importers: lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.4) - p-limit: - specifier: ^7.2.0 - version: 7.3.0 - path-browserify: - specifier: ^1.0.1 - version: 1.0.1 path-browserify-esm: specifier: ^1.0.6 version: 1.0.6 @@ -575,9 +496,6 @@ importers: shiki: specifier: ^3.20.0 version: 3.22.0 - slash: - specifier: ^5.1.0 - version: 5.1.0 socket.io-client: specifier: ^4.8.3 version: 4.8.3 @@ -590,9 +508,6 @@ importers: tailwindcss: specifier: ^4.1.17 version: 4.2.1 - url-join: - specifier: ^5.0.0 - version: 5.0.0 use-stick-to-bottom: specifier: ^1.1.1 version: 1.1.3(react@19.2.4) @@ -636,9 +551,6 @@ importers: '@vitejs/plugin-react': specifier: ^5.1.1 version: 5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) - '@vitest/coverage-istanbul': - specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18) '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.0.18(vitest@4.0.18) @@ -852,12 +764,6 @@ packages: peerDependencies: zod: 4.3.6 - '@ai-sdk/openai@3.0.33': - resolution: {integrity: sha512-O/8SVKAiwFHkGAUfBnrLb7L2IjbpP9ySWbmOktOfa0KtzutZkmKNrJ5CtB5dj+lwuENbOuZeRsnsZdOjar7hig==} - engines: {node: '>=18'} - peerDependencies: - zod: 4.3.6 - '@ai-sdk/provider-utils@4.0.15': resolution: {integrity: sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==} engines: {node: '>=18'} @@ -1199,11 +1105,6 @@ packages: resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} hasBin: true - '@electron/fuses@2.1.3': - resolution: {integrity: sha512-LoKJUXNiJ4JM8IIrUltSHI+8pkogaGj5wmJx81jE/Wk3g2w1/kfMbTEKNoY5kitGE8hiC12h32R/1SlywFtxXg==} - engines: {node: '>=22.12.0'} - hasBin: true - '@electron/get@2.0.3': resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} engines: {node: '>=12'} @@ -1221,11 +1122,6 @@ packages: engines: {node: '>=12.0.0'} hasBin: true - '@electron/packager@18.4.4': - resolution: {integrity: sha512-fTUCmgL25WXTcFpM1M72VmFP8w3E4d+KNzWxmTDRpvwkfn/S206MAtM2cy0GF78KS9AwASMOUmlOIzCHeNxcGQ==} - engines: {node: '>= 16.13.0'} - hasBin: true - '@electron/rebuild@4.0.3': resolution: {integrity: sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==} engines: {node: '>=22.12.0'} @@ -2102,10 +1998,6 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} - '@istanbuljs/schema@0.1.3': - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} - engines: {node: '>=8'} - '@jest/diff-sequences@30.0.1': resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2334,22 +2226,6 @@ packages: resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==} engines: {node: ^18.17.0 || >=20.5.0} - '@oozcitak/dom@2.0.2': - resolution: {integrity: sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==} - engines: {node: '>=20.0'} - - '@oozcitak/infra@2.0.2': - resolution: {integrity: sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==} - engines: {node: '>=20.0'} - - '@oozcitak/url@3.0.0': - resolution: {integrity: sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==} - engines: {node: '>=20.0'} - - '@oozcitak/util@10.0.0': - resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==} - engines: {node: '>=20.0'} - '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -2607,11 +2483,6 @@ packages: engines: {node: '>=18'} hasBin: true - '@puppeteer/browsers@2.3.0': - resolution: {integrity: sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA==} - engines: {node: '>=18'} - hasBin: true - '@puppeteer/browsers@3.0.6': resolution: {integrity: sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA==} engines: {node: '>=22.12.0'} @@ -2919,19 +2790,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-label@2.1.8': - resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-menu@2.1.16': resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} peerDependencies: @@ -3869,17 +3727,6 @@ packages: peerDependencies: react: ^18 || ^19 - '@tanstack/react-table@8.21.3': - resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} - engines: {node: '>=12'} - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - - '@tanstack/table-core@8.21.3': - resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} - engines: {node: '>=12'} - '@testing-library/dom@8.20.1': resolution: {integrity: sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==} engines: {node: '>=12'} @@ -3909,11 +3756,6 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@testing-library/webdriverio@3.2.1': - resolution: {integrity: sha512-mgMyCiwW+4zCidmlab9lwcO+UBz+PzlWnz9idDQ4ZS1SIHVSfJwvRLMWi+s3vNGFmc8duQxTiUHf1alW/Z48Og==} - peerDependencies: - webdriverio: '*' - '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} @@ -4172,11 +4014,6 @@ packages: oxc-transform-react: optional: true - '@vitest/coverage-istanbul@4.0.18': - resolution: {integrity: sha512-0OhjP30owEDihYTZGWuq20rNtV1RjjJs1Mv4MaZIKcFBmiLUXX7HJLX4fU7wE+Mrc3lQxI2HKq6WrSXi5FGuCQ==} - peerDependencies: - vitest: 4.0.18 - '@vitest/coverage-v8@4.0.18': resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} peerDependencies: @@ -4238,9 +4075,6 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} - '@wdio/cdp-bridge@9.2.1': - resolution: {integrity: sha512-jYJzBmGZb6waT04Uq9mBEJ0vLErFUy0hu0Kanzje5yzHPNAyM98XyWmYM1boWlrTTZYNwYyQenOnw7HSURADjQ==} - '@wdio/cli@9.24.0': resolution: {integrity: sha512-dFs1HNmyXne0pDOYPOHhFcck0BC22z0lMdu6RtTX1C4gHdEYsjTtTH2zsZ5N5BzzsZVSUol2PisuqyLQO5dZIA==} engines: {node: '>=18.20.0'} @@ -4250,17 +4084,6 @@ packages: resolution: {integrity: sha512-rcHu0eG16rSEmHL0sEKDcr/vYFmGhQ5GOlmlx54r+1sgh6sf136q+kth4169s16XqviWGW3LjZbUfpTK29pGtw==} engines: {node: '>=18.20.0'} - '@wdio/dot-reporter@9.24.0': - resolution: {integrity: sha512-hZNXnY4EVnDRTedGSlkWQL6sPkxe1zgRzTaam+S7GduF+IOTQXHIsuklfpHLs6mrj3oS+JuKbosodsyvLU7KQA==} - engines: {node: '>=18.20.0'} - - '@wdio/electron-types@9.2.1': - resolution: {integrity: sha512-kmOw1tuqpRSI1E1GXfVYKkGWi5iMI8SwkkzZ2sWUS3Qd0YCWhER30VyZ0B9Dy7WOurW5A7naIjqNN1+/cHafbw==} - - '@wdio/electron-utils@9.2.1': - resolution: {integrity: sha512-5plkhVpZKiZ1hLeFhfni733PL3zFygM3LYhjhOyS/lvtoMhsL5+Qce9iz6BIVh6wDjyfvvjilZZnKNEndlK98g==} - engines: {node: '>=18 || >=20'} - '@wdio/globals@9.23.0': resolution: {integrity: sha512-OmwPKV8c5ecLqo+EkytN7oUeYfNmRI4uOXGIR1ybP7AK5Zz+l9R0dGfoadEuwi1aZXAL0vwuhtq3p0OL3dfqHQ==} engines: {node: '>=18.20.0'} @@ -4268,10 +4091,6 @@ packages: expect-webdriverio: ^5.3.4 webdriverio: ^9.0.0 - '@wdio/local-runner@9.24.0': - resolution: {integrity: sha512-SUs5HEGHXEl/fVdkkhY1+of+Dv8AlceulRTpmYMmSR4Nu+tHUXSPkzjoYgVa+xVu0MpVrD+dhTm13hOtlDAlMg==} - engines: {node: '>=18.20.0'} - '@wdio/logger@9.18.0': resolution: {integrity: sha512-HdzDrRs+ywAqbXGKqe1i/bLtCv47plz4TvsHFH3j729OooT5VH38ctFn5aLXgECmiAKDkmH/A6kOq2Zh5DIxww==} engines: {node: '>=18.20.0'} @@ -4291,13 +4110,6 @@ packages: resolution: {integrity: sha512-0VrEX2uzjrFCHb6fNQDrQe6X7xuQbXUJhy5CGhMZghnPegW0OnKguwUy/vVKJE0HEDMOrR8djteefxJVfOOZpw==} engines: {node: '>=18.20.0'} - '@wdio/runner@9.24.0': - resolution: {integrity: sha512-B1ezRtvR/eJCKQSVyLbERpyyMG3bP7RKaQBHT7bNxGsuEuarkUU7cS/T/aB+woZb4UtWotlrBFY4icAS0HqOkA==} - engines: {node: '>=18.20.0'} - peerDependencies: - expect-webdriverio: ^5.3.4 - webdriverio: ^9.0.0 - '@wdio/spec-reporter@9.24.0': resolution: {integrity: sha512-I3HExQKvF5u+RUcwImk9JMiuBgo2MmuKDj3Y0oSRwSw0TxQX0nqVvt8udlVG6XJpOD+e4EqlCGWbFfMezi8iTA==} engines: {node: '>=18.20.0'} @@ -4310,10 +4122,6 @@ packages: resolution: {integrity: sha512-6WhtzC5SNCGRBTkaObX6A07Ofnnyyf+TQH/d/fuhZRqvBknrP4AMMZF+PFxGl1fwdySWdBn+gV2QLE+52Byowg==} engines: {node: '>=18.20.0'} - '@wdio/xvfb@9.24.0': - resolution: {integrity: sha512-eK0rUZeR+wlFapm2cb7OQ1LwC/nHmdo7eX02B2Dme7uQGF3+/ILkDTm5B0RYpGUbM3ktw3d0fhcwqqeNNH9KIA==} - engines: {node: '>=18'} - '@webcontainer/env@1.1.1': resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} @@ -4518,10 +4326,6 @@ packages: atomically@2.1.1: resolution: {integrity: sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==} - author-regex@1.0.0: - resolution: {integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==} - engines: {node: '>=0.8'} - auto-bind@5.0.1: resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4623,9 +4427,6 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - bluebird@3.7.2: - resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} - body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -4792,11 +4593,6 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} - chromium-bidi@0.6.3: - resolution: {integrity: sha512-qXlsCmpCZJAnoTYI83Iu6EdYQpMYdVkCfq08KDh2pmlVqK5t5IA9mGs4/LwCwp4fqisSOMXZxP3HIh8w8aRn0A==} - peerDependencies: - devtools-protocol: '*' - chromium-bidi@16.0.1: resolution: {integrity: sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0'} @@ -4929,9 +4725,6 @@ packages: resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} engines: {node: '>=0.10.0'} - compare-versions@6.1.1: - resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} - compress-commons@6.0.2: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} @@ -4939,11 +4732,6 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concurrently@9.2.1: - resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==} - engines: {node: '>=18'} - hasBin: true - content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -4952,10 +4740,6 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} - convert-hrtime@5.0.0: - resolution: {integrity: sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==} - engines: {node: '>=12'} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -5083,14 +4867,6 @@ packages: dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -5197,9 +4973,6 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - devtools-protocol@0.0.1312386: - resolution: {integrity: sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==} - devtools-protocol@0.0.1638949: resolution: {integrity: sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA==} @@ -5303,9 +5076,6 @@ packages: electron-to-chromium@1.5.302: resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} - electron-updater@6.8.3: - resolution: {integrity: sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==} - electron-vite@5.0.0: resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5468,10 +5238,6 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} - engines: {node: '>=0.8.0'} - escape-string-regexp@2.0.0: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} @@ -5611,10 +5377,6 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} - exit-hook@4.0.0: - resolution: {integrity: sha512-Fqs7ChZm72y40wKjOFXBKg7nJZvQJmewP5/7LtePDdnah/+FH9Hp5sgMujSCMPXlxOAW2//1jrW9pnsY7o20vQ==} - engines: {node: '>=18'} - expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -5663,9 +5425,6 @@ packages: resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} engines: {node: '>=18'} - fast-copy@3.0.2: - resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} - fast-copy@4.0.2: resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} @@ -5743,18 +5502,10 @@ packages: resolution: {integrity: sha512-ct/ckWBV/9Dg3MlvCXsLcSUyoWwv9mCKqlhLNB2DAuXR/NZolSXlQqP5dyy6guWlPXBhodZyZ5lGPQcbQDxrEQ==} engines: {node: 20 || >=22} - filename-reserved-regex@2.0.0: - resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==} - engines: {node: '>=4'} - filename-reserved-regex@4.0.0: resolution: {integrity: sha512-9ZT504KxEQDamsOogZImAWGEN24R1uFAxU3ZS4AZqn2ooidmN68Olh7n4/RcA4lLatZztjA0ZSuxeLHVoCc8JA==} engines: {node: '>=20'} - filenamify@4.3.0: - resolution: {integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==} - engines: {node: '>=8'} - filenamify@7.0.1: resolution: {integrity: sha512-9b4rfnaX2MkJCgp27wypV6DAMvj4WMOSgJ+TdcpJIO84Dql+Cv6iJjdG4XDTLubOWkfNiBv3joO59sau/TXw+Q==} engines: {node: '>=20'} @@ -5767,14 +5518,6 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} - find-up-simple@1.0.1: - resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} - engines: {node: '>=18'} - - find-up@2.1.0: - resolution: {integrity: sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==} - engines: {node: '>=4'} - find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -5787,10 +5530,6 @@ packages: resolution: {integrity: sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - find-versions@6.0.0: - resolution: {integrity: sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==} - engines: {node: '>=18'} - flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -5802,10 +5541,6 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - flora-colossus@2.0.0: - resolution: {integrity: sha512-dz4HxH6pOvbUzZpZ/yXhafjbR2I8cenK5xL0KtBFb7U2ADsR+OwXifnxZjij/pZWF775uSCMzWVd+jDik2H2IA==} - engines: {node: '>= 12'} - for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -5873,17 +5608,9 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - function-timeout@1.0.2: - resolution: {integrity: sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==} - engines: {node: '>=18'} - functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - galactus@1.0.0: - resolution: {integrity: sha512-R1fam6D4CyKQGNlvJne4dkNF+PvUUl7TAJInvTGa9fti9qAv95quQz29GXapA4d8Ec266mJJxFVh82M4GIIGDQ==} - engines: {node: '>= 12'} - geckodriver@6.1.0: resolution: {integrity: sha512-ZRXLa4ZaYTTgUO4Eefw+RsQCleugU2QLb1ME7qTYxxuRj51yAhfnXaItXNs5/vUzfIaDHuZ+YnSF005hfp07nQ==} engines: {node: '>=20.0.0'} @@ -5909,10 +5636,6 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} - get-package-info@1.0.0: - resolution: {integrity: sha512-SCbprXGAPdIhKAXiG+Mk6yeoFH61JlYunqdFQFHDtLjJlDjFf6x07dsS8acO+xWt52jpdVo49AlVDnUVK1sDNw==} - engines: {node: '>= 4.0'} - get-port@7.1.0: resolution: {integrity: sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==} engines: {node: '>=16'} @@ -5933,9 +5656,6 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} - get-tsconfig@4.13.6: - resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} - get-tsconfig@4.14.3: resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} @@ -6028,10 +5748,6 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - hasown@2.0.3: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} @@ -6102,9 +5818,6 @@ packages: resolution: {integrity: sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==} engines: {node: '>=16.9.0'} - hosted-git-info@2.8.9: - resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} - hosted-git-info@4.1.0: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} @@ -6239,10 +5952,6 @@ packages: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} - index-to-position@1.2.0: - resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} - engines: {node: '>=18'} - inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -6503,10 +6212,6 @@ packages: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} - istanbul-lib-instrument@6.0.3: - resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} - engines: {node: '>=10'} - istanbul-lib-report@3.0.1: resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} @@ -6643,10 +6348,6 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} - junk@3.1.0: - resolution: {integrity: sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==} - engines: {node: '>=8'} - katex@0.16.33: resolution: {integrity: sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA==} hasBin: true @@ -6825,17 +6526,9 @@ packages: resolution: {integrity: sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - load-json-file@2.0.0: - resolution: {integrity: sha512-3p6ZOGNbiX4CdvEd1VcE6yi78UrGNpjHO33noGwHCnT/o2fyllJDepsm8+mFFv/DvtwFHht5HIHSyOy5a+ChVQ==} - engines: {node: '>=4'} - locate-app@2.5.0: resolution: {integrity: sha512-xIqbzPMBYArJRmPGUZD9CzV9wOqmVtQnaAn3wrj3s6WYW0bQvPI7x+sPYUGmDTYMHefVK//zc6HEYZ1qnxIK+Q==} - locate-path@2.0.0: - resolution: {integrity: sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==} - engines: {node: '>=4'} - locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -6851,29 +6544,9 @@ packages: lodash.clonedeep@4.5.0: resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} - lodash.difference@4.5.0: - resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} - - lodash.escaperegexp@4.1.2: - resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} - - lodash.flatmap@4.5.0: - resolution: {integrity: sha512-/OcpcAGWlrZyoHGeHh3cAoa6nGdX6QYtmzNP84Jqol6UEQQ2gIaU3H+0eICcjcKGl0/XF8LWOujNn9lffsnaOg==} - lodash.flattendeep@4.4.0: resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==} - lodash.get@4.4.2: - resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} - deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. - - lodash.isequal@4.5.0: - resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} - deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. - - lodash.isfunction@3.0.9: - resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} - lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -6883,12 +6556,6 @@ packages: lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} - lodash.take@4.1.1: - resolution: {integrity: sha512-3T118EQjnhr9c0aBKCCMhQn0OBwRMz/O2WaRU6VH0TSKoMCmFtUpr0iUp+eWKODEiRXtYOK7R7SiBneKHdk7og==} - - lodash.takeright@4.1.1: - resolution: {integrity: sha512-/I41i2h8VkHtv3PYD8z1P4dkLIco5Z3z35hT/FJl18AxwSdifcATaaiBOxuQOT3T/F1qfRTct3VWMFSj1xCtAw==} - lodash.union@4.6.0: resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} @@ -6955,10 +6622,6 @@ packages: magicast@0.5.2: resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} - make-asynchronous@1.1.0: - resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} - engines: {node: '>=18'} - make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -7259,9 +6922,6 @@ packages: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -7346,9 +7006,6 @@ packages: engines: {node: ^18.17.0 || >=20.5.0} hasBin: true - normalize-package-data@2.5.0: - resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} - normalize-package-data@6.0.2: resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} engines: {node: ^16.14.0 || >=18.0.0} @@ -7472,10 +7129,6 @@ packages: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} - p-event@6.0.1: - resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==} - engines: {node: '>=16.17'} - p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} @@ -7484,10 +7137,6 @@ packages: resolution: {integrity: sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==} engines: {node: '>=8'} - p-limit@1.3.0: - resolution: {integrity: sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==} - engines: {node: '>=4'} - p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -7500,14 +7149,6 @@ packages: resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - p-limit@7.3.0: - resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} - engines: {node: '>=20'} - - p-locate@2.0.0: - resolution: {integrity: sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==} - engines: {node: '>=4'} - p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} @@ -7528,14 +7169,6 @@ packages: resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} engines: {node: '>=18'} - p-timeout@6.1.4: - resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} - engines: {node: '>=14.16'} - - p-try@1.0.0: - resolution: {integrity: sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==} - engines: {node: '>=4'} - p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -7564,25 +7197,13 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} - parse-author@2.0.0: - resolution: {integrity: sha512-yx5DfvkN8JsHL2xk2Os9oTia467qnvRgey4ahSm2X8epehBLx/gWLcy5KI+Y36ful5DzGbCS6RazqZGgy1gHNw==} - engines: {node: '>=0.10.0'} - parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} - parse-json@2.2.0: - resolution: {integrity: sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==} - engines: {node: '>=0.10.0'} - parse-json@7.1.1: resolution: {integrity: sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw==} engines: {node: '>=16'} - parse-json@8.3.0: - resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} - engines: {node: '>=18'} - parse-ms@4.0.0: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} @@ -7607,13 +7228,6 @@ packages: path-browserify-esm@1.0.6: resolution: {integrity: sha512-9nUwYvvu/yq1PYrUyYCihNWmpzacaRYF6gGbjLWErrZ4MRDWyfPN7RpE8E7tsw8eqBU/rr7mcoTXbS+Vih8uUA==} - path-browserify@1.0.1: - resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} - - path-exists@3.0.0: - resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} - engines: {node: '>=4'} - path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -7648,10 +7262,6 @@ packages: path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} - path-type@2.0.0: - resolution: {integrity: sha512-dUnb5dXUf+kzhC/W/F4e5/SkluXIFf5VUHolW1Eg1irn1hGWjPGdsRcvYJ1nD6lhk8Ir7VM0bHJKsYTx8Jx9OQ==} - engines: {node: '>=4'} - path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -7670,10 +7280,6 @@ packages: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} engines: {node: '>=12', npm: '>=6'} - pe-library@1.0.1: - resolution: {integrity: sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==} - engines: {node: '>=14', npm: '>=7'} - pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -7701,10 +7307,6 @@ packages: engines: {node: '>=0.10'} hasBin: true - pify@2.3.0: - resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} - engines: {node: '>=0.10.0'} - pify@4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} @@ -7719,9 +7321,6 @@ packages: resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} hasBin: true - pino-roll@1.3.0: - resolution: {integrity: sha512-bEjnbuSNjHY44LJH9MNqnrLnLWwWlDrK5AE9WMDR1bhQYiikzPgIla1TQ75+J0cx6Im2CYe5kMKRJzbRGVQjVQ==} - pino-std-serializers@7.1.0: resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} @@ -7842,10 +7441,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - puppeteer-core@22.15.0: - resolution: {integrity: sha512-cHArnywCiAAVXa3t4GGL2vttNxh7GqXtIYGym99egkNJ3oG//wL9LkvO4WE8W1TJe95t1F1ocu9X4xWaGsOKOA==} - engines: {node: '>=18'} - puppeteer-core@25.3.0: resolution: {integrity: sha512-fm+wpUr2oigH1PXZvwgATrM2tYWHMDG8ASzTEe9uukCye4X5Ldx1K5BTHPFKITrIWvQQAQ256d1NpbEveBcKjA==} engines: {node: '>=22.12.0'} @@ -8018,30 +7613,14 @@ packages: resolution: {integrity: sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==} engines: {node: ^18.17.0 || >=20.5.0} - read-package-up@11.0.0: - resolution: {integrity: sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==} - engines: {node: '>=18'} - read-pkg-up@10.1.0: resolution: {integrity: sha512-aNtBq4jR8NawpKJQldrQcSW9y/d+KWH4v24HWkHljOZ7H0av+YTGANBzRh9A5pw7v/bLVsLVPpOhJ7gHNVy8lA==} engines: {node: '>=16'} - read-pkg-up@2.0.0: - resolution: {integrity: sha512-1orxQfbWGUiTn9XsPlChs6rLie/AV9jwZTGmu2NZw/CUDJQchXJFYE0Fq5j7+n558T1JhDWLdhyd1Zj+wLY//w==} - engines: {node: '>=4'} - - read-pkg@2.0.0: - resolution: {integrity: sha512-eFIBOPW7FGjzBuk3hdXEuNSiTZS/xEMlH49HxMyzb0hyPfu4EhVjT2DH32K1hSSmVq4sebAWnZuuY5auISUTGA==} - engines: {node: '>=4'} - read-pkg@8.1.0: resolution: {integrity: sha512-PORM8AgzXeskHO/WEv312k9U03B8K9JSiWF/8N9sUuFjBa+9SF2u6K7VClzXwDXab51jCd8Nd36CNM+zR97ScQ==} engines: {node: '>=16'} - read-pkg@9.0.1: - resolution: {integrity: sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==} - engines: {node: '>=18'} - read-yaml-file@1.1.0: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} @@ -8139,10 +7718,6 @@ packages: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} - resedit@2.0.3: - resolution: {integrity: sha512-oTeemxwoMuxxTYxXUwjkrOPfngTQehlv0/HoYFNkB4uzsP1Un1A9nI8JQKGOFkxpqkC7qkMs0lUsGrvUlbLNUA==} - engines: {node: '>=14', npm: '>=7'} - reselect@5.2.0: resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} @@ -8295,10 +7870,6 @@ packages: semver-compare@1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} - semver-regex@4.0.5: - resolution: {integrity: sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==} - engines: {node: '>=12'} - semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -8394,9 +7965,6 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - simmerjs@0.5.6: - resolution: {integrity: sha512-Z00zGHUp2IVSDUuni6gzBxVVQwAEZ7jVHnqL97+2RaHVWTYKfgCNyCvgm68Uc1M6X84hjatxvtOc24Y9ECLPWQ==} - simple-update-notifier@2.0.0: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} @@ -8425,10 +7993,6 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - smol-toml@1.7.0: - resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} - engines: {node: '>= 18'} - smol-toml@1.8.0: resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} engines: {node: '>= 18'} @@ -8456,9 +8020,6 @@ packages: resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - sonic-boom@3.8.1: - resolution: {integrity: sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==} - sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -8564,10 +8125,6 @@ packages: vite-plus: optional: true - stream-buffers@3.0.3: - resolution: {integrity: sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==} - engines: {node: '>= 0.10.0'} - streamx@2.23.0: resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} @@ -8639,10 +8196,6 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} - strip-outer@1.0.1: - resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} - engines: {node: '>=0.10.0'} - strnum@2.1.2: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} @@ -8665,10 +8218,6 @@ packages: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} - super-regex@1.1.0: - resolution: {integrity: sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==} - engines: {node: '>=18'} - supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -8762,19 +8311,12 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} - time-span@5.1.0: - resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} - engines: {node: '>=12'} - tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - tiny-typed-emitter@2.1.0: - resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} - tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -8846,17 +8388,9 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} - trim-repeated@1.0.0: - resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==} - engines: {node: '>=0.10.0'} - trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} @@ -8941,9 +8475,6 @@ packages: resolution: {integrity: sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ==} engines: {node: '>=14'} - unbzip2-stream@1.4.3: - resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} - undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -8968,10 +8499,6 @@ packages: resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} engines: {node: '>=22.19.0'} - unicorn-magic@0.1.0: - resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} - engines: {node: '>=18'} - unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -9037,13 +8564,6 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - url-join@5.0.0: - resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - urlpattern-polyfill@10.0.0: - resolution: {integrity: sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==} - urlpattern-polyfill@10.1.0: resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} @@ -9276,30 +8796,12 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - wdio-electron-service@9.2.1: - resolution: {integrity: sha512-cvNe4eVeYlpxUkC9aTS6iOME0YVDEJJ/TgSjeV9GdIJ0c+pvT6xoUTdgWvsjpaRkXAGJRz2LyLEOp2CoW2lcmw==} - engines: {node: '>=18 || >=20'} - deprecated: 'DEPRECATED: This package has been moved to the official WebdriverIO organization. Please migrate to @wdio/electron-service (https://github.com/webdriverio/desktop-mobile/tree/main/packages/electron-service) which is the actively maintained successor. See the migration guide: https://github.com/webdriverio/desktop-mobile/blob/main/packages/electron-service/docs/migration/v9-to-v10.md' - peerDependencies: - electron: '*' - webdriverio: '>9.0.0' - peerDependenciesMeta: - electron: - optional: true - wdio-html-nice-reporter@8.1.7: resolution: {integrity: sha512-uVaMuhatS7L3U5MJzCQEP/m3IiUBJgQMOBYtKRjEy9wi55I6UQJ6AhKu6tB3IDtIiMvdDgh3chrVgWAy0rmCMA==} - wdio-wait-for@3.1.1: - resolution: {integrity: sha512-Ogw8907/JbjFe+aIfKYdlildDxCpU4emhwhqo4hCVZ9pjcJj6/fVb0lxmOjAMfRU30vXdB6o7ZY1rb65PFJzBw==} - engines: {node: '>=18 || >=20 || >=22'} - web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} - web-worker@1.5.0: - resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} - webdriver-bidi-protocol@0.4.2: resolution: {integrity: sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==} @@ -9463,10 +8965,6 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} - xmlbuilder2@4.0.3: - resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} - engines: {node: '>=20.0'} - xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} @@ -9616,12 +9114,6 @@ snapshots: '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) zod: 4.3.6 - '@ai-sdk/openai@3.0.33(zod@4.3.6)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - zod: 4.3.6 - '@ai-sdk/provider-utils@4.0.15(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.8 @@ -10097,8 +9589,6 @@ snapshots: fs-extra: 9.1.0 minimist: 1.2.8 - '@electron/fuses@2.1.3': {} - '@electron/get@2.0.3': dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -10146,32 +9636,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/packager@18.4.4': - dependencies: - '@electron/asar': 3.4.1 - '@electron/get': 3.1.0 - '@electron/notarize': 2.5.0 - '@electron/osx-sign': 1.3.3 - '@electron/universal': 2.0.3 - '@electron/windows-sign': 1.2.2 - '@malept/cross-spawn-promise': 2.0.0 - debug: 4.4.3(supports-color@8.1.1) - extract-zip: 2.0.1 - filenamify: 4.3.0 - fs-extra: 11.3.3 - galactus: 1.0.0 - get-package-info: 1.0.0 - junk: 3.1.0 - parse-author: 2.0.0 - plist: 3.1.0 - prettier: 3.8.1 - resedit: 2.0.3 - resolve: 1.22.12 - semver: 7.7.4 - yargs-parser: 21.1.1 - transitivePeerDependencies: - - supports-color - '@electron/rebuild@4.0.3': dependencies: '@malept/cross-spawn-promise': 2.0.0 @@ -10211,6 +9675,7 @@ snapshots: postject: 1.0.0-alpha.6 transitivePeerDependencies: - supports-color + optional: true '@emnapi/core@1.11.2': dependencies: @@ -10607,6 +10072,10 @@ snapshots: dependencies: hono: 4.12.2 + '@hono/node-server@1.19.9(hono@4.13.3)': + dependencies: + hono: 4.13.3 + '@hono/node-server@2.1.1(hono@4.13.3)': dependencies: hono: 4.13.3 @@ -10771,8 +10240,6 @@ snapshots: dependencies: minipass: 7.1.3 - '@istanbuljs/schema@0.1.3': {} - '@jest/diff-sequences@30.0.1': {} '@jest/expect-utils@30.2.0': @@ -10933,7 +10400,7 @@ snapshots: '@modelcontextprotocol/sdk@1.27.0(zod@4.3.6)': dependencies: - '@hono/node-server': 1.19.9(hono@4.12.2) + '@hono/node-server': 1.19.9(hono@4.13.3) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -10943,7 +10410,7 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.2.1(express@5.2.1) - hono: 4.12.2 + hono: 4.13.3 jose: 6.1.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -11054,23 +10521,6 @@ snapshots: dependencies: semver: 7.7.4 - '@oozcitak/dom@2.0.2': - dependencies: - '@oozcitak/infra': 2.0.2 - '@oozcitak/url': 3.0.0 - '@oozcitak/util': 10.0.0 - - '@oozcitak/infra@2.0.2': - dependencies: - '@oozcitak/util': 10.0.0 - - '@oozcitak/url@3.0.0': - dependencies: - '@oozcitak/infra': 2.0.2 - '@oozcitak/util': 10.0.0 - - '@oozcitak/util@10.0.0': {} - '@opentelemetry/api@1.9.0': {} '@oxc-parser/binding-android-arm-eabi@0.147.0': @@ -11223,22 +10673,6 @@ snapshots: - react-native-b4a - supports-color - '@puppeteer/browsers@2.3.0': - dependencies: - debug: 4.4.3(supports-color@8.1.1) - extract-zip: 2.0.1 - progress: 2.0.3 - proxy-agent: 6.5.0 - semver: 7.7.4 - tar-fs: 3.1.1 - unbzip2-stream: 1.4.3 - yargs: 17.7.2 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - - supports-color - '@puppeteer/browsers@3.0.6(yauzl@2.10.0)': dependencies: modern-tar: 0.7.6 @@ -11531,15 +10965,6 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -12412,18 +11837,10 @@ snapshots: '@tanstack/query-core': 5.96.2 react: 19.2.4 - '@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@tanstack/table-core': 8.21.3 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - '@tanstack/table-core@8.21.3': {} - '@testing-library/dom@8.20.1': dependencies: '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 '@types/aria-query': 5.0.4 aria-query: 5.1.3 chalk: 4.1.2 @@ -12454,13 +11871,6 @@ snapshots: dependencies: '@testing-library/dom': 8.20.1 - '@testing-library/webdriverio@3.2.1(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0)))': - dependencies: - '@babel/runtime': 7.28.6 - '@testing-library/dom': 8.20.1 - simmerjs: 0.5.6 - webdriverio: 9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0)) - '@tootallnate/quickjs-emscripten@0.23.0': {} '@tybys/wasm-util@0.10.3': @@ -12767,22 +12177,6 @@ snapshots: optionalDependencies: babel-plugin-react-compiler: 1.0.0 - '@vitest/coverage-istanbul@4.0.18(vitest@4.0.18)': - dependencies: - '@istanbuljs/schema': 0.1.3 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-instrument: 6.0.3 - istanbul-lib-report: 3.0.1 - istanbul-reports: 3.2.0 - magicast: 0.5.2 - obug: 2.1.1 - tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) - transitivePeerDependencies: - - supports-color - '@vitest/coverage-v8@4.0.18(vitest@4.0.18)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -12895,16 +12289,6 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 - '@wdio/cdp-bridge@9.2.1': - dependencies: - '@wdio/electron-utils': 9.2.1 - wait-port: 1.1.0 - ws: 8.21.1 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - '@wdio/cli@9.24.0(@types/node@25.3.0)(expect-webdriverio@5.6.4)(puppeteer-core@25.3.0(yauzl@2.10.0))': dependencies: '@vitest/snapshot': 2.1.9 @@ -12953,58 +12337,11 @@ snapshots: - react-native-b4a - supports-color - '@wdio/dot-reporter@9.24.0': - dependencies: - '@wdio/reporter': 9.24.0 - '@wdio/types': 9.24.0 - chalk: 5.6.2 - - '@wdio/electron-types@9.2.1': - dependencies: - '@vitest/spy': 3.2.4 - - '@wdio/electron-utils@9.2.1': - dependencies: - '@electron/packager': 18.4.4 - '@wdio/logger': 9.18.0 - debug: 4.4.3(supports-color@8.1.1) - esbuild: 0.25.12 - find-versions: 6.0.0 - json5: 2.2.3 - read-package-up: 11.0.0 - smol-toml: 1.7.0 - tsx: 4.21.0 - yaml: 2.9.0 - transitivePeerDependencies: - - supports-color - '@wdio/globals@9.23.0(expect-webdriverio@5.6.4)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0)))': dependencies: expect-webdriverio: 5.6.4(@wdio/globals@9.23.0)(@wdio/logger@9.18.0)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) webdriverio: 9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0)) - '@wdio/local-runner@9.24.0(@wdio/globals@9.23.0)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0)))': - dependencies: - '@types/node': 20.19.33 - '@wdio/logger': 9.18.0 - '@wdio/repl': 9.16.2 - '@wdio/runner': 9.24.0(expect-webdriverio@5.6.4)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) - '@wdio/types': 9.24.0 - '@wdio/xvfb': 9.24.0 - exit-hook: 4.0.0 - expect-webdriverio: 5.6.4(@wdio/globals@9.23.0)(@wdio/logger@9.18.0)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) - split2: 4.2.0 - stream-buffers: 3.0.3 - transitivePeerDependencies: - - '@wdio/globals' - - bare-abort-controller - - bare-buffer - - bufferutil - - react-native-b4a - - supports-color - - utf-8-validate - - webdriverio - '@wdio/logger@9.18.0': dependencies: chalk: 5.6.2 @@ -13041,27 +12378,6 @@ snapshots: diff: 8.0.3 object-inspect: 1.13.4 - '@wdio/runner@9.24.0(expect-webdriverio@5.6.4)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0)))': - dependencies: - '@types/node': 20.19.33 - '@wdio/config': 9.24.0 - '@wdio/dot-reporter': 9.24.0 - '@wdio/globals': 9.23.0(expect-webdriverio@5.6.4)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) - '@wdio/logger': 9.18.0 - '@wdio/types': 9.24.0 - '@wdio/utils': 9.24.0 - deepmerge-ts: 7.1.5 - expect-webdriverio: 5.6.4(@wdio/globals@9.23.0)(@wdio/logger@9.18.0)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) - webdriver: 9.24.0 - webdriverio: 9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0)) - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - bufferutil - - react-native-b4a - - supports-color - - utf-8-validate - '@wdio/spec-reporter@9.24.0': dependencies: '@wdio/reporter': 9.24.0 @@ -13096,10 +12412,6 @@ snapshots: - react-native-b4a - supports-color - '@wdio/xvfb@9.24.0': - dependencies: - '@wdio/logger': 9.18.0 - '@webcontainer/env@1.1.1': {} '@xmldom/xmldom@0.8.11': {} @@ -13331,8 +12643,6 @@ snapshots: stubborn-fs: 2.0.0 when-exit: 2.1.5 - author-regex@1.0.0: {} - auto-bind@5.0.1: {} available-typed-arrays@1.0.7: @@ -13420,8 +12730,6 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - bluebird@3.7.2: {} - body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -13650,13 +12958,6 @@ snapshots: chownr@3.0.0: {} - chromium-bidi@0.6.3(devtools-protocol@0.0.1312386): - dependencies: - devtools-protocol: 0.0.1312386 - mitt: 3.0.1 - urlpattern-polyfill: 10.0.0 - zod: 4.3.6 - chromium-bidi@16.0.1(devtools-protocol@0.0.1638949): dependencies: devtools-protocol: 0.0.1638949 @@ -13774,8 +13075,6 @@ snapshots: compare-version@0.1.2: {} - compare-versions@6.1.1: {} - compress-commons@6.0.2: dependencies: crc-32: 1.2.2 @@ -13786,21 +13085,10 @@ snapshots: concat-map@0.0.1: {} - concurrently@9.2.1: - dependencies: - chalk: 4.1.2 - rxjs: 7.8.2 - shell-quote: 1.8.3 - supports-color: 8.1.1 - tree-kill: 1.2.2 - yargs: 17.7.2 - content-disposition@1.0.1: {} content-type@1.0.5: {} - convert-hrtime@5.0.0: {} - convert-source-map@2.0.0: {} convert-to-spaces@2.0.1: {} @@ -13874,7 +13162,8 @@ snapshots: transitivePeerDependencies: - '@types/node' - cross-dirname@0.1.0: {} + cross-dirname@0.1.0: + optional: true cross-env@7.0.3: dependencies: @@ -13935,10 +13224,6 @@ snapshots: dayjs@1.11.20: {} - debug@2.6.9: - dependencies: - ms: 2.0.0 - debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -14040,8 +13325,6 @@ snapshots: dependencies: dequal: 2.0.3 - devtools-protocol@0.0.1312386: {} - devtools-protocol@0.0.1638949: optional: true @@ -14195,19 +13478,6 @@ snapshots: electron-to-chromium@1.5.302: {} - electron-updater@6.8.3: - dependencies: - builder-util-runtime: 9.5.1 - fs-extra: 10.1.0 - js-yaml: 4.1.1 - lazy-val: 1.0.5 - lodash.escaperegexp: 4.1.2 - lodash.isequal: 4.5.0 - semver: 7.7.4 - tiny-typed-emitter: 2.1.0 - transitivePeerDependencies: - - supports-color - electron-vite@5.0.0(vite@7.3.1(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.0 @@ -14491,8 +13761,6 @@ snapshots: escape-html@1.0.3: {} - escape-string-regexp@1.0.5: {} - escape-string-regexp@2.0.0: {} escape-string-regexp@4.0.0: {} @@ -14660,8 +13928,6 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 - exit-hook@4.0.0: {} - expect-type@1.3.0: {} expect-webdriverio@5.6.4(@wdio/globals@9.23.0)(@wdio/logger@9.18.0)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))): @@ -14742,8 +14008,6 @@ snapshots: fake-indexeddb@6.2.5: {} - fast-copy@3.0.2: {} - fast-copy@4.0.2: {} fast-deep-equal@2.0.1: {} @@ -14816,16 +14080,8 @@ snapshots: dependencies: minimatch: 10.2.2 - filename-reserved-regex@2.0.0: {} - filename-reserved-regex@4.0.0: {} - filenamify@4.3.0: - dependencies: - filename-reserved-regex: 2.0.0 - strip-outer: 1.0.1 - trim-repeated: 1.0.0 - filenamify@7.0.1: dependencies: filename-reserved-regex: 4.0.0 @@ -14845,12 +14101,6 @@ snapshots: transitivePeerDependencies: - supports-color - find-up-simple@1.0.1: {} - - find-up@2.1.0: - dependencies: - locate-path: 2.0.0 - find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -14866,11 +14116,6 @@ snapshots: locate-path: 7.2.0 path-exists: 5.0.0 - find-versions@6.0.0: - dependencies: - semver-regex: 4.0.5 - super-regex: 1.1.0 - flat-cache@4.0.1: dependencies: flatted: 3.3.3 @@ -14880,13 +14125,6 @@ snapshots: flatted@3.3.3: {} - flora-colossus@2.0.0: - dependencies: - debug: 4.4.3(supports-color@8.1.1) - fs-extra: 10.1.0 - transitivePeerDependencies: - - supports-color - for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -14962,18 +14200,8 @@ snapshots: function-bind@1.1.2: {} - function-timeout@1.0.2: {} - functions-have-names@1.2.3: {} - galactus@1.0.0: - dependencies: - debug: 4.4.3(supports-color@8.1.1) - flora-colossus: 2.0.0 - fs-extra: 10.1.0 - transitivePeerDependencies: - - supports-color - geckodriver@6.1.0: dependencies: '@wdio/logger': 9.18.0 @@ -15001,20 +14229,11 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.3 math-intrinsics: 1.1.0 get-nonce@1.0.1: {} - get-package-info@1.0.0: - dependencies: - bluebird: 3.7.2 - debug: 2.6.9 - lodash.get: 4.4.2 - read-pkg-up: 2.0.0 - transitivePeerDependencies: - - supports-color - get-port@7.1.0: {} get-proto@1.0.1: @@ -15033,10 +14252,6 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 - get-tsconfig@4.13.6: - dependencies: - resolve-pkg-maps: 1.0.0 - get-tsconfig@4.14.3: dependencies: resolve-pkg-maps: 1.0.0 @@ -15159,10 +14374,6 @@ snapshots: dependencies: has-symbols: 1.1.0 - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - hasown@2.0.3: dependencies: function-bind: 1.1.2 @@ -15305,8 +14516,6 @@ snapshots: hono@4.13.3: {} - hosted-git-info@2.8.9: {} - hosted-git-info@4.1.0: dependencies: lru-cache: 6.0.0 @@ -15438,8 +14647,6 @@ snapshots: indent-string@5.0.0: {} - index-to-position@1.2.0: {} - inflight@1.0.6: dependencies: once: 1.4.0 @@ -15502,7 +14709,7 @@ snapshots: internal-slot@1.1.0: dependencies: es-errors: 1.3.0 - hasown: 2.0.2 + hasown: 2.0.3 side-channel: 1.1.0 into-stream@5.1.1: @@ -15610,7 +14817,7 @@ snapshots: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.3 is-set@2.0.3: {} @@ -15676,16 +14883,6 @@ snapshots: istanbul-lib-coverage@3.2.2: {} - istanbul-lib-instrument@6.0.3: - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.0 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-coverage: 3.2.2 - semver: 7.7.4 - transitivePeerDependencies: - - supports-color - istanbul-lib-report@3.0.1: dependencies: istanbul-lib-coverage: 3.2.2 @@ -15847,8 +15044,6 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 - junk@3.1.0: {} - katex@0.16.33: dependencies: commander: 8.3.0 @@ -15988,24 +15183,12 @@ snapshots: lines-and-columns@2.0.4: {} - load-json-file@2.0.0: - dependencies: - graceful-fs: 4.2.11 - parse-json: 2.2.0 - pify: 2.3.0 - strip-bom: 3.0.0 - locate-app@2.5.0: dependencies: '@promptbook/utils': 0.69.5 type-fest: 4.26.0 userhome: 1.0.1 - locate-path@2.0.0: - dependencies: - p-locate: 2.0.0 - path-exists: 3.0.0 - locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -16020,30 +15203,14 @@ snapshots: lodash.clonedeep@4.5.0: {} - lodash.difference@4.5.0: {} - - lodash.escaperegexp@4.1.2: {} - - lodash.flatmap@4.5.0: {} - lodash.flattendeep@4.4.0: {} - lodash.get@4.4.2: {} - - lodash.isequal@4.5.0: {} - - lodash.isfunction@3.0.9: {} - lodash.merge@4.6.2: {} lodash.pickby@4.6.0: {} lodash.startcase@4.4.0: {} - lodash.take@4.1.1: {} - - lodash.takeright@4.1.1: {} - lodash.union@4.6.0: {} lodash.zip@4.2.0: {} @@ -16100,12 +15267,6 @@ snapshots: '@babel/types': 7.29.0 source-map-js: 1.2.1 - make-asynchronous@1.1.0: - dependencies: - p-event: 6.0.1 - type-fest: 4.41.0 - web-worker: 1.5.0 - make-dir@4.0.0: dependencies: semver: 7.7.4 @@ -16632,8 +15793,6 @@ snapshots: mrmime@2.0.1: {} - ms@2.0.0: {} - ms@2.1.3: {} mute-stream@2.0.0: {} @@ -16714,13 +15873,6 @@ snapshots: dependencies: abbrev: 3.0.1 - normalize-package-data@2.5.0: - dependencies: - hosted-git-info: 2.8.9 - resolve: 1.22.12 - semver: 5.7.2 - validate-npm-package-license: 3.0.4 - normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 @@ -16901,20 +16053,12 @@ snapshots: p-cancelable@2.1.1: {} - p-event@6.0.1: - dependencies: - p-timeout: 6.1.4 - p-filter@2.1.0: dependencies: p-map: 2.1.0 p-is-promise@3.0.0: {} - p-limit@1.3.0: - dependencies: - p-try: 1.0.0 - p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -16927,14 +16071,6 @@ snapshots: dependencies: yocto-queue: 1.2.2 - p-limit@7.3.0: - dependencies: - yocto-queue: 1.2.2 - - p-locate@2.0.0: - dependencies: - p-limit: 1.3.0 - p-locate@4.1.0: dependencies: p-limit: 2.3.0 @@ -16951,10 +16087,6 @@ snapshots: p-map@7.0.4: {} - p-timeout@6.1.4: {} - - p-try@1.0.0: {} - p-try@2.2.0: {} pac-proxy-agent@7.2.0: @@ -16989,10 +16121,6 @@ snapshots: dependencies: callsites: 3.1.0 - parse-author@2.0.0: - dependencies: - author-regex: 1.0.0 - parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -17003,10 +16131,6 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 - parse-json@2.2.0: - dependencies: - error-ex: 1.3.4 - parse-json@7.1.1: dependencies: '@babel/code-frame': 7.29.0 @@ -17015,12 +16139,6 @@ snapshots: lines-and-columns: 2.0.4 type-fest: 3.13.1 - parse-json@8.3.0: - dependencies: - '@babel/code-frame': 7.29.0 - index-to-position: 1.2.0 - type-fest: 4.41.0 - parse-ms@4.0.0: {} parse5-htmlparser2-tree-adapter@7.1.0: @@ -17042,10 +16160,6 @@ snapshots: path-browserify-esm@1.0.6: {} - path-browserify@1.0.1: {} - - path-exists@3.0.0: {} - path-exists@4.0.0: {} path-exists@5.0.0: {} @@ -17070,10 +16184,6 @@ snapshots: path-to-regexp@8.3.0: {} - path-type@2.0.0: - dependencies: - pify: 2.3.0 - path-type@4.0.0: {} pathe@1.1.2: {} @@ -17084,8 +16194,6 @@ snapshots: pe-library@0.4.1: {} - pe-library@1.0.1: {} - pend@1.2.0: {} picocolors@1.1.1: {} @@ -17100,8 +16208,6 @@ snapshots: pidtree@0.6.0: {} - pify@2.3.0: {} - pify@4.0.1: {} pino-abstract-transport@2.0.0: @@ -17128,10 +16234,6 @@ snapshots: sonic-boom: 4.2.1 strip-json-comments: 5.0.3 - pino-roll@1.3.0: - dependencies: - sonic-boom: 3.8.1 - pino-std-serializers@7.1.0: {} pino@10.3.1: @@ -17187,6 +16289,7 @@ snapshots: postject@1.0.0-alpha.6: dependencies: commander: 9.5.0 + optional: true prelude-ls@1.2.1: {} @@ -17274,21 +16377,6 @@ snapshots: punycode@2.3.1: {} - puppeteer-core@22.15.0: - dependencies: - '@puppeteer/browsers': 2.3.0 - chromium-bidi: 0.6.3(devtools-protocol@0.0.1312386) - debug: 4.4.3(supports-color@8.1.1) - devtools-protocol: 0.0.1312386 - ws: 8.21.1 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - bufferutil - - react-native-b4a - - supports-color - - utf-8-validate - puppeteer-core@25.3.0(yauzl@2.10.0): dependencies: '@puppeteer/browsers': 3.0.6(yauzl@2.10.0) @@ -17546,29 +16634,12 @@ snapshots: json-parse-even-better-errors: 4.0.0 npm-normalize-package-bin: 4.0.0 - read-package-up@11.0.0: - dependencies: - find-up-simple: 1.0.1 - read-pkg: 9.0.1 - type-fest: 4.41.0 - read-pkg-up@10.1.0: dependencies: find-up: 6.3.0 read-pkg: 8.1.0 type-fest: 4.41.0 - read-pkg-up@2.0.0: - dependencies: - find-up: 2.1.0 - read-pkg: 2.0.0 - - read-pkg@2.0.0: - dependencies: - load-json-file: 2.0.0 - normalize-package-data: 2.5.0 - path-type: 2.0.0 - read-pkg@8.1.0: dependencies: '@types/normalize-package-data': 2.4.4 @@ -17576,14 +16647,6 @@ snapshots: parse-json: 7.1.1 type-fest: 4.41.0 - read-pkg@9.0.1: - dependencies: - '@types/normalize-package-data': 2.4.4 - normalize-package-data: 6.0.2 - parse-json: 8.3.0 - type-fest: 4.41.0 - unicorn-magic: 0.1.0 - read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 @@ -17748,10 +16811,6 @@ snapshots: dependencies: pe-library: 0.4.1 - resedit@2.0.3: - dependencies: - pe-library: 1.0.1 - reselect@5.2.0: {} resize-observer-polyfill@1.5.1: {} @@ -17934,8 +16993,6 @@ snapshots: semver-compare@1.0.0: optional: true - semver-regex@4.0.5: {} - semver@5.7.2: {} semver@6.3.1: {} @@ -18060,14 +17117,6 @@ snapshots: signal-exit@4.1.0: {} - simmerjs@0.5.6: - dependencies: - lodash.difference: 4.5.0 - lodash.flatmap: 4.5.0 - lodash.isfunction: 3.0.9 - lodash.take: 4.1.1 - lodash.takeright: 4.1.1 - simple-update-notifier@2.0.0: dependencies: semver: 7.7.4 @@ -18096,8 +17145,6 @@ snapshots: smart-buffer@4.2.0: {} - smol-toml@1.7.0: {} - smol-toml@1.8.0: {} socket.io-adapter@2.5.6: @@ -18154,10 +17201,6 @@ snapshots: ip-address: 10.1.0 smart-buffer: 4.2.0 - sonic-boom@3.8.1: - dependencies: - atomic-sleep: 1.0.0 - sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -18270,8 +17313,6 @@ snapshots: - react-dom - utf-8-validate - stream-buffers@3.0.3: {} - streamx@2.23.0: dependencies: events-universal: 1.0.1 @@ -18347,10 +17388,6 @@ snapshots: strip-json-comments@5.0.3: {} - strip-outer@1.0.1: - dependencies: - escape-string-regexp: 1.0.5 - strnum@2.1.2: {} stubborn-fs@2.0.0: @@ -18375,12 +17412,6 @@ snapshots: transitivePeerDependencies: - supports-color - super-regex@1.1.0: - dependencies: - function-timeout: 1.0.2 - make-asynchronous: 1.1.0 - time-span: 5.1.0 - supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -18492,18 +17523,12 @@ snapshots: through@2.3.8: {} - time-span@5.1.0: - dependencies: - convert-hrtime: 5.0.0 - tiny-async-pool@1.3.0: dependencies: semver: 5.7.2 tiny-invariant@1.3.3: {} - tiny-typed-emitter@2.1.0: {} - tinybench@2.9.0: {} tinyexec@1.0.2: {} @@ -18558,14 +17583,8 @@ snapshots: dependencies: punycode: 2.3.1 - tree-kill@1.2.2: {} - trim-lines@3.0.1: {} - trim-repeated@1.0.0: - dependencies: - escape-string-regexp: 1.0.5 - trough@2.2.0: {} truncate-utf8-bytes@1.0.2: @@ -18591,7 +17610,7 @@ snapshots: tsx@4.21.0: dependencies: esbuild: 0.27.3 - get-tsconfig: 4.13.6 + get-tsconfig: 4.14.3 optionalDependencies: fsevents: 2.3.3 @@ -18640,11 +17659,6 @@ snapshots: unbash@4.0.11: {} - unbzip2-stream@1.4.3: - dependencies: - buffer: 5.7.1 - through: 2.3.8 - undici-types@6.21.0: {} undici-types@7.16.0: {} @@ -18660,8 +17674,6 @@ snapshots: undici@8.10.0: {} - unicorn-magic@0.1.0: {} - unicorn-magic@0.3.0: {} unified@11.0.5: @@ -18740,10 +17752,6 @@ snapshots: dependencies: punycode: 2.3.1 - url-join@5.0.0: {} - - urlpattern-polyfill@10.0.0: {} - urlpattern-polyfill@10.1.0: {} use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): @@ -19030,37 +18038,6 @@ snapshots: dependencies: defaults: 1.0.4 - wdio-electron-service@9.2.1(electron@39.6.1)(expect-webdriverio@5.6.4)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))): - dependencies: - '@babel/parser': 7.29.0 - '@electron/fuses': 2.1.3 - '@vitest/spy': 3.2.4 - '@wdio/cdp-bridge': 9.2.1 - '@wdio/electron-types': 9.2.1 - '@wdio/electron-utils': 9.2.1 - '@wdio/globals': 9.23.0(expect-webdriverio@5.6.4)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) - '@wdio/logger': 9.18.0 - compare-versions: 6.1.1 - debug: 4.4.3(supports-color@8.1.1) - electron-to-chromium: 1.5.302 - fast-copy: 3.0.2 - get-port: 7.1.0 - puppeteer-core: 22.15.0 - read-package-up: 11.0.0 - recast: 0.23.11 - tinyspy: 4.0.4 - webdriverio: 9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0)) - optionalDependencies: - electron: 39.6.1 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - bufferutil - - expect-webdriverio - - react-native-b4a - - supports-color - - utf-8-validate - wdio-html-nice-reporter@8.1.7(chokidar@3.6.0)(encoding@0.1.13): dependencies: '@rpii/wdio-report-events': 8.0.2 @@ -19076,12 +18053,8 @@ snapshots: - chokidar - encoding - wdio-wait-for@3.1.1: {} - web-namespaces@2.0.1: {} - web-worker@1.5.0: {} - webdriver-bidi-protocol@0.4.2: optional: true @@ -19257,13 +18230,6 @@ snapshots: xml-name-validator@5.0.0: {} - xmlbuilder2@4.0.3: - dependencies: - '@oozcitak/dom': 2.0.2 - '@oozcitak/infra': 2.0.2 - '@oozcitak/util': 10.0.0 - js-yaml: 4.1.1 - xmlbuilder@15.1.1: {} xmlchars@2.2.0: {} diff --git a/test/bin/setup.ts b/test/bin/setup.ts deleted file mode 100644 index 05ad5516..00000000 --- a/test/bin/setup.ts +++ /dev/null @@ -1,105 +0,0 @@ -#!/bin/env bun - -import os from 'os' -import path from 'path' -import fs from 'fs' -import { Path } from '../../core/path' - -const env = await Bun.file(".env.json").json() - -// 1. Prepare the tmp folder -const tmpDir = path.join(os.tmpdir(), 'smm-test-media') -// Clean up existing folder if it exists for idempotency -if (fs.existsSync(tmpDir)) { - fs.rmSync(tmpDir, { recursive: true, force: true }) -} -fs.mkdirSync(tmpDir, { recursive: true }) -console.log(`Created tmp folder: ${tmpDir}`) - -// 2. Copy "test/media" to tmp folder -const testMediaPath = path.join(import.meta.dir, '..', 'media') -const targetMediaPath = path.join(tmpDir, 'media') - -// Recursive copy function -async function copyRecursive(source: string, destination: string): Promise { - const stats = await fs.promises.stat(source) - - if (stats.isDirectory()) { - // Create destination directory - await fs.promises.mkdir(destination, { recursive: true }) - - // Read all items in the directory - const items = await fs.promises.readdir(source) - - // Copy each item recursively - for (const item of items) { - const sourcePath = path.join(source, item) - const destPath = path.join(destination, item) - await copyRecursive(sourcePath, destPath) - } - } else if (stats.isFile()) { - // Copy file - await fs.promises.copyFile(source, destination) - } -} - -await copyRecursive(testMediaPath, targetMediaPath) -console.log(`Copied test/media to ${targetMediaPath}`) - -const userConfig: { - applicationLanguage: string; - folders: string[]; - ai: { - deepseek: { - baseURL: string; - apiKey: string; - model: string; - }; - }; - selectedAI: string; -} = { - applicationLanguage: "zh-CN", - folders: [], - ai: { - deepseek: { - baseURL: "https://api.deepseek.com", - apiKey: env["deepseekApiKey"], - model: "deepseek-chat" - } - }, - selectedAI: "DeepSeek" -} - - -const appDataDir = "C:\\Users\\lawrence\\AppData\\Roaming\\SMM" - -// 3. Add test folder "[测试用字幕组] キルミーベイベー" path to userConfig -const testFolderPath = path.join(targetMediaPath, '[测试用字幕组] キルミーベイベー') -// Verify the folder exists before adding -// path.join() already returns platform-specific paths, so use it directly -if (fs.existsSync(testFolderPath)) { - userConfig.folders.push(testFolderPath) - console.log(`Added test folder to config: ${testFolderPath}`) -} else { - console.warn(`Warning: Test folder does not exist at ${testFolderPath}`) -} - -await Bun.write("C:\\Users\\lawrence\\AppData\\Roaming\\SMM\\smm.json", JSON.stringify(userConfig, null, 4)) -console.log(`Prepare smm.json: ` + JSON.stringify(userConfig, null, 4)) - - - -// 4. Write media metadata to cache -const testFolderPathInPosix = Path.posix(testFolderPath) -const mediaMetadata = { - mediaFolderPath: testFolderPathInPosix, - type: "tvshow-folder", -} - -const mediaMetadataFileName = testFolderPathInPosix.replace(/[\/\\:?*|<>"]/g, '_') -const mediaMetadataFilePath = path.join(appDataDir, 'metadata', mediaMetadataFileName + '.json') -await Bun.write(mediaMetadataFilePath, JSON.stringify(mediaMetadata, null, 4)) -console.log(`Prepared media metadata file: ${mediaMetadataFilePath}`) - -console.log("Setup completed") -export {} \ No newline at end of file From 17db04fb86cad784f22f3a83ab6372f005c60b21 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 8 Sep 2026 21:10:31 +0800 Subject: [PATCH 71/83] refactor: clean up v3 documents --- apps/cli/server.ts | 2 - ...eFolderV3.test.ts => RenameFolder.test.ts} | 8 +- apps/cli/src/route/RenameFolder.ts | 119 ++- apps/cli/src/route/RenameFolderV3.ts | 78 -- apps/e2e/package.json | 1 + apps/e2e/test/actions/events.ts | 8 +- apps/e2e/test/componentobjects/Menu.ts | 2 +- .../test/componentobjects/MoviePanel.co.ts | 2 +- .../test/componentobjects/TVShowPanel.co.ts | 104 ++- apps/e2e/test/lib/browser-fs.ts | 2 +- apps/e2e/test/lib/e2e-smm-v3.test.ts | 38 - apps/e2e/test/lib/e2e-smm-v3.ts | 20 - apps/e2e/test/lib/testbed.ts | 11 +- .../test/steps/import-folder-in-harmonyos.ts | 2 +- .../resfile/resources/app/testPage/index.html | 2 +- apps/ui/src/ai/tools/Scrape.tsx | 4 +- apps/ui/src/ai/tools/TmdbGetMovie.tsx | 2 +- apps/ui/src/ai/tools/TmdbGetTvShow.tsx | 2 +- apps/ui/src/ai/tools/TmdbSearch.tsx | 2 +- apps/ui/src/api/renameFolder.ts | 36 +- apps/ui/src/api/renameFolderV3.ts | 38 - apps/ui/src/api/{scrapeV3.ts => scrape.ts} | 16 +- apps/ui/src/api/tmdb.ts | 2 +- .../api/{tmdbV3.test.ts => tmdbHttp.test.ts} | 4 +- apps/ui/src/api/{tmdbV3.ts => tmdbHttp.ts} | 0 .../api/{tvdbV3.test.ts => tvdbHttp.test.ts} | 4 +- apps/ui/src/api/{tvdbV3.ts => tvdbHttp.ts} | 0 apps/ui/src/api/tvdbSearch.ts | 2 +- .../dialogs/useScrapeDialog.test.tsx | 2 +- .../ImportFolderEventHandler.test.ts | 127 ++- .../ImportFolderEventHandler.tsx | 39 +- .../MediaLibraryImportedEventHandler.test.ts | 14 +- .../MediaLibraryImportedEventHandler.tsx | 8 +- ...IoUserConfigFolderRenamedEventListener.tsx | 4 +- apps/ui/src/hooks/folders/index.ts | 3 +- .../hooks/folders/invalidateFoldersQuery.ts | 4 +- .../folders/useUnimportFolderMutation.ts | 4 +- .../useRenameMediaFolderMutation.test.ts | 6 +- .../src/hooks/useRenameMediaFolderMutation.ts | 6 +- apps/ui/src/hooks/useScrapeMutation.test.ts | 2 +- apps/ui/src/hooks/useScrapeMutation.ts | 8 +- apps/ui/src/hooks/useSidebar.ts | 17 +- apps/ui/src/hooks/useTvShowPanel.ts | 27 +- .../userConfig/useAddMediaFolderMutation.ts | 4 +- .../userConfig/useSaveUserConfigMutation.ts | 4 +- ...ibraryV3.test.ts => importLibrary.test.ts} | 2 +- .../{importLibraryV3.ts => importLibrary.ts} | 0 apps/ui/src/lib/mergeFolderListPaths.test.ts | 25 + apps/ui/src/lib/mergeFolderListPaths.ts | 30 + apps/ui/src/lib/pollImportFolderJob.ts | 31 + .../lib/tvShowEpisodeAssociatedFiles.test.ts | 23 + .../src/lib/tvShowEpisodeAssociatedFiles.ts | 29 + ci/run-e2e-test-lib.ts | 11 - ci/run-e2e-test.test.ts | 23 - docs/api/index.md | 16 +- ...ctor-plan-problem-a-ui-core-duplication.md | 2 +- docs/dev/rename-episode-file.md | 42 +- docs/dev/tmdb.md | 6 +- docs/dev/tvdb.md | 12 +- .../plans/2026-08-17-display-folders-v3.md | 748 ------------------ .../2026-08-17-display-folders-v3-design.md | 216 ----- .../2026-08-19-core-rename-folder-design.md | 2 + .../2026-08-19-ui-v3-rename-folder-design.md | 49 -- packages/core-routes/src/core-routes.test.ts | 4 +- .../src/routes/renameFolderRoute.ts | 4 +- pnpm-lock.yaml | 444 ++++++++++- 66 files changed, 1056 insertions(+), 1453 deletions(-) rename apps/cli/src/route/{RenameFolderV3.test.ts => RenameFolder.test.ts} (94%) delete mode 100644 apps/cli/src/route/RenameFolderV3.ts delete mode 100644 apps/e2e/test/lib/e2e-smm-v3.test.ts delete mode 100644 apps/e2e/test/lib/e2e-smm-v3.ts delete mode 100644 apps/ui/src/api/renameFolderV3.ts rename apps/ui/src/api/{scrapeV3.ts => scrape.ts} (68%) rename apps/ui/src/api/{tmdbV3.test.ts => tmdbHttp.test.ts} (97%) rename apps/ui/src/api/{tmdbV3.ts => tmdbHttp.ts} (100%) rename apps/ui/src/api/{tvdbV3.test.ts => tvdbHttp.test.ts} (95%) rename apps/ui/src/api/{tvdbV3.ts => tvdbHttp.ts} (100%) rename apps/ui/src/lib/{importLibraryV3.test.ts => importLibrary.test.ts} (98%) rename apps/ui/src/lib/{importLibraryV3.ts => importLibrary.ts} (100%) create mode 100644 apps/ui/src/lib/mergeFolderListPaths.test.ts create mode 100644 apps/ui/src/lib/mergeFolderListPaths.ts create mode 100644 apps/ui/src/lib/pollImportFolderJob.ts create mode 100644 apps/ui/src/lib/tvShowEpisodeAssociatedFiles.test.ts create mode 100644 apps/ui/src/lib/tvShowEpisodeAssociatedFiles.ts delete mode 100644 docs/superpowers/plans/2026-08-17-display-folders-v3.md delete mode 100644 docs/superpowers/specs/2026-08-17-display-folders-v3-design.md delete mode 100644 docs/superpowers/specs/2026-08-19-ui-v3-rename-folder-design.md diff --git a/apps/cli/server.ts b/apps/cli/server.ts index e6a1cece..46d11446 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -14,7 +14,6 @@ import { handleIsFolderAvailable } from './src/route/IsFolderAvailable'; import { handleWriteFile } from './src/route/WriteFile'; import { handleRenameFiles } from './src/route/RenameFiles'; import { handleRenameFolder } from './src/route/RenameFolder'; -import { handleRenameFolderV3 } from './src/route/RenameFolderV3'; import { handleRenameEpisodeFile } from './src/route/RenameEpisodeFile'; import { handleGetEpisodesRoute } from './src/route/getEpisodes'; import { handleListFilesInMediaFolderRoute } from './src/route/listFilesInMediaFolder'; @@ -270,7 +269,6 @@ export class Server { handleWriteFile(this.app); handleRenameFiles(this.app); handleRenameFolder(this.app); - handleRenameFolderV3(this.app); handleRenameEpisodeFile(this.app); handleGetEpisodesRoute(this.app); handleListFilesInMediaFolderRoute(this.app); diff --git a/apps/cli/src/route/RenameFolderV3.test.ts b/apps/cli/src/route/RenameFolder.test.ts similarity index 94% rename from apps/cli/src/route/RenameFolderV3.test.ts rename to apps/cli/src/route/RenameFolder.test.ts index 70b20328..572bacfd 100644 --- a/apps/cli/src/route/RenameFolderV3.test.ts +++ b/apps/cli/src/route/RenameFolder.test.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'os' import { join } from 'path' import { Hono } from 'hono' import { Path } from '@smm/utils/path' -import { handleRenameFolderV3 } from './RenameFolderV3' +import { handleRenameFolder } from './RenameFolder' import { metadataCachePath } from '../../test/helpers/testFolders' import { installCliTestEnv, restoreCliTestEnv, type CliTestEnv } from '../../test/helpers/cliTestEnv' import { resetCoreForTests } from '../core/getCore' @@ -29,10 +29,10 @@ describe('POST /api/rename-folder', () => { let app: Hono beforeEach(() => { - env = installCliTestEnv('smm-rename-folder-v3') - mediaDir = mkdtempSync(join(tmpdir(), 'smm-rename-folder-v3-media-')) + env = installCliTestEnv('smm-rename-folder') + mediaDir = mkdtempSync(join(tmpdir(), 'smm-rename-folder-media-')) app = new Hono() - handleRenameFolderV3(app) + handleRenameFolder(app) }) afterEach(() => { diff --git a/apps/cli/src/route/RenameFolder.ts b/apps/cli/src/route/RenameFolder.ts index badf0a12..2f67c230 100644 --- a/apps/cli/src/route/RenameFolder.ts +++ b/apps/cli/src/route/RenameFolder.ts @@ -1,67 +1,120 @@ -import { Path } from '@smm/utils/path'; -import type { FolderRenameRequestBody, FolderRenameResponseBody } from '@smm/types'; +import { Path } from '@smm/utils/path' +import type { FolderRenameRequestBody, FolderRenameResponseBody } from '@smm/types' import { doRenameFolder as doRenameFolderCore, type CoreRoutesLogger, -} from '@smm/core-routes'; -import { broadcastUserConfigFolderRenamedEvent } from '@/events/userConfigUpdatedEvent'; -import { broadcast } from '@/utils/socketIO'; -import type { Hono } from 'hono'; -import { logger } from '../../lib/logger'; -import { buildCoreRoutesConfig } from './coreRoutesConfig'; +} from '@smm/core-routes' +import type { Hono } from 'hono' +import { getCore } from '../core/getCore' +import { broadcastUserConfigFolderRenamedEvent } from '@/events/userConfigUpdatedEvent' +import { broadcast } from '@/utils/socketIO' +import { logger } from '../../lib/logger' +import { buildCoreRoutesConfig } from './coreRoutesConfig' const coreRoutesLogger: CoreRoutesLogger = { debug: (obj, msg) => logger.debug(obj, msg), info: (obj, msg) => logger.info(obj, msg), warn: (obj, msg) => logger.warn(obj, msg), error: (obj, msg) => logger.error(obj, msg), -}; +} + +interface RenameFolderHttpResponseBody { + data?: { from: string; to: string } + error?: string +} +/** + * In-process rename used by MCP / debug tools (core-routes `doRenameFolder`). + */ export async function doRenameFolder( body: FolderRenameRequestBody, clientId?: string, ): Promise { - const config = await buildCoreRoutesConfig(coreRoutesLogger); - const result = await doRenameFolderCore(body, config); + const config = await buildCoreRoutesConfig(coreRoutesLogger) + const result = await doRenameFolderCore(body, config) if (!result.error) { - const fromAsPosix = Path.posix(body.from); - const toAsPosix = Path.posix(body.to); + const fromAsPosix = Path.posix(body.from) + const toAsPosix = Path.posix(body.to) broadcastUserConfigFolderRenamedEvent({ from: Path.toPlatformPath(fromAsPosix), to: Path.toPlatformPath(toAsPosix), - }); + }) broadcast({ clientId, event: 'userConfigUpdated', data: {}, - }); + }) } - return result; + return result } -export function handleRenameFolder(app: Hono) { - app.post('/api/renameFolder', async (c) => { +/** + * `POST /api/rename-folder` → `Core.renameFolder`. + * Broadcasts folder-renamed / userConfigUpdated so UI listeners stay in sync. + */ +export function handleRenameFolder(app: Hono): void { + app.post('/api/rename-folder', async (c) => { try { - const rawBody = await c.req.json(); - const clientId = c.req.header('clientId'); + let body: unknown = {} + try { + body = await c.req.json() + } catch { + /* empty body */ + } + + const from = + typeof body === 'object' && body !== null && 'from' in body + ? (body as { from: unknown }).from + : undefined + const to = + typeof body === 'object' && body !== null && 'to' in body + ? (body as { to: unknown }).to + : undefined + + if (typeof from !== 'string' || from.trim() === '') { + const err: RenameFolderHttpResponseBody = { + error: 'Error Reason: from is required', + } + return c.json(err, 200) + } + if (typeof to !== 'string' || to.trim() === '') { + const err: RenameFolderHttpResponseBody = { + error: 'Error Reason: to is required', + } + return c.json(err, 200) + } + + const clientId = c.req.header('clientId') logger.info( - `[HTTP_IN] ${c.req.method} ${c.req.url} ${rawBody.from} -> ${rawBody.to} (clientId: ${clientId || 'not provided'})`, - ); - const result = await doRenameFolder(rawBody, clientId); - return c.json(result, 200); + `[HTTP_IN] ${c.req.method} ${c.req.url} ${from} -> ${to} (clientId: ${clientId || 'not provided'})`, + ) + + await getCore().renameFolder({ from, to }) + + const fromAsPosix = Path.posix(from) + const toAsPosix = Path.posix(to) + broadcastUserConfigFolderRenamedEvent({ + from: Path.toPlatformPath(fromAsPosix), + to: Path.toPlatformPath(toAsPosix), + }) + broadcast({ + clientId: clientId ?? undefined, + event: 'userConfigUpdated', + data: {}, + }) + + const ok: RenameFolderHttpResponseBody = { data: { from, to } } + return c.json(ok, 200) } catch (error) { - logger.error({ error }, 'RenameFolder route error:'); - return c.json( - { - error: 'Unexpected Error: Failed to process rename folder request', - details: error instanceof Error ? error.message : 'Unknown error', - }, - 200, - ); + logger.error({ error }, '[POST /api/rename-folder] route error') + const err: RenameFolderHttpResponseBody = { + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(err, 200) } - }); + }) } diff --git a/apps/cli/src/route/RenameFolderV3.ts b/apps/cli/src/route/RenameFolderV3.ts deleted file mode 100644 index 6dd93de8..00000000 --- a/apps/cli/src/route/RenameFolderV3.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Path } from '@smm/utils/path' -import type { Hono } from 'hono' -import { getCore } from '../core/getCore' -import { broadcastUserConfigFolderRenamedEvent } from '@/events/userConfigUpdatedEvent' -import { broadcast } from '@/utils/socketIO' -import { logger } from '../../lib/logger' - -interface RenameFolderV3ResponseBody { - data?: { from: string; to: string } - error?: string -} - -/** - * Layer-2 rename: POST /api/rename-folder → Core.renameFolder. - * Keeps socket broadcasts so UI listeners stay in sync (parity with legacy /api/renameFolder). - */ -export function handleRenameFolderV3(app: Hono): void { - app.post('/api/rename-folder', async (c) => { - try { - let body: unknown = {} - try { - body = await c.req.json() - } catch { - /* empty body */ - } - - const from = - typeof body === 'object' && body !== null && 'from' in body - ? (body as { from: unknown }).from - : undefined - const to = - typeof body === 'object' && body !== null && 'to' in body - ? (body as { to: unknown }).to - : undefined - - if (typeof from !== 'string' || from.trim() === '') { - const err: RenameFolderV3ResponseBody = { - error: 'Error Reason: from is required', - } - return c.json(err, 200) - } - if (typeof to !== 'string' || to.trim() === '') { - const err: RenameFolderV3ResponseBody = { - error: 'Error Reason: to is required', - } - return c.json(err, 200) - } - - const clientId = c.req.header('clientId') - logger.info( - `[HTTP_IN] ${c.req.method} ${c.req.url} ${from} -> ${to} (clientId: ${clientId || 'not provided'})`, - ) - - await getCore().renameFolder({ from, to }) - - const fromAsPosix = Path.posix(from) - const toAsPosix = Path.posix(to) - broadcastUserConfigFolderRenamedEvent({ - from: Path.toPlatformPath(fromAsPosix), - to: Path.toPlatformPath(toAsPosix), - }) - broadcast({ - clientId: clientId ?? undefined, - event: 'userConfigUpdated', - data: {}, - }) - - const ok: RenameFolderV3ResponseBody = { data: { from, to } } - return c.json(ok, 200) - } catch (error) { - logger.error({ error }, '[POST /api/rename-folder] route error') - const err: RenameFolderV3ResponseBody = { - error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, - } - return c.json(err, 200) - } - }) -} diff --git a/apps/e2e/package.json b/apps/e2e/package.json index 324a24af..4a4350fc 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -10,6 +10,7 @@ "@types/shelljs": "^0.10.0", "@wdio/cli": "^9.23.0", "@wdio/globals": "^9.23.0", + "@wdio/local-runner": "^9.31.7", "@wdio/mocha-framework": "^9.23.0", "@wdio/spec-reporter": "^9.20.0", "expect-webdriverio": "^5.6.1", diff --git a/apps/e2e/test/actions/events.ts b/apps/e2e/test/actions/events.ts index dd4aa20d..4abfbcb2 100644 --- a/apps/e2e/test/actions/events.ts +++ b/apps/e2e/test/actions/events.ts @@ -1,3 +1,6 @@ +/** Must match apps/ui `UI_ImportFolderEvent` (`eventTypes.ts`). */ +export const UI_ImportFolderEvent = 'ui.importFolder' + interface ImportMediaFolderData { type: "tvshow" | "movie" | "music"; folderPathInPlatformFormat: string; @@ -6,7 +9,10 @@ interface ImportMediaFolderData { } export async function importMediaFolder(data: ImportMediaFolderData) { - await browser.executeScript(`document.dispatchEvent(new CustomEvent('ui.mediaFolderImported', { detail: arguments[0] }))`, [data]); + await browser.executeScript( + `document.dispatchEvent(new CustomEvent('${UI_ImportFolderEvent}', { detail: arguments[0] }))`, + [data], + ); } diff --git a/apps/e2e/test/componentobjects/Menu.ts b/apps/e2e/test/componentobjects/Menu.ts index 246b39ea..76400a58 100644 --- a/apps/e2e/test/componentobjects/Menu.ts +++ b/apps/e2e/test/componentobjects/Menu.ts @@ -103,7 +103,7 @@ class Menu { * @deprecated use functions in test/actions/events.ts instead */ public async importMediaFolder(data: ImportMediaFolderData) { - await browser.executeScript(`document.dispatchEvent(new CustomEvent('ui.mediaFolderImported', { detail: arguments[0] }))`, [data]); + await browser.executeScript(`document.dispatchEvent(new CustomEvent('ui.importFolder', { detail: arguments[0] }))`, [data]); } } diff --git a/apps/e2e/test/componentobjects/MoviePanel.co.ts b/apps/e2e/test/componentobjects/MoviePanel.co.ts index 2e2ec458..e710ca20 100644 --- a/apps/e2e/test/componentobjects/MoviePanel.co.ts +++ b/apps/e2e/test/componentobjects/MoviePanel.co.ts @@ -6,7 +6,7 @@ import { SearchboxCO } from './Searchbox.co' class MoviePanelComponentObject { get table() { - return $('[data-testid="tvshow-episode-table"]') + return $('[data-testid="media-file-table"]') } get input() { diff --git a/apps/e2e/test/componentobjects/TVShowPanel.co.ts b/apps/e2e/test/componentobjects/TVShowPanel.co.ts index 3cff5a87..91bb7167 100644 --- a/apps/e2e/test/componentobjects/TVShowPanel.co.ts +++ b/apps/e2e/test/componentobjects/TVShowPanel.co.ts @@ -47,10 +47,10 @@ class TVShowPanel { } /** - * Get the episode table element + * Get the episode table element ({@link MediaFileTable}) */ get episodeTable() { - return $('[data-testid="tvshow-episode-table"]') + return $('[data-testid="media-file-table"]') } /** @@ -247,7 +247,7 @@ class TVShowPanel { * This simulates a right-click on the corresponding table row. */ async openContextMenuForEpisode(episodeId: string): Promise { - const table = await $('[data-testid="tvshow-episode-table"]') + const table = await this.episodeTable await table.waitForDisplayed({ timeout: 10_000 }) const episodeIdCell = await table.$(`td=${episodeId}`) await episodeIdCell.waitForDisplayed({ timeout: 10_000 }) @@ -274,8 +274,33 @@ class TVShowPanel { console.log(`[TVShowPanel] Clicked context menu item: ${labels.join(' / ')}`) } + /** + * Video cell text for {@link MediaFileTableEpisodeSimpleRow}. + * Prefer the rename preview target when both old/new paths are shown. + */ + private async getVideoFileCellText(cell: ChainablePromiseElement): Promise { + try { + const newPath = await cell.$('[data-testid="media-file-table-new-video-file"]') + if (await newPath.isExisting().catch(() => false)) { + return (await newPath.getText()).trim() + } + const text = (await cell.getText()).trim() + if (!text) return '' + // Recognize preview shows struck-through current path + new target. + const lines = text.split(/\n/).map((l) => l.trim()).filter(Boolean) + return lines[lines.length - 1] ?? '' + } catch { + return '' + } + } + /** * Get the current state of the TV show panel + * + * MediaFileTable nests episode rows inside season collapsible content + * (`table` within a wrapper `tr`). Season headers include a collapse + * button whose label must not be treated as the divider title. + * Episode column order: `[checkbox?] [SxxExx] [video] [thumb] [sub] [nfo]`. */ async getState(): Promise { const state: TvShowPanelState = { @@ -301,17 +326,44 @@ class TVShowPanel { const rows = await table.$$('tr') for (const row of rows) { + // Wrapper row that holds the nested episode + const nestedTable = await row.$('table') + if (await nestedTable.isExisting().catch(() => false)) { + continue + } + const cells = await row.$$('td') const cellsCount = await cells.length - if (cellsCount === 0) continue - const firstCellText = await cells[0]!.getText() - - const idMatch = firstCellText.match(/^S(\d+)E(\d+)$/) - if (idMatch) { - const tableRow: TvShowPanelState['table'][number] = { - id: firstCellText, + // Season header: title span + collapse/expand control + const collapseBtn = await row.$('button[aria-expanded]') + if (await collapseBtn.isExisting().catch(() => false)) { + const titleSpan = await row.$('td span') + const title = titleSpan && (await titleSpan.isExisting().catch(() => false)) + ? (await titleSpan.getText()).trim() + : '' + if (title) { + state.table.push({ id: title, type: 'divider' }) + } + continue + } + + // Find the SxxExx id cell (checkbox column may come first) + let idCellIndex = -1 + let idText = '' + for (let i = 0; i < cellsCount; i++) { + const text = (await cells[i]!.getText()).trim() + if (/^S\d+E\d+$/.test(text)) { + idCellIndex = i + idText = text + break + } + } + + if (idCellIndex >= 0) { + const tableRow: TvShowEpisodeTableSimpleRow = { + id: idText, type: 'episode', checkbox: false, videoFile: '', @@ -320,19 +372,16 @@ class TVShowPanel { subtitle: '' } - let cellIndex = 1 - - if (cellsCount > cellIndex) { - const nextCell = await cells[cellIndex]!.$('input[type="checkbox"]') - const hasCheckbox = await nextCell.isExisting().catch(() => false) - if (hasCheckbox) { - tableRow.checkbox = await nextCell.isSelected() - cellIndex++ + if (idCellIndex > 0) { + const checkbox = await cells[0]!.$('input[type="checkbox"]') + if (await checkbox.isExisting().catch(() => false)) { + tableRow.checkbox = await checkbox.isSelected() } } + let cellIndex = idCellIndex + 1 if (cellsCount > cellIndex) { - tableRow.videoFile = await cells[cellIndex]!.getText() + tableRow.videoFile = await this.getVideoFileCellText(cells[cellIndex]!) cellIndex++ } if (cellsCount > cellIndex) { @@ -348,11 +397,16 @@ class TVShowPanel { } state.table.push(tableRow) - } else if (firstCellText.length > 0 && !firstCellText.match(/^\s*$/)) { - state.table.push({ - id: firstCellText.trim(), - type: 'divider' - }) + continue + } + + // Metadata name/value rows (e.g. "nfo") — treat label as divider + let label = (await cells[0]!.getText()).trim() + if (!label && cellsCount > 1) { + label = (await cells[1]!.getText()).trim() + } + if (label.length > 0) { + state.table.push({ id: label, type: 'divider' }) } } } @@ -555,7 +609,7 @@ class TVShowPanel { } get newVideoFilePaths() { - return $$('[data-testid="tvshow-episode-table-new-video-file"]') + return $$('[data-testid="media-file-table-new-video-file"]') } } diff --git a/apps/e2e/test/lib/browser-fs.ts b/apps/e2e/test/lib/browser-fs.ts index 72c23623..af56e82a 100644 --- a/apps/e2e/test/lib/browser-fs.ts +++ b/apps/e2e/test/lib/browser-fs.ts @@ -474,7 +474,7 @@ export async function createTestFolderViaBrowser( /** * Create a fixture under `{tmpDir}/smm-test-folder` (or `base`) and emit - * `ui.mediaFolderImported` via {@link importMediaFolder}. + * `ui.importFolder` via {@link importMediaFolder}. */ export async function createAndImportFolderViaBrowser( folder: { diff --git a/apps/e2e/test/lib/e2e-smm-v3.test.ts b/apps/e2e/test/lib/e2e-smm-v3.test.ts deleted file mode 100644 index 911e3d89..00000000 --- a/apps/e2e/test/lib/e2e-smm-v3.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { afterEach, describe, expect, test } from 'bun:test' -import { isE2eSmmV3Enabled, localStorageEntriesAfterClear } from './e2e-smm-v3.ts' - -describe('isE2eSmmV3Enabled', () => { - const prev = process.env.E2E_SMM_V3 - - afterEach(() => { - if (prev === undefined) delete process.env.E2E_SMM_V3 - else process.env.E2E_SMM_V3 = prev - }) - - test('is false when E2E_SMM_V3 is unset', () => { - delete process.env.E2E_SMM_V3 - expect(isE2eSmmV3Enabled()).toBe(false) - }) - - test('is false when E2E_SMM_V3 is not "true"', () => { - process.env.E2E_SMM_V3 = '1' - expect(isE2eSmmV3Enabled()).toBe(false) - }) - - test('is true when E2E_SMM_V3 is "true"', () => { - process.env.E2E_SMM_V3 = 'true' - expect(isE2eSmmV3Enabled()).toBe(true) - }) -}) - -describe('localStorageEntriesAfterClear', () => { - test('is empty when v3 is off', () => { - expect(localStorageEntriesAfterClear({} as NodeJS.ProcessEnv)).toEqual({}) - }) - - test('sets smm.v3.enabled when E2E_SMM_V3=true', () => { - expect(localStorageEntriesAfterClear({ E2E_SMM_V3: 'true' } as NodeJS.ProcessEnv)).toEqual({ - 'smm.v3.enabled': 'true', - }) - }) -}) diff --git a/apps/e2e/test/lib/e2e-smm-v3.ts b/apps/e2e/test/lib/e2e-smm-v3.ts deleted file mode 100644 index dc925845..00000000 --- a/apps/e2e/test/lib/e2e-smm-v3.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** Browser localStorage key read by `isSmmV3Enabled()` in apps/ui. */ -export const SMM_V3_LOCAL_STORAGE_KEY = 'smm.v3.enabled' - -/** - * Opt-in for e2e: `E2E_SMM_V3=true` injects `smm.v3.enabled` (production default is already on). - * Any other value (unset, `"1"`, `"false"`) leaves v3 off. - */ -export function isE2eSmmV3Enabled(env: NodeJS.ProcessEnv = process.env): boolean { - return env.E2E_SMM_V3 === 'true' -} - -/** - * Keys to restore after `localStorage.clear()` so the v3 flag survives testbed cleanup. - */ -export function localStorageEntriesAfterClear( - env: NodeJS.ProcessEnv = process.env, -): Record { - if (!isE2eSmmV3Enabled(env)) return {} - return { [SMM_V3_LOCAL_STORAGE_KEY]: 'true' } -} diff --git a/apps/e2e/test/lib/testbed.ts b/apps/e2e/test/lib/testbed.ts index db5fd1ed..26e53325 100644 --- a/apps/e2e/test/lib/testbed.ts +++ b/apps/e2e/test/lib/testbed.ts @@ -25,7 +25,6 @@ import { import type { TestbedOs } from './ui-page-url' import { isOhosE2e, testbedOs as defaultTestbedOs } from './e2e-platform' import { browser } from '@wdio/globals' -import { localStorageEntriesAfterClear } from './e2e-smm-v3' import { applyResetUserConfig as applyResetUserConfigHost, updateUserConfig as updateUserConfigHost, @@ -133,7 +132,6 @@ export async function setup(options: { * Clear `localStorage` during cleanup and again after opening the page. * Defaults to `true` so Ohos/Electron attach sessions do not leak debug * overrides (e.g. wronghost TMDB asset host) across specs. - * When `E2E_SMM_V3=true`, `smm.v3.enabled` is written back after clear. */ clearLocalStorage?: boolean, /** @@ -296,7 +294,6 @@ export async function cleanup(options?: { * Clear `localStorage` (debug overrides, agreement flags, etc.). * Defaults to `true` so leftover keys do not pollute the next spec * when the session is reused (Ohos/Electron attach). - * When `E2E_SMM_V3=true`, `smm.v3.enabled` is written back after clear. */ clearLocalStorage?: boolean, /** @@ -402,14 +399,10 @@ export async function removeDirInSidebar(): Promise { async function clearBrowserLocalStorage(): Promise { // In some cleanup paths browser context may not be ready. try { - const restore = localStorageEntriesAfterClear() - await browser.execute((entries: Record) => { + await browser.execute(() => { const storage = (globalThis as { localStorage?: Storage }).localStorage storage?.clear() - for (const [key, value] of Object.entries(entries)) { - storage?.setItem(key, value) - } - }, restore) + }) } catch (error) { console.warn('Skip clearing localStorage because browser is not ready:', error) } diff --git a/apps/e2e/test/steps/import-folder-in-harmonyos.ts b/apps/e2e/test/steps/import-folder-in-harmonyos.ts index 3d066be7..b41d5ef8 100644 --- a/apps/e2e/test/steps/import-folder-in-harmonyos.ts +++ b/apps/e2e/test/steps/import-folder-in-harmonyos.ts @@ -2,7 +2,7 @@ import { registerStep } from '../lib/gherkin' import { importMediaFolder } from 'test/actions/events' /** - * Dispatch `ui.mediaFolderImported` for a folder that already exists on the + * Dispatch `ui.importFolder` for a folder that already exists on the * HarmonyOS device (no host-side fixture creation). * * Defaults to `tvshow`. Override via `ctx._folderType` (`tvshow` | `movie` | `music`). diff --git a/apps/ohos/web_engine/src/main/resources/resfile/resources/app/testPage/index.html b/apps/ohos/web_engine/src/main/resources/resfile/resources/app/testPage/index.html index c7ae80d3..7c5b5c3e 100644 --- a/apps/ohos/web_engine/src/main/resources/resfile/resources/app/testPage/index.html +++ b/apps/ohos/web_engine/src/main/resources/resfile/resources/app/testPage/index.html @@ -227,7 +227,7 @@

日志

'完整响应:\n' + JSON.stringify(result, null, 2); resultEl.className = 'ok'; - document.dispatchEvent(new CustomEvent('ui.mediaFolderImported', { + document.dispatchEvent(new CustomEvent('ui.importFolder', { detail: { type: 'movie', folderPathInPlatformFormat: path, diff --git a/apps/ui/src/ai/tools/Scrape.tsx b/apps/ui/src/ai/tools/Scrape.tsx index 0494e81d..65a0dc75 100644 --- a/apps/ui/src/ai/tools/Scrape.tsx +++ b/apps/ui/src/ai/tools/Scrape.tsx @@ -7,7 +7,7 @@ import { } from '@smm/types/ai-tools/scrape' import { scrapeFailed, scrapeSucceeded } from '@smm/core/ai-tool/scrapeResult' import { formatToolError, requireNonEmptyString, toolOk } from '@smm/core/ai-tool/toolResult' -import { scrapeFolderV3 } from '@/api/scrapeV3' +import { scrapeFolder } from '@/api/scrape' const scrapeTool = tool({ description: SCRAPE_DESCRIPTION, @@ -19,7 +19,7 @@ const scrapeTool = tool({ } try { - const result = await scrapeFolderV3({ + const result = await scrapeFolder({ path: pathCheck, language, }) diff --git a/apps/ui/src/ai/tools/TmdbGetMovie.tsx b/apps/ui/src/ai/tools/TmdbGetMovie.tsx index 58213d7c..265422ea 100644 --- a/apps/ui/src/ai/tools/TmdbGetMovie.tsx +++ b/apps/ui/src/ai/tools/TmdbGetMovie.tsx @@ -6,7 +6,7 @@ import { type TmdbGetMovieOutput, } from '@smm/types/ai-tools/tmdbGetMovie' import { formatToolError } from '@smm/core/ai-tool/toolResult' -import { getMovieInTmdb } from '@/api/tmdbV3' +import { getMovieInTmdb } from '@/api/tmdbHttp' const tmdbGetMovieTool = tool({ description: TMDB_GET_MOVIE_DESCRIPTION, diff --git a/apps/ui/src/ai/tools/TmdbGetTvShow.tsx b/apps/ui/src/ai/tools/TmdbGetTvShow.tsx index 00c13e61..59d3fe17 100644 --- a/apps/ui/src/ai/tools/TmdbGetTvShow.tsx +++ b/apps/ui/src/ai/tools/TmdbGetTvShow.tsx @@ -6,7 +6,7 @@ import { type TmdbGetTvShowOutput, } from '@smm/types/ai-tools/tmdbGetTvShow' import { formatToolError } from '@smm/core/ai-tool/toolResult' -import { getTvShowInTmdb } from '@/api/tmdbV3' +import { getTvShowInTmdb } from '@/api/tmdbHttp' const tmdbGetTvShowTool = tool({ description: TMDB_GET_TV_SHOW_DESCRIPTION, diff --git a/apps/ui/src/ai/tools/TmdbSearch.tsx b/apps/ui/src/ai/tools/TmdbSearch.tsx index dcf02b38..1efa914f 100644 --- a/apps/ui/src/ai/tools/TmdbSearch.tsx +++ b/apps/ui/src/ai/tools/TmdbSearch.tsx @@ -6,7 +6,7 @@ import { type TmdbSearchOutput, } from '@smm/types/ai-tools/tmdbSearch' import { formatToolError, requireNonEmptyString } from '@smm/core/ai-tool/toolResult' -import { searchInTmdb } from '@/api/tmdbV3' +import { searchInTmdb } from '@/api/tmdbHttp' const tmdbSearchTool = tool({ description: TMDB_SEARCH_DESCRIPTION, diff --git a/apps/ui/src/api/renameFolder.ts b/apps/ui/src/api/renameFolder.ts index 3201451f..6ef71b77 100644 --- a/apps/ui/src/api/renameFolder.ts +++ b/apps/ui/src/api/renameFolder.ts @@ -1,25 +1,25 @@ -import type { FolderRenameRequestBody, FolderRenameResponseBody } from '@smm/types' -import { apiFetch } from '@/lib/apiFetch'; +import { apiFetch } from '@/lib/apiFetch' export interface RenameFolderParams { from: string to: string } +export interface RenameFolderResponseBody { + data?: { from: string; to: string } + error?: string +} + +/** `POST /api/rename-folder` → `Core.renameFolder`. */ export async function postRenameFolder( params: RenameFolderParams, -): Promise { - const req: FolderRenameRequestBody = { - from: params.from, - to: params.to, - } - - const resp = await apiFetch('/api/renameFolder', { + signal?: AbortSignal, +): Promise { + const resp = await apiFetch('/api/rename-folder', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(req), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: params.from, to: params.to }), + signal, }) if (!resp.ok) { @@ -28,5 +28,13 @@ export async function postRenameFolder( } } - return (await resp.json()) as FolderRenameResponseBody + return (await resp.json()) as RenameFolderResponseBody +} + +/** Throws on business error. */ +export async function renameFolderViaCore(params: RenameFolderParams): Promise { + const data = await postRenameFolder(params) + if (data.error) { + throw new Error(data.error) + } } diff --git a/apps/ui/src/api/renameFolderV3.ts b/apps/ui/src/api/renameFolderV3.ts deleted file mode 100644 index 1aebd0f1..00000000 --- a/apps/ui/src/api/renameFolderV3.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { apiFetch } from '@/lib/apiFetch' - -export interface RenameFolderV3Params { - from: string - to: string -} - -interface RenameFolderV3ResponseBody { - data?: { from: string; to: string } - error?: string -} - -/** Layer-2 rename via Core (`POST /api/rename-folder`). Used when SMM v3 is enabled. */ -async function renameFolderV3( - params: RenameFolderV3Params, - signal?: AbortSignal, -): Promise { - const resp = await apiFetch('/api/rename-folder', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ from: params.from, to: params.to }), - signal, - }) - - if (!resp.ok) { - throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`) - } - - return (await resp.json()) as RenameFolderV3ResponseBody -} - -/** Throws on business error. */ -export async function renameFolderViaCore(params: RenameFolderV3Params): Promise { - const data = await renameFolderV3(params) - if (data.error) { - throw new Error(data.error) - } -} diff --git a/apps/ui/src/api/scrapeV3.ts b/apps/ui/src/api/scrape.ts similarity index 68% rename from apps/ui/src/api/scrapeV3.ts rename to apps/ui/src/api/scrape.ts index a13e29bc..a17f28c1 100644 --- a/apps/ui/src/api/scrapeV3.ts +++ b/apps/ui/src/api/scrape.ts @@ -1,20 +1,20 @@ import { apiFetch } from '@/lib/apiFetch' -export interface ScrapeFolderV3Params { +export interface ScrapeFolderParams { path: string language?: string } -export interface ScrapeFolderV3ResponseBody { +export interface ScrapeFolderResponseBody { data?: { id: string } error?: string } /** Layer-2 scrape via Core (`POST /api/scrape`). */ -export async function scrapeFolderV3( - params: ScrapeFolderV3Params, +export async function scrapeFolder( + params: ScrapeFolderParams, signal?: AbortSignal, -): Promise { +): Promise { const body: Record = { path: params.path } if (params.language !== undefined && params.language.trim() !== '') { body.language = params.language @@ -31,12 +31,12 @@ export async function scrapeFolderV3( throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`) } - return (await resp.json()) as ScrapeFolderV3ResponseBody + return (await resp.json()) as ScrapeFolderResponseBody } /** Throws on business error; returns job id. */ -export async function scrapeFolderViaCore(params: ScrapeFolderV3Params): Promise { - const data = await scrapeFolderV3(params) +export async function scrapeFolderViaCore(params: ScrapeFolderParams): Promise { + const data = await scrapeFolder(params) if (data.error) { throw new Error(data.error) } diff --git a/apps/ui/src/api/tmdb.ts b/apps/ui/src/api/tmdb.ts index e03008fa..a7309c44 100644 --- a/apps/ui/src/api/tmdb.ts +++ b/apps/ui/src/api/tmdb.ts @@ -12,7 +12,7 @@ import { fetchWithFailover } from '@/lib/http' import staticConfig from './staticConfig' import { fetchByInternalReverseProxy } from './fetchByInternalReverseProxy' import { buildTmdbErrorFromResponse } from './tmdbErrors' -import { getMovieInTmdb, getTvShowInTmdb, searchInTmdb } from './tmdbV3' +import { getMovieInTmdb, getTvShowInTmdb, searchInTmdb } from './tmdbHttp' export const SMM_TMDB_DEFAULT_UPSTREAM = 'https://mediadb.vercel.app/api/tmdb' diff --git a/apps/ui/src/api/tmdbV3.test.ts b/apps/ui/src/api/tmdbHttp.test.ts similarity index 97% rename from apps/ui/src/api/tmdbV3.test.ts rename to apps/ui/src/api/tmdbHttp.test.ts index 59be34fb..f21f5f79 100644 --- a/apps/ui/src/api/tmdbV3.test.ts +++ b/apps/ui/src/api/tmdbHttp.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getMovieInTmdb, getTvShowInTmdb, searchInTmdb } from './tmdbV3' +import { getMovieInTmdb, getTvShowInTmdb, searchInTmdb } from './tmdbHttp' vi.mock('@/lib/apiFetch', () => ({ apiFetch: vi.fn(), @@ -17,7 +17,7 @@ function jsonResponse(body: unknown, status = 200): Response { }) } -describe('tmdbV3 Internal HTTP clients', () => { +describe('tmdbHttp Internal HTTP clients', () => { beforeEach(() => { mockApiFetch.mockReset() }) diff --git a/apps/ui/src/api/tmdbV3.ts b/apps/ui/src/api/tmdbHttp.ts similarity index 100% rename from apps/ui/src/api/tmdbV3.ts rename to apps/ui/src/api/tmdbHttp.ts diff --git a/apps/ui/src/api/tvdbV3.test.ts b/apps/ui/src/api/tvdbHttp.test.ts similarity index 95% rename from apps/ui/src/api/tvdbV3.test.ts rename to apps/ui/src/api/tvdbHttp.test.ts index fac710c3..ade06ae0 100644 --- a/apps/ui/src/api/tvdbV3.test.ts +++ b/apps/ui/src/api/tvdbHttp.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { searchInTvdb, toTvdbApiLanguage } from './tvdbV3' +import { searchInTvdb, toTvdbApiLanguage } from './tvdbHttp' vi.mock('@/lib/apiFetch', () => ({ apiFetch: vi.fn(), @@ -17,7 +17,7 @@ function jsonResponse(body: unknown, status = 200): Response { }) } -describe('tvdbV3 Internal HTTP clients', () => { +describe('tvdbHttp Internal HTTP clients', () => { beforeEach(() => { mockApiFetch.mockReset() }) diff --git a/apps/ui/src/api/tvdbV3.ts b/apps/ui/src/api/tvdbHttp.ts similarity index 100% rename from apps/ui/src/api/tvdbV3.ts rename to apps/ui/src/api/tvdbHttp.ts diff --git a/apps/ui/src/api/tvdbSearch.ts b/apps/ui/src/api/tvdbSearch.ts index bcdb82a6..236bd6e7 100644 --- a/apps/ui/src/api/tvdbSearch.ts +++ b/apps/ui/src/api/tvdbSearch.ts @@ -1,5 +1,5 @@ import type { TVDBv4SearchResult } from '@smm/tvdb4/types' -import { searchInTvdb } from './tvdbV3' +import { searchInTvdb } from './tvdbHttp' export interface SearchTvdbResponse { results: TVDBv4SearchResult[] diff --git a/apps/ui/src/components/dialogs/useScrapeDialog.test.tsx b/apps/ui/src/components/dialogs/useScrapeDialog.test.tsx index 8b9acb19..b80ce658 100644 --- a/apps/ui/src/components/dialogs/useScrapeDialog.test.tsx +++ b/apps/ui/src/components/dialogs/useScrapeDialog.test.tsx @@ -14,7 +14,7 @@ const refreshMediaMetadataMock = vi.fn().mockResolvedValue(undefined) const listFilesMock = vi.fn().mockResolvedValue({ data: { items: [] } }) const userConfigMock = { preferMediaLanguage: "zh-CN" } -vi.mock("@/api/scrapeV3", () => ({ +vi.mock("@/api/scrape", () => ({ scrapeFolderViaCore: (...args: unknown[]) => scrapeFolderViaCoreMock(...args), })) vi.mock("@/api/getJob", () => ({ diff --git a/apps/ui/src/components/eventlisteners/ImportFolderEventHandler.test.ts b/apps/ui/src/components/eventlisteners/ImportFolderEventHandler.test.ts index 4733001e..ed619474 100644 --- a/apps/ui/src/components/eventlisteners/ImportFolderEventHandler.test.ts +++ b/apps/ui/src/components/eventlisteners/ImportFolderEventHandler.test.ts @@ -11,6 +11,18 @@ const { importFolderViaCoreMock } = vi.hoisted(() => ({ importFolderViaCoreMock: vi.fn(), })) +const { pollImportFolderJobMock } = vi.hoisted(() => ({ + pollImportFolderJobMock: vi.fn(), +})) + +const { showFolderViaCoreMock } = vi.hoisted(() => ({ + showFolderViaCoreMock: vi.fn(), +})) + +const { invalidateFoldersQueryMock } = vi.hoisted(() => ({ + invalidateFoldersQueryMock: vi.fn(), +})) + vi.mock("@/lib/persistHarmonyOSFileAccess", () => ({ persistHarmonyOSFileAccess: persistHarmonyOSFileAccessMock, })) @@ -19,6 +31,22 @@ vi.mock("@/api/importFolder", () => ({ importFolderViaCore: importFolderViaCoreMock, })) +vi.mock("@/lib/pollImportFolderJob", () => ({ + pollImportFolderJob: pollImportFolderJobMock, +})) + +vi.mock("@/api/showFolder", () => ({ + showFolderViaCore: showFolderViaCoreMock, +})) + +vi.mock("@/hooks/folders", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + invalidateFoldersQuery: invalidateFoldersQueryMock, + } +}) + import { ImportFolderEventHandler } from "./ImportFolderEventHandler" import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" import { UI_ImportFolderEvent, type OnMediaFolderImportedEventData } from "@/types/eventTypes" @@ -35,12 +63,16 @@ describe("ImportFolderEventHandler", () => { createElement(ImportFolderEventHandler), ), ) + return queryClient } beforeEach(() => { importFolderViaCoreMock.mockReset() persistHarmonyOSFileAccessMock.mockReset() persistHarmonyOSFileAccessMock.mockResolvedValue(undefined) + pollImportFolderJobMock.mockReset() + showFolderViaCoreMock.mockReset() + invalidateFoldersQueryMock.mockReset() useUIMediaFolderStore.setState({ folders: [], selectedFolder: "", @@ -50,6 +82,23 @@ describe("ImportFolderEventHandler", () => { it("upserts initializing folder and POSTs /api/import-folder", async () => { importFolderViaCoreMock.mockResolvedValue("core-job-1") + pollImportFolderJobMock.mockResolvedValue({ + kind: "import", + id: "core-job-1", + folderPath: "/media/tvshow/Show A", + type: "tvshow", + status: "succeeded", + stage: "persist", + progress: 100, + createdAt: 0, + updatedAt: 0, + }) + showFolderViaCoreMock.mockResolvedValue({ + path: "/media/tvshow/Show A", + status: "ok", + type: "tvshow-folder", + title: "Show A", + }) const folderPath = "/media/tvshow/Show A" renderHandler() @@ -74,15 +123,41 @@ describe("ImportFolderEventHandler", () => { expect(persistHarmonyOSFileAccessMock).toHaveBeenCalledWith([folderPath]) - const state = useUIMediaFolderStore.getState() - expect(state.selectedFolder).toBe(folderPath) - expect(state.folders).toEqual([ - { path: folderPath, status: "initializing", type: "tvshow-folder" }, - ]) + await vi.waitFor(() => { + expect(pollImportFolderJobMock).toHaveBeenCalledWith("core-job-1", expect.any(Function)) + }) + + await vi.waitFor(() => { + expect(showFolderViaCoreMock).toHaveBeenCalledWith(folderPath) + }) + + await vi.waitFor(() => { + expect(useUIMediaFolderStore.getState().folders).toEqual([ + { path: folderPath, status: "ok", type: "tvshow-folder" }, + ]) + }) + + expect(invalidateFoldersQueryMock).toHaveBeenCalled() }) it("skips optimistic UI when skipOptimisticUpdate is true", async () => { importFolderViaCoreMock.mockResolvedValue("core-job-1") + pollImportFolderJobMock.mockResolvedValue({ + kind: "import", + id: "core-job-1", + folderPath: "/media/movie/Movie A", + type: "movie", + status: "succeeded", + stage: "persist", + progress: 100, + createdAt: 0, + updatedAt: 0, + }) + showFolderViaCoreMock.mockResolvedValue({ + path: "/media/movie/Movie A", + status: "ok", + type: "movie-folder", + }) const folderPath = "/media/movie/Movie A" renderHandler() @@ -101,8 +176,15 @@ describe("ImportFolderEventHandler", () => { expect(importFolderViaCoreMock).toHaveBeenCalled() }) + await vi.waitFor(() => { + expect(showFolderViaCoreMock).toHaveBeenCalled() + }) + const state = useUIMediaFolderStore.getState() - expect(state.folders).toEqual([]) + // No optimistic insert before import; final showFolder still upserts. + expect(state.folders).toEqual([ + { path: folderPath, status: "ok", type: "movie-folder" }, + ]) expect(state.selectedFolder).toBe("") }) @@ -125,4 +207,37 @@ describe("ImportFolderEventHandler", () => { expect(useUIMediaFolderStore.getState().folders[0]?.status).toBe("error_loading_metadata") }) }) + + it("marks folder as error when import job fails", async () => { + importFolderViaCoreMock.mockResolvedValue("core-job-1") + pollImportFolderJobMock.mockResolvedValue({ + kind: "import", + id: "core-job-1", + folderPath: "/media/tvshow/Show A", + type: "tvshow", + status: "failed", + stage: "recognize", + progress: 40, + error: "Error Reason: tmdb down", + createdAt: 0, + updatedAt: 0, + }) + const folderPath = "/media/tvshow/Show A" + + renderHandler() + + document.dispatchEvent( + new CustomEvent(UI_ImportFolderEvent, { + detail: { + folderPathInPlatformFormat: folderPath, + type: "tvshow", + } satisfies OnMediaFolderImportedEventData, + }), + ) + + await vi.waitFor(() => { + expect(useUIMediaFolderStore.getState().folders[0]?.status).toBe("error_loading_metadata") + }) + expect(showFolderViaCoreMock).not.toHaveBeenCalled() + }) }) diff --git a/apps/ui/src/components/eventlisteners/ImportFolderEventHandler.tsx b/apps/ui/src/components/eventlisteners/ImportFolderEventHandler.tsx index f770a347..8f384dc9 100644 --- a/apps/ui/src/components/eventlisteners/ImportFolderEventHandler.tsx +++ b/apps/ui/src/components/eventlisteners/ImportFolderEventHandler.tsx @@ -1,15 +1,24 @@ import { useRef } from "react" import { useMount, useUnmount } from "react-use" +import { useQueryClient } from "@tanstack/react-query" import debug from "debug" import { toast } from "sonner" import { useImportFolderMutation } from "@/hooks/folders/useImportFolderMutation" -import { folderTypeToMediaType } from "@/lib/importLibraryV3" +import { invalidateFoldersQuery } from "@/hooks/folders" +import { folderTypeToMediaType } from "@/lib/importLibrary" import { persistHarmonyOSFileAccess } from "@/lib/persistHarmonyOSFileAccess" +import { pollImportFolderJob } from "@/lib/pollImportFolderJob" +import { showFolderViaCore } from "@/api/showFolder" +import { + mediaMetadataQueryKey, + normalizeMediaFolderPathForQuery, +} from "@/lib/mediaMetadataQueryKeys" import { nextTraceId } from "@/lib/utils" import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" import { UI_ImportFolderEvent, type OnMediaFolderImportedEventData } from "@/types/eventTypes" export function ImportFolderEventHandler() { + const queryClient = useQueryClient() const upsertFolder = useUIMediaFolderStore((s) => s.upsertFolder) const setSelectedFolder = useUIMediaFolderStore((s) => s.setSelectedFolder) const importFolderMutation = useImportFolderMutation() @@ -39,6 +48,34 @@ export function ImportFolderEventHandler() { traceId, }) console.log(`[${traceId}] import-folder: started job`, { jobId }) + + // Once Core has accepted the job, refresh get-folders so persisted paths + // appear even before recognition finishes (config stage writes smm.json). + invalidateFoldersQuery(queryClient) + + const finalJob = await pollImportFolderJob(jobId, (job) => { + if (job.stage === "config" || job.progress > 0) { + invalidateFoldersQuery(queryClient) + } + }) + + if (finalJob.status !== "succeeded") { + throw new Error(finalJob.error ?? "Import folder failed") + } + + const show = await showFolderViaCore(folderPathInPlatformFormat) + upsertFolder({ + path: show.path, + status: show.status, + ...(show.type !== undefined ? { type: show.type } : { type: mediaType }), + }) + invalidateFoldersQuery(queryClient) + void queryClient.invalidateQueries({ + queryKey: mediaMetadataQueryKey( + normalizeMediaFolderPathForQuery(folderPathInPlatformFormat), + ), + }) + console.log(`[${traceId}] import-folder: succeeded`, { jobId, status: show.status }) } catch (error) { console.error(`[${traceId}] import-folder: failed`, error) upsertFolder({ diff --git a/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.test.ts b/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.test.ts index 9430a5d6..2a7ee6d7 100644 --- a/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.test.ts +++ b/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.test.ts @@ -29,8 +29,8 @@ const { refreshUserConfigMock } = vi.hoisted(() => ({ refreshUserConfigMock: vi.fn().mockResolvedValue(undefined), })) -const { invalidateFoldersQueryIfV3Mock } = vi.hoisted(() => ({ - invalidateFoldersQueryIfV3Mock: vi.fn(), +const { invalidateFoldersQueryMock } = vi.hoisted(() => ({ + invalidateFoldersQueryMock: vi.fn(), })) const { addJobMock, updateJobMock } = vi.hoisted(() => ({ @@ -50,8 +50,8 @@ vi.mock("@/api/getJob", () => ({ getJobViaCore: getJobViaCoreMock, })) -vi.mock("@/lib/importLibraryV3", async (importOriginal) => { - const actual = await importOriginal() +vi.mock("@/lib/importLibrary", async (importOriginal) => { + const actual = await importOriginal() return { ...actual, pollImportLibraryJob: pollImportLibraryJobMock, @@ -65,7 +65,7 @@ vi.mock("@/hooks/userConfig", () => ({ })) vi.mock("@/hooks/folders", () => ({ - invalidateFoldersQueryIfV3: invalidateFoldersQueryIfV3Mock, + invalidateFoldersQuery: invalidateFoldersQueryMock, })) vi.mock("@/hooks/useJobManager", () => ({ @@ -127,7 +127,7 @@ describe("MediaLibraryImportedEventHandler", () => { waitForLibraryFoldersRegisteredMock.mockReset() refreshUserConfigMock.mockReset() refreshUserConfigMock.mockResolvedValue(undefined) - invalidateFoldersQueryIfV3Mock.mockReset() + invalidateFoldersQueryMock.mockReset() persistHarmonyOSFileAccessMock.mockReset() persistHarmonyOSFileAccessMock.mockResolvedValue(undefined) addJobMock.mockReset() @@ -185,7 +185,7 @@ describe("MediaLibraryImportedEventHandler", () => { expect(persistHarmonyOSFileAccessMock).toHaveBeenCalledWith([libraryPath]) expect(waitForLibraryFoldersRegisteredMock).toHaveBeenCalledWith("core-job-1", { traceId: "test-trace" }) expect(refreshUserConfigMock).toHaveBeenCalled() - expect(invalidateFoldersQueryIfV3Mock).toHaveBeenCalledWith(queryClient) + expect(invalidateFoldersQueryMock).toHaveBeenCalledWith(queryClient) expect(getJobViaCoreMock).toHaveBeenCalledWith("core-job-1") const refreshOrder = refreshUserConfigMock.mock.invocationCallOrder[0]! diff --git a/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.tsx b/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.tsx index bd747030..3452cea2 100644 --- a/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.tsx +++ b/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.tsx @@ -9,7 +9,7 @@ import { UI_MediaLibraryImportedEvent, type OnMediaLibraryImportedEventData } fr import debug from "debug" import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" import { useRefreshUserConfig } from "@/hooks/userConfig" -import { invalidateFoldersQueryIfV3 } from "@/hooks/folders" +import { invalidateFoldersQuery } from "@/hooks/folders" import { persistHarmonyOSFileAccess } from "@/lib/persistHarmonyOSFileAccess" import { toast } from "sonner" import { useTranslation } from "@/lib/i18n" @@ -20,7 +20,7 @@ import { pollImportLibraryJob, syncSidebarFromImportLibraryJob, waitForLibraryFoldersRegistered, -} from "@/lib/importLibraryV3" +} from "@/lib/importLibrary" export function MediaLibraryImportedEventHandler() { const { t: tComponents } = useTranslation("components") @@ -62,7 +62,7 @@ export function MediaLibraryImportedEventHandler() { const registeredPaths = await waitForLibraryFoldersRegistered(coreJobId, trace) await refreshUserConfig() - invalidateFoldersQueryIfV3(queryClient) + invalidateFoldersQuery(queryClient) importLibraryLog(trace, "sidebar folder list refreshed", { folderCount: registeredPaths.length, folderPaths: registeredPaths, @@ -94,7 +94,7 @@ export function MediaLibraryImportedEventHandler() { upsertFolder(folder) } await refreshUserConfig() - invalidateFoldersQueryIfV3(queryClient) + invalidateFoldersQuery(queryClient) importLibraryLog(trace, "sidebar folder status synced after import", { folderCount: importedFolders.length, }) diff --git a/apps/ui/src/components/eventlisteners/SocketIoUserConfigFolderRenamedEventListener.tsx b/apps/ui/src/components/eventlisteners/SocketIoUserConfigFolderRenamedEventListener.tsx index ea64a43a..afc7218d 100644 --- a/apps/ui/src/components/eventlisteners/SocketIoUserConfigFolderRenamedEventListener.tsx +++ b/apps/ui/src/components/eventlisteners/SocketIoUserConfigFolderRenamedEventListener.tsx @@ -3,7 +3,7 @@ import { useRef } from "react"; import { useLatest, useMount, useUnmount } from "react-use" import { useQueryClient } from "@tanstack/react-query" import { useConfig } from "@/hooks/userConfig"; -import { invalidateFoldersQueryIfV3 } from "@/hooks/folders"; +import { invalidateFoldersQuery } from "@/hooks/folders"; import { Path } from "@smm/utils/path"; import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata"; import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore"; @@ -45,7 +45,7 @@ export function SocketIoUserConfigFolderRenamedEventListener() { } : folder)) setSelectedFolder(to) - invalidateFoldersQueryIfV3(queryClient) + invalidateFoldersQuery(queryClient) fetchMediaMetadata({ path: Path.posix(to), traceId }) }; diff --git a/apps/ui/src/hooks/folders/index.ts b/apps/ui/src/hooks/folders/index.ts index 08ba32cf..f8d30e5c 100644 --- a/apps/ui/src/hooks/folders/index.ts +++ b/apps/ui/src/hooks/folders/index.ts @@ -1,5 +1,4 @@ export { useFoldersQuery } from './useFoldersQuery' -export { invalidateFoldersQueryIfV3 } from './invalidateFoldersQuery' +export { invalidateFoldersQuery } from './invalidateFoldersQuery' export { useUnimportFolderMutation } from './useUnimportFolderMutation' - diff --git a/apps/ui/src/hooks/folders/invalidateFoldersQuery.ts b/apps/ui/src/hooks/folders/invalidateFoldersQuery.ts index a05b174b..64163f33 100644 --- a/apps/ui/src/hooks/folders/invalidateFoldersQuery.ts +++ b/apps/ui/src/hooks/folders/invalidateFoldersQuery.ts @@ -1,7 +1,7 @@ import type { QueryClient } from '@tanstack/react-query' import { foldersQueryKey } from './foldersQueryKeys' -/** Invalidate the folders list query (v3 is always on). */ -export function invalidateFoldersQueryIfV3(queryClient: QueryClient): void { +/** Invalidate the folders list query (`useFoldersQuery`). */ +export function invalidateFoldersQuery(queryClient: QueryClient): void { void queryClient.invalidateQueries({ queryKey: foldersQueryKey }) } diff --git a/apps/ui/src/hooks/folders/useUnimportFolderMutation.ts b/apps/ui/src/hooks/folders/useUnimportFolderMutation.ts index 6cd91095..2bf51960 100644 --- a/apps/ui/src/hooks/folders/useUnimportFolderMutation.ts +++ b/apps/ui/src/hooks/folders/useUnimportFolderMutation.ts @@ -13,7 +13,7 @@ import { } from "@/lib/mediaMetadataQueryKeys" import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" import type { MediaMetadata } from "@smm/types" -import { invalidateFoldersQueryIfV3 } from "./invalidateFoldersQuery" +import { invalidateFoldersQuery } from "./invalidateFoldersQuery" function snapshotMetadata(queryClient: ReturnType, paths: string[]) { return paths @@ -91,7 +91,7 @@ export function useUnimportFolderMutation() { if (resp.error) throw new Error(resp.error) }), ) - invalidateFoldersQueryIfV3(queryClient) + invalidateFoldersQuery(queryClient) } catch (error) { if (dir) { queryClient.setQueryData(userConfigQueryKey(dir), prev) diff --git a/apps/ui/src/hooks/useRenameMediaFolderMutation.test.ts b/apps/ui/src/hooks/useRenameMediaFolderMutation.test.ts index 94442381..f2a22338 100644 --- a/apps/ui/src/hooks/useRenameMediaFolderMutation.test.ts +++ b/apps/ui/src/hooks/useRenameMediaFolderMutation.test.ts @@ -4,7 +4,7 @@ import { renderHook, waitFor } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { useRenameMediaFolderMutation } from "./useRenameMediaFolderMutation" import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" -import { renameFolderViaCore } from "@/api/renameFolderV3" +import { renameFolderViaCore } from "@/api/renameFolder" import { helloQueryKey } from "@/lib/appQueryKeys" import { mediaMetadataQueryKey, normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" import { userConfigQueryKey } from "@/lib/userConfigQueryKeys" @@ -13,11 +13,11 @@ vi.mock("@/lib/i18n", () => ({ useTranslation: () => ({ t: (key: string) => key }), })) vi.mock("sonner", () => ({ toast: { error: vi.fn() } })) -vi.mock("@/api/renameFolderV3", () => ({ +vi.mock("@/api/renameFolder", () => ({ renameFolderViaCore: vi.fn().mockResolvedValue(undefined), })) vi.mock("@/hooks/folders/invalidateFoldersQuery", () => ({ - invalidateFoldersQueryIfV3: vi.fn(), + invalidateFoldersQuery: vi.fn(), })) vi.mock("@/stores/uiMediaFolderStore") diff --git a/apps/ui/src/hooks/useRenameMediaFolderMutation.ts b/apps/ui/src/hooks/useRenameMediaFolderMutation.ts index 58307ef4..94f673f9 100644 --- a/apps/ui/src/hooks/useRenameMediaFolderMutation.ts +++ b/apps/ui/src/hooks/useRenameMediaFolderMutation.ts @@ -1,9 +1,9 @@ import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query' import { toast } from 'sonner' import { useTranslation } from '@/lib/i18n' -import { renameFolderViaCore } from '@/api/renameFolderV3' +import { renameFolderViaCore } from '@/api/renameFolder' import { refreshUiAfterFolderRename } from '@/lib/refreshUiAfterFolderRename' -import { invalidateFoldersQueryIfV3 } from '@/hooks/folders/invalidateFoldersQuery' +import { invalidateFoldersQuery } from '@/hooks/folders/invalidateFoldersQuery' import { useUIMediaFolderStore } from '@/stores/uiMediaFolderStore' import { dirname, join } from '@/lib/path' @@ -46,7 +46,7 @@ export function useRenameMediaFolderMutation( from: mediaFolderPath, to: newFolderPath, }) - invalidateFoldersQueryIfV3(queryClient) + invalidateFoldersQuery(queryClient) }, onError: (error, variables, context, mutation) => { userOnError?.(error, variables, context, mutation) diff --git a/apps/ui/src/hooks/useScrapeMutation.test.ts b/apps/ui/src/hooks/useScrapeMutation.test.ts index f92d261e..e858c90b 100644 --- a/apps/ui/src/hooks/useScrapeMutation.test.ts +++ b/apps/ui/src/hooks/useScrapeMutation.test.ts @@ -6,7 +6,7 @@ import { useScrapeMutation } from "./useScrapeMutation" const scrapeFolderViaCoreMock = vi.fn() -vi.mock("@/api/scrapeV3", () => ({ +vi.mock("@/api/scrape", () => ({ scrapeFolderViaCore: (...args: unknown[]) => scrapeFolderViaCoreMock(...args), })) diff --git a/apps/ui/src/hooks/useScrapeMutation.ts b/apps/ui/src/hooks/useScrapeMutation.ts index 2be93941..3d8fdd49 100644 --- a/apps/ui/src/hooks/useScrapeMutation.ts +++ b/apps/ui/src/hooks/useScrapeMutation.ts @@ -1,17 +1,17 @@ import { useMutation, type UseMutationOptions } from "@tanstack/react-query" import { scrapeFolderViaCore, - type ScrapeFolderV3Params, -} from "@/api/scrapeV3" + type ScrapeFolderParams, +} from "@/api/scrape" export function useScrapeMutation( options?: Omit< - UseMutationOptions, + UseMutationOptions, "mutationFn" >, ) { return useMutation({ ...options, - mutationFn: (params: ScrapeFolderV3Params) => scrapeFolderViaCore(params), + mutationFn: (params: ScrapeFolderParams) => scrapeFolderViaCore(params), }) } diff --git a/apps/ui/src/hooks/useSidebar.ts b/apps/ui/src/hooks/useSidebar.ts index 72a77997..834e375e 100644 --- a/apps/ui/src/hooks/useSidebar.ts +++ b/apps/ui/src/hooks/useSidebar.ts @@ -7,6 +7,8 @@ import { compareByDisplayName, type SortOrder, type FilterType } from "@/lib/sid import { openInFileManagerApi } from "@/api/openInFileManager" import { useFoldersQuery, useUnimportFolderMutation } from "@/hooks/folders" import { Path } from "@smm/utils/path" +import { useUIMediaFolderStoreState } from "@/stores/uiMediaFolderStore" +import { mergeFolderListPaths } from "@/lib/mergeFolderListPaths" export interface UseSidebarOptions { onDeleteSelected?: (paths: string[]) => void @@ -21,18 +23,27 @@ export function useSidebar({ searchQuery = "" }: UseSidebarOptions = {}) { const [filterType, setFilterType] = useState("all") const unimportFolderMutation = useUnimportFolderMutation() + const { folders: storeFolders } = useUIMediaFolderStoreState() const foldersQuery = useFoldersQuery(); + const folderPaths = useMemo( + () => mergeFolderListPaths( + foldersQuery.data, + storeFolders.map((folder) => folder.path), + ), + [foldersQuery.data, storeFolders], + ) + const metadataQueries = useQueries({ - queries: (foldersQuery.data ?? []).map((folderAbsPath) => ({ + queries: folderPaths.map((folderAbsPath) => ({ ...mediaMetadataReadQueryOptions(folderAbsPath) })), }) const folders = useMemo(() => { - let folderSearchFields = (foldersQuery.data ?? []).map((folderAbsPath) => { + let folderSearchFields = folderPaths.map((folderAbsPath) => { const m = metadataQueries.find((query) => query.data?.mediaFolderPath === Path.posix(folderAbsPath))?.data @@ -54,7 +65,7 @@ export function useSidebar({ searchQuery = "" }: UseSidebarOptions = {}) { } return folderSearchFields.map((folder) => folder.path) - }, [foldersQuery.data, metadataQueries, sortOrder, filterType, searchQuery]) + }, [folderPaths, metadataQueries, sortOrder, filterType, searchQuery]) const handleOpenInExplorer = useCallback(async (path: string) => { try { diff --git a/apps/ui/src/hooks/useTvShowPanel.ts b/apps/ui/src/hooks/useTvShowPanel.ts index b036f9d8..d128c6e2 100644 --- a/apps/ui/src/hooks/useTvShowPanel.ts +++ b/apps/ui/src/hooks/useTvShowPanel.ts @@ -3,8 +3,13 @@ import { useMemo } from "react"; import { useMediaFolderFilesQuery } from "./useMediaFolderFilesQuery"; import { useMediaMetadataQuery } from "./mediaMetadata"; import { findFilesByExtensions } from "@/lib/music"; -import { extensions, imageFileExtensions, subtitleFileExtensions } from "@smm/types/mediaFileExtensions"; -import { basename, extname } from "@/lib/path"; +import { extensions } from "@smm/types/mediaFileExtensions"; +import { basename } from "@/lib/path"; +import { + findNfos, + findSubtitles, + findThumbnails, +} from "@/lib/tvShowEpisodeAssociatedFiles"; import type { MediaMetadata } from "@smm/types/types"; import type { Plan } from "@/api/getPlans"; @@ -40,24 +45,6 @@ function findMetadataFiles(metadata: MediaMetadata, files: string[]) { } } -function findThumbnails(files: string[], videoFile: string): string[] { - const videoFileExt = extname(videoFile) - const possibleThumbnailFilePaths = imageFileExtensions.map(ext => `${videoFile.replace(videoFileExt, ext)}`) - return files.filter(file => possibleThumbnailFilePaths.includes(file)) -} - -function findSubtitles(files: string[], videoFile: string): string[] { - const videoFileExt = extname(videoFile) - const possibleSubtitleFilePaths = subtitleFileExtensions.map(ext => `${videoFile.replace(videoFileExt, ext)}`) - return files.filter(file => possibleSubtitleFilePaths.includes(file)) -} - -function findNfos(files: string[], videoFile: string): string[] { - const videoFileExt = extname(videoFile) - const nfoFilePath = `${videoFile.replace(videoFileExt, '.nfo')}` - return files.filter(file => file === nfoFilePath) -} - export function useTvShowPanel(folderPath: string | undefined, plan: Plan | undefined) { if (folderPath === undefined) { diff --git a/apps/ui/src/hooks/userConfig/useAddMediaFolderMutation.ts b/apps/ui/src/hooks/userConfig/useAddMediaFolderMutation.ts index 9e0cadd5..1afb2266 100644 --- a/apps/ui/src/hooks/userConfig/useAddMediaFolderMutation.ts +++ b/apps/ui/src/hooks/userConfig/useAddMediaFolderMutation.ts @@ -6,7 +6,7 @@ import { defaultUserConfig } from "@/api/readUserConfig" import { join } from "@/lib/path" import { helloQueryKey } from "@/lib/appQueryKeys" import { userConfigQueryKey } from "@/lib/userConfigQueryKeys" -import { invalidateFoldersQueryIfV3 } from "@/hooks/folders" +import { invalidateFoldersQuery } from "@/hooks/folders" export function useAddMediaFolderMutation() { const queryClient = useQueryClient() @@ -42,7 +42,7 @@ export function useAddMediaFolderMutation() { queryClient.setQueryData(userConfigQueryKey(dir), config) } if (foldersChanged) { - invalidateFoldersQueryIfV3(queryClient) + invalidateFoldersQuery(queryClient) } }, }) diff --git a/apps/ui/src/hooks/userConfig/useSaveUserConfigMutation.ts b/apps/ui/src/hooks/userConfig/useSaveUserConfigMutation.ts index 41546331..60462848 100644 --- a/apps/ui/src/hooks/userConfig/useSaveUserConfigMutation.ts +++ b/apps/ui/src/hooks/userConfig/useSaveUserConfigMutation.ts @@ -7,7 +7,7 @@ import { changeLanguage } from "@/lib/i18n" import { join } from "@/lib/path" import { helloQueryKey } from "@/lib/appQueryKeys" import { userConfigQueryKey } from "@/lib/userConfigQueryKeys" -import { invalidateFoldersQueryIfV3 } from "@/hooks/folders" +import { invalidateFoldersQuery } from "@/hooks/folders" export function useSaveUserConfigMutation() { const queryClient = useQueryClient() @@ -41,7 +41,7 @@ export function useSaveUserConfigMutation() { prevFolders.length !== config.folders.length || prevFolders.some((p, i) => p !== config.folders[i]) if (foldersChanged) { - invalidateFoldersQueryIfV3(queryClient) + invalidateFoldersQuery(queryClient) } }, }) diff --git a/apps/ui/src/lib/importLibraryV3.test.ts b/apps/ui/src/lib/importLibrary.test.ts similarity index 98% rename from apps/ui/src/lib/importLibraryV3.test.ts rename to apps/ui/src/lib/importLibrary.test.ts index 826a63ce..441d747b 100644 --- a/apps/ui/src/lib/importLibraryV3.test.ts +++ b/apps/ui/src/lib/importLibrary.test.ts @@ -16,7 +16,7 @@ vi.mock('@/api/getFolders', () => ({ import { importLibraryTaskStatusToUiStatus, waitForLibraryFoldersRegistered, -} from './importLibraryV3' +} from './importLibrary' describe('importLibraryTaskStatusToUiStatus', () => { it('maps task status to sidebar folder status', () => { diff --git a/apps/ui/src/lib/importLibraryV3.ts b/apps/ui/src/lib/importLibrary.ts similarity index 100% rename from apps/ui/src/lib/importLibraryV3.ts rename to apps/ui/src/lib/importLibrary.ts diff --git a/apps/ui/src/lib/mergeFolderListPaths.test.ts b/apps/ui/src/lib/mergeFolderListPaths.test.ts new file mode 100644 index 00000000..f7dffde5 --- /dev/null +++ b/apps/ui/src/lib/mergeFolderListPaths.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest" +import { mergeFolderListPaths } from "./mergeFolderListPaths" + +describe("mergeFolderListPaths", () => { + it("returns query paths when store is empty", () => { + expect(mergeFolderListPaths(["/a", "/b"], [])).toEqual(["/a", "/b"]) + }) + + it("includes store-only optimistic paths after query paths", () => { + expect(mergeFolderListPaths(["/a"], ["/b"])).toEqual(["/a", "/b"]) + }) + + it("dedupes by POSIX path and prefers the query path string", () => { + expect( + mergeFolderListPaths( + ["/media/Show"], + ["/media/Show", "/media/Other"], + ), + ).toEqual(["/media/Show", "/media/Other"]) + }) + + it("handles undefined query data", () => { + expect(mergeFolderListPaths(undefined, ["/a"])).toEqual(["/a"]) + }) +}) diff --git a/apps/ui/src/lib/mergeFolderListPaths.ts b/apps/ui/src/lib/mergeFolderListPaths.ts new file mode 100644 index 00000000..9c7a8e76 --- /dev/null +++ b/apps/ui/src/lib/mergeFolderListPaths.ts @@ -0,0 +1,30 @@ +import { Path } from "@smm/utils/path" + +/** + * Union of persisted folder paths (`get-folders`) and optimistic UI store paths + * (e.g. folders upserted while an import job is still running). Dedupes by POSIX path; + * prefers the query path string when both refer to the same folder. + */ +export function mergeFolderListPaths( + queryPaths: string[] | undefined, + storePaths: string[], +): string[] { + const merged: string[] = [] + const seen = new Set() + + for (const path of queryPaths ?? []) { + const key = Path.posix(path) + if (seen.has(key)) continue + seen.add(key) + merged.push(path) + } + + for (const path of storePaths) { + const key = Path.posix(path) + if (seen.has(key)) continue + seen.add(key) + merged.push(path) + } + + return merged +} diff --git a/apps/ui/src/lib/pollImportFolderJob.ts b/apps/ui/src/lib/pollImportFolderJob.ts new file mode 100644 index 00000000..338c1922 --- /dev/null +++ b/apps/ui/src/lib/pollImportFolderJob.ts @@ -0,0 +1,31 @@ +import { getJobViaCore, type Job, type JobStatus } from "@/api/getJob" +import { isJobTerminalStatus } from "@/hooks/useJobQuery" + +const POLL_INTERVAL_MS = 1000 + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export type ImportFolderJob = Extract + +/** + * Poll Core `get-job` until a single-folder import job reaches a terminal status. + */ +export async function pollImportFolderJob( + jobId: string, + onUpdate?: (job: ImportFolderJob) => void, + signal?: AbortSignal, +): Promise { + for (;;) { + const job = await getJobViaCore(jobId, signal) + if (job.kind !== "import") { + throw new Error(`Error Reason: unexpected job kind: ${job.kind}`) + } + onUpdate?.(job) + if (isJobTerminalStatus(job.status as JobStatus)) { + return job + } + await sleep(POLL_INTERVAL_MS) + } +} diff --git a/apps/ui/src/lib/tvShowEpisodeAssociatedFiles.test.ts b/apps/ui/src/lib/tvShowEpisodeAssociatedFiles.test.ts new file mode 100644 index 00000000..3b378078 --- /dev/null +++ b/apps/ui/src/lib/tvShowEpisodeAssociatedFiles.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { findSubtitles } from "./tvShowEpisodeAssociatedFiles"; + +describe("findSubtitles", () => { + const video = "/media/Show/S01E01.mkv"; + + it("matches exact stem subtitle files", () => { + const files = ["/media/Show/S01E01.ass", "/media/Show/S01E02.ass"]; + expect(findSubtitles(files, video)).toEqual(["/media/Show/S01E01.ass"]); + }); + + it("matches language-tagged subtitle files (e.g. S01E01.sc.ass)", () => { + const files = [ + "/media/Show/S01E01.sc.ass", + "/media/Show/S01E01.tc.ass", + "/media/Show/S01E02.sc.ass", + ]; + expect(findSubtitles(files, video)).toEqual([ + "/media/Show/S01E01.sc.ass", + "/media/Show/S01E01.tc.ass", + ]); + }); +}); diff --git a/apps/ui/src/lib/tvShowEpisodeAssociatedFiles.ts b/apps/ui/src/lib/tvShowEpisodeAssociatedFiles.ts new file mode 100644 index 00000000..59e6cbe2 --- /dev/null +++ b/apps/ui/src/lib/tvShowEpisodeAssociatedFiles.ts @@ -0,0 +1,29 @@ +import { findAssociatedFiles } from "@smm/core/pipeline/findAssociatedFiles"; +import { imageFileExtensions, subtitleFileExtensions } from "@smm/types/mediaFileExtensions"; +import { extname } from "@/lib/path"; + +/** Exact stem match: S01E01.mkv → S01E01.jpg */ +export function findThumbnails(files: string[], videoFile: string): string[] { + const videoFileExt = extname(videoFile); + const possibleThumbnailFilePaths = imageFileExtensions.map( + (ext) => `${videoFile.replace(videoFileExt, ext)}`, + ); + return files.filter((file) => possibleThumbnailFilePaths.includes(file)); +} + +/** + * Subtitles for a video: exact stem (S01E01.ass) or language-tagged + * (S01E01.sc.ass / S01E01.tc.ass). + */ +export function findSubtitles(files: string[], videoFile: string): string[] { + return findAssociatedFiles("", files, videoFile).filter((file) => + subtitleFileExtensions.some((ext) => file.endsWith(ext)), + ); +} + +/** Exact stem match: S01E01.mkv → S01E01.nfo */ +export function findNfos(files: string[], videoFile: string): string[] { + const videoFileExt = extname(videoFile); + const nfoFilePath = `${videoFile.replace(videoFileExt, ".nfo")}`; + return files.filter((file) => file === nfoFilePath); +} diff --git a/ci/run-e2e-test-lib.ts b/ci/run-e2e-test-lib.ts index 19ccea96..61552b47 100644 --- a/ci/run-e2e-test-lib.ts +++ b/ci/run-e2e-test-lib.ts @@ -54,13 +54,6 @@ export function assignE2eLocalPortEnv(env: Record): void { } } -/** Opt-in UI v3 for e2e (`localStorage smm.v3.enabled`). Production default is on; e2e still injects when E2E_SMM_V3=true. */ -function assignE2eSmmV3Env(env: Record): void { - if (process.env.E2E_SMM_V3 === 'true') { - env.E2E_SMM_V3 = 'true'; - } -} - function parsePlatform(value: string): Platform { if (!PLATFORMS.has(value as Platform)) { throw new Error( @@ -211,7 +204,6 @@ export function buildDesktopConfig(specs: string[]): CicdConfig { if (process.env.EXTERNAL_CONFIG_FILE_URL) { env.EXTERNAL_CONFIG_FILE_URL = process.env.EXTERNAL_CONFIG_FILE_URL; } - assignE2eSmmV3Env(env); assignE2eLocalPortEnv(env); return { @@ -260,7 +252,6 @@ export function buildOhosConfig(specs: string[]): CicdConfig { if (process.env.HDC_PORT_FORWARD_ENABLED) { env.HDC_PORT_FORWARD_ENABLED = process.env.HDC_PORT_FORWARD_ENABLED; } - assignE2eSmmV3Env(env); return { name: 'smm-e2e-ohos', @@ -300,7 +291,6 @@ export function buildElectronConfig(specs: string[]): CicdConfig { if (process.env.EXTERNAL_CONFIG_FILE_URL) { env.EXTERNAL_CONFIG_FILE_URL = process.env.EXTERNAL_CONFIG_FILE_URL; } - assignE2eSmmV3Env(env); return { name: 'smm-e2e-electron', @@ -376,7 +366,6 @@ export function buildDockerConfig(specs: string[]): CicdConfig { if (tvdbHttpProxy) { env.TVDB_HTTP_PROXY = tvdbHttpProxy; } - assignE2eSmmV3Env(env); return { name: 'smm-e2e-docker', diff --git a/ci/run-e2e-test.test.ts b/ci/run-e2e-test.test.ts index 9c740d8f..123fcc1f 100644 --- a/ci/run-e2e-test.test.ts +++ b/ci/run-e2e-test.test.ts @@ -113,30 +113,7 @@ describe('run-e2e-test docker platform', () => { } }); - test('buildConfig forwards E2E_SMM_V3=true into cicd env', () => { - const prev = process.env.E2E_SMM_V3; - process.env.E2E_SMM_V3 = 'true'; - try { - expect(buildConfig('desktop', ['common/tv/Scrape.e2e.ts']).env.E2E_SMM_V3).toBe('true'); - expect(buildConfig('docker', ['common/tv/Scrape.e2e.ts']).env.E2E_SMM_V3).toBe('true'); - expect(buildConfig('electron', ['common/tv/Scrape.e2e.ts']).env.E2E_SMM_V3).toBe('true'); - expect(buildConfig('ohos', ['common/tv/Scrape.e2e.ts']).env.E2E_SMM_V3).toBe('true'); - } finally { - if (prev === undefined) delete process.env.E2E_SMM_V3; - else process.env.E2E_SMM_V3 = prev; - } - }); - test('buildConfig omits E2E_SMM_V3 when unset', () => { - const prev = process.env.E2E_SMM_V3; - delete process.env.E2E_SMM_V3; - try { - expect(buildConfig('desktop', ['common/tv/Scrape.e2e.ts']).env.E2E_SMM_V3).toBeUndefined(); - } finally { - if (prev === undefined) delete process.env.E2E_SMM_V3; - else process.env.E2E_SMM_V3 = prev; - } - }); test('buildConfig desktop forwards UI_PORT and CLI_PORT from process.env', () => { const prevUi = process.env.UI_PORT; diff --git a/docs/api/index.md b/docs/api/index.md index d7efcdb8..09584b9e 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -48,7 +48,7 @@ CLI also sweeps `{userDataDir}/temp/ytdlp-cookies-*.txt` on startup (fallback wh ## GetFolders Source Code: apps/cli/src/route/GetFolders.ts -HTTP: `POST /api/get-folders` — returns imported media folder paths via Layer 2 `Core.getFolders()` (reads `userDataDir/smm.json`). Request body: `{}` (optional). Response: `{ data: { folders: string[] } }` or `{ error }`. Used by UI `useFoldersQuery` when `localStorage["smm.v3.enabled"] === "true"`. CLI equivalent: `smm list`. +HTTP: `POST /api/get-folders` — returns imported media folder paths via Layer 2 `Core.getFolders()` (reads `userDataDir/smm.json`). Request body: `{}` (optional). Response: `{ data: { folders: string[] } }` or `{ error }`. Used by UI `useFoldersQuery`. CLI equivalent: `smm list`. ## ImportFolder Source Code: apps/cli/src/route/ImportFolder.ts @@ -61,7 +61,7 @@ HTTP: `POST /api/get-job` — returns an in-memory import job from `Core.getJob( ## SearchInTmdb / GetMovieInTmdb / GetTvShowInTmdb Source Code: apps/cli/src/route/Tmdb.ts -1:1 Internal HTTP for Core TMDB methods. Response `{ data }` or `{ error }` (HTTP 200). Optional `language` / `host` / `password` / `proxy` override `userConfig.tmdb`. Used by Web UI when `localStorage["smm.v3.enabled"] === "true"` and by in-app AI tools. MCP / server-side chat inject the same Core methods in-process. +1:1 Internal HTTP for Core TMDB methods. Response `{ data }` or `{ error }` (HTTP 200). Optional `language` / `host` / `password` / `proxy` override `userConfig.tmdb`. Used by Web UI and by in-app AI tools. MCP / server-side chat inject the same Core methods in-process. - `POST /api/search-in-tmdb` → `Core.searchInTmdb`. Body: `{ keyword: string, type: "tv" | "movie", language?, host?, password?, proxy? }`. - `POST /api/get-movie-in-tmdb` → `Core.getMovieInTmdb`. Body: `{ id: number, language?, host?, password?, proxy? }`. @@ -70,14 +70,14 @@ Source Code: apps/cli/src/route/Tmdb.ts ## RecognizeFolder Source Code: apps/cli/src/route/RecognizeFolder.ts -1:1 Internal HTTP for `Core.recognizeFolder`. Assigns TMDB/TVDB TV show or movie metadata to an imported folder; sets `mediaFiles: []`. Response `{ data: { path } }` or `{ error }` (HTTP 200). Used by Web UI when the user selects a search result (`useSelectTvShowForFolderMutation` / `useSelectMovieForFolderMutation`) and `localStorage["smm.v3.enabled"] === "true"`. CLI equivalent: `smm recognize --db tmdb|tvdb --id `. +1:1 Internal HTTP for `Core.recognizeFolder`. Assigns TMDB/TVDB TV show or movie metadata to an imported folder; sets `mediaFiles: []`. Response `{ data: { path } }` or `{ error }` (HTTP 200). Used by Web UI when the user selects a search result (`useSelectTvShowForFolderMutation` / `useSelectMovieForFolderMutation`). CLI equivalent: `smm recognize --db tmdb|tvdb --id `. - `POST /api/recognize-folder` → `Core.recognizeFolder`. Body: `{ path: string, db: "tmdb" | "tvdb", id: string }`. ## SearchInTvdb / GetMovieInTvdb / GetTvShowInTvdb / GetTvdbLanguages Source Code: apps/cli/src/route/Tvdb.ts -1:1 Internal HTTP for Core TVDB methods. Response `{ data }` or `{ error }` (HTTP 200). Optional `language` (ISO 639-3) / `host` / `password` / `proxy` override `userConfig.tvdb`. TVDB custom hosts authenticate via an in-process JWT login exchange (`POST /login`). Used by Web UI when `localStorage["smm.v3.enabled"] === "true"` and by in-app AI tools. MCP / server-side chat inject the same Core methods in-process. +1:1 Internal HTTP for Core TVDB methods. Response `{ data }` or `{ error }` (HTTP 200). Optional `language` (ISO 639-3) / `host` / `password` / `proxy` override `userConfig.tvdb`. TVDB custom hosts authenticate via an in-process JWT login exchange (`POST /login`). Used by Web UI and by in-app AI tools. MCP / server-side chat inject the same Core methods in-process. - `POST /api/search-in-tvdb` → `Core.searchInTvdb`. Body: `{ keyword: string, type: "series" | "movie", language?, host?, password?, proxy? }`. - `POST /api/get-movie-in-tvdb` → `Core.getMovieInTvdb`. Body: `{ id: number, language?, host?, password?, proxy? }`. @@ -94,11 +94,11 @@ HTTP: `POST /api/folder-metadata` — MediaMetadata for an imported folder via ` ## UnimportFolder Source Code: apps/cli/src/route/UnimportFolder.ts -HTTP: `POST /api/unimport-folder` — removes an imported media folder from `userDataDir/smm.json` and deletes its metadata cache via Layer 2 `Core.unimportFolder(path)`. Request body: `{ path: string }`. Response: `{ data: { path } }` or `{ error }`. Idempotent when the path is not in the config. Used by UI delete (context menu, Delete key, multi-select) when `localStorage["smm.v3.enabled"] === "true"`. CLI equivalent: `smm rm`. +HTTP: `POST /api/unimport-folder` — removes an imported media folder from `userDataDir/smm.json` and deletes its metadata cache via Layer 2 `Core.unimportFolder(path)`. Request body: `{ path: string }`. Response: `{ data: { path } }` or `{ error }`. Idempotent when the path is not in the config. Used by UI delete (context menu, Delete key, multi-select). CLI equivalent: `smm rm`. -## RenameFolder (v3) -Source Code: apps/cli/src/route/RenameFolderV3.ts -HTTP: `POST /api/rename-folder` — renames a managed media folder via Layer 2 `Core.renameFolder({ from, to })` (metadata cache + `UserConfig.folders` + on-disk rename). Request body: `{ from: string, to: string }`. Response: `{ data: { from, to } }` or `{ error }`. Broadcasts the same folder-renamed / userConfigUpdated socket events as legacy `POST /api/renameFolder`. Used by UI Sidebar rename when `localStorage["smm.v3.enabled"] === "true"`. +## RenameFolder +Source Code: apps/cli/src/route/RenameFolder.ts +HTTP: `POST /api/rename-folder` — renames a managed media folder via Layer 2 `Core.renameFolder({ from, to })` (metadata cache + `UserConfig.folders` + on-disk rename). Request body: `{ from: string, to: string }`. Response: `{ data: { from, to } }` or `{ error }`. Broadcasts folder-renamed / userConfigUpdated socket events. Used by UI Sidebar rename and in-app AI tools. ## CLI: recognize Source Code: apps/cli/src/cli/runCli.ts + apps/core Core.tryToRecognizeFolder / Core.recognizeFolder diff --git a/docs/dev/refactor-plan-problem-a-ui-core-duplication.md b/docs/dev/refactor-plan-problem-a-ui-core-duplication.md index e974f7f6..050a99a8 100644 --- a/docs/dev/refactor-plan-problem-a-ui-core-duplication.md +++ b/docs/dev/refactor-plan-problem-a-ui-core-duplication.md @@ -224,7 +224,7 @@ Phase 1 完成后,预览与执行已共用 pure 算法,但仍有 **双路径 **执行顺序:** -1. 确认 `isSmmV3Enabled()` 在生产/CI 恒为 true +1. Core HTTP / folders query 路径已恒开启(无 feature flag) 2. 删除 `handleStartLegacy` 等调用链 3. 删除 UI 副本文件 4. 收缩 `useInitializeImportedMediaFolder` 为调用 Core job API diff --git a/docs/dev/rename-episode-file.md b/docs/dev/rename-episode-file.md index b3359170..b51d901a 100644 --- a/docs/dev/rename-episode-file.md +++ b/docs/dev/rename-episode-file.md @@ -56,7 +56,7 @@ Core **must reject** requests where `from` is not the `absolutePath` of a TV epi | Piece | Location | Role today | |-------|----------|------------| | Context menu entry | `TvShowPanel` → `MediaFileTable` extra menu; also `TvShowEpisodeTable` | Opens rename dialog for an episode row | -| Flow hook | `apps/ui/src/hooks/useRenameVideoFileFlow.ts` | Shared hook today (also used by Movie) — **TV v3 path** should call `renameEpisodeFile`; movie stays on legacy | +| Flow hook | `apps/ui/src/hooks/useRenameVideoFileFlow.ts` | Shared hook today (also used by Movie) — **TV Core path** should call `renameEpisodeFile`; movie stays on legacy | | Associate expansion | `computeAssociatedFileRenames` in `apps/ui/src/components/episode-file.tsx` | Stem-based sibling renames | | HTTP | `POST /api/renameFiles` (`packages/core-routes`) | Generic batch rename + optional metadata update + broadcast | | Metadata helper | `updateMediaMetadataAfterRename` in `packages/core/mediaMetadata.ts` | Already shared — keep / call from Core | @@ -67,7 +67,7 @@ Problem: associate discovery and orchestration live in **Layer 1**. Electron / O 1. **Port** legacy **episode** rename orchestration into `apps/core` (FsPort + metadata), with episode identity checks. 2. Expose a **thin Internal HTTP** command that only validates and calls Core. -3. Point **TV** frontends (Web UI, Electron, OHOS — shared `apps/ui`) at that API under `smm.v3.enabled`. +3. Point **TV** frontends (Web UI, Electron, OHOS — shared `apps/ui`) at that API. 4. Expose the **same Core capability** to **CLI**, **MCP**, and **in-app AI tool** (shared schemas; confirmation for MCP/AI). 5. Leave movie rename, rule-based / AI **batch plans**, and **folder** rename out of this workstream. @@ -75,7 +75,7 @@ Problem: associate discovery and orchestration live in **Layer 1**. Electron / O 1. **Core** — `Core.renameEpisodeFile` with episode check + associate expansion + disk + metadata + shared rename preflight 2. **HTTP** — `POST /api/rename-episode-file` → Core; Socket/metadata broadcast parity with today’s `/api/renameFiles` -3. **UI v3 (TV only)** — episode context-menu confirm calls the new API; no client-side `computeAssociatedFileRenames` when v3 is on +3. **UI (TV only)** — episode context-menu confirm calls the new API; no client-side `computeAssociatedFileRenames` 4. **CLI** — `smm rename ` auto-dispatches `renameFolder` vs `renameEpisodeFile` (alias: `rename-episode-file`) 5. **MCP + AI tool** — shared `rename-episode-file` tool schemas; MCP handler + in-app assistant tool; **user confirmation** before disk write (mirror `rename-folder`) 6. **Cleanup** — optional: dual paths for TV; keep generic `/api/renameFiles` for movie / plan apply until those migrate @@ -87,7 +87,7 @@ Problem: associate discovery and orchestration live in **Layer 1**. Electron / O ``` Layer 1: Web UI / Electron / OHOS / CLI / in-app AI / MCP clients │ - │ POST /api/rename-episode-file (UI v3, in-app AI) + │ POST /api/rename-episode-file (UI, in-app AI) │ Core.renameEpisodeFile(...) (CLI direct; MCP via host) │ mediaMetadataUpdated (Socket.IO) — same as today ▼ @@ -103,7 +103,7 @@ Layer 2: apps/core updateMediaMetadataAfterRename + setMetadata ``` -Per [refactoring.md](../../refactoring.md): UI only collects intent (dialog relative path) and renders results; **no** associate math and **no** metadata rewrite in Layer 1 for the v3 path. CLI / MCP / AI must **not** reimplement associate expansion — they pass primary `from` / `to` only. +Per [refactoring.md](../../refactoring.md): UI only collects intent (dialog relative path) and renders results; **no** associate math and **no** metadata rewrite in Layer 1 for the Core path. CLI / MCP / AI must **not** reimplement associate expansion — they pass primary `from` / `to` only. ### 2.2 App Level Architecture @@ -115,7 +115,7 @@ Per [refactoring.md](../../refactoring.md): UI only collects intent (dialog rela | `updateMediaMetadataAfterRename` | Existing pure helper — Core must apply it after successful disk renames | | `POST /api/rename-episode-file` | Body → Core → `{ data }` / `{ error }`, HTTP 200 | | Broadcast | After success: `mediaMetadataUpdated` for the folder (parity with `/api/renameFiles`) | -| UI TV context menu | v3 ON → new API with `{ mediaFolder, from, to }` only; v3 OFF → legacy client expand + `/api/renameFiles` | +| UI TV context menu | always `{ mediaFolder, from, to }` only via new API | | CLI | `smm rename ` auto-dispatches folder vs episode; alias `rename-episode-file` | | MCP tool `rename-episode-file` | Same args as HTTP; **confirm** then Core (or HTTP); omit on hosts that cannot rename files | | In-app AI tool `rename-episode-file` | Same schemas as MCP; UI confirmation bridge then `POST /api/rename-episode-file` | @@ -135,8 +135,8 @@ Per [refactoring.md](../../refactoring.md): UI only collects intent (dialog rela | Folder type | **`tvshow-folder` only** | | Episode identity | `from` must equal a `mediaFiles[].absolutePath` that has `seasonNumber` and `episodeNumber` | | Movie | **Out of scope** — keep legacy `/api/renameFiles` | -| Feature flag | `smm.v3.enabled` — mirror scrape / rename-folder | -| Existing `/api/renameFiles` | Keep for movie, rule-based, AI plan apply, and v3-off TV path | +| Feature flag | none (Core HTTP always on) | +| Existing `/api/renameFiles` | Keep for movie, rule-based, AI plan apply | #### Prerequisites @@ -385,26 +385,20 @@ sequenceDiagram * **Then** response is `{ error: "Error Reason: File is not a linked episode: …" }` * **And** no files are renamed -### 4.3 v3 off keeps legacy path +### 4.3 TV episode rename uses Core HTTP -* **Given** `smm.v3.enabled` is false -* **When** the user uses TV context-menu Rename -* **Then** UI still expands associates and calls `POST /api/renameFiles` - -### 4.4 v3 on all TV frontends - -* **Given** Web, Electron, or OHOS with shared UI and v3 enabled +* **Given** Web, Electron, or OHOS with shared UI * **When** TV episode context-menu Rename confirms * **Then** only `POST /api/rename-episode-file` → Core runs (no UI `computeAssociatedFileRenames`) -### 4.5 Prerequisite / validation failure +### 4.4 Prerequisite / validation failure * **Given** an unmanaged path, movie folder, or `to` outside the media folder * **When** the client calls `POST /api/rename-episode-file` * **Then** response is `{ error: "Error Reason: …" }` * **And** no files are renamed -### 4.6 CLI rename (unified) +### 4.5 CLI rename (unified) * **Given** a managed TV folder with a linked episode file * **When** the operator runs `smm rename ` @@ -415,15 +409,15 @@ sequenceDiagram * **When** the operator runs `smm rename ` * **Then** Core renames the folder and rewrites metadata / user config -### 4.7 MCP / AI tool with confirmation +### 4.6 MCP / AI tool with confirmation * **Given** MCP or in-app AI invokes `rename-episode-file` * **When** the user **cancels** confirmation * **Then** no files are renamed and the tool reports cancelled * **When** the user **confirms** -* **Then** Core (or HTTP → Core) runs the same path as the UI v3 context menu +* **Then** Core (or HTTP → Core) runs the same path as the UI episode context menu -### 4.8 Boundary vs batch rename plan tools +### 4.7 Boundary vs batch rename plan tools * **Given** the assistant needs to rename many files under a plex/emby-style plan * **When** choosing a tool @@ -445,10 +439,10 @@ sequenceDiagram - **Core unit:** episode assert accepts linked `mediaFiles` entry and rejects unlinked paths / movie folders; stem associate expansion matches `computeAssociatedFileRenames` fixtures (`S01E01.en.srt` → new stem); rejects unmanaged / escape paths; preflight refuses dest-exists / missing source before any rename; metadata updated via `updateMediaMetadataAfterRename`; partial failure still writes metadata for succeeded pairs. - **HTTP route tests:** success returns `data.succeeded`; validation / not-episode / not-TV errors. -- **UI unit:** TV context-menu flow with v3 on posts `{ mediaFolder, from, to }` to `/api/rename-episode-file` only; v3 off keeps legacy; movie panel unchanged. +- **UI unit:** TV context-menu flow posts `{ mediaFolder, from, to }` to `/api/rename-episode-file` only; movie panel unchanged. - **CLI e2e / unit:** `smm rename` dispatches folder vs episode; reject subdirectory / unmanaged; episode success prints pairs; folder success updates `list` + metadata cache. - **MCP / AI tool unit:** shared schema; cancel confirmation → no Core call; confirm → Core/HTTP invoked with primary paths only (no client-side associate list). -- **E2E:** `TVShow-RenameEpisodeFile.e2e.ts` must pass with v3 on against Core path (Web / Electron / OHOS / Docker as already tagged). +- **E2E:** `TVShow-RenameEpisodeFile.e2e.ts` must pass against Core path (Web / Electron / OHOS / Docker as already tagged). ## 7. Compatibility notes @@ -456,6 +450,6 @@ sequenceDiagram - Do not conflate with `buildTvShowRenameListForPlan` (moves associates into season folders). - `metadata.files` is legacy; prefer disk `listFiles` inside Core for associate discovery. - Until movie / plan / AI **batch** flows migrate, `/api/rename-episode-file` and `/api/renameFiles` coexist; plan tools stay on `/api/renameFiles` apply. -- Feature flag: same `isSmmV3Enabled()` used by folder rename and scrape UI (UI context menu only; CLI/MCP always hit Core path once implemented). +- Feature flag: none — folder rename and scrape UI always use Core HTTP (UI context menu; CLI/MCP always hit Core path once implemented). - Legacy hook name `useRenameVideoFileFlow` may remain until refactored; the **Core/HTTP/CLI/MCP/AI contract** must still be `renameEpisodeFile` / `rename-episode-file`. - Shared Zod contracts live under `packages/core/types/ai-tools/renameEpisodeFile` so MCP and in-app AI cannot drift. diff --git a/docs/dev/tmdb.md b/docs/dev/tmdb.md index 1308e2c7..99dd6a80 100644 --- a/docs/dev/tmdb.md +++ b/docs/dev/tmdb.md @@ -186,13 +186,11 @@ CLI runner:`apps/cli/src/mcp/mcp.ts`(`searchInTmdb` / `getMovieInTmdb` / `ge --- -## Web UI(⏳ v3 迁移) - -Web UI 的改动需要使用 localStorage 开关 `smm.v3.enabled` 控制. +## Web UI 搜索入口:`MediaDatabaseSearchbox`(`TvShowPanelHeader` / `MovieHeaderV2`)。 -**目标路径**(与 Core v3 一致):UI 调用一对一 Internal HTTP,服务端再调对应 Core 方法。不经 `BrowserNetworkPort` / `POST /api/core/fetch`。 +**路径**(与 Core 一致):UI 调用一对一 Internal HTTP,服务端再调对应 Core 方法。不经 `BrowserNetworkPort` / `POST /api/core/fetch`。 ```mermaid flowchart LR diff --git a/docs/dev/tvdb.md b/docs/dev/tvdb.md index 0b9aa7a2..0b944559 100644 --- a/docs/dev/tvdb.md +++ b/docs/dev/tvdb.md @@ -7,9 +7,9 @@ SMM 通过 `apps/core` 统一访问 TVDB:**全部出站流量**经 `Core` + `N | CLI | ✅ | `smm tvdb search` → 进程内 `Core.searchInTvdb` | | AI Tool | ✅ | 应用内 Chat → 对应 HTTP API → Core;服务端 Chat 进程内注入 Core | | MCP Tool | ✅ | MCP 工具进程内调用 Core 同名方法(与 HTTP 路由同一 Core) | -| Web UI | ⏳ | `smm.v3.enabled`:对应 HTTP API → Core 同名方法 | +| Web UI | ✅ | 对应 HTTP API → Core 同名方法 | -Core 方法与 Internal HTTP **一对一**暴露。Web UI(v3)与应用内 AI 走这些 API 再进入 Core;MCP / 服务端 Chat 在进程内调用同一套 Core 方法。均不走 `POST /api/core/fetch`(那是 `BrowserNetworkPort` 的通用出站中继,见 [network-core.md](./network-core.md))。 +Core 方法与 Internal HTTP **一对一**暴露。Web UI 与应用内 AI 走这些 API 再进入 Core;MCP / 服务端 Chat 在进程内调用同一套 Core 方法。均不走 `POST /api/core/fetch`(那是 `BrowserNetworkPort` 的通用出站中继,见 [network-core.md](./network-core.md))。 | Core 方法 | HTTP | |-----------|------| @@ -206,13 +206,11 @@ CLI runner:`apps/cli/src/mcp/mcp.ts`(`searchInTvdb` / `getMovieInTvdb` / `ge --- -## Web UI(✅ v3 搜索) - -Web UI 的改动需要使用 localStorage 开关 `smm.v3.enabled` 控制. +## Web UI 搜索入口:`MediaDatabaseSearchbox`(`TvShowPanelHeader` / `MovieHeaderV2`)。 -**目标路径**(与 Core v3 一致):UI 调用一对一 Internal HTTP,服务端再调对应 Core 方法。不经 `BrowserNetworkPort` / `POST /api/core/fetch`。 +**路径**(与 Core 一致):UI 调用一对一 Internal HTTP,服务端再调对应 Core 方法。不经 `BrowserNetworkPort` / `POST /api/core/fetch`。 ```mermaid flowchart LR @@ -224,7 +222,7 @@ flowchart LR | 层 | 文件 / 接口 | |----|------| -| UI | `apps/ui/src/components/MediaDatabaseSearchbox.tsx` · `apps/ui/src/api/tvdbSearch.ts` · `apps/ui/src/api/tvdbV3.ts` | +| UI | `apps/ui/src/components/MediaDatabaseSearchbox.tsx` · `apps/ui/src/api/tvdbSearch.ts` · `apps/ui/src/api/tvdbHttp.ts` | | HTTP | `POST /api/search-in-tvdb` · `POST /api/get-movie-in-tvdb` · `POST /api/get-tvshow-in-tvdb` · `POST /api/get-tvdb-languages` | | Core | `searchInTvdb` · `getMovieInTvdb` · `getTvShowInTvdb` · `getTvdbLanguages` | | 出站 | `apps/cli/src/core/NodejsNetworkPort.ts` | diff --git a/docs/superpowers/plans/2026-08-17-display-folders-v3.md b/docs/superpowers/plans/2026-08-17-display-folders-v3.md deleted file mode 100644 index 00650415..00000000 --- a/docs/superpowers/plans/2026-08-17-display-folders-v3.md +++ /dev/null @@ -1,748 +0,0 @@ -# Display Folders V3 Implementation Plan - -> **Status:** Implemented (2026-08-17). All 6 tasks completed via subagent-driven-development on branch `core-layer`. Commits: `0ea4f5e1` (Task 1) through `6d28212b` (Task 5). Verification: focused tests all PASS. Do not re-run. - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Wire `POST /api/get-folders` → `Core.getFolders()` and switch Sidebar path list to `useFoldersQuery` when `localStorage["smm.v3.enabled"] === "true"`, keeping status/selection in Zustand and metadata queries unchanged. - -**Architecture:** Thin cli Hono route holds a lazy Core singleton (`NodejsFsAdapter`, `appDataDir: getUserDataDir()`). UI adds API client + TanStack Query; Sidebar merges query paths with Zustand status. Flag off = zero behavior change. - -**Tech Stack:** TypeScript, Hono (cli), vitest, React 19, TanStack Query, Zustand, `core-app` (`apps/core`). - -**Spec:** [docs/superpowers/specs/2026-08-17-display-folders-v3-design.md](../specs/2026-08-17-display-folders-v3-design.md) - -## Global Constraints - -- Feature flag: `localStorage.getItem("smm.v3.enabled") === "true"` only; default off. -- HTTP RPC style: success/failure both HTTP 200; errors use `error: "Error Reason: …"`. -- `Core` constructed with `appDataDir: getUserDataDir()` so `getFolders` reads the same `smm.json` as today’s UI. -- Do not enrich get-folders DTO; do not remove Zustand; do not migrate import pipeline. -- Package name for `apps/core` is `core-app` (not `core`). - ---- - -## File Structure - -``` -apps/cli/ - package.json # + dependency "core-app": "workspace:*" - tsconfig.json # + paths for core-app - vitest.config.ts # + alias core-app → ../core/src - server.ts # register handleGetFolders - src/ - core/getCore.ts # lazy Core singleton + resetCoreForTests - route/GetFolders.ts # POST /api/get-folders - route/GetFolders.test.ts - -apps/core/ - package.json # + "exports": { ".": "./src/index.ts" } - -apps/ui/ - src/lib/localStorages.ts # + isSmmV3Enabled getter (optional but preferred) - src/lib/isSmmV3Enabled.ts # OR dedicated helper if not on localStorages - src/api/getFolders.ts - src/hooks/folders/foldersQueryKeys.ts - src/hooks/folders/useFoldersQuery.ts - src/hooks/folders/useFoldersQuery.test.ts - src/lib/mergeFolderPathsWithUiStatus.ts - src/lib/mergeFolderPathsWithUiStatus.test.ts - src/components/v2/Sidebar.tsx # V3 path source branch - src/hooks/userConfig/useSaveUserConfigMutation.ts # invalidate when folders change - src/components/eventlisteners/SocketIoUserConfigFolderRenamedEventListener.tsx - -docs/api/index.md # document POST /api/get-folders -``` - ---- - -### Task 1: Wire `core-app` into cli + Core singleton - -**Files:** -- Modify: `apps/core/package.json` -- Modify: `apps/cli/package.json` -- Modify: `apps/cli/tsconfig.json` -- Modify: `apps/cli/vitest.config.ts` -- Create: `apps/cli/src/core/getCore.ts` - -**Interfaces:** -- Produces: `getCore(): Core`, `resetCoreForTests(): void` - -- [ ] **Step 1: Add exports to `apps/core/package.json`** - -Add: - -```json -"exports": { - ".": "./src/index.ts" -} -``` - -- [ ] **Step 2: Add workspace dependency in `apps/cli/package.json`** - -Under `"dependencies"`: - -```json -"core-app": "workspace:*" -``` - -Run from repo root: - -```bash -pnpm install --filter cli... -``` - -Expected: lockfile updates; `cli` can resolve `core-app`. - -- [ ] **Step 3: Add tsconfig + vitest aliases for `core-app`** - -In `apps/cli/tsconfig.json` `paths`: - -```json -"core-app": ["../core/src/index.ts"], -"core-app/*": ["../core/src/*"] -``` - -In `apps/cli/vitest.config.ts` `resolve.alias`: - -```ts -'core-app': resolve(__dirname, '../core/src/index.ts'), -``` - -(Keep existing `@core` alias — Core sources import `@core/path`.) - -- [ ] **Step 4: Create `apps/cli/src/core/getCore.ts`** - -```ts -import { - Core, - FetchNetworkAdapter, - NodejsFsAdapter, - NoopLoggerAdapter, -} from 'core-app' -import { getUserDataDir } from '@/utils/config' - -let instance: Core | undefined - -/** Lazy singleton. appDataDir = userDataDir so getFolders reads production smm.json. */ -export function getCore(): Core { - if (!instance) { - const userDataDir = getUserDataDir() - instance = new Core({ - fs: new NodejsFsAdapter(), - network: new FetchNetworkAdapter(), - logger: new NoopLoggerAdapter(), - appDataDir: userDataDir, - userDataDir, - }) - } - return instance -} - -/** Test-only: drop the singleton so env/dir changes take effect. */ -export function resetCoreForTests(): void { - instance = undefined -} -``` - -- [ ] **Step 5: Smoke-check TypeScript resolves Core** - -Run: - -```bash -pnpm --filter cli exec tsc --noEmit 2>&1 | head -40 -``` - -Expected: no errors about `core-app` / `getCore` (pre-existing unrelated errors OK if any; new file must typecheck). If `core-app` import fails, fix aliases / exports before continuing. - -- [ ] **Step 6: Commit** - -```bash -git add apps/core/package.json apps/cli/package.json apps/cli/tsconfig.json apps/cli/vitest.config.ts apps/cli/src/core/getCore.ts pnpm-lock.yaml -git commit -m "$(cat <<'EOF' -feat(cli): wire core-app and lazy Core singleton for getFolders - -EOF -)" -``` - ---- - -### Task 2: `POST /api/get-folders` route (TDD) - -**Files:** -- Create: `apps/cli/src/route/GetFolders.test.ts` -- Create: `apps/cli/src/route/GetFolders.ts` -- Modify: `apps/cli/server.ts` -- Modify: `docs/api/index.md` - -**Interfaces:** -- Consumes: `getCore()`, `resetCoreForTests()` -- Produces: `handleGetFolders(app: Hono): void` registering `POST /api/get-folders` - -- [ ] **Step 1: Write failing route tests** - -Create `apps/cli/src/route/GetFolders.test.ts`: - -```ts -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' -import { tmpdir } from 'os' -import { join } from 'path' -import { Hono } from 'hono' -import { handleGetFolders } from './GetFolders' -import { resetCoreForTests } from '../core/getCore' - -describe('POST /api/get-folders', () => { - let userDataDir: string - let prevUserDataDir: string | undefined - let app: Hono - - beforeEach(() => { - prevUserDataDir = process.env.USER_DATA_DIR - userDataDir = mkdtempSync(join(tmpdir(), 'smm-get-folders-')) - process.env.USER_DATA_DIR = userDataDir - resetCoreForTests() - app = new Hono() - handleGetFolders(app) - }) - - afterEach(() => { - resetCoreForTests() - if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR - else process.env.USER_DATA_DIR = prevUserDataDir - rmSync(userDataDir, { recursive: true, force: true }) - }) - - it('returns empty folders when smm.json is missing', async () => { - const res = await app.request('/api/get-folders', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }) - expect(res.status).toBe(200) - await expect(res.json()).resolves.toEqual({ data: { folders: [] } }) - }) - - it('returns folders from smm.json', async () => { - writeFileSync( - join(userDataDir, 'smm.json'), - JSON.stringify({ folders: ['/media/A', '/media/B'] }), - 'utf-8', - ) - resetCoreForTests() - const res = await app.request('/api/get-folders', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }) - expect(res.status).toBe(200) - await expect(res.json()).resolves.toEqual({ - data: { folders: ['/media/A', '/media/B'] }, - }) - }) -}) -``` - -- [ ] **Step 2: Run tests — expect FAIL (module missing)** - -```bash -pnpm --filter cli test -- src/route/GetFolders.test.ts -``` - -Expected: FAIL cannot find `./GetFolders` or `handleGetFolders`. - -- [ ] **Step 3: Implement `apps/cli/src/route/GetFolders.ts`** - -```ts -import type { Hono } from 'hono' -import { getCore } from '../core/getCore' -import { logger } from '../../lib/logger' - -export interface GetFoldersResponseBody { - data?: { folders: string[] } - error?: string -} - -export function handleGetFolders(app: Hono): void { - app.post('/api/get-folders', async (c) => { - try { - // Body optional; tolerate missing/invalid JSON - try { - await c.req.json() - } catch { - /* empty body OK */ - } - const folders = await getCore().getFolders() - const body: GetFoldersResponseBody = { data: { folders } } - return c.json(body, 200) - } catch (error) { - logger.error({ error }, '[POST /api/get-folders] route error') - const body: GetFoldersResponseBody = { - error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, - } - return c.json(body, 200) - } - }) -} -``` - -- [ ] **Step 4: Register in `apps/cli/server.ts`** - -Add import near other route imports: - -```ts -import { handleGetFolders } from './src/route/GetFolders' -``` - -In the route registration block (near `handlePlans(this.app)`): - -```ts -handleGetFolders(this.app) -``` - -- [ ] **Step 5: Run tests — expect PASS** - -```bash -pnpm --filter cli test -- src/route/GetFolders.test.ts -``` - -Expected: 2 passed. - -- [ ] **Step 6: Document in `docs/api/index.md`** - -Add a section (near Plans or SetWatchedFolder): - -```markdown -## GetFolders -Source Code: apps/cli/src/route/GetFolders.ts -HTTP: `POST /api/get-folders` — returns imported media folder paths via Layer 2 `Core.getFolders()` (reads `userDataDir/smm.json`). Request body: `{}` (optional). Response: `{ data: { folders: string[] } }` or `{ error }`. Used by UI `useFoldersQuery` when `localStorage["smm.v3.enabled"] === "true"`. -``` - -- [ ] **Step 7: Commit** - -```bash -git add apps/cli/src/route/GetFolders.ts apps/cli/src/route/GetFolders.test.ts apps/cli/server.ts docs/api/index.md -git commit -m "$(cat <<'EOF' -feat(cli): add POST /api/get-folders via Core.getFolders - -EOF -)" -``` - ---- - -### Task 3: UI flag + API client + `useFoldersQuery` - -**Files:** -- Modify: `apps/ui/src/lib/localStorages.ts` -- Create: `apps/ui/src/api/getFolders.ts` -- Create: `apps/ui/src/hooks/folders/foldersQueryKeys.ts` -- Create: `apps/ui/src/hooks/folders/useFoldersQuery.ts` -- Create: `apps/ui/src/hooks/folders/useFoldersQuery.test.ts` -- Create: `apps/ui/src/hooks/folders/index.ts` - -**Interfaces:** -- Produces: `isSmmV3Enabled(): boolean`, `getFolders(signal?)`, `FOLDERS_QUERY_KEY`, `useFoldersQuery()` - -- [ ] **Step 1: Add flag helper on `localStorages`** - -In `apps/ui/src/lib/localStorages.ts`, add constant + getter on the exported object: - -```ts -const STORAGE_KEY_SMM_V3_ENABLED = 'smm.v3.enabled' - -// inside localStorages object: -get isSmmV3Enabled(): boolean { - try { - return localStorage.getItem(STORAGE_KEY_SMM_V3_ENABLED) === 'true' - } catch { - return false - } -}, -``` - -Also export a named function for non-React call sites: - -```ts -export function isSmmV3Enabled(): boolean { - return localStorages.isSmmV3Enabled -} -``` - -- [ ] **Step 2: Create API client `apps/ui/src/api/getFolders.ts`** - -```ts -import { apiFetch } from '@/lib/apiFetch' - -export interface GetFoldersResponseBody { - data?: { folders: string[] } - error?: string -} - -export async function getFolders(signal?: AbortSignal): Promise { - const resp = await apiFetch('/api/get-folders', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - signal, - }) - - if (!resp.ok) { - throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`) - } - - return (await resp.json()) as GetFoldersResponseBody -} -``` - -- [ ] **Step 3: Create query keys + hook** - -`apps/ui/src/hooks/folders/foldersQueryKeys.ts`: - -```ts -export const FOLDERS_QUERY_ROOT = 'folders' as const -export const foldersQueryKey = [FOLDERS_QUERY_ROOT] as const -``` - -`apps/ui/src/hooks/folders/useFoldersQuery.ts`: - -```ts -import { useQuery } from '@tanstack/react-query' -import { getFolders } from '@/api/getFolders' -import { isSmmV3Enabled } from '@/lib/localStorages' -import { foldersQueryKey } from './foldersQueryKeys' - -export function useFoldersQuery() { - const enabled = isSmmV3Enabled() - return useQuery({ - queryKey: foldersQueryKey, - enabled, - queryFn: async (): Promise => { - const resp = await getFolders() - if (resp.error) throw new Error(resp.error) - return resp.data?.folders ?? [] - }, - }) -} -``` - -`apps/ui/src/hooks/folders/index.ts`: - -```ts -export { useFoldersQuery } from './useFoldersQuery' -export { foldersQueryKey, FOLDERS_QUERY_ROOT } from './foldersQueryKeys' -``` - -- [ ] **Step 4: Write hook unit test (flag gating)** - -`apps/ui/src/hooks/folders/useFoldersQuery.test.ts` — follow existing hook test patterns in `apps/ui` (QueryClientProvider + renderHook). Minimal cases: - -1. When `localStorage` key unset → `fetch` / `getFolders` not called (`isFetching` false / no network). -2. When key `"true"` → queryFn runs and returns folders from mocked `getFolders`. - -Mock `@/api/getFolders` with vitest `vi.mock`. Set/clear `localStorage` in beforeEach/afterEach. - -- [ ] **Step 5: Run UI test** - -```bash -pnpm --filter ui test -- src/hooks/folders/useFoldersQuery.test.ts -``` - -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add apps/ui/src/lib/localStorages.ts apps/ui/src/api/getFolders.ts apps/ui/src/hooks/folders -git commit -m "$(cat <<'EOF' -feat(ui): add useFoldersQuery gated by smm.v3.enabled - -EOF -)" -``` - ---- - -### Task 4: Merge helper + Sidebar V3 branch - -**Files:** -- Create: `apps/ui/src/lib/mergeFolderPathsWithUiStatus.ts` -- Create: `apps/ui/src/lib/mergeFolderPathsWithUiStatus.test.ts` -- Modify: `apps/ui/src/components/v2/Sidebar.tsx` - -**Interfaces:** -- Consumes: `useFoldersQuery()`, `UIMediaFolder[]` from Zustand -- Produces: `mergeFolderPathsWithUiStatus(paths, zustandFolders): UIMediaFolder[]` - -- [ ] **Step 1: Write failing merge tests** - -```ts -import { describe, expect, it } from 'vitest' -import { mergeFolderPathsWithUiStatus } from './mergeFolderPathsWithUiStatus' -import type { UIMediaFolder } from '@/types/UIMediaFolder' - -describe('mergeFolderPathsWithUiStatus', () => { - it('defaults status to ok when Zustand has no row', () => { - const result = mergeFolderPathsWithUiStatus(['/m/A'], []) - expect(result).toEqual([ - expect.objectContaining({ status: 'ok', path: expect.any(String) }), - ]) - }) - - it('preserves Zustand status/type/test when path matches', () => { - const existing: UIMediaFolder[] = [ - { path: '/m/A', status: 'initializing', type: 'tvshow-folder', test: true }, - ] - const result = mergeFolderPathsWithUiStatus(['/m/A'], existing) - expect(result[0]?.status).toBe('initializing') - expect(result[0]?.type).toBe('tvshow-folder') - expect(result[0]?.test).toBe(true) - }) - - it('follows query path order', () => { - const existing: UIMediaFolder[] = [ - { path: '/m/B', status: 'ok' }, - { path: '/m/A', status: 'ok' }, - ] - const result = mergeFolderPathsWithUiStatus(['/m/A', '/m/B'], existing) - expect(result.map((r) => r.path)).toEqual( - result.map((_, i) => result[i]!.path), // length 2 - ) - expect(result).toHaveLength(2) - }) -}) -``` - -(Adjust path assertions to use `Path.toPlatformPath` / `Path.posix` consistently with implementation.) - -- [ ] **Step 2: Run — expect FAIL** - -```bash -pnpm --filter ui test -- src/lib/mergeFolderPathsWithUiStatus.test.ts -``` - -- [ ] **Step 3: Implement merge helper** - -```ts -import { Path } from '@core/path' -import type { UIMediaFolder } from '@/types/UIMediaFolder' - -export function mergeFolderPathsWithUiStatus( - paths: string[], - zustandFolders: UIMediaFolder[], -): UIMediaFolder[] { - const byPosix = new Map( - zustandFolders.map((f) => [Path.posix(f.path), f] as const), - ) - return paths.map((p) => { - const posix = Path.posix(p) - const existing = byPosix.get(posix) - const platform = Path.toPlatformPath(p) - return { - path: platform, - status: existing?.status ?? 'ok', - test: existing?.test, - type: existing?.type, - } - }) -} -``` - -- [ ] **Step 4: Run merge tests — PASS** - -- [ ] **Step 5: Branch Sidebar folder source** - -In `apps/ui/src/components/v2/Sidebar.tsx`: - -1. Import `isSmmV3Enabled`, `useFoldersQuery`, `mergeFolderPathsWithUiStatus`. -2. Call `const foldersQuery = useFoldersQuery()` (hook always called; query self-disables when flag off). -3. Replace raw `folders` used for **list rows** with: - -```ts -const v3 = isSmmV3Enabled() -const listFolders = v3 - ? mergeFolderPathsWithUiStatus(foldersQuery.data ?? [], folders) - : folders -``` - -4. Use `listFolders` for `folderPaths`, `rowsWithMeta`, and list rendering. Keep selection / actions on Zustand as today. - -Do **not** change filter/sort/search `useMemo` logic beyond swapping the input array to `listFolders`-derived `rowsWithMeta`. - -- [ ] **Step 6: Manual sanity (optional in agent run)** - -With flag off, open app — Sidebar unchanged. With `localStorage.setItem('smm.v3.enabled','true')` and reload — network shows `POST /api/get-folders`, list still renders. - -- [ ] **Step 7: Commit** - -```bash -git add apps/ui/src/lib/mergeFolderPathsWithUiStatus.ts apps/ui/src/lib/mergeFolderPathsWithUiStatus.test.ts apps/ui/src/components/v2/Sidebar.tsx -git commit -m "$(cat <<'EOF' -feat(ui): Sidebar uses useFoldersQuery paths when smm.v3.enabled - -EOF -)" -``` - ---- - -### Task 5: Invalidate folders query when config folders change - -**Files:** -- Create: `apps/ui/src/hooks/folders/invalidateFoldersQuery.ts` -- Modify: `apps/ui/src/hooks/userConfig/useSaveUserConfigMutation.ts` -- Modify: `apps/ui/src/components/eventlisteners/SocketIoUserConfigFolderRenamedEventListener.tsx` - -**Interfaces:** -- Produces: `invalidateFoldersQueryIfV3(queryClient: QueryClient): void` - -- [ ] **Step 1: Add helper** - -```ts -import type { QueryClient } from '@tanstack/react-query' -import { isSmmV3Enabled } from '@/lib/localStorages' -import { foldersQueryKey } from './foldersQueryKeys' - -export function invalidateFoldersQueryIfV3(queryClient: QueryClient): void { - if (!isSmmV3Enabled()) return - void queryClient.invalidateQueries({ queryKey: foldersQueryKey }) -} -``` - -Export from `apps/ui/src/hooks/folders/index.ts`. - -- [ ] **Step 2: Hook `useSaveUserConfigMutation`** - -In `onSuccess`, after `setQueryData` for userConfig: - -```ts -onSuccess: (config, { config: _requested }) => { - const helloData = queryClient.getQueryData(helloQueryKey) - const dir = helloData?.userDataDir - if (dir) { - const prev = - queryClient.getQueryData(userConfigQueryKey(dir)) ?? defaultUserConfig - // Note: setQueryData already applied `config` above — compare using mutation variables - } -} -``` - -Correct pattern — use mutation variables for “previous vs next”: - -```ts -mutationFn: async ({ traceId, config }) => { - const helloData = ... - const dir = ... - const prev = queryClient.getQueryData(userConfigQueryKey(dir)) ?? defaultUserConfig - // ... existing writeFile ... - return { config, prevFolders: prev.folders } -}, -onSuccess: ({ config, prevFolders }) => { - const dir = queryClient.getQueryData(helloQueryKey)?.userDataDir - if (dir) { - queryClient.setQueryData(userConfigQueryKey(dir), config) - } - const foldersChanged = - prevFolders.length !== config.folders.length || - prevFolders.some((p, i) => p !== config.folders[i]) - if (foldersChanged) { - invalidateFoldersQueryIfV3(queryClient) - } -}, -``` - -Keep language-change behavior intact. Adjust any callers that assumed `mutateAsync` resolved to bare `UserConfig` — grep `saveUserConfigMutation` / `mutateAsync({ traceId, config` and update types if needed. - -- [ ] **Step 3: Invalidate on socket rename** - -In `SocketIoUserConfigFolderRenamedEventListener`, after updating folders in Zustand / cache: - -```ts -import { useQueryClient } from '@tanstack/react-query' -import { invalidateFoldersQueryIfV3 } from '@/hooks/folders' - -// inside component: -const queryClient = useQueryClient() -// inside listener after setFolders: -invalidateFoldersQueryIfV3(queryClient) -``` - -- [ ] **Step 4: Run related tests** - -```bash -pnpm --filter ui test -- src/hooks/userConfig -pnpm --filter ui test -- src/hooks/folders -pnpm --filter ui test -- src/lib/mergeFolderPathsWithUiStatus.test.ts -``` - -Fix any breakage from `mutateAsync` return-type change. - -- [ ] **Step 5: Commit** - -```bash -git add apps/ui/src/hooks/folders apps/ui/src/hooks/userConfig/useSaveUserConfigMutation.ts apps/ui/src/components/eventlisteners/SocketIoUserConfigFolderRenamedEventListener.tsx -git commit -m "$(cat <<'EOF' -feat(ui): invalidate folders query when UserConfig.folders changes (v3) - -EOF -)" -``` - ---- - -### Task 6: Spec status + verification - -**Files:** -- Modify: `docs/superpowers/specs/2026-08-17-display-folders-v3-design.md` (status line at top) -- Modify: `docs/superpowers/plans/2026-08-17-display-folders-v3.md` (this file — mark Implemented when done) - -- [x] **Step 1: Run focused verification** - -```bash -pnpm --filter cli test -- src/route/GetFolders.test.ts -pnpm --filter ui test -- src/hooks/folders src/lib/mergeFolderPathsWithUiStatus.test.ts -``` - -Expected: all PASS. - -- [x] **Step 2: Mark design implemented** - -At top of the design spec, add: - -```markdown -> **Status:** Implemented (YYYY-MM-DD). ... -``` - -- [x] **Step 3: Final commit** - -```bash -git add docs/superpowers/specs/2026-08-17-display-folders-v3-design.md docs/superpowers/plans/2026-08-17-display-folders-v3.md -git commit -m "$(cat <<'EOF' -docs: mark display-folders V3 design/plan as implemented - -EOF -)" -``` - ---- - -## Spec coverage checklist - -| Spec requirement | Task | -|------------------|------| -| Flag `smm.v3.enabled` | Task 3 | -| `POST /api/get-folders` → `Core.getFolders` | Task 2 | -| Core `appDataDir: getUserDataDir()` | Task 1 | -| `useFoldersQuery` | Task 3 | -| Merge paths + Zustand status | Task 4 | -| Sidebar V3 branch; old path untouched | Task 4 | -| Invalidate on folders change | Task 5 | -| API docs | Task 2 | -| No Socket push / no DTO enrich / no Zustand removal | (YAGNI — not tasked) | - -## Self-review notes - -- `useSaveUserConfigMutation` return type change must be grepped — do not leave callers assuming bare `UserConfig`. -- Hook order: always call `useFoldersQuery()`; rely on `enabled`, do not conditionally call hooks. -- Path matching uses `Path.posix` on both sides when merging. diff --git a/docs/superpowers/specs/2026-08-17-display-folders-v3-design.md b/docs/superpowers/specs/2026-08-17-display-folders-v3-design.md deleted file mode 100644 index 32c912d6..00000000 --- a/docs/superpowers/specs/2026-08-17-display-folders-v3-design.md +++ /dev/null @@ -1,216 +0,0 @@ -# Display Folders V3(get-folders + useFoldersQuery) - -> **Status:** Implemented (2026-08-17). Tasks 1–6 completed on branch `core-layer`. Commits: `0ea4f5e1` (Core singleton) through `6d28212b` (folders query invalidation). Verification: GetFolders.test.ts (2), useFoldersQuery.test.ts (2), mergeFolderPathsWithUiStatus.test.ts (3). Do not re-run. - -本设计描述「显示媒体文件夹列表」从 UI 直接读 `UserConfig` / Zustand,迁移到 **UI → Internal HTTP → Core** 的第一刀,并由 `localStorage["smm.v3.enabled"]` 控制新旧路径。 - -> 配套已实现:`Core.getFolders()`(见 [2026-08-17-core-read-apis-design.md](./2026-08-17-core-read-apis-design.md))。 - -## 1. Background - -现状 Sidebar 列表源: - -1. `AppInitializer` → `UIMediaFolderStoreInitializer` 从 `useConfig().userConfig.folders`(`POST /api/readFile` 读 `userDataDir/smm.json`)一次性 `setFolders` 进 Zustand; -2. `Sidebar` 读 Zustand `folders`,再用 `useQueries(mediaMetadata…)` 拼 `mediaName` / `mediaType`,`useMemo` 做 search / filter / sort。 - -问题:列表 paths 与业务配置耦合在 UI 编排里,无法走 Layer 2 Core,也难以被 MCP / 外部 API 复用同一数据源。 - -目标(本切片范围,方案 A + 落地方式 1): - -- 新增 `POST /api/get-folders` → `Core.getFolders()`; -- UI 增加 `useFoldersQuery`;V3 下 Sidebar 以 query paths 为列表源; -- **status / selection 仍用 Zustand**;**metadata 仍用现有 `useQueries`**; -- flag 关闭时行为与今天完全一致。 - -## 2. Architecture - -### 2.1 Project Level Architecture - -``` -apps/ui (Layer 1) - useFoldersQuery ──POST /api/get-folders──► apps/cli (Layer 3 宿主) - │ - ▼ - Core.getFolders() (apps/core, Layer 2) - │ - ▼ - FsPort → userDataDir/smm.json -``` - -依赖方向:`ui → cli HTTP → core-app → FsPort`。本切片不改 `packages/core` 领域类型。 - -### 2.2 App Level Architecture - -| 层 | 新增 / 改动 | -|----|-------------| -| Layer 2 | 无新方法(复用已有 `Core.getFolders`) | -| Layer 3 cli | `POST /api/get-folders` 路由;懒创建/单例 `Core`(`NodejsFsAdapter`) | -| Layer 1 ui | `localStorage` flag、`getFolders` API client、`useFoldersQuery`、Sidebar 合并逻辑、folders 变更处 `invalidateQueries` | - -### 2.3 Key Design - -**Feature flag** - -```ts -localStorage.getItem("smm.v3.enabled") === "true" -``` - -默认关闭。可挂在 `localStorages` 上作为 getter,非必须。 - -**数据职责拆分(方案 A)** - -| 数据 | 来源(V3) | 来源(旧) | -|------|------------|------------| -| 列表 paths | `useFoldersQuery` → Core | Zustand `folders`(由 Initializer / import 写入) | -| status / type / test | Zustand(按 path 合并;缺失默认 `"ok"`) | 同左 | -| selection | Zustand + `localStorages.sidebarSelectedFolder` | 同左 | -| mediaName / mediaType | 现有 metadata `useQueries` | 同左 | -| search / filter / sort | `sidebarStore` + 现有 `useMemo` | 同左 | - -**路径合并(V3 Sidebar)** - -```ts -const paths = useFoldersQuery().data ?? [] -const byPath = new Map(zustandFolders.map((f) => [Path.posix(f.path), f])) -const rows = paths.map((p) => { - const platform = Path.toPlatformPath(p) - const existing = byPath.get(Path.posix(p)) - return { - path: platform, - status: existing?.status ?? "ok", - test: existing?.test, - type: existing?.type, - } -}) -// 再接现有 metadata useQueries + filteredAndSortedFolders useMemo -``` - -**Core 构造时的目录参数(重要)** - -生产环境: - -- `smm.json` → **userDataDir** -- metadata 缓存 → **appDataDir** - -Win / macOS 上二者通常相同;Linux 上 XDG 分离。`Core` 当前把 `appDataDir` 当作「持有 `smm.json` 的根」。本切片 `getFolders` 只读配置,cli 构造 Core 时传入: - -```ts -appDataDir: getUserDataDir() -``` - -以保证读到与现 UI 相同的 `smm.json`。完整宿主拆分 `userDataDir` / `appDataDir` 不在本切片;不改 Core API。 - -## 3. HTTP 契约 - -### `POST /api/get-folders` - -- **Request**:`{}`(允许空 body) -- **Success**:`{ data: { folders: string[] } }` -- **Failure**:`{ error: "Error Reason: …" }`,HTTP **200**(对齐现有 RPC 风格) -- `folders` 与 `UserConfig.folders` 一致;本接口不做 sort / filter / search - -## 4. User Stories - -### 4.1 Flag 关闭时行为不变 - -* **Given** `smm.v3.enabled` 未设或不为 `"true"` -* **When** 打开应用并查看 Sidebar -* **Then** 不发起 `/api/get-folders`;列表仍来自 Zustand,与改前一致 - -### 4.2 Flag 开启时从 Core 拉列表 - -* **Given** `smm.v3.enabled === "true"`,且 `smm.json` 含若干 folders -* **When** Sidebar 渲染 -* **Then** `useFoldersQuery` 请求 `POST /api/get-folders`,列表 paths 与配置一致;search / filter / sort / 选中仍可用 - -```mermaid -sequenceDiagram - participant UI as Sidebar / useFoldersQuery - participant CLI as POST /api/get-folders - participant Core as Core.getFolders - participant FS as FsPort (smm.json) - - UI->>CLI: POST {} - CLI->>Core: getFolders() - Core->>FS: readUserConfig - FS-->>Core: folders[] - Core-->>CLI: string[] - CLI-->>UI: { data: { folders } } - Note over UI: merge Zustand status + metadata Query + useMemo -``` - -### 4.3 导入 / 删除后列表刷新 - -* **Given** V3 已开启 -* **When** import / delete / rename 成功修改了 `UserConfig.folders` -* **Then** 调用方 `invalidateQueries({ queryKey: ['folders'] })`(或等价 root key),Sidebar paths 更新;status 仍可由 Zustand 乐观更新 - -## 5. UI 细节 - -### 5.1 `useFoldersQuery` - -- 仿 `usePlansQuery`:`queryKey: ['folders']`(或 `foldersQueryKey` 常量) -- `enabled: isSmmV3Enabled()` -- `queryFn`:调用 API client;`resp.error` 则 throw - -### 5.2 Initializer / selection(V3) - -| 职责 | V3 行为 | -|------|---------| -| 列表 paths | 来自 query,不依赖 Initializer `setFolders` 作为列表源 | -| status seed / availability | Initializer 仍可写 Zustand;缺失 path 默认 `"ok"` | -| selection | 不变(含 `sidebarSelectedFolder` 恢复) | -| 旧路径 | Initializer / Sidebar 逻辑不动 | - -### 5.3 Invalidate 落点 - -在 V3 且成功改动 `UserConfig.folders` 的路径上失效 folders query,至少包括: - -- 单目录 import / media library import -- Sidebar / AppV2 删除 -- folder rename(Socket 或 mutation 成功后) - -默认策略:**invalidate**(不做本切片强制的 `setQueryData` 乐观 paths)。 - -## 6. cli 细节 - -- 新路由文件(例如 `apps/cli/src/route/GetFolders.ts`)`handleGetFolders(app)` -- 在现有 Hono 注册处挂上该 handler -- 懒单例 `getCore()`:`new Core({ fs: new NodejsFsAdapter(), network: …, appDataDir: getUserDataDir(), userDataDir: getUserDataDir(), … })` -- `core-app` 加入 cli 的 workspace 依赖 - -NetworkPort:本切片 `getFolders` 不触网;可注入既有/最小 noop fetch 适配器以满足构造函数。 - -## 7. 测试计划 - -| 层 | 用例 | -|----|------| -| cli 路由 | 空 folders;有 folders;Core/fs 失败 → `error` 字段、HTTP 200 | -| UI hook / 合并 | flag off 不请求;flag on 合并 status;缺 Zustand 行时 status=`ok` | -| 回归 | flag off 下既有 Sidebar / Initializer 行为不变(单元或现有测试) | - -`Core.getFolders` 单测已存在,本切片不重复。 - -## 8. 不做的事(YAGNI) - -- 不用 Socket.IO 推送文件夹列表 -- 不拆 Core 的 `userDataDir` / `appDataDir` 双根(仅构造时传 `getUserDataDir()`) -- 不移除 Zustand `folders` / 不迁移初始化流水线到 Core -- 不 enrich `get-folders` DTO(不加 status / type) -- 不改 MCP `get-media-folders` 工具(可后续改为同一 Core 调用) - -## 9. 涉及文件(预期) - -- 新:`apps/cli/src/route/GetFolders.ts`(+ 可选测试) -- 改:cli 路由注册、`apps/cli/package.json`(依赖 `core-app`) -- 新:`apps/ui/src/api/getFolders.ts`、`apps/ui/src/hooks/.../useFoldersQuery.ts`(及 query key) -- 改:`apps/ui/src/components/v2/Sidebar.tsx`(或薄 hook) -- 改:import / delete / rename 成功路径上的 invalidate -- 可选:`apps/ui/src/lib/localStorages.ts` 增加 v3 flag getter -- 文档:`docs/api/index.md` 增加条目(实现时) - -## 10. 与现状代码流对照 - -**旧路径**:`useUserConfigQuery` → Initializer `setFolders` → Zustand → Sidebar `useMemo(filter/sort)` + metadata queries。 - -**新路径(flag on)**:`useFoldersQuery` → `POST /api/get-folders` → `Core.getFolders` → 合并 Zustand status → 同一套 metadata queries + `useMemo`。 diff --git a/docs/superpowers/specs/2026-08-19-core-rename-folder-design.md b/docs/superpowers/specs/2026-08-19-core-rename-folder-design.md index 29d4d973..6647355c 100644 --- a/docs/superpowers/specs/2026-08-19-core-rename-folder-design.md +++ b/docs/superpowers/specs/2026-08-19-core-rename-folder-design.md @@ -1,5 +1,7 @@ # Core.renameFolder +> **Status:** Superseded for HTTP path naming — UI and Internal HTTP use `POST /api/rename-folder` → `Core.renameFolder`. MCP/debug still use in-process `doRenameFolder` from `@smm/core-routes`. + This design document describes migrating Sidebar folder rename (metadata cache + user config + on-disk rename) into Layer 2 `apps/core`, without changing existing UI or `packages/core-routes` source. ## 1. Background diff --git a/docs/superpowers/specs/2026-08-19-ui-v3-rename-folder-design.md b/docs/superpowers/specs/2026-08-19-ui-v3-rename-folder-design.md deleted file mode 100644 index 63579be0..00000000 --- a/docs/superpowers/specs/2026-08-19-ui-v3-rename-folder-design.md +++ /dev/null @@ -1,49 +0,0 @@ -# UI v3 rename via POST /api/rename-folder → Core.renameFolder - -## 1. Background - -Sidebar rename today calls `POST /api/renameFolder` → `packages/core-routes` `doRenameFolder`. Layer 2 now has `Core.renameFolder` with the same orchestration. Under `localStorage["smm.v3.enabled"] === "true"`, the UI should drive rename through Core (same pattern as `get-folders` / `unimport-folder`), without changing the legacy `/api/renameFolder` path used by non-v3 UI, MCP, and tools. - -## 2. Architecture - -``` -Sidebar RenameDialog - → useRenameMediaFolderMutation - ├─ v3 OFF → POST /api/renameFolder → core-routes doRenameFolder (unchanged) - └─ v3 ON → POST /api/rename-folder → getCore().renameFolder → FsPort + UserConfig - → refreshUiAfterFolderRename + invalidateFoldersQueryIfV3 - → socket broadcasts (same events as legacy rename) for listeners -``` - -| Layer | Change | -|-------|--------| -| `apps/core` | No change (already has `renameFolder`) | -| `apps/cli` | New `POST /api/rename-folder` route; register in `server.ts` | -| `apps/ui` | API client + mutation branches on `isSmmV3Enabled()` | -| `packages/core-routes` | Untouched | - -## 2.3 Key Design - -**HTTP** `POST /api/rename-folder` - -- Body: `{ from: string, to: string }` (absolute paths; platform or POSIX) -- Success: `{ data: { from, to } }` (echo request paths) -- Failure: `{ error: "Error Reason: …" }` (map `Core.renameFolder` throws) -- HTTP status: `200` for business success/failure (project API guideline) -- After success: broadcast `userConfigFolderRenamed` + `userConfigUpdated` (parity with legacy `RenameFolder.ts` so socket listeners keep working) - -**UI** - -- `isSmmV3Enabled()` → call new client; else existing `renameFolder` / `postRenameFolder` -- After successful rename: existing `refreshUiAfterFolderRename` **plus** `invalidateFoldersQueryIfV3` so `useFoldersQuery` picks up the new path - -## 3. Out of scope - -- Changing MCP / AI rename tools -- Removing or rewriting `POST /api/renameFolder` -- Browser `NetworkFsAdapter.rename` (still NYI) - -## 4. Testing - -- CLI route unit test: happy path + validation + Core throw → error body (TDD) -- UI mutation unit test if existing patterns cover v3 branching; otherwise thin test on API helper choice diff --git a/packages/core-routes/src/core-routes.test.ts b/packages/core-routes/src/core-routes.test.ts index 5d0ed2db..ab8f5a20 100644 --- a/packages/core-routes/src/core-routes.test.ts +++ b/packages/core-routes/src/core-routes.test.ts @@ -582,7 +582,7 @@ describe("POST /api/listFilesInMediaFolder", () => { }); }); -describe("POST /api/renameFolder", () => { +describe("POST /api/rename-folder", () => { async function requestRenameFolder(rawBody: string | undefined) { const { handleCoreRoutesRequest } = await import("../src/register.ts"); const { IncomingMessage, ServerResponse } = await import("node:http"); @@ -591,7 +591,7 @@ describe("POST /api/renameFolder", () => { const socket = new Socket(); const req = new IncomingMessage(socket); req.method = "POST"; - req.url = "/api/renameFolder"; + req.url = "/api/rename-folder"; req.headers = { "content-type": "application/json" }; if (rawBody !== undefined) { diff --git a/packages/core-routes/src/routes/renameFolderRoute.ts b/packages/core-routes/src/routes/renameFolderRoute.ts index cee78eae..f2ca636b 100644 --- a/packages/core-routes/src/routes/renameFolderRoute.ts +++ b/packages/core-routes/src/routes/renameFolderRoute.ts @@ -9,7 +9,7 @@ export async function handleRenameFolderPost( res: ServerResponse, ctx: RouteContext, ): Promise { - if (req.method !== "POST" || ctx.url.pathname !== "/api/renameFolder") { + if (req.method !== "POST" || ctx.url.pathname !== "/api/rename-folder") { return false; } @@ -17,7 +17,7 @@ export async function handleRenameFolderPost( const rawBody = (await readJsonBody(req)) as FolderRenameRequestBody; ctx.config.logger?.info( { from: rawBody.from, to: rawBody.to }, - "[RenameFolder] POST /api/renameFolder", + "[RenameFolder] POST /api/rename-folder", ); const result = await doRenameFolder(rawBody, ctx.config); sendJson(res, 200, result); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe07f7eb..6af93470 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,6 +215,9 @@ importers: '@wdio/globals': specifier: ^9.23.0 version: 9.23.0(expect-webdriverio@5.6.4)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) + '@wdio/local-runner': + specifier: ^9.31.7 + version: 9.31.7(@wdio/globals@9.23.0)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) '@wdio/mocha-framework': specifier: ^9.23.0 version: 9.24.0 @@ -223,7 +226,7 @@ importers: version: 9.24.0 expect-webdriverio: specifier: ^5.6.1 - version: 5.6.4(@wdio/globals@9.23.0)(@wdio/logger@9.18.0)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) + version: 5.6.4(@wdio/globals@9.23.0)(@wdio/logger@9.29.1)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) shelljs: specifier: ^0.10.0 version: 0.10.0 @@ -2002,26 +2005,50 @@ packages: resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/diff-sequences@30.5.0': + resolution: {integrity: sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/expect-utils@30.2.0': resolution: {integrity: sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/expect-utils@30.5.1': + resolution: {integrity: sha512-WcRWhHQdTMRDpyWKZ/6MINBmovI7zeD+bL8wFjCncRV3NQOwKy1X45IfyblfHR4k/XciIlNEdFL9QjFO+HNKOg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/get-type@30.1.0': resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/get-type@30.5.0': + resolution: {integrity: sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/pattern@30.0.1': resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/pattern@30.5.0': + resolution: {integrity: sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/schemas@30.0.5': resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/schemas@30.5.0': + resolution: {integrity: sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/types@30.2.0': resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/types@30.5.1': + resolution: {integrity: sha512-LvVYn83nnXPl+Rg98nvcFgjx6nRMTArhSn6RAX/w3ELn54S8A42TYZvsCMdGUqTM8S0wyXbtlQdU6Hi6dykj9g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0': resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==} peerDependencies: @@ -4049,6 +4076,9 @@ packages: '@vitest/pretty-format@4.0.18': resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + '@vitest/runner@4.0.18': resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} @@ -4058,6 +4088,9 @@ packages: '@vitest/snapshot@4.0.18': resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} @@ -4075,6 +4108,9 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@wdio/cli@9.24.0': resolution: {integrity: sha512-dFs1HNmyXne0pDOYPOHhFcck0BC22z0lMdu6RtTX1C4gHdEYsjTtTH2zsZ5N5BzzsZVSUol2PisuqyLQO5dZIA==} engines: {node: '>=18.20.0'} @@ -4084,6 +4120,14 @@ packages: resolution: {integrity: sha512-rcHu0eG16rSEmHL0sEKDcr/vYFmGhQ5GOlmlx54r+1sgh6sf136q+kth4169s16XqviWGW3LjZbUfpTK29pGtw==} engines: {node: '>=18.20.0'} + '@wdio/config@9.31.7': + resolution: {integrity: sha512-lfcQyhSTlqBGdxTV0aGvoS+xDykdL+ntxpIE3g5jBArFIJonrhSAytc90dJ0BOy1YBxLmb36p7mtW+/7/sIooA==} + engines: {node: '>=18.20.0'} + + '@wdio/dot-reporter@9.31.2': + resolution: {integrity: sha512-SY7lYPJzCGdedPxHcGMEJlC2tjdxqqq+e8ROGaob3kmjbKH2Zn7bQIqgCk9fOsA814Zz+V628pEKJNKHL8xpyQ==} + engines: {node: '>=18.20.0'} + '@wdio/globals@9.23.0': resolution: {integrity: sha512-OmwPKV8c5ecLqo+EkytN7oUeYfNmRI4uOXGIR1ybP7AK5Zz+l9R0dGfoadEuwi1aZXAL0vwuhtq3p0OL3dfqHQ==} engines: {node: '>=18.20.0'} @@ -4091,10 +4135,25 @@ packages: expect-webdriverio: ^5.3.4 webdriverio: ^9.0.0 + '@wdio/globals@9.31.3': + resolution: {integrity: sha512-ZzmRKUlwUBahNoNxHbAzst+9dNInEjLQc7Z3c86OhDRxAf3Ivw8k2rc56UCbyYiZnkd1dOpuA91zFiTrUNNORw==} + engines: {node: '>=18.20.0'} + peerDependencies: + expect-webdriverio: ^6.0.9 + webdriverio: ^9.28.0 + + '@wdio/local-runner@9.31.7': + resolution: {integrity: sha512-0f44ah3AmmjZMOgJRocVaur/DpcQnvWuEJv/3E3zDlCrbprKf3zcunv7zWMgrmNaun9uIdjCW2Tfd5iLr9xKxQ==} + engines: {node: '>=18.20.0'} + '@wdio/logger@9.18.0': resolution: {integrity: sha512-HdzDrRs+ywAqbXGKqe1i/bLtCv47plz4TvsHFH3j729OooT5VH38ctFn5aLXgECmiAKDkmH/A6kOq2Zh5DIxww==} engines: {node: '>=18.20.0'} + '@wdio/logger@9.29.1': + resolution: {integrity: sha512-0ZAEIo6PNyMIJPlOGkIgyOJUjcd0pC8/QHlVAAe1c91/IcjZ1X+k0yidXHaboJdN7dq1XPUacmhRdtua0U5EZg==} + engines: {node: '>=18.20.0'} + '@wdio/mocha-framework@9.24.0': resolution: {integrity: sha512-zzTfFk79Zx3qZgfbgpJ7o0euzgXIQSCzbfFPjgtEx8u7fvrhB8tbgf+EGPOEGPBOH/X1GvpAfDkhkgZ6roDR2Q==} engines: {node: '>=18.20.0'} @@ -4102,6 +4161,9 @@ packages: '@wdio/protocols@9.24.0': resolution: {integrity: sha512-ozQKYddBLT4TRvU9J+fGrhVUtx3iDAe+KNCJcTDMFMxNSdDMR2xFQdNp8HLHypspk58oXTYCvz6ZYjySthhqsw==} + '@wdio/protocols@9.31.5': + resolution: {integrity: sha512-e4H5exl5xCcITF0y/3zd6pf9kX4TmV8/0nnWDGt4X0KbgDi/+tGqlquZ+YS4YBEPbv95yTqVhnazEsaBiH85Tw==} + '@wdio/repl@9.16.2': resolution: {integrity: sha512-FLTF0VL6+o5BSTCO7yLSXocm3kUnu31zYwzdsz4n9s5YWt83sCtzGZlZpt7TaTzb3jVUfxuHNQDTb8UMkCu0lQ==} engines: {node: '>=18.20.0'} @@ -4110,6 +4172,17 @@ packages: resolution: {integrity: sha512-0VrEX2uzjrFCHb6fNQDrQe6X7xuQbXUJhy5CGhMZghnPegW0OnKguwUy/vVKJE0HEDMOrR8djteefxJVfOOZpw==} engines: {node: '>=18.20.0'} + '@wdio/reporter@9.31.2': + resolution: {integrity: sha512-3cKE6vCOsro/Ep9dGTIbo3FBn3WNvJ++BIn32lFd9m19pFdjuwlVgLtPXHb9jWNCJs740h5AIUcVkcscqUYhAA==} + engines: {node: '>=18.20.0'} + + '@wdio/runner@9.31.7': + resolution: {integrity: sha512-qh7MjoWCVZ8xxwwiD4kBbvzrWavRkjn8TQKF9mye6Tvze1/N9MX3L2PttSBwZu2tNeTSDm+ekTFNgzUmMG7K+w==} + engines: {node: '>=18.20.0'} + peerDependencies: + expect-webdriverio: ^6.0.9 + webdriverio: ^9.28.0 + '@wdio/spec-reporter@9.24.0': resolution: {integrity: sha512-I3HExQKvF5u+RUcwImk9JMiuBgo2MmuKDj3Y0oSRwSw0TxQX0nqVvt8udlVG6XJpOD+e4EqlCGWbFfMezi8iTA==} engines: {node: '>=18.20.0'} @@ -4118,10 +4191,22 @@ packages: resolution: {integrity: sha512-PYYunNl8Uq1r8YMJAK6ReRy/V/XIrCSyj5cpCtR5EqCL6heETOORFj7gt4uPnzidfgbtMBcCru0LgjjlMiH1UQ==} engines: {node: '>=18.20.0'} + '@wdio/types@9.31.2': + resolution: {integrity: sha512-lQKaiQDCJJ6VC1K/cKzaAxCpwB9pL1U0VbmX2uGksMxO4EDbNuLvIxWryuUDJ7OaSYTjxILTKYFErdbT/xEikg==} + engines: {node: '>=18.20.0'} + '@wdio/utils@9.24.0': resolution: {integrity: sha512-6WhtzC5SNCGRBTkaObX6A07Ofnnyyf+TQH/d/fuhZRqvBknrP4AMMZF+PFxGl1fwdySWdBn+gV2QLE+52Byowg==} engines: {node: '>=18.20.0'} + '@wdio/utils@9.31.7': + resolution: {integrity: sha512-tl4YCHX4yoGTzQXyzxArquNaju26Kl71UxNkLe/5vxRD98UlsjMhU5TqNlll+5DqvxiFa79fpc06HU/cHB6vJg==} + engines: {node: '>=18.20.0'} + + '@wdio/xvfb@9.31.2': + resolution: {integrity: sha512-48MqdcrvdC/Jn2YPhW6hjGLzcQqOEt1Q6KELR/TWz3vIJoZwzTngGAdT06voUnZS+PUEcd/vKSo4a6zxQ/mm7g==} + engines: {node: '>=18.20.0'} + '@webcontainer/env@1.1.1': resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} @@ -4909,6 +4994,10 @@ packages: resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} engines: {node: '>=16.0.0'} + deepmerge-ts@8.0.2: + resolution: {integrity: sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==} + engines: {node: '>=16.9.0'} + default-browser-id@5.0.1: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} @@ -5377,6 +5466,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + exit-hook@4.0.0: + resolution: {integrity: sha512-Fqs7ChZm72y40wKjOFXBKg7nJZvQJmewP5/7LtePDdnah/+FH9Hp5sgMujSCMPXlxOAW2//1jrW9pnsY7o20vQ==} + engines: {node: '>=18'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -5389,10 +5482,22 @@ packages: '@wdio/logger': ^9.0.0 webdriverio: ^9.0.0 + expect-webdriverio@6.0.10: + resolution: {integrity: sha512-VBaMl4U6orgn3uzM7n65Gd48legqi37GoP0mklR+3f7vs/cC1s4hQVmGoiqz9aBcISVF37dAynP3aoi0R0CDow==} + engines: {node: '>=20'} + peerDependencies: + '@wdio/globals': ^9.0.0 + '@wdio/logger': ^9.0.0 + webdriverio: ^9.28.0 + expect@30.2.0: resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + expect@30.5.1: + resolution: {integrity: sha512-m8YrYgvKe9+9gEnWEuKz+qCGfHqkrff7PPfyDnOFkjsfYRKqiYyOxDFMFieCGAuhtk/VNm63tvNgKZVzVy+Hvg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} @@ -5616,6 +5721,11 @@ packages: engines: {node: '>=20.0.0'} hasBin: true + geckodriver@6.1.1: + resolution: {integrity: sha512-/AcCyc9o9o6hUbudaSJM2iOtXbxSLqQPOb4GrPvEN40cjraUeaX/j5kH3mSgwiroyMn7qzx2wM61AQ2XY8j3sA==} + engines: {node: '>=20.0.0'} + hasBin: true + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -6232,26 +6342,50 @@ packages: resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-diff@30.5.1: + resolution: {integrity: sha512-e3cNNMpv8Kh20MjjphTXs+3Vz7DQyLM1nft7KJhnh46atFhjVJRa+0Hq0beywuwsACtMQUBihQkFl8zxb7gt1Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-matcher-utils@30.2.0: resolution: {integrity: sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-matcher-utils@30.5.1: + resolution: {integrity: sha512-aroZVqwOz/wC2y6pC+obgFWKV9viaQWQTTSB6W5H55+egtUczIfXR/rxExTv92xD/ADYwpqaYfVWx1aqwKg7FA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-message-util@30.2.0: resolution: {integrity: sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-message-util@30.5.1: + resolution: {integrity: sha512-UdQlLdd9wL/Ys7xRErckqwD6wPlSZYueosSWuHc1r2ztGLwlgPvtSJq2+BPEgaEY13WLvfFbmhTj8pba0Sd1jg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-mock@30.2.0: resolution: {integrity: sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-mock@30.5.1: + resolution: {integrity: sha512-9fVjc3leUpGID2/by/LU4Dvdcp7PFh9LlxS3QRWK3ABm+KtvEVsG/AEGeLY3gKOZsjkBxyfwGltoAVlW7dygHg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-regex-util@30.0.1: resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-regex-util@30.5.0: + resolution: {integrity: sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-util@30.2.0: resolution: {integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-util@30.5.1: + resolution: {integrity: sha512-yKuxmNy2rSbTXw+3SIPanJo+nV4/BS1p26v44IYBFMsswSQySfMMcPHErnOncda7i9HEz0q605rIhSTBVgrZTg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -7383,6 +7517,10 @@ packages: resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + pretty-format@30.5.1: + resolution: {integrity: sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -7525,6 +7663,9 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-is@19.2.8: + resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -8125,6 +8266,10 @@ packages: vite-plus: optional: true + stream-buffers@3.0.3: + resolution: {integrity: sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==} + engines: {node: '>= 0.10.0'} + streamx@2.23.0: resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} @@ -8344,6 +8489,10 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + tinyspy@4.0.4: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} @@ -8491,6 +8640,10 @@ packages: resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} engines: {node: '>=18.17'} + undici@6.28.1: + resolution: {integrity: sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==} + engines: {node: '>=18.17'} + undici@7.22.0: resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} engines: {node: '>=20.18.1'} @@ -8809,6 +8962,10 @@ packages: resolution: {integrity: sha512-2R31Ey83NzMsafkl4hdFq6GlIBvOODQMkueLjeRqYAITu3QCYiq9oqBdnWA6CdePuV4dbKlYsKRX0mwMiPclDA==} engines: {node: '>=18.20.0'} + webdriver@9.31.7: + resolution: {integrity: sha512-GtupRcYV4FsWEkql4lOJtDZwjfRiZHaV4HDpcLT9qIKcwgTyNuoGPApC7onx50zYJ1E2Lm5oCDrcgxmmRKTIGQ==} + engines: {node: '>=18.20.0'} + webdriverio@9.24.0: resolution: {integrity: sha512-LTJt6Z/iDM0ne/4ytd3BykoPv9CuJ+CAILOzlwFeMGn4Mj02i4Bk2Rg9o/jeJ89f52hnv4OPmNjD0e8nzWAy5g==} engines: {node: '>=18.20.0'} @@ -10242,21 +10399,38 @@ snapshots: '@jest/diff-sequences@30.0.1': {} + '@jest/diff-sequences@30.5.0': {} + '@jest/expect-utils@30.2.0': dependencies: '@jest/get-type': 30.1.0 + '@jest/expect-utils@30.5.1': + dependencies: + '@jest/get-type': 30.5.0 + '@jest/get-type@30.1.0': {} + '@jest/get-type@30.5.0': {} + '@jest/pattern@30.0.1': dependencies: '@types/node': 22.19.11 jest-regex-util: 30.0.1 + '@jest/pattern@30.5.0': + dependencies: + '@types/node': 22.19.11 + jest-regex-util: 30.5.0 + '@jest/schemas@30.0.5': dependencies: '@sinclair/typebox': 0.34.48 + '@jest/schemas@30.5.0': + dependencies: + '@sinclair/typebox': 0.34.48 + '@jest/types@30.2.0': dependencies: '@jest/pattern': 30.0.1 @@ -10267,6 +10441,16 @@ snapshots: '@types/yargs': 17.0.35 chalk: 4.1.2 + '@jest/types@30.5.1': + dependencies: + '@jest/pattern': 30.5.0 + '@jest/schemas': 30.5.0 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 22.19.11 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: glob: 13.0.6 @@ -12244,6 +12428,10 @@ snapshots: dependencies: tinyrainbow: 3.0.3 + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + '@vitest/runner@4.0.18': dependencies: '@vitest/utils': 4.0.18 @@ -12261,6 +12449,13 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.4 @@ -12289,6 +12484,12 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + '@wdio/cli@9.24.0(@types/node@25.3.0)(expect-webdriverio@5.6.4)(puppeteer-core@25.3.0(yauzl@2.10.0))': dependencies: '@vitest/snapshot': 2.1.9 @@ -12337,11 +12538,59 @@ snapshots: - react-native-b4a - supports-color + '@wdio/config@9.31.7': + dependencies: + '@wdio/logger': 9.29.1 + '@wdio/types': 9.31.2 + '@wdio/utils': 9.31.7 + deepmerge-ts: 8.0.2 + glob: 10.5.0 + import-meta-resolve: 4.2.0 + jiti: 2.7.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + '@wdio/dot-reporter@9.31.2': + dependencies: + '@wdio/reporter': 9.31.2 + '@wdio/types': 9.31.2 + chalk: 5.6.2 + '@wdio/globals@9.23.0(expect-webdriverio@5.6.4)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0)))': dependencies: - expect-webdriverio: 5.6.4(@wdio/globals@9.23.0)(@wdio/logger@9.18.0)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) + expect-webdriverio: 5.6.4(@wdio/globals@9.23.0)(@wdio/logger@9.29.1)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) webdriverio: 9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0)) + '@wdio/globals@9.31.3(expect-webdriverio@6.0.10(@wdio/globals@9.23.0)(@wdio/logger@9.29.1)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))))(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0)))': + dependencies: + expect-webdriverio: 6.0.10(@wdio/globals@9.23.0)(@wdio/logger@9.29.1)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) + webdriverio: 9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0)) + + '@wdio/local-runner@9.31.7(@wdio/globals@9.23.0)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0)))': + dependencies: + '@types/node': 20.19.33 + '@wdio/logger': 9.29.1 + '@wdio/repl': 9.16.2 + '@wdio/runner': 9.31.7(expect-webdriverio@6.0.10(@wdio/globals@9.23.0)(@wdio/logger@9.29.1)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))))(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) + '@wdio/types': 9.31.2 + '@wdio/xvfb': 9.31.2 + exit-hook: 4.0.0 + expect-webdriverio: 6.0.10(@wdio/globals@9.23.0)(@wdio/logger@9.29.1)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) + split2: 4.2.0 + stream-buffers: 3.0.3 + transitivePeerDependencies: + - '@wdio/globals' + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + - webdriverio + '@wdio/logger@9.18.0': dependencies: chalk: 5.6.2 @@ -12350,6 +12599,14 @@ snapshots: safe-regex2: 5.0.0 strip-ansi: 7.1.2 + '@wdio/logger@9.29.1': + dependencies: + chalk: 5.6.2 + loglevel: 1.9.2 + loglevel-plugin-prefix: 0.8.4 + safe-regex2: 5.0.0 + strip-ansi: 7.2.0 + '@wdio/mocha-framework@9.24.0': dependencies: '@types/mocha': 10.0.10 @@ -12366,6 +12623,8 @@ snapshots: '@wdio/protocols@9.24.0': {} + '@wdio/protocols@9.31.5': {} + '@wdio/repl@9.16.2': dependencies: '@types/node': 20.19.33 @@ -12378,6 +12637,35 @@ snapshots: diff: 8.0.3 object-inspect: 1.13.4 + '@wdio/reporter@9.31.2': + dependencies: + '@types/node': 20.19.33 + '@wdio/logger': 9.29.1 + '@wdio/types': 9.31.2 + diff: 8.0.3 + object-inspect: 1.13.4 + + '@wdio/runner@9.31.7(expect-webdriverio@6.0.10(@wdio/globals@9.23.0)(@wdio/logger@9.29.1)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))))(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0)))': + dependencies: + '@types/node': 20.19.33 + '@wdio/config': 9.31.7 + '@wdio/dot-reporter': 9.31.2 + '@wdio/globals': 9.31.3(expect-webdriverio@6.0.10(@wdio/globals@9.23.0)(@wdio/logger@9.29.1)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))))(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) + '@wdio/logger': 9.29.1 + '@wdio/types': 9.31.2 + '@wdio/utils': 9.31.7 + deepmerge-ts: 8.0.2 + expect-webdriverio: 6.0.10(@wdio/globals@9.23.0)(@wdio/logger@9.29.1)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) + webdriver: 9.31.7 + webdriverio: 9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0)) + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + '@wdio/spec-reporter@9.24.0': dependencies: '@wdio/reporter': 9.24.0 @@ -12390,6 +12678,10 @@ snapshots: dependencies: '@types/node': 20.19.33 + '@wdio/types@9.31.2': + dependencies: + '@types/node': 20.19.33 + '@wdio/utils@9.24.0': dependencies: '@puppeteer/browsers': 2.13.0 @@ -12412,6 +12704,32 @@ snapshots: - react-native-b4a - supports-color + '@wdio/utils@9.31.7': + dependencies: + '@puppeteer/browsers': 2.13.0 + '@wdio/logger': 9.29.1 + '@wdio/types': 9.31.2 + decamelize: 6.0.1 + deepmerge-ts: 8.0.2 + edgedriver: 6.3.0 + geckodriver: 6.1.1 + get-port: 7.1.0 + import-meta-resolve: 4.2.0 + locate-app: 2.5.0 + mitt: 3.0.1 + safaridriver: 1.0.1 + split2: 4.2.0 + wait-port: 1.1.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + '@wdio/xvfb@9.31.2': + dependencies: + '@wdio/logger': 9.29.1 + '@webcontainer/env@1.1.1': {} '@xmldom/xmldom@0.8.11': {} @@ -13271,6 +13589,8 @@ snapshots: deepmerge-ts@7.1.5: {} + deepmerge-ts@8.0.2: {} + default-browser-id@5.0.1: {} default-browser@5.5.0: @@ -13928,18 +14248,30 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + exit-hook@4.0.0: {} + expect-type@1.3.0: {} - expect-webdriverio@5.6.4(@wdio/globals@9.23.0)(@wdio/logger@9.18.0)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))): + expect-webdriverio@5.6.4(@wdio/globals@9.23.0)(@wdio/logger@9.29.1)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))): dependencies: '@vitest/snapshot': 4.0.18 '@wdio/globals': 9.23.0(expect-webdriverio@5.6.4)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) - '@wdio/logger': 9.18.0 + '@wdio/logger': 9.29.1 deep-eql: 5.0.2 expect: 30.2.0 jest-matcher-utils: 30.2.0 webdriverio: 9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0)) + expect-webdriverio@6.0.10(@wdio/globals@9.23.0)(@wdio/logger@9.29.1)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))): + dependencies: + '@vitest/snapshot': 4.1.11 + '@wdio/globals': 9.23.0(expect-webdriverio@5.6.4)(webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0))) + '@wdio/logger': 9.29.1 + deep-eql: 5.0.2 + expect: 30.5.1 + jest-matcher-utils: 30.5.1 + webdriverio: 9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0)) + expect@30.2.0: dependencies: '@jest/expect-utils': 30.2.0 @@ -13949,6 +14281,15 @@ snapshots: jest-mock: 30.2.0 jest-util: 30.2.0 + expect@30.5.1: + dependencies: + '@jest/expect-utils': 30.5.1 + '@jest/get-type': 30.5.0 + jest-matcher-utils: 30.5.1 + jest-message-util: 30.5.1 + jest-mock: 30.5.1 + jest-util: 30.5.1 + exponential-backoff@3.1.3: {} express-rate-limit@8.2.1(express@5.2.1): @@ -14213,6 +14554,17 @@ snapshots: transitivePeerDependencies: - supports-color + geckodriver@6.1.1: + dependencies: + '@wdio/logger': 9.29.1 + '@zip.js/zip.js': 2.8.21 + decamelize: 6.0.1 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + modern-tar: 0.7.6 + transitivePeerDependencies: + - supports-color + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -14913,6 +15265,13 @@ snapshots: chalk: 4.1.2 pretty-format: 30.2.0 + jest-diff@30.5.1: + dependencies: + '@jest/diff-sequences': 30.5.0 + '@jest/get-type': 30.5.0 + chalk: 4.1.2 + pretty-format: 30.5.1 + jest-matcher-utils@30.2.0: dependencies: '@jest/get-type': 30.1.0 @@ -14920,6 +15279,13 @@ snapshots: jest-diff: 30.2.0 pretty-format: 30.2.0 + jest-matcher-utils@30.5.1: + dependencies: + '@jest/get-type': 30.5.0 + chalk: 4.1.2 + jest-diff: 30.5.1 + pretty-format: 30.5.1 + jest-message-util@30.2.0: dependencies: '@babel/code-frame': 7.29.0 @@ -14932,14 +15298,36 @@ snapshots: slash: 3.0.0 stack-utils: 2.0.6 + jest-message-util@30.5.1: + dependencies: + '@babel/code-frame': 7.29.0 + '@jest/types': 30.5.1 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-util: 30.5.1 + picomatch: 4.0.7 + pretty-format: 30.5.1 + slash: 3.0.0 + stack-utils: 2.0.6 + jest-mock@30.2.0: dependencies: '@jest/types': 30.2.0 '@types/node': 22.19.11 jest-util: 30.2.0 + jest-mock@30.5.1: + dependencies: + '@jest/expect-utils': 30.5.1 + '@jest/types': 30.5.1 + '@types/node': 22.19.11 + jest-util: 30.5.1 + jest-regex-util@30.0.1: {} + jest-regex-util@30.5.0: {} + jest-util@30.2.0: dependencies: '@jest/types': 30.2.0 @@ -14949,6 +15337,15 @@ snapshots: graceful-fs: 4.2.11 picomatch: 4.0.3 + jest-util@30.5.1: + dependencies: + '@jest/types': 30.5.1 + '@types/node': 22.19.11 + chalk: 4.1.2 + ci-info: 4.4.0 + graceful-fs: 4.2.11 + picomatch: 4.0.7 + jiti@2.6.1: {} jiti@2.7.0: {} @@ -15786,8 +16183,7 @@ snapshots: modern-tar@0.7.5: {} - modern-tar@0.7.6: - optional: true + modern-tar@0.7.6: {} mri@1.2.0: {} @@ -16313,6 +16709,13 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + pretty-format@30.5.1: + dependencies: + '@jest/react-is-18': react-is@18.3.1 + '@jest/react-is-19': react-is@19.2.8 + '@jest/schemas': 30.5.0 + ansi-styles: 5.2.0 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -16521,6 +16924,8 @@ snapshots: react-is@18.3.1: {} + react-is@19.2.8: {} + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): dependencies: '@types/hast': 3.0.4 @@ -17313,6 +17718,8 @@ snapshots: - react-dom - utf-8-validate + stream-buffers@3.0.3: {} + streamx@2.23.0: dependencies: events-universal: 1.0.1 @@ -17549,6 +17956,8 @@ snapshots: tinyrainbow@3.0.3: {} + tinyrainbow@3.1.1: {} + tinyspy@4.0.4: {} tldts-core@6.1.86: {} @@ -17670,6 +18079,8 @@ snapshots: undici@6.23.0: {} + undici@6.28.1: {} + undici@7.22.0: {} undici@8.10.0: {} @@ -18079,6 +18490,27 @@ snapshots: - supports-color - utf-8-validate + webdriver@9.31.7: + dependencies: + '@types/node': 20.19.33 + '@types/ws': 8.18.1 + '@wdio/config': 9.31.7 + '@wdio/logger': 9.29.1 + '@wdio/protocols': 9.31.5 + '@wdio/types': 9.31.2 + '@wdio/utils': 9.31.7 + deepmerge-ts: 8.0.2 + https-proxy-agent: 7.0.6 + undici: 6.28.1 + ws: 8.21.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + webdriverio@9.24.0(puppeteer-core@25.3.0(yauzl@2.10.0)): dependencies: '@types/node': 20.19.33 From 53723ac5d5e07440ebf37d183fe755c1776d6177 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 9 Sep 2026 01:56:05 +0800 Subject: [PATCH 72/83] fix(tvdb): localize episode titles without stalling import Fetch TVDB episode translations in parallel and show season 0 as Specials so import finishes in time and the panel matches e2e. Co-authored-by: Cursor --- apps/cli/test/import-tvdb-nfo.e2e.ts | 84 +++++++++++++++++ apps/core/src/clients/TvdbClient.test.ts | 91 +++++++++++++++++++ apps/core/src/clients/TvdbClient.ts | 52 ++++++++++- .../common/tv/InitializeTvShowByTvdb.e2e.ts | 6 +- apps/e2e/common/tv/SearchTvShow.e2e.ts | 8 +- apps/ui/src/components/tv/TvShowPanel.tsx | 28 +----- .../components/tv/TvShowPanelUtils.test.ts | 65 ++++++++++++- apps/ui/src/components/tv/TvShowPanelUtils.ts | 35 ++++++- 8 files changed, 329 insertions(+), 40 deletions(-) create mode 100644 apps/cli/test/import-tvdb-nfo.e2e.ts diff --git a/apps/cli/test/import-tvdb-nfo.e2e.ts b/apps/cli/test/import-tvdb-nfo.e2e.ts new file mode 100644 index 00000000..aae16a40 --- /dev/null +++ b/apps/cli/test/import-tvdb-nfo.e2e.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { MediaMetadata } from '@smm/types' +import { getCore, resetCoreForTests } from '../src/core/getCore' +import { smm } from './helpers/smm' +import { createFolderInTestFolder, type TestFolder } from './helpers/testFolders' + +/** + * Covers InitializeTvShowByTvdb NFO case: preferMediaLanguage zh-CN must localize + * episode titles from TVDB (not leave season-extended default Japanese names). + */ +describe('smm import TVDB NFO language', () => { + let userDataDir: string + let appDataDir: string + let mediaDir: string + let prevUserDataDir: string | undefined + let prevAppDataDir: string | undefined + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + prevAppDataDir = process.env.APP_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-cli-tvdb-nfo-ud-')) + appDataDir = mkdtempSync(join(tmpdir(), 'smm-cli-tvdb-nfo-app-')) + mediaDir = mkdtempSync(join(tmpdir(), 'smm-cli-tvdb-nfo-media-')) + process.env.USER_DATA_DIR = userDataDir + process.env.APP_DATA_DIR = appDataDir + resetCoreForTests() + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + if (prevAppDataDir === undefined) delete process.env.APP_DATA_DIR + else process.env.APP_DATA_DIR = prevAppDataDir + rmSync(userDataDir, { recursive: true, force: true }) + rmSync(appDataDir, { recursive: true, force: true }) + rmSync(mediaDir, { recursive: true, force: true }) + }) + + it( + 'import via tvshow.nfo tvdbid uses preferMediaLanguage for episode names', + { timeout: 10 * 60 * 1000 }, + async () => { + const setDb = await smm(['config', 'set', 'primaryDatabase', '"TVDB"']) + expect(setDb.code, setDb.stderr || setDb.stdout).toBe(0) + const setLang = await smm(['config', 'set', 'preferMediaLanguage', '"zh-CN"']) + expect(setLang.code, setLang.stderr || setLang.stdout).toBe(0) + + const fixture: TestFolder = { + folderName: 'WhateverItIsToEnsureCannotRecognizeByFolderName', + files: ['S01E01.mkv', 'S01E02.mkv', 'S01E03.mkv'], + type: 'tvshow', + } + const folder = createFolderInTestFolder(mediaDir, fixture) + const path = folder.path! + writeFileSync( + join(path, 'tvshow.nfo'), + ` + + 天使降临到我身边 + 355969 + 355969 +`, + 'utf-8', + ) + + const added = await smm(['add', path, '--type', 'tvshow']) + expect(added.code, added.stderr || added.stdout).toBe(0) + expect(added.stdout).toMatch(/succeeded/) + + const mm = (await getCore().getMetadata(path)) as MediaMetadata + expect(mm.tvShow?.database).toBe('TVDB') + expect(mm.tvShow?.id).toBe('355969') + expect(mm.tvShow?.name).toMatch(/天使/) + + const season1 = mm.tvShow?.seasons?.find((s) => s.season === 1) + expect(season1?.episodes?.[0]?.name).toBe('心裏癢癢的感覺') + expect(season1?.episodes?.[0]?.name).not.toBe('もにょっとした気持ち') + }, + ) +}) diff --git a/apps/core/src/clients/TvdbClient.test.ts b/apps/core/src/clients/TvdbClient.test.ts index dbf418c3..c2205893 100644 --- a/apps/core/src/clients/TvdbClient.test.ts +++ b/apps/core/src/clients/TvdbClient.test.ts @@ -102,6 +102,97 @@ describe("TvdbClient", () => { }); }); + it("getTvShowMediaMetadata localizes episode names when nameTranslations includes language", async () => { + const network: NetworkPort = { + fetch: async (url) => { + if (url.includes("/series/1/translations/zho")) { + return jsonResponse(envelope({ name: "天使降临到了我身边!" })); + } + if (url.includes("/series/1/extended")) { + return jsonResponse( + envelope({ + id: 1, + name: "Wataten", + firstAired: "2019-01-08", + seasons: [{ id: 11, number: 1, type: { name: "Aired Order" } }], + }), + ); + } + if (url.includes("/seasons/11/extended")) { + return jsonResponse( + envelope({ + id: 11, + episodes: [ + { + id: 101, + number: 1, + seasonNumber: 1, + name: "もにょっとした気持ち", + nameTranslations: ["zho", "eng"], + }, + ], + }), + ); + } + if (url.includes("/episodes/101/translations/zho")) { + return jsonResponse(envelope({ name: "心裏癢癢的感覺" })); + } + throw new Error("unexpected url: " + url); + }, + }; + const client = new TvdbClient(network, {}); + const tvShow = await client.getTvShowMediaMetadata(1, "zho"); + expect(tvShow?.name).toBe("天使降临到了我身边!"); + expect(tvShow?.seasons[0]?.episodes[0]?.name).toBe("心裏癢癢的感覺"); + }); + + it("getTvShowMediaMetadata fetches episode translations in parallel", async () => { + let inFlight = 0; + let maxInFlight = 0; + const network: NetworkPort = { + fetch: async (url) => { + if (url.includes("/series/1/translations/zho")) { + return jsonResponse(envelope({ name: "Show" })); + } + if (url.includes("/series/1/extended")) { + return jsonResponse( + envelope({ + id: 1, + name: "Show", + firstAired: "2020-01-01", + seasons: [{ id: 11, number: 1, type: { name: "Aired Order" } }], + }), + ); + } + if (url.includes("/seasons/11/extended")) { + return jsonResponse( + envelope({ + id: 11, + episodes: [ + { id: 101, number: 1, seasonNumber: 1, name: "E1", nameTranslations: ["zho"] }, + { id: 102, number: 2, seasonNumber: 1, name: "E2", nameTranslations: ["zho"] }, + { id: 103, number: 3, seasonNumber: 1, name: "E3", nameTranslations: ["zho"] }, + ], + }), + ); + } + const m = url.match(/\/episodes\/(\d+)\/translations\/zho/); + if (m) { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((r) => setTimeout(r, 30)); + inFlight -= 1; + return jsonResponse(envelope({ name: `T${m[1]}` })); + } + throw new Error("unexpected url: " + url); + }, + }; + const client = new TvdbClient(network, {}); + const tvShow = await client.getTvShowMediaMetadata(1, "zho"); + expect(tvShow?.seasons[0]?.episodes.map((e) => e.name)).toEqual(["T101", "T102", "T103"]); + expect(maxInFlight).toBeGreaterThan(1); + }); + it("getMovieMediaMetadata maps a movie", async () => { const client = new TvdbClient(tvdbNetwork(), {}); const movie = await client.getMovieMediaMetadata(2, "eng"); diff --git a/apps/core/src/clients/TvdbClient.ts b/apps/core/src/clients/TvdbClient.ts index d78396b3..02d8d882 100644 --- a/apps/core/src/clients/TvdbClient.ts +++ b/apps/core/src/clients/TvdbClient.ts @@ -106,16 +106,60 @@ export class TvdbClient { m.airDate = series.firstAired; const seasons = series.seasons.filter((s) => s.type.name === "Aired Order"); + type EpisodeRow = { + episodeId: number; + seasonNumber: number; + episodeNumber: number; + defaultName: string; + needsTranslation: boolean; + }; + const allEpisodes: EpisodeRow[] = []; + for (const season of seasons) { + m.seasons.push({ season: season.number, name: "", episodes: [] }); const seasonResp = await this.client.seasonExtendedById(season.id); const episodes = seasonResp.status === "success" ? (seasonResp.data as TVDBv4SeriesSeasonsExtendedResponse).episodes : []; - m.seasons.push({ - season: season.number, - name: "", - episodes: episodes.map((ep) => ({ season: ep.seasonNumber, episode: ep.number, name: ep.name ?? "" })), + for (const ep of episodes) { + allEpisodes.push({ + episodeId: ep.id, + seasonNumber: ep.seasonNumber, + episodeNumber: ep.number, + defaultName: ep.name ?? "", + needsTranslation: ep.nameTranslations?.includes(language) ?? false, + }); + } + } + + // Parallelize: sequential per-episode translation can take minutes on large shows + // (each request goes through media-db failover) and stalls import/e2e past UI timeouts. + const translatedNames = new Map(); + const episodesToTranslate = allEpisodes.filter((e) => e.needsTranslation); + const translationSettled = await Promise.allSettled( + episodesToTranslate.map(async (ep) => { + const tr = await this.client.episodeTranslationByLangCode(ep.episodeId, language); + if (tr.status !== "success") return; + const name = tr.data?.name; + if (typeof name === "string" && name.trim()) { + return { episodeId: ep.episodeId, name: name.trim() }; + } + }), + ); + for (const result of translationSettled) { + if (result.status === "fulfilled" && result.value) { + translatedNames.set(result.value.episodeId, result.value.name); + } + } + + for (const ep of allEpisodes) { + const mediaSeason = m.seasons.find((s) => s.season === ep.seasonNumber); + if (!mediaSeason) continue; + mediaSeason.episodes.push({ + season: ep.seasonNumber, + episode: ep.episodeNumber, + name: translatedNames.get(ep.episodeId) ?? ep.defaultName, }); } return m; diff --git a/apps/e2e/common/tv/InitializeTvShowByTvdb.e2e.ts b/apps/e2e/common/tv/InitializeTvShowByTvdb.e2e.ts index 0e3b5d41..7d763656 100644 --- a/apps/e2e/common/tv/InitializeTvShowByTvdb.e2e.ts +++ b/apps/e2e/common/tv/InitializeTvShowByTvdb.e2e.ts @@ -74,7 +74,7 @@ describe('Initialize TV Show by TVDB', () => { await browser.pause(2000) const state = await TvShowPanelCO.toString() - expect(state).toContain(`Season 0 + expect(state).toContain(`Specials S00E01 - - - - S00E02 - - - - Season 1 @@ -114,7 +114,7 @@ S01E12 - - - -`) await TvShowPanelCO.waitForTitleToBe('【我推的孩子】', 3 * 60 * 1000) const state = await TvShowPanelCO.toString() - expect(state).toContain(`Season 0 + expect(state).toContain(`Specials S00E01 - - - - S00E02 - - - - Season 1 @@ -186,7 +186,7 @@ S04E01 - - - -`) await browser.pause(2000) expect(await TvShowPanelCO.toString()).toBe(`nfo -Season 0 +Specials S00E01 - - - - S00E02 - - - - Season 1 diff --git a/apps/e2e/common/tv/SearchTvShow.e2e.ts b/apps/e2e/common/tv/SearchTvShow.e2e.ts index 01e8132a..34c96c81 100644 --- a/apps/e2e/common/tv/SearchTvShow.e2e.ts +++ b/apps/e2e/common/tv/SearchTvShow.e2e.ts @@ -16,7 +16,7 @@ import type { MediaMetadata, UserConfig } from '@smm/types' import { testbedOs } from 'test/lib/e2e-platform' -const OSHI_NO_KO_TMDB_EPISODE_TABLE = `特别篇 +const OSHI_NO_KO_TMDB_EPISODE_TABLE = `Specials S00E01 - - - - S00E02 - - - - 第 1 季 @@ -56,13 +56,13 @@ S01E33 - - - - S01E34 - - - - S01E35 - - - -` -/** TVDB lists 5 aired-order seasons (S0–S4); empty season names render as "Season N" in zh-CN UI. */ +/** TVDB lists 5 aired-order seasons (S0–S4); empty names → Specials / Season N. */ function buildEpisodeTableExpectation( seasons: ReadonlyArray<{ season: number; episodes: number }>, ): string { const lines: string[] = [] for (const { season, episodes } of seasons) { - lines.push(`Season ${season}`) + lines.push(season === 0 ? 'Specials' : `Season ${season}`) for (let episode = 1; episode <= episodes; episode += 1) { lines.push( `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')} - - - -`, @@ -154,7 +154,7 @@ describe('Search TV Show', () => { }, { timeout: 3 * 60 * 1000, interval: 2000, - timeoutMsg: 'Expected to see Season 0 in the TV show panel within 3 minutes after TMDB select', + timeoutMsg: 'Expected to see Specials in the TV show panel within 3 minutes after TMDB select', }) }) diff --git a/apps/ui/src/components/tv/TvShowPanel.tsx b/apps/ui/src/components/tv/TvShowPanel.tsx index da17104f..12887e31 100644 --- a/apps/ui/src/components/tv/TvShowPanel.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.tsx @@ -23,7 +23,6 @@ import type { MediaFileTableContextMenuProps, UIMediaFileTableRow, UIMediaEpisodeSelection, - MediaFileTableSeasonData, } from "@/components/media/UIMediaFileTable" import { useRenameVideoFileFlow } from "@/hooks/useRenameVideoFileFlow" import { TvShowPanelHeader } from "./TvShowPanelHeader" @@ -36,6 +35,7 @@ import { rebuildPlanWithSelectedEpisodes, buildRenameApplySelectedFiles, buildRecognizeApplySelectedFiles, + buildMediaFileTableSeasonData, } from "./TvShowPanelUtils" import { useLatest } from "react-use" import type { UIMediaFolderStatus } from "@/types/UIMediaFolder" @@ -47,32 +47,6 @@ import { AiBasedRecognizeEpisodePrompt } from "./AiBasedRecognizeEpisodePrompt" import type { RecognizeMediaFilePlan } from "@smm/types/RecognizeMediaFilePlan" -function buildMediaFileTableSeasonData(m: MediaMetadata): MediaFileTableSeasonData[] { - - if(m.type === 'tvshow-folder' || m.type === 'movie-folder') { - const seasons: MediaFileTableSeasonData[] = m.tvShow?.seasons?.map(s => { - return { - season: s.season, - title: s.name, - episodes: s.episodes.map(e => { - return { - season: s.season, - episode: e.episode, - title: e.name, - path: m.mediaFiles?.find(f => f.seasonNumber === s.season && f.episodeNumber === e.episode)?.absolutePath, - } - }), - } - }) ?? []; - - return seasons; - } - - // Should NOT reach this line in normal case. - console.warn(`Unsupported media type: ${m.type}, returned dummy MediaFileTableSeasonData`) - return [] -} - function TvShowPanel() { const { folders, selectedFolder } = useUIMediaFolderStoreState() const { diff --git a/apps/ui/src/components/tv/TvShowPanelUtils.test.ts b/apps/ui/src/components/tv/TvShowPanelUtils.test.ts index 72acccc3..a6843648 100644 --- a/apps/ui/src/components/tv/TvShowPanelUtils.test.ts +++ b/apps/ui/src/components/tv/TvShowPanelUtils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { mapTagToFileType, newPath, buildFileProps, renameFiles, updateMediaFileMetadatas, buildTvShowMediaMetadataByNFO, buildTmdbEpisodeByNFO, tryToRecognizeTvShowFolderByNFO, unlinkEpisode, buildRenameApplySelectedFiles, buildRecognizeApplySelectedFiles } from './TvShowPanelUtils' +import { mapTagToFileType, newPath, buildFileProps, renameFiles, updateMediaFileMetadatas, buildTvShowMediaMetadataByNFO, buildTmdbEpisodeByNFO, tryToRecognizeTvShowFolderByNFO, unlinkEpisode, buildRenameApplySelectedFiles, buildRecognizeApplySelectedFiles, buildMediaFileTableSeasonData } from './TvShowPanelUtils' import type { FileProps } from '@/lib/types' import type { MediaMetadata, MediaFileMetadata } from '@smm/types' import type { MediaMetadata } from '@smm/types' @@ -1312,3 +1312,66 @@ describe('buildRecognizeApplySelectedFiles', () => { ).toEqual([]) }) }) + +describe('buildMediaFileTableSeasonData', () => { + it('uses Specials for season 0 and Season N fallback when names are empty', () => { + const meta: MediaMetadata = { + mediaFolderPath: '/show', + type: 'tvshow-folder', + tvShow: { + database: 'TVDB', + id: '1', + name: 'Show', + seasons: [ + { season: 0, name: '', episodes: [{ season: 0, episode: 1, name: '' }] }, + { season: 1, name: '', episodes: [{ season: 1, episode: 1, name: '' }] }, + ], + }, + mediaFiles: [], + } + expect(buildMediaFileTableSeasonData(meta).map((s) => s.title)).toEqual([ + 'Specials', + 'Season 1', + ]) + }) + + it('normalizes season 0 to Specials even when API provides a localized name (TMDB)', () => { + const meta: MediaMetadata = { + mediaFolderPath: '/show', + type: 'tvshow-folder', + tvShow: { + database: 'TMDB', + id: '1', + name: 'Show', + seasons: [ + { season: 0, name: '特别篇', episodes: [{ season: 0, episode: 1, name: '' }] }, + { season: 1, name: '第 1 季', episodes: [{ season: 1, episode: 1, name: '' }] }, + ], + }, + mediaFiles: [], + } + expect(buildMediaFileTableSeasonData(meta).map((s) => s.title)).toEqual([ + 'Specials', + '第 1 季', + ]) + }) + + it('keeps non-empty names for season >= 1', () => { + const meta: MediaMetadata = { + mediaFolderPath: '/show', + type: 'tvshow-folder', + tvShow: { + database: 'TMDB', + id: '1', + name: 'Show', + seasons: [ + { season: 1, name: 'Season 1', episodes: [{ season: 1, episode: 1, name: 'Pilot' }] }, + ], + }, + mediaFiles: [{ absolutePath: '/show/S01E01.mkv', seasonNumber: 1, episodeNumber: 1 }], + } + const result = buildMediaFileTableSeasonData(meta) + expect(result[0]?.title).toBe('Season 1') + expect(result[0]?.episodes[0]?.path).toBe('/show/S01E01.mkv') + }) +}) diff --git a/apps/ui/src/components/tv/TvShowPanelUtils.ts b/apps/ui/src/components/tv/TvShowPanelUtils.ts index 94b17cbf..03facf96 100644 --- a/apps/ui/src/components/tv/TvShowPanelUtils.ts +++ b/apps/ui/src/components/tv/TvShowPanelUtils.ts @@ -739,4 +739,37 @@ export function unlinkEpisode(params: UnlinkEpisodeParams): void { console.error(`[${traceId}] Failed to unlink episode:`, error) toast.error(t('tvShowEpisodeTable.unlinkFailed')) }) -} \ No newline at end of file +} +/** Display title for a season row: season 0 is always Specials; empty names get Season N. */ +export function seasonDisplayTitle(season: number, name: string | undefined): string { + if (season === 0) return 'Specials' + const trimmed = name?.trim() ?? '' + return trimmed || `Season ${season}` +} + +/** + * Map MediaMetadata seasons into MediaFileTable seasonData rows (UI display titles). + */ +export function buildMediaFileTableSeasonData( + m: MediaMetadata, +): import("@/components/media/UIMediaFileTable").MediaFileTableSeasonData[] { + if (m.type === 'tvshow-folder' || m.type === 'movie-folder') { + return ( + m.tvShow?.seasons?.map((s) => ({ + season: s.season, + title: seasonDisplayTitle(s.season, s.name), + episodes: s.episodes.map((e) => ({ + season: s.season, + episode: e.episode, + title: e.name, + path: m.mediaFiles?.find( + (f) => f.seasonNumber === s.season && f.episodeNumber === e.episode, + )?.absolutePath, + })), + })) ?? [] + ) + } + + console.warn(`Unsupported media type: ${m.type}, returned dummy MediaFileTableSeasonData`) + return [] +} From 9bc3e5d98bb7501cd85221965ed3df87781d55bd Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 9 Sep 2026 19:00:54 +0800 Subject: [PATCH 73/83] fix(ui): disable rename confirm when plan has nothing to apply Move isConfirmButtonDisabled into recognize/rename flows so S5 cannot confirm an empty rename plan, and fix episode-link e2e selectors for nested MediaFileTable rows. Co-authored-by: Cursor --- apps/e2e/test/steps/episode-link-steps.ts | 33 ++++++++-- .../ui/src/components/tv/TvShowPanel.test.tsx | 2 + apps/ui/src/components/tv/TvShowPanel.tsx | 3 +- .../tv/useRuleBasedRecognizeFlow.test.tsx | 63 +++++++++++++++++++ .../src/hooks/tv/useRuleBasedRecognizeFlow.ts | 3 + .../tv/useRuleBasedRenameFilesFlow.test.tsx | 60 ++++++++++++++++++ .../hooks/tv/useRuleBasedRenameFilesFlow.ts | 3 + 7 files changed, 162 insertions(+), 5 deletions(-) diff --git a/apps/e2e/test/steps/episode-link-steps.ts b/apps/e2e/test/steps/episode-link-steps.ts index d668b543..56c4d432 100644 --- a/apps/e2e/test/steps/episode-link-steps.ts +++ b/apps/e2e/test/steps/episode-link-steps.ts @@ -1,11 +1,36 @@ import { expect } from '@wdio/globals' import { registerStep, requiredStepArg } from '../lib/gherkin' +/** + * Read the Video File cell for an episode row. + * + * MediaFileTable nests episode `
`s inside a season wrapper `` that also + * contains the SxxExx id as a descendant. Prefer locating the id `
` then + * walking to its parent row (same approach as TvShowPanelCO), instead of an + * XPath that matches the outer wrapper (which only has one `td` and breaks + * `./td[2]`). + * + * Column order: `[checkbox?] [SxxExx] [video] [thumb] [sub] [nfo]`. + */ async function getEpisodeVideoCellText(episodeId: string): Promise { - const rowSelector = `//tr[.//td[contains(@class,"font-mono") and normalize-space()="${episodeId}"]]` - const row = await $(rowSelector) - const videoCell = await row.$('./td[2]') - return (await videoCell.getText()).trim() + const idCell = await $(`td=${episodeId}`) + await idCell.waitForDisplayed({ timeout: 10000 }) + const row = await idCell.parentElement() + const cells = await row.$$('td') + let idCellIndex = -1 + for (let i = 0; i < cells.length; i++) { + const text = (await cells[i]!.getText()).trim() + if (text === episodeId) { + idCellIndex = i + break + } + } + if (idCellIndex < 0 || idCellIndex + 1 >= cells.length) { + throw new Error( + `Video file cell not found for episode "${episodeId}" (idCellIndex=${idCellIndex}, cells=${cells.length})`, + ) + } + return (await cells[idCellIndex + 1]!.getText()).trim() } registerStep('episode "xxx" is linked to a video file', async (_ctx, args) => { diff --git a/apps/ui/src/components/tv/TvShowPanel.test.tsx b/apps/ui/src/components/tv/TvShowPanel.test.tsx index 32ed6049..0807ea09 100644 --- a/apps/ui/src/components/tv/TvShowPanel.test.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.test.tsx @@ -144,6 +144,7 @@ vi.mock("@/hooks/tv/useRuleBasedRecognizeFlow", () => ({ tvShowTmdbId: 1, notAllEpisodesRecognized: false, allPlanFilesUnchanged: false, + isConfirmButtonDisabled: false, confirm: h.recognizeConfirm, cancel: h.recognizeCancel, start: h.recognizeStart, @@ -155,6 +156,7 @@ vi.mock("@/hooks/tv/useRuleBasedRenameFilesFlow", () => ({ plan: undefined, open: false, loading: false, + isConfirmButtonDisabled: false, selectedNamingRule: "plex", namingRuleOptions: [ { value: "plex", label: "Plex" }, diff --git a/apps/ui/src/components/tv/TvShowPanel.tsx b/apps/ui/src/components/tv/TvShowPanel.tsx index 12887e31..3f8043e7 100644 --- a/apps/ui/src/components/tv/TvShowPanel.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.tsx @@ -290,6 +290,7 @@ function TvShowPanel() { namingRuleOptions: renameFlow.namingRuleOptions, selectedNamingRule: renameFlow.selectedNamingRule, onNamingRulesSelected: renameFlow.selectNamingRule, + isConfirmButtonDisabled: renameFlow.isConfirmButtonDisabled, onConfirm: async () => { // RENAME applies to files already linked in metadata, so each checked // episode's table path (metadata.mediaFiles[...].absolutePath) is the @@ -316,7 +317,7 @@ function TvShowPanel() { tvShowTmdbId: recognizeFlow.tvShowTmdbId, notAllEpisodesRecognized: recognizeFlow.notAllEpisodesRecognized, allPlanFilesUnchanged: recognizeFlow.allPlanFilesUnchanged, - isConfirmButtonDisabled: recognizeFlow.loading || recognizeFlow.allPlanFilesUnchanged, + isConfirmButtonDisabled: recognizeFlow.isConfirmButtonDisabled, onConfirm: async () => { // RECOGNIZE applies plan-proposed paths: the files are usually NOT yet // linked in metadata, so the episode-table lookup used by RENAME would diff --git a/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.test.tsx b/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.test.tsx index 52ef3754..536bbc33 100644 --- a/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.test.tsx +++ b/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.test.tsx @@ -253,4 +253,67 @@ describe("useRuleBasedRecognizeFlow", () => { expect(result.current.open).toBe(false) expect(result.current.plan).toBeUndefined() }) + + describe("isConfirmButtonDisabled", () => { + it("is false when idle with no plan", () => { + const { result } = renderFlow() + expect(result.current.isConfirmButtonDisabled).toBe(false) + }) + + it("is true while try-to-recognize is pending", async () => { + let resolveTryToRecognize: (plan: RecognizeMediaFilePlan) => void = () => {} + tryToRecognizeMutationMock.mutateAsync.mockImplementation( + () => + new Promise((resolve) => { + resolveTryToRecognize = resolve + }), + ) + tryToRecognizeMutationMock.isPending = true + + const { result } = renderFlow() + + act(() => { + result.current.start() + }) + + expect(result.current.isConfirmButtonDisabled).toBe(true) + + await act(async () => { + tryToRecognizeMutationMock.isPending = false + resolveTryToRecognize(pendingPlan) + }) + + expect(result.current.isConfirmButtonDisabled).toBe(false) + }) + + it("is false when plan has files that still need applying", async () => { + const { result } = renderFlow() + + await act(async () => { + await result.current.start() + }) + + expect(result.current.allPlanFilesUnchanged).toBe(false) + expect(result.current.isConfirmButtonDisabled).toBe(false) + }) + + it("is true when every plan file already matches mediaFiles", async () => { + const unchangedPlan: RecognizeMediaFilePlan = { + ...pendingPlan, + files: [ + { season: 1, episode: 1, path: `${mediaFolderPath}/S01E01.mkv` }, + ], + } + tryToRecognizeMutationMock.mutateAsync.mockResolvedValue(unchangedPlan) + + const { result } = renderFlow() + + await act(async () => { + await result.current.start() + }) + + expect(result.current.allPlanFilesUnchanged).toBe(true) + expect(result.current.isConfirmButtonDisabled).toBe(true) + }) + }) }) diff --git a/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.ts b/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.ts index 508649d8..0eb15baf 100644 --- a/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.ts +++ b/apps/ui/src/hooks/tv/useRuleBasedRecognizeFlow.ts @@ -158,6 +158,8 @@ export function useRuleBasedRecognizeFlow({ return isRuleBasedRecognizePlanFullyUnchanged(plan.files, mediaMetadata) }, [plan, mediaMetadata]) + const isConfirmButtonDisabled = loading || allPlanFilesUnchanged + return { plan, open, @@ -166,6 +168,7 @@ export function useRuleBasedRecognizeFlow({ tvShowTmdbId, notAllEpisodesRecognized, allPlanFilesUnchanged, + isConfirmButtonDisabled, confirm, cancel, start, diff --git a/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.test.tsx b/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.test.tsx index e9aff1d1..74b63a62 100644 --- a/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.test.tsx +++ b/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.test.tsx @@ -317,4 +317,64 @@ describe("useRuleBasedRenameFilesFlow", () => { expect(result.current.open).toBe(true) expect(result.current.plan).toEqual(pendingPlan) }) + + describe("isConfirmButtonDisabled", () => { + it("is false when idle with no plan", () => { + const { result } = renderFlow() + expect(result.current.isConfirmButtonDisabled).toBe(false) + }) + + it("is true while try-to-rename is pending", async () => { + let resolveTryToRename: (plan: typeof pendingPlan) => void = () => {} + tryToRenameEpisodesMutationMock.mutateAsync.mockImplementation( + () => + new Promise((resolve) => { + resolveTryToRename = resolve + }), + ) + tryToRenameEpisodesMutationMock.isPending = true + + const { result } = renderFlow() + + act(() => { + result.current.start() + }) + + expect(result.current.isConfirmButtonDisabled).toBe(true) + + await act(async () => { + tryToRenameEpisodesMutationMock.isPending = false + resolveTryToRename(pendingPlan) + }) + + expect(result.current.isConfirmButtonDisabled).toBe(false) + }) + + it("is false when plan has files to rename", async () => { + const { result } = renderFlow() + + await act(async () => { + await result.current.start() + }) + + expect(result.current.plan?.files.length).toBeGreaterThan(0) + expect(result.current.isConfirmButtonDisabled).toBe(false) + }) + + it("is true when plan has no files to rename (already match naming rule)", async () => { + tryToRenameEpisodesMutationMock.mutateAsync.mockResolvedValue({ + ...pendingPlan, + files: [], + }) + + const { result } = renderFlow() + + await act(async () => { + await result.current.start() + }) + + expect(result.current.plan?.files).toEqual([]) + expect(result.current.isConfirmButtonDisabled).toBe(true) + }) + }) }) diff --git a/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts b/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts index ce3316ef..ebca8078 100644 --- a/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts +++ b/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts @@ -182,10 +182,13 @@ export function useRuleBasedRenameFilesFlow({ reset ]) + const isConfirmButtonDisabled = loading || plan?.files.length === 0 + return { plan, open, loading, + isConfirmButtonDisabled, selectedNamingRule, namingRuleOptions, selectNamingRule, From 4c33ffa99de661009ca9044221c6813291c878da Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 10 Sep 2026 03:37:46 +0800 Subject: [PATCH 74/83] fix: fixed e2e test for movie --- .../src/pipeline/recognizeMediaFolder.test.ts | 45 +++++++ .../core/src/pipeline/recognizeMediaFolder.ts | 11 +- apps/ui/src/components/movie/MoviePanel.tsx | 39 +++++- .../lib/buildMovieEpisodeTableRows.test.ts | 110 +++++++++++++++- apps/ui/src/lib/buildMovieEpisodeTableRows.ts | 122 +++++++++++++++++- 5 files changed, 318 insertions(+), 9 deletions(-) diff --git a/apps/core/src/pipeline/recognizeMediaFolder.test.ts b/apps/core/src/pipeline/recognizeMediaFolder.test.ts index 95f19d81..7cff257d 100644 --- a/apps/core/src/pipeline/recognizeMediaFolder.test.ts +++ b/apps/core/src/pipeline/recognizeMediaFolder.test.ts @@ -290,6 +290,51 @@ describe("recognizeMediaFolder", () => { expect(d.tmdb.search).toHaveBeenCalledWith(folder2.folderName, "movie", "en-US"); }); + it("recognizes movie from first TMDB hit when localized title differs from folder name", async () => { + const d = deps(); + (d.tmdb.search as ReturnType).mockResolvedValue({ + results: [ + { + id: 1539104, + title: "JUJUTSU KAISEN: Execution", + original_title: "劇場版 呪術廻戦「渋谷事変 特別編集版」×「死滅回游 先行上映」", + }, + { id: 999, title: "Unrelated Movie" }, + ], + }); + + const created = createFolderInTestFolder(mediaDir, folder2); + const mm = await mediaMetadataFrom(created); + const result = await recognizeMediaFolder(mm, d); + + expect(result.movie).toEqual({ + id: "1539104", + name: "JUJUTSU KAISEN: Execution", + database: "TMDB", + }); + expect(d.tmdb.search).toHaveBeenCalledWith(folder2.folderName, "movie", "en-US"); + }); + + it("prefers exact TMDB title match over a non-matching first result", async () => { + const d = deps(); + (d.tmdb.search as ReturnType).mockResolvedValue({ + results: [ + { id: 1, title: "Wrong First Hit" }, + { id: 1539104, title: folder2.folderName }, + ], + }); + + const created = createFolderInTestFolder(mediaDir, folder2); + const mm = await mediaMetadataFrom(created); + const result = await recognizeMediaFolder(mm, d); + + expect(result.movie).toEqual({ + id: "1539104", + name: folder2.folderName, + database: "TMDB", + }); + }); + it("recognizes movie by TVDB folder name search", async () => { const d = deps({ primaryDatabase: "TVDB" }); (d.tvdb.searchMovie as ReturnType).mockResolvedValue([ diff --git a/apps/core/src/pipeline/recognizeMediaFolder.ts b/apps/core/src/pipeline/recognizeMediaFolder.ts index b8e478c3..8bab4f59 100644 --- a/apps/core/src/pipeline/recognizeMediaFolder.ts +++ b/apps/core/src/pipeline/recognizeMediaFolder.ts @@ -160,12 +160,11 @@ async function searchInTmdb( } } else { const body = await deps.tmdb.search(folderName, "movie", deps.language); - for (const item of body.results) { - const movie = item as TMDBMovie; - if (movie.title === folderName) { - result.movie = movieMediaMetadataFromTmdbSearch(movie); - return; - } + const movies = body.results as TMDBMovie[]; + const exact = movies.find((movie) => movie.title === folderName); + const chosen = exact ?? movies[0]; + if (chosen !== undefined) { + result.movie = movieMediaMetadataFromTmdbSearch(chosen); } } } catch { diff --git a/apps/ui/src/components/movie/MoviePanel.tsx b/apps/ui/src/components/movie/MoviePanel.tsx index e58a0f63..541f1e91 100644 --- a/apps/ui/src/components/movie/MoviePanel.tsx +++ b/apps/ui/src/components/movie/MoviePanel.tsx @@ -16,7 +16,13 @@ import { buildMovieFilesFromMediaMetadata, type MovieFileModel, } from "@/helpers/movie/buildMovieFilesFromMediaMetadata" -import { buildMovieEpisodeTableRows, type MovieRenamePreviewData } from "@/lib/buildMovieEpisodeTableRows" +import { + buildMovieEpisodeAssociatedFileLists, + buildMovieEpisodeTableRows, + buildMovieMediaFileTableSeasonData, + buildMovieMetadataFiles, + type MovieRenamePreviewData, +} from "@/lib/buildMovieEpisodeTableRows" import type { MediaMetadata } from "@/lib/mediaFolderFiles" import { useMediaFolderFilesQuery } from "@/hooks/useMediaFolderFilesQuery" import type { UIMediaFolderStatus } from "@/types/UIMediaFolder" @@ -28,6 +34,7 @@ import { MovieHeaderV2 } from "./MovieHeaderV2" import type { EpisodeTableLayout } from "../tv/TvShowPanelHeader" import { MediaFileTable } from "../media/MediaFileTable" import type { + MediaFileTableSeasonData, UIMediaFileDataRow, UIMediaFileTableRow, } from "../media/UIMediaFileTable" @@ -332,6 +339,30 @@ function MoviePanel() { }) }, [mediaMetadata, folderStatus, t, renamePreview, folderFiles]) + // MediaFileTable simple/detail/preview layouts render seasonData, not legacy `data`. + const seasonData = useMemo(() => { + if (!mediaMetadata) return [] + return buildMovieMediaFileTableSeasonData(mediaMetadata) + }, [mediaMetadata]) + + const metadataFiles = useMemo( + () => (mediaMetadata ? buildMovieMetadataFiles(mediaMetadata, folderFiles) : undefined), + [mediaMetadata, folderFiles], + ) + + const associatedFileLists = useMemo( + () => + mediaMetadata + ? buildMovieEpisodeAssociatedFileLists(mediaMetadata, folderFiles) + : { subtitleFiles: [], nfoFiles: [], thumbnailFiles: [] }, + [mediaMetadata, folderFiles], + ) + + const newFilePaths = useMemo(() => { + if (!renamePreview?.newVideoFile) return [] + return [{ season: 1, episode: 1, newFilePath: renamePreview.newVideoFile }] + }, [renamePreview]) + const handleVideoCompressClick = useCallback( (row: UIMediaFileDataRow) => { if (!row.videoFile) return @@ -370,6 +401,12 @@ function MoviePanel() { ) : ( { expect(episodeRow(rows).episodeTitle).toBeUndefined(); }); }); + +describe("buildMovieMediaFileTableSeasonData", () => { + it("returns empty when mediaFiles is empty", () => { + const mm = makeMediaMetadata({ mediaFiles: [] }); + expect(buildMovieMediaFileTableSeasonData(mm)).toEqual([]); + }); + + it("returns empty when mediaFolderPath is missing", () => { + const mm = makeMediaMetadata({ + mediaFolderPath: undefined, + mediaFiles: [{ absolutePath: "/media/movies/TestMovie/video.mkv" }], + }); + expect(buildMovieMediaFileTableSeasonData(mm)).toEqual([]); + }); + + it("builds one Movie season with S01E01 from the first video file", () => { + const mm = makeMediaMetadata({ + mediaFiles: [{ absolutePath: "/media/movies/TestMovie/video.mkv" }], + }); + expect(buildMovieMediaFileTableSeasonData(mm)).toEqual([ + { + season: 1, + title: "Movie", + episodes: [ + { + season: 1, + episode: 1, + title: "Test Movie", + path: "/media/movies/TestMovie/video.mkv", + }, + ], + }, + ]); + }); + + it("uses only the first mediaFile when multiple exist", () => { + const mm = makeMediaMetadata({ + mediaFiles: [ + { absolutePath: "/media/movies/TestMovie/main.mkv" }, + { absolutePath: "/media/movies/TestMovie/extra.mkv" }, + ], + }); + const seasons = buildMovieMediaFileTableSeasonData(mm); + expect(seasons[0]?.episodes[0]?.path).toBe("/media/movies/TestMovie/main.mkv"); + }); +}); + +describe("buildMovieMetadataFiles", () => { + it("finds poster, fanart, and movie.nfo", () => { + const mm = makeMediaMetadata(); + const files = [ + "/media/movies/TestMovie/video.mkv", + "/media/movies/TestMovie/poster.jpg", + "/media/movies/TestMovie/fanart.png", + "/media/movies/TestMovie/movie.nfo", + ]; + expect(buildMovieMetadataFiles(mm, files)).toEqual({ + posterPath: "/media/movies/TestMovie/poster.jpg", + fanartPath: "/media/movies/TestMovie/fanart.png", + nfoPath: "/media/movies/TestMovie/movie.nfo", + seasonPosters: [], + clearlogoPath: undefined, + themePath: undefined, + }); + }); +}); + +describe("buildMovieEpisodeAssociatedFileLists", () => { + it("maps stem-matched subtitle/nfo/thumbnail to S01E01", () => { + const mm = makeMediaMetadata({ + mediaFiles: [{ absolutePath: "/media/movies/TestMovie/video.mkv" }], + }); + const files = [ + "/media/movies/TestMovie/video.mkv", + "/media/movies/TestMovie/video.srt", + "/media/movies/TestMovie/video.nfo", + "/media/movies/TestMovie/video.jpg", + ]; + expect(buildMovieEpisodeAssociatedFileLists(mm, files)).toEqual({ + subtitleFiles: [{ season: 1, episode: 1, files: ["/media/movies/TestMovie/video.srt"] }], + nfoFiles: [{ season: 1, episode: 1, files: ["/media/movies/TestMovie/video.nfo"] }], + thumbnailFiles: [{ season: 1, episode: 1, files: ["/media/movies/TestMovie/video.jpg"] }], + }); + }); + + it("falls back to folder-level poster and movie.nfo", () => { + const mm = makeMediaMetadata({ + mediaFiles: [{ absolutePath: "/media/movies/TestMovie/video.mkv" }], + }); + const files = [ + "/media/movies/TestMovie/video.mkv", + "/media/movies/TestMovie/poster.jpg", + "/media/movies/TestMovie/movie.nfo", + ]; + const lists = buildMovieEpisodeAssociatedFileLists(mm, files); + expect(lists.thumbnailFiles).toEqual([ + { season: 1, episode: 1, files: ["/media/movies/TestMovie/poster.jpg"] }, + ]); + expect(lists.nfoFiles).toEqual([ + { season: 1, episode: 1, files: ["/media/movies/TestMovie/movie.nfo"] }, + ]); + }); +}); diff --git a/apps/ui/src/lib/buildMovieEpisodeTableRows.ts b/apps/ui/src/lib/buildMovieEpisodeTableRows.ts index 3bf88769..127b519c 100644 --- a/apps/ui/src/lib/buildMovieEpisodeTableRows.ts +++ b/apps/ui/src/lib/buildMovieEpisodeTableRows.ts @@ -1,8 +1,18 @@ -import type { UIMediaFileDataRow, UIMediaFileTableRow } from "@/components/media/UIMediaFileTable"; +import type { + MediaFileTableSeasonData, + UIMediaFileDataRow, + UIMediaFileTableRow, +} from "@/components/media/UIMediaFileTable"; import type { MediaMetadata } from "@/lib/mediaFolderFiles" import type { UIMediaFolderStatus } from "@/types/UIMediaFolder"; +import type { MetadataFiles } from "@smm/types/MetadataFiles"; import { basename, join } from "@/lib/path"; import { findAssociatedFiles } from "@/lib/utils"; +import { + findNfos, + findSubtitles, + findThumbnails, +} from "@/lib/tvShowEpisodeAssociatedFiles"; export interface MovieRenamePreviewData { newVideoFile?: string; @@ -10,6 +20,116 @@ export interface MovieRenamePreviewData { newNfo?: string; } +export type MovieEpisodeAssociatedFileLists = { + subtitleFiles: { season: number; episode: number; files: string[] }[]; + nfoFiles: { season: number; episode: number; files: string[] }[]; + thumbnailFiles: { season: number; episode: number; files: string[] }[]; +}; + +/** + * Builds seasonData for MediaFileTable simple/detail/preview layouts. + * Movies are modeled as one season ("Movie") with a single S01E01 episode. + */ +export function buildMovieMediaFileTableSeasonData( + mm: MediaMetadata, +): MediaFileTableSeasonData[] { + if (!mm.mediaFolderPath || !mm.mediaFiles || mm.mediaFiles.length === 0) { + return []; + } + + const videoFile = mm.mediaFiles[0]!; + return [ + { + season: 1, + title: "Movie", + episodes: [ + { + season: 1, + episode: 1, + title: mm.movie?.name ?? "", + path: videoFile.absolutePath, + }, + ], + }, + ]; +} + +/** Folder-level poster / fanart / movie.nfo for MediaFileTable metadata rows. */ +export function buildMovieMetadataFiles( + mm: MediaMetadata, + folderFiles: string[], +): MetadataFiles { + void mm; + const posterPath = folderFiles.find((f) => { + const name = basename(f); + return name != null && name.startsWith("poster."); + }); + const fanartPath = folderFiles.find((f) => { + const name = basename(f); + return name != null && name.startsWith("fanart."); + }); + const nfoPath = folderFiles.find((f) => basename(f) === "movie.nfo"); + + return { + posterPath, + fanartPath, + nfoPath, + seasonPosters: [], + clearlogoPath: undefined, + themePath: undefined, + }; +} + +/** + * Associated subtitle / nfo / thumbnail lists keyed as S01E01 for MediaFileTable. + * Prefers stem-matched files; falls back to folder-level poster.* / movie.nfo. + */ +export function buildMovieEpisodeAssociatedFileLists( + mm: MediaMetadata, + folderFiles: string[], +): MovieEpisodeAssociatedFileLists { + const empty: MovieEpisodeAssociatedFileLists = { + subtitleFiles: [], + nfoFiles: [], + thumbnailFiles: [], + }; + + if (!mm.mediaFolderPath || !mm.mediaFiles || mm.mediaFiles.length === 0) { + return empty; + } + + const videoPath = mm.mediaFiles[0]!.absolutePath; + let subtitles = findSubtitles(folderFiles, videoPath); + if (subtitles.length === 0 && mm.mediaFiles[0]!.subtitleFilePaths?.length) { + subtitles = [...mm.mediaFiles[0]!.subtitleFilePaths!]; + } + + let nfoFiles = findNfos(folderFiles, videoPath); + if (nfoFiles.length === 0) { + const movieNfo = folderFiles.find((f) => basename(f) === "movie.nfo"); + if (movieNfo) nfoFiles = [movieNfo]; + } + + let thumbnails = findThumbnails(folderFiles, videoPath); + if (thumbnails.length === 0) { + const poster = folderFiles.find((f) => { + const name = basename(f); + return name != null && name.startsWith("poster."); + }); + if (poster) thumbnails = [poster]; + } + + return { + subtitleFiles: subtitles.length + ? [{ season: 1, episode: 1, files: subtitles }] + : [], + nfoFiles: nfoFiles.length ? [{ season: 1, episode: 1, files: nfoFiles }] : [], + thumbnailFiles: thumbnails.length + ? [{ season: 1, episode: 1, files: thumbnails }] + : [], + }; +} + /** * Builds UIMediaFileTableRow[] from movie MediaMetadata. * Treats the movie as a "one season, one episode" TV show (S01E01). From eafd38b82e58ddbbdcf2c16d49f98efb30864bfc Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 10 Sep 2026 05:29:29 +0800 Subject: [PATCH 75/83] refactor(ui): extract shared MediaFileTableToolbar for TV and movie panels Replace duplicated TvShowPanelHeader/MovieHeaderV2 with a shared toolbar plus media-type hooks, and keep ImmersiveSearchbox open while nested language/database Selects are used so search controls stay usable. Co-authored-by: Cursor --- .../e2e/test/componentobjects/Searchbox.co.ts | 100 ++-- apps/ui/src/components/ImmersiveSearchbox.tsx | 18 +- .../media/MediaFileTableToolbar.stories.tsx | 148 +++++ .../media/MediaFileTableToolbar.test.tsx | 177 ++++++ .../media/MediaFileTableToolbar.tsx | 519 ++++++++++++++++++ .../ui/src/components/movie/MovieHeaderV2.tsx | 312 ----------- apps/ui/src/components/movie/MoviePanel.tsx | 28 +- .../ui/src/components/tv/TvShowPanel.test.tsx | 7 +- apps/ui/src/components/tv/TvShowPanel.tsx | 29 +- .../src/components/tv/TvShowPanelHeader.tsx | 438 --------------- .../media/mediaFileTableToolbarShared.ts | 65 +++ .../useMovieMediaFileTableToolbar.test.tsx} | 66 +-- .../movie/useMovieMediaFileTableToolbar.tsx | 166 ++++++ .../useTvShowMediaFileTableToolbar.test.tsx} | 38 +- .../tv/useTvShowMediaFileTableToolbar.tsx | 177 ++++++ ...09-10-media-file-table-toolbar-menu-api.md | 59 ++ ...6-09-10-media-file-table-toolbar-design.md | 117 ++++ 17 files changed, 1598 insertions(+), 866 deletions(-) create mode 100644 apps/ui/src/components/media/MediaFileTableToolbar.stories.tsx create mode 100644 apps/ui/src/components/media/MediaFileTableToolbar.test.tsx create mode 100644 apps/ui/src/components/media/MediaFileTableToolbar.tsx delete mode 100644 apps/ui/src/components/movie/MovieHeaderV2.tsx delete mode 100644 apps/ui/src/components/tv/TvShowPanelHeader.tsx create mode 100644 apps/ui/src/hooks/media/mediaFileTableToolbarShared.ts rename apps/ui/src/{components/movie/MovieHeaderV2.test.tsx => hooks/movie/useMovieMediaFileTableToolbar.test.tsx} (85%) create mode 100644 apps/ui/src/hooks/movie/useMovieMediaFileTableToolbar.tsx rename apps/ui/src/{components/tv/TvShowPanelHeader.test.tsx => hooks/tv/useTvShowMediaFileTableToolbar.test.tsx} (94%) create mode 100644 apps/ui/src/hooks/tv/useTvShowMediaFileTableToolbar.tsx create mode 100644 docs/superpowers/plans/2026-09-10-media-file-table-toolbar-menu-api.md create mode 100644 docs/superpowers/specs/2026-09-10-media-file-table-toolbar-design.md diff --git a/apps/e2e/test/componentobjects/Searchbox.co.ts b/apps/e2e/test/componentobjects/Searchbox.co.ts index 07723f64..c18aa75d 100644 --- a/apps/e2e/test/componentobjects/Searchbox.co.ts +++ b/apps/e2e/test/componentobjects/Searchbox.co.ts @@ -91,54 +91,82 @@ class SearchboxComponentObject { await selectItem.click() } + private languageTextMatches(text: string, languageOrCode: string): boolean { + const trimmed = text.trim() + return ( + trimmed === languageOrCode || + trimmed.startsWith(`${languageOrCode} (`) || + trimmed.endsWith(`(${languageOrCode})`) || + trimmed.includes(`(${languageOrCode})`) + ) + } + async setLanguage(languageOrCode: string) { const selectTrigger = await this.language await selectTrigger.waitForExist({ timeout: 5000 }) await selectTrigger.waitForDisplayed({ timeout: 5000 }) await selectTrigger.waitForClickable({ timeout: 5000 }) - await selectTrigger.click() - const byCodeSelector = `[data-testid="tmdb-search-language-option-${languageOrCode}"]` - const byCode = await $(byCodeSelector) - - // Wait for the option to appear in the open Select portal (cold Electron - // start can race a fixed pause and report "Language option not found"). - try { - await byCode.waitForExist({ timeout: 10000 }) - await byCode.waitForClickable({ timeout: 5000 }) - await byCode.click() + // Prefer-media-language is often already en-US; avoid opening the Select. + const currentLabel = String(await selectTrigger.getText().catch(() => '')).trim() + if (this.languageTextMatches(currentLabel, languageOrCode)) { return - } catch { - // Fall through to text match (display name may not use the code as test id). } - let targetItem: WebdriverIO.Element | undefined - await browser.waitUntil( - async () => { - const selectItems = await $$('[data-testid^="tmdb-search-language-option-"]') - for (const item of selectItems) { - const text = (await item.getText()).trim() - if ( - text === languageOrCode || - text.startsWith(`${languageOrCode} (`) || - text.endsWith(`(${languageOrCode})`) - ) { - targetItem = item - return true - } + const byCodeSelector = `[data-testid="tmdb-search-language-option-${languageOrCode}"]` + const maxAttempts = 3 + let lastError: Error | undefined + + // Cold start can remount Select items when language lists hydrate; retry + // open+click so we do not fail on a single stale portal. + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + await selectTrigger.click() + await browser.pause(200) + + const byCode = await $(byCodeSelector) + try { + await byCode.waitForExist({ timeout: 5000 }) + await byCode.waitForClickable({ timeout: 5000 }) + await byCode.click() + return + } catch { + // Fall through to text match (display name may not use the code as test id). } - return false - }, - { - timeout: 10000, - interval: 200, - timeoutMsg: `Language option "${languageOrCode}" not found`, - }, - ) - await targetItem!.waitForClickable({ timeout: 5000 }) - await targetItem!.click() + let targetItem: WebdriverIO.Element | undefined + await browser.waitUntil( + async () => { + const selectItems = await $$('[data-testid^="tmdb-search-language-option-"]') + for (const item of selectItems) { + const text = (await item.getText()).trim() + if (this.languageTextMatches(text, languageOrCode)) { + targetItem = item + return true + } + } + return false + }, + { + timeout: 5000, + interval: 200, + timeoutMsg: `Language option "${languageOrCode}" not found`, + }, + ) + + await targetItem!.waitForClickable({ timeout: 5000 }) + await targetItem!.click() + return + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)) + // Dismiss a half-open select before retrying. + await browser.keys('Escape').catch(() => undefined) + await browser.pause(300) + } + } + + throw lastError ?? new Error(`Language option "${languageOrCode}" not found`) } async selectSearchResultByText(text: string) { diff --git a/apps/ui/src/components/ImmersiveSearchbox.tsx b/apps/ui/src/components/ImmersiveSearchbox.tsx index f3d3b362..ca632af4 100644 --- a/apps/ui/src/components/ImmersiveSearchbox.tsx +++ b/apps/ui/src/components/ImmersiveSearchbox.tsx @@ -207,7 +207,23 @@ export function ImmersiveSearchbox({ }} onInteractOutside={(e) => { const target = e.target as HTMLElement - if (inputContainerRef.current?.contains(target)) { + // Keep the search popover open while using nested Selects + // (database / language). Their content portals to , + // so without this the popover closes and unmounts the options. + if ( + inputContainerRef.current?.contains(target) || + target.closest('[data-slot="select-content"]') || + target.closest('[data-slot="select-trigger"]') + ) { + e.preventDefault() + } + }} + onFocusOutside={(e) => { + const target = e.target as HTMLElement + if ( + target.closest('[data-slot="select-content"]') || + target.closest('[data-slot="select-trigger"]') + ) { e.preventDefault() } }} diff --git a/apps/ui/src/components/media/MediaFileTableToolbar.stories.tsx b/apps/ui/src/components/media/MediaFileTableToolbar.stories.tsx new file mode 100644 index 00000000..39d2789c --- /dev/null +++ b/apps/ui/src/components/media/MediaFileTableToolbar.stories.tsx @@ -0,0 +1,148 @@ +import { useState, type ComponentProps } from "react" +import type { Meta, StoryObj } from "@storybook/react-vite" +import { action } from "storybook/actions" +import { + MediaFileTableToolbar, + type EpisodeTableLayout, + type MediaFileTableMenuId, +} from "./MediaFileTableToolbar" + +function SearchLeading({ value }: { value: string }) { + return ( + + ) +} + +const sharedCallbacks = { + onRecognizeButtonClick: action("recognize"), + onRenameButtonClick: action("rename"), + onScrapeButtonClick: action("scrape"), + onTranscribeClick: action("transcribe"), + onTranslateClick: action("translate"), + onSynthesizeClick: action("synthesize"), + onProcessClick: action("process"), +} + +function InteractiveToolbar( + props: Omit, "layout" | "onLayoutChange" | "leading"> & { + title: string + }, +) { + const [layout, setLayout] = useState("simple") + return ( + } + layout={layout} + onLayoutChange={setLayout} + loading={props.loading} + showPreviewLayoutButton={props.showPreviewLayoutButton} + hiddenMenuIds={props.hiddenMenuIds} + disabledMenuIds={props.disabledMenuIds} + externalUrl={props.externalUrl} + testIdPrefix={props.testIdPrefix} + {...sharedCallbacks} + /> + ) +} + +const meta = { + title: "Components/MediaFileTableToolbar", + component: MediaFileTableToolbar, + parameters: { + layout: "padded", + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const TvShow: Story = { + args: { + leading: , + externalUrl: "https://www.themoviedb.org/tv/1396", + testIdPrefix: "tvshow-header", + }, + render: () => ( + + ), +} + +export const Movie: Story = { + args: { + leading: , + hiddenMenuIds: ["recognize"] satisfies MediaFileTableMenuId[], + externalUrl: "https://www.themoviedb.org/movie/27205", + testIdPrefix: "movie-header", + }, + render: () => ( + + ), +} + +export const Loading: Story = { + args: { + leading: , + loading: true, + }, +} + +export const HarmonyOS: Story = { + args: { + leading: , + showPreviewLayoutButton: false, + hiddenMenuIds: ["subtitle", "transcribe", "translate", "synthesize", "process"], + externalUrl: "https://www.themoviedb.org/tv/1396", + }, + render: () => ( + + ), +} + +export const Unrecognized: Story = { + args: { + leading: , + disabledMenuIds: [ + "recognize", + "rename", + "scrape", + "subtitle", + "transcribe", + "translate", + "synthesize", + "process", + ], + }, + render: () => ( + + ), +} diff --git a/apps/ui/src/components/media/MediaFileTableToolbar.test.tsx b/apps/ui/src/components/media/MediaFileTableToolbar.test.tsx new file mode 100644 index 00000000..8bb8cd96 --- /dev/null +++ b/apps/ui/src/components/media/MediaFileTableToolbar.test.tsx @@ -0,0 +1,177 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { MediaFileTableToolbar } from './MediaFileTableToolbar' + +vi.mock('@/components/ui/dropdown-menu', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('react') + return { + DropdownMenu: ({ children }: any) =>
{children}
, + DropdownMenuTrigger: ({ children, asChild: _asChild }: any) =>
{children}
, + DropdownMenuContent: ({ children }: any) =>
{children}
, + DropdownMenuItem: ({ children, disabled, onClick, className, ...rest }: any) => ( +
{children}
+ ), + DropdownMenuSeparator: ({ className }: any) =>
, + } +}) + +vi.mock('@/lib/i18n', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslation: vi.fn(() => ({ + t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key, + })), + } +}) + +describe('MediaFileTableToolbar', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders the leading slot', () => { + render( + Search} + />, + ) + expect(screen.getByTestId('leading')).toHaveTextContent('Search') + }) + + it('renders built-in rename/scrape and invokes callbacks', () => { + const onRenameButtonClick = vi.fn() + const onScrapeButtonClick = vi.fn() + render( + Search} + onRenameButtonClick={onRenameButtonClick} + onScrapeButtonClick={onScrapeButtonClick} + />, + ) + + fireEvent.click(screen.getByTestId('rename-button')) + fireEvent.click(screen.getByTestId('scrape-button')) + expect(onRenameButtonClick).toHaveBeenCalledTimes(1) + expect(onScrapeButtonClick).toHaveBeenCalledTimes(1) + }) + + it('hides recognize when listed in hiddenMenuIds', () => { + const { rerender } = render( + Search} + hiddenMenuIds={['recognize']} + onRecognizeButtonClick={vi.fn()} + />, + ) + expect(screen.queryByTestId('recognize-button')).not.toBeInTheDocument() + + rerender( + Search} + onRecognizeButtonClick={vi.fn()} + />, + ) + expect(screen.getByTestId('recognize-button')).toBeInTheDocument() + }) + + it('disables menu items listed in disabledMenuIds', () => { + render( + Search} + disabledMenuIds={['rename', 'scrape']} + />, + ) + expect(screen.getByTestId('rename-button')).toBeDisabled() + expect(screen.getByTestId('scrape-button')).toBeDisabled() + }) + + it('hides subtitle menu and children when subtitle is hidden', () => { + render( + Search} + testIdPrefix="tvshow-header" + hiddenMenuIds={['subtitle']} + onTranscribeClick={vi.fn()} + />, + ) + expect(screen.queryByTestId('tvshow-header-subtitle')).not.toBeInTheDocument() + expect(screen.queryByTestId('tvshow-header-transcribe')).not.toBeInTheDocument() + }) + + it('opens externalUrl from the More menu', () => { + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + render( + Search} + externalUrl="https://www.themoviedb.org/tv/123" + />, + ) + fireEvent.click(screen.getByText('Open in TMDB')) + expect(openSpy).toHaveBeenCalledWith( + 'https://www.themoviedb.org/tv/123', + '_blank', + 'noopener,noreferrer', + ) + openSpy.mockRestore() + }) + + it('disables openExternal when externalUrl is missing', () => { + render( + Search} + />, + ) + expect(screen.getByText('Open in TMDB').closest('[role="menuitem"]')).toHaveAttribute( + 'aria-disabled', + 'true', + ) + }) + + it('shows TVDB label when externalUrl points at TVDB', () => { + render( + Search} + externalUrl="https://www.thetvdb.com/search?query=1" + />, + ) + expect(screen.getByText('Open in TVDB')).toBeInTheDocument() + }) + + it('replaces leading and actions with skeletons while loading', () => { + render( + Search} + loading + />, + ) + expect(screen.queryByTestId('leading')).not.toBeInTheDocument() + expect(screen.queryByTestId('rename-button')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument() + }) + + it('reports layout changes and hides preview when showPreviewLayoutButton is false', () => { + const onLayoutChange = vi.fn() + const { rerender } = render( + Search} + layout="simple" + onLayoutChange={onLayoutChange} + />, + ) + fireEvent.click(screen.getByRole('button', { name: 'Detail layout' })) + expect(onLayoutChange).toHaveBeenCalledWith('detail') + + rerender( + Search} + layout="simple" + onLayoutChange={onLayoutChange} + showPreviewLayoutButton={false} + />, + ) + expect(screen.queryByRole('button', { name: 'Preview layout' })).not.toBeInTheDocument() + }) +}) diff --git a/apps/ui/src/components/media/MediaFileTableToolbar.tsx b/apps/ui/src/components/media/MediaFileTableToolbar.tsx new file mode 100644 index 00000000..4d970f0b --- /dev/null +++ b/apps/ui/src/components/media/MediaFileTableToolbar.tsx @@ -0,0 +1,519 @@ +import { Fragment, type ReactNode } from "react" +import { + MoreVertical, + List, + LayoutGrid, + PanelTop, + ChevronDown, + Scan, + FileEdit, + Download, + Captions, + FileVideo, + Sparkles, + ExternalLink, +} from "lucide-react" +import { Skeleton } from "@/components/ui/skeleton" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { useTranslation } from "@/lib/i18n" +import { cn } from "@/lib/utils" + +export type EpisodeTableLayout = "simple" | "detail" | "preview" + +export type MediaFileTableMenuId = + | "recognize" + | "rename" + | "scrape" + | "subtitle" + | "transcribe" + | "translate" + | "synthesize" + | "process" + | "openExternal" + +export type MediaFileTableToolbarCollapseAt = 410 | 310 | 220 | 200 + +export interface MediaFileTableToolbarProps { + leading: ReactNode + loading?: boolean + layout?: EpisodeTableLayout + onLayoutChange?: (layout: EpisodeTableLayout) => void + /** When false, the preview layout button/menu item is hidden (e.g. HarmonyOS). */ + showPreviewLayoutButton?: boolean + /** + * When true (default), leading is capped at 50% width so action buttons keep room + * (TV layout). Movie historically used full flex-1 for the searchbox — set false there. + */ + constrainLeading?: boolean + hiddenMenuIds?: MediaFileTableMenuId[] + disabledMenuIds?: MediaFileTableMenuId[] + onRecognizeButtonClick?: () => void + onRenameButtonClick?: () => void + onScrapeButtonClick?: () => void + onTranscribeClick?: () => void + onTranslateClick?: () => void + onSynthesizeClick?: () => void + onProcessClick?: () => void + /** Toolbar opens this URL for Open in TMDB/TVDB. Missing URL disables that item. */ + externalUrl?: string + /** Prefix for subtitle-related test ids, e.g. `tvshow-header` / `movie-header`. */ + testIdPrefix?: string +} + +const BUTTON_COLLAPSE_CLASS: Record = { + 410: "hidden @[410px]:inline-flex", + 310: "hidden @[310px]:inline-flex", + 220: "hidden @[220px]:inline-flex", + 200: "hidden @[200px]:inline-flex", +} + +const OVERFLOW_COLLAPSE_CLASS: Record = { + 410: "@[410px]:hidden", + 310: "@[310px]:hidden", + 220: "@[220px]:hidden", + 200: "@[200px]:hidden", +} + +function isHidden(ids: MediaFileTableMenuId[] | undefined, id: MediaFileTableMenuId): boolean { + return ids?.includes(id) ?? false +} + +function isDisabled(ids: MediaFileTableMenuId[] | undefined, id: MediaFileTableMenuId): boolean { + return ids?.includes(id) ?? false +} + +function isTvdbUrl(url: string | undefined): boolean { + return !!url && url.includes("thetvdb.com") +} + +export function MediaFileTableToolbar({ + leading, + loading = false, + layout = "simple", + onLayoutChange, + showPreviewLayoutButton = true, + constrainLeading = true, + hiddenMenuIds, + disabledMenuIds, + onRecognizeButtonClick, + onRenameButtonClick, + onScrapeButtonClick, + onTranscribeClick, + onTranslateClick, + onSynthesizeClick, + onProcessClick, + externalUrl, + testIdPrefix = "media-file-table-toolbar", +}: MediaFileTableToolbarProps) { + const { t } = useTranslation(["components"]) + const simpleLabel = t("tvShow.layoutSimple", { ns: "components", defaultValue: "Simple layout" }) + const detailLabel = t("tvShow.layoutDetail", { ns: "components", defaultValue: "Detail layout" }) + const previewLabel = t("tvShow.layoutPreview", { ns: "components", defaultValue: "Preview layout" }) + const moreAriaLabel = t("tvShow.more", { ns: "components", defaultValue: "More" }) + + const showRecognize = !isHidden(hiddenMenuIds, "recognize") + const showRename = !isHidden(hiddenMenuIds, "rename") + const showScrape = !isHidden(hiddenMenuIds, "scrape") + const showSubtitle = !isHidden(hiddenMenuIds, "subtitle") + const showOpenExternal = !isHidden(hiddenMenuIds, "openExternal") + + const showTranscribe = showSubtitle && !isHidden(hiddenMenuIds, "transcribe") + const showTranslate = showSubtitle && !isHidden(hiddenMenuIds, "translate") + const showSynthesize = showSubtitle && !isHidden(hiddenMenuIds, "synthesize") + const showProcess = showSubtitle && !isHidden(hiddenMenuIds, "process") + + const transcribeDisabled = isDisabled(disabledMenuIds, "transcribe") + const translateDisabled = isDisabled(disabledMenuIds, "translate") + const synthesizeDisabled = isDisabled(disabledMenuIds, "synthesize") + const processDisabled = isDisabled(disabledMenuIds, "process") + + const visibleSubtitleChildrenDisabled = [ + showTranscribe && transcribeDisabled, + showTranslate && translateDisabled, + showSynthesize && synthesizeDisabled, + showProcess && processDisabled, + ].filter((entry) => entry !== false) + + const allVisibleSubtitleChildrenDisabled = + visibleSubtitleChildrenDisabled.length > 0 && + visibleSubtitleChildrenDisabled.every(Boolean) + + const subtitleDisabled = + isDisabled(disabledMenuIds, "subtitle") || allVisibleSubtitleChildrenDisabled + + const openExternalDisabled = + !externalUrl || isDisabled(disabledMenuIds, "openExternal") + + const openExternalLabel = isTvdbUrl(externalUrl) + ? t("tvShow.openInTvdb", { ns: "components", defaultValue: "Open in TVDB" }) + : t("tvShow.openInTmdb", { ns: "components", defaultValue: "Open in TMDB" }) + + const primaryButtonCount = + Number(showRecognize) + Number(showRename) + Number(showScrape) + Number(showSubtitle) + const skeletonCount = Math.max(primaryButtonCount, 2) + + return ( +
+
+
+ {loading ? ( + + ) : ( + leading + )} +
+
+ {onLayoutChange && ( + + )} + {loading ? ( + <> + {Array.from({ length: skeletonCount }, (_, index) => ( + + ))} + + ) : ( + <> + {showRecognize && ( + + )} + {showRename && ( + + )} + {showScrape && ( + + )} + {showSubtitle && ( + + + + + + {showTranscribe && ( + onTranscribeClick?.()} + data-testid={`${testIdPrefix}-transcribe`} + > + + {t("mediaPlayer.trackContextMenu.transcribe", { ns: "components" })} + + )} + {showTranslate && ( + onTranslateClick?.()} + data-testid={`${testIdPrefix}-translate`} + > + {t("mediaPlayer.trackContextMenu.translate", { ns: "components" })} + + )} + {showSynthesize && ( + onSynthesizeClick?.()} + data-testid={`${testIdPrefix}-synthesize`} + > + + {t("mediaPlayer.trackContextMenu.synthesize", { ns: "components" })} + + )} + {showProcess && ( + onProcessClick?.()} + data-testid={`${testIdPrefix}-process`} + > + + {t("mediaPlayer.trackContextMenu.process", { ns: "components" })} + + )} + + + )} + + + + + + {onLayoutChange && ( + <> + onLayoutChange("simple")} + > + + {simpleLabel} + + onLayoutChange("detail")} + > + + {detailLabel} + + {showPreviewLayoutButton && ( + onLayoutChange("preview")} + > + + {previewLabel} + + )} + + )} + {showRecognize && ( + onRecognizeButtonClick?.()} + > + + {t("tvShow.recognize", { ns: "components", defaultValue: "Recognize" })} + + )} + {showRename && ( + onRenameButtonClick?.()} + > + + {t("tvShow.rename", { ns: "components" })} + + )} + {showScrape && ( + onScrapeButtonClick?.()} + > + + {t("tvShow.scrape", { ns: "components" })} + + )} + {showSubtitle && ( + + + {showTranscribe && ( + onTranscribeClick?.()} + data-testid={`${testIdPrefix}-transcribe-overflow`} + > + + {t("mediaPlayer.trackContextMenu.transcribe", { ns: "components" })} + + )} + {showTranslate && ( + onTranslateClick?.()} + data-testid={`${testIdPrefix}-translate-overflow`} + > + {t("mediaPlayer.trackContextMenu.translate", { ns: "components" })} + + )} + {showSynthesize && ( + onSynthesizeClick?.()} + data-testid={`${testIdPrefix}-synthesize-overflow`} + > + + {t("mediaPlayer.trackContextMenu.synthesize", { ns: "components" })} + + )} + {showProcess && ( + onProcessClick?.()} + data-testid={`${testIdPrefix}-process-overflow`} + > + + {t("mediaPlayer.trackContextMenu.process", { ns: "components" })} + + )} + + )} + {showOpenExternal && ( + <> + + { + if (!externalUrl) return + window.open(externalUrl, "_blank", "noopener,noreferrer") + }} + > + + {openExternalLabel} + + + )} + + + + )} +
+
+
+ ) +} + +function LayoutSwitcher({ + layout, + onLayoutChange, + simpleLabel, + detailLabel, + previewLabel, + showPreviewLayoutButton, + disabled, +}: { + layout: EpisodeTableLayout + onLayoutChange: (layout: EpisodeTableLayout) => void + simpleLabel: string + detailLabel: string + previewLabel: string + showPreviewLayoutButton: boolean + disabled: boolean +}) { + return ( +
+ +
+ + {showPreviewLayoutButton && ( + <> +
+ + + )} +
+ ) +} diff --git a/apps/ui/src/components/movie/MovieHeaderV2.tsx b/apps/ui/src/components/movie/MovieHeaderV2.tsx deleted file mode 100644 index 270a082f..00000000 --- a/apps/ui/src/components/movie/MovieHeaderV2.tsx +++ /dev/null @@ -1,312 +0,0 @@ -import type { MediaMetadata } from "@smm/types" -import type { UIMediaFolder } from "@/types/UIMediaFolder" -import { FileEdit, Download, MoreVertical, ExternalLink, Captions, ChevronDown, FileVideo, Sparkles, List, LayoutGrid, PanelTop } from "lucide-react" -import { MediaDatabaseSearchbox } from "../MediaDatabaseSearchbox" -import { Skeleton } from "@/components/ui/skeleton" -import { Button } from "../ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { useTranslation } from "@/lib/i18n" -import { useMemo } from "react" -import { cn } from "@/lib/utils" -import { isHarmonyOS } from "@/lib/isHarmonyOS" - -export interface MovieHeaderV2Props { - onSearchResultSelected: (args: import("../MediaDatabaseSearchbox").SearchResultSelectedArgs) => void - onRenameClick?: () => void - onTranscribeClick?: () => void - onTranslateClick?: () => void - onSynthesizeClick?: () => void - onProcessClick?: () => void - isTranscribeAvailable?: boolean - hasTranscribeTargets?: boolean - isTranslateAvailable?: boolean - hasTranslateTargets?: boolean - isSynthesizeAvailable?: boolean - hasSynthesizeTargets?: boolean - isProcessAvailable?: boolean - hasProcessTargets?: boolean - /** When false, subtitle dropdown is hidden (e.g. HarmonyOS). */ - showSubtitleMenu?: boolean - selectedMediaMetadata?: MediaMetadata - episodeTableLayout?: "simple" | "detail" | "preview" - onEpisodeTableLayoutChange?: (layout: "simple" | "detail" | "preview") => void - selectedMediaFolder?: UIMediaFolder - openScrape?: (params: { mediaMetadata: MediaMetadata }) => void -} - -export function MovieHeaderV2({ - onSearchResultSelected, - onRenameClick, - onTranscribeClick, - onTranslateClick, - onSynthesizeClick, - onProcessClick, - isTranscribeAvailable = false, - hasTranscribeTargets = false, - isTranslateAvailable = false, - hasTranslateTargets = false, - isSynthesizeAvailable = false, - hasSynthesizeTargets = false, - isProcessAvailable = false, - hasProcessTargets = false, - showSubtitleMenu = true, - selectedMediaMetadata, - selectedMediaFolder, - openScrape, - episodeTableLayout = "simple", - onEpisodeTableLayoutChange, -}: MovieHeaderV2Props) { - const { t } = useTranslation(['components', 'errors', 'dialogs']) - - const folderStatus = selectedMediaFolder?.status - const movieMeta = selectedMediaMetadata?.movie - const isUpdatingMovie = selectedMediaFolder === undefined - || folderStatus === 'idle' - || folderStatus === 'pending_for_initialization' - || folderStatus === 'initializing' - || folderStatus === 'loading' - || folderStatus === 'updating' - const isMediaMetadataOk = folderStatus === 'ok' - const initialSearchValue = movieMeta?.name ?? '' - - const hasValidMovieMetadata = movieMeta != null - const actionsDisabled = !hasValidMovieMetadata - const unrecognizedHint = - isMediaMetadataOk && actionsDisabled - ? (t('movie.unrecognizedFolderHint' as any, { ns: 'components' }) as string) // eslint-disable-line @typescript-eslint/no-explicit-any - : undefined - - const database = movieMeta?.database - const mediaId = movieMeta?.id - const mediaName = movieMeta?.name ?? '' - const isTmdb = database === 'TMDB' - const transcribeBlocked = - actionsDisabled || - !hasTranscribeTargets || - !isTranscribeAvailable - const translateBlocked = - actionsDisabled || - !hasTranslateTargets || - !isTranslateAvailable - const synthesizeBlocked = - actionsDisabled || - !hasSynthesizeTargets || - !isSynthesizeAvailable - const processBlocked = - actionsDisabled || - !hasProcessTargets || - !isProcessAvailable - const subtitleBlocked = transcribeBlocked && translateBlocked && synthesizeBlocked && processBlocked - const hasExternalId = !!mediaId - const externalUrl = hasExternalId - ? isTmdb - ? `https://www.themoviedb.org/movie/${mediaId}` - : `https://www.thetvdb.com/search?query=${encodeURIComponent(`${mediaId} ${mediaName}`)}` - : undefined - - const isHarmonyOSRuntime = useMemo(() => isHarmonyOS(), []) - - return ( -
-
-
- {isUpdatingMovie ? ( - - ) : ( - - )} -
-
- {onEpisodeTableLayoutChange && ( -
- -
- - {!isHarmonyOSRuntime && ( - <> -
- - - )} -
- )} - {isUpdatingMovie ? ( - <> - - - - ) : ( - <> - - - {showSubtitleMenu && ( - - - - - - onTranscribeClick?.()} - data-testid="movie-header-transcribe" - > - - {t('mediaPlayer.trackContextMenu.transcribe', { ns: 'components' })} - - onTranslateClick?.()} - data-testid="movie-header-translate" - > - {t('mediaPlayer.trackContextMenu.translate', { ns: 'components' })} - - onSynthesizeClick?.()} - data-testid="movie-header-synthesize" - > - - {t('mediaPlayer.trackContextMenu.synthesize', { ns: 'components' })} - - onProcessClick?.()} - data-testid="movie-header-process" - > - - {t('mediaPlayer.trackContextMenu.process', { ns: 'components' })} - - - - )} - - - - - - externalUrl && window.open(externalUrl, '_blank', 'noopener,noreferrer')} - > - - {database === 'TVDB' - ? t('movie.openInTvdb', { ns: 'components', defaultValue: 'Open in TVDB' }) - : t('movie.openInTmdb', { ns: 'components', defaultValue: 'Open in TMDB' })} - - - - - )} -
-
-
- ) -} diff --git a/apps/ui/src/components/movie/MoviePanel.tsx b/apps/ui/src/components/movie/MoviePanel.tsx index 541f1e91..3451f1e7 100644 --- a/apps/ui/src/components/movie/MoviePanel.tsx +++ b/apps/ui/src/components/movie/MoviePanel.tsx @@ -30,8 +30,8 @@ import { UI_AskForVideoCompression, type OnAskForVideoCompressionEventData, } from "@/types/eventTypes" -import { MovieHeaderV2 } from "./MovieHeaderV2" -import type { EpisodeTableLayout } from "../tv/TvShowPanelHeader" +import { MediaFileTableToolbar, type EpisodeTableLayout } from "@/components/media/MediaFileTableToolbar" +import { useMovieMediaFileTableToolbar } from "@/hooks/movie/useMovieMediaFileTableToolbar" import { MediaFileTable } from "../media/MediaFileTable" import type { MediaFileTableSeasonData, @@ -375,6 +375,18 @@ function MoviePanel() { [], ) + const mediaFileTableToolbarProps = useMovieMediaFileTableToolbar({ + onSearchResultSelected: handleSelectResult, + onRenameClick: () => setIsRuleBasedRenameFilePromptOpen(true), + showSubtitleMenu: subtitleFlow.showSubtitleMenu, + ...subtitleFlow.header, + selectedMediaMetadata: mediaMetadata, + selectedMediaFolder: uiFolderRow, + openScrape: askForScrape, + episodeTableLayout: layout, + onEpisodeTableLayoutChange: setLayout, + }) + return (
@@ -383,17 +395,7 @@ function MoviePanel() {
- setIsRuleBasedRenameFilePromptOpen(true)} - showSubtitleMenu={subtitleFlow.showSubtitleMenu} - {...subtitleFlow.header} - selectedMediaMetadata={mediaMetadata} - selectedMediaFolder={uiFolderRow} - openScrape={askForScrape} - episodeTableLayout={layout} - onEpisodeTableLayoutChange={setLayout} - /> +
{folderStatus === "initializing" ? ( diff --git a/apps/ui/src/components/tv/TvShowPanel.test.tsx b/apps/ui/src/components/tv/TvShowPanel.test.tsx index 0807ea09..0d70a7c7 100644 --- a/apps/ui/src/components/tv/TvShowPanel.test.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.test.tsx @@ -187,7 +187,12 @@ vi.mock("@/hooks/tv/useAiBasedRecognizeEpisodeFlow", () => ({ }), })) -vi.mock("./TvShowPanelHeader", () => ({ TvShowPanelHeader: () => null })) +vi.mock("@/components/media/MediaFileTableToolbar", () => ({ + MediaFileTableToolbar: () => null, +})) +vi.mock("@/hooks/tv/useTvShowMediaFileTableToolbar", () => ({ + useTvShowMediaFileTableToolbar: () => ({}), +})) vi.mock("./TvShowPanelPrompts", () => ({ TvShowPanelPrompts: () => null })) vi.mock("../RuleBasedRenameFilePrompt", () => ({ RuleBasedRenameFilePrompt: () => null, diff --git a/apps/ui/src/components/tv/TvShowPanel.tsx b/apps/ui/src/components/tv/TvShowPanel.tsx index 3f8043e7..dd36c800 100644 --- a/apps/ui/src/components/tv/TvShowPanel.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.tsx @@ -25,7 +25,8 @@ import type { UIMediaEpisodeSelection, } from "@/components/media/UIMediaFileTable" import { useRenameVideoFileFlow } from "@/hooks/useRenameVideoFileFlow" -import { TvShowPanelHeader } from "./TvShowPanelHeader" +import { MediaFileTableToolbar } from "@/components/media/MediaFileTableToolbar" +import { useTvShowMediaFileTableToolbar } from "@/hooks/tv/useTvShowMediaFileTableToolbar" import { MediaPanelInitializingHint } from "../MediaPanelInitializingHint" import { TranscribeDialog, SubtitleTranslationDialog, SynthesizeSubtitleDialog, ProcessPipelineDialog } from "@/components/dialogs" import { useFeatures } from "@/hooks/useFeatures" @@ -334,6 +335,19 @@ function TvShowPanel() { } }, [recognizeFlow, selectedEpisodes]) + const mediaFileTableToolbarProps = useTvShowMediaFileTableToolbar({ + onSearchResultSelected: handleSelectResult, + onRecognizeButtonClick: recognizeFlow.start, + onRenameClick: renameFlow.start, + selectedMediaMetadata: mediaMetadata, + selectedMediaFolder: uiFolderRow, + openScrape: askForScrape, + showSubtitleMenu: subtitleFlow.showSubtitleMenu, + ...subtitleFlow.header, + episodeTableLayout, + onEpisodeTableLayoutChange: setEpisodeTableLayout, + }) + return (
{/* */} @@ -349,18 +363,7 @@ function TvShowPanel() {
- +
{uiStatus === "initializing" ? ( diff --git a/apps/ui/src/components/tv/TvShowPanelHeader.tsx b/apps/ui/src/components/tv/TvShowPanelHeader.tsx deleted file mode 100644 index 382f98b1..00000000 --- a/apps/ui/src/components/tv/TvShowPanelHeader.tsx +++ /dev/null @@ -1,438 +0,0 @@ -import type { MediaMetadata } from "@smm/types" -import type { UIMediaFolder } from "@/types/UIMediaFolder" -import { FileEdit, Download, Scan, MoreVertical, ExternalLink, List, LayoutGrid, PanelTop, Captions, ChevronDown, FileVideo, Sparkles } from "lucide-react" -import { MediaDatabaseSearchbox } from "../MediaDatabaseSearchbox" -import { Skeleton } from "@/components/ui/skeleton" -import { Button } from "../ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { useTranslation } from "@/lib/i18n" -import { useMemo } from "react" -import { cn } from "@/lib/utils" -import { isHarmonyOS } from "@/lib/isHarmonyOS" - -export type EpisodeTableLayout = "simple" | "detail" | "preview" - -export interface TvShowPanelHeaderProps { - onSearchResultSelected: (args: import("../MediaDatabaseSearchbox").SearchResultSelectedArgs) => void - onRecognizeButtonClick?: () => void - onRenameClick?: () => void - /** Opens transcribe dialog when VideoCaptioner is available and there are video files. */ - onTranscribeClick?: () => void - onTranslateClick?: () => void - onSynthesizeClick?: () => void - onProcessClick?: () => void - isTranscribeAvailable?: boolean - /** True when `mediaFiles` has at least one entry (caller-derived). */ - hasTranscribeTargets?: boolean - isTranslateAvailable?: boolean - hasTranslateTargets?: boolean - isSynthesizeAvailable?: boolean - hasSynthesizeTargets?: boolean - isProcessAvailable?: boolean - hasProcessTargets?: boolean - /** When false, subtitle dropdown and overflow items are hidden (e.g. HarmonyOS). */ - showSubtitleMenu?: boolean - selectedMediaMetadata?: MediaMetadata - selectedMediaFolder?: UIMediaFolder - openScrape?: (params: { mediaMetadata: MediaMetadata }) => void - episodeTableLayout?: EpisodeTableLayout - onEpisodeTableLayoutChange?: (layout: EpisodeTableLayout) => void -} - -export function TvShowPanelHeader({ - onSearchResultSelected, - onRecognizeButtonClick, - onRenameClick, - onTranscribeClick, - onTranslateClick, - onSynthesizeClick, - onProcessClick, - isTranscribeAvailable = false, - hasTranscribeTargets = false, - isTranslateAvailable = false, - hasTranslateTargets = false, - isSynthesizeAvailable = false, - hasSynthesizeTargets = false, - isProcessAvailable = false, - hasProcessTargets = false, - showSubtitleMenu = true, - selectedMediaMetadata, - selectedMediaFolder, - openScrape, - episodeTableLayout = "simple", - onEpisodeTableLayoutChange, -}: TvShowPanelHeaderProps) { - const { t } = useTranslation(['components', 'errors', 'dialogs']) - - // HarmonyOS doesn't ship the bundled ffmpeg/thumbnail stack the - // "preview" layout depends on, so hide that layout selector there. - const isHarmonyOSRuntime = useMemo(() => isHarmonyOS(), []) - const tvShow = selectedMediaMetadata?.tvShow; - const tvdbTvShowName = tvShow?.name ?? '' - const movie = selectedMediaMetadata?.movie - const folderStatus = selectedMediaFolder?.status - const isUpdatingTvShow = selectedMediaFolder === undefined - || folderStatus === 'idle' - || folderStatus === 'pending_for_initialization' - || folderStatus === 'initializing' - || folderStatus === 'loading' - || folderStatus === 'updating' - const isMediaMetadataOk = folderStatus === 'ok' - const initialSearchValue = tvShow?.name ?? tvdbTvShowName - - const hasValidTmdbTvShow = (tvShow != null && tvShow.id != null) || (selectedMediaMetadata?.tvShow != null) - const actionsDisabled = !hasValidTmdbTvShow - const unrecognizedHint = - isMediaMetadataOk && actionsDisabled - ? (t('tvShow.unrecognizedFolderHint' as any, { ns: 'components' }) as string) // eslint-disable-line @typescript-eslint/no-explicit-any - : undefined - - const database = tvShow?.database ?? movie?.database - const mediaId = tvShow?.id ?? movie?.id - const mediaName = tvShow?.name ?? movie?.name ?? '' - const transcribeBlocked = - actionsDisabled || - !hasTranscribeTargets || - !isTranscribeAvailable - const translateBlocked = - actionsDisabled || - !hasTranslateTargets || - !isTranslateAvailable - const synthesizeBlocked = - actionsDisabled || - !hasSynthesizeTargets || - !isSynthesizeAvailable - const processBlocked = - actionsDisabled || - !hasProcessTargets || - !isProcessAvailable - const subtitleBlocked = transcribeBlocked && translateBlocked && synthesizeBlocked && processBlocked - - const hasExternalId = !!mediaId - const externalUrl = hasExternalId - ? database === 'TVDB' - ? `https://www.thetvdb.com/search?query=${encodeURIComponent(`${mediaId} ${mediaName}`)}` - : tvShow?.id != null - ? `https://www.themoviedb.org/tv/${mediaId}` - : `https://www.themoviedb.org/movie/${mediaId}` - : undefined - - return ( -
-
-
- {isUpdatingTvShow ? ( - - ) : ( - - )} -
-
- {onEpisodeTableLayoutChange && ( -
- -
- - {!isHarmonyOSRuntime && ( - <> -
- - - )} -
- )} - {isUpdatingTvShow ? ( - <> - - - - - ) : ( - <> - - - - {showSubtitleMenu && ( - - - - - - onTranscribeClick?.()} - data-testid="tvshow-header-transcribe" - > - - {t('mediaPlayer.trackContextMenu.transcribe', { ns: 'components' })} - - onTranslateClick?.()} - data-testid="tvshow-header-translate" - > - {t('mediaPlayer.trackContextMenu.translate', { ns: 'components' })} - - onSynthesizeClick?.()} - data-testid="tvshow-header-synthesize" - > - - {t('mediaPlayer.trackContextMenu.synthesize', { ns: 'components' })} - - onProcessClick?.()} - data-testid="tvshow-header-process" - > - - {t('mediaPlayer.trackContextMenu.process', { ns: 'components' })} - - - - )} - - - - - - {onEpisodeTableLayoutChange && ( - <> - onEpisodeTableLayoutChange("simple")} - > - - {t('tvShow.layoutSimple', { ns: 'components', defaultValue: 'Simple layout' })} - - onEpisodeTableLayoutChange("detail")} - > - - {t('tvShow.layoutDetail', { ns: 'components', defaultValue: 'Detail layout' })} - - {!isHarmonyOSRuntime && ( - onEpisodeTableLayoutChange("preview")} - > - - {t('tvShow.layoutPreview', { ns: 'components', defaultValue: 'Preview layout' })} - - )} - - )} - onRecognizeButtonClick?.()} - > - - {t('tvShow.recognize', { ns: 'components', defaultValue: 'Recognize' })} - - onRenameClick?.()} - > - - {t('tvShow.rename', { ns: 'components' })} - - { - if (!selectedMediaMetadata?.mediaFiles || !selectedMediaMetadata.tvShow) return - openScrape?.({ - mediaMetadata: selectedMediaMetadata - }) - }} - > - - {t('tvShow.scrape', { ns: 'components' })} - - {showSubtitleMenu && ( - <> - - onTranscribeClick?.()} - data-testid="tvshow-header-transcribe" - > - - {t('mediaPlayer.trackContextMenu.transcribe', { ns: 'components' })} - - onTranslateClick?.()} - data-testid="tvshow-header-translate-overflow" - > - {t('mediaPlayer.trackContextMenu.translate', { ns: 'components' })} - - onSynthesizeClick?.()} - data-testid="tvshow-header-synthesize-overflow" - > - - {t('mediaPlayer.trackContextMenu.synthesize', { ns: 'components' })} - - onProcessClick?.()} - data-testid="tvshow-header-process-overflow" - > - - {t('mediaPlayer.trackContextMenu.process', { ns: 'components' })} - - - )} - - externalUrl && window.open(externalUrl, '_blank', 'noopener,noreferrer')} - > - - {database === 'TVDB' - ? t('tvShow.openInTvdb', { ns: 'components', defaultValue: 'Open in TVDB' }) - : t('tvShow.openInTmdb', { ns: 'components', defaultValue: 'Open in TMDB' })} - - - - - )} -
-
-
- ) -} diff --git a/apps/ui/src/hooks/media/mediaFileTableToolbarShared.ts b/apps/ui/src/hooks/media/mediaFileTableToolbarShared.ts new file mode 100644 index 00000000..4ba2b08c --- /dev/null +++ b/apps/ui/src/hooks/media/mediaFileTableToolbarShared.ts @@ -0,0 +1,65 @@ +import type { MediaFileTableMenuId } from "@/components/media/MediaFileTableToolbar" +import type { UIMediaFolderStatus } from "@/types/UIMediaFolder" + +export const SUBTITLE_MENU_IDS: MediaFileTableMenuId[] = [ + "subtitle", + "transcribe", + "translate", + "synthesize", + "process", +] + +const LOADING_FOLDER_STATUSES: UIMediaFolderStatus[] = [ + "idle", + "pending_for_initialization", + "initializing", + "loading", + "updating", +] + +export function isMediaFileTableToolbarLoading( + folderStatus: UIMediaFolderStatus | undefined, + folderMissing: boolean, +): boolean { + return folderMissing || (folderStatus !== undefined && LOADING_FOLDER_STATUSES.includes(folderStatus)) +} + +export function buildSubtitleHiddenMenuIds(showSubtitleMenu: boolean): MediaFileTableMenuId[] { + return showSubtitleMenu ? [] : [...SUBTITLE_MENU_IDS] +} + +export function buildActionDisabledMenuIds(options: { + actionsDisabled: boolean + scrapeBlocked: boolean + includeRecognize: boolean + isTranscribeAvailable: boolean + hasTranscribeTargets: boolean + isTranslateAvailable: boolean + hasTranslateTargets: boolean + isSynthesizeAvailable: boolean + hasSynthesizeTargets: boolean + isProcessAvailable: boolean + hasProcessTargets: boolean +}): MediaFileTableMenuId[] { + const disabled: MediaFileTableMenuId[] = [] + if (options.actionsDisabled) { + if (options.includeRecognize) disabled.push("recognize") + disabled.push("rename", "scrape", "subtitle", ...SUBTITLE_MENU_IDS.slice(1)) + return disabled + } + + if (options.scrapeBlocked) disabled.push("scrape") + if (!options.hasTranscribeTargets || !options.isTranscribeAvailable) disabled.push("transcribe") + if (!options.hasTranslateTargets || !options.isTranslateAvailable) disabled.push("translate") + if (!options.hasSynthesizeTargets || !options.isSynthesizeAvailable) disabled.push("synthesize") + if (!options.hasProcessTargets || !options.isProcessAvailable) disabled.push("process") + if ( + disabled.includes("transcribe") && + disabled.includes("translate") && + disabled.includes("synthesize") && + disabled.includes("process") + ) { + disabled.push("subtitle") + } + return disabled +} diff --git a/apps/ui/src/components/movie/MovieHeaderV2.test.tsx b/apps/ui/src/hooks/movie/useMovieMediaFileTableToolbar.test.tsx similarity index 85% rename from apps/ui/src/components/movie/MovieHeaderV2.test.tsx rename to apps/ui/src/hooks/movie/useMovieMediaFileTableToolbar.test.tsx index 3eba948c..a9187808 100644 --- a/apps/ui/src/components/movie/MovieHeaderV2.test.tsx +++ b/apps/ui/src/hooks/movie/useMovieMediaFileTableToolbar.test.tsx @@ -3,7 +3,7 @@ import React from 'react' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { MovieHeaderV2 } from './MovieHeaderV2' +import { MovieMediaFileTableToolbar } from './useMovieMediaFileTableToolbar' import type { MediaMetadata } from '@smm/types' import type { UIMediaFolder } from '@/types/UIMediaFolder' @@ -64,7 +64,7 @@ vi.mock('@/hooks/userConfig', () => ({ const defaultOkFolder: UIMediaFolder = { path: '/media/movie', status: 'ok' } -describe('MovieHeaderV2', () => { +describe('MovieMediaFileTableToolbar', () => { const defaultProps = { onSearchResultSelected: vi.fn(), onRenameClick: vi.fn(), @@ -78,9 +78,9 @@ describe('MovieHeaderV2', () => { }) describe('"更多" dropdown / "在TMDB中打开"', () => { - it('disables the more menu button when tmdb id is not available (no tmdbMovie)', () => { + it('keeps the more menu button enabled when tmdb id is not available so overflow actions stay reachable', () => { renderWithQueryClient( - { } as MediaMetadata} /> ) - const moreButton = screen.getByRole('button', { name: 'movie.more' }) - expect(moreButton).toBeDisabled() + const moreButton = screen.getByRole('button', { name: 'tvShow.more' }) + expect(moreButton).not.toBeDisabled() }) - it('disables the more menu button when movie has no usable id', () => { + it('keeps the more menu button enabled when movie has no usable id', () => { renderWithQueryClient( - { } /> ) - const moreButton = screen.getByRole('button', { name: 'movie.more' }) - expect(moreButton).toBeDisabled() + const moreButton = screen.getByRole('button', { name: 'tvShow.more' }) + expect(moreButton).not.toBeDisabled() }) it('enables the more menu button when movie has TMDB id', () => { renderWithQueryClient( - { } /> ) - const moreButton = screen.getByRole('button', { name: 'movie.more' }) + const moreButton = screen.getByRole('button', { name: 'tvShow.more' }) expect(moreButton).not.toBeDisabled() }) it('enables the more menu button when movie has TVDB id', () => { renderWithQueryClient( - { } /> ) - const moreButton = screen.getByRole('button', { name: 'movie.more' }) + const moreButton = screen.getByRole('button', { name: 'tvShow.more' }) expect(moreButton).not.toBeDisabled() }) }) @@ -157,7 +157,7 @@ describe('MovieHeaderV2', () => { it('shows "Open in TMDB" when database is TMDB', () => { renderWithQueryClient( - { /> ) - expect(screen.getByText('movie.openInTmdb')).toBeInTheDocument() - expect(screen.queryByText('movie.openInTvdb')).not.toBeInTheDocument() + expect(screen.getByText('tvShow.openInTmdb')).toBeInTheDocument() + expect(screen.queryByText('tvShow.openInTvdb')).not.toBeInTheDocument() }) it('shows "Open in TVDB" when database is TVDB', () => { renderWithQueryClient( - { /> ) - expect(screen.getByText('movie.openInTvdb')).toBeInTheDocument() - expect(screen.queryByText('movie.openInTmdb')).not.toBeInTheDocument() + expect(screen.getByText('tvShow.openInTvdb')).toBeInTheDocument() + expect(screen.queryByText('tvShow.openInTmdb')).not.toBeInTheDocument() }) it('opens TMDB movie page when clicking the TMDB link', () => { renderWithQueryClient( - { /> ) - fireEvent.click(screen.getByText('movie.openInTmdb')) + fireEvent.click(screen.getByText('tvShow.openInTmdb')) expect(openSpy).toHaveBeenCalledWith( 'https://www.themoviedb.org/movie/789', '_blank', @@ -218,7 +218,7 @@ describe('MovieHeaderV2', () => { it('opens TVDB search page with id and name when clicking the TVDB link', () => { renderWithQueryClient( - { /> ) - fireEvent.click(screen.getByText('movie.openInTvdb')) + fireEvent.click(screen.getByText('tvShow.openInTvdb')) expect(openSpy).toHaveBeenCalledWith( 'https://www.thetvdb.com/search?query=tvdb-1%20TVDB%20Movie%20Name', '_blank', @@ -241,7 +241,7 @@ describe('MovieHeaderV2', () => { it('disables the external link when no movie metadata is present', () => { renderWithQueryClient( - { /> ) - const menuItem = screen.getByText('movie.openInTmdb') + const menuItem = screen.getByText('tvShow.openInTmdb') expect(menuItem.closest('[role="menuitem"]')).toHaveAttribute('aria-disabled', 'true') }) }) @@ -266,7 +266,7 @@ describe('MovieHeaderV2', () => { it('shows loading skeleton and hides searchbox when selected folder is updating', () => { renderWithQueryClient( - { it('shows loading skeleton and hides searchbox when selected folder status is loading', () => { renderWithQueryClient( - { it('shows loading skeleton and hides searchbox when selectedMediaFolder is undefined', () => { renderWithQueryClient( - { it('shows searchbox when selected folder status is ok', () => { renderWithQueryClient( - { it('disables subtitle dropdown when transcribe, translate, synthesize, and process are all blocked', () => { renderWithQueryClient( - { it('invokes onSynthesizeClick when synthesize menu item is used', () => { const onSynthesizeClick = vi.fn() renderWithQueryClient( - { it('invokes onProcessClick when process menu item is used', () => { const onProcessClick = vi.fn() renderWithQueryClient( - void + onRenameClick?: () => void + onTranscribeClick?: () => void + onTranslateClick?: () => void + onSynthesizeClick?: () => void + onProcessClick?: () => void + isTranscribeAvailable?: boolean + hasTranscribeTargets?: boolean + isTranslateAvailable?: boolean + hasTranslateTargets?: boolean + isSynthesizeAvailable?: boolean + hasSynthesizeTargets?: boolean + isProcessAvailable?: boolean + hasProcessTargets?: boolean + showSubtitleMenu?: boolean + selectedMediaMetadata?: MediaMetadata + selectedMediaFolder?: UIMediaFolder + openScrape?: (params: { mediaMetadata: MediaMetadata }) => void + episodeTableLayout?: EpisodeTableLayout + onEpisodeTableLayoutChange?: (layout: EpisodeTableLayout) => void +} + +export function useMovieMediaFileTableToolbar({ + onSearchResultSelected, + onRenameClick, + onTranscribeClick, + onTranslateClick, + onSynthesizeClick, + onProcessClick, + isTranscribeAvailable = false, + hasTranscribeTargets = false, + isTranslateAvailable = false, + hasTranslateTargets = false, + isSynthesizeAvailable = false, + hasSynthesizeTargets = false, + isProcessAvailable = false, + hasProcessTargets = false, + showSubtitleMenu = true, + selectedMediaMetadata, + selectedMediaFolder, + openScrape, + episodeTableLayout = "simple", + onEpisodeTableLayoutChange, +}: UseMovieMediaFileTableToolbarArgs): MediaFileTableToolbarProps { + const { t } = useTranslation(["components", "errors", "dialogs"]) + const isHarmonyOSRuntime = useMemo(() => isHarmonyOS(), []) + + const folderStatus = selectedMediaFolder?.status + const movieMeta = selectedMediaMetadata?.movie + const loading = isMediaFileTableToolbarLoading(folderStatus, selectedMediaFolder === undefined) + const isMediaMetadataOk = folderStatus === "ok" + const initialSearchValue = movieMeta?.name ?? "" + + const hasValidMovieMetadata = movieMeta != null + const actionsDisabled = !hasValidMovieMetadata + const unrecognizedHint = + isMediaMetadataOk && actionsDisabled + ? (t("movie.unrecognizedFolderHint" as any, { ns: "components" }) as string) // eslint-disable-line @typescript-eslint/no-explicit-any + : undefined + + const database = movieMeta?.database + const mediaId = movieMeta?.id + const mediaName = movieMeta?.name ?? "" + const isTmdb = database === "TMDB" + const externalUrl = mediaId + ? isTmdb + ? `https://www.themoviedb.org/movie/${mediaId}` + : `https://www.thetvdb.com/search?query=${encodeURIComponent(`${mediaId} ${mediaName}`)}` + : undefined + + const scrapeBlocked = + actionsDisabled || + !selectedMediaMetadata?.mediaFiles || + selectedMediaMetadata.mediaFiles.length === 0 + + const hiddenMenuIds = useMemo(() => { + return ["recognize", ...buildSubtitleHiddenMenuIds(showSubtitleMenu)] + }, [showSubtitleMenu]) + + const disabledMenuIds = useMemo( + () => + buildActionDisabledMenuIds({ + actionsDisabled, + scrapeBlocked, + includeRecognize: false, + isTranscribeAvailable, + hasTranscribeTargets, + isTranslateAvailable, + hasTranslateTargets, + isSynthesizeAvailable, + hasSynthesizeTargets, + isProcessAvailable, + hasProcessTargets, + }), + [ + actionsDisabled, + scrapeBlocked, + isTranscribeAvailable, + hasTranscribeTargets, + isTranslateAvailable, + hasTranslateTargets, + isSynthesizeAvailable, + hasSynthesizeTargets, + isProcessAvailable, + hasProcessTargets, + ], + ) + + return { + leading: ( + + ), + // Match former MovieHeaderV2: searchbox uses full flex-1 (no 50% cap). + constrainLeading: false, + loading, + layout: episodeTableLayout, + onLayoutChange: onEpisodeTableLayoutChange, + showPreviewLayoutButton: !isHarmonyOSRuntime, + hiddenMenuIds, + disabledMenuIds, + onRenameButtonClick: onRenameClick, + onScrapeButtonClick: () => { + if (scrapeBlocked || !selectedMediaMetadata) return + openScrape?.({ mediaMetadata: selectedMediaMetadata }) + }, + onTranscribeClick, + onTranslateClick, + onSynthesizeClick, + onProcessClick, + externalUrl, + testIdPrefix: "movie-header", + } +} + +/** Test/render helper: hook + toolbar in one component. */ +export function MovieMediaFileTableToolbar(props: UseMovieMediaFileTableToolbarArgs) { + const toolbarProps = useMovieMediaFileTableToolbar(props) + return +} diff --git a/apps/ui/src/components/tv/TvShowPanelHeader.test.tsx b/apps/ui/src/hooks/tv/useTvShowMediaFileTableToolbar.test.tsx similarity index 94% rename from apps/ui/src/components/tv/TvShowPanelHeader.test.tsx rename to apps/ui/src/hooks/tv/useTvShowMediaFileTableToolbar.test.tsx index 8d63bd7b..849f5509 100644 --- a/apps/ui/src/components/tv/TvShowPanelHeader.test.tsx +++ b/apps/ui/src/hooks/tv/useTvShowMediaFileTableToolbar.test.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' -import { TvShowPanelHeader } from './TvShowPanelHeader' +import { TvShowMediaFileTableToolbar } from './useTvShowMediaFileTableToolbar' import type { MediaMetadata } from '@smm/types' import type { UIMediaFolder } from '@/types/UIMediaFolder' @@ -9,11 +9,11 @@ const mockMediaDatabaseSearchbox = vi.fn((props: any) => (
)) -vi.mock('../MediaDatabaseSearchbox', () => ({ +vi.mock('@/components/MediaDatabaseSearchbox', () => ({ MediaDatabaseSearchbox: (props: any) => mockMediaDatabaseSearchbox(props), })) -vi.mock('../ui/dropdown-menu', () => { +vi.mock('@/components/ui/dropdown-menu', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const React = require('react') return { @@ -54,7 +54,7 @@ vi.mock('@/lib/isHarmonyOS', () => ({ isHarmonyOS: isHarmonyOSMock, })) -describe('TvShowPanelHeader', () => { +describe('TvShowMediaFileTableToolbar', () => { const defaultProps = { onSearchResultSelected: vi.fn(), onRecognizeButtonClick: vi.fn(), @@ -73,7 +73,7 @@ describe('TvShowPanelHeader', () => { describe('"更多" dropdown / "在TMDB中打开"', () => { it('always enables the more menu button so overflow actions are accessible on small screens', () => { render( - { it('enables the more menu button when tvShow has TMDB id', () => { render( - { it('enables the more menu button when tvShow.id is available', () => { render( - { describe('TVDB TV Show Metadata', () => { it('passes tvShow.name as value for TVDB metadata', () => { render( - { it('shows "Open in TMDB" when database is TMDB', () => { render( - { it('shows "Open in TVDB" when database is TVDB', () => { render( - { it('opens TMDB TV page when clicking the TMDB link', () => { render( - { it('opens TVDB search page with id and name when clicking the TVDB link', () => { render( - { it('disables the external link when no media id is available', () => { render( - { it('shows loading skeleton and hides searchbox when selected folder is loading', () => { render( - { it('shows searchbox when selected folder status is ok', () => { render( - { it('disables subtitle dropdown when transcribe, translate, synthesize, and process are all blocked', () => { render( - { it('invokes onSynthesizeClick when synthesize menu item is used', () => { const onSynthesizeClick = vi.fn() render( - { it('invokes onProcessClick when process menu item is used', () => { const onProcessClick = vi.fn() render( - { return { onEpisodeTableLayoutChange, renderResult: render( - void + onRecognizeButtonClick?: () => void + onRenameClick?: () => void + onTranscribeClick?: () => void + onTranslateClick?: () => void + onSynthesizeClick?: () => void + onProcessClick?: () => void + isTranscribeAvailable?: boolean + hasTranscribeTargets?: boolean + isTranslateAvailable?: boolean + hasTranslateTargets?: boolean + isSynthesizeAvailable?: boolean + hasSynthesizeTargets?: boolean + isProcessAvailable?: boolean + hasProcessTargets?: boolean + showSubtitleMenu?: boolean + selectedMediaMetadata?: MediaMetadata + selectedMediaFolder?: UIMediaFolder + openScrape?: (params: { mediaMetadata: MediaMetadata }) => void + episodeTableLayout?: EpisodeTableLayout + onEpisodeTableLayoutChange?: (layout: EpisodeTableLayout) => void +} + +export function useTvShowMediaFileTableToolbar({ + onSearchResultSelected, + onRecognizeButtonClick, + onRenameClick, + onTranscribeClick, + onTranslateClick, + onSynthesizeClick, + onProcessClick, + isTranscribeAvailable = false, + hasTranscribeTargets = false, + isTranslateAvailable = false, + hasTranslateTargets = false, + isSynthesizeAvailable = false, + hasSynthesizeTargets = false, + isProcessAvailable = false, + hasProcessTargets = false, + showSubtitleMenu = true, + selectedMediaMetadata, + selectedMediaFolder, + openScrape, + episodeTableLayout = "simple", + onEpisodeTableLayoutChange, +}: UseTvShowMediaFileTableToolbarArgs): MediaFileTableToolbarProps { + const { t } = useTranslation(["components", "errors", "dialogs"]) + const isHarmonyOSRuntime = useMemo(() => isHarmonyOS(), []) + + const tvShow = selectedMediaMetadata?.tvShow + const movie = selectedMediaMetadata?.movie + const folderStatus = selectedMediaFolder?.status + const loading = isMediaFileTableToolbarLoading(folderStatus, selectedMediaFolder === undefined) + const isMediaMetadataOk = folderStatus === "ok" + const initialSearchValue = tvShow?.name ?? "" + + const hasValidTvShow = (tvShow != null && tvShow.id != null) || selectedMediaMetadata?.tvShow != null + const actionsDisabled = !hasValidTvShow + const unrecognizedHint = + isMediaMetadataOk && actionsDisabled + ? (t("tvShow.unrecognizedFolderHint" as any, { ns: "components" }) as string) // eslint-disable-line @typescript-eslint/no-explicit-any + : undefined + + const database = tvShow?.database ?? movie?.database + const mediaId = tvShow?.id ?? movie?.id + const mediaName = tvShow?.name ?? movie?.name ?? "" + const externalUrl = mediaId + ? database === "TVDB" + ? `https://www.thetvdb.com/search?query=${encodeURIComponent(`${mediaId} ${mediaName}`)}` + : tvShow?.id != null + ? `https://www.themoviedb.org/tv/${mediaId}` + : `https://www.themoviedb.org/movie/${mediaId}` + : undefined + + const scrapeBlocked = + actionsDisabled || + !selectedMediaMetadata?.mediaFiles || + selectedMediaMetadata.mediaFiles.length === 0 + + const hiddenMenuIds = useMemo( + () => buildSubtitleHiddenMenuIds(showSubtitleMenu), + [showSubtitleMenu], + ) + + const disabledMenuIds = useMemo( + () => + buildActionDisabledMenuIds({ + actionsDisabled, + scrapeBlocked, + includeRecognize: true, + isTranscribeAvailable, + hasTranscribeTargets, + isTranslateAvailable, + hasTranslateTargets, + isSynthesizeAvailable, + hasSynthesizeTargets, + isProcessAvailable, + hasProcessTargets, + }), + [ + actionsDisabled, + scrapeBlocked, + isTranscribeAvailable, + hasTranscribeTargets, + isTranslateAvailable, + hasTranslateTargets, + isSynthesizeAvailable, + hasSynthesizeTargets, + isProcessAvailable, + hasProcessTargets, + ], + ) + + return { + leading: ( + + ), + loading, + layout: episodeTableLayout, + onLayoutChange: onEpisodeTableLayoutChange, + showPreviewLayoutButton: !isHarmonyOSRuntime, + hiddenMenuIds, + disabledMenuIds, + onRecognizeButtonClick: () => { + console.log("[tvshow] clicked recognize-button") + onRecognizeButtonClick?.() + }, + onRenameButtonClick: () => { + console.log("[tvshow] clicked rename-button") + onRenameClick?.() + }, + onScrapeButtonClick: () => { + if (!selectedMediaMetadata?.mediaFiles || !selectedMediaMetadata.tvShow) return + openScrape?.({ mediaMetadata: selectedMediaMetadata }) + }, + onTranscribeClick, + onTranslateClick, + onSynthesizeClick, + onProcessClick, + externalUrl, + testIdPrefix: "tvshow-header", + } +} + +/** Test/render helper: hook + toolbar in one component. */ +export function TvShowMediaFileTableToolbar(props: UseTvShowMediaFileTableToolbarArgs) { + const toolbarProps = useTvShowMediaFileTableToolbar(props) + return +} diff --git a/docs/superpowers/plans/2026-09-10-media-file-table-toolbar-menu-api.md b/docs/superpowers/plans/2026-09-10-media-file-table-toolbar-menu-api.md new file mode 100644 index 00000000..d0ec4384 --- /dev/null +++ b/docs/superpowers/plans/2026-09-10-media-file-table-toolbar-menu-api.md @@ -0,0 +1,59 @@ +# MediaFileTableToolbar Menu API Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace caller-built `actions`/`moreItems` with an internalized shared menu driven by `hiddenMenuIds`, `disabledMenuIds`, per-button callbacks, and `externalUrl`. + +**Architecture:** `MediaFileTableToolbar` owns menu order, labels, icons, and More overflow. Headers only compute hide/disable sets, wire callbacks, and pass `externalUrl`. + +**Tech Stack:** React 19, Vitest, Testing Library, i18next, lucide-react + +## Global Constraints + +- Follow `docs/superpowers/specs/2026-09-10-media-file-table-toolbar-design.md` +- Keep existing TV e2e test ids: `recognize-button`, `rename-button`, `scrape-button` +- Subtitle test ids use `testIdPrefix` (`tvshow-header` / `movie-header`) +- Shared chrome labels use `tvShow.*` + `mediaPlayer.trackContextMenu.*` +- TDD for toolbar behavior changes; keep header unit tests green (update expectations for shared i18n keys) + +--- + +### Task 1: Redesign MediaFileTableToolbar + +**Files:** +- Modify: `apps/ui/src/components/media/MediaFileTableToolbar.tsx` +- Modify: `apps/ui/src/components/media/MediaFileTableToolbar.test.tsx` +- Modify: `apps/ui/src/components/media/MediaFileTableToolbar.stories.tsx` + +**Interfaces:** +- Produces: `MediaFileTableMenuId`, props `hiddenMenuIds`, `disabledMenuIds`, per-action callbacks, `externalUrl`, `testIdPrefix` + +- [ ] Rewrite toolbar tests for built-in menu + hide/disable +- [ ] Implement built-in menu; remove `actions`/`moreItems` +- [ ] Update Storybook stories +- [ ] Run toolbar unit tests + +### Task 2: Thin TvShowPanelHeader + +**Files:** +- Modify: `apps/ui/src/components/tv/TvShowPanelHeader.tsx` +- Modify: `apps/ui/src/components/tv/TvShowPanelHeader.test.tsx` (only if needed) + +- [ ] Replace action array construction with `hiddenMenuIds`/`disabledMenuIds` + callbacks +- [ ] Pass `externalUrl`, `testIdPrefix="tvshow-header"` +- [ ] Run TV header tests + +### Task 3: Thin MovieHeaderV2 + +**Files:** +- Modify: `apps/ui/src/components/movie/MovieHeaderV2.tsx` +- Modify: `apps/ui/src/components/movie/MovieHeaderV2.test.tsx` (shared `tvShow.openIn*` keys) + +- [ ] Same pattern; `hiddenMenuIds` includes `recognize` +- [ ] `testIdPrefix="movie-header"` +- [ ] Run movie header tests + +### Task 4: Verify + +- [ ] `pnpm --filter ui test` for the three suites +- [ ] `pnpm --filter ui typecheck` diff --git a/docs/superpowers/specs/2026-09-10-media-file-table-toolbar-design.md b/docs/superpowers/specs/2026-09-10-media-file-table-toolbar-design.md new file mode 100644 index 00000000..c7a7170a --- /dev/null +++ b/docs/superpowers/specs/2026-09-10-media-file-table-toolbar-design.md @@ -0,0 +1,117 @@ +# MediaFileTableToolbar + +This design document describe the high level design of a feature. +The design document is golden source and reference by one or more features. + +## 1. Background + +`TvShowPanelHeader` and `MovieHeaderV2` share the same chrome and the same primary menu. Earlier we extracted a presentational toolbar with caller-built `actions` / `moreItems` arrays. That duplicated menu construction in both headers and treated overflow items as a separate concept. + +Goal: `MediaFileTableToolbar` owns the shared menu (labels, icons, collapse breakpoints, More overflow). Headers only supply leading search, loading/layout state, which items are hidden/disabled, callbacks, and an optional external URL. + +## 2. Architecture + +## 2.1 Project Level Architecture + +UI-only change in `apps/ui`. No CLI, core, or type-package changes. + +## 2.2 App Level Architecture + +``` +TvShowPanel / MoviePanel + -> useTvShowMediaFileTableToolbar / useMovieMediaFileTableToolbar + derives: loading, hiddenMenuIds, disabledMenuIds, externalUrl, leading, callbacks + -> MediaFileTableToolbar + owns: menu order, i18n labels, icons, collapse, More overflow +``` + +`TvShowPanelHeader` and `MovieHeaderV2` are removed. Panels call the hooks at top level and spread props into `MediaFileTableToolbar`. + +## 2.3 Key Design + +### Menu ids + +```ts +type MediaFileTableMenuId = + | "recognize" + | "rename" + | "scrape" + | "subtitle" + | "transcribe" + | "translate" + | "synthesize" + | "process" + | "openExternal" +``` + +### Props (toolbar) + +| Prop | Role | +|------|------| +| `leading` | Search slot | +| `loading` | Skeletons for leading + actions | +| `layout` / `onLayoutChange` / `showPreviewLayoutButton` | Layout switcher (preview omitted on HarmonyOS) | +| `hiddenMenuIds` | Do not render these items | +| `disabledMenuIds` | Render but disabled | +| `onRecognizeButtonClick` / `onRenameButtonClick` / `onScrapeButtonClick` | Primary actions | +| `onTranscribeClick` / `onTranslateClick` / `onSynthesizeClick` / `onProcessClick` | Subtitle submenu | +| `externalUrl?` | Open in TMDB/TVDB; toolbar `window.open`s; missing URL disables `openExternal` | +| `moreAriaLabel` | Accessible name for More | +| `testIdPrefix?` | e.g. `tvshow-header` / `movie-header` for subtitle-related test ids | + +Removed: `actions`, `moreItems`, `layoutLabels`, caller-built menu arrays. + +### Menu behavior + +Fixed order (left → right). Narrow screens collapse from right → left into More using existing breakpoints (410 / 310 / 220 / 200). + +1. Recognize (hidden when in `hiddenMenuIds`, e.g. movie) +2. Rename +3. Scrape +4. Subtitle dropdown (children: transcribe, translate, synthesize, process) +5. `openExternal` — always in More only (never a primary bar button) + +Rules: + +- Hiding `subtitle` also hides its four children. +- Subtitle trigger is disabled if it is in `disabledMenuIds`, or if all visible children are disabled. +- `openExternal` is disabled when `externalUrl` is absent/empty, or when listed in `disabledMenuIds`. +- More button stays enabled so overflow actions remain reachable. + +### Labels + +Toolbar owns i18n for layout and menu chrome. Prefer shared keys where copy is identical (`mediaPlayer.trackContextMenu.*`). For rename/scrape/recognize/openExternal, use `tvShow.*` keys as the shared chrome strings (movie header historically used `movie.*` with the same English defaults — migrate movie to the shared keys unless product requires distinct copy). + +### Headers + +`TvShowPanelHeader` / `MovieHeaderV2` remain logic wrappers: + +- Build `leading` (`MediaDatabaseSearchbox` with correct `mediaType`) +- Compute `loading` from folder status +- Compute `hiddenMenuIds` (movie: `recognize`; HarmonyOS subtitle: `subtitle` + children) +- Compute `disabledMenuIds` from metadata / scrape / subtitle availability +- Pass callbacks and `externalUrl` + +## 3. User Stories + +### 3.1 TV header menu without assembling arrays + +* **Given** a recognized TV folder +* **When** the header renders the toolbar with empty hidden ids and domain-derived disabled ids +* **Then** Recognize, Rename, Scrape, Subtitle, and Open external appear with correct collapse behavior + +### 3.2 Movie hides Recognize via hiddenMenuIds + +* **Given** a movie folder +* **When** the header passes `hiddenMenuIds: ["recognize"]` +* **Then** Recognize is not shown in the bar or More; Rename/Scrape/Subtitle/Open external still work + +```mermaid +sequenceDiagram + participant Header as TvShowPanelHeader / MovieHeaderV2 + participant Toolbar as MediaFileTableToolbar + Header->>Header: derive hidden/disabled ids, externalUrl + Header->>Toolbar: leading, ids, callbacks, externalUrl + Toolbar-->>Header: onRenameButtonClick / onScrapeButtonClick / ... + Toolbar->>Toolbar: window.open(externalUrl) on openExternal +``` From ef550814a78a3114a7ec11cf42c2c367c4ade2fc Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 10 Sep 2026 06:57:03 +0800 Subject: [PATCH 76/83] fix(ui): resolve ESLint errors in TvShowPanel and related hooks Unblock pnpm lint by fixing hooks rules, empty object types, and React Compiler memoization warnings. Co-authored-by: Cursor --- .../ui/src/components/musicTableRowShared.tsx | 2 - .../tv/AiBasedRecognizeEpisodePrompt.tsx | 3 +- .../tv/AiBasedRenameEpisodePrompt.tsx | 3 +- apps/ui/src/components/tv/TvShowPanel.tsx | 71 +++++------- .../useMediaMetadataMutation.test.tsx | 1 - .../movie/useMovieMediaFileTableToolbar.tsx | 1 + .../hooks/tv/useRuleBasedRenameFilesFlow.ts | 3 +- .../tv/useTvShowMediaFileTableToolbar.tsx | 1 + apps/ui/src/hooks/useTvShowPanel.ts | 109 ++++++++---------- apps/ui/src/lib/ytdlpFormatPresets.ts | 10 +- 10 files changed, 83 insertions(+), 121 deletions(-) diff --git a/apps/ui/src/components/musicTableRowShared.tsx b/apps/ui/src/components/musicTableRowShared.tsx index 92bb618d..0260fd85 100644 --- a/apps/ui/src/components/musicTableRowShared.tsx +++ b/apps/ui/src/components/musicTableRowShared.tsx @@ -7,7 +7,6 @@ import { Music } from "lucide-react" import { cn } from "@/lib/utils" import Image from "@/components/Image" -// eslint-disable-next-line react-refresh/only-export-components function formatDuration(seconds: number): string { const mins = Math.floor(seconds / 60) const secs = Math.floor(seconds % 60) @@ -15,7 +14,6 @@ function formatDuration(seconds: number): string { } /** Builds a file:// URL for the thumbnail that the backend can resolve. */ -// eslint-disable-next-line react-refresh/only-export-components function getThumbnailImageUrl( thumbnailPath: string, mediaFolderPath: string | undefined, diff --git a/apps/ui/src/components/tv/AiBasedRecognizeEpisodePrompt.tsx b/apps/ui/src/components/tv/AiBasedRecognizeEpisodePrompt.tsx index e1eab89d..0227bbe3 100644 --- a/apps/ui/src/components/tv/AiBasedRecognizeEpisodePrompt.tsx +++ b/apps/ui/src/components/tv/AiBasedRecognizeEpisodePrompt.tsx @@ -2,8 +2,7 @@ import { FloatingPrompt, type FloatingPromptProps } from "../FloatingPrompt" import { cn } from "@/lib/utils" import { useTranslation } from "@/lib/i18n" -export interface AiBasedRecognizeEpisodePromptProps extends Omit { -} +export type AiBasedRecognizeEpisodePromptProps = Omit /** * AiBasedRecognizeEpisodePrompt component built on top of FloatingPrompt. diff --git a/apps/ui/src/components/tv/AiBasedRenameEpisodePrompt.tsx b/apps/ui/src/components/tv/AiBasedRenameEpisodePrompt.tsx index bb59032c..96b934c3 100644 --- a/apps/ui/src/components/tv/AiBasedRenameEpisodePrompt.tsx +++ b/apps/ui/src/components/tv/AiBasedRenameEpisodePrompt.tsx @@ -2,8 +2,7 @@ import { FloatingPrompt, type FloatingPromptProps } from "../FloatingPrompt" import { cn } from "@/lib/utils" import { useTranslation } from "@/lib/i18n" -export interface AiBasedRenameEpisodePromptProps extends Omit { -} +export type AiBasedRenameEpisodePromptProps = Omit /** * AiBasedRenameEpisodePrompt component built on top of FloatingPrompt. diff --git a/apps/ui/src/components/tv/TvShowPanel.tsx b/apps/ui/src/components/tv/TvShowPanel.tsx index dd36c800..26b55a1d 100644 --- a/apps/ui/src/components/tv/TvShowPanel.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.tsx @@ -2,7 +2,7 @@ import { useUIMediaFolderStoreState } from "@/stores/uiMediaFolderStore" import { useMediaMetadataQuery } from "@/hooks/mediaMetadata" import { useSelectTvShowForFolderMutation } from "@/hooks/useSelectTvShowForFolderMutation" import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" -import { useState, useCallback, useMemo, useEffect, useRef } from "react" +import { useState, useCallback, useMemo } from "react" import type { MediaMetadata } from "@/lib/mediaFolderFiles" import { useMediaFolderFilesQuery } from "@/hooks/useMediaFolderFilesQuery" import type { TMDBTVShow, TMDBTVShowDetails } from "@smm/types" @@ -38,7 +38,6 @@ import { buildRecognizeApplySelectedFiles, buildMediaFileTableSeasonData, } from "./TvShowPanelUtils" -import { useLatest } from "react-use" import type { UIMediaFolderStatus } from "@/types/UIMediaFolder" import { useTvShowPanel } from "@/hooks/useTvShowPanel" import { RuleBasedRenameFilePrompt } from "../RuleBasedRenameFilePrompt" @@ -132,7 +131,7 @@ function TvShowPanel() { const { handleFormatConvertForRow } = useTvShowEpisodeFormatConvert(mediaMetadata) const mediaFileTableSeasonData = useMemo(() => { - return !!mediaMetadata ? buildMediaFileTableSeasonData(mediaMetadata) : [] + return mediaMetadata ? buildMediaFileTableSeasonData(mediaMetadata) : [] }, [mediaMetadata]) const subtitleFlow = useSubtitleFlow({ @@ -244,45 +243,33 @@ function TvShowPanel() { ], ) - const latestMediaMetadata = useLatest(mediaMetadata) const planId = useMemo(() => { return plan?.id ?? '' }, [plan]) - const selectedEpisodesByPlanId = useRef>(new Map()) - - const latestPlan = useLatest(plan) - const latestMetadata = useLatest(mediaMetadata) - - useEffect(() => { - const plan = latestPlan.current; - const metadata = latestMetadata.current; - - if(planId !== plan?.id || plan.status !== 'pending') { - return; - } - - if(plan.task === 'rename-files') { - const m = latestMediaMetadata.current; - const selectedEpisodes = m?.mediaFiles - ?.filter(f => f.seasonNumber !== undefined && f.episodeNumber !== undefined) - ?.map(f => { return { season: f.seasonNumber!, episode: f.episodeNumber!} }) - const episodes = selectedEpisodes ?? [] - selectedEpisodesByPlanId.current.set(planId, episodes) - setSelectedEpisodes(episodes) - } else if (plan.task === 'recognize-media-file') { - const recognizePlan = plan as RecognizeMediaFilePlan; - const episodes = recognizePlan.files.map(f => { - return { - season: f.season, - episode: f.episode, - } - }) - .filter(f => { - return metadata?.tvShow?.seasons?.find(s => s.season === f.season)?.episodes?.find(e => e.episode === f.episode) - }) - setSelectedEpisodes(episodes) + const [syncedPlanId, setSyncedPlanId] = useState(planId) + + // Adjust checkbox selection when the active plan changes (React: adjust state during render). + if (planId !== syncedPlanId) { + setSyncedPlanId(planId) + + if (plan && plan.id === planId && plan.status === 'pending') { + if (plan.task === 'rename-files') { + const episodes = + mediaMetadata?.mediaFiles + ?.filter(f => f.seasonNumber !== undefined && f.episodeNumber !== undefined) + ?.map(f => ({ season: f.seasonNumber!, episode: f.episodeNumber! })) ?? [] + setSelectedEpisodes(episodes) + } else if (plan.task === 'recognize-media-file') { + const recognizePlan = plan as RecognizeMediaFilePlan + const episodes = recognizePlan.files + .map(f => ({ season: f.season, episode: f.episode })) + .filter(f => + mediaMetadata?.tvShow?.seasons + ?.find(s => s.season === f.season) + ?.episodes?.find(e => e.episode === f.episode), + ) + setSelectedEpisodes(episodes) + } } - - - }, [planId]) + } const ruleBasedRenameFilePromptProps = useMemo(() => { return { @@ -385,15 +372,13 @@ function TvShowPanel() { checboxVisible={plan !== undefined} onCheck={(season, episode, checked) => { setSelectedEpisodes(prev => { - const newSelected = checked + return checked ? prev.some(e => e.season === season && e.episode === episode) ? prev : [...prev, { season, episode }] : prev.some(e => e.season === season && e.episode === episode) ? prev.filter(e => e.season !== season || e.episode !== episode) : prev - selectedEpisodesByPlanId.current.set(planId, newSelected) - return newSelected }) }} /> diff --git a/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.test.tsx b/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.test.tsx index 78684dab..06f1cc5a 100644 --- a/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.test.tsx +++ b/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.test.tsx @@ -6,7 +6,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { MetadataHttpError, createMetadata, - deleteMetadata, getMetadata, setMetadata, } from "@/api/metadata" diff --git a/apps/ui/src/hooks/movie/useMovieMediaFileTableToolbar.tsx b/apps/ui/src/hooks/movie/useMovieMediaFileTableToolbar.tsx index 71e0c13c..c2527374 100644 --- a/apps/ui/src/hooks/movie/useMovieMediaFileTableToolbar.tsx +++ b/apps/ui/src/hooks/movie/useMovieMediaFileTableToolbar.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components -- hook module with test helper component */ import { useMemo } from "react" import type { MediaMetadata } from "@smm/types" import type { UIMediaFolder } from "@/types/UIMediaFolder" diff --git a/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts b/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts index ebca8078..c89c8056 100644 --- a/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts +++ b/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts @@ -136,6 +136,7 @@ export function useRuleBasedRenameFilesFlow({ }, [ mediaFolderPath, + mediaMetadata, plan, applyPlanMutation, renameFailedMessage, @@ -161,7 +162,7 @@ export function useRuleBasedRenameFilesFlow({ reset() }, - [mediaFolderPath, rejectPlanMutation, renameFailedMessage, plan, reset], + [mediaFolderPath, rejectPlanMutation, plan, reset], ) /** Opens RuleBasedRenameFilePrompt by calling try-to-rename-episodes with the default rule. */ diff --git a/apps/ui/src/hooks/tv/useTvShowMediaFileTableToolbar.tsx b/apps/ui/src/hooks/tv/useTvShowMediaFileTableToolbar.tsx index d96b8a9b..ff532c18 100644 --- a/apps/ui/src/hooks/tv/useTvShowMediaFileTableToolbar.tsx +++ b/apps/ui/src/hooks/tv/useTvShowMediaFileTableToolbar.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components -- hook module with test helper component */ import { useMemo } from "react" import type { MediaMetadata } from "@smm/types" import type { UIMediaFolder } from "@/types/UIMediaFolder" diff --git a/apps/ui/src/hooks/useTvShowPanel.ts b/apps/ui/src/hooks/useTvShowPanel.ts index d128c6e2..a39a68ec 100644 --- a/apps/ui/src/hooks/useTvShowPanel.ts +++ b/apps/ui/src/hooks/useTvShowPanel.ts @@ -10,7 +10,7 @@ import { findSubtitles, findThumbnails, } from "@/lib/tvShowEpisodeAssociatedFiles"; -import type { MediaMetadata } from "@smm/types/types"; +import type { MediaFileMetadata, MediaMetadata } from "@smm/types/types"; import type { Plan } from "@/api/getPlans"; const INIT_METADATA_FILES: MetadataFiles = { @@ -45,20 +45,19 @@ function findMetadataFiles(metadata: MediaMetadata, files: string[]) { } } -export function useTvShowPanel(folderPath: string | undefined, plan: Plan | undefined) { - - if (folderPath === undefined) { - return { - metadataFiles: INIT_METADATA_FILES - } - } +function hasSeasonEpisode( + mediaFile: MediaFileMetadata, +): mediaFile is MediaFileMetadata & { seasonNumber: number; episodeNumber: number } { + return mediaFile.seasonNumber !== undefined && mediaFile.episodeNumber !== undefined +} +export function useTvShowPanel(folderPath: string | undefined, plan: Plan | undefined) { const metadataQuery = useMediaMetadataQuery(folderPath) const filesQuery = useMediaFolderFilesQuery(folderPath) const metadataFiles: MetadataFiles = useMemo(() => { - - if (metadataQuery.data === undefined + if (folderPath === undefined + || metadataQuery.data === undefined || metadataQuery.isError || metadataQuery.isPending || metadataQuery.fetchStatus !== 'idle' @@ -75,83 +74,72 @@ export function useTvShowPanel(folderPath: string | undefined, plan: Plan | unde const metadata = metadataQuery.data return findMetadataFiles(metadata, files) - }, [metadataQuery.data, filesQuery.data]) + }, [ + folderPath, + metadataQuery.data, + metadataQuery.isError, + metadataQuery.isPending, + metadataQuery.fetchStatus, + filesQuery.data, + filesQuery.isError, + filesQuery.isPending, + filesQuery.fetchStatus, + ]) const subtitleFiles: { season: number, episode: number, files: string[] }[] = useMemo(() => { - - if (metadataQuery.data === undefined - || filesQuery.data === undefined - ) { + if (metadataQuery.data === undefined || filesQuery.data === undefined) { return [] } - return metadataQuery.data?.mediaFiles - ?.filter((mediaFile) => mediaFile.seasonNumber !== undefined && mediaFile.episodeNumber !== undefined) - ?.map((mediaFile) => { - return { - season: mediaFile.seasonNumber!!, - episode: mediaFile.episodeNumber!!, - files: findSubtitles(filesQuery.data, mediaFile.absolutePath) - } - }) ?? [] - + return metadataQuery.data.mediaFiles + ?.filter(hasSeasonEpisode) + ?.map((mediaFile) => ({ + season: mediaFile.seasonNumber, + episode: mediaFile.episodeNumber, + files: findSubtitles(filesQuery.data, mediaFile.absolutePath), + })) ?? [] }, [metadataQuery.data, filesQuery.data]) const nfoFiles: { season: number, episode: number, files: string[] }[] = useMemo(() => { - - if (metadataQuery.data === undefined - || filesQuery.data === undefined - ) { + if (metadataQuery.data === undefined || filesQuery.data === undefined) { return [] } - return metadataQuery.data?.mediaFiles - ?.filter((mediaFile) => mediaFile.seasonNumber !== undefined && mediaFile.episodeNumber !== undefined) - ?.map((mediaFile) => { - return { - season: mediaFile.seasonNumber!!, - episode: mediaFile.episodeNumber!!, - files: findNfos(filesQuery.data, mediaFile.absolutePath) - } - }) ?? [] - + return metadataQuery.data.mediaFiles + ?.filter(hasSeasonEpisode) + ?.map((mediaFile) => ({ + season: mediaFile.seasonNumber, + episode: mediaFile.episodeNumber, + files: findNfos(filesQuery.data, mediaFile.absolutePath), + })) ?? [] }, [metadataQuery.data, filesQuery.data]) const thumbnailFiles: { season: number, episode: number, files: string[] }[] = useMemo(() => { - - if (metadataQuery.data === undefined - || filesQuery.data === undefined - ) { + if (metadataQuery.data === undefined || filesQuery.data === undefined) { return [] } - return metadataQuery.data?.mediaFiles - ?.filter((mediaFile) => mediaFile.seasonNumber !== undefined && mediaFile.episodeNumber !== undefined) - ?.map((mediaFile) => { - return { - season: mediaFile.seasonNumber!!, - episode: mediaFile.episodeNumber!!, - files: findThumbnails(filesQuery.data, mediaFile.absolutePath) - } - }) ?? [] - + return metadataQuery.data.mediaFiles + ?.filter(hasSeasonEpisode) + ?.map((mediaFile) => ({ + season: mediaFile.seasonNumber, + episode: mediaFile.episodeNumber, + files: findThumbnails(filesQuery.data, mediaFile.absolutePath), + })) ?? [] }, [metadataQuery.data, filesQuery.data]) const newFilePaths: { season: number, episode: number, newFilePath: string }[] = useMemo(() => { - - if(plan === undefined) { + if (plan === undefined) { return []; } console.log(`Detected plan: `, plan) - if(plan.task === 'rename-files') { + if (plan.task === 'rename-files') { return plan.files .map(file => { - // If rename-files plan is built wrongly // The plan may try to rename the episode that does not exist - const episode = metadataQuery.data?.mediaFiles?.find(mediaFile => mediaFile.absolutePath === file.from) return { season: episode?.seasonNumber ?? -1, @@ -162,7 +150,7 @@ export function useTvShowPanel(folderPath: string | undefined, plan: Plan | unde .filter(file => file.season !== -1 && file.episode !== -1) } - if(plan.task === 'recognize-media-file') { + if (plan.task === 'recognize-media-file') { return plan.files.map(file => { return { season: file.season, @@ -174,7 +162,6 @@ export function useTvShowPanel(folderPath: string | undefined, plan: Plan | unde console.warn(`Unsupported type of plan`) return []; - }, [plan, metadataQuery.data]) return { @@ -184,4 +171,4 @@ export function useTvShowPanel(folderPath: string | undefined, plan: Plan | unde thumbnailFiles, newFilePaths, } -} \ No newline at end of file +} diff --git a/apps/ui/src/lib/ytdlpFormatPresets.ts b/apps/ui/src/lib/ytdlpFormatPresets.ts index 70f34877..c4c34533 100644 --- a/apps/ui/src/lib/ytdlpFormatPresets.ts +++ b/apps/ui/src/lib/ytdlpFormatPresets.ts @@ -1,12 +1,4 @@ -const YTDLP_FORMAT_PRESET_IDS = [ - "default", - "best", - "1080p", - "720p", - "audio", -] as const; - -export type YtdlpFormatPresetId = (typeof YTDLP_FORMAT_PRESET_IDS)[number]; +export type YtdlpFormatPresetId = "default" | "best" | "1080p" | "720p" | "audio"; export interface YtdlpFormatPreset { id: YtdlpFormatPresetId; From 938dd695466dd65dd46ed32bcd1a40d4dc296cd3 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 10 Sep 2026 07:22:21 +0800 Subject: [PATCH 77/83] fix: clear knip unused export and dependency findings Unexport in-file-only toolbar helpers and ignore @wdio/local-runner loaded via runner config. Co-authored-by: Cursor --- apps/ui/src/components/media/MediaFileTableToolbar.tsx | 2 +- apps/ui/src/components/tv/TvShowPanelUtils.ts | 2 +- apps/ui/src/hooks/media/mediaFileTableToolbarShared.ts | 2 +- apps/ui/src/hooks/tv/useTvShowMediaFileTableToolbar.tsx | 2 -- knip.json | 3 ++- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/ui/src/components/media/MediaFileTableToolbar.tsx b/apps/ui/src/components/media/MediaFileTableToolbar.tsx index 4d970f0b..8886518f 100644 --- a/apps/ui/src/components/media/MediaFileTableToolbar.tsx +++ b/apps/ui/src/components/media/MediaFileTableToolbar.tsx @@ -38,7 +38,7 @@ export type MediaFileTableMenuId = | "process" | "openExternal" -export type MediaFileTableToolbarCollapseAt = 410 | 310 | 220 | 200 +type MediaFileTableToolbarCollapseAt = 410 | 310 | 220 | 200 export interface MediaFileTableToolbarProps { leading: ReactNode diff --git a/apps/ui/src/components/tv/TvShowPanelUtils.ts b/apps/ui/src/components/tv/TvShowPanelUtils.ts index 03facf96..5d0f923f 100644 --- a/apps/ui/src/components/tv/TvShowPanelUtils.ts +++ b/apps/ui/src/components/tv/TvShowPanelUtils.ts @@ -741,7 +741,7 @@ export function unlinkEpisode(params: UnlinkEpisodeParams): void { }) } /** Display title for a season row: season 0 is always Specials; empty names get Season N. */ -export function seasonDisplayTitle(season: number, name: string | undefined): string { +function seasonDisplayTitle(season: number, name: string | undefined): string { if (season === 0) return 'Specials' const trimmed = name?.trim() ?? '' return trimmed || `Season ${season}` diff --git a/apps/ui/src/hooks/media/mediaFileTableToolbarShared.ts b/apps/ui/src/hooks/media/mediaFileTableToolbarShared.ts index 4ba2b08c..9fa3214a 100644 --- a/apps/ui/src/hooks/media/mediaFileTableToolbarShared.ts +++ b/apps/ui/src/hooks/media/mediaFileTableToolbarShared.ts @@ -1,7 +1,7 @@ import type { MediaFileTableMenuId } from "@/components/media/MediaFileTableToolbar" import type { UIMediaFolderStatus } from "@/types/UIMediaFolder" -export const SUBTITLE_MENU_IDS: MediaFileTableMenuId[] = [ +const SUBTITLE_MENU_IDS: MediaFileTableMenuId[] = [ "subtitle", "transcribe", "translate", diff --git a/apps/ui/src/hooks/tv/useTvShowMediaFileTableToolbar.tsx b/apps/ui/src/hooks/tv/useTvShowMediaFileTableToolbar.tsx index ff532c18..88e16a01 100644 --- a/apps/ui/src/hooks/tv/useTvShowMediaFileTableToolbar.tsx +++ b/apps/ui/src/hooks/tv/useTvShowMediaFileTableToolbar.tsx @@ -17,8 +17,6 @@ import { isMediaFileTableToolbarLoading, } from "../media/mediaFileTableToolbarShared" -export type { EpisodeTableLayout } - export interface UseTvShowMediaFileTableToolbarArgs { onSearchResultSelected: (args: SearchResultSelectedArgs) => void onRecognizeButtonClick?: () => void diff --git a/knip.json b/knip.json index d384afbc..c7a5152c 100644 --- a/knip.json +++ b/knip.json @@ -87,7 +87,8 @@ "common/manual/**" ], "ignoreDependencies": [ - "@wdio/html-nice-reporter" + "@wdio/html-nice-reporter", + "@wdio/local-runner" ] }, "apps/convex": { From 61ccfb6175700967f8937454b68cb9717ee6ea64 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 10 Sep 2026 07:45:25 +0800 Subject: [PATCH 78/83] fix: fixed typecheck errors --- apps/e2e/test/steps/episode-link-steps.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/e2e/test/steps/episode-link-steps.ts b/apps/e2e/test/steps/episode-link-steps.ts index 56c4d432..f67d2abe 100644 --- a/apps/e2e/test/steps/episode-link-steps.ts +++ b/apps/e2e/test/steps/episode-link-steps.ts @@ -17,17 +17,18 @@ async function getEpisodeVideoCellText(episodeId: string): Promise { await idCell.waitForDisplayed({ timeout: 10000 }) const row = await idCell.parentElement() const cells = await row.$$('td') + const cellsCount = await cells.length let idCellIndex = -1 - for (let i = 0; i < cells.length; i++) { + for (let i = 0; i < cellsCount; i++) { const text = (await cells[i]!.getText()).trim() if (text === episodeId) { idCellIndex = i break } } - if (idCellIndex < 0 || idCellIndex + 1 >= cells.length) { + if (idCellIndex < 0 || idCellIndex + 1 >= cellsCount) { throw new Error( - `Video file cell not found for episode "${episodeId}" (idCellIndex=${idCellIndex}, cells=${cells.length})`, + `Video file cell not found for episode "${episodeId}" (idCellIndex=${idCellIndex}, cells=${cellsCount})`, ) } return (await cells[idCellIndex + 1]!.getText()).trim() From 664faf0ad768811ff08e2f283d182390ac73b307 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 10 Sep 2026 10:07:12 +0800 Subject: [PATCH 79/83] fix: fixed unit tests failure --- apps/ui/src/components/tv/TvShowPanel.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/ui/src/components/tv/TvShowPanel.tsx b/apps/ui/src/components/tv/TvShowPanel.tsx index 26b55a1d..6e1e14a4 100644 --- a/apps/ui/src/components/tv/TvShowPanel.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.tsx @@ -244,7 +244,9 @@ function TvShowPanel() { ) const planId = useMemo(() => { return plan?.id ?? '' }, [plan]) - const [syncedPlanId, setSyncedPlanId] = useState(planId) + // null sentinel so the first render with an already-pending plan still seeds selection + // (useState(planId) would skip sync when plan is present on mount). + const [syncedPlanId, setSyncedPlanId] = useState(null) // Adjust checkbox selection when the active plan changes (React: adjust state during render). if (planId !== syncedPlanId) { From 987a4972f8b72bfb80dc436bf9eeaf174860c1cb Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 10 Sep 2026 10:19:26 +0800 Subject: [PATCH 80/83] fix: fixed build failure --- apps/ui/src/hooks/useTvShowPanel.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/ui/src/hooks/useTvShowPanel.ts b/apps/ui/src/hooks/useTvShowPanel.ts index a39a68ec..47c4753f 100644 --- a/apps/ui/src/hooks/useTvShowPanel.ts +++ b/apps/ui/src/hooks/useTvShowPanel.ts @@ -87,7 +87,7 @@ export function useTvShowPanel(folderPath: string | undefined, plan: Plan | unde ]) const subtitleFiles: { season: number, episode: number, files: string[] }[] = useMemo(() => { - if (metadataQuery.data === undefined || filesQuery.data === undefined) { + if (metadataQuery.data == null || filesQuery.data === undefined) { return [] } @@ -101,7 +101,7 @@ export function useTvShowPanel(folderPath: string | undefined, plan: Plan | unde }, [metadataQuery.data, filesQuery.data]) const nfoFiles: { season: number, episode: number, files: string[] }[] = useMemo(() => { - if (metadataQuery.data === undefined || filesQuery.data === undefined) { + if (metadataQuery.data == null || filesQuery.data === undefined) { return [] } @@ -115,7 +115,7 @@ export function useTvShowPanel(folderPath: string | undefined, plan: Plan | unde }, [metadataQuery.data, filesQuery.data]) const thumbnailFiles: { season: number, episode: number, files: string[] }[] = useMemo(() => { - if (metadataQuery.data === undefined || filesQuery.data === undefined) { + if (metadataQuery.data == null || filesQuery.data === undefined) { return [] } From 4ad59ffada72bfcaab2fd892d15ac38b8fa42fcb Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 10 Sep 2026 20:50:55 +0800 Subject: [PATCH 81/83] fix: fixed e2e failure --- .../src/pipeline/recognizeMediaFolder.test.ts | 40 ++++++- .../core/src/pipeline/recognizeMediaFolder.ts | 101 +++++++++--------- apps/e2e/README.md | 15 +-- apps/e2e/import-folder-test.md | 26 +++++ apps/e2e/package.json | 6 +- packages/test/src/index.ts | 2 + packages/test/src/testFolders.ts | 4 +- pnpm-lock.yaml | 10 ++ ...w-to-recognize-episode-video-files.test.ts | 2 +- .../how-to-rename-episode-video-files.test.ts | 2 +- 10 files changed, 137 insertions(+), 71 deletions(-) create mode 100644 apps/e2e/import-folder-test.md diff --git a/apps/core/src/pipeline/recognizeMediaFolder.test.ts b/apps/core/src/pipeline/recognizeMediaFolder.test.ts index 7cff257d..41143b9f 100644 --- a/apps/core/src/pipeline/recognizeMediaFolder.test.ts +++ b/apps/core/src/pipeline/recognizeMediaFolder.test.ts @@ -272,7 +272,7 @@ describe("recognizeMediaFolder", () => { expect(d.tvdb.getMovieMediaMetadata).toHaveBeenCalledWith(116, "eng"); }); - it("recognizes movie by exact TMDB title search (folder2 fixture)", async () => { + it("recognizes movie by TMDB folder name search (folder2 fixture)", async () => { const d = deps(); (d.tmdb.search as ReturnType).mockResolvedValue({ results: [{ id: 1539104, title: folder2.folderName }], @@ -315,7 +315,7 @@ describe("recognizeMediaFolder", () => { expect(d.tmdb.search).toHaveBeenCalledWith(folder2.folderName, "movie", "en-US"); }); - it("prefers exact TMDB title match over a non-matching first result", async () => { + it("uses first TMDB movie result like TV search (no exact folder name match)", async () => { const d = deps(); (d.tmdb.search as ReturnType).mockResolvedValue({ results: [ @@ -329,8 +329,9 @@ describe("recognizeMediaFolder", () => { const result = await recognizeMediaFolder(mm, d); expect(result.movie).toEqual({ - id: "1539104", - name: folder2.folderName, + id: "1", + name: "Wrong First Hit", + airDate: undefined, database: "TMDB", }); }); @@ -353,6 +354,37 @@ describe("recognizeMediaFolder", () => { expect(d.tvdb.searchMovie).toHaveBeenCalledWith("The Dark Knight", "eng"); }); + it("recognizes movie by TVDB search without requiring exact folder name match", async () => { + const capedCrusaders: MovieMediaMetadata = { + database: "TVDB", + id: "13611", + name: "蝙蝠侠:披风斗士归来", + }; + const d = deps({ primaryDatabase: "TVDB", language: "zh-CN" }); + (d.tvdb.searchMovie as ReturnType).mockResolvedValue([ + { + objectID: "movie-13611", + name: "蝙蝠侠:披风斗士归来", + tvdb_id: "13611", + }, + ]); + (d.tvdb.getMovieMediaMetadata as ReturnType).mockResolvedValue(capedCrusaders); + + const created = createFolderInTestFolder(mediaDir, { + ...movieFolder, + folderName: "Batman Return of the Caped Crusaders", + }); + const mm = await mediaMetadataFrom(created); + const result = await recognizeMediaFolder(mm, d); + + expect(result.movie).toEqual(capedCrusaders); + expect(d.tvdb.searchMovie).toHaveBeenCalledWith( + "Batman Return of the Caped Crusaders", + "zho", + ); + expect(d.tmdb.search).not.toHaveBeenCalled(); + }); + it("recognizes movie via movie.nfo tmdbid on disk", async () => { const d = deps(); (d.tmdb.getMovieMediaMetadata as ReturnType).mockResolvedValue({ diff --git a/apps/core/src/pipeline/recognizeMediaFolder.ts b/apps/core/src/pipeline/recognizeMediaFolder.ts index 8bab4f59..639ce1b7 100644 --- a/apps/core/src/pipeline/recognizeMediaFolder.ts +++ b/apps/core/src/pipeline/recognizeMediaFolder.ts @@ -57,10 +57,15 @@ export function getTvdbIdFromFolderName(folderName: string): string | null { return match === null ? null : match[1]!; } -export function resolveTvdbSeriesId(item: TVDBv4SearchResult): number | undefined { +/** Shared TVDB search-id resolution for series and movies (same matching rules). */ +export function resolveTvdbSearchId( + item: TVDBv4SearchResult, + kind: "series" | "movie", +): number | undefined { const oid = item.objectID ?? item.id; - if (oid.startsWith("series-")) { - const n = parseInt(oid.slice("series-".length), 10); + const prefixRe = kind === "series" ? /^series-/i : /^movie-/i; + if (prefixRe.test(oid)) { + const n = parseInt(oid.replace(prefixRe, ""), 10); if (Number.isFinite(n) && n > 0) return n; } const raw = item.tvdb_id; @@ -71,18 +76,12 @@ export function resolveTvdbSeriesId(item: TVDBv4SearchResult): number | undefine return undefined; } +export function resolveTvdbSeriesId(item: TVDBv4SearchResult): number | undefined { + return resolveTvdbSearchId(item, "series"); +} + export function resolveTvdbMovieId(item: TVDBv4SearchResult): number | undefined { - const oid = item.objectID ?? item.id; - if (/^movie-/i.test(oid)) { - const n = parseInt(oid.replace(/^movie-/i, ""), 10); - if (Number.isFinite(n) && n > 0) return n; - } - const raw = item.tvdb_id; - if (raw !== undefined) { - const n = parseInt(String(raw), 10); - if (Number.isFinite(n) && n > 0) return n; - } - return undefined; + return resolveTvdbSearchId(item, "movie"); } function folderNameOf(mm: MediaMetadata): string { @@ -144,6 +143,10 @@ async function recognizeByNfo( } } +/** + * TV and movie share the same rule: take the first usable search hit. + * Do not require an exact folder-name / title match. + */ async function searchInTmdb( folderName: string, isTvShow: boolean, @@ -151,27 +154,29 @@ async function searchInTmdb( result: RecognitionResult, ): Promise { try { + const type = isTvShow ? "tv" : "movie"; + const body = await deps.tmdb.search(folderName, type, deps.language); + const first = body.results[0] as TMDBTVShow | TMDBMovie | undefined; + if (first === undefined) return; + if (isTvShow) { - const body = await deps.tmdb.search(folderName, "tv", deps.language); - const first = body.results[0] as TMDBTVShow | undefined; - if (first !== undefined) { - const tvShow = await deps.tmdb.getTvShowMediaMetadata(first.id, deps.language); - if (tvShow !== undefined) result.tvShow = tvShow; - } + const tvShow = await deps.tmdb.getTvShowMediaMetadata( + (first as TMDBTVShow).id, + deps.language, + ); + if (tvShow !== undefined) result.tvShow = tvShow; } else { - const body = await deps.tmdb.search(folderName, "movie", deps.language); - const movies = body.results as TMDBMovie[]; - const exact = movies.find((movie) => movie.title === folderName); - const chosen = exact ?? movies[0]; - if (chosen !== undefined) { - result.movie = movieMediaMetadataFromTmdbSearch(chosen); - } + result.movie = movieMediaMetadataFromTmdbSearch(first as TMDBMovie); } } catch { // recognition is best-effort; fall through to the next phase } } +/** + * TV and movie share the same rule: walk search results in order and use the + * first item that resolves to metadata. No exact folder-name match. + */ async function searchInTvdb( folderName: string, isTvShow: boolean, @@ -180,37 +185,31 @@ async function searchInTvdb( tvdbLang: string, ): Promise { try { - if (isTvShow) { - const items = await deps.tvdb.searchSeries(folderName, tvdbLang); - for (const item of items ?? []) { - try { - const id = resolveTvdbSeriesId(item); - if (id === undefined) continue; + const kind = isTvShow ? "series" : "movie"; + const items = isTvShow + ? await deps.tvdb.searchSeries(folderName, tvdbLang) + : await deps.tvdb.searchMovie(folderName, tvdbLang); + + for (const item of items ?? []) { + try { + const id = resolveTvdbSearchId(item, kind); + if (id === undefined) continue; + + if (isTvShow) { const tvShow = await deps.tvdb.getTvShowMediaMetadata(id, tvdbLang); if (tvShow !== undefined) { result.tvShow = tvShow; return; } - } catch { - // best-effort per search result - } - } - } else { - const items = await deps.tvdb.searchMovie(folderName, tvdbLang); - for (const item of items ?? []) { - try { - if (item.name === folderName) { - const id = resolveTvdbMovieId(item); - if (id === undefined) continue; - const movie = await deps.tvdb.getMovieMediaMetadata(id, tvdbLang); - if (movie !== undefined) { - result.movie = movie; - return; - } + } else { + const movie = await deps.tvdb.getMovieMediaMetadata(id, tvdbLang); + if (movie !== undefined) { + result.movie = movie; + return; } - } catch { - // best-effort per search result } + } catch { + // best-effort per search result } } } catch { diff --git a/apps/e2e/README.md b/apps/e2e/README.md index ca1ae1de..02e703d5 100644 --- a/apps/e2e/README.md +++ b/apps/e2e/README.md @@ -1,15 +1,10 @@ -# e2e +# apps/e2e -To install dependencies: +This folder holds the e2e tests for various [supported platform](../../docs/dev/supported-platform.md). -```bash -bun install -``` +The test cases are managed by google/zx markdown file. -To run: -```bash -bun run index.ts ``` - -This project was created using `bun init` in bun v1.3.3. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. +$ pnpm zx import-folder-test.md --platform web +``` diff --git a/apps/e2e/import-folder-test.md b/apps/e2e/import-folder-test.md new file mode 100644 index 00000000..b993f952 --- /dev/null +++ b/apps/e2e/import-folder-test.md @@ -0,0 +1,26 @@ +# Import Folder Test + +> This is markdown file executed by google/zx + +```js +const supportedPlatforms = ['web', 'ohos', 'cli', 'electron', 'mcp', 'ai'] + +if(!supportedPlatforms.includes(argv.platform)) { + console.error(`Unsupported platform "${argv.platform}". Valid values are ${JSON.stringify(supportedPlatforms)}`) + process.exit(1) +} + +process.env.TARGET_PLATFORM = argv.platform + +``` + +## Test Cases for Web UI + +```bash +if [ "$TARGET_PLATFORM" = "web" ]; then + bun ../../ci/run-e2e-test.ts --spec './common/tv/TVShow-Import.e2e.ts' +else + echo 'skipped: web' +fi +``` + diff --git a/apps/e2e/package.json b/apps/e2e/package.json index 4a4350fc..8d10a24c 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -16,7 +16,8 @@ "expect-webdriverio": "^5.6.1", "shelljs": "^0.10.0", "typescript": "^5.0.0", - "wdio-html-nice-reporter": "^8.1.7" + "wdio-html-nice-reporter": "^8.1.7", + "zx": "^8.8.5" }, "scripts": { "typecheck": "tsc --noEmit", @@ -38,7 +39,8 @@ "wdio:other": "wdio run ./wdio.conf.ts --spec \"./common/other/*.e2e.ts\"", "wdio:config": "wdio run ./wdio.conf.ts --spec \"./common/config/*.e2e.ts\" --spec \"./common/httpproxy/*.e2e.ts\"", "wdio:ai": "wdio run ./wdio.conf.ts --spec \"./test/specs/ai/*.e2e.ts\"", - "wdio:mcp": "wdio run ./wdio.conf.ts --spec \"./common/mcp/*.e2e.ts\"" + "wdio:mcp": "wdio run ./wdio.conf.ts --spec \"./common/mcp/*.e2e.ts\"", + "zx": "zx --verbose" }, "dependencies": { "@smm/test": "workspace:*", diff --git a/packages/test/src/index.ts b/packages/test/src/index.ts index 49341d73..ac3fec0c 100644 --- a/packages/test/src/index.ts +++ b/packages/test/src/index.ts @@ -6,9 +6,11 @@ export { type LangCode, type TestFolder, + folder1, folder2, folder3, folder4, + folder5, folder6, musicFolder, tvShowFolder, diff --git a/packages/test/src/testFolders.ts b/packages/test/src/testFolders.ts index ef1bb36d..47300da8 100644 --- a/packages/test/src/testFolders.ts +++ b/packages/test/src/testFolders.ts @@ -17,7 +17,7 @@ export interface TestFolder { } /** TMDB-tagged TV show (天使降临到我身边). */ -const folder1: TestFolder = { +export const folder1: TestFolder = { folderName: '天使降临到我身边! (2019) {tmdbid=84666}', mediaName: '天使降临到我身边!', translations: { @@ -77,7 +77,7 @@ export const folder4: TestFolder = { } /** TVDB-tagged movie. */ -const folder5: TestFolder = { +export const folder5: TestFolder = { folderName: 'The Dark Knight {tvdbid=116}', mediaName: '蝙蝠侠:黑暗骑士', translations: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6af93470..a85c1ee7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,6 +236,9 @@ importers: wdio-html-nice-reporter: specifier: ^8.1.7 version: 8.1.7(chokidar@3.6.0)(encoding@0.1.13) + zx: + specifier: ^8.8.5 + version: 8.8.5 apps/electron: dependencies: @@ -9245,6 +9248,11 @@ packages: zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + zx@8.8.5: + resolution: {integrity: sha512-SNgDF5L0gfN7FwVOdEFguY3orU5AkfFZm9B5YSHog/UDHv+lvmd82ZAsOenOkQixigwH2+yyH198AwNdKhj+RA==} + engines: {node: '>= 12.17.0'} + hasBin: true + snapshots: 7zip-bin@5.2.0: {} @@ -18762,3 +18770,5 @@ snapshots: use-sync-external-store: 1.6.0(react@19.2.4) zwitch@2.0.4: {} + + zx@8.8.5: {} diff --git a/test/mcp/how-to-recognize-episode-video-files.test.ts b/test/mcp/how-to-recognize-episode-video-files.test.ts index 567c9230..32d1008f 100644 --- a/test/mcp/how-to-recognize-episode-video-files.test.ts +++ b/test/mcp/how-to-recognize-episode-video-files.test.ts @@ -9,6 +9,6 @@ describe('MCP Prompts - HowToRecognizeEpisodeVideoFilesTool', () => { const r = await callTool(ctx.url, 'how-to-recognize-episode-video-files') expect(r.isError).toBe(false) expect(r.structuredContent!.text).toContain('如何使用 SMM MCP tool 识别季集视频文件') - expect(r.structuredContent!.text).toContain('begin-recognize-task') + expect(r.structuredContent!.text).toContain('create-recognize-episode-plan') }) }) diff --git a/test/mcp/how-to-rename-episode-video-files.test.ts b/test/mcp/how-to-rename-episode-video-files.test.ts index cc541839..c25f4b7e 100644 --- a/test/mcp/how-to-rename-episode-video-files.test.ts +++ b/test/mcp/how-to-rename-episode-video-files.test.ts @@ -9,6 +9,6 @@ describe('MCP Prompts - HowToRenameEpisodeVideoFilesTool', () => { const r = await callTool(ctx.url, 'how-to-rename-episode-video-files') expect(r.isError).toBe(false) expect(r.structuredContent!.text).toContain('如何使用 SMM MCP tool 重命名媒体文件') - expect(r.structuredContent!.text).toContain('begin-rename-episode-video-file-task') + expect(r.structuredContent!.text).toContain('create-rename-episode-plan') }) }) From 0faae9c4776b3413c7fa9e69d05e9ca9b2222cba Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 10 Sep 2026 21:50:19 +0800 Subject: [PATCH 82/83] fix: fixed e2e failure --- ...ize.test.ts => recognize-episodes.test.ts} | 0 .../mcp/McpOther-RecognizeTaskFlow.e2e.ts | 30 +++---- ...e2e.ts => TVShow-RecognizeEpisodes.e2e.ts} | 0 apps/e2e/recognize-episodes-test.md | 86 +++++++++++++++++++ apps/e2e/test/lib/McpClient.ts | 37 +++----- apps/e2e/test/lib/mcpToolTypes.ts | 43 +++------- docs/dev/recognize-episodes.md | 2 + knip.json | 6 +- 8 files changed, 127 insertions(+), 77 deletions(-) rename apps/e2e/cli/{recognize.test.ts => recognize-episodes.test.ts} (100%) rename apps/e2e/common/tv/{TVShow-Recognize.e2e.ts => TVShow-RecognizeEpisodes.e2e.ts} (100%) create mode 100644 apps/e2e/recognize-episodes-test.md diff --git a/apps/e2e/cli/recognize.test.ts b/apps/e2e/cli/recognize-episodes.test.ts similarity index 100% rename from apps/e2e/cli/recognize.test.ts rename to apps/e2e/cli/recognize-episodes.test.ts diff --git a/apps/e2e/common/mcp/McpOther-RecognizeTaskFlow.e2e.ts b/apps/e2e/common/mcp/McpOther-RecognizeTaskFlow.e2e.ts index d4edab86..a24c9bc7 100644 --- a/apps/e2e/common/mcp/McpOther-RecognizeTaskFlow.e2e.ts +++ b/apps/e2e/common/mcp/McpOther-RecognizeTaskFlow.e2e.ts @@ -67,7 +67,7 @@ describe('MCP Other - RecognizeTaskFlow', () => { } }) - it('MCP recognize task tools should recognize episode video file via begin/add/end flow', async () => { + it('MCP create recognize episode plan tool should recognize an episode video file', async () => { const folder: TestFolder = { ...folder1, files: ['[1].mp4'], @@ -87,25 +87,19 @@ describe('MCP Other - RecognizeTaskFlow', () => { return mm.mediaFiles === undefined || mm.mediaFiles.length === 0 }) - const begin = await mcpClient.beginRecognizeTask(ctx.clientCwd, ctx.mcpAddress, { + const created = await mcpClient.createRecognizeEpisodePlan(ctx.clientCwd, ctx.mcpAddress, { mediaFolderPath: folderPath, + files: [ + { + season: 1, + episode: 1, + path: joinPlatformPath(folderPath, '[1].mp4'), + }, + ], }) - expect(begin.success).toBe(true) - expect(typeof begin.taskId).toBe('string') - expect(begin.taskId.length).toBeGreaterThan(0) - - const add = await mcpClient.addRecognizedFile(ctx.clientCwd, ctx.mcpAddress, { - taskId: begin.taskId, - season: 1, - episode: 1, - path: joinPlatformPath(folderPath, '[1].mp4'), - }) - expect(add.success).toBe(true) - - const end = await mcpClient.endRecognizeTask(ctx.clientCwd, ctx.mcpAddress, { - taskId: begin.taskId, - }) - expect(end.success).toBe(true) + expect(typeof created.planId).toBe('string') + expect(created.planId.length).toBeGreaterThan(0) + expect(typeof created.message).toBe('string') await Prompts.aiBasedRecognizePrompt.waitForDisplayed({ timeout: 10000 }) await Prompts.confirmButton.click() diff --git a/apps/e2e/common/tv/TVShow-Recognize.e2e.ts b/apps/e2e/common/tv/TVShow-RecognizeEpisodes.e2e.ts similarity index 100% rename from apps/e2e/common/tv/TVShow-Recognize.e2e.ts rename to apps/e2e/common/tv/TVShow-RecognizeEpisodes.e2e.ts diff --git a/apps/e2e/recognize-episodes-test.md b/apps/e2e/recognize-episodes-test.md new file mode 100644 index 00000000..627d221c --- /dev/null +++ b/apps/e2e/recognize-episodes-test.md @@ -0,0 +1,86 @@ +# Recognize Episodes Test + +> This is markdown file executed by google/zx + + +## Test Cases + +```js +const supportedPlatforms = ['web', 'ohos', 'cli', 'electron', 'mcp', 'ai', 'all'] + +if(!supportedPlatforms.includes(argv.platform)) { + console.error(`Unsupported platform "${argv.platform}". Valid values are ${JSON.stringify(supportedPlatforms)}`) + process.exit(1) +} + +process.env.TARGET_PLATFORM = argv.platform + +``` + +### Web UI + +```bash +if [ "$TARGET_PLATFORM" = "web" ] || [ "$TARGET_PLATFORM" = "all" ]; then + bun ../../ci/run-e2e-test.ts --spec './common/tv/TVShow-RecognizeEpisodes.e2e.ts' +else + echo "skipped: web" +fi +``` + + +### Electron + +```bash +if [ "$TARGET_PLATFORM" = "electron" ] || [ "$TARGET_PLATFORM" = "all" ]; then + bun ../../ci/run-e2e-test.ts --spec './common/tv/TVShow-RecognizeEpisodes.e2e.ts' --platform electron +else + echo "skipped: electron" +fi +``` + +### Ohos + +```bash +if [ "$TARGET_PLATFORM" = "ohos" ] || [ "$TARGET_PLATFORM" = "all" ]; then + bun ../../ci/run-e2e-test.ts --spec './common/tv/TVShow-RecognizeEpisodes.e2e.ts' --platform ohos +else + echo "skipped: ohos" +fi +``` + +### CLI + +```bash +if [ "$TARGET_PLATFORM" = "cli" ] || [ "$TARGET_PLATFORM" = "all" ]; then + bun test cli/recognize-episodes.test.ts +else + echo "skipped: cli" +fi +``` + +### MCP + + +```bash +if [ "$TARGET_PLATFORM" = "mcp" ] || [ "$TARGET_PLATFORM" = "all" ]; then + bun ../../ci/run-e2e-test.ts --spec './common/mcp\McpOther-RecognizeTaskFlow.e2e.ts' +else + echo "skipped: mcp" +fi +``` + +### AI + +```bash +if [ "$TARGET_PLATFORM" = "ai" ] || [ "$TARGET_PLATFORM" = "all" ]; then + echo "no test cases" +else + echo "skipped: ai" +fi +``` + + +## References + +[How to execute google/gz script](./README.md) +[Recognize Episodes Requirement](../../docs/dev/recognize-episodes.md) diff --git a/apps/e2e/test/lib/McpClient.ts b/apps/e2e/test/lib/McpClient.ts index 2c1325eb..e3fbdffa 100644 --- a/apps/e2e/test/lib/McpClient.ts +++ b/apps/e2e/test/lib/McpClient.ts @@ -4,14 +4,10 @@ import shell from 'shelljs' import { delay } from 'es-toolkit' import { McpToolName, - type AddRecognizedFileRequest, - type AddRecognizedFileResponse, type CreateRenameEpisodePlanRequest, type CreateRenameEpisodePlanResponse, - type BeginRecognizeTaskRequest, - type BeginRecognizeTaskResponse, - type EndRecognizeTaskRequest, - type EndRecognizeTaskResponse, + type CreateRecognizeEpisodePlanRequest, + type CreateRecognizeEpisodePlanResponse, type GetAppContextResponse, type GetEpisodeRequest, type GetEpisodeResponse, @@ -250,28 +246,17 @@ class McpClient { ) } - async beginRecognizeTask( + async createRecognizeEpisodePlan( clientCwd: string, mcpAddress: string, - req: BeginRecognizeTaskRequest, - ): Promise { - return this.execTyped(clientCwd, mcpAddress, McpToolName.beginRecognizeTask, toolArgs(req)) - } - - async addRecognizedFile( - clientCwd: string, - mcpAddress: string, - req: AddRecognizedFileRequest, - ): Promise { - return this.execTyped(clientCwd, mcpAddress, McpToolName.addRecognizedFile, toolArgs(req)) - } - - async endRecognizeTask( - clientCwd: string, - mcpAddress: string, - req: EndRecognizeTaskRequest, - ): Promise { - return this.execTyped(clientCwd, mcpAddress, McpToolName.endRecognizeTask, toolArgs(req)) + req: CreateRecognizeEpisodePlanRequest, + ): Promise { + return this.execTyped( + clientCwd, + mcpAddress, + McpToolName.createRecognizeEpisodePlan, + toolArgs(req), + ) } async getEpisode( diff --git a/apps/e2e/test/lib/mcpToolTypes.ts b/apps/e2e/test/lib/mcpToolTypes.ts index 2ae33c38..e76c554d 100644 --- a/apps/e2e/test/lib/mcpToolTypes.ts +++ b/apps/e2e/test/lib/mcpToolTypes.ts @@ -19,9 +19,7 @@ export const McpToolName = { scrape: 'scrape', getJob: 'get-job', createRenameEpisodePlan: 'create-rename-episode-plan', - beginRecognizeTask: 'begin-recognize-task', - addRecognizedFile: 'add-recognized-media-file', - endRecognizeTask: 'end-recognize-task', + createRecognizeEpisodePlan: 'create-recognize-episode-plan', getEpisode: 'get-episode', getEpisodes: 'get-episodes', tmdbSearch: 'tmdb-search', @@ -207,38 +205,19 @@ export interface CreateRenameEpisodePlanResponse { planId: string } -// --- recognize task --- -export interface BeginRecognizeTaskRequest { +// --- create-recognize-episode-plan --- +export interface CreateRecognizeEpisodePlanRequest { mediaFolderPath: string + files: Array<{ + season: number + episode: number + path: string + }> } -export interface BeginRecognizeTaskResponse { - success: boolean - taskId: string - mediaFolderPath?: string -} - -export interface AddRecognizedFileRequest { - taskId: string - season: number - episode: number - path: string -} - -export interface AddRecognizedFileResponse { - success: boolean - taskId: string -} - -export interface EndRecognizeTaskRequest { - taskId: string -} - -export interface EndRecognizeTaskResponse { - success: boolean - taskId: string - fileCount?: number - error?: string +export interface CreateRecognizeEpisodePlanResponse { + message: string + planId: string } // --- get-episode / get-episodes --- diff --git a/docs/dev/recognize-episodes.md b/docs/dev/recognize-episodes.md index 0bab2334..a7899699 100644 --- a/docs/dev/recognize-episodes.md +++ b/docs/dev/recognize-episodes.md @@ -58,3 +58,5 @@ The Web UI may uncheck episodes before confirming; `apply-plan` then carries `da [Import Folder](./import-folder.md) [Supported Platform](./supported-platform.md) + +[E2E Test Cases](../../apps/e2e/recognize-episodes-test.md) \ No newline at end of file diff --git a/knip.json b/knip.json index c7a5152c..db524074 100644 --- a/knip.json +++ b/knip.json @@ -133,6 +133,10 @@ "src/**/*.test.ts" ] }, - "packages/test": {} + "packages/test": { + "ignoreIssues": { + "src/testFolders.ts": ["duplicates"] + } + } } } \ No newline at end of file From d9e7572ac9e80753ca757c680c5e4a44a71aca94 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Fri, 11 Sep 2026 00:36:00 +0800 Subject: [PATCH 83/83] fix: fixed e2e failure --- apps/e2e/cli/import-folder.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/e2e/cli/import-folder.test.ts b/apps/e2e/cli/import-folder.test.ts index 8e178962..46bde8e6 100644 --- a/apps/e2e/cli/import-folder.test.ts +++ b/apps/e2e/cli/import-folder.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { join } from 'node:path' import { folder1, createFolderInTestFolder, folder2 } from '../test/actions/import-folders' import { setup, cleanup, bin } from './base' import { metadataMediaFileLine } from './helpers' +import { Path } from '@smm/utils/path' import { $ } from 'bun' const FIVE_MINUTES_MS = 5 * 60 * 1000 @@ -107,7 +109,10 @@ Type: tvshow-folder it('import movie folder', async () => { - const testFolder = createFolderInTestFolder(folder2) + const testFolder = createFolderInTestFolder({ + ...folder2, + folderName: '{tmdbid=1539104}', + }) const folderPath = testFolder.path const ret = await $`${bin} add ${folderPath} --type movie --verbose @@ -117,14 +122,23 @@ ${bin} metadata ${folderPath} `.nothrow() expect(ret.exitCode).toBe(0) + const movieFileLine = Path.toPlatformPath(join(folderPath!, 'movie.mkv')) expect(ret.text()).toContain(`${folderPath} Path: ${folderPath} Status: ok Type: movie-folder +Title: JUJUTSU KAISEN: Execution + + movie.mkv mediaFolderPath: ${folderPath} type: movie-folder +movie: + name: JUJUTSU KAISEN: Execution + database: TMDB + id: 1539104 + airDate: 2025-11-07 mediaFiles: - (empty)`) + - absolutePath: ${movieFileLine}`) }, FIVE_MINUTES_MS) it('import music folder', async () => {