Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ dependencies {
implementation "io.github.andrewauclair:modern-docking-ui:1.4.1"
implementation "com.formdev:flatlaf:3.7"
implementation "com.formdev:flatlaf-extras:3.7"

// HTML parsing for link previews
implementation 'org.jsoup:jsoup:1.18.3'

// VLCJ for video playback
implementation 'uk.co.caprica:vlcj:4.8.3'
}

tasks.named('test') {
Expand Down
132 changes: 132 additions & 0 deletions src/main/java/cafe/woden/ircclient/embed/EmbedCache.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package cafe.woden.ircclient.embed;

import org.springframework.stereotype.Component;

import java.awt.image.BufferedImage;
import java.util.LinkedHashMap;
import java.util.Map;

/**
* LRU cache for embed results with size-based eviction.
*/
@Component
public class EmbedCache {

private final long maxSizeBytes;
private long currentSizeBytes = 0;

private final LinkedHashMap<String, CacheEntry> cache;

public EmbedCache(EmbedSettings settings) {
this.maxSizeBytes = settings.cacheSizeBytes();
this.cache = new LinkedHashMap<>(64, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, CacheEntry> eldest) {
if (currentSizeBytes > maxSizeBytes) {
currentSizeBytes -= eldest.getValue().sizeBytes;
return true;
}
return false;
}
};
}

/**
* Get cached embed result for URL.
*/
public synchronized EmbedResult get(String url) {
CacheEntry entry = cache.get(url);
return entry != null ? entry.result : null;
}

/**
* Cache an embed result.
*/
public synchronized void put(String url, EmbedResult result) {
long size = estimateSize(result);

// Remove existing entry if present
CacheEntry existing = cache.remove(url);
if (existing != null) {
currentSizeBytes -= existing.sizeBytes;
}

// Add new entry
cache.put(url, new CacheEntry(result, size));
currentSizeBytes += size;

// Trigger LRU eviction if needed
while (currentSizeBytes > maxSizeBytes && cache.size() > 1) {
var it = cache.entrySet().iterator();
if (it.hasNext()) {
var entry = it.next();
currentSizeBytes -= entry.getValue().sizeBytes;
it.remove();
}
}
}

/**
* Remove cached embed for URL.
*/
public synchronized void remove(String url) {
CacheEntry entry = cache.remove(url);
if (entry != null) {
currentSizeBytes -= entry.sizeBytes;
}
}

/**
* Clear the entire cache.
*/
public synchronized void clear() {
cache.clear();
currentSizeBytes = 0;
}

/**
* Get current cache size in bytes.
*/
public synchronized long getCurrentSizeBytes() {
return currentSizeBytes;
}

/**
* Get number of cached entries.
*/
public synchronized int size() {
return cache.size();
}

private long estimateSize(EmbedResult result) {
long size = 100; // Base overhead

if (result instanceof EmbedResult.ImageEmbed img) {
size += estimateImageSize(img.original());
size += estimateImageSize(img.thumbnail());
} else if (result instanceof EmbedResult.VideoEmbed vid) {
if (vid.thumbnail() != null) {
size += estimateImageSize(vid.thumbnail());
}
if (vid.title() != null) {
size += vid.title().length() * 2L;
}
} else if (result instanceof EmbedResult.LinkPreview link) {
if (link.title() != null) size += link.title().length() * 2L;
if (link.description() != null) size += link.description().length() * 2L;
if (link.siteName() != null) size += link.siteName().length() * 2L;
if (link.favicon() != null) size += estimateImageSize(link.favicon());
if (link.ogImage() != null) size += estimateImageSize(link.ogImage());
}

return size;
}

private long estimateImageSize(BufferedImage img) {
if (img == null) return 0;
// Approximate: width * height * bytes per pixel
return (long) img.getWidth() * img.getHeight() * 4;
}

private record CacheEntry(EmbedResult result, long sizeBytes) {}
}
73 changes: 73 additions & 0 deletions src/main/java/cafe/woden/ircclient/embed/EmbedFetcher.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package cafe.woden.ircclient.embed;

import io.reactivex.rxjava3.core.Single;
import org.springframework.stereotype.Component;

/**
* Coordinates fetching of embeds based on URL type.
*/
@Component
public class EmbedFetcher {

private final UrlClassifier urlClassifier;
private final ImageFetcher imageFetcher;
private final VideoFetcher videoFetcher;
private final LinkPreviewFetcher linkPreviewFetcher;
private final EmbedCache embedCache;
private final EmbedSettings settings;

public EmbedFetcher(
UrlClassifier urlClassifier,
ImageFetcher imageFetcher,
VideoFetcher videoFetcher,
LinkPreviewFetcher linkPreviewFetcher,
EmbedCache embedCache,
EmbedSettings settings
) {
this.urlClassifier = urlClassifier;
this.imageFetcher = imageFetcher;
this.videoFetcher = videoFetcher;
this.linkPreviewFetcher = linkPreviewFetcher;
this.embedCache = embedCache;
this.settings = settings;
}

/**
* Fetch embed data for the given URL.
* Returns cached result if available, otherwise fetches and caches.
*/
public Single<EmbedResult> fetch(String url) {
if (!settings.enabled()) {
return Single.just(new EmbedResult.Failed(url, "Embeds disabled"));
}

// Check cache first
EmbedResult cached = embedCache.get(url);
if (cached != null) {
return Single.just(cached);
}

EmbedType type = urlClassifier.classify(url);

Single<EmbedResult> fetchSingle = switch (type) {
case IMAGE -> imageFetcher.fetch(url);
case VIDEO -> videoFetcher.fetch(url);
case LINK_PREVIEW -> linkPreviewFetcher.fetch(url);
case NONE -> Single.just(new EmbedResult.Failed(url, "URL type not supported"));
};

// Cache the result
return fetchSingle.doOnSuccess(result -> {
if (!(result instanceof EmbedResult.Failed)) {
embedCache.put(url, result);
}
});
}

/**
* Get the embed type for a URL without fetching.
*/
public EmbedType classifyUrl(String url) {
return urlClassifier.classify(url);
}
}
Loading