From 4fd7b7ddd09ea98e53511027c82e1cee5fc94c3b Mon Sep 17 00:00:00 2001 From: 0xb00bface <0xboobface@gmail.com> Date: Fri, 19 Feb 2021 17:06:57 +0100 Subject: [PATCH] First kind of working model groups # Conflicts: # common/src/main/java/ctbrec/AbstractModel.java # common/src/main/java/ctbrec/Model.java # common/src/main/java/ctbrec/ModelGroup.java # common/src/main/java/ctbrec/ModelGroupEntry.java # common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java # common/src/main/java/ctbrec/recorder/Recorder.java # common/src/main/java/ctbrec/recorder/RemoteRecorder.java --- .../src/main/java/ctbrec/AbstractModel.java | 11 +++ common/src/main/java/ctbrec/Config.java | 4 + common/src/main/java/ctbrec/Model.java | 4 + common/src/main/java/ctbrec/ModelGroup.java | 74 +++++++++++++++ .../src/main/java/ctbrec/ModelGroupEntry.java | 53 +++++++++++ common/src/main/java/ctbrec/Settings.java | 3 + .../main/java/ctbrec/io/ModelJsonAdapter.java | 16 ++++ .../main/java/ctbrec/io/UuidJSonAdapter.java | 22 +++++ .../ctbrec/recorder/NextGenLocalRecorder.java | 95 ++++++++++++++++--- .../main/java/ctbrec/recorder/Recorder.java | 20 +++- .../recorder/RecordingPreconditions.java | 36 +++++++ .../java/ctbrec/recorder/RemoteRecorder.java | 85 +++++++++++++---- 12 files changed, 385 insertions(+), 38 deletions(-) create mode 100644 common/src/main/java/ctbrec/ModelGroup.java create mode 100644 common/src/main/java/ctbrec/ModelGroupEntry.java create mode 100644 common/src/main/java/ctbrec/io/UuidJSonAdapter.java diff --git a/common/src/main/java/ctbrec/AbstractModel.java b/common/src/main/java/ctbrec/AbstractModel.java index 216f4c72..577236fe 100644 --- a/common/src/main/java/ctbrec/AbstractModel.java +++ b/common/src/main/java/ctbrec/AbstractModel.java @@ -41,6 +41,7 @@ public abstract class AbstractModel implements Model { private Instant lastRecorded; private Instant recordUntil; private SubsequentAction recordUntilSubsequentAction; + private Optional modelGroup = Optional.empty(); // NOSONAR @Override public boolean isOnline() throws IOException, ExecutionException, InterruptedException { @@ -309,4 +310,14 @@ public abstract class AbstractModel implements Model { } return true; } + + @Override + public Optional getModelGroup() { + return modelGroup; + } + + @Override + public void setModelGroup(ModelGroupEntry modelGroup) { + this.modelGroup = Optional.ofNullable(modelGroup); + } } diff --git a/common/src/main/java/ctbrec/Config.java b/common/src/main/java/ctbrec/Config.java index f3c8d2c1..f16c6ec9 100644 --- a/common/src/main/java/ctbrec/Config.java +++ b/common/src/main/java/ctbrec/Config.java @@ -19,6 +19,7 @@ import java.util.Map.Entry; import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; +import java.util.UUID; import org.apache.commons.io.FileUtils; import org.slf4j.Logger; @@ -31,6 +32,7 @@ import ctbrec.Settings.SplitStrategy; import ctbrec.io.FileJsonAdapter; import ctbrec.io.ModelJsonAdapter; import ctbrec.io.PostProcessorJsonAdapter; +import ctbrec.io.UuidJSonAdapter; import ctbrec.recorder.postprocessing.DeleteTooShort; import ctbrec.recorder.postprocessing.PostProcessor; import ctbrec.recorder.postprocessing.RemoveKeepFile; @@ -74,6 +76,7 @@ public class Config { .add(Model.class, new ModelJsonAdapter(sites)) .add(PostProcessor.class, new PostProcessorJsonAdapter()) .add(File.class, new FileJsonAdapter()) + .add(UUID.class, new UuidJSonAdapter()) .build(); JsonAdapter adapter = moshi.adapter(Settings.class).lenient(); File configFile = new File(configDir, filename); @@ -234,6 +237,7 @@ public class Config { .add(Model.class, new ModelJsonAdapter()) .add(PostProcessor.class, new PostProcessorJsonAdapter()) .add(File.class, new FileJsonAdapter()) + .add(UUID.class, new UuidJSonAdapter()) .build(); JsonAdapter adapter = moshi.adapter(Settings.class).indent(" "); String json = adapter.toJson(settings); diff --git a/common/src/main/java/ctbrec/Model.java b/common/src/main/java/ctbrec/Model.java index 551ac5c4..bf30ab32 100644 --- a/common/src/main/java/ctbrec/Model.java +++ b/common/src/main/java/ctbrec/Model.java @@ -4,6 +4,7 @@ import java.io.IOException; import java.io.Serializable; import java.time.Instant; import java.util.List; +import java.util.Optional; import java.util.concurrent.ExecutionException; import javax.xml.bind.JAXBException; @@ -148,4 +149,7 @@ public interface Model extends Comparable, Serializable { */ public boolean exists() throws IOException; + public void setModelGroup(ModelGroupEntry modelGroupEntry); + public Optional getModelGroup(); + } \ No newline at end of file diff --git a/common/src/main/java/ctbrec/ModelGroup.java b/common/src/main/java/ctbrec/ModelGroup.java new file mode 100644 index 00000000..4092b219 --- /dev/null +++ b/common/src/main/java/ctbrec/ModelGroup.java @@ -0,0 +1,74 @@ +package ctbrec; + +import java.io.Serializable; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +public class ModelGroup implements Serializable { + private static final long serialVersionUID = 1L; + + private UUID id; + private String name; + private transient List models = new LinkedList<>(); + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public List getModels() { + return models; + } + + public void setModels(List models) { + this.models = models; + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ModelGroup other = (ModelGroup) obj; + return Objects.equals(id, other.id); + } + + @Override + public String toString() { + return "ModelGroup [id=" + id + ", name=" + name + ", models=" + models + "]"; + } + + public void add(Model model) { + models.add(model); + Collections.sort(models, (m1, m2) -> { + int index1 = m1.getModelGroup().map(ModelGroupEntry::getIndex).orElse(0); + int index2 = m2.getModelGroup().map(ModelGroupEntry::getIndex).orElse(0); + return index1 - index2; + }); + } +} diff --git a/common/src/main/java/ctbrec/ModelGroupEntry.java b/common/src/main/java/ctbrec/ModelGroupEntry.java new file mode 100644 index 00000000..df04eb6f --- /dev/null +++ b/common/src/main/java/ctbrec/ModelGroupEntry.java @@ -0,0 +1,53 @@ +package ctbrec; + +import java.io.Serializable; +import java.util.Objects; +import java.util.UUID; + +public class ModelGroupEntry implements Serializable { + private static final long serialVersionUID = 0L; + + private UUID id; + private int index; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public int getIndex() { + return index; + } + + public void setIndex(int index) { + this.index = index; + } + + @Override + public int hashCode() { + return Objects.hash(id, index); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ModelGroupEntry other = (ModelGroupEntry) obj; + return Objects.equals(id, other.id) && index == other.index; + } + + @Override + public String toString() { + return "ModelGroupEntry [id=" + id + ", index=" + index + "]"; + } +} diff --git a/common/src/main/java/ctbrec/Settings.java b/common/src/main/java/ctbrec/Settings.java index 8f5b737b..c0402eaf 100644 --- a/common/src/main/java/ctbrec/Settings.java +++ b/common/src/main/java/ctbrec/Settings.java @@ -3,8 +3,10 @@ package ctbrec; import java.io.File; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import ctbrec.event.EventHandlerConfiguration; import ctbrec.recorder.postprocessing.PostProcessor; @@ -108,6 +110,7 @@ public class Settings { public long minimumSpaceLeftInBytes = 0; public Map modelNotes = new HashMap<>(); public List models = new ArrayList<>(); + public Set modelGroups = new HashSet<>(); @Deprecated public List modelsIgnored = new ArrayList<>(); public boolean monitorClipboard = false; diff --git a/common/src/main/java/ctbrec/io/ModelJsonAdapter.java b/common/src/main/java/ctbrec/io/ModelJsonAdapter.java index 7f585968..ed44bd61 100644 --- a/common/src/main/java/ctbrec/io/ModelJsonAdapter.java +++ b/common/src/main/java/ctbrec/io/ModelJsonAdapter.java @@ -5,6 +5,7 @@ import java.lang.reflect.InvocationTargetException; import java.time.Instant; import java.util.List; import java.util.Optional; +import java.util.UUID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,6 +16,7 @@ import com.squareup.moshi.JsonReader.Token; import com.squareup.moshi.JsonWriter; import ctbrec.Model; +import ctbrec.ModelGroupEntry; import ctbrec.SubsequentAction; import ctbrec.sites.Site; import ctbrec.sites.chaturbate.ChaturbateModel; @@ -69,6 +71,18 @@ public class ModelJsonAdapter extends JsonAdapter { model.setRecordUntil(Instant.ofEpochMilli(reader.nextLong())); } else if (key.equals("recordUntilSubsequentAction")) { model.setRecordUntilSubsequentAction(SubsequentAction.valueOf(reader.nextString())); + } else if (key.equals("groupId")) { + ModelGroupEntry entry = new ModelGroupEntry(); + entry.setId(UUID.fromString(reader.nextString())); + model.setModelGroup(entry); + } else if (key.equals("groupIndex")) { + model.getModelGroup().ifPresent(mg -> { + try { + mg.setIndex(reader.nextInt()); + } catch (IOException e) { + LOG.error("Error while reading model group index", e); + } + }); } else if (key.equals("siteSpecific")) { reader.beginObject(); try { @@ -114,6 +128,8 @@ public class ModelJsonAdapter extends JsonAdapter { writer.name("lastRecorded").value(model.getLastRecorded().toEpochMilli()); writer.name("recordUntil").value(model.getRecordUntil().toEpochMilli()); writer.name("recordUntilSubsequentAction").value(model.getRecordUntilSubsequentAction().name()); + writer.name("groupId").value(model.getModelGroup().map(ModelGroupEntry::getId).map(Object::toString).orElse(null)); + writer.name("groupIndex").value(model.getModelGroup().map(ModelGroupEntry::getIndex).orElse(0)); writer.name("siteSpecific"); writer.beginObject(); model.writeSiteSpecificData(writer); diff --git a/common/src/main/java/ctbrec/io/UuidJSonAdapter.java b/common/src/main/java/ctbrec/io/UuidJSonAdapter.java new file mode 100644 index 00000000..b3a481a7 --- /dev/null +++ b/common/src/main/java/ctbrec/io/UuidJSonAdapter.java @@ -0,0 +1,22 @@ +package ctbrec.io; + +import java.io.IOException; +import java.util.UUID; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.JsonReader; +import com.squareup.moshi.JsonWriter; + +public class UuidJSonAdapter extends JsonAdapter { + + @Override + public UUID fromJson(JsonReader reader) throws IOException { + return UUID.fromString(reader.nextString()); + } + + @Override + public void toJson(JsonWriter writer, UUID value) throws IOException { + writer.value(value.toString()); + } + +} diff --git a/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java b/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java index fa29d037..3b120c39 100644 --- a/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java +++ b/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java @@ -22,6 +22,7 @@ import java.util.Map; import java.util.NoSuchElementException; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CompletableFuture; @@ -45,6 +46,8 @@ import com.google.common.eventbus.Subscribe; import ctbrec.Config; import ctbrec.Model; +import ctbrec.ModelGroup; +import ctbrec.ModelGroupEntry; import ctbrec.Recording; import ctbrec.Recording.State; import ctbrec.event.Event; @@ -54,6 +57,7 @@ import ctbrec.event.NoSpaceLeftEvent; import ctbrec.event.RecordingStateChangedEvent; import ctbrec.io.HttpClient; import ctbrec.recorder.download.Download; +import ctbrec.recorder.postprocessing.PostProcessingContext; import ctbrec.recorder.postprocessing.PostProcessor; import ctbrec.sites.Site; @@ -88,18 +92,8 @@ public class NextGenLocalRecorder implements Recorder { downloadPool = Executors.newScheduledThreadPool(5, createThreadFactory("Download", MAX_PRIORITY)); threadPoolScaler = new ThreadPoolScaler((ThreadPoolExecutor) downloadPool, 5); recordingManager = new RecordingManager(config, sites); - config.getSettings().models.stream().forEach(m -> { - if (m.getSite() != null) { - if (m.getSite().isEnabled()) { - models.add(m); - } else { - LOG.info("{} disabled -> ignoring {}", m.getSite().getName(), m.getName()); - } - } else { - LOG.info("Site for model {} is unknown -> ignoring", m.getName()); - } - }); - + loadModels(); + createModelGroups(); int ppThreads = config.getSettings().postProcessingThreads; ppPool = new ThreadPoolExecutor(ppThreads, ppThreads, 5, TimeUnit.MINUTES, ppQueue, createThreadFactory("PP", MIN_PRIORITY)); @@ -127,6 +121,30 @@ public class NextGenLocalRecorder implements Recorder { }, 1, 1, TimeUnit.SECONDS); } + private void loadModels() { + config.getSettings().models.stream().forEach(m -> { + if (m.getSite() != null) { + if (m.getSite().isEnabled()) { + models.add(m); + } else { + LOG.info("{} disabled -> ignoring {}", m.getSite().getName(), m.getName()); + } + } else { + LOG.info("Site for model {} is unknown -> ignoring", m.getName()); + } + }); + } + + private void createModelGroups() { + for (Model model : models) { + if(model.getModelGroup().isPresent()) { + ModelGroupEntry groupEntry = model.getModelGroup().get(); // NOSONAR + ModelGroup group = getModelGroup(groupEntry.getId()); + group.add(model); + } + } + } + private void startCompletionHandler() { downloadCompletionPool.submit(() -> { while (!Thread.currentThread().isInterrupted()) { @@ -209,9 +227,10 @@ public class NextGenLocalRecorder implements Recorder { recordingManager.saveRecording(recording); recording.postprocess(); List postProcessors = config.getSettings().postProcessors; + PostProcessingContext ctx = createPostProcessingContext(recording); for (PostProcessor postProcessor : postProcessors) { LOG.debug("Running post-processor: {}", postProcessor.getName()); - boolean continuePP = postProcessor.postprocess(recording, recordingManager, config); + boolean continuePP = postProcessor.postprocess(ctx); if (!continuePP) { break; } @@ -237,6 +256,15 @@ public class NextGenLocalRecorder implements Recorder { }); } + private PostProcessingContext createPostProcessingContext(Recording recording) { + PostProcessingContext ctx = new PostProcessingContext(); + ctx.setConfig(config); + ctx.setRecorder(this); + ctx.setRecording(recording); + ctx.setRecordingManager(recordingManager); + return ctx; + } + private void setRecordingStatus(Recording recording, State status) { recording.setStatus(status); RecordingStateChangedEvent evt = new RecordingStateChangedEvent(recording.getDownload().getTarget(), status, recording.getModel(), @@ -761,4 +789,45 @@ public class NextGenLocalRecorder implements Recorder { public int getModelCount() { return (int) models.stream().filter(m -> !m.isMarkedForLaterRecording()).count(); } + + @Override + public Set getModelGroups() { + return config.getSettings().modelGroups; + } + + @Override + public ModelGroup getModelGroup(UUID id) { + for (ModelGroup group : getModelGroups()) { + if (Objects.equals(group.getId(), id)) { + return group; + } + } + throw new NoSuchElementException("ModelGroup with id " + id + " not found"); + } + + @Override + public ModelGroup createModelGroup(String name) { + ModelGroup group = new ModelGroup(); + group.setName(name); + config.getSettings().modelGroups.add(group); + try { + config.save(); + } catch (IOException e) { + LOG.error("Couldn't save new model group", e); + } + return group; + } + + @Override + public void deleteModelGroup(ModelGroup group) { + for (Model model : group.getModels()) { + model.setModelGroup(null); + } + config.getSettings().modelGroups.remove(group); + try { + config.save(); + } catch (IOException e) { + LOG.error("Couldn't delete model group", e); + } + } } diff --git a/common/src/main/java/ctbrec/recorder/Recorder.java b/common/src/main/java/ctbrec/recorder/Recorder.java index 4c88b47b..f61105d3 100644 --- a/common/src/main/java/ctbrec/recorder/Recorder.java +++ b/common/src/main/java/ctbrec/recorder/Recorder.java @@ -1,15 +1,18 @@ package ctbrec.recorder; -import ctbrec.Model; -import ctbrec.Recording; -import ctbrec.io.HttpClient; - import java.io.IOException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.List; +import java.util.Set; +import java.util.UUID; import java.util.stream.Collectors; +import ctbrec.Model; +import ctbrec.ModelGroup; +import ctbrec.Recording; +import ctbrec.io.HttpClient; + public interface Recorder { public void addModel(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException; @@ -143,5 +146,14 @@ public interface Recorder { */ public void resume() throws InvalidKeyException, NoSuchAlgorithmException, IOException; + /** + * Returns the number of models, which are on the recording list and not marked for later recording + * @return + */ public int getModelCount(); + + public Set getModelGroups(); + public ModelGroup createModelGroup(String name); + public ModelGroup getModelGroup(UUID uuid); + public void deleteModelGroup(ModelGroup group); } diff --git a/common/src/main/java/ctbrec/recorder/RecordingPreconditions.java b/common/src/main/java/ctbrec/recorder/RecordingPreconditions.java index b1008beb..9222ba0f 100644 --- a/common/src/main/java/ctbrec/recorder/RecordingPreconditions.java +++ b/common/src/main/java/ctbrec/recorder/RecordingPreconditions.java @@ -12,6 +12,8 @@ import org.slf4j.LoggerFactory; import ctbrec.Config; import ctbrec.Model; +import ctbrec.ModelGroup; +import ctbrec.ModelGroupEntry; import ctbrec.Recording; import ctbrec.recorder.download.Download; @@ -36,6 +38,7 @@ public class RecordingPreconditions { ensureEnoughSpaceForRecording(); ensureDownloadSlotAvailable(model); ensureModelIsOnline(model); + ensureNoOtherFromModelGroupIsRecording(model); } private void ensureModelIsOnline(Model model) { @@ -130,4 +133,37 @@ public class RecordingPreconditions { int concurrentRecordings = Config.getInstance().getSettings().concurrentRecordings; return concurrentRecordings == 0 || concurrentRecordings > 0 && recorder.getRecordingProcesses().size() < concurrentRecordings; } + + private void ensureNoOtherFromModelGroupIsRecording(Model model) { + if (model.getModelGroup().isPresent()) { + ModelGroupEntry modelGroupEntry = model.getModelGroup().get(); // NOSONAR + ModelGroup modelGroup = recorder.getModelGroup(modelGroupEntry.getId()); + for (Model groupModel : modelGroup.getModels()) { + if (groupModel.equals(model)) { + return; // no other model with lower group index is online, start recording + } else if (otherModelCanBeRecorded(groupModel)) { + throw new PreconditionNotMetException(groupModel + " from the same group is already recorded"); + } + } + } + } + + private boolean otherModelCanBeRecorded(Model model) { + try { + ensureRecorderIsActive(); + ensureModelIsNotSuspended(model); + ensureModelIsNotMarkedForLaterRecording(model); + ensureRecordUntilIsInFuture(model); + ensureModelShouldBeRecorded(model); + ensureEnoughSpaceForRecording(); + ensureDownloadSlotAvailable(model); + ensureModelIsOnline(model); + return true; + } catch (PreconditionNotMetException e) { + // precondition for other model not met + } catch (IOException e) { + LOG.warn("Couldn't check if preconditions of other model from group are met. Assuming she's offline", e); + } + return false; + } } diff --git a/common/src/main/java/ctbrec/recorder/RemoteRecorder.java b/common/src/main/java/ctbrec/recorder/RemoteRecorder.java index fa075ef1..4e711057 100644 --- a/common/src/main/java/ctbrec/recorder/RemoteRecorder.java +++ b/common/src/main/java/ctbrec/recorder/RemoteRecorder.java @@ -1,25 +1,5 @@ package ctbrec.recorder; -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.Moshi; -import ctbrec.Config; -import ctbrec.Hmac; -import ctbrec.Model; -import ctbrec.Recording; -import ctbrec.event.EventBusHolder; -import ctbrec.event.NoSpaceLeftEvent; -import ctbrec.event.RecordingStateChangedEvent; -import ctbrec.io.*; -import ctbrec.sites.Site; -import okhttp3.MediaType; -import okhttp3.Request; -import okhttp3.Request.Builder; -import okhttp3.RequestBody; -import okhttp3.Response; -import org.json.JSONObject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.io.File; import java.io.IOException; import java.io.UnsupportedEncodingException; @@ -27,7 +7,43 @@ import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.time.Instant; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; + +import ctbrec.Config; +import ctbrec.Hmac; +import ctbrec.Model; +import ctbrec.ModelGroup; +import ctbrec.Recording; +import ctbrec.event.EventBusHolder; +import ctbrec.event.NoSpaceLeftEvent; +import ctbrec.event.RecordingStateChangedEvent; +import ctbrec.io.BandwidthMeter; +import ctbrec.io.FileJsonAdapter; +import ctbrec.io.HttpClient; +import ctbrec.io.HttpException; +import ctbrec.io.InstantJsonAdapter; +import ctbrec.io.ModelJsonAdapter; +import ctbrec.sites.Site; +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.Request.Builder; +import okhttp3.RequestBody; +import okhttp3.Response; public class RemoteRecorder implements Recorder { @@ -592,4 +608,31 @@ public class RemoteRecorder implements Recorder { public int getModelCount() { return (int) models.stream().filter(m -> !m.isMarkedForLaterRecording()).count(); } + + @Override + public Set getModelGroups() { + // TODO Auto-generated method stub + return null; + } + + @Override + public ModelGroup createModelGroup(String name) { + // TODO Auto-generated method stub + return null; + } + + @Override + public void deleteModelGroup(ModelGroup group) { + // TODO Auto-generated method stub + } + + @Override + public ModelGroup getModelGroup(UUID id) { + for (ModelGroup group : getModelGroups()) { + if (Objects.equals(group.getId(), id)) { + return group; + } + } + throw new NoSuchElementException("ModelGroup with id " + id + " not found"); + } }