Add smart combined album asset redistribution + fix locale string
Some checks failed
Validate / Hassfest (push) Has been cancelled

Core library:
- New combine_album_assets() in asset_utils.py: smart redistribution
  of unused quota when albums return fewer assets than their share.
  Two-pass algorithm: even split then redistribute remainder.
- 6 new tests (56 total passing).

Frontend:
- Fix "leave empty to keep current" not localized in server edit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 17:15:26 +03:00
parent b708b14f32
commit 5192483fff
5 changed files with 121 additions and 1 deletions

View File

@@ -48,6 +48,7 @@
"url": "Immich URL", "url": "Immich URL",
"urlPlaceholder": "http://immich:2283", "urlPlaceholder": "http://immich:2283",
"apiKey": "API Key", "apiKey": "API Key",
"apiKeyKeep": "API Key (leave empty to keep current)",
"connecting": "Connecting...", "connecting": "Connecting...",
"noServers": "No servers configured yet.", "noServers": "No servers configured yet.",
"delete": "Delete", "delete": "Delete",

View File

@@ -48,6 +48,7 @@
"url": "URL Immich", "url": "URL Immich",
"urlPlaceholder": "http://immich:2283", "urlPlaceholder": "http://immich:2283",
"apiKey": "API ключ", "apiKey": "API ключ",
"apiKeyKeep": "API ключ (оставьте пустым, чтобы сохранить текущий)",
"connecting": "Подключение...", "connecting": "Подключение...",
"noServers": "Серверы не настроены.", "noServers": "Серверы не настроены.",
"delete": "Удалить", "delete": "Удалить",

View File

@@ -71,7 +71,7 @@
<input id="srv-url" bind:value={form.url} required placeholder={t('servers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> <input id="srv-url" bind:value={form.url} required placeholder={t('servers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div> </div>
<div> <div>
<label for="srv-key" class="block text-sm font-medium mb-1">{t('servers.apiKey')}{editing ? ' (leave empty to keep current)' : ''}</label> <label for="srv-key" class="block text-sm font-medium mb-1">{editing ? t('servers.apiKeyKeep') : t('servers.apiKey')}</label>
<input id="srv-key" bind:value={form.api_key} type="password" required={!editing} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> <input id="srv-key" bind:value={form.api_key} type="password" required={!editing} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div> </div>
<button type="submit" disabled={submitting} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50"> <button type="submit" disabled={submitting} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">

View File

@@ -165,6 +165,72 @@ def sort_assets(
return result return result
def combine_album_assets(
album_assets: dict[str, list[AssetInfo]],
total_limit: int,
order_by: str = "random",
order: str = "descending",
) -> list[AssetInfo]:
"""Smart combined fetch from multiple albums with quota redistribution.
Distributes the total_limit across albums, then redistributes unused
quota from albums that returned fewer assets than their share.
Args:
album_assets: Dict mapping album_id -> list of filtered assets
total_limit: Maximum total assets to return
order_by: Sort method for final result
order: Sort direction
Returns:
Combined and sorted list of assets, at most total_limit items
Example:
2 albums, limit=10
Album A has 1 matching asset, Album B has 20
Pass 1: A gets 5 quota -> returns 1, B gets 5 quota -> returns 5 (total: 6)
Pass 2: 4 unused from A redistributed to B -> B gets 4 more (total: 10)
"""
if not album_assets or total_limit <= 0:
return []
num_albums = len(album_assets)
per_album = max(1, total_limit // num_albums)
# Pass 1: initial even distribution
collected: dict[str, list[AssetInfo]] = {}
remainder = 0
for album_id, assets in album_assets.items():
take = min(per_album, len(assets))
collected[album_id] = assets[:take]
unused = per_album - take
remainder += unused
# Pass 2: redistribute remainder to albums that have more
if remainder > 0:
for album_id, assets in album_assets.items():
if remainder <= 0:
break
already_taken = len(collected[album_id])
available = len(assets) - already_taken
if available > 0:
extra = min(remainder, available)
collected[album_id].extend(assets[already_taken : already_taken + extra])
remainder -= extra
# Combine all
combined = []
for assets in collected.values():
combined.extend(assets)
# Trim to exact limit
combined = combined[:total_limit]
# Sort the combined result
return sort_assets(combined, order_by=order_by, order=order)
# --- Shared link URL helpers --- # --- Shared link URL helpers ---

View File

@@ -2,6 +2,7 @@
from immich_watcher_core.asset_utils import ( from immich_watcher_core.asset_utils import (
build_asset_detail, build_asset_detail,
combine_album_assets,
filter_assets, filter_assets,
get_any_url, get_any_url,
get_public_url, get_public_url,
@@ -183,3 +184,54 @@ class TestBuildAssetDetail:
assert "url" not in detail assert "url" not in detail
assert "download_url" not in detail assert "download_url" not in detail
assert "thumbnail_url" in detail # always present assert "thumbnail_url" in detail # always present
class TestCombineAlbumAssets:
def test_even_distribution(self):
"""Both albums have plenty, split evenly."""
a = [_make_asset(f"a{i}") for i in range(10)]
b = [_make_asset(f"b{i}") for i in range(10)]
result = combine_album_assets({"A": a, "B": b}, total_limit=6, order_by="name")
assert len(result) == 6
def test_smart_redistribution(self):
"""Album A has 1 photo, Album B has 20. Limit=10 should get 10 total."""
a = [_make_asset("a1", created_at="2023-03-19T10:00:00Z")]
b = [_make_asset(f"b{i}", created_at=f"2023-03-19T{10+i}:00:00Z") for i in range(20)]
result = combine_album_assets({"A": a, "B": b}, total_limit=10, order_by="name")
assert len(result) == 10
# a1 should be in result
ids = {r.id for r in result}
assert "a1" in ids
def test_redistribution_with_3_albums(self):
"""3 albums: A has 1, B has 2, C has 20. Limit=12."""
a = [_make_asset("a1")]
b = [_make_asset("b1"), _make_asset("b2")]
c = [_make_asset(f"c{i}") for i in range(20)]
result = combine_album_assets({"A": a, "B": b, "C": c}, total_limit=12, order_by="name")
assert len(result) == 12
# All of A and B should be included
ids = {r.id for r in result}
assert "a1" in ids
assert "b1" in ids
assert "b2" in ids
# C fills the remaining 9
c_count = sum(1 for r in result if r.id.startswith("c"))
assert c_count == 9
def test_all_albums_empty(self):
result = combine_album_assets({"A": [], "B": []}, total_limit=10)
assert result == []
def test_single_album(self):
a = [_make_asset(f"a{i}") for i in range(5)]
result = combine_album_assets({"A": a}, total_limit=3, order_by="name")
assert len(result) == 3
def test_total_less_than_limit(self):
"""Both albums together have fewer than limit."""
a = [_make_asset("a1")]
b = [_make_asset("b1"), _make_asset("b2")]
result = combine_album_assets({"A": a, "B": b}, total_limit=10, order_by="name")
assert len(result) == 3