Add smart combined album asset redistribution + fix locale string
Some checks failed
Validate / Hassfest (push) Has been cancelled
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:
@@ -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",
|
||||||
|
|||||||
@@ -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": "Удалить",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user