Minimizing Blast Radius When You Must Use Unstable Internals

Two files in Photo Sweep touch Nextcloud internals that have no public equivalent — OC\Files\Search\* and OCA\Files_Trashbin. Both check what's available first and degrade rather than break.

Minimizing Blast Radius When You Must Use Unstable Internals

Nextcloud's stable, documented interface is OCP — the OCP\* namespace, versioned and covered by a compatibility promise across major releases. Everything outside it, the OC\* internals and the classes owned by other apps, can change shape or vanish in any release without warning. Photo Sweep is built almost entirely on OCP. Two files are the exception, and they're the exception for reasons that don't have a public workaround.

MediaFinder and TrashService are the only two files touching unstable internals — both with a fallback
MediaFinder and TrashService are the only two files touching unstable internals — both with a fallback

If either internal moves in a future release, exactly one file needs a look — not the whole codebase.

MediaFinder: paging a search with no public factory

Nextcloud's file search is exposed publicly through Folder::search(), which takes an ISearchQuery. There is no public way to construct one with pagination and ordering — the concrete classes that implement it, SearchQuery, SearchComparison, SearchBinaryOperator, SearchOrder, all live under OC\Files\Search\*, the internal namespace. To ask for "the next 500 media files, ordered by file id" at all, MediaFinder has to import from there:

use OC\Files\Search\SearchBinaryOperator;
use OC\Files\Search\SearchComparison;
use OC\Files\Search\SearchOrder;
use OC\Files\Search\SearchQuery;

The class comment is upfront about why this is contained rather than avoided: "Constructing an ISearchQuery needs the OC\Files\Search implementations — there is no public factory for them — so the coupling is real and is better concentrated in one small file than sprinkled through the indexer."

If those internal classes move, findBatch() catches the failure and falls back to the fully public Folder::searchByMime():

public function findBatch(Folder $scope, int $offset, int $limit): array {
    if (class_exists(SearchQuery::class)) {
        try {
            return $this->searchPaged($scope, $offset, $limit);
        } catch (\Throwable $e) {
            $this->logger->warning('Paged media search failed, falling back to searchByMime', [...]);
        }
    }
    return $this->searchByMimeFallback($scope, $offset, $limit);
}

The fallback is slower — it loads every matching node before slicing the page in memory, rather than letting the database do the pagination — but it returns the same pages in the same order, sorted by file id after the fact. The scan degrades gracefully on a large library; it doesn't break.

TrashService: restoring has no public interface at all

Nextcloud's trash is a separate optional app, files_trashbin. Deleting a file into it is entirely public — a plain $file->delete(). Restoring a specific file from it by id has no OCP equivalent whatsoever; the only way to do it is OCA\Files_Trashbin\Trash\ITrashManager, owned by another app entirely.

private const TRASH_MANAGER = 'OCA\\Files_Trashbin\\Trash\\ITrashManager';

public function isAvailable(): bool {
    return $this->appManager->isEnabledForUser('files_trashbin')
        && interface_exists(self::TRASH_MANAGER);
}

Every reference to that interface lives in TrashService, and the app asks this class whether restoring is even possible before promising a user it can bring a file back — critically, before anyone trashes a thousand photos expecting the option to exist. If files_trashbin is disabled, or the interface has moved, isAvailable() returns false and the UI says so plainly, rather than the app discovering the gap only when someone actually tries to hit undo.

The pattern

Neither file pretends the internal dependency isn't there, and neither scatters it. Two rules make this manageable rather than fragile:

  1. One file per internal dependency, so a future Nextcloud release that moves OC\Files\Search\* or restructures the trashbin app touches exactly one file's worth of Photo Sweep, not an unknown set scattered across the codebase.
  2. A capability check and a degrade path, not a hard failure. Both classes ask "is this even available right now" before using it, and both have a defined, honest answer for when it isn't — a slower search, or a clear "restoring isn't possible on this server."

Everything else in the app — ninety-plus percent of it — is plain OCP, with none of this ceremony, because none of it needs it. Confining the unstable parts to a visibly small surface is what makes the rest of the codebase trustworthy without auditing it file by file.

Series: Photo Sweep.