feat: enhance Synology Photos integration with OTP, SSL skip, and better UX

- Fix endpoint path: users now provide full base URL (e.g. https://nas:5001/photo)
- Add OTP/2FA field for Synology login
- Add skip SSL verification option (DB column + checkbox UI)
- Add device ID (synology_did) column for session tracking
- Trigger in-app notification when Synology session is cleared
- Show disconnection banner in MemoriesPanel
- Add URL hint in provider settings
- Map Synology API error codes to human-readable messages
- Update i18n for all locales
This commit is contained in:
jubnl
2026-04-11 18:25:22 +02:00
parent bcc37d6b7d
commit 7871c06059
24 changed files with 441 additions and 54 deletions
@@ -714,6 +714,23 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
{/* Disconnected banner — shown when photos exist but provider is unreachable */}
{!connected && allVisible.length > 0 && enabledProviders.length > 0 && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 16px', flexShrink: 0,
background: 'rgba(234,179,8,0.08)', borderBottom: '1px solid rgba(234,179,8,0.25)',
fontSize: 12, color: 'var(--text-muted)',
}}>
<Camera size={13} style={{ color: '#ca8a04', flexShrink: 0 }} />
<span>
{t('memories.providerDisconnectedBanner', {
provider_name: enabledProviders.length === 1 ? enabledProviders[0].name : enabledProviders.map(p => p.name).join(', ')
})}
</span>
</div>
)}
{/* Header */} {/* Header */}
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0 }}> <div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -11,6 +11,7 @@ interface ProviderField {
label: string label: string
input_type: string input_type: string
placeholder?: string | null placeholder?: string | null
hint?: string | null
required: boolean required: boolean
secret: boolean secret: boolean
settings_key?: string | null settings_key?: string | null
@@ -71,6 +72,10 @@ export default function PhotoProvidersSection(): React.ReactElement {
const payload: Record<string, unknown> = {} const payload: Record<string, unknown> = {}
for (const field of getProviderFields(provider)) { for (const field of getProviderFields(provider)) {
const payloadKey = field.payload_key || field.settings_key || field.key const payloadKey = field.payload_key || field.settings_key || field.key
if (field.input_type === 'checkbox') {
payload[payloadKey] = values[field.key] === 'true'
continue
}
const value = (values[field.key] || '').trim() const value = (values[field.key] || '').trim()
if (field.secret && !value) continue if (field.secret && !value) continue
payload[payloadKey] = value payload[payloadKey] = value
@@ -102,6 +107,18 @@ export default function PhotoProvidersSection(): React.ReactElement {
const cfg = getProviderConfig(provider) const cfg = getProviderConfig(provider)
const fields = getProviderFields(provider) const fields = getProviderFields(provider)
// Seed checkbox defaults before the async settings load resolves
const checkboxDefaults: Record<string, string> = {}
for (const field of fields) {
if (field.input_type === 'checkbox') checkboxDefaults[field.key] = 'false'
}
if (Object.keys(checkboxDefaults).length > 0) {
setProviderValues(prev => ({
...prev,
[provider.id]: { ...checkboxDefaults, ...(prev[provider.id] || {}) },
}))
}
if (cfg.settings_get) { if (cfg.settings_get) {
apiClient.get(cfg.settings_get).then(res => { apiClient.get(cfg.settings_get).then(res => {
if (isCancelled) return if (isCancelled) return
@@ -112,7 +129,13 @@ export default function PhotoProvidersSection(): React.ReactElement {
if (field.secret) continue if (field.secret) continue
const sourceKey = field.settings_key || field.payload_key || field.key const sourceKey = field.settings_key || field.payload_key || field.key
const rawValue = (res.data as Record<string, unknown>)[sourceKey] const rawValue = (res.data as Record<string, unknown>)[sourceKey]
nextValues[field.key] = typeof rawValue === 'string' ? rawValue : rawValue != null ? String(rawValue) : '' if (rawValue != null) {
nextValues[field.key] = typeof rawValue === 'string' ? rawValue : String(rawValue)
} else if (field.input_type === 'checkbox') {
nextValues[field.key] = 'false'
} else {
nextValues[field.key] = ''
}
} }
setProviderValues(prev => ({ setProviderValues(prev => ({
...prev, ...prev,
@@ -198,14 +221,31 @@ export default function PhotoProvidersSection(): React.ReactElement {
<div className="space-y-3"> <div className="space-y-3">
{fields.map(field => ( {fields.map(field => (
<div key={`${provider.id}-${field.key}`}> <div key={`${provider.id}-${field.key}`}>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label> {field.input_type === 'checkbox' ? (
<input <label className="flex items-center gap-2 cursor-pointer select-none">
type={field.input_type || 'text'} <input
value={values[field.key] || ''} type="checkbox"
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.value)} checked={values[field.key] === 'true'}
placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')} onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.checked ? 'true' : 'false')}
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" className="w-4 h-4 rounded border-slate-300 accent-slate-900"
/> />
<span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span>
</label>
) : (
<>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
<input
type={field.input_type || 'text'}
value={values[field.key] || ''}
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.value)}
placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')}
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
/>
{field.hint && (
<p className="mt-1 text-xs text-slate-500">{t(`memories.${field.hint}`)}</p>
)}
</>
)}
</div> </div>
))} ))}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -228,11 +268,16 @@ export default function PhotoProvidersSection(): React.ReactElement {
: <Camera className="w-4 h-4" />} : <Camera className="w-4 h-4" />}
{t('memories.testConnection')} {t('memories.testConnection')}
</button> </button>
{connected && ( {connected ? (
<span className="text-xs font-medium text-green-600 flex items-center gap-1"> <span className="text-xs font-medium text-green-600 flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full" /> <span className="w-2 h-2 bg-green-500 rounded-full" />
{t('memories.connected')} {t('memories.connected')}
</span> </span>
) : (
<span className="text-xs font-medium text-slate-400 flex items-center gap-1">
<span className="w-2 h-2 bg-slate-300 rounded-full" />
{t('memories.disconnected')}
</span>
)} )}
</div> </div>
</div> </div>
+14
View File
@@ -1438,6 +1438,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'memories.title': 'صور', 'memories.title': 'صور',
'memories.notConnected': 'Immich غير متصل', 'memories.notConnected': 'Immich غير متصل',
'memories.notConnectedHint': 'قم بتوصيل Immich في الإعدادات لعرض صور رحلتك هنا.', 'memories.notConnectedHint': 'قم بتوصيل Immich في الإعدادات لعرض صور رحلتك هنا.',
'memories.notConnectedMultipleHint': 'قم بتوصيل أحد موفري الصور هؤلاء: {provider_names} في الإعدادات لتتمكن من إضافة صور إلى هذه الرحلة.',
'memories.noDates': 'أضف تواريخ لرحلتك لتحميل الصور.', 'memories.noDates': 'أضف تواريخ لرحلتك لتحميل الصور.',
'memories.noPhotos': 'لم يتم العثور على صور', 'memories.noPhotos': 'لم يتم العثور على صور',
'memories.noPhotosHint': 'لم يتم العثور على صور في Immich لفترة هذه الرحلة.', 'memories.noPhotosHint': 'لم يتم العثور على صور في Immich لفترة هذه الرحلة.',
@@ -1448,6 +1449,13 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'memories.reviewTitle': 'مراجعة صورك', 'memories.reviewTitle': 'مراجعة صورك',
'memories.reviewHint': 'انقر على الصور لاستبعادها من المشاركة.', 'memories.reviewHint': 'انقر على الصور لاستبعادها من المشاركة.',
'memories.shareCount': 'مشاركة {count} صور', 'memories.shareCount': 'مشاركة {count} صور',
'memories.providerUrl': 'عنوان URL للخادم',
'memories.providerApiKey': 'مفتاح API',
'memories.providerUsername': 'اسم المستخدم',
'memories.providerPassword': 'كلمة المرور',
'memories.providerOTP': 'رمز MFA (إذا كان مفعلاً)',
'memories.skipSSLVerification': 'تخطي التحقق من شهادة SSL',
'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
'memories.testConnection': 'اختبار الاتصال', 'memories.testConnection': 'اختبار الاتصال',
'memories.testFirst': 'اختبر الاتصال أولاً', 'memories.testFirst': 'اختبر الاتصال أولاً',
'memories.connected': 'متصل', 'memories.connected': 'متصل',
@@ -1455,17 +1463,21 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'memories.connectionSuccess': 'تم الاتصال بـ Immich', 'memories.connectionSuccess': 'تم الاتصال بـ Immich',
'memories.connectionError': 'تعذر الاتصال بـ Immich', 'memories.connectionError': 'تعذر الاتصال بـ Immich',
'memories.saved': 'تم حفظ إعدادات Immich', 'memories.saved': 'تم حفظ إعدادات Immich',
'memories.providerDisconnectedBanner': 'اتصالك بـ {provider_name} مفقود. أعد الاتصال في الإعدادات لعرض الصور.',
'memories.saveError': 'تعذّر حفظ إعدادات {provider_name}',
'memories.oldest': 'الأقدم أولاً', 'memories.oldest': 'الأقدم أولاً',
'memories.newest': 'الأحدث أولاً', 'memories.newest': 'الأحدث أولاً',
'memories.allLocations': 'جميع المواقع', 'memories.allLocations': 'جميع المواقع',
'memories.addPhotos': 'إضافة صور', 'memories.addPhotos': 'إضافة صور',
'memories.linkAlbum': 'ربط ألبوم', 'memories.linkAlbum': 'ربط ألبوم',
'memories.selectAlbum': 'اختيار ألبوم Immich', 'memories.selectAlbum': 'اختيار ألبوم Immich',
'memories.selectAlbumMultiple': 'اختيار ألبوم',
'memories.noAlbums': 'لم يتم العثور على ألبومات', 'memories.noAlbums': 'لم يتم العثور على ألبومات',
'memories.syncAlbum': 'مزامنة الألبوم', 'memories.syncAlbum': 'مزامنة الألبوم',
'memories.unlinkAlbum': 'إلغاء الربط', 'memories.unlinkAlbum': 'إلغاء الربط',
'memories.photos': 'صور', 'memories.photos': 'صور',
'memories.selectPhotos': 'اختيار صور من Immich', 'memories.selectPhotos': 'اختيار صور من Immich',
'memories.selectPhotosMultiple': 'اختيار الصور',
'memories.selectHint': 'انقر على الصور لتحديدها.', 'memories.selectHint': 'انقر على الصور لتحديدها.',
'memories.selected': 'محدد', 'memories.selected': 'محدد',
'memories.addSelected': 'إضافة {count} صور', 'memories.addSelected': 'إضافة {count} صور',
@@ -1622,6 +1634,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'notifications.markUnread': 'تحديد كغير مقروء', 'notifications.markUnread': 'تحديد كغير مقروء',
'notifications.delete': 'حذف', 'notifications.delete': 'حذف',
'notifications.system': 'النظام', 'notifications.system': 'النظام',
'notifications.synologySessionCleared.title': 'تم قطع اتصال Synology Photos',
'notifications.synologySessionCleared.text': 'تغير خادمك أو حسابك — انتقل إلى الإعدادات لاختبار اتصالك مرة أخرى.',
'memories.error.loadAlbums': 'فشل تحميل الألبومات', 'memories.error.loadAlbums': 'فشل تحميل الألبومات',
'memories.error.linkAlbum': 'فشل ربط الألبوم', 'memories.error.linkAlbum': 'فشل ربط الألبوم',
'memories.error.unlinkAlbum': 'فشل إلغاء ربط الألبوم', 'memories.error.unlinkAlbum': 'فشل إلغاء ربط الألبوم',
+14
View File
@@ -1477,6 +1477,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'memories.title': 'Fotos', 'memories.title': 'Fotos',
'memories.notConnected': 'Immich não conectado', 'memories.notConnected': 'Immich não conectado',
'memories.notConnectedHint': 'Conecte sua instância Immich nas Configurações para ver suas fotos de viagem aqui.', 'memories.notConnectedHint': 'Conecte sua instância Immich nas Configurações para ver suas fotos de viagem aqui.',
'memories.notConnectedMultipleHint': 'Conecte um destes provedores de fotos: {provider_names} nas Configurações para poder adicionar fotos a esta viagem.',
'memories.noDates': 'Adicione datas à sua viagem para carregar fotos.', 'memories.noDates': 'Adicione datas à sua viagem para carregar fotos.',
'memories.noPhotos': 'Nenhuma foto encontrada', 'memories.noPhotos': 'Nenhuma foto encontrada',
'memories.noPhotosHint': 'Nenhuma foto encontrada no Immich para o período desta viagem.', 'memories.noPhotosHint': 'Nenhuma foto encontrada no Immich para o período desta viagem.',
@@ -1487,6 +1488,13 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'memories.reviewTitle': 'Revise suas fotos', 'memories.reviewTitle': 'Revise suas fotos',
'memories.reviewHint': 'Clique nas fotos para excluí-las do compartilhamento.', 'memories.reviewHint': 'Clique nas fotos para excluí-las do compartilhamento.',
'memories.shareCount': 'Compartilhar {count} fotos', 'memories.shareCount': 'Compartilhar {count} fotos',
'memories.providerUrl': 'URL do servidor',
'memories.providerApiKey': 'Chave de API',
'memories.providerUsername': 'Nome de usuário',
'memories.providerPassword': 'Senha',
'memories.providerOTP': 'Código MFA (se habilitado)',
'memories.skipSSLVerification': 'Pular verificação de certificado SSL',
'memories.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo',
'memories.testConnection': 'Testar conexão', 'memories.testConnection': 'Testar conexão',
'memories.testFirst': 'Teste a conexão primeiro', 'memories.testFirst': 'Teste a conexão primeiro',
'memories.connected': 'Conectado', 'memories.connected': 'Conectado',
@@ -1494,14 +1502,18 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'memories.connectionSuccess': 'Conectado ao Immich', 'memories.connectionSuccess': 'Conectado ao Immich',
'memories.connectionError': 'Não foi possível conectar ao Immich', 'memories.connectionError': 'Não foi possível conectar ao Immich',
'memories.saved': 'Configurações do Immich salvas', 'memories.saved': 'Configurações do Immich salvas',
'memories.providerDisconnectedBanner': 'Sua conexão com {provider_name} foi perdida. Reconecte nas Configurações para ver as fotos.',
'memories.saveError': 'Não foi possível salvar as configurações de {provider_name}',
'memories.addPhotos': 'Adicionar fotos', 'memories.addPhotos': 'Adicionar fotos',
'memories.linkAlbum': 'Vincular álbum', 'memories.linkAlbum': 'Vincular álbum',
'memories.selectAlbum': 'Selecionar álbum do Immich', 'memories.selectAlbum': 'Selecionar álbum do Immich',
'memories.selectAlbumMultiple': 'Selecionar álbum',
'memories.noAlbums': 'Nenhum álbum encontrado', 'memories.noAlbums': 'Nenhum álbum encontrado',
'memories.syncAlbum': 'Sincronizar álbum', 'memories.syncAlbum': 'Sincronizar álbum',
'memories.unlinkAlbum': 'Desvincular', 'memories.unlinkAlbum': 'Desvincular',
'memories.photos': 'fotos', 'memories.photos': 'fotos',
'memories.selectPhotos': 'Selecionar fotos do Immich', 'memories.selectPhotos': 'Selecionar fotos do Immich',
'memories.selectPhotosMultiple': 'Selecionar fotos',
'memories.selectHint': 'Toque nas fotos para selecioná-las.', 'memories.selectHint': 'Toque nas fotos para selecioná-las.',
'memories.selected': 'selecionadas', 'memories.selected': 'selecionadas',
'memories.addSelected': 'Adicionar {count} fotos', 'memories.addSelected': 'Adicionar {count} fotos',
@@ -1617,6 +1629,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'notifications.markUnread': 'Marcar como não lido', 'notifications.markUnread': 'Marcar como não lido',
'notifications.delete': 'Excluir', 'notifications.delete': 'Excluir',
'notifications.system': 'Sistema', 'notifications.system': 'Sistema',
'notifications.synologySessionCleared.title': 'Synology Photos desconectado',
'notifications.synologySessionCleared.text': 'Seu servidor ou conta foi alterado — vá para Configurações para testar sua conexão novamente.',
'memories.error.loadAlbums': 'Falha ao carregar álbuns', 'memories.error.loadAlbums': 'Falha ao carregar álbuns',
'memories.error.linkAlbum': 'Falha ao vincular álbum', 'memories.error.linkAlbum': 'Falha ao vincular álbum',
'memories.error.unlinkAlbum': 'Falha ao desvincular álbum', 'memories.error.unlinkAlbum': 'Falha ao desvincular álbum',
+14
View File
@@ -1436,6 +1436,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'memories.title': 'Fotky', 'memories.title': 'Fotky',
'memories.notConnected': 'Immich není připojen', 'memories.notConnected': 'Immich není připojen',
'memories.notConnectedHint': 'Připojte svoji instanci Immich v Nastavení, abyste zde viděli fotky z cest.', 'memories.notConnectedHint': 'Připojte svoji instanci Immich v Nastavení, abyste zde viděli fotky z cest.',
'memories.notConnectedMultipleHint': 'Pro přidání fotek k tomuto výletu připojte v Nastavení jednoho z těchto poskytovatelů fotek: {provider_names}.',
'memories.noDates': 'Přidejte data k cestě pro načtení fotek.', 'memories.noDates': 'Přidejte data k cestě pro načtení fotek.',
'memories.noPhotos': 'Nenalezeny žádné fotky', 'memories.noPhotos': 'Nenalezeny žádné fotky',
'memories.noPhotosHint': 'V Immich nebyly nalezeny žádné fotky pro období této cesty.', 'memories.noPhotosHint': 'V Immich nebyly nalezeny žádné fotky pro období této cesty.',
@@ -1446,6 +1447,13 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'memories.reviewTitle': 'Zkontrolujte své fotky', 'memories.reviewTitle': 'Zkontrolujte své fotky',
'memories.reviewHint': 'Klikněte na fotky pro vyloučení ze sdílení.', 'memories.reviewHint': 'Klikněte na fotky pro vyloučení ze sdílení.',
'memories.shareCount': 'Sdílet {count} fotek', 'memories.shareCount': 'Sdílet {count} fotek',
'memories.providerUrl': 'URL serveru',
'memories.providerApiKey': 'API klíč',
'memories.providerUsername': 'Uživatelské jméno',
'memories.providerPassword': 'Heslo',
'memories.providerOTP': 'MFA kód (pokud je povoleno)',
'memories.skipSSLVerification': 'Přeskočit ověření SSL certifikátu',
'memories.providerUrlHintSynology': 'Zahrňte cestu aplikace Photos do URL, např. https://nas:5001/photo',
'memories.testConnection': 'Otestovat připojení', 'memories.testConnection': 'Otestovat připojení',
'memories.testFirst': 'Nejprve otestujte připojení', 'memories.testFirst': 'Nejprve otestujte připojení',
'memories.connected': 'Připojeno', 'memories.connected': 'Připojeno',
@@ -1453,14 +1461,18 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'memories.connectionSuccess': 'Připojeno k Immich', 'memories.connectionSuccess': 'Připojeno k Immich',
'memories.connectionError': 'Nepodařilo se připojit k Immich', 'memories.connectionError': 'Nepodařilo se připojit k Immich',
'memories.saved': 'Nastavení Immich uloženo', 'memories.saved': 'Nastavení Immich uloženo',
'memories.providerDisconnectedBanner': 'Vaše připojení k {provider_name} bylo ztraceno. Obnovte připojení v Nastavení pro zobrazení fotek.',
'memories.saveError': 'Nepodařilo se uložit nastavení {provider_name}',
'memories.addPhotos': 'Přidat fotky', 'memories.addPhotos': 'Přidat fotky',
'memories.linkAlbum': 'Propojit album', 'memories.linkAlbum': 'Propojit album',
'memories.selectAlbum': 'Vybrat album z Immich', 'memories.selectAlbum': 'Vybrat album z Immich',
'memories.selectAlbumMultiple': 'Vybrat album',
'memories.noAlbums': 'Žádná alba nenalezena', 'memories.noAlbums': 'Žádná alba nenalezena',
'memories.syncAlbum': 'Synchronizovat album', 'memories.syncAlbum': 'Synchronizovat album',
'memories.unlinkAlbum': 'Odpojit', 'memories.unlinkAlbum': 'Odpojit',
'memories.photos': 'fotek', 'memories.photos': 'fotek',
'memories.selectPhotos': 'Vybrat fotky z Immich', 'memories.selectPhotos': 'Vybrat fotky z Immich',
'memories.selectPhotosMultiple': 'Vybrat fotky',
'memories.selectHint': 'Klepněte na fotky pro jejich výběr.', 'memories.selectHint': 'Klepněte na fotky pro jejich výběr.',
'memories.selected': 'vybráno', 'memories.selected': 'vybráno',
'memories.addSelected': 'Přidat {count} fotek', 'memories.addSelected': 'Přidat {count} fotek',
@@ -1620,6 +1632,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'notifications.markUnread': 'Označit jako nepřečtené', 'notifications.markUnread': 'Označit jako nepřečtené',
'notifications.delete': 'Smazat', 'notifications.delete': 'Smazat',
'notifications.system': 'Systém', 'notifications.system': 'Systém',
'notifications.synologySessionCleared.title': 'Synology Photos odpojeno',
'notifications.synologySessionCleared.text': 'Váš server nebo účet se změnil — přejděte do Nastavení a znovu otestujte připojení.',
'settings.mustChangePassword': 'Před pokračováním musíte změnit heslo.', 'settings.mustChangePassword': 'Před pokračováním musíte změnit heslo.',
'atlas.searchCountry': 'Hledat zemi...', 'atlas.searchCountry': 'Hledat zemi...',
'memories.error.loadAlbums': 'Načtení alb se nezdařilo', 'memories.error.loadAlbums': 'Načtení alb se nezdařilo',
+14
View File
@@ -1436,6 +1436,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'memories.title': 'Fotos', 'memories.title': 'Fotos',
'memories.notConnected': 'Immich nicht verbunden', 'memories.notConnected': 'Immich nicht verbunden',
'memories.notConnectedHint': 'Verbinde deine Immich-Instanz in den Einstellungen, um deine Reisefotos hier zu sehen.', 'memories.notConnectedHint': 'Verbinde deine Immich-Instanz in den Einstellungen, um deine Reisefotos hier zu sehen.',
'memories.notConnectedMultipleHint': 'Verbinde einen dieser Fotoanbieter: {provider_names} in den Einstellungen, um Fotos zu dieser Reise hinzufügen zu können.',
'memories.noDates': 'Füge Daten zu deiner Reise hinzu, um Fotos zu laden.', 'memories.noDates': 'Füge Daten zu deiner Reise hinzu, um Fotos zu laden.',
'memories.noPhotos': 'Keine Fotos gefunden', 'memories.noPhotos': 'Keine Fotos gefunden',
'memories.noPhotosHint': 'Keine Fotos in Immich für den Zeitraum dieser Reise gefunden.', 'memories.noPhotosHint': 'Keine Fotos in Immich für den Zeitraum dieser Reise gefunden.',
@@ -1446,6 +1447,13 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'memories.reviewTitle': 'Deine Fotos prüfen', 'memories.reviewTitle': 'Deine Fotos prüfen',
'memories.reviewHint': 'Klicke auf Fotos, um sie vom Teilen auszuschließen.', 'memories.reviewHint': 'Klicke auf Fotos, um sie vom Teilen auszuschließen.',
'memories.shareCount': '{count} Fotos teilen', 'memories.shareCount': '{count} Fotos teilen',
'memories.providerUrl': 'Server-URL',
'memories.providerApiKey': 'API-Schlüssel',
'memories.providerUsername': 'Benutzername',
'memories.providerPassword': 'Passwort',
'memories.providerOTP': 'MFA-Code (falls aktiviert)',
'memories.skipSSLVerification': 'SSL-Zertifikatsprüfung überspringen',
'memories.providerUrlHintSynology': 'Füge den Fotos-App-Pfad in die URL ein, z.B. https://nas:5001/photo',
'memories.testConnection': 'Verbindung testen', 'memories.testConnection': 'Verbindung testen',
'memories.testFirst': 'Verbindung zuerst testen', 'memories.testFirst': 'Verbindung zuerst testen',
'memories.connected': 'Verbunden', 'memories.connected': 'Verbunden',
@@ -1453,14 +1461,18 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'memories.connectionSuccess': 'Verbindung zu Immich hergestellt', 'memories.connectionSuccess': 'Verbindung zu Immich hergestellt',
'memories.connectionError': 'Verbindung zu Immich fehlgeschlagen', 'memories.connectionError': 'Verbindung zu Immich fehlgeschlagen',
'memories.saved': 'Immich-Einstellungen gespeichert', 'memories.saved': 'Immich-Einstellungen gespeichert',
'memories.providerDisconnectedBanner': 'Deine {provider_name}-Verbindung wurde getrennt. Verbinde erneut in den Einstellungen, um Fotos anzuzeigen.',
'memories.saveError': '{provider_name}-Einstellungen konnten nicht gespeichert werden',
'memories.addPhotos': 'Fotos hinzufügen', 'memories.addPhotos': 'Fotos hinzufügen',
'memories.linkAlbum': 'Album verknüpfen', 'memories.linkAlbum': 'Album verknüpfen',
'memories.selectAlbum': 'Immich-Album auswählen', 'memories.selectAlbum': 'Immich-Album auswählen',
'memories.selectAlbumMultiple': 'Album auswählen',
'memories.noAlbums': 'Keine Alben gefunden', 'memories.noAlbums': 'Keine Alben gefunden',
'memories.syncAlbum': 'Album synchronisieren', 'memories.syncAlbum': 'Album synchronisieren',
'memories.unlinkAlbum': 'Album trennen', 'memories.unlinkAlbum': 'Album trennen',
'memories.photos': 'Fotos', 'memories.photos': 'Fotos',
'memories.selectPhotos': 'Fotos aus Immich auswählen', 'memories.selectPhotos': 'Fotos aus Immich auswählen',
'memories.selectPhotosMultiple': 'Fotos auswählen',
'memories.selectHint': 'Tippe auf Fotos um sie auszuwählen.', 'memories.selectHint': 'Tippe auf Fotos um sie auszuwählen.',
'memories.selected': 'ausgewählt', 'memories.selected': 'ausgewählt',
'memories.addSelected': '{count} Fotos hinzufügen', 'memories.addSelected': '{count} Fotos hinzufügen',
@@ -1620,6 +1632,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'notifications.markUnread': 'Als ungelesen markieren', 'notifications.markUnread': 'Als ungelesen markieren',
'notifications.delete': 'Löschen', 'notifications.delete': 'Löschen',
'notifications.system': 'System', 'notifications.system': 'System',
'notifications.synologySessionCleared.title': 'Synology Photos getrennt',
'notifications.synologySessionCleared.text': 'Dein Server oder Konto hat sich geändert — gehe zu Einstellungen, um deine Verbindung erneut zu testen.',
'memories.error.loadAlbums': 'Alben konnten nicht geladen werden', 'memories.error.loadAlbums': 'Alben konnten nicht geladen werden',
'memories.error.linkAlbum': 'Album konnte nicht verknüpft werden', 'memories.error.linkAlbum': 'Album konnte nicht verknüpft werden',
'memories.error.unlinkAlbum': 'Album konnte nicht getrennt werden', 'memories.error.unlinkAlbum': 'Album konnte nicht getrennt werden',
+6
View File
@@ -1475,6 +1475,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'memories.providerApiKey': 'API Key', 'memories.providerApiKey': 'API Key',
'memories.providerUsername': 'Username', 'memories.providerUsername': 'Username',
'memories.providerPassword': 'Password', 'memories.providerPassword': 'Password',
'memories.providerOTP': 'MFA code (if enabled)',
'memories.skipSSLVerification': 'Skip SSL certificate verification',
'memories.providerUrlHintSynology': 'Include the Photos app path in the URL, e.g. https://nas:5001/photo',
'memories.testConnection': 'Test connection', 'memories.testConnection': 'Test connection',
'memories.testFirst': 'Test connection first', 'memories.testFirst': 'Test connection first',
'memories.connected': 'Connected', 'memories.connected': 'Connected',
@@ -1482,6 +1485,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'memories.connectionSuccess': 'Connected to {provider_name}', 'memories.connectionSuccess': 'Connected to {provider_name}',
'memories.connectionError': 'Could not connect to {provider_name}', 'memories.connectionError': 'Could not connect to {provider_name}',
'memories.saved': '{provider_name} settings saved', 'memories.saved': '{provider_name} settings saved',
'memories.providerDisconnectedBanner': 'Your {provider_name} connection is lost. Reconnect in Settings to view photos.',
'memories.saveError': 'Could not save {provider_name} settings', 'memories.saveError': 'Could not save {provider_name} settings',
//------------------------ //------------------------
'memories.addPhotos': 'Add photos', 'memories.addPhotos': 'Add photos',
@@ -1664,6 +1668,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'notifications.markUnread': 'Mark as unread', 'notifications.markUnread': 'Mark as unread',
'notifications.delete': 'Delete', 'notifications.delete': 'Delete',
'notifications.system': 'System', 'notifications.system': 'System',
'notifications.synologySessionCleared.title': 'Synology Photos disconnected',
'notifications.synologySessionCleared.text': 'Your server or account changed — go to Settings to test your connection again.',
// Notification test keys (dev only) // Notification test keys (dev only)
'notifications.versionAvailable.title': 'Update Available', 'notifications.versionAvailable.title': 'Update Available',
+14
View File
@@ -1387,6 +1387,7 @@ const es: Record<string, string> = {
'memories.title': 'Fotos', 'memories.title': 'Fotos',
'memories.notConnected': 'Immich no conectado', 'memories.notConnected': 'Immich no conectado',
'memories.notConnectedHint': 'Conecta tu instancia de Immich en Ajustes para ver tus fotos de viaje aquí.', 'memories.notConnectedHint': 'Conecta tu instancia de Immich en Ajustes para ver tus fotos de viaje aquí.',
'memories.notConnectedMultipleHint': 'Conecta alguno de estos proveedores de fotos: {provider_names} en Configuración para poder añadir fotos a este viaje.',
'memories.noDates': 'Añade fechas a tu viaje para cargar fotos.', 'memories.noDates': 'Añade fechas a tu viaje para cargar fotos.',
'memories.noPhotos': 'No se encontraron fotos', 'memories.noPhotos': 'No se encontraron fotos',
'memories.noPhotosHint': 'No se encontraron fotos en Immich para el rango de fechas de este viaje.', 'memories.noPhotosHint': 'No se encontraron fotos en Immich para el rango de fechas de este viaje.',
@@ -1397,6 +1398,13 @@ const es: Record<string, string> = {
'memories.reviewTitle': 'Revisar tus fotos', 'memories.reviewTitle': 'Revisar tus fotos',
'memories.reviewHint': 'Haz clic en las fotos para excluirlas de compartir.', 'memories.reviewHint': 'Haz clic en las fotos para excluirlas de compartir.',
'memories.shareCount': 'Compartir {count} fotos', 'memories.shareCount': 'Compartir {count} fotos',
'memories.providerUrl': 'URL del servidor',
'memories.providerApiKey': 'Clave API',
'memories.providerUsername': 'Nombre de usuario',
'memories.providerPassword': 'Contraseña',
'memories.providerOTP': 'Código MFA (si está habilitado)',
'memories.skipSSLVerification': 'Omitir verificación del certificado SSL',
'memories.providerUrlHintSynology': 'Incluye la ruta de la aplicación Photos en la URL, p.ej. https://nas:5001/photo',
'memories.testConnection': 'Probar conexión', 'memories.testConnection': 'Probar conexión',
'memories.testFirst': 'Probar conexión primero', 'memories.testFirst': 'Probar conexión primero',
'memories.connected': 'Conectado', 'memories.connected': 'Conectado',
@@ -1404,17 +1412,21 @@ const es: Record<string, string> = {
'memories.connectionSuccess': 'Conectado a Immich', 'memories.connectionSuccess': 'Conectado a Immich',
'memories.connectionError': 'No se pudo conectar a Immich', 'memories.connectionError': 'No se pudo conectar a Immich',
'memories.saved': 'Configuración de Immich guardada', 'memories.saved': 'Configuración de Immich guardada',
'memories.providerDisconnectedBanner': 'Se perdió la conexión con {provider_name}. Vuelve a conectar en Configuración para ver las fotos.',
'memories.saveError': 'No se pudieron guardar los ajustes de {provider_name}',
'memories.oldest': 'Más antiguas', 'memories.oldest': 'Más antiguas',
'memories.newest': 'Más recientes', 'memories.newest': 'Más recientes',
'memories.allLocations': 'Todas las ubicaciones', 'memories.allLocations': 'Todas las ubicaciones',
'memories.addPhotos': 'Añadir fotos', 'memories.addPhotos': 'Añadir fotos',
'memories.linkAlbum': 'Vincular álbum', 'memories.linkAlbum': 'Vincular álbum',
'memories.selectAlbum': 'Seleccionar álbum de Immich', 'memories.selectAlbum': 'Seleccionar álbum de Immich',
'memories.selectAlbumMultiple': 'Seleccionar álbum',
'memories.noAlbums': 'No se encontraron álbumes', 'memories.noAlbums': 'No se encontraron álbumes',
'memories.syncAlbum': 'Sincronizar álbum', 'memories.syncAlbum': 'Sincronizar álbum',
'memories.unlinkAlbum': 'Desvincular', 'memories.unlinkAlbum': 'Desvincular',
'memories.photos': 'fotos', 'memories.photos': 'fotos',
'memories.selectPhotos': 'Seleccionar fotos de Immich', 'memories.selectPhotos': 'Seleccionar fotos de Immich',
'memories.selectPhotosMultiple': 'Seleccionar fotos',
'memories.selectHint': 'Toca las fotos para seleccionarlas.', 'memories.selectHint': 'Toca las fotos para seleccionarlas.',
'memories.selected': 'seleccionado(s)', 'memories.selected': 'seleccionado(s)',
'memories.addSelected': 'Añadir {count} fotos', 'memories.addSelected': 'Añadir {count} fotos',
@@ -1624,6 +1636,8 @@ const es: Record<string, string> = {
'notifications.markUnread': 'Marcar como no leída', 'notifications.markUnread': 'Marcar como no leída',
'notifications.delete': 'Eliminar', 'notifications.delete': 'Eliminar',
'notifications.system': 'Sistema', 'notifications.system': 'Sistema',
'notifications.synologySessionCleared.title': 'Synology Photos desconectado',
'notifications.synologySessionCleared.text': 'Tu servidor o cuenta ha cambiado — ve a Configuración para probar la conexión de nuevo.',
'memories.error.loadAlbums': 'Error al cargar los álbumes', 'memories.error.loadAlbums': 'Error al cargar los álbumes',
'memories.error.linkAlbum': 'Error al vincular el álbum', 'memories.error.linkAlbum': 'Error al vincular el álbum',
'memories.error.unlinkAlbum': 'Error al desvincular el álbum', 'memories.error.unlinkAlbum': 'Error al desvincular el álbum',
+14
View File
@@ -1434,6 +1434,7 @@ const fr: Record<string, string> = {
'memories.title': 'Photos', 'memories.title': 'Photos',
'memories.notConnected': 'Immich non connecté', 'memories.notConnected': 'Immich non connecté',
'memories.notConnectedHint': 'Connectez votre instance Immich dans les paramètres pour voir vos photos de voyage ici.', 'memories.notConnectedHint': 'Connectez votre instance Immich dans les paramètres pour voir vos photos de voyage ici.',
'memories.notConnectedMultipleHint': 'Connectez un de ces fournisseurs de photos : {provider_names} dans les Paramètres pour pouvoir ajouter des photos à ce voyage.',
'memories.noDates': 'Ajoutez des dates à votre voyage pour charger les photos.', 'memories.noDates': 'Ajoutez des dates à votre voyage pour charger les photos.',
'memories.noPhotos': 'Aucune photo trouvée', 'memories.noPhotos': 'Aucune photo trouvée',
'memories.noPhotosHint': 'Aucune photo trouvée dans Immich pour la période de ce voyage.', 'memories.noPhotosHint': 'Aucune photo trouvée dans Immich pour la période de ce voyage.',
@@ -1444,6 +1445,13 @@ const fr: Record<string, string> = {
'memories.reviewTitle': 'Vérifier vos photos', 'memories.reviewTitle': 'Vérifier vos photos',
'memories.reviewHint': 'Cliquez sur les photos pour les exclure du partage.', 'memories.reviewHint': 'Cliquez sur les photos pour les exclure du partage.',
'memories.shareCount': 'Partager {count} photos', 'memories.shareCount': 'Partager {count} photos',
'memories.providerUrl': 'URL du serveur',
'memories.providerApiKey': 'Clé API',
'memories.providerUsername': 'Nom d\'utilisateur',
'memories.providerPassword': 'Mot de passe',
'memories.providerOTP': 'Code MFA (si activé)',
'memories.skipSSLVerification': 'Ignorer la vérification du certificat SSL',
'memories.providerUrlHintSynology': 'Incluez le chemin de l\'application Photos dans l\'URL, ex. https://nas:5001/photo',
'memories.testConnection': 'Tester la connexion', 'memories.testConnection': 'Tester la connexion',
'memories.testFirst': 'Testez la connexion avant de sauvegarder', 'memories.testFirst': 'Testez la connexion avant de sauvegarder',
'memories.connected': 'Connecté', 'memories.connected': 'Connecté',
@@ -1451,17 +1459,21 @@ const fr: Record<string, string> = {
'memories.connectionSuccess': 'Connecté à Immich', 'memories.connectionSuccess': 'Connecté à Immich',
'memories.connectionError': 'Impossible de se connecter à Immich', 'memories.connectionError': 'Impossible de se connecter à Immich',
'memories.saved': 'Paramètres Immich enregistrés', 'memories.saved': 'Paramètres Immich enregistrés',
'memories.providerDisconnectedBanner': 'Votre connexion {provider_name} est perdue. Reconnectez-vous dans les Paramètres pour voir les photos.',
'memories.saveError': 'Impossible d\'enregistrer les paramètres de {provider_name}',
'memories.oldest': 'Plus anciennes', 'memories.oldest': 'Plus anciennes',
'memories.newest': 'Plus récentes', 'memories.newest': 'Plus récentes',
'memories.allLocations': 'Tous les lieux', 'memories.allLocations': 'Tous les lieux',
'memories.addPhotos': 'Ajouter des photos', 'memories.addPhotos': 'Ajouter des photos',
'memories.linkAlbum': 'Lier un album', 'memories.linkAlbum': 'Lier un album',
'memories.selectAlbum': 'Choisir un album Immich', 'memories.selectAlbum': 'Choisir un album Immich',
'memories.selectAlbumMultiple': 'Sélectionner un album',
'memories.noAlbums': 'Aucun album trouvé', 'memories.noAlbums': 'Aucun album trouvé',
'memories.syncAlbum': 'Synchroniser', 'memories.syncAlbum': 'Synchroniser',
'memories.unlinkAlbum': 'Délier', 'memories.unlinkAlbum': 'Délier',
'memories.photos': 'photos', 'memories.photos': 'photos',
'memories.selectPhotos': 'Sélectionner des photos depuis Immich', 'memories.selectPhotos': 'Sélectionner des photos depuis Immich',
'memories.selectPhotosMultiple': 'Sélectionner des photos',
'memories.selectHint': 'Appuyez sur les photos pour les sélectionner.', 'memories.selectHint': 'Appuyez sur les photos pour les sélectionner.',
'memories.selected': 'sélectionné(s)', 'memories.selected': 'sélectionné(s)',
'memories.addSelected': 'Ajouter {count} photos', 'memories.addSelected': 'Ajouter {count} photos',
@@ -1618,6 +1630,8 @@ const fr: Record<string, string> = {
'notifications.markUnread': 'Marquer comme non lu', 'notifications.markUnread': 'Marquer comme non lu',
'notifications.delete': 'Supprimer', 'notifications.delete': 'Supprimer',
'notifications.system': 'Système', 'notifications.system': 'Système',
'notifications.synologySessionCleared.title': 'Synology Photos déconnecté',
'notifications.synologySessionCleared.text': 'Votre serveur ou compte a changé — allez dans Paramètres pour tester à nouveau votre connexion.',
'memories.error.loadAlbums': 'Impossible de charger les albums', 'memories.error.loadAlbums': 'Impossible de charger les albums',
'memories.error.linkAlbum': 'Impossible de lier l\'album', 'memories.error.linkAlbum': 'Impossible de lier l\'album',
'memories.error.unlinkAlbum': 'Impossible de dissocier l\'album', 'memories.error.unlinkAlbum': 'Impossible de dissocier l\'album',
+14
View File
@@ -1505,6 +1505,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'memories.title': 'Fotók', 'memories.title': 'Fotók',
'memories.notConnected': 'Immich nincs csatlakoztatva', 'memories.notConnected': 'Immich nincs csatlakoztatva',
'memories.notConnectedHint': 'Csatlakoztasd az Immich példányodat a Beállításokban, hogy itt lásd az utazási fotóidat.', 'memories.notConnectedHint': 'Csatlakoztasd az Immich példányodat a Beállításokban, hogy itt lásd az utazási fotóidat.',
'memories.notConnectedMultipleHint': 'A fényképek hozzáadásához csatlakoztasson egyet a következő fényképszolgáltatók közül a Beállításokban: {provider_names}.',
'memories.noDates': 'Adj hozzá dátumokat az utazáshoz a fotók betöltéséhez.', 'memories.noDates': 'Adj hozzá dátumokat az utazáshoz a fotók betöltéséhez.',
'memories.noPhotos': 'Nem találhatók fotók', 'memories.noPhotos': 'Nem találhatók fotók',
'memories.noPhotosHint': 'Nem találhatók fotók az Immichben erre az utazási időszakra.', 'memories.noPhotosHint': 'Nem találhatók fotók az Immichben erre az utazási időszakra.',
@@ -1515,6 +1516,13 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'memories.reviewTitle': 'Nézd át a fotóidat', 'memories.reviewTitle': 'Nézd át a fotóidat',
'memories.reviewHint': 'Kattints a fotókra a megosztásból való kizáráshoz.', 'memories.reviewHint': 'Kattints a fotókra a megosztásból való kizáráshoz.',
'memories.shareCount': '{count} fotó megosztása', 'memories.shareCount': '{count} fotó megosztása',
'memories.providerUrl': 'Szerver URL',
'memories.providerApiKey': 'API kulcs',
'memories.providerUsername': 'Felhasználónév',
'memories.providerPassword': 'Jelszó',
'memories.providerOTP': 'MFA kód (ha engedélyezve van)',
'memories.skipSSLVerification': 'SSL tanúsítvány ellenőrzésének kihagyása',
'memories.providerUrlHintSynology': 'Adja meg a Photos alkalmazás elérési útját az URL-ben, pl. https://nas:5001/photo',
'memories.testConnection': 'Kapcsolat tesztelése', 'memories.testConnection': 'Kapcsolat tesztelése',
'memories.testFirst': 'Először teszteld a kapcsolatot', 'memories.testFirst': 'Először teszteld a kapcsolatot',
'memories.connected': 'Csatlakoztatva', 'memories.connected': 'Csatlakoztatva',
@@ -1522,14 +1530,18 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'memories.connectionSuccess': 'Csatlakozva az Immichhez', 'memories.connectionSuccess': 'Csatlakozva az Immichhez',
'memories.connectionError': 'Nem sikerült csatlakozni az Immichhez', 'memories.connectionError': 'Nem sikerült csatlakozni az Immichhez',
'memories.saved': 'Immich beállítások mentve', 'memories.saved': 'Immich beállítások mentve',
'memories.providerDisconnectedBanner': 'A {provider_name} kapcsolat megszakadt. Csatlakozzon újra a Beállításokban a fényképek megtekintéséhez.',
'memories.saveError': 'Nem sikerült menteni a(z) {provider_name} beállításait',
'memories.addPhotos': 'Fotók hozzáadása', 'memories.addPhotos': 'Fotók hozzáadása',
'memories.linkAlbum': 'Album csatolása', 'memories.linkAlbum': 'Album csatolása',
'memories.selectAlbum': 'Immich album kiválasztása', 'memories.selectAlbum': 'Immich album kiválasztása',
'memories.selectAlbumMultiple': 'Album kiválasztása',
'memories.noAlbums': 'Nem található album', 'memories.noAlbums': 'Nem található album',
'memories.syncAlbum': 'Album szinkronizálása', 'memories.syncAlbum': 'Album szinkronizálása',
'memories.unlinkAlbum': 'Leválasztás', 'memories.unlinkAlbum': 'Leválasztás',
'memories.photos': 'fotó', 'memories.photos': 'fotó',
'memories.selectPhotos': 'Fotók kiválasztása az Immichből', 'memories.selectPhotos': 'Fotók kiválasztása az Immichből',
'memories.selectPhotosMultiple': 'Fényképek kiválasztása',
'memories.selectHint': 'Koppints a fotókra a kijelölésükhöz.', 'memories.selectHint': 'Koppints a fotókra a kijelölésükhöz.',
'memories.selected': 'kijelölve', 'memories.selected': 'kijelölve',
'memories.addSelected': '{count} fotó hozzáadása', 'memories.addSelected': '{count} fotó hozzáadása',
@@ -1619,6 +1631,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'notifications.markUnread': 'Olvasatlannak jelölés', 'notifications.markUnread': 'Olvasatlannak jelölés',
'notifications.delete': 'Törlés', 'notifications.delete': 'Törlés',
'notifications.system': 'Rendszer', 'notifications.system': 'Rendszer',
'notifications.synologySessionCleared.title': 'Synology Photos leválasztva',
'notifications.synologySessionCleared.text': 'A szerver vagy a fiók megváltozott — lépjen a Beállításokba a kapcsolat újrateszteléséhez.',
'memories.error.loadAlbums': 'Az albumok betöltése sikertelen', 'memories.error.loadAlbums': 'Az albumok betöltése sikertelen',
'memories.error.linkAlbum': 'Az album csatolása sikertelen', 'memories.error.linkAlbum': 'Az album csatolása sikertelen',
'memories.error.unlinkAlbum': 'Az album leválasztása sikertelen', 'memories.error.unlinkAlbum': 'Az album leválasztása sikertelen',
+14
View File
@@ -1435,6 +1435,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'memories.title': 'Foto', 'memories.title': 'Foto',
'memories.notConnected': 'Immich non connesso', 'memories.notConnected': 'Immich non connesso',
'memories.notConnectedHint': 'Connetti la tua istanza Immich nelle Impostazioni per vedere qui le foto del tuo viaggio.', 'memories.notConnectedHint': 'Connetti la tua istanza Immich nelle Impostazioni per vedere qui le foto del tuo viaggio.',
'memories.notConnectedMultipleHint': 'Collega uno di questi provider di foto: {provider_names} nelle Impostazioni per poter aggiungere foto a questo viaggio.',
'memories.noDates': 'Aggiungi le date al tuo viaggio per caricare le foto.', 'memories.noDates': 'Aggiungi le date al tuo viaggio per caricare le foto.',
'memories.noPhotos': 'Nessuna foto trovata', 'memories.noPhotos': 'Nessuna foto trovata',
'memories.noPhotosHint': 'Nessuna foto trovata in Immich per l\'intervallo di date di questo viaggio.', 'memories.noPhotosHint': 'Nessuna foto trovata in Immich per l\'intervallo di date di questo viaggio.',
@@ -1445,6 +1446,13 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'memories.reviewTitle': 'Rivedi le tue foto', 'memories.reviewTitle': 'Rivedi le tue foto',
'memories.reviewHint': 'Clicca sulle foto per escluderle dalla condivisione.', 'memories.reviewHint': 'Clicca sulle foto per escluderle dalla condivisione.',
'memories.shareCount': 'Condividi {count} foto', 'memories.shareCount': 'Condividi {count} foto',
'memories.providerUrl': 'URL del server',
'memories.providerApiKey': 'Chiave API',
'memories.providerUsername': 'Nome utente',
'memories.providerPassword': 'Password',
'memories.providerOTP': 'Codice MFA (se abilitato)',
'memories.skipSSLVerification': 'Ignora la verifica del certificato SSL',
'memories.providerUrlHintSynology': 'Includi il percorso dell\'app Foto nell\'URL, es. https://nas:5001/photo',
'memories.testConnection': 'Test connessione', 'memories.testConnection': 'Test connessione',
'memories.testFirst': 'Testa prima la connessione', 'memories.testFirst': 'Testa prima la connessione',
'memories.connected': 'Connesso', 'memories.connected': 'Connesso',
@@ -1452,14 +1460,18 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'memories.connectionSuccess': 'Connesso a Immich', 'memories.connectionSuccess': 'Connesso a Immich',
'memories.connectionError': 'Impossibile connettersi a Immich', 'memories.connectionError': 'Impossibile connettersi a Immich',
'memories.saved': 'Impostazioni Immich salvate', 'memories.saved': 'Impostazioni Immich salvate',
'memories.providerDisconnectedBanner': 'La connessione a {provider_name} è persa. Riconnetti nelle Impostazioni per visualizzare le foto.',
'memories.saveError': 'Impossibile salvare le impostazioni di {provider_name}',
'memories.addPhotos': 'Aggiungi foto', 'memories.addPhotos': 'Aggiungi foto',
'memories.linkAlbum': 'Collega album', 'memories.linkAlbum': 'Collega album',
'memories.selectAlbum': 'Seleziona album Immich', 'memories.selectAlbum': 'Seleziona album Immich',
'memories.selectAlbumMultiple': 'Seleziona album',
'memories.noAlbums': 'Nessun album trovato', 'memories.noAlbums': 'Nessun album trovato',
'memories.syncAlbum': 'Sincronizza album', 'memories.syncAlbum': 'Sincronizza album',
'memories.unlinkAlbum': 'Scollega', 'memories.unlinkAlbum': 'Scollega',
'memories.photos': 'foto', 'memories.photos': 'foto',
'memories.selectPhotos': 'Seleziona foto da Immich', 'memories.selectPhotos': 'Seleziona foto da Immich',
'memories.selectPhotosMultiple': 'Seleziona foto',
'memories.selectHint': 'Tocca le foto per selezionarle.', 'memories.selectHint': 'Tocca le foto per selezionarle.',
'memories.selected': 'selezionate', 'memories.selected': 'selezionate',
'memories.addSelected': 'Aggiungi {count} foto', 'memories.addSelected': 'Aggiungi {count} foto',
@@ -1621,6 +1633,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'notifications.markUnread': 'Segna come non letto', 'notifications.markUnread': 'Segna come non letto',
'notifications.delete': 'Elimina', 'notifications.delete': 'Elimina',
'notifications.system': 'Sistema', 'notifications.system': 'Sistema',
'notifications.synologySessionCleared.title': 'Synology Photos disconnesso',
'notifications.synologySessionCleared.text': 'Il server o l\'account è cambiato — vai alle Impostazioni per testare nuovamente la connessione.',
'memories.error.loadAlbums': 'Caricamento album non riuscito', 'memories.error.loadAlbums': 'Caricamento album non riuscito',
'memories.error.linkAlbum': 'Collegamento album non riuscito', 'memories.error.linkAlbum': 'Collegamento album non riuscito',
'memories.error.unlinkAlbum': 'Scollegamento album non riuscito', 'memories.error.unlinkAlbum': 'Scollegamento album non riuscito',
+14
View File
@@ -1434,6 +1434,7 @@ const nl: Record<string, string> = {
'memories.title': 'Foto\'s', 'memories.title': 'Foto\'s',
'memories.notConnected': 'Immich niet verbonden', 'memories.notConnected': 'Immich niet verbonden',
'memories.notConnectedHint': 'Verbind je Immich-instantie in Instellingen om je reisfoto\'s hier te zien.', 'memories.notConnectedHint': 'Verbind je Immich-instantie in Instellingen om je reisfoto\'s hier te zien.',
'memories.notConnectedMultipleHint': 'Verbind een van deze fotoproviders: {provider_names} in Instellingen om foto\'s aan dit reisplan toe te voegen.',
'memories.noDates': 'Voeg data toe aan je reis om foto\'s te laden.', 'memories.noDates': 'Voeg data toe aan je reis om foto\'s te laden.',
'memories.noPhotos': 'Geen foto\'s gevonden', 'memories.noPhotos': 'Geen foto\'s gevonden',
'memories.noPhotosHint': 'Geen foto\'s gevonden in Immich voor de datumreeks van deze reis.', 'memories.noPhotosHint': 'Geen foto\'s gevonden in Immich voor de datumreeks van deze reis.',
@@ -1444,6 +1445,13 @@ const nl: Record<string, string> = {
'memories.reviewTitle': 'Je foto\'s bekijken', 'memories.reviewTitle': 'Je foto\'s bekijken',
'memories.reviewHint': 'Klik op foto\'s om ze uit te sluiten van delen.', 'memories.reviewHint': 'Klik op foto\'s om ze uit te sluiten van delen.',
'memories.shareCount': '{count} foto\'s delen', 'memories.shareCount': '{count} foto\'s delen',
'memories.providerUrl': 'Server-URL',
'memories.providerApiKey': 'API-sleutel',
'memories.providerUsername': 'Gebruikersnaam',
'memories.providerPassword': 'Wachtwoord',
'memories.providerOTP': 'MFA-code (indien ingeschakeld)',
'memories.skipSSLVerification': 'SSL-certificaatverificatie overslaan',
'memories.providerUrlHintSynology': 'Voeg het pad van de Photos-app toe aan de URL, bijv. https://nas:5001/photo',
'memories.testConnection': 'Verbinding testen', 'memories.testConnection': 'Verbinding testen',
'memories.testFirst': 'Test eerst de verbinding', 'memories.testFirst': 'Test eerst de verbinding',
'memories.connected': 'Verbonden', 'memories.connected': 'Verbonden',
@@ -1451,17 +1459,21 @@ const nl: Record<string, string> = {
'memories.connectionSuccess': 'Verbonden met Immich', 'memories.connectionSuccess': 'Verbonden met Immich',
'memories.connectionError': 'Kon niet verbinden met Immich', 'memories.connectionError': 'Kon niet verbinden met Immich',
'memories.saved': 'Immich-instellingen opgeslagen', 'memories.saved': 'Immich-instellingen opgeslagen',
'memories.providerDisconnectedBanner': 'Je {provider_name}-verbinding is verbroken. Maak opnieuw verbinding in Instellingen om foto\'s te bekijken.',
'memories.saveError': '{provider_name}-instellingen konden niet worden opgeslagen',
'memories.oldest': 'Oudste eerst', 'memories.oldest': 'Oudste eerst',
'memories.newest': 'Nieuwste eerst', 'memories.newest': 'Nieuwste eerst',
'memories.allLocations': 'Alle locaties', 'memories.allLocations': 'Alle locaties',
'memories.addPhotos': 'Foto\'s toevoegen', 'memories.addPhotos': 'Foto\'s toevoegen',
'memories.linkAlbum': 'Album koppelen', 'memories.linkAlbum': 'Album koppelen',
'memories.selectAlbum': 'Immich-album selecteren', 'memories.selectAlbum': 'Immich-album selecteren',
'memories.selectAlbumMultiple': 'Album selecteren',
'memories.noAlbums': 'Geen albums gevonden', 'memories.noAlbums': 'Geen albums gevonden',
'memories.syncAlbum': 'Album synchroniseren', 'memories.syncAlbum': 'Album synchroniseren',
'memories.unlinkAlbum': 'Ontkoppelen', 'memories.unlinkAlbum': 'Ontkoppelen',
'memories.photos': 'fotos', 'memories.photos': 'fotos',
'memories.selectPhotos': 'Selecteer foto\'s uit Immich', 'memories.selectPhotos': 'Selecteer foto\'s uit Immich',
'memories.selectPhotosMultiple': 'Foto\'s selecteren',
'memories.selectHint': 'Tik op foto\'s om ze te selecteren.', 'memories.selectHint': 'Tik op foto\'s om ze te selecteren.',
'memories.selected': 'geselecteerd', 'memories.selected': 'geselecteerd',
'memories.addSelected': '{count} foto\'s toevoegen', 'memories.addSelected': '{count} foto\'s toevoegen',
@@ -1618,6 +1630,8 @@ const nl: Record<string, string> = {
'notifications.markUnread': 'Markeren als ongelezen', 'notifications.markUnread': 'Markeren als ongelezen',
'notifications.delete': 'Verwijderen', 'notifications.delete': 'Verwijderen',
'notifications.system': 'Systeem', 'notifications.system': 'Systeem',
'notifications.synologySessionCleared.title': 'Synology Photos verbroken',
'notifications.synologySessionCleared.text': 'Je server of account is gewijzigd — ga naar Instellingen om je verbinding opnieuw te testen.',
'memories.error.loadAlbums': 'Albums laden mislukt', 'memories.error.loadAlbums': 'Albums laden mislukt',
'memories.error.linkAlbum': 'Album koppelen mislukt', 'memories.error.linkAlbum': 'Album koppelen mislukt',
'memories.error.unlinkAlbum': 'Album ontkoppelen mislukt', 'memories.error.unlinkAlbum': 'Album ontkoppelen mislukt',
+14
View File
@@ -1393,6 +1393,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'memories.title': 'Zdjęcia', 'memories.title': 'Zdjęcia',
'memories.notConnected': 'Immich nie jest połączony', 'memories.notConnected': 'Immich nie jest połączony',
'memories.notConnectedHint': 'Połącz swoją instancję Immich w ustawieniach, aby przeglądać tutaj swoje zdjęcia z podróży.', 'memories.notConnectedHint': 'Połącz swoją instancję Immich w ustawieniach, aby przeglądać tutaj swoje zdjęcia z podróży.',
'memories.notConnectedMultipleHint': 'Połącz jednego z tych dostawców zdjęć: {provider_names} w Ustawieniach, aby móc dodawać zdjęcia do tej podróży.',
'memories.noDates': 'Dodaj daty do swojej podróży, aby załadować zdjęcia.', 'memories.noDates': 'Dodaj daty do swojej podróży, aby załadować zdjęcia.',
'memories.noPhotos': 'Nie znaleziono zdjęć', 'memories.noPhotos': 'Nie znaleziono zdjęć',
'memories.noPhotosHint': 'Nie znaleziono zdjęć w Immich dla tego zakresu dat podróży.', 'memories.noPhotosHint': 'Nie znaleziono zdjęć w Immich dla tego zakresu dat podróży.',
@@ -1403,14 +1404,24 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'memories.reviewTitle': 'Przejrzyj swoje zdjęcia', 'memories.reviewTitle': 'Przejrzyj swoje zdjęcia',
'memories.reviewHint': 'Kliknij w zdjęcia, aby wykluczyć je z udostępnienia.', 'memories.reviewHint': 'Kliknij w zdjęcia, aby wykluczyć je z udostępnienia.',
'memories.shareCount': 'Udostępnij {count} zdjęć', 'memories.shareCount': 'Udostępnij {count} zdjęć',
'memories.providerUrl': 'URL serwera',
'memories.providerApiKey': 'Klucz API',
'memories.providerUsername': 'Nazwa użytkownika',
'memories.providerPassword': 'Hasło',
'memories.providerOTP': 'Kod MFA (jeśli włączony)',
'memories.skipSSLVerification': 'Pomiń weryfikację certyfikatu SSL',
'memories.providerUrlHintSynology': 'Uwzględnij ścieżkę aplikacji Photos w URL, np. https://nas:5001/photo',
'memories.testConnection': 'Test', 'memories.testConnection': 'Test',
'memories.connected': 'Połączono', 'memories.connected': 'Połączono',
'memories.disconnected': 'Nie połączono', 'memories.disconnected': 'Nie połączono',
'memories.connectionSuccess': 'Połączono z Immich', 'memories.connectionSuccess': 'Połączono z Immich',
'memories.connectionError': 'Nie udało się połączyć z Immich', 'memories.connectionError': 'Nie udało się połączyć z Immich',
'memories.saved': 'Ustawienia Immich zostały zapisane', 'memories.saved': 'Ustawienia Immich zostały zapisane',
'memories.providerDisconnectedBanner': 'Połączenie z {provider_name} zostało utracone. Połącz ponownie w Ustawieniach, aby wyświetlać zdjęcia.',
'memories.saveError': 'Nie można zapisać ustawień {provider_name}',
'memories.addPhotos': 'Dodaj zdjęcia', 'memories.addPhotos': 'Dodaj zdjęcia',
'memories.selectPhotos': 'Wybierz zdjęcia z Immich', 'memories.selectPhotos': 'Wybierz zdjęcia z Immich',
'memories.selectPhotosMultiple': 'Wybierz zdjęcia',
'memories.selectHint': 'Dotknij zdjęć, aby je zaznaczyć.', 'memories.selectHint': 'Dotknij zdjęć, aby je zaznaczyć.',
'memories.selected': 'wybranych', 'memories.selected': 'wybranych',
'memories.addSelected': 'Dodaj {count} zdjęć', 'memories.addSelected': 'Dodaj {count} zdjęć',
@@ -1561,6 +1572,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'memories.testFirst': 'Najpierw przetestuj połączenie', 'memories.testFirst': 'Najpierw przetestuj połączenie',
'memories.linkAlbum': 'Połącz album', 'memories.linkAlbum': 'Połącz album',
'memories.selectAlbum': 'Wybierz album Immich', 'memories.selectAlbum': 'Wybierz album Immich',
'memories.selectAlbumMultiple': 'Wybierz album',
'memories.noAlbums': 'Nie znaleziono albumów', 'memories.noAlbums': 'Nie znaleziono albumów',
'memories.syncAlbum': 'Synchronizuj album', 'memories.syncAlbum': 'Synchronizuj album',
'memories.unlinkAlbum': 'Odłącz album', 'memories.unlinkAlbum': 'Odłącz album',
@@ -1646,6 +1658,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'notifications.markUnread': 'Oznacz jako nieprzeczytane', 'notifications.markUnread': 'Oznacz jako nieprzeczytane',
'notifications.delete': 'Usuń', 'notifications.delete': 'Usuń',
'notifications.system': 'System', 'notifications.system': 'System',
'notifications.synologySessionCleared.title': 'Synology Photos rozłączone',
'notifications.synologySessionCleared.text': 'Twój serwer lub konto zostało zmienione — przejdź do Ustawień, aby ponownie przetestować połączenie.',
'notifications.versionAvailable.title': 'Dostępna aktualizacja', 'notifications.versionAvailable.title': 'Dostępna aktualizacja',
'notifications.versionAvailable.text': 'TREK {version} jest już dostępny.', 'notifications.versionAvailable.text': 'TREK {version} jest już dostępny.',
'notifications.versionAvailable.button': 'Zobacz szczegóły', 'notifications.versionAvailable.button': 'Zobacz szczegóły',
+14
View File
@@ -1434,6 +1434,7 @@ const ru: Record<string, string> = {
'memories.title': 'Фото', 'memories.title': 'Фото',
'memories.notConnected': 'Immich не подключён', 'memories.notConnected': 'Immich не подключён',
'memories.notConnectedHint': 'Подключите Immich в настройках, чтобы видеть фотографии из поездок.', 'memories.notConnectedHint': 'Подключите Immich в настройках, чтобы видеть фотографии из поездок.',
'memories.notConnectedMultipleHint': 'Подключите одного из этих фотопровайдеров: {provider_names} в Настройках, чтобы добавлять фотографии к этому путешествию.',
'memories.noDates': 'Добавьте даты поездки для загрузки фотографий.', 'memories.noDates': 'Добавьте даты поездки для загрузки фотографий.',
'memories.noPhotos': 'Фотографии не найдены', 'memories.noPhotos': 'Фотографии не найдены',
'memories.noPhotosHint': 'В Immich нет фотографий за период этой поездки.', 'memories.noPhotosHint': 'В Immich нет фотографий за период этой поездки.',
@@ -1444,6 +1445,13 @@ const ru: Record<string, string> = {
'memories.reviewTitle': 'Проверьте ваши фото', 'memories.reviewTitle': 'Проверьте ваши фото',
'memories.reviewHint': 'Нажмите на фото, чтобы исключить его из общего доступа.', 'memories.reviewHint': 'Нажмите на фото, чтобы исключить его из общего доступа.',
'memories.shareCount': 'Поделиться ({count} фото)', 'memories.shareCount': 'Поделиться ({count} фото)',
'memories.providerUrl': 'URL сервера',
'memories.providerApiKey': 'API-ключ',
'memories.providerUsername': 'Имя пользователя',
'memories.providerPassword': 'Пароль',
'memories.providerOTP': 'Код MFA (если включён)',
'memories.skipSSLVerification': 'Пропустить проверку SSL-сертификата',
'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo',
'memories.testConnection': 'Проверить подключение', 'memories.testConnection': 'Проверить подключение',
'memories.testFirst': 'Сначала проверьте подключение', 'memories.testFirst': 'Сначала проверьте подключение',
'memories.connected': 'Подключено', 'memories.connected': 'Подключено',
@@ -1451,17 +1459,21 @@ const ru: Record<string, string> = {
'memories.connectionSuccess': 'Подключение к Immich установлено', 'memories.connectionSuccess': 'Подключение к Immich установлено',
'memories.connectionError': 'Не удалось подключиться к Immich', 'memories.connectionError': 'Не удалось подключиться к Immich',
'memories.saved': 'Настройки Immich сохранены', 'memories.saved': 'Настройки Immich сохранены',
'memories.providerDisconnectedBanner': 'Соединение с {provider_name} потеряно. Переподключитесь в Настройках для просмотра фотографий.',
'memories.saveError': 'Не удалось сохранить настройки {provider_name}',
'memories.oldest': 'Сначала старые', 'memories.oldest': 'Сначала старые',
'memories.newest': 'Сначала новые', 'memories.newest': 'Сначала новые',
'memories.allLocations': 'Все места', 'memories.allLocations': 'Все места',
'memories.addPhotos': 'Добавить фото', 'memories.addPhotos': 'Добавить фото',
'memories.linkAlbum': 'Привязать альбом', 'memories.linkAlbum': 'Привязать альбом',
'memories.selectAlbum': 'Выбрать альбом Immich', 'memories.selectAlbum': 'Выбрать альбом Immich',
'memories.selectAlbumMultiple': 'Выбрать альбом',
'memories.noAlbums': 'Альбомы не найдены', 'memories.noAlbums': 'Альбомы не найдены',
'memories.syncAlbum': 'Синхронизировать', 'memories.syncAlbum': 'Синхронизировать',
'memories.unlinkAlbum': 'Отвязать', 'memories.unlinkAlbum': 'Отвязать',
'memories.photos': 'фото', 'memories.photos': 'фото',
'memories.selectPhotos': 'Выбрать фото из Immich', 'memories.selectPhotos': 'Выбрать фото из Immich',
'memories.selectPhotosMultiple': 'Выбрать фотографии',
'memories.selectHint': 'Нажмите на фото, чтобы выбрать их.', 'memories.selectHint': 'Нажмите на фото, чтобы выбрать их.',
'memories.selected': 'выбрано', 'memories.selected': 'выбрано',
'memories.addSelected': 'Добавить {count} фото', 'memories.addSelected': 'Добавить {count} фото',
@@ -1618,6 +1630,8 @@ const ru: Record<string, string> = {
'notifications.markUnread': 'Отметить как непрочитанное', 'notifications.markUnread': 'Отметить как непрочитанное',
'notifications.delete': 'Удалить', 'notifications.delete': 'Удалить',
'notifications.system': 'Система', 'notifications.system': 'Система',
'notifications.synologySessionCleared.title': 'Synology Photos отключено',
'notifications.synologySessionCleared.text': 'Ваш сервер или аккаунт изменился — перейдите в Настройки, чтобы проверить соединение снова.',
'memories.error.loadAlbums': 'Не удалось загрузить альбомы', 'memories.error.loadAlbums': 'Не удалось загрузить альбомы',
'memories.error.linkAlbum': 'Не удалось привязать альбом', 'memories.error.linkAlbum': 'Не удалось привязать альбом',
'memories.error.unlinkAlbum': 'Не удалось отвязать альбом', 'memories.error.unlinkAlbum': 'Не удалось отвязать альбом',
+14
View File
@@ -1434,6 +1434,7 @@ const zh: Record<string, string> = {
'memories.title': '照片', 'memories.title': '照片',
'memories.notConnected': 'Immich 未连接', 'memories.notConnected': 'Immich 未连接',
'memories.notConnectedHint': '在设置中连接您的 Immich 实例以在此查看旅行照片。', 'memories.notConnectedHint': '在设置中连接您的 Immich 实例以在此查看旅行照片。',
'memories.notConnectedMultipleHint': '请在设置中连接以下任一照片提供商:{provider_names},以便向此行程添加照片。',
'memories.noDates': '为旅行添加日期以加载照片。', 'memories.noDates': '为旅行添加日期以加载照片。',
'memories.noPhotos': '未找到照片', 'memories.noPhotos': '未找到照片',
'memories.noPhotosHint': 'Immich 中未找到此旅行日期范围内的照片。', 'memories.noPhotosHint': 'Immich 中未找到此旅行日期范围内的照片。',
@@ -1444,6 +1445,13 @@ const zh: Record<string, string> = {
'memories.reviewTitle': '审查您的照片', 'memories.reviewTitle': '审查您的照片',
'memories.reviewHint': '点击照片以将其从分享中排除。', 'memories.reviewHint': '点击照片以将其从分享中排除。',
'memories.shareCount': '分享 {count} 张照片', 'memories.shareCount': '分享 {count} 张照片',
'memories.providerUrl': '服务器 URL',
'memories.providerApiKey': 'API 密钥',
'memories.providerUsername': '用户名',
'memories.providerPassword': '密码',
'memories.providerOTP': 'MFA 验证码(如已启用)',
'memories.skipSSLVerification': '跳过 SSL 证书验证',
'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo',
'memories.testConnection': '测试连接', 'memories.testConnection': '测试连接',
'memories.testFirst': '请先测试连接', 'memories.testFirst': '请先测试连接',
'memories.connected': '已连接', 'memories.connected': '已连接',
@@ -1451,17 +1459,21 @@ const zh: Record<string, string> = {
'memories.connectionSuccess': '已连接到 Immich', 'memories.connectionSuccess': '已连接到 Immich',
'memories.connectionError': '无法连接到 Immich', 'memories.connectionError': '无法连接到 Immich',
'memories.saved': 'Immich 设置已保存', 'memories.saved': 'Immich 设置已保存',
'memories.providerDisconnectedBanner': '您与 {provider_name} 的连接已断开。请在设置中重新连接以查看照片。',
'memories.saveError': '无法保存 {provider_name} 设置',
'memories.oldest': '最早优先', 'memories.oldest': '最早优先',
'memories.newest': '最新优先', 'memories.newest': '最新优先',
'memories.allLocations': '所有地点', 'memories.allLocations': '所有地点',
'memories.addPhotos': '添加照片', 'memories.addPhotos': '添加照片',
'memories.linkAlbum': '关联相册', 'memories.linkAlbum': '关联相册',
'memories.selectAlbum': '选择 Immich 相册', 'memories.selectAlbum': '选择 Immich 相册',
'memories.selectAlbumMultiple': '选择相册',
'memories.noAlbums': '未找到相册', 'memories.noAlbums': '未找到相册',
'memories.syncAlbum': '同步相册', 'memories.syncAlbum': '同步相册',
'memories.unlinkAlbum': '取消关联', 'memories.unlinkAlbum': '取消关联',
'memories.photos': '张照片', 'memories.photos': '张照片',
'memories.selectPhotos': '从 Immich 选择照片', 'memories.selectPhotos': '从 Immich 选择照片',
'memories.selectPhotosMultiple': '选择照片',
'memories.selectHint': '点击照片以选择。', 'memories.selectHint': '点击照片以选择。',
'memories.selected': '已选择', 'memories.selected': '已选择',
'memories.addSelected': '添加 {count} 张照片', 'memories.addSelected': '添加 {count} 张照片',
@@ -1618,6 +1630,8 @@ const zh: Record<string, string> = {
'notifications.markUnread': '标为未读', 'notifications.markUnread': '标为未读',
'notifications.delete': '删除', 'notifications.delete': '删除',
'notifications.system': '系统', 'notifications.system': '系统',
'notifications.synologySessionCleared.title': 'Synology Photos 已断开连接',
'notifications.synologySessionCleared.text': '您的服务器或账户已更改 — 请前往设置重新测试您的连接。',
'memories.error.loadAlbums': '加载相册失败', 'memories.error.loadAlbums': '加载相册失败',
'memories.error.linkAlbum': '关联相册失败', 'memories.error.linkAlbum': '关联相册失败',
'memories.error.unlinkAlbum': '取消关联相册失败', 'memories.error.unlinkAlbum': '取消关联相册失败',
+6
View File
@@ -1474,6 +1474,9 @@ const zhTw: Record<string, string> = {
'memories.providerApiKey': 'API 金鑰', 'memories.providerApiKey': 'API 金鑰',
'memories.providerUsername': '使用者名稱', 'memories.providerUsername': '使用者名稱',
'memories.providerPassword': '密碼', 'memories.providerPassword': '密碼',
'memories.providerOTP': 'MFA 驗證碼(如已啟用)',
'memories.skipSSLVerification': '跳過 SSL 憑證驗證',
'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo',
'memories.testConnection': '測試連線', 'memories.testConnection': '測試連線',
'memories.testFirst': '請先測試連線', 'memories.testFirst': '請先測試連線',
'memories.connected': '已連線', 'memories.connected': '已連線',
@@ -1481,6 +1484,7 @@ const zhTw: Record<string, string> = {
'memories.connectionSuccess': '已連線到 {provider_name}', 'memories.connectionSuccess': '已連線到 {provider_name}',
'memories.connectionError': '無法連線到 {provider_name}', 'memories.connectionError': '無法連線到 {provider_name}',
'memories.saved': '{provider_name} 設定已儲存', 'memories.saved': '{provider_name} 設定已儲存',
'memories.providerDisconnectedBanner': '您與 {provider_name} 的連線已中斷。請在設定中重新連線以查看照片。',
'memories.saveError': '無法儲存 {provider_name} 設定', 'memories.saveError': '無法儲存 {provider_name} 設定',
'memories.oldest': '最早優先', 'memories.oldest': '最早優先',
'memories.newest': '最新優先', 'memories.newest': '最新優先',
@@ -1685,6 +1689,8 @@ const zhTw: Record<string, string> = {
'notifications.markUnread': '標為未讀', 'notifications.markUnread': '標為未讀',
'notifications.delete': '刪除', 'notifications.delete': '刪除',
'notifications.system': '系統', 'notifications.system': '系統',
'notifications.synologySessionCleared.title': 'Synology Photos 已斷線',
'notifications.synologySessionCleared.text': '您的伺服器或帳號已更改 — 請前往設定重新測試連線。',
'memories.error.loadAlbums': '載入相簿失敗', 'memories.error.loadAlbums': '載入相簿失敗',
'memories.error.linkAlbum': '關聯相簿失敗', 'memories.error.linkAlbum': '關聯相簿失敗',
'memories.error.unlinkAlbum': '取消關聯相簿失敗', 'memories.error.unlinkAlbum': '取消關聯相簿失敗',
+3 -1
View File
@@ -205,7 +205,7 @@ export function createApp(): express.Application {
ORDER BY sort_order, id ORDER BY sort_order, id
`).all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>; `).all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>;
const fields = db.prepare(` const fields = db.prepare(`
SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order SELECT provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order
FROM photo_provider_fields FROM photo_provider_fields
ORDER BY sort_order, id ORDER BY sort_order, id
`).all() as Array<{ `).all() as Array<{
@@ -214,6 +214,7 @@ export function createApp(): express.Application {
label: string; label: string;
input_type: string; input_type: string;
placeholder?: string | null; placeholder?: string | null;
hint?: string | null;
required: number; required: number;
secret: number; secret: number;
settings_key?: string | null; settings_key?: string | null;
@@ -243,6 +244,7 @@ export function createApp(): express.Application {
label: f.label, label: f.label,
input_type: f.input_type, input_type: f.input_type,
placeholder: f.placeholder || '', placeholder: f.placeholder || '',
hint: f.hint || null,
required: !!f.required, required: !!f.required,
secret: !!f.secret, secret: !!f.secret,
settings_key: f.settings_key || null, settings_key: f.settings_key || null,
+26
View File
@@ -973,6 +973,32 @@ function runMigrations(db: Database.Database): void {
} }
}, },
}, },
// Migration: Add OTP field, skip_ssl column, device_id (did) column, and hint column for Synology Photos
() => {
const cols = db.prepare('PRAGMA table_info(photo_provider_fields)').all() as Array<{ name: string }>;
if (!cols.some(c => c.name === 'hint')) {
db.exec(`ALTER TABLE photo_provider_fields ADD COLUMN hint TEXT`);
}
db.exec(`
INSERT OR IGNORE INTO photo_provider_fields
(provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order)
VALUES
('synologyphotos', 'synology_otp', 'providerOTP', 'text', '123456', 0, 0, NULL, 'synology_otp', 3)
`);
db.exec(`ALTER TABLE users ADD COLUMN synology_skip_ssl INTEGER NOT NULL DEFAULT 0`);
db.exec(`ALTER TABLE users ADD COLUMN synology_did TEXT`);
db.exec(`
INSERT OR IGNORE INTO photo_provider_fields
(provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order)
VALUES
('synologyphotos', 'synology_skip_ssl', 'skipSSLVerification', 'checkbox', NULL, 0, 0, 'synology_skip_ssl', 'synology_skip_ssl', 4)
`);
db.exec(`
UPDATE photo_provider_fields
SET hint = 'providerUrlHintSynology'
WHERE provider_id = 'synologyphotos' AND field_key = 'synology_url'
`);
},
]; ];
if (currentVersion < migrations.length) { if (currentVersion < migrations.length) {
+1
View File
@@ -245,6 +245,7 @@ function createTables(db: Database.Database): void {
label TEXT NOT NULL, label TEXT NOT NULL,
input_type TEXT NOT NULL DEFAULT 'text', input_type TEXT NOT NULL DEFAULT 'text',
placeholder TEXT, placeholder TEXT,
hint TEXT,
required INTEGER DEFAULT 0, required INTEGER DEFAULT 0,
secret INTEGER DEFAULT 0, secret INTEGER DEFAULT 0,
settings_key TEXT, settings_key TEXT,
+9 -7
View File
@@ -115,15 +115,17 @@ function seedAddons(db: Database.Database): void {
for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.sort_order); for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.sort_order);
const providerFields = [ const providerFields = [
{ provider_id: 'immich', field_key: 'immich_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 }, { provider_id: 'immich', field_key: 'immich_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://immich.example.com', hint: null, required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 },
{ provider_id: 'immich', field_key: 'immich_api_key', label: 'providerApiKey', input_type: 'password', placeholder: 'API Key', required: 1, secret: 1, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 }, { provider_id: 'immich', field_key: 'immich_api_key', label: 'providerApiKey', input_type: 'password', placeholder: 'API Key', hint: null, required: 1, secret: 1, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 },
{ provider_id: 'synologyphotos', field_key: 'synology_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://synology.example.com', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'synology_url', sort_order: 0 }, { provider_id: 'synologyphotos', field_key: 'synology_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://synology.example.com/photo', hint: 'providerUrlHintSynology', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'synology_url', sort_order: 0 },
{ provider_id: 'synologyphotos', field_key: 'synology_username', label: 'providerUsername', input_type: 'text', placeholder: 'Username', required: 1, secret: 0, settings_key: 'synology_username', payload_key: 'synology_username', sort_order: 1 }, { provider_id: 'synologyphotos', field_key: 'synology_username', label: 'providerUsername', input_type: 'text', placeholder: 'Username', hint: null, required: 1, secret: 0, settings_key: 'synology_username', payload_key: 'synology_username', sort_order: 1 },
{ provider_id: 'synologyphotos', field_key: 'synology_password', label: 'providerPassword', input_type: 'password', placeholder: 'Password', required: 1, secret: 1, settings_key: null, payload_key: 'synology_password', sort_order: 2 }, { provider_id: 'synologyphotos', field_key: 'synology_password', label: 'providerPassword', input_type: 'password', placeholder: 'Password', hint: null, required: 1, secret: 1, settings_key: null, payload_key: 'synology_password', sort_order: 2 },
{ provider_id: 'synologyphotos', field_key: 'synology_otp', label: 'providerOTP', input_type: 'text', placeholder: '123456', hint: null, required: 0, secret: 0, settings_key: null, payload_key: 'synology_otp', sort_order: 3 },
{ provider_id: 'synologyphotos', field_key: 'synology_skip_ssl', label: 'skipSSLVerification', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, settings_key: 'synology_skip_ssl', payload_key: 'synology_skip_ssl', sort_order: 4 },
]; ];
const insertProviderField = db.prepare('INSERT OR IGNORE INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); const insertProviderField = db.prepare('INSERT OR IGNORE INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
for (const f of providerFields) { for (const f of providerFields) {
insertProviderField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.required, f.secret, f.settings_key, f.payload_key, f.sort_order); insertProviderField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.hint, f.required, f.secret, f.settings_key, f.payload_key, f.sort_order);
} }
console.log('Default addons seeded'); console.log('Default addons seeded');
} catch (err: unknown) { } catch (err: unknown) {
+6 -2
View File
@@ -36,12 +36,13 @@ router.put('/settings', authenticate, async (req: Request, res: Response) => {
const synology_url = _parseStringBodyField(body.synology_url); const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username); const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password); const synology_password = _parseStringBodyField(body.synology_password);
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
if (!synology_url || !synology_username) { if (!synology_url || !synology_username) {
handleServiceResult(res, fail('URL and username are required', 400)); handleServiceResult(res, fail('URL and username are required', 400));
} }
else { else {
handleServiceResult(res, await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password)); handleServiceResult(res, await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password, synology_skip_ssl));
} }
}); });
@@ -51,10 +52,13 @@ router.get('/status', authenticate, async (req: Request, res: Response) => {
}); });
router.post('/test', authenticate, async (req: Request, res: Response) => { router.post('/test', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const body = req.body as Record<string, unknown>; const body = req.body as Record<string, unknown>;
const synology_url = _parseStringBodyField(body.synology_url); const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username); const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password); const synology_password = _parseStringBodyField(body.synology_password);
const synology_otp = _parseStringBodyField(body.synology_otp);
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
if (!synology_url || !synology_username || !synology_password) { if (!synology_url || !synology_username || !synology_password) {
const missingFields: string[] = []; const missingFields: string[] = [];
@@ -64,7 +68,7 @@ router.post('/test', authenticate, async (req: Request, res: Response) => {
handleServiceResult(res, success({ connected: false, error: `${missingFields.join(', ')} ${missingFields.length > 1 ? 'are' : 'is'} required` })); handleServiceResult(res, success({ connected: false, error: `${missingFields.join(', ')} ${missingFields.length > 1 ? 'are' : 'is'} required` }));
} }
else{ else{
handleServiceResult(res, await testSynologyConnection(synology_url, synology_username, synology_password)); handleServiceResult(res, await testSynologyConnection(authReq.user.id, synology_url, synology_username, synology_password, synology_otp, synology_skip_ssl));
} }
}); });
+131 -30
View File
@@ -19,26 +19,62 @@ import {
SyncAlbumResult, SyncAlbumResult,
AssetInfo AssetInfo
} from './helpersService'; } from './helpersService';
import { createNotification } from '../inAppNotifications';
const SYNOLOGY_PROVIDER = 'synologyphotos'; const SYNOLOGY_PROVIDER = 'synologyphotos';
const SYNOLOGY_ENDPOINT_PATH = '/photo/webapi/entry.cgi'; // Users provide the full base URL including the Photos app path (e.g. https://nas:5001/photo).
// The API endpoint is always at {base_url}/webapi/entry.cgi.
const SYNOLOGY_ENDPOINT_PATH = '/webapi/entry.cgi';
const SYNOLOGY_ERROR_MESSAGES: Record<number, string> = {
101: 'Missing API, method, or version parameter.',
102: 'Requested API does not exist.',
103: 'Requested method does not exist.',
104: 'Requested API version is not supported.',
105: 'Insufficient privilege.',
106: 'Connection timeout.',
107: 'Multiple logins blocked from this IP.',
117: 'Manager privilege required.',
119: 'Session is invalid or expired.',
400: 'Invalid credentials.',
401: 'Session expired or account disabled.',
402: 'No permission to use this account.',
403: 'Two-factor authentication code required.',
404: 'Two-factor authentication failed.',
406: 'Two-factor authentication is enforced for this account.',
407: 'Maximum login attempts reached.',
408: 'Password expired.',
409: 'Remote password expired.',
410: 'Password must be changed before login.',
412: 'Guest account cannot log in.',
413: 'OTP system files are corrupted.',
414: 'Unable to log in.',
416: 'Unable to log in.',
417: 'OTP system is full.',
498: 'System is upgrading.',
499: 'System is not ready.',
};
interface SynologyUserRecord { interface SynologyUserRecord {
synology_url?: string | null; synology_url?: string | null;
synology_username?: string | null; synology_username?: string | null;
synology_password?: string | null; synology_password?: string | null;
synology_sid?: string | null; synology_sid?: string | null;
synology_did?: string | null;
synology_skip_ssl?: number | null;
}; };
interface SynologyCredentials { interface SynologyCredentials {
synology_url: string; synology_url: string;
synology_username: string; synology_username: string;
synology_password: string; synology_password: string;
synology_skip_ssl: boolean;
} }
interface SynologySettings { interface SynologySettings {
synology_url: string; synology_url: string;
synology_username: string; synology_username: string;
synology_skip_ssl: boolean;
connected: boolean; connected: boolean;
} }
@@ -84,7 +120,7 @@ interface SynologyPhotoItem {
function _readSynologyUser(userId: number, columns: string[]): ServiceResult<SynologyUserRecord> { function _readSynologyUser(userId: number, columns: string[]): ServiceResult<SynologyUserRecord> {
try { try {
const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined; const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid, synology_did, synology_skip_ssl FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
if (!row) { if (!row) {
return fail('User not found', 404); return fail('User not found', 404);
@@ -102,7 +138,7 @@ function _readSynologyUser(userId: number, columns: string[]): ServiceResult<Syn
} }
function _getSynologyCredentials(userId: number): ServiceResult<SynologyCredentials> { function _getSynologyCredentials(userId: number): ServiceResult<SynologyCredentials> {
const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']); const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password', 'synology_skip_ssl']);
if (!user.success) return user as ServiceResult<SynologyCredentials>; if (!user.success) return user as ServiceResult<SynologyCredentials>;
if (!user?.data.synology_url || !user.data.synology_username || !user.data.synology_password) return fail('Synology not configured', 400); if (!user?.data.synology_url || !user.data.synology_username || !user.data.synology_password) return fail('Synology not configured', 400);
const password = decrypt_api_key(user.data.synology_password); const password = decrypt_api_key(user.data.synology_password);
@@ -111,6 +147,7 @@ function _getSynologyCredentials(userId: number): ServiceResult<SynologyCredenti
synology_url: user.data.synology_url, synology_url: user.data.synology_url,
synology_username: user.data.synology_username, synology_username: user.data.synology_username,
synology_password: password, synology_password: password,
synology_skip_ssl: user.data.synology_skip_ssl !== 0,
}); });
} }
@@ -129,7 +166,7 @@ function _buildSynologyFormBody(params: ApiCallParams): URLSearchParams {
return body; return body;
} }
async function _fetchSynologyJson<T>(url: string, body: URLSearchParams): Promise<ServiceResult<T>> { async function _fetchSynologyJson<T>(url: string, body: URLSearchParams, skipSsl = true): Promise<ServiceResult<T>> {
const endpoint = _buildSynologyEndpoint(url, `api=${body.get('api')}`); const endpoint = _buildSynologyEndpoint(url, `api=${body.get('api')}`);
try { try {
const resp = await safeFetch(endpoint, { const resp = await safeFetch(endpoint, {
@@ -139,12 +176,20 @@ async function _fetchSynologyJson<T>(url: string, body: URLSearchParams): Promis
}, },
body, body,
signal: AbortSignal.timeout(30000) as any, signal: AbortSignal.timeout(30000) as any,
}); }, { rejectUnauthorized: !skipSsl });
if (!resp.ok) { if (!resp.ok) {
return fail('Synology API request failed with status ' + resp.status, resp.status); return fail('Synology API request failed with status ' + resp.status, resp.status);
} }
const response = await resp.json() as SynologyApiResponse<T>; const response = await resp.json() as SynologyApiResponse<T>;
return response.success ? success(response.data) : fail('Synology failed with code ' + response.error.code, response.error.code); if (!response.success) {
const code = response.error.code;
const message = SYNOLOGY_ERROR_MESSAGES[code] ?? 'Synology API request failed (code ' + code + ')';
// Preserve session error codes (106, 107, 119) for internal retry logic in _requestSynologyApi.
// All other Synology app-level codes are mapped to HTTP 400 — they are not HTTP status codes.
const httpStatus = [106, 107, 119].includes(code) ? code : 400;
return fail(message, httpStatus);
}
return success(response.data);
} catch (error) { } catch (error) {
if (error instanceof SsrfBlockedError) { if (error instanceof SsrfBlockedError) {
return fail(error.message, 400); return fail(error.message, 400);
@@ -153,25 +198,41 @@ async function _fetchSynologyJson<T>(url: string, body: URLSearchParams): Promis
} }
} }
async function _loginToSynology(url: string, username: string, password: string): Promise<ServiceResult<string>> { const SYNOLOGY_DEVICE_NAME = 'trek';
async function _loginToSynology(
url: string,
username: string,
password: string,
opts: { otp?: string; deviceId?: string; skipSsl?: boolean } = {},
): Promise<ServiceResult<{ sid: string; did?: string }>> {
const { otp, deviceId, skipSsl = false } = opts;
const body = new URLSearchParams({ const body = new URLSearchParams({
api: 'SYNO.API.Auth', api: 'SYNO.API.Auth',
method: 'login', method: 'login',
version: '3', version: '6',
account: username, account: username,
passwd: password, passwd: password,
format: 'sid',
client: 'browser',
device_name: SYNOLOGY_DEVICE_NAME,
}); });
if (otp && otp.trim()) {
body.append('otp_code', otp.trim());
body.append('enable_device_token', 'yes');
}
if (deviceId) {
body.append('device_id', deviceId);
}
const result = await _fetchSynologyJson<{ sid?: string }>(url, body); const result = await _fetchSynologyJson<{ sid?: string; did?: string }>(url, body, skipSsl);
if (!result.success) { if (!result.success) {
return result as ServiceResult<string>; return result as ServiceResult<{ sid: string; did?: string }>;
} }
if (!result.data.sid) { if (!result.data.sid) {
return fail('Failed to get session ID from Synology', 500); return fail('Failed to get session ID from Synology', 500);
} }
return success(result.data.sid); return success({ sid: result.data.sid, did: result.data.did });
} }
async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Promise<ServiceResult<T>> { async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Promise<ServiceResult<T>> {
@@ -185,8 +246,9 @@ async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Pr
return session as ServiceResult<T>; return session as ServiceResult<T>;
} }
const skipSsl = creds.data.synology_skip_ssl;
const body = _buildSynologyFormBody({ ...params, _sid: session.data }); const body = _buildSynologyFormBody({ ...params, _sid: session.data });
const result = await _fetchSynologyJson<T>(creds.data.synology_url, body); const result = await _fetchSynologyJson<T>(creds.data.synology_url, body, skipSsl);
// 106 = session timeout, 107 = duplicate login kicked us out, 119 = SID not found/invalid // 106 = session timeout, 107 = duplicate login kicked us out, 119 = SID not found/invalid
if ('error' in result && [106, 107, 119].includes(result.error.status)) { if ('error' in result && [106, 107, 119].includes(result.error.status)) {
_clearSynologySID(userId); _clearSynologySID(userId);
@@ -194,7 +256,7 @@ async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Pr
if (!retrySession.success || !retrySession.data) { if (!retrySession.success || !retrySession.data) {
return retrySession as ServiceResult<T>; return retrySession as ServiceResult<T>;
} }
return _fetchSynologyJson<T>(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data })); return _fetchSynologyJson<T>(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data }), skipSsl);
} }
return result; return result;
} }
@@ -232,6 +294,10 @@ function _clearSynologySID(userId: number): void {
db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId); db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId);
} }
function _clearSynologySession(userId: number): void {
db.prepare('UPDATE users SET synology_sid = NULL, synology_did = NULL WHERE id = ?').run(userId);
}
function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } | null { function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } | null {
// cache_key format from Synology is "{unit_id}_{timestamp}", e.g. "40808_1633659236". // cache_key format from Synology is "{unit_id}_{timestamp}", e.g. "40808_1633659236".
// The first segment must be a non-empty integer (the unit ID used for API calls). // The first segment must be a non-empty integer (the unit ID used for API calls).
@@ -241,9 +307,9 @@ function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string;
} }
async function _getSynologySession(userId: number): Promise<ServiceResult<string>> { async function _getSynologySession(userId: number): Promise<ServiceResult<string>> {
const cachedSid = _readSynologyUser(userId, ['synology_sid']); const cached = _readSynologyUser(userId, ['synology_sid', 'synology_did']);
if (cachedSid.success && cachedSid.data?.synology_sid) { if (cached.success && cached.data?.synology_sid) {
const decryptedSid = decrypt_api_key(cachedSid.data.synology_sid); const decryptedSid = decrypt_api_key(cached.data.synology_sid);
if (decryptedSid) return success(decryptedSid); if (decryptedSid) return success(decryptedSid);
// Decryption failed (e.g. key rotation) — clear the stale SID and re-login // Decryption failed (e.g. key rotation) — clear the stale SID and re-login
_clearSynologySID(userId); _clearSynologySID(userId);
@@ -254,15 +320,22 @@ async function _getSynologySession(userId: number): Promise<ServiceResult<string
return creds as ServiceResult<string>; return creds as ServiceResult<string>;
} }
const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password); // Use stored device ID to skip OTP on re-login (trusted device flow)
const storedDid = cached.success && cached.data?.synology_did
? (decrypt_api_key(cached.data.synology_did) || undefined)
: undefined;
const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password, {
deviceId: storedDid,
skipSsl: creds.data.synology_skip_ssl,
});
if (!resp.success) { if (!resp.success) {
return resp as ServiceResult<string>; return resp as ServiceResult<string>;
} }
const encrypted = encrypt_api_key(resp.data); db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId);
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypted, userId); return success(resp.data.sid);
return success(resp.data);
} }
export async function getSynologySettings(userId: number): Promise<ServiceResult<SynologySettings>> { export async function getSynologySettings(userId: number): Promise<ServiceResult<SynologySettings>> {
@@ -272,11 +345,12 @@ export async function getSynologySettings(userId: number): Promise<ServiceResult
return success({ return success({
synology_url: creds.data.synology_url || '', synology_url: creds.data.synology_url || '',
synology_username: creds.data.synology_username || '', synology_username: creds.data.synology_username || '',
synology_skip_ssl: creds.data.synology_skip_ssl,
connected: session.success, connected: session.success,
}); });
} }
export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string): Promise<ServiceResult<string>> { export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string, synologySkipSsl = false): Promise<ServiceResult<string>> {
const ssrf = await checkSsrf(synologyUrl); const ssrf = await checkSsrf(synologyUrl);
if (!ssrf.allowed) { if (!ssrf.allowed) {
@@ -291,24 +365,43 @@ export async function updateSynologySettings(userId: number, synologyUrl: string
return fail('No stored password found. Please provide a password to save settings.', 400); return fail('No stored password found. Please provide a password to save settings.', 400);
} }
// Only invalidate the session when the account itself changes (different URL or username).
// If the user just tested the connection, testSynologyConnection already stored a fresh
// sid + did — clearing them here would force an unnecessary re-login that may fail (MFA).
const existing = _readSynologyUser(userId, ['synology_url', 'synology_username']);
const urlChanged = existing.success && existing.data.synology_url !== synologyUrl;
const userChanged = existing.success && existing.data.synology_username !== synologyUsername;
const sessionCleared = urlChanged || userChanged;
if (sessionCleared) {
_clearSynologySession(userId);
createNotification({
type: 'simple',
scope: 'user',
target: userId,
sender_id: null,
title_key: 'notifications.synologySessionCleared.title',
text_key: 'notifications.synologySessionCleared.text',
});
}
try { try {
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run( db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ?, synology_skip_ssl = ? WHERE id = ?').run(
synologyUrl, synologyUrl,
synologyUsername, synologyUsername,
synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword, synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword,
synologySkipSsl ? 1 : 0,
userId, userId,
); );
} catch { } catch {
return fail('Failed to update Synology settings', 500); return fail('Failed to update Synology settings', 500);
} }
_clearSynologySID(userId); return success('settings updated');
return success("settings updated");
} }
export async function getSynologyStatus(userId: number): Promise<ServiceResult<StatusResult>> { export async function getSynologyStatus(userId: number): Promise<ServiceResult<StatusResult>> {
const sid = await _getSynologySession(userId); const sid = await _getSynologySession(userId);
if ('error' in sid) return success({ connected: false, error: sid.error.status === 400 ? 'Invalid credentials' : sid.error.message }); if ('error' in sid) return success({ connected: false, error: sid.error.message });
if (!sid.data) return success({ connected: false, error: 'Not connected to Synology' }); if (!sid.data) return success({ connected: false, error: 'Not connected to Synology' });
try { try {
const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined; const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined;
@@ -318,17 +411,25 @@ export async function getSynologyStatus(userId: number): Promise<ServiceResult<S
} }
} }
export async function testSynologyConnection(synologyUrl: string, synologyUsername: string, synologyPassword: string): Promise<ServiceResult<StatusResult>> { export async function testSynologyConnection(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword: string, synologyOtp?: string, synologySkipSsl = false): Promise<ServiceResult<StatusResult>> {
const ssrf = await checkSsrf(synologyUrl); const ssrf = await checkSsrf(synologyUrl);
if (!ssrf.allowed) { if (!ssrf.allowed) {
return fail(ssrf.error, 400); return fail(ssrf.error, 400);
} }
const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword); const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword, { otp: synologyOtp, skipSsl: synologySkipSsl });
if ('error' in resp) { if ('error' in resp) {
return success({ connected: false, error: resp.error.status === 400 ? 'Invalid credentials' : resp.error.message }); return success({ connected: false, error: resp.error.message });
} }
// Persist the session so the OTP code is not required again on save.
// The did (device token) allows future re-logins without OTP.
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId);
if (resp.data.did) {
db.prepare('UPDATE users SET synology_did = ? WHERE id = ?').run(encrypt_api_key(resp.data.did), userId);
}
return success({ connected: true, user: { name: synologyUsername } }); return success({ connected: true, user: { name: synologyUsername } });
} }
+12 -3
View File
@@ -114,17 +114,25 @@ export class SsrfBlockedError extends Error {
} }
} }
export interface SafeFetchOptions {
rejectUnauthorized?: boolean;
}
/** /**
* SSRF-safe fetch wrapper. Validates the URL with checkSsrf(), then makes * SSRF-safe fetch wrapper. Validates the URL with checkSsrf(), then makes
* the request using a DNS-pinned dispatcher so the resolved IP cannot change * the request using a DNS-pinned dispatcher so the resolved IP cannot change
* between the check and the actual connection (DNS rebinding prevention). * between the check and the actual connection (DNS rebinding prevention).
*
* Pass `{ rejectUnauthorized: false }` for targets that use self-signed TLS
* certificates (e.g. a Synology NAS on a local network). The SSRF guard still
* applies only the TLS certificate check is relaxed.
*/ */
export async function safeFetch(url: string, init?: RequestInit): Promise<Response> { export async function safeFetch(url: string, init?: RequestInit, options?: SafeFetchOptions): Promise<Response> {
const ssrf = await checkSsrf(url); const ssrf = await checkSsrf(url);
if (!ssrf.allowed) { if (!ssrf.allowed) {
throw new SsrfBlockedError(ssrf.error ?? 'Request blocked by SSRF guard'); throw new SsrfBlockedError(ssrf.error ?? 'Request blocked by SSRF guard');
} }
const dispatcher = createPinnedDispatcher(ssrf.resolvedIp!); const dispatcher = createPinnedDispatcher(ssrf.resolvedIp!, options?.rejectUnauthorized ?? true);
return fetch(url, { ...init, dispatcher } as any); return fetch(url, { ...init, dispatcher } as any);
} }
@@ -133,9 +141,10 @@ export async function safeFetch(url: string, init?: RequestInit): Promise<Respon
* IP. This prevents DNS rebinding (TOCTOU) by ensuring the outbound connection * IP. This prevents DNS rebinding (TOCTOU) by ensuring the outbound connection
* goes to the IP we checked, not a re-resolved one. * goes to the IP we checked, not a re-resolved one.
*/ */
export function createPinnedDispatcher(resolvedIp: string): Agent { export function createPinnedDispatcher(resolvedIp: string, rejectUnauthorized = true): Agent {
return new Agent({ return new Agent({
connect: { connect: {
rejectUnauthorized,
lookup: (_hostname: string, opts: Record<string, unknown>, callback: Function) => { lookup: (_hostname: string, opts: Record<string, unknown>, callback: Function) => {
const family = resolvedIp.includes(':') ? 6 : 4; const family = resolvedIp.includes(':') ? 6 : 4;
// Node.js 18+ may call lookup with `all: true`, expecting an array of address objects // Node.js 18+ may call lookup with `all: true`, expecting an array of address objects
@@ -39,7 +39,7 @@ vi.mock('../../src/config', () => ({
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {}, updateJwtSecret: () => {},
})); }));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
// ── SSRF guard mock — routes all Synology API calls to fake responses ───────── // ── SSRF guard mock — routes all Synology API calls to fake responses ─────────
vi.mock('../../src/utils/ssrfGuard', async () => { vi.mock('../../src/utils/ssrfGuard', async () => {