From ece18d125b762bb332ef16dbd39272d473aa028e Mon Sep 17 00:00:00 2001
From: Samu
Date: Thu, 3 Sep 2026 22:45:20 +0200
Subject: [PATCH] feat: play button, last played, loan lend/return on item page
---
server/src/routes/collectionRoutes.ts | 9 ++-
server/test/loans.test.ts | 16 +++++
web/src/pages/ItemPage.tsx | 96 +++++++++++++++++++++++----
web/test/item.test.tsx | 78 ++++++++++++++++++++--
4 files changed, 179 insertions(+), 20 deletions(-)
diff --git a/server/src/routes/collectionRoutes.ts b/server/src/routes/collectionRoutes.ts
index 71a7b08..b0fa2c3 100644
--- a/server/src/routes/collectionRoutes.ts
+++ b/server/src/routes/collectionRoutes.ts
@@ -179,7 +179,14 @@ export async function registerCollectionRoutes(app: FastifyInstance): Promise {
diff --git a/server/test/loans.test.ts b/server/test/loans.test.ts
index 9d7df05..900a2ba 100644
--- a/server/test/loans.test.ts
+++ b/server/test/loans.test.ts
@@ -56,6 +56,22 @@ describe('loans', () => {
await app.close()
})
+ it('detail route reports the active loan, null before lending', async () => {
+ const { app, cookie, itemId } = await appWithItem()
+ const before = await app.inject({ method: 'GET', url: `/api/collection/${itemId}`, ...auth(cookie) })
+ expect(before.json().loan).toBeNull()
+
+ await app.inject({
+ method: 'POST',
+ url: `/api/collection/${itemId}/loan`,
+ ...auth(cookie),
+ payload: { borrower: 'Bob' },
+ })
+ const after = await app.inject({ method: 'GET', url: `/api/collection/${itemId}`, ...auth(cookie) })
+ expect(after.json().loan).toMatchObject({ borrower: 'Bob' })
+ await app.close()
+ })
+
it('validates borrower and ownership', async () => {
const { app, cookie, itemId } = await appWithItem()
const empty = await app.inject({
diff --git a/web/src/pages/ItemPage.tsx b/web/src/pages/ItemPage.tsx
index 2da8f29..81ade21 100644
--- a/web/src/pages/ItemPage.tsx
+++ b/web/src/pages/ItemPage.tsx
@@ -1,13 +1,29 @@
-import { useEffect, useState } from 'react'
+import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api } from '../api.js'
-import type { DigitalAlbum, Item } from '../types.js'
+import { usePlayer } from '../player/PlayerContext.js'
+import type { DigitalAlbum, Item, ItemDetail } from '../types.js'
import Cover from '../components/Cover.js'
+function timeAgo(iso: string): string {
+ const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
+ if (secs < 60) return 'just now'
+ const mins = Math.floor(secs / 60)
+ if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`
+ const hours = Math.floor(mins / 60)
+ if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`
+ const days = Math.floor(hours / 24)
+ if (days < 30) return `${days} day${days === 1 ? '' : 's'} ago`
+ const months = Math.floor(days / 30)
+ if (months < 12) return `${months} month${months === 1 ? '' : 's'} ago`
+ return `${Math.floor(months / 12)} year${months >= 24 ? 's' : ''} ago`
+}
+
export default function ItemPage() {
const { id } = useParams()
const navigate = useNavigate()
- const [item, setItem] = useState- (null)
+ const { load } = usePlayer()
+ const [item, setItem] = useState(null)
const [error, setError] = useState(false)
const [matching, setMatching] = useState(false)
const [albumQuery, setAlbumQuery] = useState('')
@@ -16,13 +32,19 @@ export default function ItemPage() {
const [confirmRemove, setConfirmRemove] = useState(false)
const [mutationError, setMutationError] = useState(null)
+ const refetch = useCallback(() => {
+ void api.getItem(Number(id)).then(setItem).catch(() => setError(true))
+ }, [id])
+
useEffect(() => {
setMutationError(null)
- void api
- .getItem(Number(id))
- .then(setItem)
- .catch(() => setError(true))
- }, [id])
+ refetch()
+ }, [refetch])
+
+ /** Rip/match mutations return a plain Item — keep the detail-only fields. */
+ function applyUpdated(updated: Item) {
+ setItem((prev) => (prev ? { ...updated, matchedAlbum: prev.matchedAlbum, loan: prev.loan } : null))
+ }
function searchAlbums() {
void api
@@ -39,7 +61,7 @@ export default function ItemPage() {
void api
.setMatch(item.id, albumId)
.then((updated) => {
- setItem(updated)
+ applyUpdated(updated)
setMutationError(null)
})
.catch(() => setMutationError("That didn't work — check your connection and try again."))
@@ -91,6 +113,19 @@ export default function ItemPage() {
)}
+ {item.ripStatus === 'ripped' && item.matchedAlbum && (
+
+ )}
+ {item.matchedAlbum?.lastPlayedAt && (
+ Last played {timeAgo(item.matchedAlbum.lastPlayedAt)}
+ )}
+
{mutationError && {mutationError}
}
@@ -101,7 +136,7 @@ export default function ItemPage() {
void api
.setRip(item.id, false)
.then((updated) => {
- setItem(updated)
+ applyUpdated(updated)
setMutationError(null)
})
.catch(() => setMutationError("That didn't work — check your connection and try again."))
@@ -117,7 +152,7 @@ export default function ItemPage() {
void api
.setRip(item.id, true)
.then((updated) => {
- setItem(updated)
+ applyUpdated(updated)
setMutationError(null)
})
.catch(() => setMutationError("That didn't work — check your connection and try again."))
@@ -134,7 +169,7 @@ export default function ItemPage() {
void api
.setRip(item.id, null)
.then((updated) => {
- setItem(updated)
+ applyUpdated(updated)
setMutationError(null)
})
.catch(() => setMutationError("That didn't work — check your connection and try again."))
@@ -209,6 +244,43 @@ export default function ItemPage() {
)}
+
+ {item.loan ? (
+
+
+ Out to {item.loan.borrower} since {new Date(item.loan.lentAt).toLocaleDateString()}
+
+
+
+ ) : (
+
+ )}
+
+
{item.tracklist.length > 0 && (
Tracklist
diff --git a/web/test/item.test.tsx b/web/test/item.test.tsx
index 1557a8b..92db6a1 100644
--- a/web/test/item.test.tsx
+++ b/web/test/item.test.tsx
@@ -3,19 +3,31 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import ItemPage from '../src/pages/ItemPage.js'
-import type { Item } from '../src/types.js'
+import { PlayerProvider } from '../src/player/PlayerContext.js'
+import type { ItemDetail, MatchedAlbum } from '../src/types.js'
vi.mock('../src/api.js', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
- api: { ...actual.api, getItem: vi.fn(), setRip: vi.fn(), setMatch: vi.fn(), deleteItem: vi.fn(), searchAlbums: vi.fn() },
+ api: {
+ ...actual.api,
+ getItem: vi.fn(),
+ setRip: vi.fn(),
+ setMatch: vi.fn(),
+ deleteItem: vi.fn(),
+ searchAlbums: vi.fn(),
+ getAlbumTracks: vi.fn(),
+ markPlayed: vi.fn(),
+ lendItem: vi.fn(),
+ returnLoan: vi.fn(),
+ },
}
})
import { api } from '../src/api.js'
-const item: Item = {
+const item: ItemDetail = {
id: 1,
discogsReleaseId: 1001,
title: 'Motion',
@@ -32,24 +44,37 @@ const item: Item = {
dateAdded: '2026-08-29',
ripOverride: null,
ripStatus: 'not_ripped',
+ matchedAlbum: null,
+ loan: null,
}
+const matched: MatchedAlbum = { id: 77, subsonicId: 'alb-1', lastPlayedAt: '2026-09-01T10:00:00Z' }
+const rippedItem: ItemDetail = { ...item, ripStatus: 'ripped', matchedAlbum: matched }
+
beforeEach(() => {
+ vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
+ vi.spyOn(HTMLMediaElement.prototype, 'pause').mockReturnValue(undefined)
vi.mocked(api.getItem).mockReset()
vi.mocked(api.getItem).mockResolvedValue(item as never)
vi.mocked(api.setRip).mockReset()
vi.mocked(api.setMatch).mockReset()
vi.mocked(api.deleteItem).mockReset()
vi.mocked(api.searchAlbums).mockReset()
+ vi.mocked(api.getAlbumTracks).mockReset()
+ vi.mocked(api.markPlayed).mockReset()
+ vi.mocked(api.lendItem).mockReset()
+ vi.mocked(api.returnLoan).mockReset()
})
function renderItem() {
return render(
-
- } />
- library} />
-
+
+
+ } />
+ library} />
+
+
)
}
@@ -142,4 +167,43 @@ describe('ItemPage', () => {
await userEvent.click(await screen.findByRole('button', { name: /mark ripped/i }))
await waitFor(() => expect(screen.getByText(/didn't work/i)).toBeTruthy())
})
+
+ it('shows Play for a ripped item with a matched album and loads the player', async () => {
+ vi.mocked(api.getItem).mockResolvedValue(rippedItem as never)
+ vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'alb-1', title: 'Motion', artist: 'TCO', tracks: [] } as never)
+ vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
+ renderItem()
+ const play = await screen.findByRole('button', { name: /play album/i })
+ await userEvent.click(play)
+ await waitFor(() => expect(api.getAlbumTracks).toHaveBeenCalledWith('alb-1'))
+ await waitFor(() => expect(api.markPlayed).toHaveBeenCalledWith('alb-1'))
+ })
+
+ it('hides Play when unmatched or not ripped', async () => {
+ renderItem()
+ await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy())
+ expect(screen.queryByRole('button', { name: /play album/i })).toBeNull()
+ })
+
+ it('shows last played under the rip banner', async () => {
+ vi.mocked(api.getItem).mockResolvedValue(rippedItem as never)
+ renderItem()
+ expect(await screen.findByText(/last played/i)).toBeTruthy()
+ })
+
+ it('lend and return flow', async () => {
+ vi.mocked(api.getItem)
+ .mockResolvedValueOnce(rippedItem as never)
+ .mockResolvedValueOnce({ ...rippedItem, loan: { id: 9, borrower: 'Bob', lentAt: '2026-09-03' } } as never)
+ vi.mocked(api.lendItem).mockResolvedValue({ id: 9, itemId: 1, borrower: 'Bob', lentAt: '2026-09-03', returnedAt: null } as never)
+ renderItem()
+ await userEvent.type(await screen.findByLabelText(/borrower/i), 'Bob')
+ await userEvent.click(screen.getByRole('button', { name: /^lend$/i }))
+ await waitFor(() => expect(api.lendItem).toHaveBeenCalledWith(1, 'Bob'))
+
+ vi.mocked(api.returnLoan).mockResolvedValue({ ok: true } as never)
+ await waitFor(() => expect(screen.getByText(/out to bob/i)).toBeTruthy())
+ await userEvent.click(screen.getByRole('button', { name: /mark returned/i }))
+ await waitFor(() => expect(api.returnLoan).toHaveBeenCalledWith(9))
+ })
})