Skip to content

Commit 19c4ff6

Browse files
feat(plex): add source/remote_id columns for Plex source tracking (TASK-342.2)
Add idempotent migrations for source TEXT NOT NULL DEFAULT 'local' and remote_id TEXT to the library table, with idx_library_source and idx_library_remote_id indexes. Update Track model, row_to_track, all SELECT statements, and library_get_all with optional source_filter param.
1 parent e26efcd commit 19c4ff6

10 files changed

Lines changed: 192 additions & 25 deletions

File tree

backlog/tasks/task-342.2 - Backend-Database-migration-for-Plex-source-tracking.md

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
---
22
id: TASK-342.2
33
title: 'Backend: Database migration for Plex source tracking'
4-
status: In Progress
4+
status: Done
55
assignee: []
66
created_date: '2026-05-21 22:56'
7-
updated_date: '2026-05-22 04:31'
7+
updated_date: '2026-05-23 00:48'
88
labels: []
99
dependencies: []
1010
parent_task_id: TASK-342
@@ -32,13 +32,69 @@ Key files:
3232

3333
## Acceptance Criteria
3434
<!-- AC:BEGIN -->
35-
- [ ] #1 Schema migration follows the column-presence idiom in `crates/mt-tauri/src/db/schema.rs::run_migrations` — read columns via `get_table_columns(conn, "library")`, then `conn.execute("ALTER TABLE library ADD COLUMN X ...", [])` guarded by `if !cols.contains(...)`. Do not introduce `PRAGMA user_version` or any new versioning system.
36-
- [ ] #2 New columns on `library`: `source TEXT NOT NULL DEFAULT 'local'` and `remote_id TEXT`. Existing rows acquire `source='local'` via the DEFAULT; `remote_id` stays NULL.
37-
- [ ] #3 Add index `idx_library_remote_id ON library(remote_id) WHERE remote_id IS NOT NULL`, created behind an `index_exists(conn, "idx_library_remote_id")?` guard.
38-
- [ ] #4 Add index `idx_library_source ON library(source)`, created behind an `index_exists` guard, to support `WHERE source = ?` filters efficiently.
39-
- [ ] #5 The `Track` model in `crates/mt-tauri/src/db/models.rs` gains `source: String` (default `"local"` in constructors) and `remote_id: Option<String>`. All existing row-builder functions read the new columns.
40-
- [ ] #6 Existing library queries (`library_get_all`, `library_get_section` in `crates/mt-tauri/src/library/commands.rs`) continue to return all tracks by default (no source filter on the WHERE clause). A new optional `source_filter: Option<String>` parameter is added to `library_get_all`; when `Some("local")` or `Some("plex")` it appends `AND source = ?`. When `None`, behavior is unchanged.
41-
- [ ] #7 Library stats (`library_get_stats` at `commands.rs:476`) count both sources — no change required (existing query has no source filter).
42-
- [ ] #8 Migration is verified idempotent by a Rust unit test that runs `run_migrations` twice in a row against a fresh in-memory SQLite DB and asserts (a) both new columns exist after the first call, (b) the second call is a no-op (no error, no duplicate columns).
43-
- [ ] #9 A second test inserts one row with `source='local'`, one with `source='plex'` + `remote_id='12345'`, and asserts `library_get_all(source_filter=Some("plex"))` returns exactly the second row.
35+
- [x] #1 Schema migration follows the column-presence idiom in `crates/mt-tauri/src/db/schema.rs::run_migrations` — read columns via `get_table_columns(conn, "library")`, then `conn.execute("ALTER TABLE library ADD COLUMN X ...", [])` guarded by `if !cols.contains(...)`. Do not introduce `PRAGMA user_version` or any new versioning system.
36+
- [x] #2 New columns on `library`: `source TEXT NOT NULL DEFAULT 'local'` and `remote_id TEXT`. Existing rows acquire `source='local'` via the DEFAULT; `remote_id` stays NULL.
37+
- [x] #3 Add index `idx_library_remote_id ON library(remote_id) WHERE remote_id IS NOT NULL`, created behind an `index_exists(conn, "idx_library_remote_id")?` guard.
38+
- [x] #4 Add index `idx_library_source ON library(source)`, created behind an `index_exists` guard, to support `WHERE source = ?` filters efficiently.
39+
- [x] #5 The `Track` model in `crates/mt-tauri/src/db/models.rs` gains `source: String` (default `"local"` in constructors) and `remote_id: Option<String>`. All existing row-builder functions read the new columns.
40+
- [x] #6 Existing library queries (`library_get_all`, `library_get_section` in `crates/mt-tauri/src/library/commands.rs`) continue to return all tracks by default (no source filter on the WHERE clause). A new optional `source_filter: Option<String>` parameter is added to `library_get_all`; when `Some("local")` or `Some("plex")` it appends `AND source = ?`. When `None`, behavior is unchanged.
41+
- [x] #7 Library stats (`library_get_stats` at `commands.rs:476`) count both sources — no change required (existing query has no source filter).
42+
- [x] #8 Migration is verified idempotent by a Rust unit test that runs `run_migrations` twice in a row against a fresh in-memory SQLite DB and asserts (a) both new columns exist after the first call, (b) the second call is a no-op (no error, no duplicate columns).
43+
- [x] #9 A second test inserts one row with `source='local'`, one with `source='plex'` + `remote_id='12345'`, and asserts `library_get_all(source_filter=Some("plex"))` returns exactly the second row.
4444
<!-- AC:END -->
45+
46+
## Implementation Plan
47+
48+
<!-- SECTION:PLAN:BEGIN -->
49+
## Implementation Plan
50+
51+
### Files to modify
52+
1. `crates/mt-tauri/src/db/schema.rs` — 4 new migrations + update existing idempotent test
53+
2. `crates/mt-tauri/src/db/models.rs` — add `source: String` and `remote_id: Option<String>` to Track
54+
3. `crates/mt-tauri/src/db/library.rs` — row_to_track, LibraryQuery, build_library_where, 7 SELECT statements, add source_filter test
55+
4. `crates/mt-tauri/src/library/commands.rs` — add source_filter param to library_get_all, fix get_section_all struct literal
56+
5. `crates/mt-tauri/src/db/playlists.rs` — add l.source, l.remote_id to JOIN SELECT
57+
58+
### Migrations (schema.rs)
59+
Append to end of run_migrations before Ok(()):
60+
- source TEXT NOT NULL DEFAULT 'local' (guarded by column check)
61+
- remote_id TEXT (guarded by column check)
62+
- idx_library_remote_id partial index WHERE remote_id IS NOT NULL
63+
- idx_library_source index
64+
65+
### Model changes (models.rs)
66+
Add to Track struct after last_seen_at:
67+
- `pub source: String`
68+
- `pub remote_id: Option<String>`
69+
70+
### Library query changes (library.rs)
71+
- row_to_track: add source + remote_id reads with unwrap_or fallbacks
72+
- LibraryQuery: add `source_filter: Option<String>` field
73+
- build_library_where: add condition when source_filter is Some
74+
- 7 SELECT statements: add `source, remote_id` to column list
75+
- get_all_tracks (~line 135)
76+
- find_tracks_by_artist_title (~line 330)
77+
- get_track_by_id (~line 364)
78+
- get_track_by_filepath (~line 382)
79+
- get_missing_tracks (~line 960)
80+
- find_missing_track_by_inode (~line 1000)
81+
- find_missing_track_by_content_hash (~line 1022)
82+
- Add test_source_filter test (AC#9)
83+
84+
### Command changes (commands.rs)
85+
- library_get_all: add source_filter: Option<String> param, pass to LibraryQuery
86+
- get_section_all (line 221): add source_filter: None to struct literal
87+
88+
### Playlist changes (playlists.rs)
89+
- JOIN SELECT (~line 93): add l.source, l.remote_id columns
90+
91+
### Tests
92+
- schema.rs test_migrations_idempotent: assert source + remote_id columns + indexes (AC#8)
93+
- library.rs test_source_filter: insert local + plex row, assert plex filter works (AC#9)
94+
<!-- SECTION:PLAN:END -->
95+
96+
## Final Summary
97+
98+
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
99+
Added source/remote_id columns to the library table with idempotent migrations following the existing column-presence pattern. Updated Track model, row_to_track, all SELECT statements across library/favorites/queue/playlists, and library_get_all command. All 817 tests pass.
100+
<!-- SECTION:FINAL_SUMMARY:END -->

crates/mt-tauri/src/commands/queue.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1030,6 +1030,8 @@ mod tests {
10301030
file_ctime_ns: None,
10311031
file_inode: None,
10321032
content_hash: None,
1033+
source: "local".to_string(),
1034+
remote_id: None,
10331035
};
10341036

10351037
let response = PlayContextResponse {
@@ -1081,6 +1083,8 @@ mod tests {
10811083
file_ctime_ns: None,
10821084
file_inode: None,
10831085
content_hash: None,
1086+
source: "local".to_string(),
1087+
remote_id: None,
10841088
};
10851089

10861090
let response = PlayContextResponse {

crates/mt-tauri/src/db/favorites.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ pub(crate) fn get_favorites(
2020
l.track_number, l.track_total, l.disc_number, l.disc_total, l.date, l.genre,
2121
l.duration, l.file_size, l.play_count, l.last_played, l.added_date,
2222
l.missing, l.last_seen_at, l.file_mtime_ns, l.file_inode, l.content_hash,
23-
f.timestamp as favorited_date
23+
l.source, l.remote_id, f.timestamp as favorited_date
2424
FROM favorites f
2525
JOIN library l ON f.track_id = l.id
2626
ORDER BY f.timestamp ASC
@@ -49,7 +49,8 @@ pub(crate) fn get_top_25(conn: &Connection) -> DbResult<Vec<Track>> {
4949
"SELECT id, filepath, title, artist, album, album_artist,
5050
track_number, track_total, disc_number, disc_total, date, genre,
5151
duration, file_size, play_count, last_played, added_date,
52-
missing, last_seen_at, file_mtime_ns, file_ctime_ns, file_inode, content_hash
52+
missing, last_seen_at, file_mtime_ns, file_ctime_ns, file_inode, content_hash,
53+
source, remote_id
5354
FROM library
5455
WHERE play_count > 0
5556
ORDER BY play_count DESC, last_played DESC
@@ -76,7 +77,8 @@ pub(crate) fn get_recently_played(
7677
"SELECT id, filepath, title, artist, album, album_artist,
7778
track_number, track_total, disc_number, disc_total, date, genre,
7879
duration, file_size, play_count, last_played, added_date,
79-
missing, last_seen_at, file_mtime_ns, file_ctime_ns, file_inode, content_hash
80+
missing, last_seen_at, file_mtime_ns, file_ctime_ns, file_inode, content_hash,
81+
source, remote_id
8082
FROM library
8183
WHERE last_played IS NOT NULL
8284
AND last_played >= datetime('now', ?)
@@ -100,7 +102,8 @@ pub(crate) fn get_recently_added(conn: &Connection, days: i64, limit: i64) -> Db
100102
"SELECT id, filepath, title, artist, album, album_artist,
101103
track_number, track_total, disc_number, disc_total, date, genre,
102104
duration, file_size, play_count, last_played, added_date,
103-
missing, last_seen_at, file_mtime_ns, file_ctime_ns, file_inode, content_hash
105+
missing, last_seen_at, file_mtime_ns, file_ctime_ns, file_inode, content_hash,
106+
source, remote_id
104107
FROM library
105108
WHERE added_date IS NOT NULL
106109
AND added_date >= datetime('now', ?)

crates/mt-tauri/src/db/library.rs

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ pub(crate) fn row_to_track(row: &Row) -> rusqlite::Result<Track> {
3737
play_count: row.get::<_, Option<i64>>("play_count")?.unwrap_or(0),
3838
missing: row.get::<_, Option<i64>>("missing")?.unwrap_or(0) != 0,
3939
last_seen_at: row.get("last_seen_at")?,
40+
source: row
41+
.get::<_, String>("source")
42+
.unwrap_or_else(|_| "local".to_string()),
43+
remote_id: row.get("remote_id").unwrap_or(None),
4044
})
4145
}
4246

@@ -55,6 +59,8 @@ pub struct LibraryQuery {
5559
pub offset: i64,
5660
/// CSV of prefix words to strip for text sort ordering (e.g. "the,a,an")
5761
pub ignore_words: Option<String>,
62+
/// When Some, appends `AND source = ?` to filter by track source ('local' or 'plex')
63+
pub source_filter: Option<String>,
5864
}
5965

6066
impl LibraryQuery {
@@ -106,6 +112,11 @@ fn build_library_where(query: &LibraryQuery) -> (String, Vec<Box<dyn rusqlite::T
106112
params_vec.push(Box::new(year_to));
107113
}
108114

115+
if let Some(source) = &query.source_filter {
116+
conditions.push("source = ?");
117+
params_vec.push(Box::new(source.clone()));
118+
}
119+
109120
// Always filter out missing tracks from library view
110121
conditions.push("(missing = 0 OR missing IS NULL)");
111122

@@ -135,7 +146,8 @@ pub(crate) fn get_all_tracks(
135146
"SELECT id, filepath, title, artist, album, album_artist,
136147
track_number, track_total, disc_number, disc_total, date, genre,
137148
duration, file_size, play_count, last_played, added_date,
138-
missing, last_seen_at, file_mtime_ns, file_ctime_ns, file_inode, content_hash
149+
missing, last_seen_at, file_mtime_ns, file_ctime_ns, file_inode, content_hash,
150+
source, remote_id
139151
FROM library
140152
{}
141153
ORDER BY {} {}{}
@@ -330,7 +342,8 @@ pub(crate) fn find_tracks_by_artist_title(
330342
let sql = "SELECT id, filepath, title, artist, album, album_artist,
331343
track_number, track_total, disc_number, disc_total, date, genre,
332344
duration, file_size, play_count, last_played, added_date,
333-
missing, last_seen_at, file_mtime_ns, file_ctime_ns, file_inode, content_hash
345+
missing, last_seen_at, file_mtime_ns, file_ctime_ns, file_inode, content_hash,
346+
source, remote_id
334347
FROM library
335348
WHERE (missing = 0 OR missing IS NULL)
336349
AND title = ? COLLATE NOCASE
@@ -365,7 +378,8 @@ pub(crate) fn get_track_by_id(conn: &Connection, track_id: i64) -> DbResult<Opti
365378
"SELECT id, filepath, title, artist, album, album_artist,
366379
track_number, track_total, disc_number, disc_total, date, genre,
367380
duration, file_size, play_count, last_played, added_date,
368-
missing, last_seen_at, file_mtime_ns, file_ctime_ns, file_inode, content_hash
381+
missing, last_seen_at, file_mtime_ns, file_ctime_ns, file_inode, content_hash,
382+
source, remote_id
369383
FROM library WHERE id = ?",
370384
)?;
371385

@@ -383,7 +397,8 @@ pub(crate) fn get_track_by_filepath(conn: &Connection, filepath: &str) -> DbResu
383397
"SELECT id, filepath, title, artist, album, album_artist,
384398
track_number, track_total, disc_number, disc_total, date, genre,
385399
duration, file_size, play_count, last_played, added_date,
386-
missing, last_seen_at, file_mtime_ns, file_ctime_ns, file_inode, content_hash
400+
missing, last_seen_at, file_mtime_ns, file_ctime_ns, file_inode, content_hash,
401+
source, remote_id
387402
FROM library WHERE filepath = ?",
388403
)?;
389404

@@ -961,7 +976,8 @@ pub(crate) fn get_missing_tracks(conn: &Connection) -> DbResult<Vec<Track>> {
961976
"SELECT id, filepath, title, artist, album, album_artist,
962977
track_number, track_total, disc_number, disc_total, date, genre,
963978
duration, file_size, play_count, last_played, added_date,
964-
missing, last_seen_at, file_mtime_ns, file_ctime_ns, file_inode, content_hash
979+
missing, last_seen_at, file_mtime_ns, file_ctime_ns, file_inode, content_hash,
980+
source, remote_id
965981
FROM library WHERE missing = 1 ORDER BY title ASC",
966982
)?;
967983

@@ -1001,7 +1017,8 @@ pub(crate) fn find_missing_track_by_inode(
10011017
"SELECT id, filepath, title, artist, album, album_artist,
10021018
track_number, track_total, disc_number, disc_total, date, genre,
10031019
duration, file_size, play_count, last_played, added_date,
1004-
missing, last_seen_at, file_mtime_ns, file_ctime_ns, file_inode, content_hash
1020+
missing, last_seen_at, file_mtime_ns, file_ctime_ns, file_inode, content_hash,
1021+
source, remote_id
10051022
FROM library WHERE file_inode = ? AND missing = 1 LIMIT 1",
10061023
)?;
10071024

@@ -1022,7 +1039,8 @@ pub(crate) fn find_missing_track_by_content_hash(
10221039
"SELECT id, filepath, title, artist, album, album_artist,
10231040
track_number, track_total, disc_number, disc_total, date, genre,
10241041
duration, file_size, play_count, last_played, added_date,
1025-
missing, last_seen_at, file_mtime_ns, file_ctime_ns, file_inode, content_hash
1042+
missing, last_seen_at, file_mtime_ns, file_ctime_ns, file_inode, content_hash,
1043+
source, remote_id
10261044
FROM library WHERE content_hash = ? AND missing = 1 LIMIT 1",
10271045
)?;
10281046

@@ -3666,4 +3684,31 @@ mod tests {
36663684
assert_eq!(first.artist.as_deref(), Some("Arcade Fire"));
36673685
assert_eq!(second.artist.as_deref(), Some("The Decemberists"));
36683686
}
3687+
3688+
#[test]
3689+
fn test_source_filter() {
3690+
let conn = setup_test_db();
3691+
3692+
conn.execute(
3693+
"INSERT INTO library (filepath, title, source, missing) VALUES ('/local.mp3', 'Local Track', 'local', 0)",
3694+
[],
3695+
)
3696+
.unwrap();
3697+
conn.execute(
3698+
"INSERT INTO library (filepath, title, source, remote_id, missing) VALUES ('/plex/12345', 'Plex Track', 'plex', '12345', 0)",
3699+
[],
3700+
)
3701+
.unwrap();
3702+
3703+
let query = LibraryQuery {
3704+
source_filter: Some("plex".to_string()),
3705+
limit: 100,
3706+
..Default::default()
3707+
};
3708+
let result = get_all_tracks(&conn, &query).unwrap();
3709+
assert_eq!(result.items.len(), 1);
3710+
assert_eq!(result.items[0].title, Some("Plex Track".to_string()));
3711+
assert_eq!(result.items[0].remote_id, Some("12345".to_string()));
3712+
assert_eq!(result.items[0].source, "plex");
3713+
}
36693714
}

crates/mt-tauri/src/db/models.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
66
use serde::{Deserialize, Serialize};
77

8+
fn default_source() -> String {
9+
"local".to_string()
10+
}
11+
812
/// Track metadata from the library table
913
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1014
pub struct Track {
@@ -31,6 +35,9 @@ pub struct Track {
3135
pub play_count: i64,
3236
pub missing: bool,
3337
pub last_seen_at: Option<i64>,
38+
#[serde(default = "default_source")]
39+
pub source: String,
40+
pub remote_id: Option<String>,
3441
}
3542

3643
/// Track metadata for insertion (without id and computed fields)

crates/mt-tauri/src/db/playlists.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ pub(crate) fn get_playlist(
9494
l.track_number, l.track_total, l.disc_number, l.disc_total,
9595
l.date, l.genre, l.duration, l.file_size,
9696
l.play_count, l.last_played, l.added_date, l.missing, l.last_seen_at,
97-
l.file_mtime_ns, l.file_ctime_ns, l.file_inode, l.content_hash, pi.position, pi.added_at
97+
l.file_mtime_ns, l.file_ctime_ns, l.file_inode, l.content_hash,
98+
l.source, l.remote_id, pi.position, pi.added_at
9899
FROM playlist_items pi
99100
JOIN library l ON pi.track_id = l.id
100101
WHERE pi.playlist_id = ?

crates/mt-tauri/src/db/queue.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ pub(crate) fn get_queue(conn: &Connection) -> DbResult<Vec<QueueItem>> {
1414
l.track_number, l.track_total, l.disc_number, l.disc_total,
1515
l.date, l.genre, l.duration, l.file_size,
1616
l.play_count, l.last_played, l.added_date, l.missing, l.last_seen_at,
17-
l.file_mtime_ns, l.file_ctime_ns, l.file_inode, l.content_hash
17+
l.file_mtime_ns, l.file_ctime_ns, l.file_inode, l.content_hash,
18+
l.source, l.remote_id
1819
FROM queue q
1920
LEFT JOIN library l ON q.filepath = l.filepath
2021
ORDER BY q.id",
@@ -50,6 +51,10 @@ pub(crate) fn get_queue(conn: &Connection) -> DbResult<Vec<QueueItem>> {
5051
play_count: row.get::<_, Option<i64>>("play_count")?.unwrap_or(0),
5152
missing: row.get::<_, Option<i64>>("missing")?.unwrap_or(0) != 0,
5253
last_seen_at: row.get("last_seen_at")?,
54+
source: row
55+
.get::<_, String>("source")
56+
.unwrap_or_else(|_| "local".to_string()),
57+
remote_id: row.get("remote_id").unwrap_or(None),
5358
};
5459

5560
items.push(QueueItem { position, track });

crates/mt-tauri/src/db/queue_props_test.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ mod tests {
4141
last_played TEXT,
4242
play_count INTEGER DEFAULT 0,
4343
missing INTEGER DEFAULT 0,
44-
last_seen_at TEXT
44+
last_seen_at TEXT,
45+
source TEXT NOT NULL DEFAULT 'local',
46+
remote_id TEXT
4547
)",
4648
[],
4749
)

0 commit comments

Comments
 (0)