Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ jobs:

- run: npm ci

- name: Check beacon/ sync
run: bash scripts/check-beacon-sync.sh

- name: Type check
run: npx tsc --noEmit

Expand Down
18 changes: 10 additions & 8 deletions beacon/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ export function App() {
);

// Event notifications (browser + HA mobile_app)
useNotifications(events, client, !focusMemberId);
useNotifications(events, client, !focusMemberId, settings.notificationMinutes);

// Leaderboard is still a slide-over panel (not a full view); Chores is now
// a real full-screen activeView (see PRD: dedicated chores screen).
Expand Down Expand Up @@ -566,7 +566,7 @@ export function App() {

return (
<I18nContext.Provider value={i18n}>
<div className={`beacon beacon--sidebar-${sidebarPos} ${isIngress ? 'beacon--ingress' : ''} ${compact ? 'beacon--compact' : ''}`}>
<div className={`beacon ${settings.kioskMode ? 'beacon--kiosk' : `beacon--sidebar-${sidebarPos}`} ${isIngress ? 'beacon--ingress' : ''} ${compact ? 'beacon--compact' : ''}`}>
{focusInvalid && (
<div className="focus-invalid-banner">
{t('app.kidDisplayNotFound')}
Expand All @@ -575,12 +575,14 @@ export function App() {
</button>
</div>
)}
{/* Sidebar */}
<Sidebar
activeView={activeView}
onChangeView={handleChangeView}
position={sidebarPos}
/>
{/* Sidebar — hidden entirely in Kiosk Mode (Settings > Display) */}
{!settings.kioskMode && (
<Sidebar
activeView={activeView}
onChangeView={handleChangeView}
position={sidebarPos}
/>
)}

{/* Main content area */}
<div className="beacon-main">
Expand Down
121 changes: 121 additions & 0 deletions beacon/src/api/photos.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getPhotos } from './photos';
import { haFetch, hasToken } from './ha-rest';

vi.mock('./ha-rest', () => ({
haFetch: vi.fn(),
hasToken: vi.fn(),
}));

vi.mock('../config', () => ({
getConfig: () => ({ photo_directory: '/media/beacon/photos' }),
}));

const mockHaFetch = vi.mocked(haFetch);
const mockHasToken = vi.mocked(hasToken);

describe('getPhotos', () => {
beforeEach(() => {
mockHaFetch.mockReset();
mockHasToken.mockReset();
});

it('returns no photos when there is no way to authenticate (not add-on, no token)', async () => {
mockHasToken.mockReturnValue(false);

const photos = await getPhotos(['local']);

expect(photos).toEqual([]);
expect(mockHaFetch).not.toHaveBeenCalled();
});

it('browses the configured photo_directory as a media-source:// id, not the raw filesystem path', async () => {
mockHasToken.mockReturnValue(true);
mockHaFetch.mockImplementation(async (path: string) => {
if (path.startsWith('/api/media_source/browse_media/')) {
return {
children: [
{ media_content_id: 'media-source://media_source/local/beacon/photos/a.jpg', title: 'a', media_content_type: 'image/jpeg' },
],
};
}
if (path.startsWith('/api/media_source/resolve_media/')) {
return { url: '/api/media_source/local/signed-a', mime_type: 'image/jpeg' };
}
throw new Error(`unexpected path: ${path}`);
});

const photos = await getPhotos(['local']);

const browseCall = mockHaFetch.mock.calls.find(([p]) => p.includes('browse_media'));
expect(browseCall?.[0]).toBe(
`/api/media_source/browse_media/${encodeURIComponent('media-source://media_source/local/beacon/photos')}`,
);
expect(photos).toEqual([
{ url: '/api/media_source/local/signed-a', caption: 'a', source: 'local' },
]);
});

it('resolves each image to a signed URL instead of hand-building one', async () => {
mockHasToken.mockReturnValue(true);
mockHaFetch.mockImplementation(async (path: string) => {
if (path.includes('browse_media')) {
return {
children: [
{ media_content_id: 'id-1', title: 'one', media_content_type: 'image/jpeg' },
{ media_content_id: 'id-2', title: 'two', media_content_type: 'image/png' },
],
};
}
if (path === `/api/media_source/resolve_media/${encodeURIComponent('id-1')}`) {
return { url: 'https://ha.local/signed-1' };
}
if (path === `/api/media_source/resolve_media/${encodeURIComponent('id-2')}`) {
return { url: 'https://ha.local/signed-2' };
}
throw new Error(`unexpected path: ${path}`);
});

const photos = await getPhotos(['local']);

expect(photos.map((p) => p.url).sort()).toEqual([
'https://ha.local/signed-1',
'https://ha.local/signed-2',
]);
});

it('filters out non-image children and drops photos whose resolve_media call fails', async () => {
mockHasToken.mockReturnValue(true);
mockHaFetch.mockImplementation(async (path: string) => {
if (path.includes('browse_media')) {
return {
children: [
{ media_content_id: 'ok', title: 'ok', media_content_type: 'image/jpeg' },
{ media_content_id: 'broken', title: 'broken', media_content_type: 'image/jpeg' },
{ media_content_id: 'folder', title: 'folder', media_content_type: 'directory' },
],
};
}
if (path === `/api/media_source/resolve_media/${encodeURIComponent('ok')}`) {
return { url: 'https://ha.local/ok' };
}
if (path === `/api/media_source/resolve_media/${encodeURIComponent('broken')}`) {
throw new Error('resolve failed');
}
throw new Error(`unexpected path: ${path}`);
});

const photos = await getPhotos(['local']);

expect(photos).toEqual([{ url: 'https://ha.local/ok', caption: 'ok', source: 'local' }]);
});

it('does not blow up the whole call when browse_media itself fails', async () => {
mockHasToken.mockReturnValue(true);
mockHaFetch.mockRejectedValue(new Error('network error'));

const photos = await getPhotos(['local']);

expect(photos).toEqual([]);
});
});
142 changes: 76 additions & 66 deletions beacon/src/api/photos.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Photo } from '../types/photos';
import { getConfig } from '../config';
import { haFetch, hasToken } from './ha-rest';

const { ha_url: HA_URL, ha_token: HA_TOKEN, photo_directory: PHOTOS_PATH } = getConfig();
const { photo_directory: PHOTOS_PATH } = getConfig();

/**
* Shuffles an array in place using Fisher-Yates.
Expand All @@ -15,90 +16,99 @@ function shuffle<T>(arr: T[]): T[] {
}

/**
* Fetches photos from HA media browser API.
* Converts a filesystem-style config path (e.g. "/media/beacon/photos") into
* the media-source:// content id browse_media actually expects. HA's local
* media root IS /media, so the id is everything after that prefix.
*/
async function fetchHAMediaPhotos(): Promise<Photo[]> {
if (!HA_TOKEN) return [];
function toMediaSourceId(path: string): string {
const relative = path.replace(/^\/?media\/?/, '').replace(/\/$/, '');
return relative
? `media-source://media_source/local/${relative}`
: 'media-source://media_source/local';
}

try {
const baseUrl = HA_URL.replace(/\/$/, '');
const response = await fetch(
`${baseUrl}/api/media_source/browse_media`,
{
headers: {
Authorization: `Bearer ${HA_TOKEN}`,
'Content-Type': 'application/json',
},
},
);
interface BrowseMediaChild {
media_content_id: string;
title: string;
media_content_type: string;
}

if (!response.ok) return [];

const data = await response.json();
const children: Array<{
media_content_id: string;
title: string;
media_content_type: string;
thumbnail?: string;
}> = data.children || [];

return children
.filter((item) => item.media_content_type?.startsWith('image'))
.map((item) => ({
url: `${baseUrl}/api/media_source/local/${item.media_content_id}`,
caption: item.title,
source: 'ha_media' as const,
}));
} catch {
console.warn('Beacon: Failed to fetch HA media photos');
return [];
}
interface BrowseMediaResponse {
children?: BrowseMediaChild[];
}

interface ResolveMediaResponse {
url: string;
mime_type?: string;
}

/**
* Fetches photos from a local directory served by HA.
* Resolves a media_content_id to a signed, playable URL via HA's
* resolve_media endpoint — the hand-built /api/media_source/local/<id> URL
* used previously isn't a real HA endpoint.
*/
async function fetchLocalPhotos(): Promise<Photo[]> {
if (!HA_TOKEN) return [];
async function resolvePhotoUrl(mediaContentId: string): Promise<string | null> {
try {
const resolved = (await haFetch(
`/api/media_source/resolve_media/${encodeURIComponent(mediaContentId)}`,
)) as ResolveMediaResponse;
return resolved?.url ?? null;
} catch {
return null;
}
}

async function browseAndResolvePhotos(
mediaContentId: string | null,
source: 'local' | 'ha_media',
): Promise<Photo[]> {
if (!hasToken()) return [];

try {
const baseUrl = HA_URL.replace(/\/$/, '');
const response = await fetch(
`${baseUrl}/api/media_source/browse_media?media_content_id=${encodeURIComponent(PHOTOS_PATH)}`,
{
headers: {
Authorization: `Bearer ${HA_TOKEN}`,
'Content-Type': 'application/json',
},
},
const path = mediaContentId
? `/api/media_source/browse_media/${encodeURIComponent(mediaContentId)}`
: '/api/media_source/browse_media';
const data = (await haFetch(path)) as BrowseMediaResponse;
const children = data.children || [];

const images = children.filter((item) => item.media_content_type?.startsWith('image'));

const resolved = await Promise.all(
images.map(async (item): Promise<Photo | null> => {
const url = await resolvePhotoUrl(item.media_content_id);
if (!url) return null;
return { url, caption: item.title, source };
}),
);

if (!response.ok) return [];

const data = await response.json();
const children: Array<{
media_content_id: string;
title: string;
media_content_type: string;
}> = data.children || [];

return children
.filter((item) => item.media_content_type?.startsWith('image'))
.map((item) => ({
url: `${baseUrl}/api/media_source/local/${item.media_content_id}`,
caption: item.title,
source: 'local' as const,
}));
return resolved.filter((photo): photo is Photo => photo !== null);
} catch {
console.warn('Beacon: Failed to fetch local photos');
console.warn(`Beacon: Failed to fetch ${source} photos`);
return [];
}
}

/**
* Fetches photos from HA's media browser root.
*/
async function fetchHAMediaPhotos(): Promise<Photo[]> {
return browseAndResolvePhotos(null, 'ha_media');
}

/**
* Fetches photos from the configured local directory, served via HA's
* media source (converted from the filesystem-style photo_directory path).
*/
async function fetchLocalPhotos(): Promise<Photo[]> {
return browseAndResolvePhotos(toMediaSourceId(PHOTOS_PATH), 'local');
}

/**
* Gets all photos from configured sources, shuffled.
*/
export async function getPhotos(sources: Array<'local' | 'google_photos' | 'ha_media'> = ['ha_media', 'local']): Promise<Photo[]> {
export async function getPhotos(
sources: Array<'local' | 'google_photos' | 'ha_media'> = ['ha_media', 'local'],
): Promise<Photo[]> {
const fetchers: Promise<Photo[]>[] = [];

if (sources.includes('ha_media')) {
Expand Down
Loading
Loading