feat: implement API photo packs with migration and settings management

This commit is contained in:
alexsparkes
2026-02-02 19:51:45 +00:00
parent 9040766fe3
commit 987f6756a0
8 changed files with 724 additions and 26 deletions

View File

@@ -7,6 +7,7 @@ import { getAllBackgrounds, getAllBackgroundsWithMetadata } from 'utils/customBa
import { BackgroundQueueManager } from 'utils/backgroundQueue';
import { getProxiedImageUrl } from 'utils/marketplace';
import { safeParseJSON } from 'utils/jsonStorage';
import { buildPhotoPool, checkAndRefreshAPIPacks } from './photoPackAPI';
/**
* Fetches image data from the configured API
@@ -295,15 +296,15 @@ async function prefetchCustomBackgrounds(queueManager, allBackgrounds, currentId
/**
* Gets photo pack background with prefetching and blurhash
* Now supports both static and API-based photo packs
*/
function getPhotoPackBackground(isOffline) {
if (isOffline) return getOfflineImage('photo_pack');
const photos = safeParseJSON('installed', []).flatMap((item) =>
item.type === 'photos' && item.photos ? item.photos : [],
);
// Build combined pool from static and API packs
const pool = buildPhotoPool();
if (photos.length === 0) return null;
if (pool.length === 0) return null;
const queueManager = new BackgroundQueueManager('photoPackQueue', 3);
let photoData;
@@ -313,19 +314,20 @@ function getPhotoPackBackground(isOffline) {
if (cachedQueue.length > 0) {
photoData = queueManager.shift();
} else {
// Pick random photo
const index = Math.floor(Math.random() * photos.length);
const selected = photos[index];
// Pick random photo from pool
const selected = pool[Math.floor(Math.random() * pool.length)];
photoData = {
url: getProxiedImageUrl(selected.url.default),
url: getProxiedImageUrl(selected.url.default || selected.url),
type: 'photo_pack',
photoInfo: {
hidden: false,
credit: selected.photographer,
location: selected.location,
blur_hash: selected.blur_hash || null,
url: selected.url.default,
url: selected.url.default || selected.url,
source: selected.source,
pack_id: selected.pack_id,
},
};
}
@@ -338,7 +340,7 @@ function getPhotoPackBackground(isOffline) {
// Prefetch more photos in the background
if (queueManager.needsPrefetch()) {
prefetchPhotoPackImages(queueManager, photos, photoData, cachedQueue).catch((error) => {
prefetchPhotoPackImages(queueManager, pool, photoData, cachedQueue).catch((error) => {
console.error('Failed to prefetch photo pack images:', error);
});
}
@@ -348,19 +350,23 @@ function getPhotoPackBackground(isOffline) {
/**
* Prefetch photo pack images in the background
* Supports both static and API-based photo packs
* @param {BackgroundQueueManager} queueManager - The queue manager
* @param {Array} allPhotos - All available photos from installed packs
* @param {Array} pool - Combined pool of photos from all packs
* @param {Object} currentPhoto - The current photo data
* @param {Array} currentQueue - The current queue state
*/
async function prefetchPhotoPackImages(queueManager, allPhotos, currentPhoto, currentQueue) {
async function prefetchPhotoPackImages(queueManager, pool, currentPhoto, currentQueue) {
const count = queueManager.getSpaceNeeded();
// Get already used URLs
const usedUrls = [currentPhoto.url, ...currentQueue.map((p) => p.url)];
// Filter available photos
const available = allPhotos.filter((p) => !usedUrls.includes(p.url.default));
// Filter available photos (handle both URL formats)
const available = pool.filter((p) => {
const url = p.url.default || p.url;
return !usedUrls.includes(url) && !usedUrls.includes(getProxiedImageUrl(url));
});
if (available.length === 0) return;
@@ -370,16 +376,21 @@ async function prefetchPhotoPackImages(queueManager, allPhotos, currentPhoto, cu
// Normalize metadata
const normalized = selected.map((photo) => ({
url: getProxiedImageUrl(photo.url.default),
url: getProxiedImageUrl(photo.url.default || photo.url),
type: 'photo_pack',
photoInfo: {
hidden: false,
credit: photo.photographer,
location: photo.location,
blur_hash: photo.blur_hash || null,
url: photo.url.default,
url: photo.url.default || photo.url,
source: photo.source,
pack_id: photo.pack_id,
},
}));
queueManager.push(normalized);
// Check if any API pack cache needs refresh
checkAndRefreshAPIPacks();
}

View File

@@ -0,0 +1,213 @@
import variables from 'config/variables';
/**
* Fetch photos from MUE API based on pack settings
* @param {string} packId - The ID of the photo pack
* @param {object} settings - Pack-specific settings
* @returns {Promise<object|null>} Photo object or null on error
*/
export async function fetchFromMUE(packId, settings) {
const { quality, categories } = settings;
const params = new URLSearchParams({
quality: quality || 'high',
categories: (categories || []).join(','),
});
try {
const response = await fetch(`${variables.constants.API_URL}/images/random?${params}`);
if (!response.ok) throw new Error(`API error: ${response.status}`);
const data = await response.json();
return {
photographer: data.photographer,
location: data.location?.name || 'Unknown',
url: { default: data.file },
blur_hash: data.blur_hash,
colour: data.colour,
category: data.category,
camera: data.camera,
};
} catch (error) {
console.error('MUE API fetch failed:', error);
return null;
}
}
/**
* Fetch photos from Unsplash API
* @param {string} packId - The ID of the photo pack
* @param {object} settings - Pack-specific settings (must include api_key)
* @returns {Promise<object|null>} Photo object or null on error
*/
export async function fetchFromUnsplash(packId, settings) {
const { api_key, collections } = settings;
if (!api_key) {
console.warn('Unsplash API key not configured');
return null;
}
// Deobfuscate API key
const decodedKey = atob(api_key);
const params = new URLSearchParams({
orientation: 'landscape',
});
if (collections) {
params.append('collections', collections);
}
try {
const response = await fetch(`https://api.unsplash.com/photos/random?${params}`, {
headers: {
Authorization: `Client-ID ${decodedKey}`,
},
});
if (!response.ok) {
if (response.status === 429) {
console.warn('Unsplash rate limit hit');
}
throw new Error(`Unsplash API error: ${response.status}`);
}
const data = await response.json();
return {
photographer: data.user.name,
location: data.location?.title || 'Unknown',
url: { default: data.urls.regular },
blur_hash: data.blur_hash,
colour: data.color,
unsplash_url: data.links.html,
photographer_url: data.user.links.html,
};
} catch (error) {
console.error('Unsplash API fetch failed:', error);
return null;
}
}
/**
* Fetch multiple photos for a pack and update cache
* @param {string} packId - The ID of the photo pack to refresh
* @returns {Promise<boolean>} Success status
*/
export async function refreshAPIPackCache(packId) {
const installed = JSON.parse(localStorage.getItem('installed') || '[]');
const pack = installed.find((p) => p.id === packId);
if (!pack || !pack.api_enabled) return false;
const settings = JSON.parse(localStorage.getItem(`photopack_settings_${packId}`) || '{}');
const fetchFunction = pack.api_provider === 'mue' ? fetchFromMUE : fetchFromUnsplash;
// Fetch 8 photos
const promises = Array.from({ length: 8 }, () => fetchFunction(packId, settings));
const results = await Promise.all(promises);
const validPhotos = results.filter(Boolean);
if (validPhotos.length === 0) {
console.warn(`No photos fetched for pack ${packId}`);
return false;
}
// Update cache
const apiPackCache = JSON.parse(localStorage.getItem('api_pack_cache') || '{}');
apiPackCache[packId] = {
photos: validPhotos,
last_fetched: Date.now(),
last_refresh_attempt: Date.now(),
};
try {
localStorage.setItem('api_pack_cache', JSON.stringify(apiPackCache));
return true;
} catch (error) {
if (error.name === 'QuotaExceededError') {
// Keep only 5 most recent photos
apiPackCache[packId].photos = validPhotos.slice(0, 5);
localStorage.setItem('api_pack_cache', JSON.stringify(apiPackCache));
}
return false;
}
}
/**
* Check all API packs and refresh if needed
* Hardcoded 1 hour refresh interval (separate from global background change frequency)
*/
export async function checkAndRefreshAPIPacks() {
const apiPacksReady = JSON.parse(localStorage.getItem('api_packs_ready') || '[]');
const apiPackCache = JSON.parse(localStorage.getItem('api_pack_cache') || '{}');
// Hardcoded 1 hour refresh interval (separate from global background change frequency)
const CACHE_REFRESH_INTERVAL = 3600 * 1000; // 1 hour in milliseconds
for (const packId of apiPacksReady) {
const cached = apiPackCache[packId];
const needsRefresh =
!cached ||
Date.now() - cached.last_fetched > CACHE_REFRESH_INTERVAL ||
cached.photos.length < 3;
if (needsRefresh) {
// Don't block - refresh in background
refreshAPIPackCache(packId).catch((error) => {
console.error(`Failed to refresh ${packId}:`, error);
});
}
}
}
/**
* Build combined photo pool from static and API packs
* @returns {Array} Combined array of photos from all sources
*/
export function buildPhotoPool() {
const pool = [];
const installed = JSON.parse(localStorage.getItem('installed') || '[]');
const apiPacksReady = JSON.parse(localStorage.getItem('api_packs_ready') || '[]');
const apiPackCache = JSON.parse(localStorage.getItem('api_pack_cache') || '{}');
installed.forEach((pack) => {
if (pack.type !== 'photos') return;
if (pack.api_enabled) {
// API pack - check if configured and ready
if (apiPacksReady.includes(pack.id)) {
const cached = apiPackCache[pack.id];
if (cached && cached.photos.length > 0) {
// Add cached photos with source metadata
cached.photos.forEach((photo) => {
pool.push({
...photo,
source: `api:${pack.api_provider}`,
pack_id: pack.id,
});
});
}
}
} else {
// Static pack - add all photos
pack.photos.forEach((photo) => {
pool.push({
photographer: photo.photographer,
location: photo.location,
url: photo.url,
blur_hash: photo.blur_hash,
source: 'static',
pack_id: pack.id,
});
});
}
});
return pool;
}

View File

@@ -0,0 +1,241 @@
import React, { useState, useEffect } from 'react';
import variables from 'config/variables';
import EventBus from 'utils/eventbus';
import { Dropdown, Text, Switch, Slider } from 'components/Form/Settings';
import { Row, Content, Action } from 'components/Layout/Settings/Item';
import { Button } from 'components/Elements';
import { refreshAPIPackCache } from 'features/background/api/photoPackAPI';
import { MdRefresh, MdWarning } from 'react-icons/md';
/**
* ChipSelect component for multi-select options
*/
const ChipSelect = ({ label, options, defaultValue, name, onChange }) => {
const [selected, setSelected] = useState(defaultValue || []);
const toggleChip = (value) => {
const newSelected = selected.includes(value)
? selected.filter((v) => v !== value)
: [...selected, value];
setSelected(newSelected);
onChange(newSelected);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<label style={{ fontSize: '14px', fontWeight: 500 }}>{label}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => toggleChip(option.value)}
style={{
padding: '6px 12px',
borderRadius: '16px',
border: '1px solid',
borderColor: selected.includes(option.value) ? '#4CAF50' : '#ccc',
backgroundColor: selected.includes(option.value) ? '#4CAF50' : 'transparent',
color: selected.includes(option.value) ? '#fff' : '#333',
cursor: 'pointer',
fontSize: '13px',
transition: 'all 0.2s',
}}
>
{option.label}
</button>
))}
</div>
</div>
);
};
const PhotoPackSettings = ({ pack }) => {
if (!pack.settings_schema || pack.settings_schema.length === 0) {
return null;
}
const [settings, setSettings] = useState(() => {
const saved = localStorage.getItem(`photopack_settings_${pack.id}`);
return saved ? JSON.parse(saved) : {};
});
const [dynamicOptions, setDynamicOptions] = useState({});
const [isRefreshing, setIsRefreshing] = useState(false);
const [validationErrors, setValidationErrors] = useState([]);
// Load dynamic options (e.g., categories from API)
useEffect(() => {
pack.settings_schema.forEach((field) => {
if (field.dynamic && field.options_source) {
loadDynamicOptions(field);
}
});
}, [pack.id]);
// Validate settings
useEffect(() => {
validateSettings();
}, [settings]);
const loadDynamicOptions = async (field) => {
if (field.options_source === 'api:categories') {
try {
const response = await fetch(`${variables.constants.API_URL}/images/categories`);
const categories = await response.json();
setDynamicOptions((prev) => ({
...prev,
[field.key]: categories.map((cat) => ({ value: cat, label: cat })),
}));
} catch (error) {
console.error('Failed to load categories:', error);
}
}
};
const validateSettings = () => {
const errors = [];
pack.settings_schema.forEach((field) => {
if (field.required && !settings[field.key]) {
errors.push(`${field.label} is required`);
}
});
setValidationErrors(errors);
// Update api_packs_ready list
const apiPacksReady = JSON.parse(localStorage.getItem('api_packs_ready') || '[]');
const isReady = errors.length === 0;
const isInList = apiPacksReady.includes(pack.id);
if (isReady && !isInList) {
apiPacksReady.push(pack.id);
localStorage.setItem('api_packs_ready', JSON.stringify(apiPacksReady));
} else if (!isReady && isInList) {
const filtered = apiPacksReady.filter((id) => id !== pack.id);
localStorage.setItem('api_packs_ready', JSON.stringify(filtered));
}
};
const handleSettingChange = (key, value, secure = false) => {
const processedValue = secure ? btoa(value) : value;
const newSettings = { ...settings, [key]: processedValue };
setSettings(newSettings);
localStorage.setItem(`photopack_settings_${pack.id}`, JSON.stringify(newSettings));
};
const handleManualRefresh = async () => {
setIsRefreshing(true);
await refreshAPIPackCache(pack.id);
setIsRefreshing(false);
// Trigger background refresh
EventBus.emit('refresh', 'background');
};
const renderField = (field, index) => {
const value =
field.secure && settings[field.key] ? atob(settings[field.key]) : settings[field.key] || field.default;
switch (field.type) {
case 'dropdown':
return (
<Dropdown
label={field.label}
name={`${pack.id}_${field.key}`}
value={value}
items={field.options}
onChange={(newValue) => handleSettingChange(field.key, newValue)}
/>
);
case 'chipselect':
const options = field.dynamic ? dynamicOptions[field.key] || [] : field.options;
return (
<ChipSelect
label={field.label}
options={options}
defaultValue={value}
name={`${pack.id}_${field.key}`}
onChange={(newValue) => handleSettingChange(field.key, newValue)}
/>
);
case 'text':
return (
<Text
title={field.label}
placeholder={field.placeholder}
value={value}
name={`${pack.id}_${field.key}`}
type={field.secure ? 'password' : 'text'}
onChange={(e) => handleSettingChange(field.key, e.target.value, field.secure)}
subtitle={field.help_text}
/>
);
case 'switch':
return (
<Switch
name={`${pack.id}_${field.key}`}
text={field.label}
value={value}
onChange={(newValue) => handleSettingChange(field.key, newValue)}
/>
);
case 'slider':
return (
<Slider
title={field.label}
name={`${pack.id}_${field.key}`}
min={field.min || 0}
max={field.max || 100}
step={field.step || 1}
value={value}
onChange={(newValue) => handleSettingChange(field.key, newValue)}
/>
);
default:
return null;
}
};
return (
<>
<Row>
<Content
title={`${pack.name} Settings`}
subtitle={pack.api_provider === 'mue' ? 'MUE API' : 'Unsplash API'}
/>
<Action>
<Button
onClick={handleManualRefresh}
icon={<MdRefresh />}
label="Refresh Photos"
disabled={isRefreshing || validationErrors.length > 0}
/>
</Action>
</Row>
{validationErrors.length > 0 && (
<Row>
<Content>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#f44336' }}>
<MdWarning />
<span>Configuration incomplete: {validationErrors.join(', ')}</span>
</div>
</Content>
</Row>
)}
{pack.settings_schema.map((field, index) => (
<Row key={field.key} final={index === pack.settings_schema.length - 1}>
<Content title="" />
<Action>{renderField(field, index)}</Action>
</Row>
))}
</>
);
};
export default PhotoPackSettings;

View File

@@ -5,6 +5,7 @@ import { Row, Content, Action } from 'components/Layout/Settings/Item';
import { Button } from 'components/Elements';
import Items from 'features/marketplace/components/Items/Items';
import { getBackgroundOptionItems } from '../optionTypes';
import PhotoPackSettings from './PhotoPackSettings';
const SourceSection = ({
backgroundType,
@@ -59,6 +60,11 @@ const SourceSection = ({
onUninstall={onPhotoPackUninstall}
viewType="grid"
/>
{/* Settings for API packs */}
{installedPhotoPacks.map((pack) =>
pack.api_enabled ? <PhotoPackSettings key={pack.id} pack={pack} /> : null,
)}
</>
)}
</>