npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@obsidian_north/react-native-mediastore

v2.1.1

Published

Universal high-performance media indexing library for Android (MediaStore) and iOS (Photos Framework)

Readme

@obsidian_north/react-native-mediastore

Universal high-performance media indexing library for Android and iOS.

A reusable Expo Module that provides fast, production-grade access to media and indexed documents on Android (via MediaStore) and iOS (via Photos Framework) — no recursive filesystem scanning.

| Build | Lint | Type Check | Tests | Release | |-------|------|------------|-------|---------| | ✅ | ✅ | ✅ | ✅ | ✅ |

Architecture

App
 │
 ▼
TypeScript SDK
 │
 ▼
Expo Module (expo-module-core)
 │
 ├── Permission Manager
 ├── Cache Manager (LRU + TTL)
 ├── Search Engine
 ├── Observer (ContentObserver / PHPhotoLibraryChangeObserver)
 ├── Repository
 │
 ├─── Android ──→ MediaStore (SQLite Index) ──→ ContentResolver
 │                                              │
 │                                              ▼
 │                                         Cursor → Mapper → Domain Models → JSON
 │
 └─── iOS ─────→ Photos Framework (PHAsset) ──→ PHImageManager / PHAssetFetchRequest
                                                 │
                                                 ▼
                                            PHAsset → Domain Models → JSON

Query Flow

getAudio()
 │
 ▼
Permission Check ─── DENIED ──→ PermissionDenied error
 │
 ▼ (granted)
Cache Lookup ─── HIT ──→ Return cached result
 │
 ▼ (miss)
Native Query (ContentResolver / PHAsset)
 │
 ▼
Object Mapping (typed projection / PHAsset properties)
 │
 ▼
JSON Serialization
 │
 ▼
React Native

Thread Model

JS Thread ──→ async/await Promise
 │
 ▼
Native Module Thread (coroutine dispatcher / DispatchQueue)
 │
 ▼
IO Dispatcher (Dispatchers.IO) / Background Queue
 │
 ▼
MediaStore / Photos Framework
 │
 ▼
Back to JS (Promise resolved)

All queries execute on background dispatchers — the UI thread is never blocked.

Memory Usage

100,000 songs
 │
 ▼
Cursor / PHFetchResult (lazy, not loaded entirely)
 │
 ▼
Stream rows individually
 │
 ▼
Map each row → JSON object
 │
 ▼
Collect results
 │
 ▼
Dispose Cursor / Release PHFetchResult

No entire library is loaded into memory. Each row is mapped and collected incrementally, then the cursor is closed in a use block.


Features

  • Cross-platform — Android (MediaStore) and iOS (Photos Framework) with a unified API
  • Blazing fast — queries the native media database directly, no recursive directory scans
  • All media types — audio, video, images, and documents (Android only; PDF, DOC/DOCX, XLS/XLSX, PPT/PPTX, TXT, EPUB, RTF, CSV, JSON, XML, ZIP, RAR, 7Z)
  • Rich metadata — duration, resolution, bitrate, EXIF, GPS, album art, and more
  • Sorted & filtered queries — sort by name, date, size, duration, artist, etc. Filter by MIME, extension, folder, date range, size range, and more
  • Full-text search — prefix, partial, case-insensitive, multi-keyword, unicode-aware
  • Pagination — limit/offset and cursor-based pagination on every API
  • Real-time change observationContentObserver (Android) / PHPhotoLibraryChangeObserver (iOS) fires events when files are added, removed, or modified
  • Permissions-aware — scoped READ_MEDIA_* permissions on Android 13+, PHPhotoLibrary authorization on iOS, automatic fallback
  • LRU caching — optional in-memory cache with configurable TTL, auto-invalidated on changes
  • Fully typed — complete TypeScript definitions, no any
  • Reactive — React hook useMediaChangeEvent for real-time updates
  • Batch queriesgetLibrary() returns all media types in one native call
  • Thumbnail/artwork — helper methods for album art and video/image thumbnails
  • Folder statistics — size histograms and per-type breakdowns for folders
  • Incremental indexing — delta-only refresh tracking added, modified, and removed items
  • Plugin hooks — extensible metadata system via JS-side plugin registration
  • Improved batch queries — per-type pagination, selective type fetching, and query timing

Performance Benchmarks

| Library | 50k songs | |---------|----------:| | Recursive FS | 14 sec | | MediaStore | 280 ms |

| Operation | Time | |-----------|-----:| | getAudio | 120 ms | | getVideos | 90 ms | | getImages | 95 ms | | getDocuments | 70 ms | | search | 25 ms | | getStatistics | 15 ms |

Measurements taken on a Pixel 7 (Android 14) with 50k audio, 2k video, 10k images. Results cached after first query.


Installation

npm install @obsidian_north/react-native-mediastore

Or with a development build:

npx expo install @obsidian_north/react-native-mediastore

Prerequisites

  • Expo SDK 52+ or React Native with New Architecture enabled
  • Android: API 21+ (Android 5.0)
    • Android 13+ (API 33): granular media permissions are requested automatically
    • Android 12 and below: READ_EXTERNAL_STORAGE permission is required
  • iOS: iOS 13.0+
    • Photos Framework permission is requested automatically via PHPhotoLibrary.requestAuthorization

Permissions Matrix

| Platform | Version | Permission | |----------|---------|-----------| | iOS | 13.0+ | PHPhotoLibrary (read/write authorization) | | Android 15 | 35 | READ_MEDIA_AUDIO, READ_MEDIA_VIDEO, READ_MEDIA_IMAGES | | Android 14 | 34 | READ_MEDIA_AUDIO, READ_MEDIA_VIDEO, READ_MEDIA_IMAGES | | Android 13 | 33 | READ_MEDIA_AUDIO, READ_MEDIA_VIDEO, READ_MEDIA_IMAGES | | Android 12 | 32 | READ_EXTERNAL_STORAGE | | Android 11 | 30–31 | READ_EXTERNAL_STORAGE | | Android 10 | 29 | READ_EXTERNAL_STORAGE | | Android 5–9 | 21–28 | READ_EXTERNAL_STORAGE |

Call requestPermissions() before querying media on first launch.

Compatibility Matrix

| Expo SDK | React Native | AGP | Kotlin | Gradle | Status | |----------|-------------|-----|--------|--------|--------| | 52 | 0.76 | 8.7 | 2.0.21 | 8.11 | ✅ | | 56 | 0.85 | 9.0 | 2.1.20 | 9.3 | ✅ |


Comparison

| Feature | react-native-mediastore | expo-file-system | react-native-fs | |---------|:---------------------------:|:----------------:|:----------------:| | Android | ✅ | ✅ | ✅ | | iOS | ✅ | ✅ | ✅ | | Audio (music) | ✅ | ❌ | ⚠️ | | Video | ✅ | ❌ | ⚠️ | | Images | ✅ | ❌ | ⚠️ | | Documents | ✅ (Android) | ❌ | ⚠️ | | Rich metadata | ✅ | ❌ | ❌ | | Album art | ✅ | ❌ | ❌ | | EXIF/GPS | ✅ | ❌ | ❌ | | Full-text search | ✅ | ❌ | ❌ | | Watch changes | ✅ | ❌ | ❌ | | Pagination | ✅ | ❌ | ❌ | | Sorting | ✅ | ❌ | ❌ | | Filters | ✅ | ❌ | ❌ | | Batch query | ✅ | ❌ | ❌ | | Duplicate detection | ✅ | ❌ | ❌ | | Statistics | ✅ | ❌ | ❌ | | Favorites | ✅ | ❌ | ❌ | | Folder histograms | ✅ | ❌ | ❌ | | Incremental indexing | ✅ | ❌ | ❌ | | Plugin metadata | ✅ | ❌ | ❌ |

⚠️ = partial support (no metadata, no filtering)


Quick Start

import { getAudio, getImages, useMediaChangeEvent } from "@obsidian_north/react-native-mediastore";

// Fetch all audio tracks
const songs = await getAudio(
  { field: "dateAdded", order: "desc" },
  { minDuration: 30_000 }
);

// Fetch recent images with pagination
const photos = await getImages(
  { field: "dateAdded", order: "desc" },
  null,
  { limit: 20, offset: 0 }
);

// Batch query — audio, video, images, documents in one call
const library = await getLibrary(
  { field: "dateAdded", order: "desc" }
);

// Fetch album artwork
const artworkUri = await getAlbumArtwork(albumId);

// Listen for MediaStore changes
function MediaWatcher() {
  const event = useMediaChangeEvent((e) => {
    switch (e.type) {
      case "added":
        console.log(`New ${e.mediaType}: ${e.uri}`);
        break;
      case "removed":
        console.log(`${e.mediaType} removed: ${e.itemId}`);
        break;
      case "modified":
        console.log(`${e.mediaType} modified: ${e.itemId}`);
        break;
    }
  });
  return null;
}

API Reference

Media Queries

| Function | Returns | Description | |----------|---------|-------------| | getAudio(sort?, filter?, pagination?) | AudioItem[] | Fetch audio tracks | | getVideos(sort?, filter?, pagination?) | VideoItem[] | Fetch video files | | getImages(sort?, filter?, pagination?) | ImageItem[] | Fetch images | | getDocuments(sort?, filter?, pagination?) | DocumentItem[] | Fetch documents (Android only) | | getAlbums(sort?, filter?, pagination?) | Album[] | Fetch audio albums | | getArtists(sort?, pagination?) | Artist[] | Fetch artists | | getGenres(sort?, pagination?) | Genre[] | Fetch genres | | getPlaylists(sort?, pagination?) | Playlist[] | Fetch playlists | | getFolders(sort?, filter?, pagination?) | Folder[] | Aggregate files by folder (Android only) |

Search & Lookup

| Function | Returns | Description | |----------|---------|-------------| | search(options) | SearchResult | Full-text search across media types | | getById(mediaType, id) | AudioItem \| VideoItem \| ImageItem \| DocumentItem \| null | Lookup by database ID | | getByUri(uri) | AudioItem \| VideoItem \| ImageItem \| DocumentItem \| null | Lookup by content URI |

Utility Queries

| Function | Returns | Description | |----------|---------|-------------| | getRecent(mediaType?, limit?) | (AudioItem \| VideoItem \| ImageItem \| DocumentItem)[] | Most recently added items | | getFavorites(mediaType?, sort?, pagination?) | (AudioItem \| VideoItem \| ImageItem \| DocumentItem)[] | Starred/favorited items | | getLargestFiles(mediaType?, limit?) | (AudioItem \| VideoItem \| ImageItem \| DocumentItem)[] | Largest files by size | | getDuplicates(mediaType?) | DuplicateItem[] | Detect duplicate files (Android only) | | getStatistics() | MediaStoreStatistics | Aggregate counts and sizes | | getLibrary(sort?, filter?, pagination?) | LibraryResult | Audio, video, images, docs in one batch | | getLibraryQuery(options?) | LibraryQueryResult | Improved batch query with per-type pagination and statistics | | getFolderStatistics(folderPath?) | FolderStatistics[] | Size histograms and type breakdowns per folder | | refreshIncremental(lastTimestamp?) | IncrementalChanges | Delta-only refresh tracking changes since timestamp | | getLastRefreshTimestamp() | number | Get last refresh timestamp |

Plugin System

| Function | Returns | Description | |----------|---------|-------------| | registerPlugin(plugin) | void | Register a metadata extractor plugin | | unregisterPlugin(pluginId) | boolean | Remove a plugin by ID | | getRegisteredPlugins() | MetadataPlugin[] | List all registered plugins |

Artwork & Thumbnails

| Function | Returns | Description | |----------|---------|-------------| | getAlbumArtwork(albumId) | string \| null | Album art content URI | | getVideoThumbnail(videoId, width?, height?) | string \| null | Video thumbnail URI | | getImageThumbnail(imageId, width?, height?) | string \| null | Image thumbnail URI |

System

| Function | Returns | Description | |----------|---------|-------------| | refresh() | void | Invalidate all caches | | checkPermissions() | PermissionStatus | Check current permission state | | requestPermissions() | PermissionStatus | Request media permissions | | useMediaChangeEvent(callback?) | MediaChangeEvent \| null | React hook for change events |


Error Codes

Every thrown error has a structured MediaStoreError with a typed code:

| Code | Meaning | |------|---------| | PERMISSION_DENIED | Required media permissions not granted | | QUERY_FAILED | MediaStore / Photos query failed (database error) | | INVALID_ARGUMENTS | Invalid sort field, MIME type, or filter option | | INVALID_SORT_FIELD | The requested sort field is not valid for this media type | | INVALID_MIME_TYPE | The MIME type filter does not match any known type | | UNSUPPORTED_ANDROID_VERSION | Android version below minimum SDK (21) | | FILE_UNAVAILABLE | File not found or inaccessible | | CURSOR_CLOSED | Cursor was closed before iteration completed | | CACHE_FAILURE | Cache operation failed | | UNKNOWN_ERROR | Unexpected error |

try {
  const songs = await getAudio();
} catch (error) {
  if (error.code === "PERMISSION_DENIED") {
    // Handle permission flow
  }
}

Event System

The module uses Android's ContentObserver and iOS's PHPhotoLibraryChangeObserver to monitor media changes and emit events to JavaScript.

Events

| Event | Payload | Description | |-------|---------|-------------| | mediaAdded | { type: "added", mediaType, itemId, uri } | New media file indexed | | mediaRemoved | { type: "removed", mediaType, itemId, uri } | Media file deleted | | mediaModified | { type: "modified", mediaType, itemId, uri } | Metadata or file modified | | permissionChanged | { type: "permissionChanged", granted, mediaType } | Permission state changed | | cacheInvalidated | { type: "cacheInvalidated" } | Cache cleared automatically |

React Hook

import { useMediaChangeEvent } from "@obsidian_north/react-native-mediastore";

function MyComponent() {
  const lastEvent = useMediaChangeEvent((event) => {
    // React to every change
  });

  return <Text>Last change: {lastEvent?.type}</Text>;
}

Platform Support

Android

getFolders() aggregates files into folders by their relativePath.

interface Folder {
  id: string;
  name: string;        // Display name (last segment)
  path: string;        // Full relative path
  fileCount: number;   // Total files in folder
  totalSize: number;   // Cumulative size in bytes
}
  • Grouping: Files are grouped by relativePath (e.g. Music/Artist/Album)
  • Counts: fileCount is the number of files in that folder
  • Sorting: Supports sort fields like name, dateAdded, dateModified, fileSize
  • Filtering: Supports mimeTypes, extensions, folder (deep path prefix), minSize/maxSize
  • Statistics: Use getFolderStatistics() for size histograms and per-type breakdowns per folder

iOS

iOS uses Apple's Photos Framework (PHAsset) for media indexing:

  • Audio, video, image queries with metadata via PHAsset properties
  • Album, artist, genre, playlist aggregation via PHAssetCollection
  • Search with CONTAINS[cd] matching (case/diacritic-insensitive)
  • Thumbnail generation via PHImageManager.requestImage()
  • Real-time changes via PHPhotoLibraryChangeObserver
  • Documents return an empty array (no Photos Framework equivalent)
  • Folders return an empty array (no equivalent aggregation)

Advanced Search

import { search } from "@obsidian_north/react-native-mediastore";

const result = await search({
  query: "beatles",
  types: ["audio", "video"],
  filter: {
    artist: "Queen",
    album: "Greatest Hits",
    mimeTypes: ["audio/*"],
    minDuration: 60_000,
  },
  sort: { field: "name", order: "asc" },
  pagination: { limit: 50, cursor: "..." },
});

console.log(`${result.totalCount} results`);

Caching

Request
 │
 ▼
Cache Lookup ─── HIT (within TTL) ──→ Return cached
 │
 ▼ (miss)
MediaStore Query
 │
 ▼
Store in Cache (LRU eviction)
 │
 ▼
Return result
 │
 ▼ (on change)
Observer fires cacheInvalidated
 │
 ▼
Cache cleared → next query goes to native DB
  • TTL: Configurable time-to-live per query (default: 30s)
  • Eviction: LRU-based when cache reaches max entries (100)
  • Invalidation: Automatic on media change events
  • Refresh: Call refresh() to manually clear all caches

Code Examples

Music Player Library

import { getAudio, getAlbums, getArtists, getAlbumArtwork } from "@obsidian_north/react-native-mediastore";

async function loadLibrary() {
  const [songs, albums, artists] = await Promise.all([
    getAudio({ field: "artist", order: "asc" }),
    getAlbums(),
    getArtists(),
  ]);

  const albumArt = albums.reduce((map, album) => {
    map[album.id] = getAlbumArtwork(album.id);
    return map;
  }, {} as Record<string, Promise<string | null>>);

  return { songs, albums, artists, albumArt };
}

Gallery with Thumbnails

import { getImages, getImageThumbnail } from "@obsidian_north/react-native-mediastore";

async function loadGallery() {
  const images = await getImages(
    { field: "dateAdded", order: "desc" },
    null,
    { limit: 100 }
  );

  return images.map((img) => ({
    id: img.id,
    uri: img.uri,
    thumbnail: getImageThumbnail(img.id, 320, 320),
    width: img.width,
    height: img.height,
  }));
}

File Manager File List

import { getDocuments, getFolders } from "@obsidian_north/react-native-mediastore";

async function loadFileManager(folder?: string) {
  const [files, folders] = await Promise.all([
    getDocuments(null, { folder }),
    getFolders(null, { folder }),
  ]);
  return { files, folders };
}

Search Screen

import { search, SearchResult } from "@obsidian_north/react-native-mediastore";
import { useState, useCallback } from "react";

function useSearch() {
  const [results, setResults] = useState<SearchResult | null>(null);
  const [loading, setLoading] = useState(false);

  const query = useCallback(async (text: string) => {
    setLoading(true);
    const res = await search({ query: text, types: ["audio", "video", "image", "document"] });
    setResults(res);
    setLoading(false);
  }, []);

  return { results, loading, query };
}

Infinite Scroll (Cursor-based)

import { getAudio } from "@obsidian_north/react-native-mediastore";
import { useState, useCallback } from "react";

const PAGE_SIZE = 30;

function useInfiniteScroll() {
  const [songs, setSongs] = useState<any[]>([]);
  const [cursor, setCursor] = useState<string | undefined>();

  const loadMore = useCallback(async () => {
    const page = await getAudio(
      { field: "name", order: "asc" },
      null,
      { limit: PAGE_SIZE, cursor }
    );
    setSongs(prev => [...prev, ...page]);
  }, [cursor]);

  return { songs, loadMore };
}

Pagination (Offset-based)

import { getVideos } from "@obsidian_north/react-native-mediastore";

async function getPage(page: number, pageSize: number = 20) {
  return getVideos(
    { field: "dateAdded", order: "desc" },
    null,
    { limit: pageSize, offset: page * pageSize }
  );
}

Album Browser

import { getAlbums, getAudio, getAlbumArtwork } from "@obsidian_north/react-native-mediastore";

async function loadAlbumBrowser() {
  const albums = await getAlbums({ field: "year", order: "desc" });

  const albumDetails = await Promise.all(
    albums.map(async (album) => ({
      ...album,
      songs: await getAudio(null, { album: album.title }),
      artwork: await getAlbumArtwork(album.id),
    }))
  );

  return albumDetails;
}

Playlist Browser

import { getPlaylists, getAudio } from "@obsidian_north/react-native-mediastore";

async function loadPlaylistSongs(playlistId: string) {
  return getAudio(null, { playlistId });
}

Reactive Auto-refresh

import { getAudio, useMediaChangeEvent } from "@obsidian_north/react-native-mediastore";
import { useState, useEffect } from "react";

function useReactiveAudio() {
  const [songs, setSongs] = useState<any[]>([]);
  const refresh = async () => { setSongs(await getAudio()); };
  useEffect(() => { refresh(); }, []);
  useMediaChangeEvent(refresh);
  return songs;
}

Folder Statistics

import { getFolderStatistics } from "@obsidian_north/react-native-mediastore";

async function analyzeStorage() {
  const folders = await getFolderStatistics();

  for (const folder of folders) {
    console.log(`${folder.path}: ${folder.fileCount} files, ${folder.totalSize} bytes`);
    console.log(`  <1MB: ${folder.histogram.lessThan1MB}`);
    console.log(`  1-10MB: ${folder.histogram.from1to10MB}`);
    console.log(`  10-100MB: ${folder.histogram.from10to100MB}`);
    console.log(`  Avg size: ${folder.averageFileSize} bytes`);
    console.log(`  Audio: ${folder.mediaTypeBreakdown.audio}, Video: ${folder.mediaTypeBreakdown.video}`);
  }
}

Incremental Indexing

import { refreshIncremental, getLastRefreshTimestamp } from "@obsidian_north/react-native-mediastore";

async function syncLibrary() {
  const lastSync = await getLastRefreshTimestamp();
  const changes = await refreshIncremental(lastSync);

  console.log(`${changes.added} new items`);
  console.log(`${changes.modified} modified items`);
  console.log(`${changes.removed} removed items`);
}

Plugin System

import { registerPlugin, getAudio, unregisterPlugin } from "@obsidian_north/react-native-mediastore";

// Register a plugin that adds star ratings based on duration
registerPlugin({
  id: "duration-rating",
  name: "Duration Rating",
  version: "1.0.0",
  extract: (item) => {
    if ("duration" in item) {
      const duration = (item as any).duration;
      const rating = duration > 300000 ? "long" : duration > 60000 ? "medium" : "short";
      return { durationRating: rating };
    }
    return {};
  },
});

// All queries now include plugin metadata
const songs = await getAudio();
// songs[0].customMetadata?.durationRating === "long" | "medium" | "short"

unregisterPlugin("duration-rating");

Improved Batch Query

import { getLibraryQuery } from "@obsidian_north/react-native-mediastore";

async function loadLibrary() {
  const result = await getLibraryQuery({
    types: ["audio", "video"],
    typePagination: {
      audio: { limit: 100, offset: 0 },
      video: { limit: 20, offset: 0 },
    },
    includeStatistics: true,
  });

  console.log(`Total: ${result.totalCount} items (${result.totalSize} bytes)`);
  console.log(`Query time: ${result.queryTime}ms`);
  console.log(`Audio: ${result.perTypeStatistics?.audio.count} items`);
  console.log(`Video: ${result.perTypeStatistics?.video.count} items`);
}

Types

AudioItem

interface AudioItem {
  id: string; uri: string; title: string;
  artist: string; album: string; albumId: string;
  genre: string | null; duration: number; size: number;
  trackNumber: number; discNumber: number; year: number;
  dateAdded: number; dateModified: number;
  composer: string | null; lyrics: string | null;
  albumArtist: string | null; isFavorite: boolean;
  playCount: number; lastPlayed: number; bookmark: number;
  bitrate: number | null; sampleRate: number | null;
  channels: number | null; encoding: string | null;
  mimeType: string; fileExtension: string; relativePath: string;
  displayName: string; contentUri: string;
}

VideoItem

interface VideoItem {
  id: string; uri: string; title: string; duration: number;
  width: number; height: number; frameRate: number | null;
  rotation: number; size: number; mimeType: string;
  relativePath: string; displayName: string; dateAdded: number;
  dateModified: number; resolution: string; orientation: number;
}

ImageItem

interface ImageItem {
  id: string; uri: string; title: string; width: number;
  height: number; orientation: number; cameraMake: string | null;
  cameraModel: string | null; dateTaken: number;
  gpsLatitude: number | null; gpsLongitude: number | null;
  mimeType: string; size: number; relativePath: string;
  displayName: string; dateAdded: number; dateModified: number;
}

DocumentItem

interface DocumentItem {
  id: string; uri: string; name: string; size: number;
  mimeType: string; extension: string; relativePath: string;
  dateAdded: number; dateModified: number;
}

Album

interface Album {
  id: string; title: string; artist: string;
  songCount: number; duration: number;
  artworkUri: string | null; dateAdded: number; year: number | null;
}

Artist

interface Artist {
  id: string; name: string;
  albumCount: number; songCount: number;
  duration: number; dateAdded: number;
}

Genre

interface Genre {
  id: string; name: string;
  songCount: number; duration: number;
}

Playlist

interface Playlist {
  id: string; name: string;
  songCount: number; duration: number;
  dateAdded: number; dateModified: number;
}

Folder

interface Folder {
  id: string; name: string; path: string;
  fileCount: number; totalSize: number;
}

SearchResult

interface SearchResult {
  audio: AudioItem[]; videos: VideoItem[];
  images: ImageItem[]; documents: DocumentItem[];
  totalCount: number; query: string;
}

LibraryResult

interface LibraryResult {
  audio: AudioItem[]; videos: VideoItem[];
  images: ImageItem[]; documents: DocumentItem[];
  totalCount: number;
}

MediaStoreStatistics

interface MediaStoreStatistics {
  totalAudio: number; totalVideo: number;
  totalImages: number; totalDocuments: number;
  totalSize: number; totalDuration: number;
}

DuplicateItem

interface DuplicateItem {
  fileHash: string; count: number;
  items: AudioItem[]; totalSize: number;
}

SortOptions

interface SortOptions { field: SortField; order: SortOrder; }
enum SortField { Name = "name", DateAdded = "dateAdded", DateModified = "dateModified", Duration = "duration", Artist = "artist", Album = "album", Year = "year", FileSize = "fileSize", Resolution = "resolution", Width = "width", Height = "height" }
enum SortOrder { Ascending = "asc", Descending = "desc" }

FilterOptions

interface FilterOptions {
  mimeTypes?: string[]; extensions?: string[]; folder?: string;
  album?: string; artist?: string; minDuration?: number;
  maxDuration?: number; minSize?: number; maxSize?: number;
  minResolution?: number; maxResolution?: number; startDate?: number;
  endDate?: number; includeHidden?: boolean; favoritesOnly?: boolean;
  playlistId?: string;
}

PaginationOptions

interface PaginationOptions { limit?: number; offset?: number; cursor?: string; }

SearchOptions

interface SearchOptions {
  query: string; types?: ("audio" | "video" | "image" | "document")[];
  sort?: SortOptions; filter?: FilterOptions; pagination?: PaginationOptions;
}

MediaChangeEvent

interface MediaChangeEvent {
  type: "added" | "removed" | "modified";
  mediaType: "audio" | "video" | "image" | "document";
  itemId: string; uri: string;
}

PermissionStatus

interface PermissionStatus { granted: boolean; audio: boolean; video: boolean; images: boolean; }

MediaStoreError

type ErrorCode =
  | "PERMISSION_DENIED" | "QUERY_FAILED" | "INVALID_ARGUMENTS"
  | "INVALID_SORT_FIELD" | "INVALID_MIME_TYPE"
  | "UNSUPPORTED_ANDROID_VERSION" | "FILE_UNAVAILABLE"
  | "CURSOR_CLOSED" | "CACHE_FAILURE" | "UNKNOWN_ERROR";
interface MediaStoreError { code: ErrorCode; message: string; details?: string; }

SizeHistogram

interface SizeHistogram {
  lessThan1MB: number;
  from1to10MB: number;
  from10to100MB: number;
  from100MBto1GB: number;
  greaterThan1GB: number;
}

FolderStatistics

interface FolderStatistics {
  id: string; name: string; path: string;
  fileCount: number; totalSize: number;
  histogram: SizeHistogram;
  mediaTypeBreakdown: MediaTypeBreakdown;
  averageFileSize: number;
}
interface MediaTypeBreakdown {
  audio: number; video: number; image: number; document: number;
}

IncrementalChanges

interface IncrementalChanges {
  added: number; modified: number;
  removed: number; timestamp: number;
}

MetadataPlugin

interface MetadataPlugin {
  id: string; name: string; version: string;
  extract: (item: AudioItem | VideoItem | ImageItem | DocumentItem)
    => Record<string, unknown> | Promise<Record<string, unknown>>;
}

LibraryQueryOptions

interface LibraryQueryOptions {
  sort?: SortOptions; filter?: FilterOptions;
  pagination?: PaginationOptions;
  types?: ("audio" | "video" | "image" | "document")[];
  typePagination?: {
    audio?: PaginationOptions; video?: PaginationOptions;
    image?: PaginationOptions; document?: PaginationOptions;
  };
  includeStatistics?: boolean;
}

LibraryQueryResult

interface LibraryQueryResult {
  audio: AudioItem[]; videos: VideoItem[];
  images: ImageItem[]; documents: DocumentItem[];
  totalCount: number; totalSize: number;
  perTypeStatistics?: LibraryPerTypeStatistics;
  queryTime: number;
}
interface LibraryPerTypeStatistics {
  audio: { count: number; totalSize: number; totalDuration: number };
  video: { count: number; totalSize: number; totalDuration: number };
  image: { count: number; totalSize: number };
  document: { count: number; totalSize: number };
}

Supported Document Types

| Format | MIME Type | |--------|-----------| | PDF | application/pdf | | DOC | application/msword | | DOCX | application/vnd.openxmlformats-officedocument.wordprocessingml.document | | XLS | application/vnd.ms-excel | | XLSX | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | | PPT | application/vnd.ms-powerpoint | | PPTX | application/vnd.openxmlformats-officedocument.presentationml.presentation | | TXT | text/plain | | EPUB | application/epub+zip | | RTF | application/rtf | | CSV | text/csv | | JSON | application/json | | XML | application/xml, text/xml | | ZIP | application/zip | | RAR | application/x-rar-compressed | | 7Z | application/x-7z-compressed |

Document queries are Android-only. iOS returns an empty array.


FAQ

Q: Does it work in Expo Go? A: No. Requires native module support (Expo Dev Build or bare React Native).

Q: Does it work with Expo Dev Build? A: Yes.

Q: Does it work on iOS? A: Yes. iOS 13.0+ is supported via the Photos Framework (PHAsset). Document queries return an empty array on iOS.

Q: Can I delete files? A: No. This module is read-only. Use expo-file-system for mutations.

Q: Can I rename files? A: No. Renames belong in a filesystem module.

Q: Can I monitor changes? A: Yes. Use useMediaChangeEvent or the native observer (ContentObserver on Android, PHPhotoLibraryChangeObserver on iOS).

Q: Can I search? A: Yes. Full-text search via search() with multi-keyword, unicode support.

Q: Does it use MediaStore? A: On Android, yes — all queries go through ContentResolver → MediaStore database. On iOS, it uses the Photos Framework (PHAsset).

Q: Does it support SD Cards? A: Yes, where indexed by the MediaStore database (Android).

Q: What Android versions are supported? A: Android 5.0+ (API 21+). Minimum SDK is 21.

Q: What iOS versions are supported? A: iOS 13.0+. Uses Photos Framework with PHPhotoLibrary authorization.

Q: Does it require MANAGE_EXTERNAL_STORAGE? A: No. Uses standard MediaStore access pattern (Android) and Photos Framework (iOS).

Q: Can I get album artwork? A: Yes. Use getAlbumArtwork(albumId).

Q: Can I get video thumbnails? A: Yes. Use getVideoThumbnail(videoId).

Q: Is it typed? A: Yes. 100% TypeScript with no any.


Project Structure

react-native-mediastore/
 ├── android/
 │   └── src/main/java/expo/modules/mediastore/
 │       ├── MediaStoreModule.kt
 │       ├── MediaStoreRepository.kt
 │       ├── MediaStoreQueryBuilder.kt
 │       ├── MediaStoreMapper.kt
 │       ├── MediaStoreObserver.kt
 │       ├── MediaStorePermissions.kt
 │       ├── MediaStoreCache.kt
 │       ├── models/
 │       ├── utils/
 │       └── extensions/
 ├── ios/
 │   ├── ExpoMediaStore.podspec
 │   ├── MediaStoreModule.swift
 │   ├── MediaStoreRepository.swift
 │   ├── MediaStoreObserver.swift
 │   └── MediaStorePermissions.swift
 ├── src/
 │   ├── index.ts
 │   ├── MediaStoreModule.ts
 │   └── MediaStoreModule.types.ts
 ├── build/
 ├── __tests__/
 │   └── types.test.ts
 ├── example/
 │   ├── app/ (Music, Gallery, Documents, Search tabs)
 │   └── package.json
 ├── .github/workflows/
 │   ├── ci.yml
 │   └── release.yml
 ├── expo-module.config.json
 ├── package.json
 └── tsconfig.json

Roadmap

2.0
  ✓ iOS support (Photos Framework: PHAsset, PHImageManager, PHPhotoLibraryChangeObserver)
  ✓ Thumbnail generation (video + image) with configurable dimensions
  ✓ Album artwork extraction via ContentResolver / PHImageManager
  ✓ Batch library query (getLibrary)
  ✓ Reactive subscriptions (useMediaChangeEvent hook)
  ✓ Duplicate detection via size + MD5 hash
  ✓ Document statistics in getStatistics
  ✓ Cache auto-invalidation on media change events
  ✓ Pagination actually applied (limit/offset/cursor)
  ✓ SQL injection prevention (escapeSql)
  ✓ ExifInterface for camera make/model metadata

2.1 (Current)
  ✓ Folder statistics (size histograms)
  ✓ Incremental indexing (delta-only refresh)
  ✓ Plugin hooks for custom metadata
  ✓ Batch library query improvements

3.0
  ☐ AI semantic search
  ☐ Smart albums / auto-playlists
  ☐ EXIF utilities (editing GPS, date)
  ☐ Waveform extraction (audio)
  ☐ Face clustering (images)
  ☐ OCR indexing (documents)

4.0
  ☐ Desktop (Electron / Tauri)
  ☐ Cloud sync abstraction
  ☐ Cross-platform unified API

Contributing

See CONTRIBUTING.md for details.


Changelog

See CHANGELOG.md for release history.


License

MIT