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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,12 @@ MultiCam Planner is designed for quick, intuitive camera planning with essential
next version instead of a second device. *Upload own devices
automatically* (Settings, on by default) uploads on start and a few seconds
after a change; *Sync now* uploads everything. Unchanged entries (by hash of
what was sent last) are not re-sent automatically. Under the selector every
own entry shows its last result — waiting for moderation, live, in sync,
blocked (with the reason, e.g. *datasheet link missing*) or failed — and
what was sent last) are not re-sent automatically — except those still
waiting for moderation: the server reports `moderation: pending | approved`
with every result (also `in-sync`), so the status turns *live* once a
moderator approves. Under the selector every own entry shows its last
result — waiting for moderation, live, blocked (with the reason, e.g.
*datasheet link missing*) or failed — and
*Upload…*, which asks for the datasheet link, stores it on the entry
(`manufacturerUrl`) and uploads at once. Not signed in, it leads to the
sign-in. Changed community guidelines (`guidelines-outdated`) have to be
Expand Down
38 changes: 30 additions & 8 deletions src/__tests__/deviceLibrary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ describe('store against a mocked server', () => {
fetchMock
.mockResolvedValueOnce(json({ user: { id: 'u', email: 'a@b.de', username: 'l' } }, { 'set-auth-token': 'T' }))
.mockResolvedValueOnce(json({ planner: 'multicam', results: [
{ localId: eigeneKamera.id, state: 'edit-proposed', slug: 'sony-pxw-fx9' },
{ localId: eigeneKamera.id, state: 'edit-proposed', slug: 'sony-pxw-fx9', moderation: 'pending' },
{ localId: 'custom-lens-1', state: 'blocked', findings: [{ kind: 'no-source', blocking: true }] },
] }))
.mockResolvedValueOnce(json(antwort(0, [])));
Expand All @@ -247,19 +247,27 @@ describe('store against a mocked server', () => {
expect(r['custom-lens-1']).toMatchObject({ state: 'blocked', findings: ['no-source'] });
expect(JSON.parse(speicher['multicam-device-library-uploads']).records[eigeneKamera.id].state).toBe('edit-proposed');

// … unveraendert geht automatisch nichts erneut hoch, nur der Abgleich:
// … automatisch geht unveraendert nur noch mit, was in der Moderation
// wartet (die Kamera), nicht das Blockierte — und die Freigabe kommt an:
fetchMock
.mockResolvedValueOnce(json({ results: [{ localId: eigeneKamera.id, state: 'in-sync', moderation: 'approved' }] }))
.mockResolvedValueOnce(json(antwort(0, [])));
await useDeviceLibrary.getState().syncAll();
expect(JSON.parse(String((fetchMock.mock.calls[3][1] as RequestInit).body)).items.map((i: { localId: string }) => i.localId)).toEqual([eigeneKamera.id]);
expect(useDeviceLibrary.getState().uploads.records[eigeneKamera.id]).toMatchObject({ state: 'in-sync', moderation: 'approved', slug: 'sony-pxw-fx9' });

// … danach, freigegeben und unveraendert, nur noch der Abgleich:
fetchMock.mockResolvedValueOnce(json(antwort(0, [])));
await useDeviceLibrary.getState().syncAll();
expect(fetchMock.mock.calls[3][0]).toContain('/api/sync');
expect(fetchMock.mock.calls[5][0]).toContain('/api/sync');

// … „Sync now" schickt alles, dann erst den Abgleich.
fetchMock
.mockResolvedValueOnce(json({ results: [{ localId: eigeneKamera.id, state: 'in-sync' }, { localId: 'custom-lens-1', state: 'blocked' }] }))
.mockResolvedValueOnce(json({ results: [{ localId: eigeneKamera.id, state: 'in-sync', moderation: 'approved' }, { localId: 'custom-lens-1', state: 'blocked' }] }))
.mockResolvedValueOnce(json(antwort(0, [])));
await useDeviceLibrary.getState().syncAll({ manual: true });
expect(fetchMock.mock.calls[4][0]).toContain('/api/upload');
expect(fetchMock.mock.calls[5][0]).toContain('/api/sync');
expect(useDeviceLibrary.getState().uploads.records[eigeneKamera.id]).toMatchObject({ state: 'in-sync', slug: 'sony-pxw-fx9' });
expect(fetchMock.mock.calls[6][0]).toContain('/api/upload');
expect(fetchMock.mock.calls[7][0]).toContain('/api/sync');
});

it('automatic upload can be switched off; manual sync still uploads', async () => {
Expand Down Expand Up @@ -324,7 +332,10 @@ describe('upload ledger', () => {
const { pendingUploads, applyUploadResults, emptyLedger, hashOf, toUploadItem } = await import('../library/upload');
const erst = pendingUploads([kamera], emptyLedger('https://s'));
expect(erst).toHaveLength(1);
const ledger = applyUploadResults(emptyLedger('https://s'), erst, [{ localId: eigeneKamera.id, state: 'created', slug: 'x' }], '2026-09-27T00:00:00Z');
const wartend = applyUploadResults(emptyLedger('https://s'), erst, [{ localId: eigeneKamera.id, state: 'created', slug: 'x', moderation: 'pending' }], '2026-09-27T00:00:00Z');
// Wartet in der Moderation: geht erneut mit, damit die Freigabe ankommt.
expect(pendingUploads([kamera], wartend)).toHaveLength(1);
const ledger = applyUploadResults(wartend, erst, [{ localId: eigeneKamera.id, state: 'in-sync', moderation: 'approved' }], '2026-09-27T00:00:00Z');
expect(pendingUploads([kamera], ledger)).toHaveLength(0);
expect(pendingUploads([kamera], ledger, true)).toHaveLength(1);
const umsortiert = Object.fromEntries(Object.entries(eigeneKamera).reverse()) as unknown as Camera;
Expand All @@ -336,6 +347,17 @@ describe('upload ledger', () => {
expect(hashOf({ a: 1, b: 2 })).toBe(hashOf({ b: 2, a: 1 }));
});

it('display bucket follows moderation, older records fall back to the state', async () => {
const { uploadBucket } = await import('../library/upload');
const r = (state: string, moderation?: 'pending' | 'approved') => ({ hash: 'h', state, at: 'z', ...(moderation ? { moderation } : {}) }) as never;
expect(uploadBucket(r('in-sync', 'pending'))).toBe('waiting');
expect(uploadBucket(r('in-sync', 'approved'))).toBe('live');
expect(uploadBucket(r('created'))).toBe('waiting');
expect(uploadBucket(r('in-sync'))).toBe('live');
expect(uploadBucket(r('blocked', 'pending'))).toBe('blocked');
expect(uploadBucket(r('error'))).toBe('failed');
});

it('a ledger of another server is not reused; deleted entries fall out', async () => {
const { readLedger, pruneLedger, emptyLedger } = await import('../library/upload');
const l = { server: 'https://a', records: { x: { hash: 'h', state: 'created' as const, at: 'z' } } };
Expand Down
11 changes: 6 additions & 5 deletions src/components/Settings/DeviceLibrarySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { useDeviceLibrary } from '../../library/store';
import LibraryErrorLine from '../Library/LibraryErrorLine';
import { usesKeychain } from '../../library/tokenStore';
import { uploadBucket } from '../../library/upload';

const feld = 'block w-full border border-bc-border bg-bc-dark text-xs text-bc-text-bright';
const knopf = 'border border-bc-border text-xs text-bc-text transition-colors hover:bg-bc-panel-raised disabled:opacity-50';
Expand All @@ -28,7 +29,7 @@ export default function DeviceLibrarySection() {

const busy = lib.phase === 'signing-in' || lib.phase === 'checking' || lib.phase === 'syncing' || lib.phase === 'uploading';
const records = Object.values(lib.uploads.records);
const zaehle = (...states: string[]) => records.filter((r) => states.includes(r.state)).length;
const zaehle = (bucket: ReturnType<typeof uploadBucket>) => records.filter((r) => uploadBucket(r) === bucket).length;
const cameras = lib.cache.entries.filter((e) => e.kind === 'camera').length;
const lenses = lib.cache.entries.length - cameras;

Expand Down Expand Up @@ -224,12 +225,12 @@ export default function DeviceLibrarySection() {
{records.length > 0 && (
<p className="mt-1 text-xs text-bc-muted">
{format(
t('library.upload.summary', 'Own entries: {live} live or in sync, {waiting} waiting for moderation, {blocked} blocked, {failed} failed.'),
t('library.upload.summary', 'Own entries: {live} live, {waiting} waiting for moderation, {blocked} blocked, {failed} failed.'),
{
live: zaehle('approved', 'in-sync'),
waiting: zaehle('created', 'edit-proposed', 'pending-updated'),
live: zaehle('live'),
waiting: zaehle('waiting'),
blocked: zaehle('blocked'),
failed: zaehle('error'),
failed: zaehle('failed'),
},
)}
</p>
Expand Down
8 changes: 4 additions & 4 deletions src/data/cameras.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,13 +578,13 @@ export const CAMERAS: Camera[] = [
{ id: 'datavideo-ptc-305', manufacturerUrl: 'https://datavideo.com/product/PTC-305', manufacturer: 'Datavideo', model: 'PTC-305', sensor: { name: '1/1.8" (7.44×5.58)', widthMm: 7.44, heightMm: 5.58, cropFactor: 4.84 }, mount: 'integrated', resolutions: ['4K','HD'], type: 'ptz' },
{ id: 'datavideo-ptc-145', manufacturerUrl: 'https://datavideo.com/product/PTC-145', manufacturer: 'Datavideo', model: 'PTC-145', sensor: { name: '1/2.8" (5.37×4.04)', widthMm: 5.37, heightMm: 4.04, cropFactor: 6.7 }, mount: 'integrated', resolutions: ['HD'], type: 'ptz' },
{ id: 'avonic-cm93-ip', manufacturerUrl: 'https://avonic.com/cm93/', manufacturer: 'Avonic', model: 'CM93-IP', sensor: { name: '1/1.8" (7.44×5.58)', widthMm: 7.44, heightMm: 5.58, cropFactor: 4.84 }, mount: 'integrated', resolutions: ['4K','HD'], type: 'ptz' },
{ id: 'sony-srg-a30-b', manufacturer: 'Sony', model: 'SRG-XB25', sensor: { name: '1/2.5" (5.76×4.29)', widthMm: 5.76, heightMm: 4.29, cropFactor: 6.25 }, mount: 'integrated', resolutions: ['4K','HD'], type: 'ptz' },
{ id: 'canon-cr-n300w', manufacturer: 'Canon', model: 'CR-N300 (weiß)', sensor: { name: '1/2.3" (6.17×4.55)', widthMm: 6.17, heightMm: 4.55, cropFactor: 5.64 }, mount: 'integrated', resolutions: ['4K','HD'], type: 'ptz' },
{ id: 'sony-srg-a30-b', manufacturerUrl: 'https://pro.sony/ue_US/products/ptz-network-cameras/srg-xb25', manufacturer: 'Sony', model: 'SRG-XB25', sensor: { name: '1/2.5" (5.76×4.29)', widthMm: 5.76, heightMm: 4.29, cropFactor: 6.25 }, mount: 'integrated', resolutions: ['4K','HD'], type: 'ptz' },
{ id: 'canon-cr-n300w', manufacturerUrl: 'https://www.canon-europe.com/ptz-cameras/cr-n300/specifications/', manufacturer: 'Canon', model: 'CR-N300 (weiß)', sensor: { name: '1/2.3" (6.17×4.55)', widthMm: 6.17, heightMm: 4.55, cropFactor: 5.64 }, mount: 'integrated', resolutions: ['4K','HD'], type: 'ptz' },
{ id: 'ptzoptics-move-4k-ndi', manufacturerUrl: 'https://docs.ptzoptics.com/docs/cameras/move-4k/technical-specs', manufacturer: 'PTZOptics', model: 'Move 4K SDI', sensor: { name: '1/2.5" (5.76×4.29)', widthMm: 5.76, heightMm: 4.29, cropFactor: 6.25 }, mount: 'integrated', resolutions: ['4K','HD'], type: 'ptz' },
{ id: 'lumens-vc-b11u', manufacturerUrl: 'https://www.mylumens.com/en/Products_detail/1047/VC-B11U-Video-Conference-Camera', manufacturer: 'Lumens', model: 'VC-B11U', sensor: { name: '1/2.8" (5.37×4.04)', widthMm: 5.37, heightMm: 4.04, cropFactor: 6.7 }, mount: 'integrated', resolutions: ['4K','HD'], type: 'ptz' },
{ id: 'marshall-cv730-ndi', manufacturerUrl: 'https://marshall-usa.com/cameras/CV730-NDI/', manufacturer: 'Marshall', model: 'CV730-NDI', sensor: { name: '1/1.8" (7.44×5.58)', widthMm: 7.44, heightMm: 5.58, cropFactor: 4.84 }, mount: 'integrated', resolutions: ['4K','HD'], type: 'ptz' },
{ id: 'aida-uhd-100a', manufacturer: 'AIDA', model: 'UHD-100A', sensor: { name: '1/2.5" (5.76×4.29)', widthMm: 5.76, heightMm: 4.29, cropFactor: 6.25 }, mount: 'C/CS', resolutions: ['4K','HD'], type: 'broadcast' },
{ id: 'aida-ptz4k-ndi', manufacturer: 'AIDA', model: 'PTZ4K-NDI-X30', sensor: { name: '1/1.8" (7.44×5.58)', widthMm: 7.44, heightMm: 5.58, cropFactor: 4.84 }, mount: 'integrated', resolutions: ['4K','HD'], type: 'ptz' },
{ id: 'aida-uhd-100a', manufacturerUrl: 'https://aidaimaging.com/uhd-100a/', manufacturer: 'AIDA', model: 'UHD-100A', sensor: { name: '1/2.5" (5.76×4.29)', widthMm: 5.76, heightMm: 4.29, cropFactor: 6.25 }, mount: 'C/CS', resolutions: ['4K','HD'], type: 'broadcast' },
{ id: 'aida-ptz4k-ndi', manufacturerUrl: 'https://aidaimaging.com/ptz4k-ndi-x30/', manufacturer: 'AIDA', model: 'PTZ4K-NDI-X30', sensor: { name: '1/1.8" (7.44×5.58)', widthMm: 7.44, heightMm: 5.58, cropFactor: 4.84 }, mount: 'integrated', resolutions: ['4K','HD'], type: 'ptz' },

// ── Marshall POV ──
{ id: 'marshall-cv568', manufacturerUrl: 'https://marshall-usa.com/cameras/CV568/', manufacturer: 'Marshall', model: 'CV568', sensor: { name: '1/1.8" (7.44×5.58)', widthMm: 7.44, heightMm: 5.58, cropFactor: 4.84 }, mount: 'M12', resolutions: ['HD'], type: 'broadcast', notes: 'POV camera, global shutter' },
Expand Down
Loading
Loading