Playlist Management
This chapter is based on the following source files:
internal/models/models.go-- Playlist / PlaylistSong model definitions, type constants, CanAddSong validationinternal/database/migrations/0001_init.sql-- playlists / playlist_songs table schema and constraintsinternal/database/playlist_repository.go-- Playlist repository (CRUD, AutoCreate, BatchDelete, etc.)internal/database/playlist_song_repository.go-- Playlist-song association repositoryinternal/services/playlist_service.go-- Playlist service layerinternal/services/backup_service.go-- Playlist backup and restoreinternal/services/song_service.go-- Trigger logic for auto-creating playlists after a scan completesinternal/handlers/playlist.go-- Playlist HTTP handlersinternal/handlers/backup.go-- Backup import/export handlersinternal/handlers/scan.go-- Handler for the auto-create-playlists toggle config
Table of Contents
- Playlist Type System
- Built-in Playlists
- Playlist CRUD
- Song Association Management
- Ordering System
- Auto-Created Playlists
- Cover Art Management
- Backup and Restore
- Touch Mechanism
1. Playlist Type System
Section sources: internal/models/models.go
Playlists are distinguished into two types by the type field, enforced at the database level by the CHECK(type IN ('normal', 'radio')) constraint:
| Type constant | Value | Description |
|---|---|---|
PlaylistTypeNormal | "normal" | Normal playlist, holding local and network songs |
PlaylistTypeRadio | "radio" | Radio playlist, holding only radio/broadcast stations |
CanAddSong Type Validation
When adding a song to a playlist, Playlist.CanAddSong(songType) performs a type-compatibility check:
| Playlist type | Allowed song types | Rejected song types |
|---|---|---|
normal | local, remote | radio |
radio | radio | local, remote |
The service-layer AddSong and AddSongs methods both call this validation before performing the insert, returning an error rather than silently skipping when the type does not match. In batch add (AddSongs), songs with incompatible types are counted in the skipped count.
Type Is Immutable
Once a playlist is created, the type field may not be modified. The Update method validates with ValidateForUpdate(), which only checks that name is non-empty and does not validate the type field; the repository-layer UpdatePlaylist SQL also does not include an update to the type column.
Label System
Playlists use the labels field (a JSON string array) to mark special properties; two label constants are currently defined:
| Label constant | Value | Purpose |
|---|---|---|
PlaylistLabelBuiltIn | "built_in" | Marks a built-in playlist; not deletable, updates restricted |
PlaylistLabelAutoCreated | "auto_created" | Marks a scan-auto-created playlist; cleared and rebuilt on each scan |
In the database, the labels field is stored as JSON text (e.g. ["built_in"]) and is parsed for matching at query time via SQLite's json_each function.
2. Built-in Playlists
Section sources: internal/models/models.go, internal/services/playlist_service.go, internal/database/playlist_repository.go
The system pre-provisions two built-in playlists via database migration:
| ID | Name | Type | Labels | Purpose |
|---|---|---|---|---|
| 1 | Favorites | normal | ["built_in"] | User-favorited normal songs |
| 2 | Radio Favorites | radio | ["built_in"] | User-favorited radio programs |
Built-in Playlist Protection Mechanism
Deletion protection: before deleting, PlaylistService.Delete checks the playlist's labels array; if it finds "built_in", it directly returns the error "cannot delete built-in playlist". Batch delete (BatchDelete) skips built-in playlists at the repository layer via the SQL clause NOT EXISTS (SELECT 1 FROM json_each(labels) WHERE value = ?).
Update protection: PlaylistService.Update only allows updating the two fields cover_path and cover_url for built-in playlists; the remaining fields (name, description, labels, type) are forced to keep their original values.
3. Playlist CRUD
Section sources: internal/handlers/playlist.go, internal/services/playlist_service.go, internal/database/playlist_repository.go
Create Playlist
POST /api/v1/playlists
The creation flow guarantees atomicity via a transaction at the repository layer:
Validate()checks that name is non-empty and type is valid- Within the transaction,
FindPlaylistByNamechecks for a playlist with the same name (regardless of type); on conflict it returnsErrPlaylistNameConflict(HTTP 409) GetMaxPlaylistPositionobtains the current maximum position; the new playlist's position = max + 1CreatePlaylistinserts the record and returns the auto-increment ID
The database layer has the idx_playlists_name_unique unique index as a backstop, so even under concurrency no same-name playlists can appear.
Read Playlists
GET /api/v1/playlists-- list query, supportingtype(normal/radio) filtering andlimit/offsetpagination; the response includes thetotalcountGET /api/v1/playlists/{id}-- single query, returning complete playlist info (includingsong_count)
The song count is computed in real time at query time via the LEFT JOIN subquery (SELECT playlist_id, COUNT(*) FROM playlist_songs GROUP BY playlist_id), with no need to maintain a redundant field.
Update Playlist
PUT /api/v1/playlists/{id}
Updatable fields: name, description, cover_path, cover_url. When renaming, a same-name check (excluding the playlist's own ID) is performed within the transaction; conflicts return 409. Built-in playlists are subject to the protection rules.
Delete Playlist
DELETE /api/v1/playlists/{id}-- single delete; built-in playlists cannot be deletedPOST /api/v1/playlists/batch-delete-- batch delete, request body{ids: [...]}; built-in playlists are skipped, returns{deleted: N}for the actual number deleted
When a playlist is deleted, the playlist_songs association records are automatically cascade-cleaned by the foreign key ON DELETE CASCADE. After a successful delete, the service layer calls removeCoverIfUnreferenced to clean up unreferenced cover files.
4. Song Association Management
Section sources: internal/database/playlist_song_repository.go, internal/services/playlist_service.go
Data Table Schema
Diagram sources: internal/database/migrations/0001_init.sql
playlist_songs
├── id INTEGER PRIMARY KEY AUTOINCREMENT
├── playlist_id INTEGER NOT NULL → FK playlists(id) ON DELETE CASCADE
├── song_id INTEGER NOT NULL → FK songs(id) ON DELETE CASCADE
├── position INTEGER NOT NULL
├── added_at DATETIME DEFAULT CURRENT_TIMESTAMP
└── UNIQUE(playlist_id, song_id) -- prevent the same song from being added to the same playlist twiceThe UNIQUE(playlist_id, song_id) constraint is the core deduplication mechanism. Batch add uses the INSERT OR IGNORE statement; existing associations are silently skipped and counted in skipped.
Add Songs
POST /api/v1/playlists/{id}/songs, request body {song_ids: [...]}
Batch add flow (PlaylistService.AddSongs):
- Fetch playlist info and confirm the playlist exists
- Query the types of all candidate songs at once (
ListTypesByIDs), avoiding an O(N) per-song lookup - Filter out incompatible types with
CanAddSong; incompatible ones are counted inskipped MaxPositionobtains the current maximum position as the starting valueAddSongsBatchbatch-inserts within a single transaction, positions accumulating from startPos+1, withINSERT OR IGNOREskipping existing associations
Returns {added: N, skipped: M} so the client knows the actual numbers added and skipped.
Remove Song
DELETE /api/v1/playlists/{id}/songs/{songId}
Deletes the corresponding row from the association table; returns ErrNotFound when it does not exist. Does not affect the song's own data.
Replace Song
ReplaceSong is used in source-switching scenarios (e.g. updating the association after a remote song switches source): within a transaction it looks up the old song's position, deletes the old association, and inserts the new song at the same position, keeping the playback order unchanged. The SQL body replaceSongInPlaylistTx is designed as a reusable internal function that can either start a transaction on its own or be embedded within an outer transaction, avoiding SQLITE_BUSY caused by nested transactions.
Query Songs Contained in a Playlist
GET /api/v1/playlists/{id}/songs, supporting limit/offset pagination
Songs are returned in ascending position order, and the response also includes the total count. The repository layer provides two query methods: GetSongs (full) for internal statistics scenarios, and GetSongsPaginated (paginated) for API responses. The ListPlaylistsContainingSong method performs a reverse query for all normal playlist IDs containing a given song, used to locate playlists that need updating during cache conversion.
5. Ordering System
Section sources: internal/services/playlist_service.go, internal/database/playlist_repository.go, internal/database/playlist_song_repository.go
Ordering Between Playlists (ReorderPlaylists)
PUT /api/v1/playlists/reorder, request body {playlist_ids: [3, 1, 2, ...]}
The frontend passes the complete list of playlist IDs in the desired order. The service layer validates that the count passed in matches the total number of playlists in the database (to prevent omissions or extras), then updates position to 1..N in list order within a transaction.
The default ordering rule is position ASC, updated_at DESC (when positions are equal, the most recently updated comes first).
Ordering Songs Within a Playlist (ReorderPlaylistSongs)
PUT /api/v1/playlists/{id}/songs/reorder, request body {song_ids: [5, 3, 8, ...]}
Likewise requires that the number of song IDs passed in matches the playlist's actual song count. Within a transaction, each song's position is updated to 1..N in order; an error is returned if a song does not exist.
Transaction Guarantee
Both ordering operations complete within the repository layer's runInTx. If the underlying connection is already a *sql.Tx (e.g. within a UnitOfWork transaction), it is reused directly rather than nesting a transaction; when the underlying connection is a *sql.DB, a new transaction is opened automatically. This guarantees the atomicity of batch position updates: either all succeed or all roll back.
6. Auto-Created Playlists
Section sources: internal/database/playlist_repository.go (AutoCreate method), internal/services/song_service.go (trigger logic), internal/handlers/scan.go (config toggle)
Trigger Timing
After a scan completes, SongService checks the scan_auto_create_playlists config (default true) and, when enabled, calls runAutoCreatePlaylists. This method reads the scan_playlist_mode config (default directory) and then calls PlaylistRepository.AutoCreate in that mode.
Config Toggles
| Config endpoint | Config key | Default | Description |
|---|---|---|---|
/settings/scan-auto-create-playlists | scan_auto_create_playlists | true | Master switch: whether to auto-create directory playlists after a scan |
/settings/scan-playlist-mode | scan_playlist_mode | directory | Merge mode: directory (a separate playlist per folder) / top_level (merge by top-level directory) / bubble_up (songs bubble up to join all parent-directory playlists) |
AutoCreate Core Flow
The entire operation completes within a single transaction:
- Query all local songs and group them into directories by
file_path - Directory aggregation: merge by
scan_playlist_mode—directorymakes each directory its own playlist;top_levelmerges into the top-level directory;bubble_upadds each song to all of its parent-directory playlists at once - Delete old auto playlists: clear all playlists with the
auto_createdlabel (CASCADE automatically cleans up associations) - Smart naming:
- Compute the common prefix of all directories and extract the baseName of the relative path as the playlist name
- Disambiguate same-name directories: append a parent-path suffix (e.g.
Album - Artist1/Album) - On conflict with an existing playlist, append a
(auto)/(auto 2)suffix
- Batch-insert playlists with the label set to
["auto_created"] - Batch-insert associations, 500 rows per batch, with songs sorted by numeric prefix (consistent with the Flutter frontend's display order)
- Randomly pick a cover: randomly select one of the playlist's songs that has a cover as the playlist cover
Auto-created playlists carry the auto_created label and are all cleared and rebuilt on each scan, keeping them in sync with the disk directory structure.
Song Ordering Rule
Songs within an auto-created playlist are sorted by numeric prefix (lessSongByNumberThenTitle), replicating the Flutter frontend's ordering logic:
- Both titles contain a number -- the smaller value comes first; when equal, sort alphabetically by title
- Only one contains a number -- the one with a number comes first
- Neither contains a number -- sort case-insensitively by title
Fault-Tolerant Design
When runAutoCreatePlaylists fails, it only logs a slog.Warn and does not affect the scan's "completed" status. The next scan will retry creation, avoiding blocking the entire scan flow due to a playlist-creation failure.
7. Cover Art Management
Section sources: internal/handlers/playlist.go, internal/services/playlist_service.go
Cover Storage
Playlist cover art is managed via two fields:
| Field | Description |
|---|---|
cover_path | Local file path (json:"-", not exposed to the client); highest priority |
cover_url | External URL, used when the local cover does not exist |
Upload Cover
POST /api/v1/playlists/{id}/cover (multipart/form-data)
- Limits the upload file to 10 MB
- Validates the format: only jpg/jpeg/png/gif/bmp/webp are supported
- Calls
MetadataExtractor.SaveCoverto save it locally - Updates the playlist's
cover_pathand simultaneously clearscover_url
Get Cover
GET /api/v1/playlists/{id}/cover
Three-level fallback strategy:
- Local cover: when
cover_pathis non-empty and the file exists, return it directly and setCache-Control: public, max-age=31536000(one-year cache) - External URL: when
cover_urlis non-empty, proxy-forward the remote resource - Song cover fallback: take the first 20 songs in the playlist and return the cover of the first song that has a local cover
8. Backup and Restore
Section sources: internal/services/backup_service.go, internal/handlers/backup.go, internal/models/backup.go
Export (ExportPlaylists)
GET /api/v1/playlists/export
Export flow:
- Fetch all playlists (unpaginated)
- For each playlist, fetch all songs and convert them to the
BackupSongstructure (including metadata such asplugin_entry_pathanddedup_key) - Wrap them into
BackupData, settingversion: 1and the export timestamp - Set the response header
Content-Disposition: attachment; filename="songloft-backup-20260612.json"to trigger a browser download
Exported JSON structure:
BackupData
├── version: 1
├── exported_at: "2026-06-12T..."
└── playlists[]
├── name, type, description, labels
└── songs[]
├── type, title, artist, album, duration
├── file_path -- local song path (used for matching on import)
├── url, cover_url -- network song info
├── plugin_entry_path -- source plugin identifier
└── dedup_key -- deduplication keyImport (ImportPlaylists)
POST /api/v1/playlists/import (multipart/form-data, limited to 32 MB)
Import completes within a single transaction; the core strategy is merge rather than overwrite:
Playlist matching: an exact lookup by name; merge songs if it exists, otherwise create a new playlist. Import does not create playlists with the built_in label (they are filtered out).
Song matching strategy (handled separately by type):
| Song type | Matching method | Behavior on no match |
|---|---|---|
local | Look up in the local library by file_path | Skip (file not in the library) |
remote/radio | First look up by the plugin_entry_path + dedup_key combination | Create a new song via UpsertRemote |
Deduplication mechanism: plugin_entry_path + dedup_key is the uniqueness identifier for network songs, guaranteed by the database's idx_songs_dedup_key_unique partial unique index (WHERE dedup_key != ''). Association inserts use AddSongIgnore (INSERT OR IGNORE); songs already in the playlist are silently skipped.
Import result returns ImportResult:
{
"playlists_created": 2,
"playlists_merged": 1,
"songs_created": 15,
"songs_matched": 30,
"songs_skipped": 3
}9. Touch Mechanism
Section sources: internal/services/playlist_service.go, internal/database/playlist_repository.go
POST /api/v1/playlists/{id}/touch
The Touch operation only updates the playlist's updated_at timestamp to the current time, without modifying any other field. Its purpose is to record the playlist's last play/access time, so that when sorting by updated_at DESC the most recently played playlists come first.
The repository-layer implementation is simple and efficient: it executes UPDATE playlists SET updated_at = ? WHERE id = ?, returning ErrNotFound when the number of affected rows is 0.
Typical use case: the frontend calls Touch when the user clicks to play a playlist, so that the playlist list sorted by "recently played" reflects the true access order. Unlike Update, Touch does not trigger same-name checks or data validation and has minimal overhead.
