48 files
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -61,12 +61,24 @@ from .const import (
|
||||
CONF_SYNOLOGY_SPACE,
|
||||
CONF_SYNOLOGY_ALBUM_ID,
|
||||
CONF_SYNOLOGY_IMAGE_SIZE,
|
||||
CONF_SYNOLOGY_PASSPHRASE,
|
||||
CONF_SYNOLOGY_FAVORITE,
|
||||
CONF_SYNOLOGY_SELECTION,
|
||||
DEFAULT_SYNOLOGY_IMAGE_SIZE,
|
||||
SYNOLOGY_SPACE_PERSONAL,
|
||||
SYNOLOGY_SPACE_SHARED,
|
||||
SYNOLOGY_IMAGE_SMALL,
|
||||
SYNOLOGY_IMAGE_MEDIUM,
|
||||
SYNOLOGY_IMAGE_LARGE,
|
||||
CONF_NEXTCLOUD_URL,
|
||||
CONF_NEXTCLOUD_USERNAME,
|
||||
CONF_NEXTCLOUD_PASSWORD,
|
||||
CONF_NEXTCLOUD_FOLDER,
|
||||
CONF_NEXTCLOUD_RECURSIVE,
|
||||
CONF_NEXTCLOUD_IMAGE_SIZE,
|
||||
DEFAULT_NEXTCLOUD_IMAGE_SIZE,
|
||||
NEXTCLOUD_IMAGE_PREVIEW,
|
||||
NEXTCLOUD_IMAGE_ORIGINAL,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
@@ -75,6 +87,7 @@ from .const import (
|
||||
PROVIDER_PHOTOPRISM,
|
||||
PROVIDER_ICLOUD,
|
||||
PROVIDER_SYNOLOGY,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
DEFAULT_RECURSIVE,
|
||||
)
|
||||
|
||||
@@ -129,6 +142,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self._syn_device_id: str | None = None
|
||||
self._syn_space: str = SYNOLOGY_SPACE_PERSONAL
|
||||
self._syn_albums: dict[str, str] = {}
|
||||
# option key -> {"album_id": id|None, "passphrase": str|None}
|
||||
self._syn_album_meta: dict[str, dict[str, Any]] = {}
|
||||
# id(str) -> name maps for the composite category multi-selects.
|
||||
self._syn_people: dict[str, str] = {}
|
||||
self._syn_places: dict[str, str] = {}
|
||||
self._syn_tags: dict[str, str] = {}
|
||||
self._syn_subjects: dict[str, str] = {}
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
@@ -147,7 +167,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
``__init__`` raises (the symptom is a 500 when the user clicks
|
||||
Configure).
|
||||
"""
|
||||
if config_entry.data.get(CONF_PROVIDER) == PROVIDER_LOCAL_FOLDER:
|
||||
if config_entry.data.get(CONF_PROVIDER) in (
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
):
|
||||
return LocalFolderOptionsFlow()
|
||||
return _NoOptionsFlow()
|
||||
|
||||
@@ -166,6 +189,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return await self.async_step_icloud()
|
||||
if self._provider == PROVIDER_SYNOLOGY:
|
||||
return await self.async_step_synology()
|
||||
if self._provider == PROVIDER_NEXTCLOUD:
|
||||
return await self.async_step_nextcloud()
|
||||
return await self.async_step_google_shared()
|
||||
|
||||
schema = vol.Schema(
|
||||
@@ -177,6 +202,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
PROVIDER_PHOTOPRISM: "PhotoPrism (direct API, full metadata)",
|
||||
PROVIDER_ICLOUD: "iCloud Shared Album",
|
||||
PROVIDER_SYNOLOGY: "Synology Photos (direct API, full metadata)",
|
||||
PROVIDER_NEXTCLOUD: "Nextcloud (WebDAV folder, full metadata)",
|
||||
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
|
||||
})
|
||||
}
|
||||
@@ -699,9 +725,23 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
try:
|
||||
await client.async_login(otp_code=otp or None)
|
||||
albums = await client.async_list_albums()
|
||||
# Albums and category browsing live only in the Personal space
|
||||
# (there is no Shared Space album/category API). For the Shared
|
||||
# Space, validate access up front so a permission problem
|
||||
# surfaces here, not later.
|
||||
if space == SYNOLOGY_SPACE_SHARED:
|
||||
albums = people = places = tags = subjects = []
|
||||
await client.async_collect_assets(None)
|
||||
else:
|
||||
albums = await client.async_list_albums()
|
||||
people = await client.async_list_people()
|
||||
places = await client.async_list_places()
|
||||
tags = await client.async_list_tags()
|
||||
subjects = await client.async_list_subjects()
|
||||
except syn_api.SynologyOtpRequired:
|
||||
errors["otp_code"] = "synology_otp_required"
|
||||
except syn_api.SynologyPermissionError:
|
||||
errors["base"] = "synology_shared_unavailable"
|
||||
except Exception: # noqa: BLE001 - any failure means bad URL/creds
|
||||
errors["base"] = "synology_cannot_connect"
|
||||
else:
|
||||
@@ -712,10 +752,33 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
# A trusted-device token is captured only on the OTP login;
|
||||
# store it so future logins skip the 2FA prompt.
|
||||
self._syn_device_id = client.captured_device_id
|
||||
self._syn_albums = {
|
||||
str(a["id"]): (a.get("name") or str(a["id"]))
|
||||
for a in albums
|
||||
if a.get("id") is not None
|
||||
# Key each album by a synthetic value so an own album and a
|
||||
# shared-with-me album that happen to share a numeric id don't
|
||||
# collide. Track album_id + passphrase per option.
|
||||
self._syn_albums = {}
|
||||
self._syn_album_meta = {}
|
||||
for a in albums:
|
||||
if a.get("id") is None:
|
||||
continue
|
||||
shared = bool(a.get("shared"))
|
||||
key = f"{'shared' if shared else 'own'}:{a['id']}"
|
||||
label = a.get("name") or str(a["id"])
|
||||
self._syn_albums[key] = f"{label} (shared)" if shared else label
|
||||
self._syn_album_meta[key] = {
|
||||
"album_id": None if shared else a["id"],
|
||||
"passphrase": a.get("passphrase") if shared else None,
|
||||
}
|
||||
self._syn_people = {
|
||||
str(p["id"]): p["name"] for p in people if p.get("id") is not None
|
||||
}
|
||||
self._syn_places = {
|
||||
str(p["id"]): p["name"] for p in places if p.get("id") is not None
|
||||
}
|
||||
self._syn_tags = {
|
||||
str(t["id"]): t["name"] for t in tags if t.get("id") is not None
|
||||
}
|
||||
self._syn_subjects = {
|
||||
str(s["id"]): s["name"] for s in subjects if s.get("id") is not None
|
||||
}
|
||||
await client.async_logout()
|
||||
return await self.async_step_synology_select()
|
||||
@@ -745,7 +808,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
async def async_step_synology_select(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Pick a Synology album (or the whole space) and image size."""
|
||||
"""Build a composite Synology selection and finish the entry.
|
||||
|
||||
Like the Immich/PhotoPrism providers: tick any mix of favorites,
|
||||
albums, people, places, tags and subjects. Synology has no OR across
|
||||
categories, so each member is queried on its own and merged. An empty
|
||||
selection means the whole space.
|
||||
"""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
@@ -753,13 +822,42 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
size = user_input.get(
|
||||
CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE
|
||||
)
|
||||
album_id = user_input.get("album")
|
||||
if album_id in (None, "", "__all__") or album_id not in self._syn_albums:
|
||||
album_id = ""
|
||||
favorites = bool(user_input.get("favorites"))
|
||||
|
||||
album_ids: list[Any] = []
|
||||
passphrases: list[str] = []
|
||||
for key in user_input.get("albums", []) or []:
|
||||
meta = self._syn_album_meta.get(key)
|
||||
if not meta:
|
||||
continue
|
||||
if meta.get("passphrase"):
|
||||
passphrases.append(meta["passphrase"])
|
||||
elif meta.get("album_id") is not None:
|
||||
album_ids.append(meta["album_id"])
|
||||
|
||||
def _ids(field: str, valid: dict[str, str]) -> list[int]:
|
||||
out: list[int] = []
|
||||
for v in user_input.get(field, []) or []:
|
||||
if v in valid:
|
||||
try:
|
||||
out.append(int(v))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return out
|
||||
|
||||
selection = {
|
||||
"favorites": favorites,
|
||||
"album_ids": album_ids,
|
||||
"passphrases": passphrases,
|
||||
"person_ids": _ids("people", self._syn_people),
|
||||
"geocoding_ids": _ids("places", self._syn_places),
|
||||
"tag_ids": _ids("tags", self._syn_tags),
|
||||
"concept_ids": _ids("subjects", self._syn_subjects),
|
||||
}
|
||||
sel_id = json.dumps(selection, sort_keys=True)
|
||||
unique = (
|
||||
f"{DOMAIN}:{PROVIDER_SYNOLOGY}:{self._syn_url}:"
|
||||
f"{self._syn_space}:{album_id or 'all'}"
|
||||
f"{self._syn_space}:{sel_id}"
|
||||
)
|
||||
await self.async_set_unique_id(unique)
|
||||
self._abort_if_unique_id_configured()
|
||||
@@ -769,44 +867,128 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
CONF_SYNOLOGY_USERNAME: self._syn_username,
|
||||
CONF_SYNOLOGY_PASSWORD: self._syn_password,
|
||||
CONF_SYNOLOGY_SPACE: self._syn_space,
|
||||
CONF_SYNOLOGY_SELECTION: sel_id,
|
||||
CONF_SYNOLOGY_IMAGE_SIZE: size,
|
||||
CONF_ALBUM_NAME: name,
|
||||
}
|
||||
if album_id:
|
||||
data[CONF_SYNOLOGY_ALBUM_ID] = album_id
|
||||
if self._syn_device_id:
|
||||
data[CONF_SYNOLOGY_DEVICE_ID] = self._syn_device_id
|
||||
return self.async_create_entry(title=name, data=data)
|
||||
|
||||
album_options = [
|
||||
selector.SelectOptionDict(value="__all__", label="All photos in this space")
|
||||
] + [
|
||||
selector.SelectOptionDict(value=aid, label=aname)
|
||||
for aid, aname in self._syn_albums.items()
|
||||
]
|
||||
def _multi(options: dict[str, str]):
|
||||
return selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=[
|
||||
selector.SelectOptionDict(value=v, label=l)
|
||||
for v, l in options.items()
|
||||
],
|
||||
multiple=True,
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
custom_value=False,
|
||||
)
|
||||
)
|
||||
|
||||
fields: dict[Any, Any] = {vol.Required(CONF_ALBUM_NAME): str}
|
||||
# Favorites, albums and subjects are Personal-space concepts.
|
||||
if self._syn_space == SYNOLOGY_SPACE_PERSONAL:
|
||||
fields[vol.Optional("favorites", default=False)] = (
|
||||
selector.BooleanSelector()
|
||||
)
|
||||
if self._syn_albums:
|
||||
fields[vol.Optional("albums")] = _multi(self._syn_albums)
|
||||
if self._syn_people:
|
||||
fields[vol.Optional("people")] = _multi(self._syn_people)
|
||||
if self._syn_places:
|
||||
fields[vol.Optional("places")] = _multi(self._syn_places)
|
||||
if self._syn_tags:
|
||||
fields[vol.Optional("tags")] = _multi(self._syn_tags)
|
||||
if self._syn_subjects:
|
||||
fields[vol.Optional("subjects")] = _multi(self._syn_subjects)
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_SYNOLOGY_IMAGE_SIZE, default=DEFAULT_SYNOLOGY_IMAGE_SIZE
|
||||
)
|
||||
] = vol.In(
|
||||
{
|
||||
SYNOLOGY_IMAGE_LARGE: "Large (best for slideshow)",
|
||||
SYNOLOGY_IMAGE_MEDIUM: "Medium",
|
||||
SYNOLOGY_IMAGE_SMALL: "Small (thumbnail, fastest)",
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="synology_select", data_schema=vol.Schema(fields), errors=errors
|
||||
)
|
||||
|
||||
async def async_step_nextcloud(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Collect and validate a Nextcloud WebDAV folder + app password."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
name = user_input[CONF_ALBUM_NAME].strip()
|
||||
url = user_input[CONF_NEXTCLOUD_URL].strip()
|
||||
username = user_input[CONF_NEXTCLOUD_USERNAME].strip()
|
||||
password = user_input.get(CONF_NEXTCLOUD_PASSWORD) or ""
|
||||
folder = (user_input.get(CONF_NEXTCLOUD_FOLDER) or "").strip()
|
||||
recursive = bool(user_input.get(CONF_NEXTCLOUD_RECURSIVE, False))
|
||||
size = user_input.get(
|
||||
CONF_NEXTCLOUD_IMAGE_SIZE, DEFAULT_NEXTCLOUD_IMAGE_SIZE
|
||||
)
|
||||
|
||||
from . import nextcloud as nc_api
|
||||
|
||||
client = nc_api.NextcloudClient(
|
||||
self.hass, url, username, password, folder
|
||||
)
|
||||
try:
|
||||
await client.async_validate()
|
||||
except Exception: # noqa: BLE001 - any failure means bad URL/creds/folder
|
||||
errors["base"] = "nextcloud_cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
f"{DOMAIN}:{PROVIDER_NEXTCLOUD}:{client.base_url}:"
|
||||
f"{username}:{client.folder}"
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={
|
||||
CONF_PROVIDER: PROVIDER_NEXTCLOUD,
|
||||
CONF_NEXTCLOUD_URL: client.base_url,
|
||||
CONF_NEXTCLOUD_USERNAME: username,
|
||||
CONF_NEXTCLOUD_PASSWORD: password,
|
||||
CONF_NEXTCLOUD_FOLDER: client.folder,
|
||||
CONF_NEXTCLOUD_RECURSIVE: recursive,
|
||||
CONF_NEXTCLOUD_IMAGE_SIZE: size,
|
||||
CONF_ALBUM_NAME: name,
|
||||
},
|
||||
)
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ALBUM_NAME): str,
|
||||
vol.Optional("album", default="__all__"): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=album_options,
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
custom_value=False,
|
||||
)
|
||||
vol.Required(CONF_NEXTCLOUD_URL): str,
|
||||
vol.Required(CONF_NEXTCLOUD_USERNAME): str,
|
||||
vol.Required(CONF_NEXTCLOUD_PASSWORD): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Optional(CONF_NEXTCLOUD_FOLDER, default=""): str,
|
||||
vol.Optional(CONF_NEXTCLOUD_RECURSIVE, default=False): (
|
||||
selector.BooleanSelector()
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_SYNOLOGY_IMAGE_SIZE, default=DEFAULT_SYNOLOGY_IMAGE_SIZE
|
||||
CONF_NEXTCLOUD_IMAGE_SIZE, default=DEFAULT_NEXTCLOUD_IMAGE_SIZE
|
||||
): vol.In(
|
||||
{
|
||||
SYNOLOGY_IMAGE_LARGE: "Large (best for slideshow)",
|
||||
SYNOLOGY_IMAGE_MEDIUM: "Medium",
|
||||
SYNOLOGY_IMAGE_SMALL: "Small (thumbnail, fastest)",
|
||||
NEXTCLOUD_IMAGE_PREVIEW: "Preview (smoothest slideshow)",
|
||||
NEXTCLOUD_IMAGE_ORIGINAL: "Original (full quality, slower)",
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="synology_select", data_schema=schema, errors=errors
|
||||
step_id="nextcloud", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,28 @@ PROVIDER_IMMICH = "immich"
|
||||
PROVIDER_PHOTOPRISM = "photoprism"
|
||||
PROVIDER_ICLOUD = "icloud"
|
||||
PROVIDER_SYNOLOGY = "synology"
|
||||
PROVIDER_NEXTCLOUD = "nextcloud"
|
||||
|
||||
# Nextcloud (authenticated WebDAV folder) provider. Points at any folder in a
|
||||
# user's files and lists it over WebDAV. Auth is HTTP Basic with a username +
|
||||
# app password (Settings > Security > Devices & sessions); the app password is
|
||||
# stored so the coordinator can re-list on each refresh and is sent server-side
|
||||
# only, never reaching the browser.
|
||||
CONF_NEXTCLOUD_URL = "nextcloud_url"
|
||||
CONF_NEXTCLOUD_USERNAME = "nextcloud_username"
|
||||
CONF_NEXTCLOUD_PASSWORD = "nextcloud_password"
|
||||
CONF_NEXTCLOUD_FOLDER = "nextcloud_folder"
|
||||
CONF_NEXTCLOUD_RECURSIVE = "nextcloud_recursive"
|
||||
CONF_NEXTCLOUD_IMAGE_SIZE = "nextcloud_image_size"
|
||||
|
||||
# ``preview`` uses the core/preview thumbnail endpoint (smoother, smaller);
|
||||
# ``original`` fetches the real file straight off the WebDAV collection.
|
||||
NEXTCLOUD_IMAGE_PREVIEW = "preview"
|
||||
NEXTCLOUD_IMAGE_ORIGINAL = "original"
|
||||
NEXTCLOUD_IMAGE_SIZE_OPTIONS = [NEXTCLOUD_IMAGE_PREVIEW, NEXTCLOUD_IMAGE_ORIGINAL]
|
||||
DEFAULT_NEXTCLOUD_IMAGE_SIZE = NEXTCLOUD_IMAGE_PREVIEW
|
||||
# Long edge (px) requested from the core/preview endpoint for preview quality.
|
||||
NEXTCLOUD_PREVIEW_PX = 1920
|
||||
|
||||
# Synology Photos (direct API) provider. Talks to a DSM Photos package over its
|
||||
# entry.cgi web API. The account password is stored so the coordinator can
|
||||
@@ -70,6 +92,21 @@ CONF_SYNOLOGY_DEVICE_ID = "synology_device_id"
|
||||
CONF_SYNOLOGY_SPACE = "synology_space"
|
||||
CONF_SYNOLOGY_ALBUM_ID = "synology_album_id"
|
||||
CONF_SYNOLOGY_IMAGE_SIZE = "synology_image_size"
|
||||
# Passphrase for an album that was shared with the configured account. Present
|
||||
# only when the chosen source is a shared-with-me album; such albums are
|
||||
# reachable by passphrase rather than by album id.
|
||||
CONF_SYNOLOGY_PASSPHRASE = "synology_passphrase"
|
||||
# When True, the source is the account's Favorites (favorited photos) rather
|
||||
# than the whole space or a specific album.
|
||||
CONF_SYNOLOGY_FAVORITE = "synology_favorite"
|
||||
# Composite selection: a client-side union of any mix of albums, people,
|
||||
# places, tags, subjects and favorites. Synology has no OR across categories,
|
||||
# so each selected member is queried on its own and the results are merged
|
||||
# (see the Immich/PhotoPrism composite). Stored as a JSON object:
|
||||
# ``{"favorites": bool, "album_ids": [...], "passphrases": [...],
|
||||
# "person_ids": [...], "geocoding_ids": [...], "tag_ids": [...],
|
||||
# "concept_ids": [...]}``; an empty composite means the whole space.
|
||||
CONF_SYNOLOGY_SELECTION = "synology_selection"
|
||||
|
||||
# Personal ("My Photos") vs shared ("Shared Space") library.
|
||||
SYNOLOGY_SPACE_PERSONAL = "personal"
|
||||
|
||||
@@ -51,8 +51,20 @@ from .const import (
|
||||
CONF_SYNOLOGY_SPACE,
|
||||
CONF_SYNOLOGY_ALBUM_ID,
|
||||
CONF_SYNOLOGY_IMAGE_SIZE,
|
||||
CONF_SYNOLOGY_PASSPHRASE,
|
||||
CONF_SYNOLOGY_FAVORITE,
|
||||
CONF_SYNOLOGY_SELECTION,
|
||||
DEFAULT_SYNOLOGY_IMAGE_SIZE,
|
||||
SYNOLOGY_SPACE_PERSONAL,
|
||||
CONF_NEXTCLOUD_URL,
|
||||
CONF_NEXTCLOUD_USERNAME,
|
||||
CONF_NEXTCLOUD_PASSWORD,
|
||||
CONF_NEXTCLOUD_FOLDER,
|
||||
CONF_NEXTCLOUD_RECURSIVE,
|
||||
CONF_NEXTCLOUD_IMAGE_SIZE,
|
||||
DEFAULT_NEXTCLOUD_IMAGE_SIZE,
|
||||
NEXTCLOUD_IMAGE_ORIGINAL,
|
||||
NEXTCLOUD_PREVIEW_PX,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
DOMAIN,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
@@ -62,6 +74,7 @@ from .const import (
|
||||
PROVIDER_PHOTOPRISM,
|
||||
PROVIDER_ICLOUD,
|
||||
PROVIDER_SYNOLOGY,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
)
|
||||
from .store import SlideshowStore
|
||||
|
||||
@@ -420,6 +433,11 @@ _NOMINATIM_TIMEOUT_S = 20
|
||||
_EXIF_BATCH_SAVE = 25
|
||||
_GEOCODE_BATCH_SAVE = 10
|
||||
|
||||
# Cap a single Nextcloud enrichment download. EXIF/IPTC/XMP live in the first
|
||||
# blocks of the file, but we read the whole thing since Pillow needs a complete
|
||||
# image; this bounds memory for pathological files. 64 MB matches the camera.
|
||||
_NEXTCLOUD_ENRICH_MAX_BYTES = 64 * 1024 * 1024
|
||||
|
||||
# Inserted between background-enrichment iterations so the event loop
|
||||
# stays responsive on the fast path (items that are already scanned and
|
||||
# need zero work).
|
||||
@@ -674,53 +692,90 @@ def _read_local_exif(path: Path) -> dict[str, Any]:
|
||||
|
||||
try:
|
||||
with Image.open(path) as img:
|
||||
exif = img.getexif()
|
||||
|
||||
if exif:
|
||||
dt_raw = exif.get(_EXIF_TAG_DATETIME_ORIGINAL) or exif.get(
|
||||
_EXIF_TAG_DATETIME
|
||||
)
|
||||
offset_raw = exif.get(_EXIF_TAG_OFFSET_TIME_ORIGINAL)
|
||||
parsed = _parse_exif_datetime(dt_raw, offset_raw)
|
||||
if parsed is not None:
|
||||
out["captured_at"] = parsed
|
||||
|
||||
# Description can come from IPTC / XMP even when the file has no
|
||||
# EXIF IFD, so this runs regardless of ``exif`` being present.
|
||||
description = _read_photo_description(img, exif)
|
||||
if description:
|
||||
out["description"] = description
|
||||
|
||||
if not exif:
|
||||
return out
|
||||
|
||||
gps = None
|
||||
try:
|
||||
gps = exif.get_ifd(_EXIF_TAG_GPS_IFD) or None
|
||||
except Exception:
|
||||
gps = None
|
||||
if gps:
|
||||
lat = _gps_to_decimal(
|
||||
gps.get(_EXIF_GPS_LAT), gps.get(_EXIF_GPS_LAT_REF)
|
||||
)
|
||||
lon = _gps_to_decimal(
|
||||
gps.get(_EXIF_GPS_LON), gps.get(_EXIF_GPS_LON_REF)
|
||||
)
|
||||
if lat is not None and lon is not None:
|
||||
# Null Island guard: GPS chips and some editors stamp
|
||||
# ``(0, 0)`` when the fix is invalid. Treat that as no
|
||||
# location rather than dropping every such photo onto
|
||||
# the equator off the African coast.
|
||||
if abs(lat) < 1e-6 and abs(lon) < 1e-6:
|
||||
return out
|
||||
out["latitude"] = lat
|
||||
out["longitude"] = lon
|
||||
_read_exif_from_image(img, out)
|
||||
except Exception as err:
|
||||
_LOGGER.debug("EXIF: failed to read %s: %s", path, err)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _read_exif_from_image(img: Any, out: dict[str, Any]) -> None:
|
||||
"""Fill ``out`` with capture date / description / GPS from an open image.
|
||||
|
||||
Shared by ``_read_local_exif`` (opens from a filesystem path) and
|
||||
``_read_exif_from_bytes`` (opens from downloaded bytes, e.g. the
|
||||
Nextcloud provider) - both hand this an already-``Image.open``'d image
|
||||
plus a dict pre-seeded with a fallback ``captured_at``.
|
||||
"""
|
||||
exif = img.getexif()
|
||||
|
||||
if exif:
|
||||
dt_raw = exif.get(_EXIF_TAG_DATETIME_ORIGINAL) or exif.get(
|
||||
_EXIF_TAG_DATETIME
|
||||
)
|
||||
offset_raw = exif.get(_EXIF_TAG_OFFSET_TIME_ORIGINAL)
|
||||
parsed = _parse_exif_datetime(dt_raw, offset_raw)
|
||||
if parsed is not None:
|
||||
out["captured_at"] = parsed
|
||||
|
||||
# Description can come from IPTC / XMP even when the file has no EXIF
|
||||
# IFD, so this runs regardless of ``exif`` being present.
|
||||
description = _read_photo_description(img, exif)
|
||||
if description:
|
||||
out["description"] = description
|
||||
|
||||
if not exif:
|
||||
return
|
||||
|
||||
gps = None
|
||||
try:
|
||||
gps = exif.get_ifd(_EXIF_TAG_GPS_IFD) or None
|
||||
except Exception:
|
||||
gps = None
|
||||
if gps:
|
||||
lat = _gps_to_decimal(gps.get(_EXIF_GPS_LAT), gps.get(_EXIF_GPS_LAT_REF))
|
||||
lon = _gps_to_decimal(gps.get(_EXIF_GPS_LON), gps.get(_EXIF_GPS_LON_REF))
|
||||
if lat is not None and lon is not None:
|
||||
# Null Island guard: GPS chips and some editors stamp ``(0, 0)``
|
||||
# when the fix is invalid. Treat that as no location rather than
|
||||
# dropping every such photo onto the equator off the African coast.
|
||||
if abs(lat) < 1e-6 and abs(lon) < 1e-6:
|
||||
return
|
||||
out["latitude"] = lat
|
||||
out["longitude"] = lon
|
||||
|
||||
|
||||
def _read_exif_from_bytes(
|
||||
data: bytes, mtime_fallback_ms: int | None
|
||||
) -> dict[str, Any]:
|
||||
"""Read EXIF metadata from already-downloaded image bytes.
|
||||
|
||||
Same return shape as ``_read_local_exif``, for providers (Nextcloud)
|
||||
whose files live on a remote server rather than the local filesystem -
|
||||
the caller downloads the file once for enrichment, regardless of which
|
||||
quality is used for display. ``mtime_fallback_ms`` takes the place of
|
||||
the filesystem mtime fallback (e.g. the WebDAV ``Last-Modified`` date).
|
||||
"""
|
||||
out: dict[str, Any] = {}
|
||||
if isinstance(mtime_fallback_ms, int):
|
||||
out["captured_at"] = mtime_fallback_ms
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except Exception: # pragma: no cover - Pillow ships with HA core
|
||||
return out
|
||||
|
||||
try:
|
||||
import io
|
||||
|
||||
with Image.open(io.BytesIO(data)) as img:
|
||||
_read_exif_from_image(img, out)
|
||||
except Exception as err:
|
||||
_LOGGER.debug("EXIF: failed to read image bytes: %s", err)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _format_nominatim_location(payload: dict[str, Any]) -> str | None:
|
||||
"""Turn a Nominatim reverse-geocode response into a short label.
|
||||
|
||||
@@ -951,6 +1006,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
data = await self._update_icloud()
|
||||
elif self.provider == PROVIDER_SYNOLOGY:
|
||||
data = await self._update_synology()
|
||||
elif self.provider == PROVIDER_NEXTCLOUD:
|
||||
data = await self._update_nextcloud()
|
||||
else:
|
||||
raise UpdateFailed(f"Unsupported provider: {self.provider}")
|
||||
except UpdateFailed:
|
||||
@@ -964,7 +1021,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
raise
|
||||
|
||||
items = data.get("items") or []
|
||||
if self.provider in (PROVIDER_LOCAL_FOLDER, PROVIDER_IMMICH) and items:
|
||||
if self.provider in (PROVIDER_LOCAL_FOLDER, PROVIDER_IMMICH, PROVIDER_NEXTCLOUD) and items:
|
||||
# Carry forward EXIF/geocode metadata for items we've already
|
||||
# scanned this session; new items get filled in by the
|
||||
# background worker below.
|
||||
@@ -1541,6 +1598,9 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
device_id = self.entry.data.get(CONF_SYNOLOGY_DEVICE_ID)
|
||||
space = self.entry.data.get(CONF_SYNOLOGY_SPACE, SYNOLOGY_SPACE_PERSONAL)
|
||||
album_id = self.entry.data.get(CONF_SYNOLOGY_ALBUM_ID)
|
||||
passphrase = self.entry.data.get(CONF_SYNOLOGY_PASSPHRASE)
|
||||
favorite_only = bool(self.entry.data.get(CONF_SYNOLOGY_FAVORITE))
|
||||
selection_raw = self.entry.data.get(CONF_SYNOLOGY_SELECTION)
|
||||
size = self.entry.data.get(
|
||||
CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE
|
||||
)
|
||||
@@ -1557,7 +1617,23 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
)
|
||||
try:
|
||||
await client.async_login()
|
||||
photos = await client.async_collect_assets(album_id or None)
|
||||
if selection_raw:
|
||||
# Composite selection (albums + people + places + tags +
|
||||
# subjects + favorites), merged client-side.
|
||||
try:
|
||||
selection = json.loads(selection_raw)
|
||||
except (TypeError, ValueError):
|
||||
selection = {}
|
||||
photos = await client.async_collect_composite(selection)
|
||||
else:
|
||||
# Legacy single-source entries (favorites / one album / all).
|
||||
photos = await client.async_collect_assets(
|
||||
album_id or None,
|
||||
passphrase=passphrase or None,
|
||||
favorite_only=favorite_only,
|
||||
)
|
||||
except syn_api.SynologyPermissionError as err:
|
||||
raise UpdateFailed(str(err)) from err
|
||||
except Exception as err:
|
||||
raise UpdateFailed(f"Error querying Synology Photos: {err}") from err
|
||||
|
||||
@@ -1577,10 +1653,19 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
continue
|
||||
unit_id, cache_key = ref
|
||||
meta = syn_api.parse_photo_meta(p)
|
||||
# Items pulled from a shared-with-me album carry their own
|
||||
# passphrase (composite path); fall back to the single-album
|
||||
# passphrase for legacy entries.
|
||||
item_pp = p.get("_passphrase") or passphrase or None
|
||||
items.append(
|
||||
MediaItem(
|
||||
url=syn_api.build_thumbnail_url(
|
||||
client.base_url, unit_id, cache_key, size, space
|
||||
client.base_url,
|
||||
unit_id,
|
||||
cache_key,
|
||||
size,
|
||||
space,
|
||||
passphrase=item_pp,
|
||||
),
|
||||
width=meta.get("width"),
|
||||
height=meta.get("height"),
|
||||
@@ -1605,6 +1690,143 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def _update_nextcloud(self) -> dict[str, Any]:
|
||||
"""List photos from an authenticated Nextcloud WebDAV folder.
|
||||
|
||||
The PROPFIND listing carries filename/size/content-type/mtime but no
|
||||
EXIF, so capture date, GPS and description are filled in afterwards by
|
||||
the background enrichment worker (one original-file download per photo -
|
||||
Nextcloud has no metadata-only endpoint the way Immich does). The app
|
||||
password is sent server-side only via the coordinator's image headers.
|
||||
"""
|
||||
from . import nextcloud as nc_api
|
||||
|
||||
url = self.entry.data.get(CONF_NEXTCLOUD_URL)
|
||||
username = self.entry.data.get(CONF_NEXTCLOUD_USERNAME)
|
||||
password = self.entry.data.get(CONF_NEXTCLOUD_PASSWORD)
|
||||
folder = self.entry.data.get(CONF_NEXTCLOUD_FOLDER) or ""
|
||||
recursive = bool(self.entry.data.get(CONF_NEXTCLOUD_RECURSIVE, False))
|
||||
size = self.entry.data.get(
|
||||
CONF_NEXTCLOUD_IMAGE_SIZE, DEFAULT_NEXTCLOUD_IMAGE_SIZE
|
||||
)
|
||||
if not url or not username or not password:
|
||||
raise UpdateFailed("Nextcloud provider is missing URL or credentials")
|
||||
|
||||
client = nc_api.NextcloudClient(self.hass, url, username, password, folder)
|
||||
try:
|
||||
photos = await client.async_list_photos(recursive=recursive)
|
||||
except Exception as err:
|
||||
raise UpdateFailed(f"Error listing Nextcloud folder: {err}") from err
|
||||
|
||||
if not photos:
|
||||
raise UpdateFailed("No images found in the Nextcloud folder")
|
||||
|
||||
# The camera fetches image bytes server-side with this Basic-auth
|
||||
# header, so the app password never appears in the browser URL.
|
||||
self.image_request_headers = dict(client.image_headers)
|
||||
|
||||
items: list[MediaItem] = []
|
||||
for p in photos:
|
||||
href = p.get("href")
|
||||
if not href:
|
||||
continue
|
||||
if size != NEXTCLOUD_IMAGE_ORIGINAL and p.get("file_id"):
|
||||
display_url = nc_api.build_preview_url(
|
||||
client.base_url, p["file_id"], NEXTCLOUD_PREVIEW_PX
|
||||
)
|
||||
else:
|
||||
display_url = href
|
||||
items.append(
|
||||
MediaItem(
|
||||
url=display_url,
|
||||
width=None,
|
||||
height=None,
|
||||
mime_type=p.get("content_type"),
|
||||
filename=p.get("filename"),
|
||||
uploaded_at=p.get("mtime_ms"),
|
||||
byte_size=p.get("size"),
|
||||
source_id=p.get("file_id") or href,
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"title": self.entry.title,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def _enrich_nextcloud_item(self, item: MediaItem) -> None:
|
||||
"""Download one Nextcloud photo's original bytes and read its EXIF.
|
||||
|
||||
Nextcloud's WebDAV folder has no metadata-only endpoint (unlike
|
||||
Immich's per-asset detail call), so enrichment costs one full-file
|
||||
download per photo regardless of the display quality configured.
|
||||
"""
|
||||
from . import nextcloud as nc_api
|
||||
from urllib.parse import quote
|
||||
|
||||
url = self.entry.data.get(CONF_NEXTCLOUD_URL)
|
||||
username = self.entry.data.get(CONF_NEXTCLOUD_USERNAME)
|
||||
password = self.entry.data.get(CONF_NEXTCLOUD_PASSWORD)
|
||||
folder = self.entry.data.get(CONF_NEXTCLOUD_FOLDER) or ""
|
||||
if not username or not password or not url:
|
||||
item.exif_scanned = True
|
||||
return
|
||||
|
||||
# Reconstruct the original-file URL: for preview items the display url
|
||||
# is the preview endpoint, so fall back to the folder href by filename.
|
||||
original_url = None
|
||||
if isinstance(item.url, str) and "/remote.php/dav/files/" in item.url:
|
||||
original_url = item.url
|
||||
elif item.filename:
|
||||
client = nc_api.NextcloudClient(self.hass, url, username, password, folder)
|
||||
original_url = client.dav_root + quote(item.filename)
|
||||
if not original_url:
|
||||
item.exif_scanned = True
|
||||
return
|
||||
|
||||
headers = {
|
||||
"Authorization": nc_api.basic_auth_header(username, password)
|
||||
}
|
||||
session = async_get_clientsession(self.hass)
|
||||
try:
|
||||
async with async_timeout.timeout(30):
|
||||
async with session.get(original_url, headers=headers) as resp:
|
||||
resp.raise_for_status()
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
async for chunk in resp.content.iter_chunked(64 * 1024):
|
||||
total += len(chunk)
|
||||
if total > _NEXTCLOUD_ENRICH_MAX_BYTES:
|
||||
_LOGGER.debug(
|
||||
"Nextcloud: %s exceeded %d byte enrichment cap; skipping",
|
||||
item.filename, _NEXTCLOUD_ENRICH_MAX_BYTES,
|
||||
)
|
||||
item.exif_scanned = True
|
||||
return
|
||||
chunks.append(chunk)
|
||||
data = b"".join(chunks)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.debug(
|
||||
"Nextcloud: failed to download %s for enrichment: %s",
|
||||
item.filename, err,
|
||||
)
|
||||
item.exif_scanned = True
|
||||
return
|
||||
|
||||
info = await self.hass.async_add_executor_job(
|
||||
_read_exif_from_bytes, data, item.uploaded_at
|
||||
)
|
||||
if "captured_at" in info:
|
||||
item.captured_at = info["captured_at"]
|
||||
if "description" in info:
|
||||
item.description = info["description"]
|
||||
if "latitude" in info and "longitude" in info:
|
||||
item.latitude = info["latitude"]
|
||||
item.longitude = info["longitude"]
|
||||
item.exif_scanned = True
|
||||
|
||||
async def _enrich_immich_item(self, item: MediaItem) -> None:
|
||||
"""Fetch one Immich asset's detail and fill location/description."""
|
||||
from . import immich as immich_api
|
||||
@@ -1677,6 +1899,24 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
self.async_set_updated_data(data)
|
||||
continue
|
||||
|
||||
if self.provider == PROVIDER_NEXTCLOUD:
|
||||
try:
|
||||
await self._enrich_nextcloud_item(item)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.debug("Nextcloud enrich error: %s", err)
|
||||
item.exif_scanned = True
|
||||
scanned_since_save += 1
|
||||
self._enrich_progress["exif_done"] = (
|
||||
self._enrich_progress.get("exif_done", 0) + 1
|
||||
)
|
||||
if scanned_since_save >= _EXIF_BATCH_SAVE:
|
||||
scanned_since_save = 0
|
||||
await self._save_cached_items(data)
|
||||
self.async_set_updated_data(data)
|
||||
continue
|
||||
|
||||
url = item.url
|
||||
if not url.startswith("file://"):
|
||||
item.exif_scanned = True
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
|
||||
"requirements": ["Pillow"],
|
||||
"version": "1.5.0"
|
||||
"version": "1.6.0"
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
},
|
||||
"synology": {
|
||||
"title": "Synology Photos",
|
||||
"description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Tip: use a dedicated read-only Photos account rather than an admin login.",
|
||||
"description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Choose Personal for your own photos and albums (this is also where albums shared with you appear); choose Shared Space only if your NAS has the shared team library enabled. Tip: use a dedicated Photos account rather than an admin login.",
|
||||
"data": {
|
||||
"synology_url": "DSM URL",
|
||||
"synology_username": "Username",
|
||||
@@ -98,12 +98,30 @@
|
||||
},
|
||||
"synology_select": {
|
||||
"title": "Synology source",
|
||||
"description": "Pick an album or show the whole library, and choose the image quality.",
|
||||
"description": "Choose what to show. Tick any mix of favorites, albums, people, places, tags and subjects; they are combined into one slideshow. Leave everything empty to show all photos in this space.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"album": "Album",
|
||||
"favorites": "Favorites",
|
||||
"albums": "Albums",
|
||||
"people": "People",
|
||||
"places": "Places",
|
||||
"tags": "Tags",
|
||||
"subjects": "Subjects",
|
||||
"synology_image_size": "Image quality"
|
||||
}
|
||||
},
|
||||
"nextcloud": {
|
||||
"title": "Nextcloud folder",
|
||||
"description": "Connect to a folder in your Nextcloud files over WebDAV. Enter the server address (e.g. http://192.168.1.10, or your Nextcloud domain over HTTPS), your username, and an app password (create one under Settings > Security > Devices and sessions - not your main login password). Point it at a folder path (e.g. Photos/Family), or leave the folder blank for your whole files root. Turn on recursive to include subfolders.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"nextcloud_url": "Server URL",
|
||||
"nextcloud_username": "Username",
|
||||
"nextcloud_password": "App password",
|
||||
"nextcloud_folder": "Folder path (optional)",
|
||||
"nextcloud_recursive": "Include subfolders",
|
||||
"nextcloud_image_size": "Image quality"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -122,13 +140,15 @@
|
||||
"invalid_icloud_url": "That does not look like an iCloud Shared Album link.",
|
||||
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share.",
|
||||
"synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.",
|
||||
"synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue."
|
||||
"synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue.",
|
||||
"synology_shared_unavailable": "Could not access the Shared Space. Enable Shared Space in Synology Photos and make sure this account has access to it, or choose Personal instead.",
|
||||
"nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Local Folder options",
|
||||
"title": "Location & privacy options",
|
||||
"description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.",
|
||||
"data": {
|
||||
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap"
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
},
|
||||
"synology": {
|
||||
"title": "Synology Photos",
|
||||
"description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Tip: use a dedicated read-only Photos account rather than an admin login.",
|
||||
"description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Choose Personal for your own photos and albums (this is also where albums shared with you appear); choose Shared Space only if your NAS has the shared team library enabled. Tip: use a dedicated Photos account rather than an admin login.",
|
||||
"data": {
|
||||
"synology_url": "DSM URL",
|
||||
"synology_username": "Username",
|
||||
@@ -98,12 +98,30 @@
|
||||
},
|
||||
"synology_select": {
|
||||
"title": "Synology source",
|
||||
"description": "Pick an album or show the whole library, and choose the image quality.",
|
||||
"description": "Choose what to show. Tick any mix of favorites, albums, people, places, tags and subjects; they are combined into one slideshow. Leave everything empty to show all photos in this space.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"album": "Album",
|
||||
"favorites": "Favorites",
|
||||
"albums": "Albums",
|
||||
"people": "People",
|
||||
"places": "Places",
|
||||
"tags": "Tags",
|
||||
"subjects": "Subjects",
|
||||
"synology_image_size": "Image quality"
|
||||
}
|
||||
},
|
||||
"nextcloud": {
|
||||
"title": "Nextcloud folder",
|
||||
"description": "Connect to a folder in your Nextcloud files over WebDAV. Enter the server address (e.g. http://192.168.1.10, or your Nextcloud domain over HTTPS), your username, and an app password (create one under Settings > Security > Devices and sessions - not your main login password). Point it at a folder path (e.g. Photos/Family), or leave the folder blank for your whole files root. Turn on recursive to include subfolders.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"nextcloud_url": "Server URL",
|
||||
"nextcloud_username": "Username",
|
||||
"nextcloud_password": "App password",
|
||||
"nextcloud_folder": "Folder path (optional)",
|
||||
"nextcloud_recursive": "Include subfolders",
|
||||
"nextcloud_image_size": "Image quality"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -122,13 +140,15 @@
|
||||
"invalid_icloud_url": "That does not look like an iCloud Shared Album link.",
|
||||
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share.",
|
||||
"synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.",
|
||||
"synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue."
|
||||
"synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue.",
|
||||
"synology_shared_unavailable": "Could not access the Shared Space. Enable Shared Space in Synology Photos and make sure this account has access to it, or choose Personal instead.",
|
||||
"nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Local Folder options",
|
||||
"title": "Location & privacy options",
|
||||
"description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.",
|
||||
"data": {
|
||||
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap"
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* tap_action: none # none | more-info
|
||||
*/
|
||||
|
||||
const VERSION = "1.5.0";
|
||||
const VERSION = "1.6.0";
|
||||
|
||||
const ANIMATED_TRANSITIONS = [
|
||||
"fade",
|
||||
|
||||
Reference in New Issue
Block a user