diff --git a/mobile/src/api/playlist.ts b/mobile/src/api/playlist.ts index fcdf8889..599b4fa2 100644 --- a/mobile/src/api/playlist.ts +++ b/mobile/src/api/playlist.ts @@ -199,6 +199,13 @@ export async function deletePlaylist(id: string) { /** Replace the "junction" field from the `Playlist` table with `tracks`. */ function fixPlaylistJunction(data: PlaylistWithJunction): PlaylistWithTracks { const { tracksToPlaylists, ...rest } = data; - return { ...rest, tracks: tracksToPlaylists.map(({ track }) => track) }; + return { + ...rest, + // Note: We do the filter in the case where we attempted to delete a track, + // but failed to do so (resulting in an invalid track floating around). + tracks: tracksToPlaylists + .map(({ track }) => track) + .filter((t) => t !== null), + }; } //#endregion diff --git a/mobile/src/app/_layout.tsx b/mobile/src/app/_layout.tsx index 0bfea9fc..534ef05a 100644 --- a/mobile/src/app/_layout.tsx +++ b/mobile/src/app/_layout.tsx @@ -12,6 +12,7 @@ import { AppProvider } from "@/providers"; import "@/resources/global.css"; import "@/modules/i18n"; // Make sure translations are bundled. import { TopAppBar, TopAppBarMarquee } from "@/components/TopAppBar"; +import { MigrationFunctionMap } from "@/modules/scanning/helpers/migrations"; // Catch any errors thrown by the Layout component. export { ErrorBoundary }; @@ -40,6 +41,7 @@ export default function RootLayout() { try { // Clear music store state in case error propagated from invalid data. musicStore.getState().reset(); + MigrationFunctionMap["no-track-playlist-ref"](); } catch {} // Send error message to Sentry. Doesn't send if you followed the // "Personal Privacy Build" documentation. diff --git a/mobile/src/modules/scanning/constants.ts b/mobile/src/modules/scanning/constants.ts index f1e9ac33..523f8e6b 100644 --- a/mobile/src/modules/scanning/constants.ts +++ b/mobile/src/modules/scanning/constants.ts @@ -1,4 +1,8 @@ -const MigrationOptions = ["v1-to-v2-store", "v1-to-v2-schema"] as const; +const MigrationOptions = [ + "v1-to-v2-store", + "v1-to-v2-schema", + "no-track-playlist-ref", +] as const; export type MigrationOption = (typeof MigrationOptions)[number]; @@ -24,4 +28,5 @@ export const MigrationHistory: Record< version: "v2.0.0-rc.1", changes: ["v1-to-v2-store", "v1-to-v2-schema"], }, + 4: { version: "v2.0.1", changes: ["no-track-playlist-ref"] }, }; diff --git a/mobile/src/modules/scanning/helpers/migrations.ts b/mobile/src/modules/scanning/helpers/migrations.ts index cea904cb..6f2cec48 100644 --- a/mobile/src/modules/scanning/helpers/migrations.ts +++ b/mobile/src/modules/scanning/helpers/migrations.ts @@ -1,5 +1,5 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import { db } from "@/db"; import { tracks, tracksToPlaylists } from "@/db/schema"; @@ -79,6 +79,27 @@ export const MigrationFunctionMap: Record< } }); }, + /** Removes track to playlist relations where the track doesn't exist. */ + "no-track-playlist-ref": async () => { + const [allTracks, trackRels] = await Promise.all([ + db.query.tracks.findMany({ columns: { id: true } }), + db + .selectDistinct({ id: tracksToPlaylists.trackId }) + .from(tracksToPlaylists), + ]); + try { + const trackIds = new Set(allTracks.map((t) => t.id)); + const relTrackIds = trackRels.map((t) => t.id); + // Get ids in the track to playlist relationship where the track id + // doesn't exist and delete them. + const invalidTracks = relTrackIds.filter((id) => !trackIds.has(id)); + if (invalidTracks.length > 0) { + await db + .delete(tracksToPlaylists) + .where(inArray(tracksToPlaylists.trackId, invalidTracks)); + } + } catch {} + }, }; /** Helper to parse value from AsyncStorage. */ diff --git a/mobile/src/screens/ErrorBoundary.tsx b/mobile/src/screens/ErrorBoundary.tsx index ef7e7dae..c1c41b8a 100644 --- a/mobile/src/screens/ErrorBoundary.tsx +++ b/mobile/src/screens/ErrorBoundary.tsx @@ -7,6 +7,7 @@ import { AppProvider } from "@/providers"; import { Card } from "@/components/Containment/Card"; import { StyledText } from "@/components/Typography/StyledText"; +import { MigrationFunctionMap } from "@/modules/scanning/helpers/migrations"; /** Screen displayed when an error is thrown in a component. */ export function ErrorBoundary({ error }: ErrorBoundaryProps) { @@ -14,6 +15,7 @@ export function ErrorBoundary({ error }: ErrorBoundaryProps) { try { // Clear music store state in case error propagated from invalid data. musicStore.getState().reset(); + MigrationFunctionMap["no-track-playlist-ref"](); } catch {} }, []);