Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/local-music.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,54 @@ is an ordinary `dart:io` walk. What differs is where the path comes from:
artist instead. FLAC avoids that because Linthra reads its comment block
itself and keeps the field names.

## The library keeps itself up to date (Linux)

On Linux, Linthra watches the folders you selected and refreshes the library
when something under one of them changes. Adding an album, deleting a track, or
renaming a folder shows up without pressing Rescan.

What that means in practice:

- **Only the folders you chose are watched**, recursively. Nothing else on the
machine is looked at, and removing a folder from the list releases its watch
immediately.
- **A burst is one refresh, not hundreds.** Copying a 12-track album produces
dozens of filesystem events; Linthra waits for the copying to go quiet and
then refreshes once. A long copy that never goes quiet (over a slow network
mount, say) still refreshes periodically while it runs, so the album fills in
rather than appearing only at the end.
- **The refresh is the ordinary incremental scan**, the same one the Rescan
button runs, so it re-reads only the files that actually changed. There is no
separate "live update" path that could disagree with a manual rescan.
- **Files that are not music are ignored.** A downloader's `.part` files, cover
art, `.nfo` / `.log` / `.cue` sidecars and editor swap files change constantly
while music is being added and cannot change the library, so they do not
trigger anything.
- **Nothing is written to your folders.** Watching only reads, exactly like
scanning.

### When watching is not available

Watching is best-effort, and Linthra says so rather than pretending the library
is live:

- **Linux uses inotify, which has a per-user watch limit.** A very large library
on a machine with a low `fs.inotify.max_user_watches` can exhaust it. That
folder is then simply not watched; the others still are, and **manual Rescan
keeps working exactly as before**. Raising the limit is a system setting
(`sysctl fs.inotify.max_user_watches`), not something an app can do for you.
- **Many network filesystems cannot be watched at all** (NFS and SMB do not
report changes another machine made). Those folders need a manual rescan.
- **A folder on an unmounted drive** cannot be watched until it comes back.
Rescanning or re-selecting retries it.
- **Hidden files and folders are not watched.** `.DS_Store` and the bookkeeping
folders sync tools scatter around (`.stfolder`, `.stversions`) change on
someone else's schedule; music inside a hidden folder is still found by a
manual rescan.
- **Android does not use this at all.** Its local library is a Storage Access
Framework tree or a MediaStore query rather than a directory, so there is no
path to watch.

## Rescans only read what changed

A scan of a real library spends nearly all of its time opening files and
Expand Down
9 changes: 9 additions & 0 deletions lib/app/application_lifecycle.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import '../data/repositories/playback_preferences_provider.dart';
import '../data/repositories/playback_session_store_provider.dart';
import '../data/repositories/playlist_repository_provider.dart';
import '../data/repositories/remote_cache_index_provider.dart';
import '../features/library/local_library_watch_service.dart';
import '../features/player/media_artwork_providers.dart';
import '../features/player/player_providers.dart';
import '../features/settings/audiobookshelf/audiobookshelf_settings_controller.dart';
Expand Down Expand Up @@ -253,6 +254,14 @@ Future<ApplicationHandle> bootstrapApplication(
container.read(mediaArtworkPrewarmServiceProvider);
container.read(smartPrecacheServiceProvider);
container.read(remotePrebufferServiceProvider);
// Filesystem watching for the local library (#409). Reading it opens
// watches on the folders already selected and keeps them in step as the
// selection changes; the watches are released with the container, so a
// shutdown gives back its inotify descriptors rather than leaving them to
// the process exit. Inert on Android, whose local library is a SAF tree
// rather than a directory, and harmless when the kernel refuses a watch:
// the folder is simply not live and manual refresh is untouched.
container.read(localLibraryWatchServiceProvider);
// Loads and prunes the credential-free remote-cache manifest off the
// first-frame path. Owned rather than merely unawaited: it writes to the
// app-support directory, so a restart must not race a prune still in
Expand Down
78 changes: 78 additions & 0 deletions lib/core/sources/local/local_directory_watch.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import 'dart:async';
import 'dart:io';

/// One change the filesystem reported under a watched folder.
///
/// Deliberately thin: the watcher does not care *what* happened, only that
/// something under a music folder did, because the answer either way is the
/// same incremental rescan. Keeping the event this small also means the fake a
/// test drives it with is a two-line class rather than a re-implementation of
/// inotify.
class LocalDirectoryChange {
const LocalDirectoryChange(this.path);

/// The path the platform named. Absolute for a real watch; whatever the test
/// supplied for a synthetic one.
final String path;

@override
String toString() => 'LocalDirectoryChange($path)';
}

/// Opens a recursive watch on one folder.
///
/// The seam through which library watching touches the OS, in the same shape
/// as [AudioFileScanner] and [LocalMetadataReader], so the debounce, failure
/// and disposal rules can all be exercised without a real filesystem or a real
/// inotify budget.
///
/// Implementations either return a stream or throw. Throwing is a normal,
/// expected outcome: the folder may not exist, the platform may not support
/// watching, or the kernel may be out of watch descriptors.
abstract interface class DirectoryWatchFactory {
/// A stream of changes under [root], recursively.
///
/// Throws when a watch cannot be opened at all. The stream may also emit an
/// error later, which means the same thing: this folder is no longer being
/// watched.
Stream<LocalDirectoryChange> watch(String root);
}

/// The production [DirectoryWatchFactory]: `dart:io`, which on Linux is
/// inotify.
///
/// Recursive because a music library is a tree of artist and album folders and
/// the interesting change is usually several levels down. `dart:io` adds
/// watches for subfolders as it sees them created, so an album copied into a
/// new folder is noticed without re-opening the watch.
class IoDirectoryWatchFactory implements DirectoryWatchFactory {
const IoDirectoryWatchFactory();

@override
Stream<LocalDirectoryChange> watch(String root) {
if (!FileSystemEntity.isWatchSupported) {
throw const FileSystemException('filesystem watching is not supported');
}
return Directory(root)
.watch(recursive: true)
.map((FileSystemEvent event) => LocalDirectoryChange(event.path));
}
}

/// A [DirectoryWatchFactory] for the platforms where watching a path is not the
/// right question: Android, whose local library is a Storage Access Framework
/// tree or a MediaStore query rather than a directory Linthra may walk.
///
/// It refuses every root, which the watcher treats exactly as it treats a
/// kernel that ran out of watches: nothing is watched, manual refresh is
/// untouched, and the app says so rather than pretending it is live.
class UnsupportedDirectoryWatchFactory implements DirectoryWatchFactory {
const UnsupportedDirectoryWatchFactory();

@override
Stream<LocalDirectoryChange> watch(String root) {
throw const FileSystemException(
'filesystem watching is not used on this platform',
);
}
}
Loading
Loading