diff --git a/docs/PHOTO_SOURCE_CONNECTIONS.md b/docs/PHOTO_SOURCE_CONNECTIONS.md new file mode 100644 index 0000000..6bee70c --- /dev/null +++ b/docs/PHOTO_SOURCE_CONNECTIONS.md @@ -0,0 +1,25 @@ +# Photo sources and shared connections + +Each photo source has a unique ID and independently persisted directory/URL, +orientation, caption, quality, duplicate and person-selection settings. Jobs refer +to the source ID. Updating one source does not change another source. + +Local sources read folders under the configured NAS mount. Ordinary file reads +and local Synology thumbnails do not require a Photos login. Person filtering +uses the shared Synology Photos session, intersected with that source's folder +catalog. Shared/personal space and person IDs are saved separately per source. + +The current service supports one NAS Photos endpoint and one active account. +Signing in with another account or disconnecting affects every people-filtered +source. Personal-space person IDs may differ between accounts: recheck each +source after an account switch. Multiple independent NAS/account connections +are not supported. Do not interpret multiple folder sources as multiple logins. + +WebDAV credentials and native drive credentials belong to their respective +sources and do not use the shared NAS Photos login. Explicitly reusing a WebDAV +credential variable shares that credential, but does not share filter settings. + +The UI labels the top card **Shared connection**, displays source IDs and paths +on each source card, and explains each source's connection dependency. Shared +session disconnect is available only in the shared connection card and asks for +confirmation of its impact on all people-filtered sources. diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js index 1966843..b3fd679 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -117,18 +117,19 @@ async function photosPeoplePicker(selected){ $('#photos-connect').onclick=busy(async()=>{const password=$('#photos-password').value;$('#photos-password').value='';await api(path,{method:'POST',body:{username:$('#photos-username').value,password}});await photosPeoplePicker(selected);});return; } const people=await api(path+'/people?space='+encodeURIComponent($('#source-people-space')?.value||'shared')); - target.innerHTML=`

`; + target.innerHTML=`

`; const render=()=>{const query=$('#people-search').value.toLowerCase();const rows=people.filter(p=>(p.named||selected.has(p.id)||$('#people-unnamed').checked)&&(p.name.toLowerCase().includes(query)||String(p.id).includes(query)));$('#people-list').innerHTML=rows.map(p=>``).join('');$('#people-selected').textContent=t('peopleSelected')+selected.size;$('#people-list').querySelectorAll('[data-person]').forEach(box=>box.onchange=()=>{const id=Number(box.dataset.person);box.checked?selected.add(id):selected.delete(id);$('#people-selected').textContent=t('peopleSelected')+selected.size;});}; - $('#people-search').oninput=render;$('#people-unnamed').onchange=render;$('#photos-disconnect').onclick=busy(async()=>{await api(path,{method:'DELETE'});await photosPeoplePicker(selected);});render(); + $('#people-search').oninput=render;$('#people-unnamed').onchange=render;render(); } async function photosConnectionPanel(){ const status=await api('/api/v1/integrations/synology-photos'); const box=$('#photos-connection');if(!box)return; - box.innerHTML=`

Synology Photos

${t('photosConnectionHelp')}

${t(status.connected?'photosConnected':'photosDisconnected')}

${t(!status.configured?'photosSetupHelp':status.connected?'photosConnectedHelp':'photosReconnectHelp')}

${buttons('photos-login',status.connected?'photosReconnect':'photosConnect',true)}${buttons('photos-status-refresh','refresh')}
`; + box.innerHTML=`

Synology Photos · ${t('photosSharedConnection')}

${t('photosConnectionHelp')}

${t(status.connected?'photosConnected':'photosDisconnected')}

${t(!status.configured?'photosSetupHelp':status.connected?'photosConnectedHelp':'photosReconnectHelp')}

${buttons('photos-login',status.connected?'photosReconnect':'photosConnect',true)}${buttons('photos-status-refresh','refresh')}${status.connected?buttons('photos-disconnect','photosDisconnect'):''}
`; $('#photos-login').disabled=!status.configured; bind('photos-login',()=>photosLoginDialog()); bind('photos-status-refresh',()=>photosConnectionPanel()); + bind('photos-disconnect',()=>confirmDialog(t('photosSharedDisconnectWarning'),async()=>{await api('/api/v1/integrations/synology-photos',{method:'DELETE'});await navigate('photo-sources');})); } function photosLoginDialog(){ showModal(`

${t('photosConnect')}

${t('photosLoginHelp')}

${t('photosSessionHint')}

`); @@ -138,7 +139,7 @@ function photosLoginDialog(){ async function photoSourcesPanel(){ const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section); const sources=await api('/api/v1/photo-sources');if(!section.isConnected)return; - section.innerHTML=`

${t('photoSources')}

${t('sourceHint')}

${buttons('add-photo-source','addSource')}
${sources.length?sources.map(s=>`

${esc(s.name)}

${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}

`).join(''):`

${t('noSources')}

`}
`; + section.innerHTML=`

${t('photoSources')}

${t('sourceHint')}

${buttons('add-photo-source','addSource')}
${sources.length?sources.map(s=>`

${esc(s.name)}

${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}

${s.people_filter?t('sourceSharedPhotos'):s.kind==='local'?t('sourceLocalOnly'):t('sourceOwnCredentials')}

${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}

`).join(''):`

${t('noSources')}

`}
`; await photosConnectionPanel(); bind('add-photo-source',()=>photoSourceForm()); section.querySelectorAll('[data-source-edit]').forEach(b=>b.onclick=()=>photoSourceForm(sources.find(s=>s.id===b.dataset.sourceEdit))); @@ -148,7 +149,7 @@ async function photoSourcesPanel(){ section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob)); } async function photoSourceForm(source=null){ - showModal(`

${t(source?'edit':'addSource')}

${t('sourceLocationHint')}

${t('sourceCredentialHint')}

${t('orientationHint')}

${t('folderCaptionHint')}

${t('peopleHint')}

${t('peopleSelected')}${(source?.person_ids||[]).length}

`); + showModal(`

${t(source?'edit':'addSource')}

${t('sourceIndependentHelp')}

${t('sourceLocationHint')}

${t('sourceCredentialHint')}

${t('orientationHint')}

${t('folderCaptionHint')}

${t('peopleHint')}

${t('peopleSelected')}${(source?.person_ids||[]).length}

`); $('#source-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'local';const update=()=>{const dav=$('#source-kind').value!=='local';$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-credential').closest('.field').hidden=$('#source-kind').value!=='webdav';const oauth=$('#source-oauth');if(oauth)oauth.closest('.field').hidden=!['google-drive','alipan','baidu'].includes($('#source-kind').value);const quality=$('#source-local-quality');if(quality)quality.hidden=dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=dav;$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};addPhotoQualityFields(source);update();$('#source-kind').onchange=update;$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';}; $('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=$('#source-kind').value!=='local';await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,recursive:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:!dav&&$('#source-synology-previews').checked,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');}); } diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js index 99d3c39..076ac52 100644 --- a/pigway_frame_flow/web/i18n.js +++ b/pigway_frame_flow/web/i18n.js @@ -124,3 +124,9 @@ Object.assign(MESSAGES.en,{sourceHint:'Manage NAS folders, WebDAV and cloud driv Object.assign(MESSAGES.zh,{choosePeople:'选择人物'}); Object.assign(MESSAGES.en,{choosePeople:'Choose people'}); + +Object.assign(MESSAGES.zh,{photosSharedConnection:'共享连接',photosConnectionHelp:'当前仅支持一台 NAS、一个 Photos 登录账号。下方文件夹分别保存设置;此登录不是任何一张数据源卡片的专属连接。人物筛选使用此共享连接,普通目录读取和本地缩略图不依赖此账号。',photosLoginHelp:'使用群晖 NAS / Photos 账号,不是 Frame Flow 账号或 KoKonna Key。更换账号会影响所有启用人物筛选的数据源;个人空间的人物可能不同,更换后请逐个重新核对人物选择。',sourceIndependentHelp:'这里只保存当前数据源的目录、人物与筛选规则,不会修改其他数据源,也不会更换顶部共享 NAS 账号。任务通过数据源 ID 引用这些设置。',sourceIdLabel:'数据源 ID',sourceSharedPhotos:'关联:顶部 Synology Photos 共享连接(人物筛选)',sourceLocalOnly:'关联:NAS 挂载目录;读取文件无需顶部 Photos 登录',sourceOwnCredentials:'关联:此数据源自己的网盘/WebDAV 凭据;不使用顶部 NAS 登录'}); +Object.assign(MESSAGES.en,{photosSharedConnection:'Shared connection',photosConnectionHelp:'Currently one NAS and one Photos account are supported. Each folder stores its own settings; this sign-in is not owned by any source card. People filtering uses this shared session. Ordinary folder reads and local thumbnails do not use this account.',photosLoginHelp:'Use a Synology NAS / Photos account, not a Frame Flow account or KoKonna key. Changing accounts affects all people-filtered sources. Personal-space people may differ; recheck each source after switching accounts.',sourceIndependentHelp:'These folder, people and filter settings apply only to this source. Other sources and the shared NAS account are unchanged. Jobs refer to these settings by source ID.',sourceIdLabel:'Source ID',sourceSharedPhotos:'Connection: shared Synology Photos session above (people filtering)',sourceLocalOnly:'Connection: mounted NAS folder; file reads do not require Photos sign-in',sourceOwnCredentials:'Connection: this source’s drive/WebDAV credentials; independent of NAS sign-in'}); + +Object.assign(MESSAGES.zh,{photosDisconnect:'断开共享连接',photosSharedDisconnectWarning:'这将断开所有人物筛选数据源共用的 Photos 登录,相关任务会无法读取人物照片。文件夹设置和网盘连接不变。确定断开吗?'}); +Object.assign(MESSAGES.en,{photosDisconnect:'Disconnect shared session',photosSharedDisconnectWarning:'This disconnects the Photos session used by all people-filtered sources. Their tasks will fail to read people photos. Folder settings and drive connections remain unchanged. Disconnect?'}); diff --git a/pigway_frame_flow/web/style.css b/pigway_frame_flow/web/style.css index 963537e..710224a 100644 --- a/pigway_frame_flow/web/style.css +++ b/pigway_frame_flow/web/style.css @@ -102,3 +102,5 @@ form>.modal-actions{margin-top:8px} @media(max-width:720px){button,input:not([type=checkbox]):not([type=radio]),select{min-height:44px}.image-card button,.row-actions button,.history-actions button{font-size:14px;padding:9px 12px}.check-row{min-height:44px;align-items:center}} .auto-collage{margin-bottom:24px}.auto-collage h2{margin-bottom:12px}.auto-collage>p{margin-bottom:20px}.auto-collage #collage-preview:not(:empty){margin-top:20px}.auto-collage>.actions{margin-top:16px}.manual-collage>summary{cursor:pointer;font-weight:600}.manual-collage[open]>summary{margin-bottom:24px} + +.source-identity{overflow-wrap:anywhere} diff --git a/tests/test_photo_sources.py b/tests/test_photo_sources.py index 00a2ad0..bfd8d53 100644 --- a/tests/test_photo_sources.py +++ b/tests/test_photo_sources.py @@ -17,3 +17,52 @@ def test_filename_does_not_determine_photo_source(client, service): client.get("/api/v1/assets?source=uploaded").json()[0]["name"] == "weather.jpg" ) assert client.get("/api/v1/assets?source=invalid").status_code == 422 + + +def test_multiple_sources_keep_separate_people_and_folder_settings( + service, settings, tmp_path +): + from pigway_frame_flow.providers.photo_sources import PhotoSourceInput + + root = tmp_path / "photos" + root.mkdir() + (root / "family").mkdir() + (root / "travel").mkdir() + settings.photo_root = root + service.photo_sources.save( + "family", + PhotoSourceInput( + name="Family", + kind="local", + location="family", + people_filter=True, + person_ids=[1], + orientation="portrait", + ), + ) + service.photo_sources.save( + "travel", + PhotoSourceInput( + name="Travel", + kind="local", + location="travel", + people_filter=True, + person_ids=[2], + orientation="landscape", + ), + ) + before = service.photo_sources.get("travel").model_dump() + service.photo_sources.save( + "family", + PhotoSourceInput( + name="Family", + kind="local", + location="family", + people_filter=True, + person_ids=[3], + orientation="auto", + ), + ) + assert service.photo_sources.get("travel").model_dump() == before + assert service.photo_sources.get("family").person_ids == [3] + assert service.photo_sources.get("travel").person_ids == [2]