Filter NAS photos by EXIF-aware orientation with persistent dimension cache
This commit is contained in:
@@ -143,3 +143,11 @@ caption still comes from the original curated directory.
|
|||||||
只允许 Auth login/logout、共享空间 Person.list、Item.list、Folder.get。人物照片按 500 条分页,成员路径缓存 15 分钟,源目录缓存 60 秒;显式刷新会重新查询。无后台高频轮询,不下载全图库原图。查询失败和登录失效明确报错。大型人物相册首次查询可能需要数十秒。
|
只允许 Auth login/logout、共享空间 Person.list、Item.list、Folder.get。人物照片按 500 条分页,成员路径缓存 15 分钟,源目录缓存 60 秒;显式刷新会重新查询。无后台高频轮询,不下载全图库原图。查询失败和登录失效明确报错。大型人物相册首次查询可能需要数十秒。
|
||||||
|
|
||||||
This optional integration reads the existing shared-space People catalog. It does not modify Photos or perform face inference. Login stores only a protected session, never the NAS password. Certificate pinning happens before credentials are sent. Select one or more people and match any/all, intersect with the existing curated folder rules, then randomize. Empty matches and expired sessions fail closed. Person membership caches for 15 minutes; explicit refresh re-queries. This improves relevance, not blur/closed-eye detection. Recognition can miss faces. Personal-space filtering and automatic photo-quality scoring are not implemented.
|
This optional integration reads the existing shared-space People catalog. It does not modify Photos or perform face inference. Login stores only a protected session, never the NAS password. Certificate pinning happens before credentials are sent. Select one or more people and match any/all, intersect with the existing curated folder rules, then randomize. Empty matches and expired sessions fail closed. Person membership caches for 15 minutes; explicit refresh re-queries. This improves relevance, not blur/closed-eye detection. Recognition can miss faces. Personal-space filtering and automatic photo-quality scoring are not implemented.
|
||||||
|
|
||||||
|
## 照片方向 / Orientation
|
||||||
|
|
||||||
|
编辑照片数据源可选:横向、竖向、自动识别。自动匹配当前渲染画布 1200×1600,因此选竖向。按 EXIF 校正后的 width/height 分类,正方形不混入横向或竖向。预览、指定图片和定时随机选图使用同一筛选结果;空结果明确失败,不退回其他方向。照片不为匹配方向强制旋转或拉伸。
|
||||||
|
|
||||||
|
已有数据源 `orientation=null` 保持原来的全部方向,编辑器显示“不筛方向(原有设置)”;新建数据源默认自动。尺寸缓存持久化在 SQLite `photo_dimensions`,按路径、修改时间和大小失效。首次扫描读取本地图片尺寸和 EXIF,不批量生成全图库预览;后续刷新复用缓存。仅 NAS 本地源支持,WebDAV 不批量下载原图测尺寸。若将来改变原生画布尺寸,需要同步调整 auto 的匹配规则。
|
||||||
|
|
||||||
|
Orientation options: landscape, portrait, auto. Auto matches the current portrait rendering canvas. Classification honors EXIF orientation; square photos are excluded from directional matches. Existing sources remain unfiltered (`null`) until changed. Dimensions persist in SQLite and refresh when the file changes. Local NAS sources only; no bulk original downloads from WebDAV. Empty matches fail closed. This controls selection, not photo rotation or stretching.
|
||||||
|
|||||||
@@ -10897,6 +10897,44 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"title": "Provider Preview",
|
"title": "Provider Preview",
|
||||||
"default": false
|
"default": false
|
||||||
|
},
|
||||||
|
"orientation": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"landscape",
|
||||||
|
"portrait",
|
||||||
|
"square"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Orientation"
|
||||||
|
},
|
||||||
|
"width": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Width"
|
||||||
|
},
|
||||||
|
"height": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Height"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -11002,6 +11040,22 @@
|
|||||||
"title": "Folder Caption",
|
"title": "Folder Caption",
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
|
"orientation": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"auto",
|
||||||
|
"landscape",
|
||||||
|
"portrait"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Orientation"
|
||||||
|
},
|
||||||
"people_filter": {
|
"people_filter": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"title": "People Filter",
|
"title": "People Filter",
|
||||||
@@ -11100,6 +11154,22 @@
|
|||||||
"title": "Folder Caption",
|
"title": "Folder Caption",
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
|
"orientation": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"auto",
|
||||||
|
"landscape",
|
||||||
|
"portrait"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Orientation"
|
||||||
|
},
|
||||||
"people_filter": {
|
"people_filter": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"title": "People Filter",
|
"title": "People Filter",
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""EXIF-aware dimensions without decoding original photo pixels."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
|
|
||||||
|
def dimensions(root: Path, path: Path):
|
||||||
|
parent_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
|
||||||
|
try:
|
||||||
|
parts = path.relative_to(root).parts
|
||||||
|
for part in parts[:-1]:
|
||||||
|
child = os.open(
|
||||||
|
part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent_fd
|
||||||
|
)
|
||||||
|
os.close(parent_fd)
|
||||||
|
parent_fd = child
|
||||||
|
fd = os.open(
|
||||||
|
parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent_fd
|
||||||
|
)
|
||||||
|
with os.fdopen(fd, "rb") as stream:
|
||||||
|
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
||||||
|
return None
|
||||||
|
with Image.open(stream) as image:
|
||||||
|
width, height = image.size
|
||||||
|
if image.getexif().get(274, 1) in (5, 6, 7, 8):
|
||||||
|
width, height = height, width
|
||||||
|
return width, height
|
||||||
|
except (OSError, ValueError, UnidentifiedImageError, Image.DecompressionBombError):
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
os.close(parent_fd)
|
||||||
|
|
||||||
|
|
||||||
|
def orientation(width, height):
|
||||||
|
return "landscape" if width > height else "portrait" if height > width else "square"
|
||||||
@@ -18,6 +18,7 @@ from pigway_frame_flow.errors import ErrorCode as E
|
|||||||
from pigway_frame_flow.errors import FlowError
|
from pigway_frame_flow.errors import FlowError
|
||||||
from pigway_frame_flow.models import StrictModel
|
from pigway_frame_flow.models import StrictModel
|
||||||
from pigway_frame_flow.providers.photo_names import path_caption
|
from pigway_frame_flow.providers.photo_names import path_caption
|
||||||
|
from pigway_frame_flow.providers.photo_orientation import dimensions, orientation
|
||||||
from pigway_frame_flow.providers.synology_photos import SynologyPhotos
|
from pigway_frame_flow.providers.synology_photos import SynologyPhotos
|
||||||
from pigway_frame_flow.settings import secret
|
from pigway_frame_flow.settings import secret
|
||||||
from pigway_frame_flow.storage.database import encode
|
from pigway_frame_flow.storage.database import encode
|
||||||
@@ -33,6 +34,7 @@ class PhotoSourceInput(StrictModel):
|
|||||||
synology_previews: bool = False
|
synology_previews: bool = False
|
||||||
organized_only: bool = False
|
organized_only: bool = False
|
||||||
folder_caption: bool = False
|
folder_caption: bool = False
|
||||||
|
orientation: Literal["auto", "landscape", "portrait"] | None = None
|
||||||
people_filter: bool = False
|
people_filter: bool = False
|
||||||
person_ids: list[int] = Field(default_factory=list, max_length=50)
|
person_ids: list[int] = Field(default_factory=list, max_length=50)
|
||||||
person_match: Literal["any", "all"] = "any"
|
person_match: Literal["any", "all"] = "any"
|
||||||
@@ -50,6 +52,9 @@ class PhotoItem(StrictModel):
|
|||||||
name: str
|
name: str
|
||||||
caption: dict[str, str] | None = None
|
caption: dict[str, str] | None = None
|
||||||
provider_preview: bool = False
|
provider_preview: bool = False
|
||||||
|
orientation: Literal["landscape", "portrait", "square"] | None = None
|
||||||
|
width: int | None = None
|
||||||
|
height: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class PhotoCatalog(StrictModel):
|
class PhotoCatalog(StrictModel):
|
||||||
@@ -96,6 +101,11 @@ class PhotoSources:
|
|||||||
return PhotoSourceInput.model_validate_json(row["payload"])
|
return PhotoSourceInput.model_validate_json(row["payload"])
|
||||||
|
|
||||||
def save(self, source_id, source):
|
def save(self, source_id, source):
|
||||||
|
if source.orientation and source.kind != "local":
|
||||||
|
raise FlowError(
|
||||||
|
E.UNSUPPORTED_CAPABILITY,
|
||||||
|
"Orientation filtering requires a local photo source",
|
||||||
|
)
|
||||||
if source.people_filter and source.kind != "local":
|
if source.people_filter and source.kind != "local":
|
||||||
raise FlowError(
|
raise FlowError(
|
||||||
E.INVALID_REQUEST,
|
E.INVALID_REQUEST,
|
||||||
@@ -250,6 +260,13 @@ class PhotoSources:
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
items = []
|
items = []
|
||||||
|
sizes = (
|
||||||
|
{r["path"]: r for r in self.db.all("SELECT * FROM photo_dimensions")}
|
||||||
|
if source.orientation
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
size_updates = []
|
||||||
|
wanted = "portrait" if source.orientation == "auto" else source.orientation
|
||||||
if source.kind == "local":
|
if source.kind == "local":
|
||||||
directory = self.local_directory(source.location)
|
directory = self.local_directory(source.location)
|
||||||
root = self.settings.photo_root.resolve()
|
root = self.settings.photo_root.resolve()
|
||||||
@@ -292,8 +309,45 @@ class PhotoSources:
|
|||||||
relative = path.relative_to(root).as_posix()
|
relative = path.relative_to(root).as_posix()
|
||||||
if allowed is not None and relative not in allowed:
|
if allowed is not None and relative not in allowed:
|
||||||
continue
|
continue
|
||||||
|
info = {}
|
||||||
|
if wanted:
|
||||||
|
key = str(path)
|
||||||
|
stamp = path.stat()
|
||||||
|
cached_size = sizes.get(key)
|
||||||
|
if (
|
||||||
|
cached_size
|
||||||
|
and cached_size["mtime"] == stamp.st_mtime_ns
|
||||||
|
and cached_size["bytes"] == stamp.st_size
|
||||||
|
):
|
||||||
|
width, height = (
|
||||||
|
cached_size["width"],
|
||||||
|
cached_size["height"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
size = dimensions(root, path)
|
||||||
|
if size is None:
|
||||||
|
continue
|
||||||
|
width, height = size
|
||||||
|
size_updates.append(
|
||||||
|
(
|
||||||
|
key,
|
||||||
|
stamp.st_mtime_ns,
|
||||||
|
stamp.st_size,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
direction = orientation(width, height)
|
||||||
|
if direction != wanted:
|
||||||
|
continue
|
||||||
|
info = {
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"orientation": direction,
|
||||||
|
}
|
||||||
items.append(
|
items.append(
|
||||||
{
|
{
|
||||||
|
**info,
|
||||||
"id": relative,
|
"id": relative,
|
||||||
"name": name,
|
"name": name,
|
||||||
"provider_preview": preview,
|
"provider_preview": preview,
|
||||||
@@ -358,6 +412,12 @@ class PhotoSources:
|
|||||||
break
|
break
|
||||||
except ElementTree.ParseError:
|
except ElementTree.ParseError:
|
||||||
raise FlowError(E.INVALID_REQUEST, "Invalid WebDAV catalog") from None
|
raise FlowError(E.INVALID_REQUEST, "Invalid WebDAV catalog") from None
|
||||||
|
if size_updates:
|
||||||
|
with self.db.transaction() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"INSERT OR REPLACE INTO photo_dimensions(path,mtime,bytes,width,height) VALUES(?,?,?,?,?)",
|
||||||
|
size_updates,
|
||||||
|
)
|
||||||
self.cache[source_id] = (time.monotonic(), items)
|
self.cache[source_id] = (time.monotonic(), items)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class Database:
|
|||||||
PRAGMA journal_mode=WAL;
|
PRAGMA journal_mode=WAL;
|
||||||
PRAGMA busy_timeout=5000;
|
PRAGMA busy_timeout=5000;
|
||||||
PRAGMA synchronous=FULL;
|
PRAGMA synchronous=FULL;
|
||||||
|
CREATE TABLE IF NOT EXISTS photo_dimensions (path TEXT PRIMARY KEY, mtime INTEGER NOT NULL, bytes INTEGER NOT NULL, width INTEGER NOT NULL, height INTEGER NOT NULL);
|
||||||
CREATE TABLE IF NOT EXISTS asset_sources (asset_id TEXT PRIMARY KEY, payload TEXT NOT NULL);
|
CREATE TABLE IF NOT EXISTS asset_sources (asset_id TEXT PRIMARY KEY, payload TEXT NOT NULL);
|
||||||
CREATE TABLE IF NOT EXISTS image_sources (device_id TEXT, image_id INTEGER, asset_id TEXT, operation_id TEXT, payload TEXT NOT NULL, PRIMARY KEY(device_id,image_id));
|
CREATE TABLE IF NOT EXISTS image_sources (device_id TEXT, image_id INTEGER, asset_id TEXT, operation_id TEXT, payload TEXT NOT NULL, PRIMARY KEY(device_id,image_id));
|
||||||
CREATE TABLE IF NOT EXISTS app_config (key TEXT PRIMARY KEY, payload TEXT NOT NULL);
|
CREATE TABLE IF NOT EXISTS app_config (key TEXT PRIMARY KEY, payload TEXT NOT NULL);
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ async function photosPeoplePicker(selected){
|
|||||||
async function photoSourcesPanel(){
|
async function photoSourcesPanel(){
|
||||||
const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section);
|
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;
|
const sources=await api('/api/v1/photo-sources');if(!section.isConnected)return;
|
||||||
section.innerHTML=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint">${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}</p><div class="actions"><button data-source-browse="${esc(s.id)}">${t('browse')}</button><button data-source-random="${esc(s.id)}" ${s.enabled?'':'disabled'}>${t('randomPreview')}</button><button data-source-job="${esc(s.id)}">${t('sourceJob')}</button><button data-source-edit="${esc(s.id)}">${t('edit')}</button><button class="danger" data-source-delete="${esc(s.id)}">${t('remove')}</button></div></article>`).join(''):`<p class="hint">${t('noSources')}</p>`}</div><div id="source-browser"></div>`;
|
section.innerHTML=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint">${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}</p><div class="actions"><button data-source-browse="${esc(s.id)}">${t('browse')}</button><button data-source-random="${esc(s.id)}" ${s.enabled?'':'disabled'}>${t('randomPreview')}</button><button data-source-job="${esc(s.id)}">${t('sourceJob')}</button><button data-source-edit="${esc(s.id)}">${t('edit')}</button><button class="danger" data-source-delete="${esc(s.id)}">${t('remove')}</button></div></article>`).join(''):`<p class="hint">${t('noSources')}</p>`}</div><div id="source-browser"></div>`;
|
||||||
bind('add-photo-source',()=>photoSourceForm());
|
bind('add-photo-source',()=>photoSourceForm());
|
||||||
section.querySelectorAll('[data-source-edit]').forEach(b=>b.onclick=()=>photoSourceForm(sources.find(s=>s.id===b.dataset.sourceEdit)));
|
section.querySelectorAll('[data-source-edit]').forEach(b=>b.onclick=()=>photoSourceForm(sources.find(s=>s.id===b.dataset.sourceEdit)));
|
||||||
section.querySelectorAll('[data-source-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/photo-sources/'+b.dataset.sourceDelete,{method:'DELETE'});await navigate('photo-sources');}));
|
section.querySelectorAll('[data-source-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/photo-sources/'+b.dataset.sourceDelete,{method:'DELETE'});await navigate('photo-sources');}));
|
||||||
@@ -130,13 +130,13 @@ async function photoSourcesPanel(){
|
|||||||
section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob));
|
section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob));
|
||||||
}
|
}
|
||||||
async function photoSourceForm(source=null){
|
async function photoSourceForm(source=null){
|
||||||
showModal(`<h2>${t(source?'edit':'addSource')}</h2><form id="photo-source-form"><div class="field"><label for="source-id">ID</label><input id="source-id" pattern="[a-z0-9-]{1,60}" required value="${esc(source?.id||'')}" ${source?'readonly':''}></div><div class="field"><label for="source-name">${t('name')}</label><input id="source-name" required maxlength="120" value="${esc(source?.name||'')}"></div><div class="field"><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="local">${t('nasDirectory')}</option><option value="webdav">WebDAV</option></select></div><div class="field"><label for="source-location">${t('sourceLocation')}</label><input id="source-location" required maxlength="2048" value="${esc(source?.location||'.')}"><p class="hint">${t('sourceLocationHint')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${source?.folder_caption?'checked':''}>${t('folderCaption')}</label><label class="check-row"><input id="source-synology-previews" type="checkbox" ${source?.synology_previews?'checked':''}>${t('synologyPreviews')}</label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
|
showModal(`<h2>${t(source?'edit':'addSource')}</h2><form id="photo-source-form"><div class="field"><label for="source-id">ID</label><input id="source-id" pattern="[a-z0-9-]{1,60}" required value="${esc(source?.id||'')}" ${source?'readonly':''}></div><div class="field"><label for="source-name">${t('name')}</label><input id="source-name" required maxlength="120" value="${esc(source?.name||'')}"></div><div class="field"><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="local">${t('nasDirectory')}</option><option value="webdav">WebDAV</option></select></div><div class="field"><label for="source-location">${t('sourceLocation')}</label><input id="source-location" required maxlength="2048" value="${esc(source?.location||'.')}"><p class="hint">${t('sourceLocationHint')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${source?.folder_caption?'checked':''}>${t('folderCaption')}</label><label class="check-row"><input id="source-synology-previews" type="checkbox" ${source?.synology_previews?'checked':''}>${t('synologyPreviews')}</label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
|
||||||
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==='webdav';$('#source-credential').disabled=!dav;$('#source-recursive').disabled=dav;$('#source-synology-previews').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};update();$('#source-kind').onchange=update;
|
$('#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==='webdav';$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-recursive').disabled=dav;$('#source-synology-previews').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};update();$('#source-kind').onchange=update;
|
||||||
$('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const dav=$('#source-kind').value==='webdav';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:!dav&&$('#source-recursive').checked,synology_previews:!dav&&$('#source-synology-previews').checked,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:$('#source-organized').checked,folder_caption:$('#source-caption').checked,people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});$('#modal').close();await navigate('photo-sources');});
|
$('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const dav=$('#source-kind').value==='webdav';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:!dav&&$('#source-recursive').checked,synology_previews:!dav&&$('#source-synology-previews').checked,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:$('#source-organized').checked,folder_caption:$('#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,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});$('#modal').close();await navigate('photo-sources');});
|
||||||
}
|
}
|
||||||
async function browseSource(id,offset=0,refresh=false){
|
async function browseSource(id,offset=0,refresh=false){
|
||||||
const result=await api('/api/v1/photo-sources/'+id+'/images?offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return;
|
const result=await api('/api/v1/photo-sources/'+id+'/images?offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return;
|
||||||
box.innerHTML=`<h4>${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}</h4><p class="hint">${t('sourceBrowseHint')}</p><div class="source-items">${result.items.map((x,i)=>`<button data-source-item="${i}">${esc(x.name)}${x.caption?`<small>${esc([x.caption.title,x.caption.date,x.caption.place].filter(Boolean).join(' · '))}</small>`:''}</button>`).join('')||t('noSources')}</div><div class="actions"><button id="source-prev" ${offset?'':'disabled'}>${t('previousPage')}</button><button id="source-next" ${offset+result.items.length<result.total?'':'disabled'}>${t('nextPage')}</button>${buttons('source-refresh','refresh')}</div>`;
|
box.innerHTML=`<h4>${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}</h4><p class="hint">${t('sourceBrowseHint')}</p><div class="source-items">${result.items.map((x,i)=>`<button data-source-item="${i}">${esc(x.name)}${x.width?`<small>${x.width} × ${x.height}</small>`:''}${x.caption?`<small>${esc([x.caption.title,x.caption.date,x.caption.place].filter(Boolean).join(' · '))}</small>`:''}</button>`).join('')||t('noSources')}</div><div class="actions"><button id="source-prev" ${offset?'':'disabled'}>${t('previousPage')}</button><button id="source-next" ${offset+result.items.length<result.total?'':'disabled'}>${t('nextPage')}</button>${buttons('source-refresh','refresh')}</div>`;
|
||||||
box.querySelectorAll('[data-source-item]').forEach(b=>b.onclick=busy(()=>previewSource(id,result.items[Number(b.dataset.sourceItem)].id)));bind('source-prev',()=>browseSource(id,Math.max(0,offset-50)));bind('source-next',()=>browseSource(id,offset+50));bind('source-refresh',()=>browseSource(id,0,true));
|
box.querySelectorAll('[data-source-item]').forEach(b=>b.onclick=busy(()=>previewSource(id,result.items[Number(b.dataset.sourceItem)].id)));bind('source-prev',()=>browseSource(id,Math.max(0,offset-50)));bind('source-next',()=>browseSource(id,offset+50));bind('source-refresh',()=>browseSource(id,0,true));
|
||||||
}
|
}
|
||||||
async function previewSource(id,item_id=null){
|
async function previewSource(id,item_id=null){
|
||||||
|
|||||||
@@ -30,3 +30,6 @@ Object.assign(MESSAGES.en,{peopleFilter:'Filter by Synology Photos people',peopl
|
|||||||
|
|
||||||
Object.assign(MESSAGES.zh,{'photo-sources':'照片数据源'});
|
Object.assign(MESSAGES.zh,{'photo-sources':'照片数据源'});
|
||||||
Object.assign(MESSAGES.en,{'photo-sources':'Photo sources'});
|
Object.assign(MESSAGES.en,{'photo-sources':'Photo sources'});
|
||||||
|
|
||||||
|
Object.assign(MESSAGES.zh,{photoOrientation:'照片方向',orientationAuto:'自动识别 · 匹配画布方向',orientationLandscape:'横向照片',orientationPortrait:'竖向照片',orientationLegacy:'不筛方向(原有设置)',orientationHint:'自动按当前 1200×1600 画布选择竖向照片;横向/竖向按照片 EXIF 校正后的尺寸筛选,正方形不匹配。仅支持 NAS 本地数据源,不旋转或拉伸照片。'});
|
||||||
|
Object.assign(MESSAGES.en,{photoOrientation:'Photo orientation',orientationAuto:'Auto · match canvas orientation',orientationLandscape:'Landscape photos',orientationPortrait:'Portrait photos',orientationLegacy:'Any orientation (existing setting)',orientationHint:'Auto selects portrait photos for the current 1200×1600 canvas. Orientation uses EXIF-corrected dimensions; square photos do not match. Local NAS sources only; photos are not rotated or stretched to fit.'});
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
/* Local mock server only; no vendor/cloud writes. PHOTO_ROOT must contain a fixture image. */
|
/* Local mock server only; no vendor/cloud writes. PHOTO_ROOT must contain a fixture image. */
|
||||||
const assert=require('assert'),{chromium}=require('playwright');
|
const assert=require('assert'),{chromium}=require('playwright');
|
||||||
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));const id='ui-photo-'+Date.now();let jobId;let checks=0;try{await page.locator('[data-page="photo-sources"]').click();await page.locator('#add-photo-source').click();await page.locator('#source-id').fill(id);await page.locator('#source-name').fill('UI photo fixture');await page.locator('#source-location').fill('.');await page.locator('#source-organized').check();await page.locator('#source-caption').check();await page.locator('#source-synology-previews').check();await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-browse="${id}"]`).waitFor();for(const lang of ['zh','en'])for(const width of [1440,390])for(const theme of ['dark','light']){await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="photo-sources"]').click();await page.locator(`[data-source-browse="${id}"]`).click();await page.locator('[data-source-item]').first().waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-id').inputValue()===id);assert(await page.locator('#source-organized').isChecked());assert(await page.locator('#source-caption').isChecked());assert(await page.locator('#source-synology-previews').isChecked());await page.locator('#modal-cancel').click();checks++;}await page.locator(`[data-source-random="${id}"]`).click();await page.locator('#source-preview').waitFor();await page.waitForFunction(()=>document.querySelector('#source-preview')?.naturalWidth>0);const before=await page.evaluate(()=>api('/api/v1/devices/'+state.device));await page.locator('#modal-cancel').click();await page.locator(`[data-source-job="${id}"]`).click();assert(!await page.locator('#source-job-enabled').isChecked());await page.locator('#source-job-name').fill(id);await page.locator('#source-job-form button[type="submit"]').click();await page.locator('#new-job').waitFor();jobId=await page.evaluate(async id=>(await api('/api/v1/jobs')).find(j=>j.name===id).id,id);await page.evaluate(async jobId=>{const job=(await api('/api/v1/jobs')).find(j=>j.id===jobId);await jobEditor(job);},jobId);await page.locator('#job-photo-source').waitFor();assert(await page.locator('#job-content-type').inputValue()==='photo_source');assert(await page.locator('#job-photo-source').inputValue()===id);await page.locator('#modal-cancel').click();const after=await page.evaluate(()=>api('/api/v1/devices/'+state.device));assert(before.current_content.id===after.current_content.id);checks+=3;console.log('Photo sources browser: '+checks+' passed (8 language/size/theme profiles, preview and scheduler editing)');}finally{await page.evaluate(async({id,jobId})=>{if(jobId)await api('/api/v1/jobs/'+jobId,{method:'DELETE'});await api('/api/v1/photo-sources/'+id,{method:'DELETE'});},{id,jobId});await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
|
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));const id='ui-photo-'+Date.now();let jobId;let checks=0;try{await page.locator('[data-page="photo-sources"]').click();await page.locator('#add-photo-source').click();await page.locator('#source-id').fill(id);await page.locator('#source-name').fill('UI photo fixture');await page.locator('#source-location').fill('.');await page.locator('#source-orientation').selectOption('landscape');await page.locator('#source-organized').check();await page.locator('#source-caption').check();await page.locator('#source-synology-previews').check();await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-browse="${id}"]`).waitFor();for(const lang of ['zh','en'])for(const width of [1440,390])for(const theme of ['dark','light']){await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="photo-sources"]').click();await page.locator(`[data-source-browse="${id}"]`).click();await page.locator('[data-source-item]').first().waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-id').inputValue()===id);assert(await page.locator('#source-orientation').inputValue()==='landscape');assert(await page.locator('#source-organized').isChecked());assert(await page.locator('#source-caption').isChecked());assert(await page.locator('#source-synology-previews').isChecked());await page.locator('#modal-cancel').click();checks++;}await page.locator(`[data-source-random="${id}"]`).click();await page.locator('#source-preview').waitFor();await page.waitForFunction(()=>document.querySelector('#source-preview')?.naturalWidth>0);const before=await page.evaluate(()=>api('/api/v1/devices/'+state.device));await page.locator('#modal-cancel').click();await page.locator(`[data-source-job="${id}"]`).click();assert(!await page.locator('#source-job-enabled').isChecked());await page.locator('#source-job-name').fill(id);await page.locator('#source-job-form button[type="submit"]').click();await page.locator('#new-job').waitFor();jobId=await page.evaluate(async id=>(await api('/api/v1/jobs')).find(j=>j.name===id).id,id);await page.evaluate(async jobId=>{const job=(await api('/api/v1/jobs')).find(j=>j.id===jobId);await jobEditor(job);},jobId);await page.locator('#job-photo-source').waitFor();assert(await page.locator('#job-content-type').inputValue()==='photo_source');assert(await page.locator('#job-photo-source').inputValue()===id);await page.locator('#modal-cancel').click();const after=await page.evaluate(()=>api('/api/v1/devices/'+state.device));assert(before.current_content.id===after.current_content.id);checks+=3;console.log('Photo sources browser: '+checks+' passed (8 language/size/theme profiles, preview and scheduler editing)');}finally{await page.evaluate(async({id,jobId})=>{if(jobId)await api('/api/v1/jobs/'+jobId,{method:'DELETE'});await api('/api/v1/photo-sources/'+id,{method:'DELETE'});},{id,jobId});await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
|
||||||
|
|||||||
@@ -852,6 +852,22 @@
|
|||||||
"title": "Folder Caption",
|
"title": "Folder Caption",
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
|
"orientation": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"auto",
|
||||||
|
"landscape",
|
||||||
|
"portrait"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Orientation"
|
||||||
|
},
|
||||||
"people_filter": {
|
"people_filter": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"title": "People Filter",
|
"title": "People Filter",
|
||||||
@@ -950,6 +966,22 @@
|
|||||||
"title": "Folder Caption",
|
"title": "Folder Caption",
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
|
"orientation": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"auto",
|
||||||
|
"landscape",
|
||||||
|
"portrait"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Orientation"
|
||||||
|
},
|
||||||
"people_filter": {
|
"people_filter": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"title": "People Filter",
|
"title": "People Filter",
|
||||||
@@ -1027,6 +1059,44 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"title": "Provider Preview",
|
"title": "Provider Preview",
|
||||||
"default": false
|
"default": false
|
||||||
|
},
|
||||||
|
"orientation": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"landscape",
|
||||||
|
"portrait",
|
||||||
|
"square"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Orientation"
|
||||||
|
},
|
||||||
|
"width": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Width"
|
||||||
|
},
|
||||||
|
"height": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Height"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def oriented(service, settings):
|
||||||
|
settings.photo_root = settings.data_dir.parent / "photos"
|
||||||
|
root = settings.photo_root / "20240101 Day"
|
||||||
|
root.mkdir(parents=True)
|
||||||
|
for name, size, rotation in [
|
||||||
|
("wide.jpg", (200, 100), 1),
|
||||||
|
("tall.jpg", (100, 200), 1),
|
||||||
|
("rotated.jpg", (200, 100), 6),
|
||||||
|
("square.jpg", (100, 100), 1),
|
||||||
|
]:
|
||||||
|
exif = Image.Exif()
|
||||||
|
exif[274] = rotation
|
||||||
|
Image.new("RGB", size).save(root / name, exif=exif)
|
||||||
|
return service.photo_sources
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"direction,names",
|
||||||
|
[
|
||||||
|
("landscape", {"wide.jpg"}),
|
||||||
|
("portrait", {"tall.jpg", "rotated.jpg"}),
|
||||||
|
("auto", {"tall.jpg", "rotated.jpg"}),
|
||||||
|
(None, {"wide.jpg", "tall.jpg", "rotated.jpg", "square.jpg"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_orientation_catalog_and_exif(oriented, direction, names):
|
||||||
|
oriented.save(
|
||||||
|
"family",
|
||||||
|
PhotoSourceInput(
|
||||||
|
name="Family",
|
||||||
|
kind="local",
|
||||||
|
location=".",
|
||||||
|
orientation=direction,
|
||||||
|
organized_only=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rows = oriented.catalog("family")
|
||||||
|
assert {r["name"] for r in rows} == names
|
||||||
|
if direction:
|
||||||
|
assert oriented.db.one("SELECT COUNT(*) AS n FROM photo_dimensions")["n"] == 4
|
||||||
|
if direction != "landscape":
|
||||||
|
rotated = next(r for r in rows if r["name"] == "rotated.jpg")
|
||||||
|
assert (rotated["width"], rotated["height"]) == (100, 200)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cached_dimensions_invalidation_and_no_fallback(
|
||||||
|
oriented, settings, monkeypatch
|
||||||
|
):
|
||||||
|
from pigway_frame_flow.errors import FlowError
|
||||||
|
|
||||||
|
oriented.save(
|
||||||
|
"family",
|
||||||
|
PhotoSourceInput(
|
||||||
|
name="Family", kind="local", location=".", orientation="landscape"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert len(oriented.catalog("family")) == 1
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"pigway_frame_flow.providers.photo_sources.dimensions",
|
||||||
|
lambda *a: pytest.fail("unchanged dimensions should be cached"),
|
||||||
|
)
|
||||||
|
assert len(oriented.catalog("family", refresh=True)) == 1
|
||||||
|
monkeypatch.undo()
|
||||||
|
Image.new("RGB", (50, 100)).save(settings.photo_root / "20240101 Day" / "wide.jpg")
|
||||||
|
assert oriented.catalog("family", refresh=True) == []
|
||||||
|
with pytest.raises(FlowError):
|
||||||
|
oriented.read("family")
|
||||||
|
|
||||||
|
|
||||||
|
def test_orientation_validation_and_api(client, oriented):
|
||||||
|
r = client.put(
|
||||||
|
"/api/v1/photo-sources/family",
|
||||||
|
json={
|
||||||
|
"name": "Family",
|
||||||
|
"kind": "local",
|
||||||
|
"location": ".",
|
||||||
|
"orientation": "landscape",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 and r.json()["orientation"] == "landscape"
|
||||||
|
assert client.get("/api/v1/photo-sources/family/images").json()["total"] == 1
|
||||||
|
assert (
|
||||||
|
client.put(
|
||||||
|
"/api/v1/photo-sources/family",
|
||||||
|
json={
|
||||||
|
"name": "Family",
|
||||||
|
"kind": "local",
|
||||||
|
"location": ".",
|
||||||
|
"orientation": "sideways",
|
||||||
|
},
|
||||||
|
).status_code
|
||||||
|
== 422
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.put(
|
||||||
|
"/api/v1/photo-sources/dav",
|
||||||
|
json={
|
||||||
|
"name": "Remote",
|
||||||
|
"kind": "webdav",
|
||||||
|
"location": "https://example.com/",
|
||||||
|
"orientation": "portrait",
|
||||||
|
},
|
||||||
|
).json()["error"]["code"]
|
||||||
|
== "UNSUPPORTED_CAPABILITY"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user