From e34d8ac989bfc8524576af84050fa01bf645cff9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 18:57:20 +0000 Subject: [PATCH 1/6] Add Import/Export feature: JSON state backup, filesystem metadata export, smb.json scanner support --- src/app/(drawer)/settings.tsx | 123 +++++- src/scripts/FileScanner.ts | 143 ++++++- src/scripts/ImportExportService.ts | 604 +++++++++++++++++++++++++++++ src/scripts/SmbTypes.ts | 66 ++++ 4 files changed, 923 insertions(+), 13 deletions(-) create mode 100644 src/scripts/ImportExportService.ts create mode 100644 src/scripts/SmbTypes.ts diff --git a/src/app/(drawer)/settings.tsx b/src/app/(drawer)/settings.tsx index 14a9a3a..9cea9a5 100644 --- a/src/app/(drawer)/settings.tsx +++ b/src/app/(drawer)/settings.tsx @@ -18,6 +18,8 @@ import { logger } from '@/scripts/Logger'; import Constants from 'expo-constants'; import { useEditMode } from '@/contexts/EditModeContext'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { exportJson, importJson, exportToFilesystem } from '@/scripts/ImportExportService'; +import type { JsonExportOptions } from '@/scripts/ImportExportService'; const DIVIDER_COLOR = 'rgba(128,128,128,0.35)'; const DESTRUCTIVE_COLOR = '#E55'; @@ -84,6 +86,10 @@ export default function SettingsPrompt() { const [scanning, setScanning] = useState(false); const [scanComplete, setScanComplete] = useState(false); const [troubleshootingExpanded, setTroubleshootingExpanded] = useState(false); + const [importExportExpanded, setImportExportExpanded] = useState(false); + const [exportIncludeSettings, setExportIncludeSettings] = useState(true); + const [exportIncludeOverrides, setExportIncludeOverrides] = useState(true); + const [exportIncludeMatches, setExportIncludeMatches] = useState(true); const dispatch = useDispatch(); const settingsPassword = useSelector(selectPassword); @@ -500,7 +506,61 @@ export default function SettingsPrompt() { ], ); }, [dispatch]); - + + const handleExportJson = useCallback(async () => { + try { + const options: JsonExportOptions = { + includeSettings: exportIncludeSettings, + includeOverrides: exportIncludeOverrides, + includeMatches: exportIncludeMatches, + }; + if (!options.includeSettings && !options.includeOverrides && !options.includeMatches) { + Alert.alert('Nothing to export', 'Please select at least one section to include in the export.'); + return; + } + await exportJson(options); + Alert.alert('Export complete', 'Settings and metadata have been saved to the selected folder.'); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + if (!msg.includes('denied') && !msg.includes('selected')) { + Alert.alert('Export failed', msg); + } + logger.warn('Settings', 'JSON export failed', e); + } + }, [exportIncludeSettings, exportIncludeOverrides, exportIncludeMatches]); + + const handleImportJson = useCallback(async () => { + try { + const { applied } = await importJson(); + Alert.alert('Import complete', `Applied: ${applied.join(', ')}.`); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + if (!msg.includes('No file') && !msg.includes('cancel')) { + Alert.alert('Import failed', msg); + } + logger.warn('Settings', 'JSON import failed', e); + } + }, []); + + const handleExportToFilesystem = useCallback(async () => { + try { + const { showsExported, moviesExported, failed, skipped } = await exportToFilesystem(safeMediaSources); + const parts: string[] = []; + if (showsExported > 0) parts.push(`${showsExported} show(s)`); + if (moviesExported > 0) parts.push(`${moviesExported} movie(s)`); + const summary = parts.length > 0 ? parts.join(' and ') : 'nothing'; + const extra: string[] = []; + if (skipped > 0) extra.push(`${skipped} skipped (no data or no subfolder)`); + if (failed > 0) extra.push(`${failed} failed`); + const detail = extra.length > 0 ? `\n\n${extra.join(', ')}.` : ''; + Alert.alert('Export complete', `Exported metadata alongside ${summary}.${detail}`); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + Alert.alert('Export failed', msg); + logger.warn('Settings', 'Filesystem export failed', e); + } + }, [safeMediaSources]); + return ( @@ -871,6 +931,50 @@ export default function SettingsPrompt() { + {/* Import / Export */} + + setImportExportExpanded(prev => !prev)} + accessibilityRole="button" + accessibilityState={{ expanded: importExportExpanded }} + > + Import / Export + {importExportExpanded ? '▲' : '▼'} + + {importExportExpanded && ( + + {/* JSON export options */} + Export to JSON file — select what to include: + + + Settings (API keys, view options) + + + + Overrides (custom titles, sort titles) + + + + Matches (TMDB/TVDB IDs, episode names) + + + 📤 Export app state to JSON + Saves selected data to a JSON file in a folder you choose. + + + 📥 Import app state from JSON + Restores settings, overrides and/or matches from a previously exported JSON file. + + {/* Filesystem export */} + + 💾 Export metadata to media folders + Writes smb.json, poster.jpg and episode thumbnails alongside your media files. These are picked up automatically during the next library scan. + + + )} + + {/* Troubleshooting */} ; + /** + * Episode thumbnails exported by ImportExportService and found during + * scanning. Keyed by folderKey → `${seasonKey}:${episodeKey}` → + * local file:// URI inside smb_thumbnails_fs/. + */ + smbThumbnails: Map>; } /** @@ -434,6 +453,8 @@ export class FileScanner { // Collect TV files from all TV sources and merge into one library const allTvFiles: IScannedFile[] = []; const tvPosterMap = new Map(); + const allTvSmbData = new Map(); + const allTvSmbThumbnails = new Map>(); for (const src of tvSources) { if (this._cancelRequested) { logger.log('FileScanner', 'Scan cancelled before TV source'); @@ -441,10 +462,12 @@ export class FileScanner { } collectProgress.currentSourceIndex = sourceIndexByUri.get(src.uri) ?? 1; logger.log('FileScanner', `Scanning TV source (${collectProgress.currentSourceIndex}/${collectProgress.sourcesTotal}): ${src.uri}`); - const { files, posterMap } = await this.collectAllMediaFiles(src.uri, dirSemaphore, collectProgress, 'tv', src.metadataSource); + const { files, posterMap, smbData, smbThumbnails } = await this.collectAllMediaFiles(src.uri, dirSemaphore, collectProgress, 'tv', src.metadataSource); logger.log('FileScanner', ` Found ${files.length} TV file(s) in source`); allTvFiles.push(...files); posterMap.forEach((uri, key) => { if (!tvPosterMap.has(key)) tvPosterMap.set(key, uri); }); + smbData.forEach((v, k) => { if (!allTvSmbData.has(k)) allTvSmbData.set(k, v); }); + smbThumbnails.forEach((v, k) => { if (!allTvSmbThumbnails.has(k)) allTvSmbThumbnails.set(k, v); }); } if (this._cancelRequested) { @@ -452,12 +475,13 @@ export class FileScanner { return; } - const mergedLibrary = this.buildLibrary(allTvFiles, tvPosterMap); + const mergedLibrary = this.buildLibrary(allTvFiles, tvPosterMap, allTvSmbData, allTvSmbThumbnails); logger.log('FileScanner', `Built TV library with ${Object.keys(mergedLibrary).length} show(s) from ${allTvFiles.length} file(s)`); // Collect movie files from all movie sources const allMovieFiles: IScannedFile[] = []; const moviePosterMap = new Map(); + const allMovieSmbData = new Map(); for (const src of movieSources) { if (this._cancelRequested) { logger.log('FileScanner', 'Scan cancelled before movie source'); @@ -465,10 +489,11 @@ export class FileScanner { } collectProgress.currentSourceIndex = sourceIndexByUri.get(src.uri) ?? 1; logger.log('FileScanner', `Scanning Movie source (${collectProgress.currentSourceIndex}/${collectProgress.sourcesTotal}): ${src.uri}`); - const { files, posterMap } = await this.collectAllMediaFiles(src.uri, dirSemaphore, collectProgress, 'movie', src.metadataSource); + const { files, posterMap, smbData } = await this.collectAllMediaFiles(src.uri, dirSemaphore, collectProgress, 'movie', src.metadataSource); logger.log('FileScanner', ` Found ${files.length} movie file(s) in source`); allMovieFiles.push(...files); posterMap.forEach((uri, key) => { if (!moviePosterMap.has(key)) moviePosterMap.set(key, uri); }); + smbData.forEach((v, k) => { if (!allMovieSmbData.has(k)) allMovieSmbData.set(k, v); }); } if (this._cancelRequested) { @@ -476,7 +501,7 @@ export class FileScanner { return; } - const movies = this.buildMovieList(allMovieFiles, moviePosterMap); + const movies = this.buildMovieList(allMovieFiles, moviePosterMap, allMovieSmbData); logger.log('FileScanner', `Built movie list with ${movies.length} movie(s)`); // Build a combined scan list for diagnostic purposes @@ -710,14 +735,14 @@ export class FileScanner { logger.log('FileScanner', `scanFolder filtered to ${filtered.length} item(s)`); // Recursively collect all media files and build the library - const { files: allMediaFiles, posterMap } = await this.collectAllMediaFiles( + const { files: allMediaFiles, posterMap, smbData, smbThumbnails } = await this.collectAllMediaFiles( directory, new Semaphore(MAX_CONCURRENT_DIR_READS), { filesFound: 0, currentSourceIndex: 1, sourcesTotal: 1 }, 'tv', undefined, ); - const library = this.buildLibrary(allMediaFiles, posterMap); + const library = this.buildLibrary(allMediaFiles, posterMap, smbData, smbThumbnails); store.dispatch(setMediaLibrary(library)); return filtered; @@ -731,7 +756,7 @@ export class FileScanner { progress: { filesFound: number; currentSourceIndex: number; sourcesTotal: number }, sourceType: 'tv' | 'movie' = 'tv', metadataSource?: dataSources, - ): Promise<{ files: IScannedFile[]; posterMap: Map }> { + ): Promise<{ files: IScannedFile[]; posterMap: Map; smbData: Map; smbThumbnails: Map> }> { const result: IScannedFile[] = []; const posterMap = new Map(); const streamState: StreamState = { @@ -740,6 +765,8 @@ export class FileScanner { movieBatch: [], moviePathsByFolder: new Map(), metadataSource, + smbData: new Map(), + smbThumbnails: new Map(), }; await this.recursiveCollect(rootDirectory, [], result, posterMap, 0, dirSemaphore, progress, streamState); // Stamp the source-level metadata provider on every collected file so @@ -749,7 +776,7 @@ export class FileScanner { } // Flush any remaining buffered items that did not reach the batch threshold this.flushStreamBatch(streamState, posterMap); - return { files: result, posterMap }; + return { files: result, posterMap, smbData: streamState.smbData, smbThumbnails: streamState.smbThumbnails }; } private async recursiveCollect( @@ -874,6 +901,57 @@ export class FileScanner { // Skip files with known non-directory extensions (subtitles, // images, metadata side-cars, etc.) to avoid the overhead of // an always-failing readDirectoryAsync call for each one. + // We check for smb.json and smb_thumb_* before this guard. + if (relativePathParts.length >= 1 && ext === '.json' && filename.toLowerCase() === 'smb.json') { + const folderKey = relativePathParts[0]; + if (!streamState.smbData.has(folderKey)) { + try { + const jsonText = await LegacyFileSystem.readAsStringAsync(resolvedUri); + const parsed = JSON.parse(jsonText) as SmbJsonData; + if (parsed && typeof parsed === 'object' && parsed.smbVersion === 1) { + streamState.smbData.set(folderKey, parsed); + logger.log('FileScanner', `Found smb.json for "${folderKey}"`); + } + } catch (e) { + logger.warn('FileScanner', `Failed to read smb.json in "${folderKey}"`, e); + } + } + continue; + } + + // Detect smb_thumb_s01e01.jpg files written by the filesystem export. + if (relativePathParts.length >= 1 && ext === '.jpg') { + const thumbMatch = filename.toLowerCase().match(/^smb_thumb_(s\d{2})(e\d{2,3})\.jpg$/); + if (thumbMatch) { + const folderKey = relativePathParts[0]; + const seasonKey = thumbMatch[1]; + const episodeKey = thumbMatch[2]; + const thumbMapKey = `${seasonKey}:${episodeKey}`; + if (!streamState.smbThumbnails.has(folderKey)) { + streamState.smbThumbnails.set(folderKey, new Map()); + } + const folderThumbMap = streamState.smbThumbnails.get(folderKey)!; + if (!folderThumbMap.has(thumbMapKey)) { + try { + if (!SMB_THUMBNAILS_FS_DIR.exists) { + SMB_THUMBNAILS_FS_DIR.create({ intermediates: true, idempotent: true }); + } + const safeName = `${folderKey.replace(/[^a-zA-Z0-9_-]/g, '_')}_${seasonKey}${episodeKey}.jpg`; + const localFile = new File(SMB_THUMBNAILS_FS_DIR, safeName); + if (!localFile.exists) { + const base64 = await StorageAccessFramework.readAsStringAsync(resolvedUri, { encoding: 'base64' }); + localFile.write(base64, { encoding: 'base64' }); + } + folderThumbMap.set(thumbMapKey, localFile.uri); + logger.log('FileScanner', `Found smb_thumb for "${folderKey}" ${seasonKey}${episodeKey}`); + } catch (e) { + logger.warn('FileScanner', `Failed to copy smb_thumb for "${folderKey}" ${seasonKey}${episodeKey}`, e); + } + } + continue; + } + } + if (NON_DIRECTORY_EXTENSIONS.has(ext)) continue; // Collect as a potential subdirectory to recurse into in parallel. @@ -1027,7 +1105,7 @@ export class FileScanner { // ─── Movie list building ───────────────────────────────────────────────── - buildMovieList(files: IScannedFile[], posterMap?: Map): IMediaObject[] { + buildMovieList(files: IScannedFile[], posterMap?: Map, smbData?: Map): IMediaObject[] { return files.map((file) => { // Destructure out relativePathParts so it is not included in the stored IMediaObject const { relativePathParts, ...mediaObj } = file; @@ -1044,13 +1122,25 @@ export class FileScanner { const poster = relativePathParts.length > 0 ? (posterMap?.get(relativePathParts[0]) ?? '') : ''; - return { ...mediaObj, title, poster }; + const movie: IMediaObject = { ...mediaObj, title, poster }; + // Apply IDs from smb.json when present and not already set. + // These IDs will be overwritten by API-fetched IDs on the next enrichment. + if (smbData && relativePathParts.length > 0) { + const folderKey = relativePathParts[0]; + const smb = smbData.get(folderKey); + if (smb && smb.type === 'movie' && smb.ids) { + if (!movie.ids.tmdb && smb.ids.tmdb) movie.ids.tmdb = smb.ids.tmdb; + if (!movie.ids.tvdb && smb.ids.tvdb) movie.ids.tvdb = smb.ids.tvdb; + if (!movie.ids.imdb && smb.ids.imdb) movie.ids.imdb = smb.ids.imdb; + } + } + return movie; }); } // ─── Library building ──────────────────────────────────────────────────── - buildLibrary(files: IScannedFile[], posterMap?: Map): IMediaLibrary { + buildLibrary(files: IScannedFile[], posterMap?: Map, smbData?: Map, smbThumbnails?: Map>): IMediaLibrary { const library: IMediaLibrary = {}; for (const file of files) { @@ -1074,6 +1164,19 @@ export class FileScanner { rawNames: rawShowName !== showName ? [rawShowName] : [], metadataSource: file.metadataSource, }; + // Apply IDs and metadata source from smb.json when present. + // These are overwritten by API-fetched IDs during enrichment. + if (smbData && folderKey) { + const smb = smbData.get(folderKey); + if (smb && smb.type === 'show' && smb.ids) { + if (!library[showName].ids.tmdb && smb.ids.tmdb) library[showName].ids.tmdb = smb.ids.tmdb; + if (!library[showName].ids.tvdb && smb.ids.tvdb) library[showName].ids.tvdb = smb.ids.tvdb; + if (!library[showName].ids.imdb && smb.ids.imdb) library[showName].ids.imdb = smb.ids.imdb; + if (!library[showName].metadataSource && smb.metadataSource) { + library[showName].metadataSource = smb.metadataSource; + } + } + } } else { // Back-fill year if not yet recorded for this canonical show if (showYear > 0 && !library[showName].year) { @@ -1103,12 +1206,28 @@ export class FileScanner { // Strip the internal relativePathParts field before storing const { relativePathParts, ...mediaObj } = file; const resolvedTitle = title || file.filename; - library[showName].seasons[seasonKey].episodes[episodeKey] = { + const ep: IMediaObject = { ...mediaObj, title: resolvedTitle, scannedTitle: resolvedTitle, episodeNumber: episode, }; + // Apply episode title from smb.json when not derived from filename. + const folderKey = relativePathParts[0]; + if (smbData && folderKey) { + const smb = smbData.get(folderKey); + if (smb && smb.type === 'show' && smb.seasons) { + const smbEp = smb.seasons[seasonKey]?.episodes[episodeKey]; + if (smbEp?.title && !ep.tmdbTitle) ep.tmdbTitle = smbEp.title; + } + } + // Apply episode thumbnail from smb_thumb files found during scan. + if (smbThumbnails && folderKey) { + const thumbMap = smbThumbnails.get(folderKey); + const thumbUri = thumbMap?.get(`${seasonKey}:${episodeKey}`); + if (thumbUri && !ep.tmdbThumbnail) ep.tmdbThumbnail = thumbUri; + } + library[showName].seasons[seasonKey].episodes[episodeKey] = ep; } } diff --git a/src/scripts/ImportExportService.ts b/src/scripts/ImportExportService.ts new file mode 100644 index 0000000..be6aca1 --- /dev/null +++ b/src/scripts/ImportExportService.ts @@ -0,0 +1,604 @@ +/** + * ImportExportService — import/export functionality for SimpleMediaBrowser. + * + * Provides three operations: + * 1. exportJson – save settings, overrides and/or matched metadata to a JSON file. + * 2. importJson – restore state from a previously exported JSON file. + * 3. exportToFilesystem – write smb.json metadata, poster images and episode thumbnails + * alongside the media files in the configured SAF directories. + */ + +import * as LegacyFileSystem from 'expo-file-system/legacy'; +import * as DocumentPicker from 'expo-document-picker'; +import { store } from '@/store/store'; +import { + setMediaOverride, + clearAllOverrides, + updateShowMetadata, + updateMovieMetadata, + updateSeasonEpisodeMetadata, +} from '@/store/libraryReducer'; +import type { IMediaOverride, IMediaShow } from '@/store/libraryReducer'; +import { + setDataSource, + setMediaStructure, + setViewOrientation, + setViewScale, + setTmdbApiKey, + setTvdbApiKey, + setTvdbPin, + setDefaultPage, + setEnablePosterFetching, + setEnableThumbnailGeneration, + setRescanOnStartup, + setFetchEpisodeNames, + setFetchEpisodeThumbnails, + setAppColorScheme, +} from '@/store/settingsReducer'; +import type { IMediaSource, dataSources, viewTypes, viewOrientations, defaultPages, appColorSchemes } from '@/store/settingsReducer'; +import { logger } from '@/scripts/Logger'; +import type { SmbJsonData, SmbJsonShowData, SmbJsonMovieData } from '@/scripts/SmbTypes'; + +// Re-export smb.json types so callers can import them from this module. +export type { SmbJsonData, SmbJsonShowData, SmbJsonMovieData }; +export type { SmbJsonEpisodeData, SmbJsonSeasonData } from '@/scripts/SmbTypes'; + +const { StorageAccessFramework } = LegacyFileSystem; + +// ─── SAF helpers ───────────────────────────────────────────────────────────── + +/** + * Returns the SAF URI of the parent directory for a given file URI. + * + * Example: + * …/document/primary%3ATV%2FShow%2FSeason1%2FEp.mkv + * → …/document/primary%3ATV%2FShow%2FSeason1 + */ +export function getSafParentDirUri(fileUri: string): string | null { + const docMarker = '/document/'; + const docIdx = fileUri.indexOf(docMarker); + if (docIdx === -1) return null; + const prefix = fileUri.substring(0, docIdx + docMarker.length); + const encodedDocId = fileUri.substring(docIdx + docMarker.length); + const docId = decodeURIComponent(encodedDocId); + const lastSlash = docId.lastIndexOf('/'); + if (lastSlash === -1) return null; + return prefix + encodeURIComponent(docId.substring(0, lastSlash)); +} + +/** + * Returns the SAF URI for the first-level subfolder (show/movie folder) that + * contains the given file, relative to the matching TV source root. + */ +export function getShowFolderUri(episodePath: string, mediaSources: IMediaSource[]): string | null { + for (const source of mediaSources) { + if (source.contentType !== 'tv') continue; + const treeMatch = source.uri.match(/^content:\/\/([^/]+)\/tree\/([^/]+)$/); + if (!treeMatch) continue; + const authority = treeMatch[1]; + const encodedTreeDocId = treeMatch[2]; + const expectedPrefix = `content://${authority}/tree/${encodedTreeDocId}/document/`; + if (!episodePath.startsWith(expectedPrefix)) continue; + const encodedEpisodeDocId = episodePath.substring(expectedPrefix.length); + const episodeDocId = decodeURIComponent(encodedEpisodeDocId); + const treeDocId = decodeURIComponent(encodedTreeDocId); + if (!episodeDocId.startsWith(treeDocId + '/')) continue; + const relativePath = episodeDocId.substring(treeDocId.length + 1); + const showFolderName = relativePath.split('/')[0]; + if (!showFolderName) continue; + const showDocId = `${treeDocId}/${showFolderName}`; + return `content://${authority}/tree/${encodedTreeDocId}/document/${encodeURIComponent(showDocId)}`; + } + return null; +} + +/** + * Returns the number of path segments between a file and its SAF source root. + * Returns -1 when the file is not within any of the provided sources. + * + * Examples (relative to source root): + * "movie.mkv" → 1 (movie directly in source root) + * "Avatar/avatar.mkv" → 2 (movie in subfolder) + */ +function getRelativeDepth(filePath: string, mediaSources: IMediaSource[]): number { + for (const source of mediaSources) { + const treeMatch = source.uri.match(/^content:\/\/([^/]+)\/tree\/([^/]+)$/); + if (!treeMatch) continue; + const authority = treeMatch[1]; + const encodedTreeDocId = treeMatch[2]; + const expectedPrefix = `content://${authority}/tree/${encodedTreeDocId}/document/`; + if (!filePath.startsWith(expectedPrefix)) continue; + const encodedDocId = filePath.substring(expectedPrefix.length); + const docId = decodeURIComponent(encodedDocId); + const treeDocId = decodeURIComponent(encodedTreeDocId); + if (!docId.startsWith(treeDocId + '/')) continue; + const relativePath = docId.substring(treeDocId.length + 1); + return relativePath.split('/').length; + } + return -1; +} + +/** + * Write (or overwrite) a named file inside a SAF directory. + * Checks whether the file already exists by listing the directory first; + * overwrites in-place when found, otherwise creates a new file. + */ +async function writeToSafDir( + dirUri: string, + filename: string, + content: string, + mimeType: string, + encoding: 'utf8' | 'base64' = 'utf8', +): Promise { + let fileUri: string | null = null; + try { + const entries = await StorageAccessFramework.readDirectoryAsync(dirUri); + for (const uri of entries) { + const decoded = decodeURIComponent(uri); + if (decoded.endsWith('/' + filename)) { + fileUri = uri; + break; + } + } + } catch { + // Proceed to create; listing may fail on some SAF providers. + } + if (!fileUri) { + fileUri = await StorageAccessFramework.createFileAsync(dirUri, filename, mimeType); + } + if (encoding === 'base64') { + // Type-assert to access the optional encoding parameter that expo-file-system + // supports at runtime even if the TypeScript declaration doesn't surface it. + await (StorageAccessFramework.writeAsStringAsync as (uri: string, content: string, options: { encoding: string }) => Promise)(fileUri, content, { encoding: 'base64' }); + } else { + await StorageAccessFramework.writeAsStringAsync(fileUri, content); + } +} + +/** + * Finds the show folder SAF URI by scanning the show's episodes for a known path. + */ +function getShowFolderUriFromShow(show: IMediaShow, mediaSources: IMediaSource[]): string | null { + for (const season of Object.values(show.seasons)) { + for (const ep of Object.values(season.episodes)) { + const folderUri = getShowFolderUri(ep.path, mediaSources); + if (folderUri) return folderUri; + } + } + return null; +} + +// ─── JSON export options ────────────────────────────────────────────────────── + +export interface JsonExportOptions { + /** Include app settings (API keys, view options, etc.) in the export. */ + includeSettings: boolean; + /** Include all user-defined media overrides (title, sort title, poster, etc.). */ + includeOverrides: boolean; + /** Include matched metadata IDs and episode names from enrichment. */ + includeMatches: boolean; +} + +// Internal shape of the exported JSON file +interface ShowMatchExport { + ids: { tmdb: string | null; tvdb: string | null; imdb: string | null }; + title: string; + year: number; + seasons: Record }>; +} + +interface MovieMatchExport { + parsedPath: string; + ids: { tmdb: string | null; tvdb: string | null; imdb: string | null }; + title: string; +} + +interface SmbExportJson { + smbVersion: 1; + exportedAt: string; + settings?: Record; + overrides?: Record; + matches?: { + shows: Record; + movies: MovieMatchExport[]; + }; +} + +// ─── JSON export ───────────────────────────────────────────────────────────── + +/** + * Builds a JSON export blob from the current Redux state and prompts the user + * to choose a save directory. The file is named + * `smb_export_YYYY-MM-DDTHH-MM-SS.json`. + * + * Note: media source URIs and the settings password are intentionally excluded + * from the export because they are device-specific or sensitive. + */ +export async function exportJson(options: JsonExportOptions): Promise { + const state = store.getState(); + const payload: SmbExportJson = { + smbVersion: 1, + exportedAt: new Date().toISOString(), + }; + + if (options.includeSettings) { + const s = state.settingsReducer; + payload.settings = { + dataSource: s.dataSource, + viewType: s.viewType, + viewScale: s.viewScale, + viewOrientation: s.viewOrientation, + tmdbApiKey: s.tmdbApiKey, + tvdbApiKey: s.tvdbApiKey, + tvdbPin: s.tvdbPin, + defaultPage: s.defaultPage, + enablePosterFetching: s.enablePosterFetching, + enableThumbnailGeneration: s.enableThumbnailGeneration, + rescanOnStartup: s.rescanOnStartup, + fetchEpisodeNames: s.fetchEpisodeNames, + fetchEpisodeThumbnails: s.fetchEpisodeThumbnails, + appColorScheme: s.appColorScheme, + }; + } + + if (options.includeOverrides) { + payload.overrides = { ...state.libraryReducer.mediaOverrides }; + } + + if (options.includeMatches) { + const library = state.libraryReducer.mediaLibrary; + const movies = state.libraryReducer.movies; + const shows: Record = {}; + + for (const [showName, show] of Object.entries(library)) { + if (!show.ids.tmdb && !show.ids.tvdb && !show.ids.imdb) continue; + const seasons: ShowMatchExport['seasons'] = {}; + for (const [seasonKey, season] of Object.entries(show.seasons)) { + const episodes: Record = {}; + let hasEpData = false; + for (const [epKey, ep] of Object.entries(season.episodes)) { + if (ep.tmdbTitle) { + episodes[epKey] = { tmdbTitle: ep.tmdbTitle }; + hasEpData = true; + } + } + if (hasEpData) seasons[seasonKey] = { episodes }; + } + shows[showName] = { + ids: { tmdb: show.ids.tmdb, tvdb: show.ids.tvdb, imdb: show.ids.imdb }, + title: show.title, + year: show.year, + seasons, + }; + } + + const movieMatches: MovieMatchExport[] = movies + .filter((m) => m.ids.tmdb || m.ids.tvdb || m.ids.imdb) + .map((m) => ({ + parsedPath: m.parsedPath, + ids: { tmdb: m.ids.tmdb, tvdb: m.ids.tvdb, imdb: m.ids.imdb }, + title: m.title, + })); + + payload.matches = { shows, movies: movieMatches }; + } + + const dirResult = await StorageAccessFramework.requestDirectoryPermissionsAsync(); + if (!dirResult.granted) { + throw new Error('Directory permission denied'); + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const filename = `smb_export_${timestamp}.json`; + await writeToSafDir(dirResult.directoryUri, filename, JSON.stringify(payload, null, 2), 'application/json'); + + logger.log('ImportExport', `Exported JSON state to ${filename}`); +} + +// ─── JSON import ───────────────────────────────────────────────────────────── + +/** + * Prompts the user to select an smb JSON export file and applies the contained + * data to the Redux store. + * + * Returns the list of sections that were successfully applied. + */ +export async function importJson(): Promise<{ applied: string[] }> { + const result = await DocumentPicker.getDocumentAsync({ + type: 'application/json', + copyToCacheDirectory: true, + }); + + if (result.canceled || !result.assets?.[0]) { + throw new Error('No file selected'); + } + + const jsonText = await LegacyFileSystem.readAsStringAsync(result.assets[0].uri); + const data = JSON.parse(jsonText) as SmbExportJson; + + if (!data || typeof data !== 'object' || data.smbVersion !== 1) { + throw new Error('Invalid or unsupported export file format'); + } + + const applied: string[] = []; + + if (data.settings) { + const s = data.settings as Record; + if (typeof s.dataSource === 'string') store.dispatch(setDataSource(s.dataSource as dataSources)); + if (typeof s.viewType === 'string') store.dispatch(setMediaStructure(s.viewType as viewTypes)); + if (typeof s.viewScale === 'number') store.dispatch(setViewScale(s.viewScale)); + if (typeof s.viewOrientation === 'string') store.dispatch(setViewOrientation(s.viewOrientation as viewOrientations)); + if (s.tmdbApiKey === null || typeof s.tmdbApiKey === 'string') store.dispatch(setTmdbApiKey(s.tmdbApiKey as string | null)); + if (s.tvdbApiKey === null || typeof s.tvdbApiKey === 'string') store.dispatch(setTvdbApiKey(s.tvdbApiKey as string | null)); + if (s.tvdbPin === null || typeof s.tvdbPin === 'string') store.dispatch(setTvdbPin(s.tvdbPin as string | null)); + if (typeof s.defaultPage === 'string') store.dispatch(setDefaultPage(s.defaultPage as defaultPages)); + if (typeof s.enablePosterFetching === 'boolean') store.dispatch(setEnablePosterFetching(s.enablePosterFetching)); + if (typeof s.enableThumbnailGeneration === 'boolean') store.dispatch(setEnableThumbnailGeneration(s.enableThumbnailGeneration)); + if (typeof s.rescanOnStartup === 'boolean') store.dispatch(setRescanOnStartup(s.rescanOnStartup)); + if (typeof s.fetchEpisodeNames === 'boolean') store.dispatch(setFetchEpisodeNames(s.fetchEpisodeNames)); + if (typeof s.fetchEpisodeThumbnails === 'boolean') store.dispatch(setFetchEpisodeThumbnails(s.fetchEpisodeThumbnails)); + if (typeof s.appColorScheme === 'string') store.dispatch(setAppColorScheme(s.appColorScheme as appColorSchemes)); + applied.push('settings'); + } + + if (data.overrides && typeof data.overrides === 'object') { + store.dispatch(clearAllOverrides()); + for (const [key, override] of Object.entries(data.overrides)) { + if (override && typeof override === 'object') { + store.dispatch(setMediaOverride({ key, override: override as IMediaOverride })); + } + } + applied.push('overrides'); + } + + if (data.matches && typeof data.matches === 'object') { + const { shows, movies } = data.matches; + + if (shows && typeof shows === 'object') { + for (const [showName, match] of Object.entries(shows)) { + if (!match || typeof match !== 'object') continue; + if (match.ids?.tmdb) { + store.dispatch(updateShowMetadata({ + showName, + providerId: match.ids.tmdb, + source: 'tmdb', + title: match.title, + year: match.year, + })); + } + if (match.ids?.tvdb) { + store.dispatch(updateShowMetadata({ + showName, + providerId: match.ids.tvdb, + source: 'tvdb', + })); + } + for (const [seasonKey, season] of Object.entries(match.seasons ?? {})) { + if (!season?.episodes) continue; + const episodeUpdates: Record = {}; + for (const [epKey, ep] of Object.entries(season.episodes)) { + if (ep?.tmdbTitle) episodeUpdates[epKey] = { tmdbTitle: ep.tmdbTitle }; + } + if (Object.keys(episodeUpdates).length > 0) { + store.dispatch(updateSeasonEpisodeMetadata({ showName, seasonKey, episodeUpdates })); + } + } + } + } + + if (Array.isArray(movies)) { + const currentMovies = store.getState().libraryReducer.movies; + const movieByParsedPath = new Map(currentMovies.map((m) => [m.parsedPath, m])); + for (const match of movies) { + if (!match?.parsedPath) continue; + const movie = movieByParsedPath.get(match.parsedPath); + if (!movie) continue; + if (match.ids?.tmdb) { + store.dispatch(updateMovieMetadata({ path: movie.path, providerId: match.ids.tmdb, source: 'tmdb' })); + } + if (match.ids?.tvdb) { + store.dispatch(updateMovieMetadata({ path: movie.path, providerId: match.ids.tvdb, source: 'tvdb' })); + } + } + } + + applied.push('matches'); + } + + logger.log('ImportExport', `Import complete. Applied: ${applied.join(', ')}`); + return { applied }; +} + +// ─── Filesystem export ──────────────────────────────────────────────────────── + +export interface FilesystemExportResult { + showsExported: number; + moviesExported: number; + failed: number; + skipped: number; +} + +/** + * Writes smb.json metadata files, poster images and episode thumbnails + * alongside the media files in the SAF-accessible library directories. + * + * smb.json is written to: + * /smb.json – contains show IDs, episode titles + * /smb.json – contains movie IDs + * + * Poster images (poster.jpg) are written to the show/movie folder when the + * app has a locally cached provider poster. + * + * Episode thumbnails are written to: + * /smb_thumb_.jpg + * e.g. Season 1/smb_thumb_s01e01.jpg + * + * Movies placed directly in the source root (no subfolder) are skipped since + * there is no unambiguous folder to write the smb.json to. + * + * The written files are readable by FileScanner during the next library scan. + */ +export async function exportToFilesystem(mediaSources: IMediaSource[]): Promise { + const state = store.getState(); + const library = state.libraryReducer.mediaLibrary; + const movies = state.libraryReducer.movies; + const mediaOverrides = state.libraryReducer.mediaOverrides; + + let showsExported = 0; + let moviesExported = 0; + let failed = 0; + let skipped = 0; + + // ── TV shows ────────────────────────────────────────────────────────────── + for (const [showName, show] of Object.entries(library)) { + try { + const showFolderUri = getShowFolderUriFromShow(show, mediaSources); + if (!showFolderUri) { + skipped++; + continue; + } + + const smbShow: SmbJsonShowData = { + smbVersion: 1, + type: 'show', + title: show.title || undefined, + year: show.year || undefined, + ids: { tmdb: show.ids.tmdb, tvdb: show.ids.tvdb, imdb: show.ids.imdb }, + metadataSource: show.metadataSource, + }; + + const override = mediaOverrides[`show:${showName}`]; + if (override) { + smbShow.overrides = { + title: override.title, + sortTitle: override.sortTitle, + year: override.year, + hidden: override.hidden, + }; + } + + // Build seasons block with episode titles and thumbnail file references + const seasons: Required['seasons'] = {}; + let hasSeasonData = false; + for (const [seasonKey, season] of Object.entries(show.seasons)) { + const episodes: Record = {}; + let hasEpData = false; + for (const [epKey, ep] of Object.entries(season.episodes)) { + const epTitle = ep.tmdbTitle; + const thumbName = ep.tmdbThumbnail ? `smb_thumb_${seasonKey}${epKey}.jpg` : undefined; + if (epTitle || thumbName) { + episodes[epKey] = {}; + if (epTitle) episodes[epKey].title = epTitle; + if (thumbName) episodes[epKey].thumbnailFile = thumbName; + hasEpData = true; + } + } + if (hasEpData) { + seasons[seasonKey] = { episodes }; + hasSeasonData = true; + } + } + if (hasSeasonData) smbShow.seasons = seasons; + + // Skip shows with no useful data to export + const hasIds = !!(smbShow.ids.tmdb || smbShow.ids.tvdb); + if (!hasIds && !hasSeasonData && !override) { + skipped++; + continue; + } + + await writeToSafDir(showFolderUri, 'smb.json', JSON.stringify(smbShow, null, 2), 'application/json'); + + // Copy provider poster to show folder + if (show.poster?.startsWith('file://')) { + try { + const base64 = await LegacyFileSystem.readAsStringAsync(show.poster, { encoding: 'base64' }); + await writeToSafDir(showFolderUri, 'poster.jpg', base64, 'image/jpeg', 'base64'); + } catch (e) { + logger.warn('ImportExport', `Could not copy poster for "${showName}"`, e); + } + } + + // Copy episode thumbnails to their season directories + for (const [seasonKey, season] of Object.entries(show.seasons)) { + for (const [epKey, ep] of Object.entries(season.episodes)) { + if (!ep.tmdbThumbnail?.startsWith('file://')) continue; + const seasonDirUri = getSafParentDirUri(ep.path); + if (!seasonDirUri) continue; + const thumbName = `smb_thumb_${seasonKey}${epKey}.jpg`; + try { + const base64 = await LegacyFileSystem.readAsStringAsync(ep.tmdbThumbnail, { encoding: 'base64' }); + await writeToSafDir(seasonDirUri, thumbName, base64, 'image/jpeg', 'base64'); + } catch (e) { + logger.warn('ImportExport', `Could not copy thumbnail ${thumbName} for "${showName}"`, e); + } + } + } + + showsExported++; + } catch (e) { + logger.warn('ImportExport', `Failed to export show "${showName}"`, e); + failed++; + } + } + + // ── Movies ──────────────────────────────────────────────────────────────── + for (const movie of movies) { + try { + // Skip movies directly in the source root (no subfolder to write smb.json to) + if (getRelativeDepth(movie.path, mediaSources) === 1) { + skipped++; + continue; + } + + const movieFolderUri = getSafParentDirUri(movie.path); + if (!movieFolderUri) { + skipped++; + continue; + } + + const smbMovie: SmbJsonMovieData = { + smbVersion: 1, + type: 'movie', + title: movie.title || undefined, + ids: { tmdb: movie.ids.tmdb, tvdb: movie.ids.tvdb, imdb: movie.ids.imdb }, + }; + + const override = mediaOverrides[`movie:${movie.parsedPath}`]; + if (override) { + smbMovie.overrides = { + title: override.title, + sortTitle: override.sortTitle, + year: override.year, + hidden: override.hidden, + }; + } + + const hasIds = !!(smbMovie.ids.tmdb || smbMovie.ids.tvdb); + if (!hasIds && !override) { + skipped++; + continue; + } + + await writeToSafDir(movieFolderUri, 'smb.json', JSON.stringify(smbMovie, null, 2), 'application/json'); + + // Copy provider poster to movie folder + if (movie.poster?.startsWith('file://')) { + try { + const base64 = await LegacyFileSystem.readAsStringAsync(movie.poster, { encoding: 'base64' }); + await writeToSafDir(movieFolderUri, 'poster.jpg', base64, 'image/jpeg', 'base64'); + } catch (e) { + logger.warn('ImportExport', `Could not copy poster for movie "${movie.title}"`, e); + } + } + + moviesExported++; + } catch (e) { + logger.warn('ImportExport', `Failed to export movie "${movie.title}"`, e); + failed++; + } + } + + logger.log('ImportExport', `Filesystem export complete: ${showsExported} shows, ${moviesExported} movies, ${failed} failed, ${skipped} skipped`); + return { showsExported, moviesExported, failed, skipped }; +} diff --git a/src/scripts/SmbTypes.ts b/src/scripts/SmbTypes.ts new file mode 100644 index 0000000..dadb171 --- /dev/null +++ b/src/scripts/SmbTypes.ts @@ -0,0 +1,66 @@ +/** + * Types for the smb.json file format written alongside media files. + * These are read by FileScanner during library scanning and written by + * ImportExportService during filesystem metadata export. + * + * Kept in a separate file to avoid circular imports between FileScanner and + * ImportExportService. + */ + +export interface SmbJsonEpisodeData { + /** Display title for the episode (from the metadata provider). */ + title?: string; + /** Relative filename of the episode thumbnail image in the same directory. */ + thumbnailFile?: string; +} + +export interface SmbJsonSeasonData { + /** Episode entries keyed by episode key (e.g. "e01", "e02"). */ + episodes: Record; +} + +/** smb.json file written to a TV show folder. */ +export interface SmbJsonShowData { + smbVersion: 1; + type: 'show'; + /** Canonical title from the metadata provider. */ + title?: string; + /** Release year. */ + year?: number; + /** Provider IDs fetched during metadata enrichment. */ + ids: { tmdb: string | null; tvdb: string | null; imdb: string | null }; + /** Metadata provider used to enrich this show. */ + metadataSource?: 'tmdb' | 'tvdb'; + /** User-supplied overrides for title, sort key, visibility, etc. */ + overrides?: { + title?: string; + sortTitle?: string; + year?: number; + hidden?: boolean; + }; + /** Season/episode metadata keyed by season key (e.g. "s01"). */ + seasons?: Record; +} + +/** smb.json file written to a movie folder. */ +export interface SmbJsonMovieData { + smbVersion: 1; + type: 'movie'; + /** Canonical title from the metadata provider. */ + title?: string; + /** Release year. */ + year?: number; + /** Provider IDs fetched during metadata enrichment. */ + ids: { tmdb: string | null; tvdb: string | null; imdb: string | null }; + /** Metadata provider used to enrich this movie. */ + metadataSource?: 'tmdb' | 'tvdb'; + /** User-supplied overrides for title, sort key, visibility, etc. */ + overrides?: { + title?: string; + sortTitle?: string; + year?: number; + hidden?: boolean; + }; +} + +export type SmbJsonData = SmbJsonShowData | SmbJsonMovieData; From c8063479ffb8645b12c2ea8559c2a0bc1de527a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 19:00:35 +0000 Subject: [PATCH 2/6] Address code review: shared thumb filename util, base64 write helper, idempotent dir creation, accessibility fix --- src/app/(drawer)/settings.tsx | 2 +- src/scripts/FileScanner.ts | 9 +++++---- src/scripts/ImportExportService.ts | 23 ++++++++++++++++++----- src/scripts/SmbTypes.ts | 18 ++++++++++++++++++ 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/app/(drawer)/settings.tsx b/src/app/(drawer)/settings.tsx index 9cea9a5..f9bede9 100644 --- a/src/app/(drawer)/settings.tsx +++ b/src/app/(drawer)/settings.tsx @@ -945,7 +945,7 @@ export default function SettingsPrompt() { {importExportExpanded && ( {/* JSON export options */} - Export to JSON file — select what to include: + Export to JSON file: select what to include: Settings (API keys, view options) diff --git a/src/scripts/FileScanner.ts b/src/scripts/FileScanner.ts index 353d907..791919a 100644 --- a/src/scripts/FileScanner.ts +++ b/src/scripts/FileScanner.ts @@ -11,6 +11,7 @@ import type { IMediaSource } from "@/store/settingsReducer"; import { logger } from "@/scripts/Logger"; import { MetadataService } from "@/scripts/MetadataService"; import type { SmbJsonData } from "@/scripts/SmbTypes"; +import { SMB_THUMB_REGEX, smbThumbFilename } from "@/scripts/SmbTypes"; export interface IMediaObject { ids: { @@ -921,7 +922,7 @@ export class FileScanner { // Detect smb_thumb_s01e01.jpg files written by the filesystem export. if (relativePathParts.length >= 1 && ext === '.jpg') { - const thumbMatch = filename.toLowerCase().match(/^smb_thumb_(s\d{2})(e\d{2,3})\.jpg$/); + const thumbMatch = filename.toLowerCase().match(SMB_THUMB_REGEX); if (thumbMatch) { const folderKey = relativePathParts[0]; const seasonKey = thumbMatch[1]; @@ -933,9 +934,9 @@ export class FileScanner { const folderThumbMap = streamState.smbThumbnails.get(folderKey)!; if (!folderThumbMap.has(thumbMapKey)) { try { - if (!SMB_THUMBNAILS_FS_DIR.exists) { - SMB_THUMBNAILS_FS_DIR.create({ intermediates: true, idempotent: true }); - } + // Always attempt idempotent creation to avoid a race between + // an exists-check and a create in concurrent async scan loops. + SMB_THUMBNAILS_FS_DIR.create({ intermediates: true, idempotent: true }); const safeName = `${folderKey.replace(/[^a-zA-Z0-9_-]/g, '_')}_${seasonKey}${episodeKey}.jpg`; const localFile = new File(SMB_THUMBNAILS_FS_DIR, safeName); if (!localFile.exists) { diff --git a/src/scripts/ImportExportService.ts b/src/scripts/ImportExportService.ts index be6aca1..beb1b47 100644 --- a/src/scripts/ImportExportService.ts +++ b/src/scripts/ImportExportService.ts @@ -38,6 +38,7 @@ import { import type { IMediaSource, dataSources, viewTypes, viewOrientations, defaultPages, appColorSchemes } from '@/store/settingsReducer'; import { logger } from '@/scripts/Logger'; import type { SmbJsonData, SmbJsonShowData, SmbJsonMovieData } from '@/scripts/SmbTypes'; +import { smbThumbFilename } from '@/scripts/SmbTypes'; // Re-export smb.json types so callers can import them from this module. export type { SmbJsonData, SmbJsonShowData, SmbJsonMovieData }; @@ -45,6 +46,20 @@ export type { SmbJsonEpisodeData, SmbJsonSeasonData } from '@/scripts/SmbTypes'; const { StorageAccessFramework } = LegacyFileSystem; +/** + * StorageAccessFramework.writeAsStringAsync accepts an encoding option at runtime, + * but the TypeScript declaration omits it. This typed wrapper encapsulates the cast + * in one place so callers stay clean. + */ +async function writeSafBase64(uri: string, base64Content: string): Promise { + const writeFn = StorageAccessFramework.writeAsStringAsync as ( + uri: string, + content: string, + options: { encoding: string }, + ) => Promise; + await writeFn(uri, base64Content, { encoding: 'base64' }); +} + // ─── SAF helpers ───────────────────────────────────────────────────────────── /** @@ -147,9 +162,7 @@ async function writeToSafDir( fileUri = await StorageAccessFramework.createFileAsync(dirUri, filename, mimeType); } if (encoding === 'base64') { - // Type-assert to access the optional encoding parameter that expo-file-system - // supports at runtime even if the TypeScript declaration doesn't surface it. - await (StorageAccessFramework.writeAsStringAsync as (uri: string, content: string, options: { encoding: string }) => Promise)(fileUri, content, { encoding: 'base64' }); + await writeSafBase64(fileUri, content); } else { await StorageAccessFramework.writeAsStringAsync(fileUri, content); } @@ -485,7 +498,7 @@ export async function exportToFilesystem(mediaSources: IMediaSource[]): Promise< let hasEpData = false; for (const [epKey, ep] of Object.entries(season.episodes)) { const epTitle = ep.tmdbTitle; - const thumbName = ep.tmdbThumbnail ? `smb_thumb_${seasonKey}${epKey}.jpg` : undefined; + const thumbName = ep.tmdbThumbnail ? smbThumbFilename(seasonKey, epKey) : undefined; if (epTitle || thumbName) { episodes[epKey] = {}; if (epTitle) episodes[epKey].title = epTitle; @@ -525,7 +538,7 @@ export async function exportToFilesystem(mediaSources: IMediaSource[]): Promise< if (!ep.tmdbThumbnail?.startsWith('file://')) continue; const seasonDirUri = getSafParentDirUri(ep.path); if (!seasonDirUri) continue; - const thumbName = `smb_thumb_${seasonKey}${epKey}.jpg`; + const thumbName = smbThumbFilename(seasonKey, epKey); try { const base64 = await LegacyFileSystem.readAsStringAsync(ep.tmdbThumbnail, { encoding: 'base64' }); await writeToSafDir(seasonDirUri, thumbName, base64, 'image/jpeg', 'base64'); diff --git a/src/scripts/SmbTypes.ts b/src/scripts/SmbTypes.ts index dadb171..33dff16 100644 --- a/src/scripts/SmbTypes.ts +++ b/src/scripts/SmbTypes.ts @@ -64,3 +64,21 @@ export interface SmbJsonMovieData { } export type SmbJsonData = SmbJsonShowData | SmbJsonMovieData; + +/** + * Returns the smb_thumb filename for a given season and episode key. + * Kept here so that both FileScanner (reader) and ImportExportService (writer) + * use an identical pattern and stay in sync. + * + * @param seasonKey - e.g. "s01" + * @param epKey - e.g. "e01" or "e001" + */ +export function smbThumbFilename(seasonKey: string, epKey: string): string { + return `smb_thumb_${seasonKey}${epKey}.jpg`; +} + +/** + * Regex that matches a smb_thumb filename produced by smbThumbFilename(). + * Captures season key (group 1) and episode key (group 2). + */ +export const SMB_THUMB_REGEX = /^smb_thumb_(s\d{2})(e\d{2,3})\.jpg$/i; From b3db774a30db31c3469ca6d415b6dacf14e9ed39 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 19:34:42 +0000 Subject: [PATCH 3/6] Refactor LegacyFileSystem to modern class-based API; rename tmdbTitle to episodeTitle in JSON export format --- src/scripts/FileScanner.ts | 3 +-- src/scripts/ImportExportService.ts | 21 ++++++++++----------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/scripts/FileScanner.ts b/src/scripts/FileScanner.ts index 791919a..421a72a 100644 --- a/src/scripts/FileScanner.ts +++ b/src/scripts/FileScanner.ts @@ -1,6 +1,5 @@ import { Directory, File, Paths } from "expo-file-system"; import { StorageAccessFramework } from "expo-file-system/legacy"; -import * as LegacyFileSystem from "expo-file-system/legacy"; import { createVideoPlayer } from "expo-video"; import type { VideoThumbnail } from "expo-video"; import { ImageManipulator, SaveFormat } from "expo-image-manipulator"; @@ -907,7 +906,7 @@ export class FileScanner { const folderKey = relativePathParts[0]; if (!streamState.smbData.has(folderKey)) { try { - const jsonText = await LegacyFileSystem.readAsStringAsync(resolvedUri); + const jsonText = await StorageAccessFramework.readAsStringAsync(resolvedUri); const parsed = JSON.parse(jsonText) as SmbJsonData; if (parsed && typeof parsed === 'object' && parsed.smbVersion === 1) { streamState.smbData.set(folderKey, parsed); diff --git a/src/scripts/ImportExportService.ts b/src/scripts/ImportExportService.ts index beb1b47..60d15cb 100644 --- a/src/scripts/ImportExportService.ts +++ b/src/scripts/ImportExportService.ts @@ -8,7 +8,8 @@ * alongside the media files in the configured SAF directories. */ -import * as LegacyFileSystem from 'expo-file-system/legacy'; +import { File } from 'expo-file-system'; +import { StorageAccessFramework } from 'expo-file-system/legacy'; import * as DocumentPicker from 'expo-document-picker'; import { store } from '@/store/store'; import { @@ -44,8 +45,6 @@ import { smbThumbFilename } from '@/scripts/SmbTypes'; export type { SmbJsonData, SmbJsonShowData, SmbJsonMovieData }; export type { SmbJsonEpisodeData, SmbJsonSeasonData } from '@/scripts/SmbTypes'; -const { StorageAccessFramework } = LegacyFileSystem; - /** * StorageAccessFramework.writeAsStringAsync accepts an encoding option at runtime, * but the TypeScript declaration omits it. This typed wrapper encapsulates the cast @@ -197,7 +196,7 @@ interface ShowMatchExport { ids: { tmdb: string | null; tvdb: string | null; imdb: string | null }; title: string; year: number; - seasons: Record }>; + seasons: Record }>; } interface MovieMatchExport { @@ -267,11 +266,11 @@ export async function exportJson(options: JsonExportOptions): Promise { if (!show.ids.tmdb && !show.ids.tvdb && !show.ids.imdb) continue; const seasons: ShowMatchExport['seasons'] = {}; for (const [seasonKey, season] of Object.entries(show.seasons)) { - const episodes: Record = {}; + const episodes: Record = {}; let hasEpData = false; for (const [epKey, ep] of Object.entries(season.episodes)) { if (ep.tmdbTitle) { - episodes[epKey] = { tmdbTitle: ep.tmdbTitle }; + episodes[epKey] = { episodeTitle: ep.tmdbTitle }; hasEpData = true; } } @@ -326,7 +325,7 @@ export async function importJson(): Promise<{ applied: string[] }> { throw new Error('No file selected'); } - const jsonText = await LegacyFileSystem.readAsStringAsync(result.assets[0].uri); + const jsonText = new File(result.assets[0].uri).textSync(); const data = JSON.parse(jsonText) as SmbExportJson; if (!data || typeof data !== 'object' || data.smbVersion !== 1) { @@ -390,7 +389,7 @@ export async function importJson(): Promise<{ applied: string[] }> { if (!season?.episodes) continue; const episodeUpdates: Record = {}; for (const [epKey, ep] of Object.entries(season.episodes)) { - if (ep?.tmdbTitle) episodeUpdates[epKey] = { tmdbTitle: ep.tmdbTitle }; + if (ep?.episodeTitle) episodeUpdates[epKey] = { tmdbTitle: ep.episodeTitle }; } if (Object.keys(episodeUpdates).length > 0) { store.dispatch(updateSeasonEpisodeMetadata({ showName, seasonKey, episodeUpdates })); @@ -525,7 +524,7 @@ export async function exportToFilesystem(mediaSources: IMediaSource[]): Promise< // Copy provider poster to show folder if (show.poster?.startsWith('file://')) { try { - const base64 = await LegacyFileSystem.readAsStringAsync(show.poster, { encoding: 'base64' }); + const base64 = await new File(show.poster).base64(); await writeToSafDir(showFolderUri, 'poster.jpg', base64, 'image/jpeg', 'base64'); } catch (e) { logger.warn('ImportExport', `Could not copy poster for "${showName}"`, e); @@ -540,7 +539,7 @@ export async function exportToFilesystem(mediaSources: IMediaSource[]): Promise< if (!seasonDirUri) continue; const thumbName = smbThumbFilename(seasonKey, epKey); try { - const base64 = await LegacyFileSystem.readAsStringAsync(ep.tmdbThumbnail, { encoding: 'base64' }); + const base64 = await new File(ep.tmdbThumbnail).base64(); await writeToSafDir(seasonDirUri, thumbName, base64, 'image/jpeg', 'base64'); } catch (e) { logger.warn('ImportExport', `Could not copy thumbnail ${thumbName} for "${showName}"`, e); @@ -598,7 +597,7 @@ export async function exportToFilesystem(mediaSources: IMediaSource[]): Promise< // Copy provider poster to movie folder if (movie.poster?.startsWith('file://')) { try { - const base64 = await LegacyFileSystem.readAsStringAsync(movie.poster, { encoding: 'base64' }); + const base64 = await new File(movie.poster).base64(); await writeToSafDir(movieFolderUri, 'poster.jpg', base64, 'image/jpeg', 'base64'); } catch (e) { logger.warn('ImportExport', `Could not copy poster for movie "${movie.title}"`, e); From 81fe38f742fa8925a2fdb12018f6a28eabdc5058 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 19:36:31 +0000 Subject: [PATCH 4/6] Use async text() instead of textSync() for non-blocking import in importJson --- src/scripts/ImportExportService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scripts/ImportExportService.ts b/src/scripts/ImportExportService.ts index 60d15cb..a42f153 100644 --- a/src/scripts/ImportExportService.ts +++ b/src/scripts/ImportExportService.ts @@ -325,7 +325,7 @@ export async function importJson(): Promise<{ applied: string[] }> { throw new Error('No file selected'); } - const jsonText = new File(result.assets[0].uri).textSync(); + const jsonText = await new File(result.assets[0].uri).text(); const data = JSON.parse(jsonText) as SmbExportJson; if (!data || typeof data !== 'object' || data.smbVersion !== 1) { From 9e8c5b09419b76670afee753ece95c334ee3e7b7 Mon Sep 17 00:00:00 2001 From: Kallum Burgin <1054734+Kallb123@users.noreply.github.com> Date: Tue, 26 May 2026 20:45:26 +0100 Subject: [PATCH 5/6] Bump version to 1.10.0 and update versionCode --- app.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app.json b/app.json index b8b6606..85973af 100644 --- a/app.json +++ b/app.json @@ -3,7 +3,7 @@ "name": "SimpleMediaBrowser", "owner": "kallb123", "slug": "SimpleMediaBrowser", - "version": "1.9.0", + "version": "1.10.0", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "simplemediabrowser", @@ -20,7 +20,7 @@ }, "predictiveBackGestureEnabled": false, "package": "net.nawt.simplemediabrowser", - "versionCode": 42 + "versionCode": 43 }, "web": { "output": "static", From 9fbffc43a264cdc9b6b69c270424c831a7a474ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 19:59:56 +0000 Subject: [PATCH 6/6] Replace StorageAccessFramework legacy import with modern Directory/File API in ImportExportService --- package-lock.json | 6 ++-- src/scripts/ImportExportService.ts | 50 +++++++++--------------------- 2 files changed, 17 insertions(+), 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9e666c8..bc5197a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3761,7 +3761,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "dependencies": { "csstype": "^3.2.2" } @@ -5548,7 +5548,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true + "devOptional": true }, "node_modules/data-urls": { "version": "3.0.2", @@ -14050,7 +14050,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/scripts/ImportExportService.ts b/src/scripts/ImportExportService.ts index a42f153..563a5f0 100644 --- a/src/scripts/ImportExportService.ts +++ b/src/scripts/ImportExportService.ts @@ -8,8 +8,7 @@ * alongside the media files in the configured SAF directories. */ -import { File } from 'expo-file-system'; -import { StorageAccessFramework } from 'expo-file-system/legacy'; +import { File, Directory } from 'expo-file-system'; import * as DocumentPicker from 'expo-document-picker'; import { store } from '@/store/store'; import { @@ -45,20 +44,6 @@ import { smbThumbFilename } from '@/scripts/SmbTypes'; export type { SmbJsonData, SmbJsonShowData, SmbJsonMovieData }; export type { SmbJsonEpisodeData, SmbJsonSeasonData } from '@/scripts/SmbTypes'; -/** - * StorageAccessFramework.writeAsStringAsync accepts an encoding option at runtime, - * but the TypeScript declaration omits it. This typed wrapper encapsulates the cast - * in one place so callers stay clean. - */ -async function writeSafBase64(uri: string, base64Content: string): Promise { - const writeFn = StorageAccessFramework.writeAsStringAsync as ( - uri: string, - content: string, - options: { encoding: string }, - ) => Promise; - await writeFn(uri, base64Content, { encoding: 'base64' }); -} - // ─── SAF helpers ───────────────────────────────────────────────────────────── /** @@ -144,27 +129,18 @@ async function writeToSafDir( mimeType: string, encoding: 'utf8' | 'base64' = 'utf8', ): Promise { - let fileUri: string | null = null; + const dir = new Directory(dirUri); + let file: File; try { - const entries = await StorageAccessFramework.readDirectoryAsync(dirUri); - for (const uri of entries) { - const decoded = decodeURIComponent(uri); - if (decoded.endsWith('/' + filename)) { - fileUri = uri; - break; - } - } + const existing = dir.list().find( + (item): item is File => item instanceof File && decodeURIComponent(item.uri).endsWith('/' + filename) + ); + file = existing ?? dir.createFile(filename, mimeType); } catch { // Proceed to create; listing may fail on some SAF providers. + file = dir.createFile(filename, mimeType); } - if (!fileUri) { - fileUri = await StorageAccessFramework.createFileAsync(dirUri, filename, mimeType); - } - if (encoding === 'base64') { - await writeSafBase64(fileUri, content); - } else { - await StorageAccessFramework.writeAsStringAsync(fileUri, content); - } + file.write(content, { encoding }); } /** @@ -295,14 +271,16 @@ export async function exportJson(options: JsonExportOptions): Promise { payload.matches = { shows, movies: movieMatches }; } - const dirResult = await StorageAccessFramework.requestDirectoryPermissionsAsync(); - if (!dirResult.granted) { + let dir: Directory; + try { + dir = await Directory.pickDirectoryAsync(); + } catch { throw new Error('Directory permission denied'); } const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const filename = `smb_export_${timestamp}.json`; - await writeToSafDir(dirResult.directoryUri, filename, JSON.stringify(payload, null, 2), 'application/json'); + await writeToSafDir(dir.uri, filename, JSON.stringify(payload, null, 2), 'application/json'); logger.log('ImportExport', `Exported JSON state to ${filename}`); }