Skip to content

Playlist Management

This chapter is based on the following source files:

  • internal/models/models.go -- Playlist / PlaylistSong model definitions, type constants, CanAddSong validation
  • internal/database/migrations/0001_init.sql -- playlists / playlist_songs table schema and constraints
  • internal/database/playlist_repository.go -- Playlist repository (CRUD, AutoCreate, BatchDelete, etc.)
  • internal/database/playlist_song_repository.go -- Playlist-song association repository
  • internal/services/playlist_service.go -- Playlist service layer
  • internal/services/backup_service.go -- Playlist backup and restore
  • internal/services/song_service.go -- Trigger logic for auto-creating playlists after a scan completes
  • internal/handlers/playlist.go -- Playlist HTTP handlers
  • internal/handlers/backup.go -- Backup import/export handlers
  • internal/handlers/scan.go -- Handler for the auto-create-playlists toggle config

Table of Contents

  1. Playlist Type System
  2. Built-in Playlists
  3. Playlist CRUD
  4. Song Association Management
  5. Ordering System
  6. Auto-Created Playlists
  7. Cover Art Management
  8. Backup and Restore
  9. 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 constantValueDescription
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 typeAllowed song typesRejected song types
normallocal, remoteradio
radioradiolocal, 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 constantValuePurpose
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:

IDNameTypeLabelsPurpose
1Favoritesnormal["built_in"]User-favorited normal songs
2Radio Favoritesradio["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:

  1. Validate() checks that name is non-empty and type is valid
  2. Within the transaction, FindPlaylistByName checks for a playlist with the same name (regardless of type); on conflict it returns ErrPlaylistNameConflict (HTTP 409)
  3. GetMaxPlaylistPosition obtains the current maximum position; the new playlist's position = max + 1
  4. CreatePlaylist inserts 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, supporting type (normal/radio) filtering and limit/offset pagination; the response includes the total count
  • GET /api/v1/playlists/{id} -- single query, returning complete playlist info (including song_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 deleted
  • POST /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 twice

The 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):

  1. Fetch playlist info and confirm the playlist exists
  2. Query the types of all candidate songs at once (ListTypesByIDs), avoiding an O(N) per-song lookup
  3. Filter out incompatible types with CanAddSong; incompatible ones are counted in skipped
  4. MaxPosition obtains the current maximum position as the starting value
  5. AddSongsBatch batch-inserts within a single transaction, positions accumulating from startPos+1, with INSERT OR IGNORE skipping 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 endpointConfig keyDefaultDescription
/settings/scan-auto-create-playlistsscan_auto_create_playliststrueMaster switch: whether to auto-create directory playlists after a scan
/settings/scan-playlist-modescan_playlist_modedirectoryMerge 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:

  1. Query all local songs and group them into directories by file_path
  2. Directory aggregation: merge by scan_playlist_modedirectory makes each directory its own playlist; top_level merges into the top-level directory; bubble_up adds each song to all of its parent-directory playlists at once
  3. Delete old auto playlists: clear all playlists with the auto_created label (CASCADE automatically cleans up associations)
  4. 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
  5. Batch-insert playlists with the label set to ["auto_created"]
  6. Batch-insert associations, 500 rows per batch, with songs sorted by numeric prefix (consistent with the Flutter frontend's display order)
  7. 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:

  1. Both titles contain a number -- the smaller value comes first; when equal, sort alphabetically by title
  2. Only one contains a number -- the one with a number comes first
  3. 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:

FieldDescription
cover_pathLocal file path (json:"-", not exposed to the client); highest priority
cover_urlExternal URL, used when the local cover does not exist

Upload Cover

POST /api/v1/playlists/{id}/cover (multipart/form-data)

  1. Limits the upload file to 10 MB
  2. Validates the format: only jpg/jpeg/png/gif/bmp/webp are supported
  3. Calls MetadataExtractor.SaveCover to save it locally
  4. Updates the playlist's cover_path and simultaneously clears cover_url

Get Cover

GET /api/v1/playlists/{id}/cover

Three-level fallback strategy:

  1. Local cover: when cover_path is non-empty and the file exists, return it directly and set Cache-Control: public, max-age=31536000 (one-year cache)
  2. External URL: when cover_url is non-empty, proxy-forward the remote resource
  3. 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:

  1. Fetch all playlists (unpaginated)
  2. For each playlist, fetch all songs and convert them to the BackupSong structure (including metadata such as plugin_entry_path and dedup_key)
  3. Wrap them into BackupData, setting version: 1 and the export timestamp
  4. 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 key

Import (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 typeMatching methodBehavior on no match
localLook up in the local library by file_pathSkip (file not in the library)
remote/radioFirst look up by the plugin_entry_path + dedup_key combinationCreate 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:

json
{
  "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.