Collections (Step H.2)
Phase: H (Smart Features) Step: H.2 Since: 0.14.0
Overview
Collections are named groups of media items that curators can manually assemble (bulk-add from search) or that derive from saved smart playlist rules. Collections appear alongside libraries in the UI, support defined sort order, and can be nested.
Model
Entity: Collection
final class Collection
{
public function __construct(
public readonly string $id,
public readonly string $name,
public readonly string $libraryId,
public readonly ?string $smartPlaylistId, // null = manual
public readonly ?string $parentId, // null = top-level
public readonly int $sortOrder = 0,
public readonly \DateTimeImmutable $createdAt,
public readonly \DateTimeImmutable $updatedAt,
) {}
public function isSmart(): bool // true if smartPlaylistId !== null
}DTO: CollectionWithItems
Hydrated DTO containing the collection entity plus its full list of media items.
final class CollectionWithItems
{
public function __construct(
public readonly Collection $collection,
public readonly array $items, // MediaItem[]
public readonly int $total,
) {}
}Database Schema
CREATE TABLE collections (
id CHAR(36) NOT NULL PRIMARY KEY,
name VARCHAR(128) NOT NULL,
library_id CHAR(36) NOT NULL,
smart_playlist_id CHAR(36) NULL,
parent_id CHAR(36) NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX idx_col_library (library_id),
INDEX idx_col_smart_pl (smart_playlist_id),
INDEX idx_col_parent (parent_id)
) ENGINE=InnoDB CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE collection_items (
collection_id CHAR(36) NOT NULL,
media_item_id CHAR(36) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
added_at DATETIME NOT NULL,
PRIMARY KEY (collection_id, media_item_id),
INDEX idx_ci_media (media_item_id)
) ENGINE=InnoDB CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;API Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/collections | List all collections |
| POST | /api/v1/collections | Create collection |
| GET | /api/v1/collections/{id} | Get collection with items |
| PUT | /api/v1/collections/{id} | Update collection |
| DELETE | /api/v1/collections/{id} | Delete collection |
| POST | /api/v1/collections/{id}/items/{mediaItemId} | Add item to collection |
| DELETE | /api/v1/collections/{id}/items/{mediaItemId} | Remove item from collection |
| POST | /api/v1/collections/{id}/bulk-add | Bulk-add from search results |
| POST | /api/v1/collections/{id}/refresh | Re-evaluate smart collection |
| GET | /api/v1/libraries/{libraryId}/collections | Collections for library |
Creating a Collection
POST /api/v1/collections
{
"name": "Oscar Winners 2020-2024",
"library_id": "lib-uuid-here",
"parent_id": null,
"sort_order": 0
}Response (201):
{
"collection": {
"id": "col-uuid",
"name": "Oscar Winners 2020-2024",
"library_id": "lib-uuid-here",
"smart_playlist_id": null,
"parent_id": null,
"sort_order": 0,
"is_smart": false,
"created_at": "2024-01-15 10:30:00",
"updated_at": "2024-01-15 10:30:00"
}
}Bulk-adding Items
POST /api/v1/collections/col-uuid/bulk-add
{
"media_item_ids": ["media-1", "media-2", "media-3"]
}The client first searches for items, then passes the resolved IDs to bulk-add. The server trusts the client to have already validated those IDs.
Smart Collection Sync Algorithm
Smart collections auto-sync from saved playlist rules when:
- A library scan completes (
LibraryUpdatedevent) - A user manually triggers refresh via
POST /api/v1/collections/{id}/refresh
Sync uses diff (not wipe-and-rebuild):
public function refreshSmartCollection(string $id): void
{
// 1. Load collection and its smart playlist
$playlist = $this->playlistRepo->findById($collection->smartPlaylistId);
// 2. Evaluate rules against library
$matchedItems = $this->engine->evaluateOnScan($playlist->getRules(), ...);
// 3. Diff: items in new results but not in collection → add
$toAdd = array_diff($newIds, $currentIds);
// 4. Diff: items in collection but not in new results → remove
$toRemove = array_diff($currentIds, $newIds);
// 5. Add new items with sort order at end
foreach ($toAdd as $mediaItemId) {
$this->itemRepo->insert($id, $mediaItemId, $sortOrder++);
}
// 6. Remove non-matching items (preserves curator order on retained items)
foreach ($toRemove as $mediaItemId) {
$this->itemRepo->delete($id, $mediaItemId);
}
}This approach preserves curator-applied sort orders on retained items while adding new matches and removing items that no longer match.
Integration with SmartPlaylistRefreshHandler
When SmartPlaylistRefreshHandler::onLibraryUpdated() re-evaluates a smart playlist, it also calls refreshSmartCollection() for any collection linked to that playlist:
private function refreshCollectionsForPlaylist(string $smartPlaylistId): void
{
$collections = $this->collectionRepo->findBySmartPlaylistId($smartPlaylistId);
foreach ($collections as $collection) {
$this->collectionManager->refreshSmartCollection($collection->id);
}
}Architecture
CollectionController (REST API)
└── CollectionManager (orchestrator)
├── CollectionRepository (CRUD)
├── CollectionItemRepository (membership)
├── SmartPlaylistEngine (rule evaluation)
├── SmartPlaylistRepository (playlist lookup)
└── ItemRepository (media item lookup)Extending Collection Types
To add a new collection type (e.g., "Recently Added" auto-collection):
- Add a
typecolumn to thecollectionstable via migration - Add
$typeto theCollectionentity - Add type-specific logic in
CollectionManager(e.g., query recently added items) - Document the new type in this file
Testing
- Unit tests:
tests/unit/Collections/ - Integration test:
tests/integration/Collections/CollectionCrudTest.php - Coverage target:
CollectionManager,CollectionRepository,CollectionItemRepository≥ 85%