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;