This commit is contained in:
2025-12-11 01:58:57 +01:00
parent d2ee45e6b9
commit 93a95d4a2d
7 changed files with 33 additions and 436 deletions

View File

@@ -1,17 +0,0 @@
package com.imgfloat.app.config;
import org.springframework.boot.task.TaskExecutorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskExecutor;
@Configuration
public class TaskConfig {
@Bean(name = "assetTaskExecutor")
public TaskExecutor assetTaskExecutor(TaskExecutorBuilder builder) {
return builder
.threadNamePrefix("asset-optimizer-")
.build();
}
}

View File

@@ -34,15 +34,6 @@ public class AssetEvent {
return event;
}
public static AssetEvent updated(String channel, AssetView asset) {
AssetEvent event = new AssetEvent();
event.type = Type.UPDATED;
event.channel = channel;
event.payload = asset;
event.assetId = asset.id();
return event;
}
public static AssetEvent play(String channel, AssetView asset, boolean play) {
AssetEvent event = new AssetEvent();
event.type = Type.PLAY;

View File

@@ -5,7 +5,6 @@ import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Index;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.PrePersist;
@@ -19,17 +18,13 @@ import java.util.Set;
import java.util.stream.Collectors;
@Entity
@Table(name = "channels", indexes = {
@Index(name = "idx_channels_broadcaster", columnList = "broadcaster")
})
@Table(name = "channels")
public class Channel {
@Id
private String broadcaster;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "channel_admins",
joinColumns = @JoinColumn(name = "channel_id"),
indexes = @Index(name = "idx_channel_admins_username", columnList = "admin_username"))
@CollectionTable(name = "channel_admins", joinColumns = @JoinColumn(name = "channel_id"))
@Column(name = "admin_username")
private Set<String> admins = new HashSet<>();

View File

@@ -3,12 +3,5 @@ package com.imgfloat.app.repository;
import com.imgfloat.app.model.Channel;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ChannelRepository extends JpaRepository<Channel, String> {
List<Channel> findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(String broadcaster);
List<Channel> findTop50ByOrderByBroadcasterAsc();
List<Channel> findByAdminsContaining(String username);
}

View File

@@ -19,8 +19,6 @@ import org.jcodec.common.model.Picture;
import org.jcodec.scale.AWTUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@@ -30,8 +28,6 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URLConnection;
import java.nio.ByteBuffer;
import java.nio.file.InvalidPathException;
@@ -39,12 +35,12 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.nio.file.StandardCopyOption;
import java.util.Base64;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
@@ -62,21 +58,17 @@ public class ChannelDirectoryService {
private static final int MIN_GIF_DELAY_MS = 20;
private static final String PREVIEW_MEDIA_TYPE = "image/png";
private static final Path PREVIEW_ROOT = Paths.get("previews");
private static final Path ASSET_ROOT = Paths.get("assets");
private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class);
private final ChannelRepository channelRepository;
private final AssetRepository assetRepository;
private final SimpMessagingTemplate messagingTemplate;
private final TaskExecutor assetTaskExecutor;
public ChannelDirectoryService(ChannelRepository channelRepository,
AssetRepository assetRepository,
SimpMessagingTemplate messagingTemplate,
@Qualifier("assetTaskExecutor") TaskExecutor assetTaskExecutor) {
SimpMessagingTemplate messagingTemplate) {
this.channelRepository = channelRepository;
this.assetRepository = assetRepository;
this.messagingTemplate = messagingTemplate;
this.assetTaskExecutor = assetTaskExecutor;
}
public Channel getOrCreateChannel(String broadcaster) {
@@ -87,13 +79,13 @@ public class ChannelDirectoryService {
public List<String> searchBroadcasters(String query) {
String normalizedQuery = normalize(query);
if (normalizedQuery == null || normalizedQuery.isBlank()) {
return channelRepository.findTop50ByOrderByBroadcasterAsc().stream()
.map(Channel::getBroadcaster)
.toList();
}
return channelRepository.findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(normalizedQuery).stream()
return channelRepository.findAll().stream()
.map(Channel::getBroadcaster)
.map(this::normalize)
.filter(Objects::nonNull)
.filter(name -> normalizedQuery == null || normalizedQuery.isBlank() || name.contains(normalizedQuery))
.sorted()
.limit(50)
.toList();
}
@@ -142,139 +134,39 @@ public class ChannelDirectoryService {
public Optional<AssetView> createAsset(String broadcaster, MultipartFile file) throws IOException {
Channel channel = getOrCreateChannel(broadcaster);
byte[] bytes;
try (InputStream stream = file.getInputStream()) {
bytes = stream.readAllBytes();
}
byte[] bytes = file.getBytes();
String mediaType = detectMediaType(file, bytes);
OptimizedAsset optimized = optimizeAsset(bytes, mediaType);
if (optimized == null) {
return Optional.empty();
}
String name = Optional.ofNullable(file.getOriginalFilename())
.map(filename -> filename.replaceAll("^.*[/\\\\]", ""))
.filter(s -> !s.isBlank())
.orElse("Asset " + System.currentTimeMillis());
Asset asset = buildPlaceholderAsset(channel, name, bytes, mediaType);
if (asset == null) {
return Optional.empty();
}
if (!shouldOptimizeAsync(mediaType)) {
OptimizedAsset optimized = optimizeAsset(bytes, mediaType);
if (optimized == null) {
return Optional.empty();
}
applyOptimizedAsset(channel.getBroadcaster(), asset, optimized);
}
assetRepository.save(asset);
AssetView view = AssetView.from(channel.getBroadcaster(), asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view));
if (shouldOptimizeAsync(mediaType)) {
enqueueOptimization(channel.getBroadcaster(), asset.getId(), bytes, mediaType);
}
return Optional.of(view);
}
private Asset buildPlaceholderAsset(Channel channel, String name, byte[] bytes, String mediaType) {
Dimension dimension = inferDimensions(bytes, mediaType);
double width = dimension.width() > 0 ? dimension.width() : (mediaType.startsWith("audio/") ? 400 : 640);
double height = dimension.height() > 0 ? dimension.height() : (mediaType.startsWith("audio/") ? 80 : 360);
Asset asset = new Asset(channel.getBroadcaster(), name, "", width, height);
String dataUrl = "data:" + optimized.mediaType() + ";base64," + Base64.getEncoder().encodeToString(optimized.bytes());
double width = optimized.width() > 0 ? optimized.width() : (optimized.mediaType().startsWith("audio/") ? 400 : 640);
double height = optimized.height() > 0 ? optimized.height() : (optimized.mediaType().startsWith("audio/") ? 80 : 360);
Asset asset = new Asset(channel.getBroadcaster(), name, dataUrl, width, height);
asset.setOriginalMediaType(mediaType);
asset.setMediaType(optimized.mediaType());
asset.setPreview(null);
asset.setUrl(storeAsset(channel.getBroadcaster(), asset.getId(), optimized.bytes(), optimized.mediaType()));
asset.setPreview(storePreview(channel.getBroadcaster(), asset.getId(), optimized.previewBytes()));
asset.setSpeed(1.0);
asset.setMuted(mediaType.startsWith("video/"));
asset.setMuted(optimized.mediaType().startsWith("video/"));
asset.setAudioLoop(false);
asset.setAudioDelayMillis(0);
asset.setAudioSpeed(1.0);
asset.setAudioPitch(1.0);
asset.setAudioVolume(1.0);
asset.setZIndex(nextZIndex(channel.getBroadcaster()));
return asset;
}
private boolean shouldOptimizeAsync(String mediaType) {
return "image/gif".equalsIgnoreCase(mediaType) || mediaType.startsWith("video/");
}
private void enqueueOptimization(String broadcaster, String assetId, byte[] bytes, String mediaType) {
assetTaskExecutor.execute(() -> {
try {
OptimizedAsset optimized = optimizeAsset(bytes, mediaType);
if (optimized == null) {
return;
}
assetRepository.findById(assetId)
.filter(asset -> broadcaster.equals(asset.getBroadcaster()))
.ifPresent(asset -> {
boolean changed = applyOptimizedAsset(broadcaster, asset, optimized);
if (changed) {
assetRepository.save(asset);
AssetView view = AssetView.from(broadcaster, asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, view));
}
});
} catch (Exception e) {
logger.warn("Unable to optimize asset {}", assetId, e);
}
});
}
private boolean applyOptimizedAsset(String broadcaster, Asset asset, OptimizedAsset optimized) {
if (optimized == null) {
return false;
}
boolean changed = false;
String optimizedUrl = toDataUrl(optimized.bytes(), optimized.mediaType());
if (!optimizedUrl.equals(asset.getUrl())) {
asset.setUrl(optimizedUrl);
changed = true;
}
if (!Objects.equals(asset.getMediaType(), optimized.mediaType())) {
asset.setMediaType(optimized.mediaType());
changed = true;
}
if (optimized.width() > 0 && optimized.height() > 0) {
asset.setWidth(optimized.width());
asset.setHeight(optimized.height());
changed = true;
}
asset.setMuted(optimized.mediaType().startsWith("video/"));
String previousPreview = asset.getPreview();
try {
asset.setPreview(storePreview(broadcaster, asset.getId(), optimized.previewBytes()));
if (!Objects.equals(previousPreview, asset.getPreview())) {
deletePreviewFile(previousPreview);
changed = changed || asset.getPreview() != null;
}
} catch (IOException e) {
logger.warn("Unable to store preview for asset {}", asset.getId(), e);
}
return changed;
}
private Dimension inferDimensions(byte[] bytes, String mediaType) {
try {
if (mediaType.startsWith("video/")) {
return extractVideoDimensions(bytes);
}
if (mediaType.startsWith("image/")) {
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
if (image != null) {
return new Dimension(image.getWidth(), image.getHeight());
}
}
} catch (IOException e) {
logger.warn("Unable to infer dimensions for {}", mediaType, e);
}
return new Dimension(0, 0);
}
private String toDataUrl(byte[] bytes, String mediaType) {
return "data:" + mediaType + ";base64," + Base64.getEncoder().encodeToString(bytes);
assetRepository.save(asset);
AssetView view = AssetView.from(channel.getBroadcaster(), asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view));
return Optional.of(view);
}
public Optional<AssetView> updateTransform(String broadcaster, String assetId, TransformRequest request) {
@@ -352,7 +244,6 @@ public class ChannelDirectoryService {
.filter(asset -> normalized.equals(asset.getBroadcaster()))
.map(asset -> {
deletePreviewFile(asset.getPreview());
deleteAssetFile(asset.getUrl());
assetRepository.delete(asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.deleted(broadcaster, assetId));
return true;
@@ -410,7 +301,8 @@ public class ChannelDirectoryService {
return List.of();
}
String login = username.toLowerCase();
return channelRepository.findByAdminsContaining(login).stream()
return channelRepository.findAll().stream()
.filter(channel -> channel.getAdmins().contains(login))
.map(Channel::getBroadcaster)
.toList();
}
@@ -432,18 +324,9 @@ public class ChannelDirectoryService {
}
private Optional<AssetContent> decodeAssetData(Asset asset) {
if (asset.getUrl() == null) {
return Optional.empty();
}
if (asset.getUrl().startsWith("data:")) {
return decodeDataUrl(asset.getUrl())
.flatMap(content -> backfillAssetStorage(asset, content));
}
return loadAssetFromStore(asset)
return decodeDataUrl(asset.getUrl())
.or(() -> {
logger.warn("Unable to read asset data for {}", asset.getId());
logger.warn("Unable to decode asset data for {}", asset.getId());
return Optional.empty();
});
}
@@ -490,88 +373,6 @@ public class ChannelDirectoryService {
}
}
private String storeAsset(String broadcaster, String assetId, byte[] bytes, String mediaType) throws IOException {
Path directory = ASSET_ROOT.resolve(normalize(broadcaster));
Files.createDirectories(directory);
String extension = extensionFor(mediaType);
Path assetFile = directory.resolve(assetId + (extension == null ? "" : ("." + extension)));
try (InputStream in = new ByteArrayInputStream(bytes)) {
Files.copy(in, assetFile, StandardCopyOption.REPLACE_EXISTING);
}
return assetFile.toUri().toString();
}
private Optional<AssetContent> loadAssetFromStore(Asset asset) {
String location = asset.getUrl();
if (location == null || location.isBlank()) {
return Optional.empty();
}
try {
URI uri = URI.create(location);
Path path;
if (uri.getScheme() == null) {
path = Paths.get(location);
} else if ("file".equalsIgnoreCase(uri.getScheme())) {
path = Paths.get(uri);
} else {
logger.warn("Unsupported asset URI scheme {} for {}", uri.getScheme(), asset.getId());
return Optional.empty();
}
if (path == null || !Files.exists(path)) {
logger.warn("Asset location {} is not readable", location);
return Optional.empty();
}
try (InputStream stream = Files.newInputStream(path)) {
byte[] bytes = stream.readAllBytes();
String mediaType = asset.getMediaType() == null ? "application/octet-stream" : asset.getMediaType();
return Optional.of(new AssetContent(bytes, mediaType));
}
} catch (IOException | IllegalArgumentException e) {
logger.warn("Unable to read asset data from {}", location, e);
return Optional.empty();
}
}
private Optional<AssetContent> backfillAssetStorage(Asset asset, AssetContent content) {
try {
String storedLocation = storeAsset(asset.getBroadcaster(), asset.getId(), content.bytes(),
asset.getMediaType() == null ? content.mediaType() : asset.getMediaType());
asset.setUrl(storedLocation);
assetRepository.save(asset);
return Optional.of(new AssetContent(content.bytes(),
asset.getMediaType() == null ? content.mediaType() : asset.getMediaType()));
} catch (IOException e) {
logger.warn("Unable to backfill storage for {}", asset.getId(), e);
return Optional.empty();
}
}
private void deleteAssetFile(String location) {
if (location == null || location.isBlank()) {
return;
}
try {
URI uri = URI.create(location);
Path path;
if (uri.getScheme() == null) {
path = Paths.get(location);
} else if ("file".equalsIgnoreCase(uri.getScheme())) {
path = Paths.get(uri);
} else {
return;
}
if (path != null) {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
logger.warn("Unable to delete asset file {}", path, e);
}
}
} catch (IllegalArgumentException e) {
logger.debug("Asset location {} is not a file path; skipping delete", location);
}
}
private String storePreview(String broadcaster, String assetId, byte[] previewBytes) throws IOException {
if (previewBytes == null || previewBytes.length == 0) {
return null;
@@ -638,24 +439,6 @@ public class ChannelDirectoryService {
.orElse("application/octet-stream");
}
private String extensionFor(String mediaType) {
if (mediaType == null) {
return null;
}
return switch (mediaType.toLowerCase(Locale.ROOT)) {
case "image/png" -> "png";
case "image/jpeg" -> "jpg";
case "image/gif" -> "gif";
case "video/mp4" -> "mp4";
case "video/webm" -> "webm";
case "video/quicktime" -> "mov";
case "audio/mpeg" -> "mp3";
case "audio/wav" -> "wav";
case "audio/ogg" -> "ogg";
default -> null;
};
}
private OptimizedAsset optimizeAsset(byte[] bytes, String mediaType) throws IOException {
if ("image/gif".equalsIgnoreCase(mediaType)) {
OptimizedAsset transcoded = transcodeGifToVideo(bytes);

View File

@@ -11,24 +11,19 @@ import com.imgfloat.app.service.ChannelDirectoryService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.core.task.TaskExecutor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.mock.web.MockMultipartFile;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ConcurrentHashMap;
import javax.imageio.ImageIO;
import org.jcodec.api.awt.AWTSequenceEncoder;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@@ -43,18 +38,14 @@ class ChannelDirectoryServiceTest {
private SimpMessagingTemplate messagingTemplate;
private ChannelRepository channelRepository;
private AssetRepository assetRepository;
private RecordingTaskExecutor taskExecutor;
private Map<String, Channel> channels;
private Map<String, Asset> assets;
@BeforeEach
void setup() {
messagingTemplate = mock(SimpMessagingTemplate.class);
channelRepository = mock(ChannelRepository.class);
assetRepository = mock(AssetRepository.class);
taskExecutor = new RecordingTaskExecutor();
setupInMemoryPersistence();
service = new ChannelDirectoryService(channelRepository, assetRepository, messagingTemplate, taskExecutor);
service = new ChannelDirectoryService(channelRepository, assetRepository, messagingTemplate);
}
@Test
@@ -67,41 +58,6 @@ class ChannelDirectoryServiceTest {
verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.contains("/topic/channel/caster"), captor.capture());
}
@Test
void asyncGifOptimizationSwitchesToVideo() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "image.gif", "image/gif", largeGif());
long start = System.currentTimeMillis();
AssetView created = service.createAsset("caster", file).orElseThrow();
long duration = System.currentTimeMillis() - start;
assertThat(duration).isLessThan(750L);
Asset placeholder = assets.get(created.id());
assertThat(placeholder.getMediaType()).isEqualTo("image/gif");
assertThat(placeholder.getPreview()).isNull();
taskExecutor.runAll();
Asset optimized = assets.get(created.id());
assertThat(optimized.getMediaType()).isEqualTo("video/mp4");
assertThat(optimized.getPreview()).isNotBlank();
}
@Test
void videoPreviewGeneratedInBackground() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "clip.mp4", "video/mp4", sampleVideo());
AssetView created = service.createAsset("caster", file).orElseThrow();
Asset placeholder = assets.get(created.id());
assertThat(placeholder.getPreview()).isNull();
taskExecutor.runAll();
Asset optimized = assets.get(created.id());
assertThat(optimized.getPreview()).isNotBlank();
assertThat(optimized.getMediaType()).isEqualTo("video/mp4");
}
@Test
void updatesTransformAndVisibility() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng());
@@ -129,33 +85,9 @@ class ChannelDirectoryServiceTest {
return out.toByteArray();
}
private byte[] largeGif() throws IOException {
BufferedImage image = new BufferedImage(256, 256, BufferedImage.TYPE_INT_ARGB);
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
ImageIO.write(image, "gif", out);
return out.toByteArray();
}
}
private byte[] sampleVideo() throws IOException {
File temp = File.createTempFile("sample", ".mp4");
temp.deleteOnExit();
try {
AWTSequenceEncoder encoder = AWTSequenceEncoder.createSequenceEncoder(temp, 10);
for (int i = 0; i < 15; i++) {
BufferedImage frame = new BufferedImage(64, 64, BufferedImage.TYPE_INT_RGB);
encoder.encodeImage(frame);
}
encoder.finish();
return java.nio.file.Files.readAllBytes(temp.toPath());
} finally {
temp.delete();
}
}
private void setupInMemoryPersistence() {
channels = new ConcurrentHashMap<>();
assets = new ConcurrentHashMap<>();
Map<String, Channel> channels = new ConcurrentHashMap<>();
Map<String, Asset> assets = new ConcurrentHashMap<>();
when(channelRepository.findById(anyString()))
.thenAnswer(invocation -> Optional.ofNullable(channels.get(invocation.getArgument(0))));
@@ -190,18 +122,4 @@ class ChannelDirectoryServiceTest {
.filter(asset -> !onlyVisible || !asset.isHidden())
.toList();
}
private static class RecordingTaskExecutor implements TaskExecutor {
private final List<Runnable> queued = new CopyOnWriteArrayList<>();
@Override
public void execute(Runnable task) {
queued.add(task);
}
void runAll() {
new ArrayList<>(queued).forEach(Runnable::run);
queued.clear();
}
}
}

View File

@@ -1,66 +0,0 @@
package com.imgfloat.app.service;
import com.imgfloat.app.model.Channel;
import com.imgfloat.app.repository.ChannelRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import com.imgfloat.app.repository.AssetRepository;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(SpringExtension.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ChannelDirectoryServiceIntegrationTest {
@Autowired
private ChannelRepository channelRepository;
private ChannelDirectoryService channelDirectoryService;
@BeforeEach
void setUp() {
channelDirectoryService = new ChannelDirectoryService(
channelRepository,
Mockito.mock(AssetRepository.class),
Mockito.mock(SimpMessagingTemplate.class));
}
@Test
void searchBroadcastersFindsPartialMatchesCaseInsensitive() {
Channel alpha = new Channel("Alpha");
Channel alphabeta = new Channel("AlphaBeta");
Channel bravo = new Channel("Bravo");
channelRepository.saveAll(List.of(alpha, alphabeta, bravo));
List<String> results = channelDirectoryService.searchBroadcasters("lPh");
assertThat(results).containsExactly("alpha", "alphabeta");
}
@Test
void adminChannelsForFindsNormalizedAdminEntries() {
Channel alpha = new Channel("Alpha");
alpha.addAdmin("ModOne");
Channel bravo = new Channel("Bravo");
bravo.addAdmin("modone");
Channel charlie = new Channel("Charlie");
charlie.addAdmin("other");
channelRepository.saveAll(List.of(alpha, bravo, charlie));
List<String> adminChannels = channelDirectoryService.adminChannelsFor("MODONE").stream().sorted().toList();
assertThat(adminChannels).containsExactly("alpha", "bravo");
}
}