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", 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/app/(drawer)/settings.tsx b/src/app/(drawer)/settings.tsx index 14a9a3a..f9bede9 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 StorageAccessFramework.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_REGEX); + 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 { + // 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) { + 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..563a5f0 --- /dev/null +++ b/src/scripts/ImportExportService.ts @@ -0,0 +1,594 @@ +/** + * 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 { File, Directory } from 'expo-file-system'; +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'; +import { smbThumbFilename } 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'; + +// ─── 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 { + const dir = new Directory(dirUri); + let file: File; + try { + 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); + } + file.write(content, { encoding }); +} + +/** + * 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] = { episodeTitle: 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 }; + } + + 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(dir.uri, 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 new File(result.assets[0].uri).text(); + 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?.episodeTitle) episodeUpdates[epKey] = { tmdbTitle: ep.episodeTitle }; + } + 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 ? smbThumbFilename(seasonKey, epKey) : 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 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); + } + } + + // 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 = smbThumbFilename(seasonKey, epKey); + try { + 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); + } + } + } + + 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 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); + } + } + + 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..33dff16 --- /dev/null +++ b/src/scripts/SmbTypes.ts @@ -0,0 +1,84 @@ +/** + * 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; + +/** + * 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;