diff --git a/app.json b/app.json index 44cc426..a217057 100644 --- a/app.json +++ b/app.json @@ -3,7 +3,7 @@ "name": "SimpleMediaBrowser", "owner": "kallb123", "slug": "SimpleMediaBrowser", - "version": "1.11.1", + "version": "1.11.2", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "simplemediabrowser", @@ -20,7 +20,7 @@ }, "predictiveBackGestureEnabled": false, "package": "net.nawt.simplemediabrowser", - "versionCode": 45 + "versionCode": 46 }, "web": { "output": "static", diff --git a/src/components/MediaBrowserScreen.tsx b/src/components/MediaBrowserScreen.tsx index 1fa7a93..424c7c4 100644 --- a/src/components/MediaBrowserScreen.tsx +++ b/src/components/MediaBrowserScreen.tsx @@ -1,16 +1,19 @@ -import { BackHandler, StyleSheet, TouchableOpacity, View, useWindowDimensions } from 'react-native'; +import { Alert, BackHandler, StyleSheet, TouchableOpacity, View, useWindowDimensions } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { openMediaInExternalApp } from '@/scripts/openMedia'; import { ThemedText } from '@/components/ThemedText'; import { ThemedView } from '@/components/ThemedView'; import { useThemeColor } from '@/hooks/useThemeColor'; +import { File } from 'expo-file-system'; import type { VideoThumbnail } from 'expo-video'; import { Link, router } from 'expo-router'; import { useEffect, useMemo, useCallback, useState, useRef } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { selectMediaSources, selectMediaStructure, selectViewScale, selectViewOrientation } from '@/store/settingsReducer'; -import { setMediaOverride } from '@/store/libraryReducer'; +import { clearEpisodeMetadata, clearMovieMetadata, clearMediaOverride, clearShowMetadata, setMediaOverride } from '@/store/libraryReducer'; import { selectMediaLibrary, selectMovies, selectIsScanning, selectMediaOverrides, selectScanProgress } from '@/store/libraryReducer'; +import { MetadataService } from '@/scripts/MetadataService'; +import { store } from '@/store/store'; import { FlashList, FlashListRef } from '@shopify/flash-list'; import { IMediaObject, thumbnailCache } from '@/scripts/FileScanner'; import type { IMediaLibrary } from '@/store/libraryReducer'; @@ -635,13 +638,121 @@ export function MediaBrowserScreen({ mediaFilter }: MediaBrowserScreenProps) { // Derive the selected show names (without prefix) for the Merge action. const selectedShowKeys = useMemo( - () => Array.from(selectedItems).filter((k) => k.startsWith('show:')).map((k) => k.slice(5)), + () => (Array.from(selectedItems) as string[]).filter((k) => k.startsWith('show:')).map((k) => k.slice(5)), + [selectedItems], + ); + + const selectedMoviePaths = useMemo( + () => (Array.from(selectedItems) as string[]).filter((k) => k.startsWith('movie:')).map((k) => k.slice(6)), + [selectedItems], + ); + + const selectedEpisodePaths = useMemo( + () => (Array.from(selectedItems) as string[]).filter((k) => k.startsWith('episode:')).map((k) => k.slice(8)), [selectedItems], ); const shouldShowToolbar = editMode && selectedItems.size >= 1; const shouldShowMerge = selectedShowKeys.length >= 2; + const deleteLocalFile = useCallback((uri?: string) => { + if (!uri) return; + try { + const file = new File(uri); + if (file.exists) { + file.delete(); + } + } catch { + // ignore failures; refresh can still proceed + } + }, []); + + const performResetSelectedMetadata = useCallback(async () => { + const selectedKeys = Array.from(selectedItems); + if (selectedKeys.length === 0) return; + + const selectedShows = selectedShowKeys; + const selectedMoviesForRefresh = selectedMoviePaths; + const selectedEpisodes = selectedEpisodePaths; + + // Clear selected overrides first, then clear metadata state and downloaded files. + for (const key of selectedKeys) { + dispatch(clearMediaOverride(key)); + } + + const state = store.getState(); + for (const showName of selectedShows) { + const show = state.libraryReducer.mediaLibrary[showName]; + if (show?.poster) { + deleteLocalFile(show.poster); + } + for (const season of Object.values(show?.seasons ?? {})) { + for (const ep of Object.values(season.episodes)) { + deleteLocalFile(ep.tmdbThumbnail); + } + } + dispatch(clearShowMetadata(showName)); + } + + for (const moviePath of selectedMoviesForRefresh) { + const movie = state.libraryReducer.movies.find((m: IMediaObject) => m.path === moviePath); + if (movie?.poster) { + deleteLocalFile(movie.poster); + } + dispatch(clearMovieMetadata(moviePath)); + } + + for (const episodePath of selectedEpisodes) { + const episode = Object.values(state.libraryReducer.mediaLibrary).flatMap((show) => + Object.values(show.seasons).flatMap((season) => Object.values(season.episodes)), + ).find((ep) => ep.path === episodePath); + if (episode?.tmdbThumbnail) { + deleteLocalFile(episode.tmdbThumbnail); + } + dispatch(clearEpisodeMetadata(episodePath)); + } + + clearItemSelection(); + + // Re-read the refreshed state for the selected subset before metadata enrichment. + const refreshedState = store.getState(); + const librarySubset: IMediaLibrary = {}; + for (const showName of selectedShows) { + const show = refreshedState.libraryReducer.mediaLibrary[showName]; + if (show) librarySubset[showName] = show; + } + for (const episodePath of selectedEpisodes) { + const showEntry = Object.entries(refreshedState.libraryReducer.mediaLibrary).find(([, show]) => + Object.values(show.seasons).some((season) => + Object.values(season.episodes).some((ep) => ep.path === episodePath), + ), + ); + if (showEntry) { + const [showName, show] = showEntry; + librarySubset[showName] = show; + } + } + const moviesSubset = refreshedState.libraryReducer.movies.filter((movie: IMediaObject) => selectedMoviesForRefresh.includes(movie.parsedPath)); + + try { + await MetadataService.getInstance().enrichAll(librarySubset, moviesSubset); + } catch (e) { + logger.warn('MediaBrowserScreen', 'Failed to refresh metadata for selected items', e); + } + }, [dispatch, selectedItems, selectedShowKeys, selectedMoviePaths, selectedEpisodePaths, clearItemSelection, deleteLocalFile]); + + const handleResetMetadata = useCallback(() => { + if (selectedItems.size === 0) return; + Alert.alert( + 'Reset metadata', + 'This will remove overrides and downloaded metadata for the selected items, then refresh their metadata from the configured provider.', + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Reset', style: 'destructive', onPress: () => { void performResetSelectedMetadata(); } }, + ], + ); + }, [selectedItems.size, performResetSelectedMetadata]); + /** * Marks all currently selected items as hidden by setting `hidden: true` in their * `mediaOverrides` entry. No files are deleted; the action is fully reversible by @@ -838,6 +949,12 @@ export function MediaBrowserScreen({ mediaFilter }: MediaBrowserScreenProps) { > Hide + + Reset + {shouldShowMerge && ( ({ setEditMode: () => {}, drawerUnlocked: false, setDrawerUnlocked: () => {}, - selectedItems: new Set(), + selectedItems: new Set(), toggleItemSelection: () => {}, clearItemSelection: () => {}, }); diff --git a/src/store/libraryReducer.ts b/src/store/libraryReducer.ts index 06f89c2..6ef213a 100644 --- a/src/store/libraryReducer.ts +++ b/src/store/libraryReducer.ts @@ -285,6 +285,34 @@ export const settingsSlice = createSlice({ clearMediaOverride: (state, action: PayloadAction) => { delete state.mediaOverrides[action.payload]; }, + clearShowMetadata: (state, action: PayloadAction) => { + const show = state.mediaLibrary[action.payload]; + if (!show) return; + show.poster = ''; + for (const season of Object.values(show.seasons) as IMediaSeason[]) { + for (const ep of Object.values(season.episodes) as IMediaObject[]) { + ep.tmdbTitle = undefined; + ep.tmdbThumbnail = undefined; + } + } + }, + clearMovieMetadata: (state, action: PayloadAction) => { + const movie = state.movies.find((m) => m.path === action.payload); + if (!movie) return; + movie.poster = ''; + }, + clearEpisodeMetadata: (state, action: PayloadAction) => { + for (const show of Object.values(state.mediaLibrary) as IMediaShow[]) { + for (const season of Object.values(show.seasons) as IMediaSeason[]) { + const episode = Object.values(season.episodes as { [key: string]: IMediaObject }).find((ep) => ep.path === action.payload); + if (episode) { + episode.tmdbTitle = undefined; + episode.tmdbThumbnail = undefined; + return; + } + } + } + }, /** * Clears both the TV library and the movie list. Dispatched at the start * of each scan so stale data is not shown before streaming results arrive. @@ -419,8 +447,8 @@ export const settingsSlice = createSlice({ clearShowEpisodeMetadata: (state, action: PayloadAction) => { const show = state.mediaLibrary[action.payload]; if (!show) return; - for (const season of Object.values(show.seasons)) { - for (const ep of Object.values(season.episodes)) { + for (const season of Object.values(show.seasons) as IMediaSeason[]) { + for (const ep of Object.values(season.episodes) as IMediaObject[]) { ep.tmdbTitle = undefined; ep.tmdbThumbnail = undefined; } @@ -437,10 +465,10 @@ export const settingsSlice = createSlice({ for (const key of Object.keys(state.mediaOverrides)) { delete state.mediaOverrides[key].poster; } - for (const show of Object.values(state.mediaLibrary)) { + for (const show of Object.values(state.mediaLibrary) as IMediaShow[]) { show.poster = ''; - for (const season of Object.values(show.seasons)) { - for (const ep of Object.values(season.episodes)) { + for (const season of Object.values(show.seasons) as IMediaSeason[]) { + for (const ep of Object.values(season.episodes) as IMediaObject[]) { ep.tmdbThumbnail = undefined; } } @@ -494,7 +522,7 @@ export const settingsSlice = createSlice({ }, }) -export const { addToScanList, setScanList, clearScanList, setMediaLibrary, setMovies, setIsScanning, setScanProgress, setThumbnail, clearThumbnails, updateShowMetadata, updateMovieMetadata, setMediaOverride, clearMediaOverride, clearLibraryAndMovies, mergeEpisodeBatch, appendMovieBatch, updateShowPoster, setMoviePoster, mergeDuplicateShows, clearPosterOverrides, clearTvdbPosterOverrides, clearAllOverrides, updateSeasonEpisodeMetadata, clearShowEpisodeMetadata } = settingsSlice.actions; +export const { addToScanList, setScanList, clearScanList, setMediaLibrary, setMovies, setIsScanning, setScanProgress, setThumbnail, clearThumbnails, updateShowMetadata, updateMovieMetadata, setMediaOverride, clearMediaOverride, clearShowMetadata, clearMovieMetadata, clearEpisodeMetadata, clearLibraryAndMovies, mergeEpisodeBatch, appendMovieBatch, updateShowPoster, setMoviePoster, mergeDuplicateShows, clearPosterOverrides, clearTvdbPosterOverrides, clearAllOverrides, updateSeasonEpisodeMetadata, clearShowEpisodeMetadata } = settingsSlice.actions; // Other code such as selectors can use the imported `RootState` type export const selectScanList = (state: RootState) => state.libraryReducer.scanList;