@@ -170,36 +170,51 @@ public Result loadPluginFromZip(File zipFile, File pluginsDir) {
170170 * the jar and the metadata subdirectory.
171171 */
172172 public Result unloadPluginFromZip (String pluginName , File pluginsDir ) {
173- PluginRecord record = pluginRecords . get (pluginName );
173+ PluginRecord record = findPluginRecord (pluginName );
174174 String pluginId = record != null ? record .getPluginId () : slugify (pluginName );
175175
176- Result unloadResult = unloadPlugin (pluginId );
176+ // Try to unload beans — will fail silently if plugin was loaded by Spring
177+ // component scan (after restart) rather than by PluginDeployer
178+ unloadPlugin (pluginId );
177179
178180 if (record != null ) {
179181 String pid = record .getPluginId ();
180182 File canonicalDir = new File (pluginsDir , pid );
181183 File uninstallSh = new File (canonicalDir , "uninstall.sh" );
182- File flatJar = new File (pluginsDir , pid + ".jar" );
183184
185+ // Run uninstall.sh if present — handles WEB-INF/lib cleanup for
186+ // restart-required plugins
184187 if (uninstallSh .exists ()) {
188+ File jarFile = record .getJarPath () != null
189+ ? new File (record .getJarPath ())
190+ : new File (pluginsDir , pid + ".jar" );
185191 int exitCode = runInstallScript (uninstallSh , canonicalDir , record .getName (),
186- record .getVersion (), flatJar , pid );
192+ record .getVersion (), jarFile , pid );
187193 if (exitCode != 0 ) {
188194 log .warn ("uninstall.sh for {} exited with code {}" , pluginName , exitCode );
189195 }
190196 }
191197
198+ // Delete the jar wherever it is — plugins/ or WEB-INF/lib/
199+ if (record .getJarPath () != null ) {
200+ File jarFile = new File (record .getJarPath ());
201+ if (jarFile .exists () && !jarFile .delete ()) {
202+ log .warn ("Failed to delete plugin jar: {}" , jarFile .getAbsolutePath ());
203+ }
204+ }
205+
206+ // Also try the flat jar in plugins/ in case jarPath wasn't set
207+ File flatJar = new File (pluginsDir , pid + ".jar" );
192208 if (flatJar .exists () && !flatJar .delete ()) {
193209 log .warn ("Failed to delete plugin jar: {}" , flatJar .getAbsolutePath ());
194210 }
211+
195212 deleteDirectory (canonicalDir );
196- record .setState (PluginState .UNINSTALLED );
213+ record .setState (PluginState .INSTALLED_PENDING_RESTART );
214+ record .setLastError ("Uninstalled — restart server to fully clean up" );
197215 }
198216
199- pluginRecords .remove (pluginName );
200- return new Result (true , unloadResult .isSuccess ()
201- ? "Plugin removed: " + pluginName
202- : "Plugin removed (was not active): " + pluginName );
217+ return new Result (true , "Plugin removed. Restart server to fully clean up." );
203218 }
204219
205220 /**
@@ -323,11 +338,50 @@ protected boolean isCandidateComponent(
323338 }
324339
325340 /**
326- * Destroys singleton beans for a plugin in each webapp context.
341+ * Destroys plugin beans, removes stream listeners, and reloads Jersey
342+ * without the plugin's REST classes. Works for both hot-loaded plugins
343+ * (tracked in springPluginBeanNames) and startup-loaded plugins (found
344+ * by scanning Spring context bean names).
327345 */
328346 Result unloadPlugin (String pluginId ) {
347+ // Find bean names — either from our tracking map (hot-loaded) or by
348+ // scanning the record's jar to find what @Component classes it has
329349 List <String > beanNames = springPluginBeanNames .get (pluginId );
330- if (beanNames == null ) {
350+
351+ // For startup-loaded plugins, scan the jar to find bean names
352+ PluginRecord record = findPluginRecord (pluginId );
353+ List <Class <?>> restClassesToRemove = new ArrayList <>();
354+
355+ if (beanNames == null && record != null && record .getJarPath () != null ) {
356+ beanNames = new ArrayList <>();
357+ try {
358+ File jarFile = new File (record .getJarPath ());
359+ if (jarFile .exists ()) {
360+ try (JarFile jar = new JarFile (jarFile )) {
361+ java .util .Enumeration <java .util .jar .JarEntry > entries = jar .entries ();
362+ while (entries .hasMoreElements ()) {
363+ java .util .jar .JarEntry entry = entries .nextElement ();
364+ String name = entry .getName ();
365+ if (!name .endsWith (".class" ) || name .contains ("$" )) continue ;
366+ String className = name .replace ('/' , '.' ).replace (".class" , "" );
367+ try {
368+ Class <?> cls = Class .forName (className , false , ClassLoader .getSystemClassLoader ());
369+ if (cls .isAnnotationPresent (Component .class )) {
370+ beanNames .add (resolveBeanName (cls ));
371+ if (cls .isAnnotationPresent (Path .class )) {
372+ restClassesToRemove .add (cls );
373+ }
374+ }
375+ } catch (ClassNotFoundException | NoClassDefFoundError ignored ) {}
376+ }
377+ }
378+ }
379+ } catch (Exception e ) {
380+ log .warn ("Could not scan jar for bean names: {}" , e .getMessage ());
381+ }
382+ }
383+
384+ if (beanNames == null || beanNames .isEmpty ()) {
331385 return new Result (false , "Plugin not found: " + pluginId );
332386 }
333387
@@ -341,14 +395,30 @@ Result unloadPlugin(String pluginId) {
341395
342396 try {
343397 AutowireCapableBeanFactory bf = springCtx .getAutowireCapableBeanFactory ();
344- if (bf instanceof SingletonBeanRegistry ) {
345- for (String beanName : beanNames ) {
346- try {
347- ((org .springframework .beans .factory .support .DefaultSingletonBeanRegistry ) bf )
348- .destroySingleton (beanName );
349- } catch (Exception e ) {
350- log .warn ("Failed to destroy bean {} in {}: {}" , beanName , ctxPath , e .getMessage ());
398+ if (!(bf instanceof org .springframework .beans .factory .support .DefaultSingletonBeanRegistry )) continue ;
399+
400+ org .springframework .beans .factory .support .DefaultSingletonBeanRegistry registry =
401+ (org .springframework .beans .factory .support .DefaultSingletonBeanRegistry ) bf ;
402+
403+ for (String beanName : beanNames ) {
404+ try {
405+ // Remove stream listener if the bean is one
406+ Object bean = ((org .springframework .beans .factory .BeanFactory ) bf ).getBean (beanName );
407+ if (bean instanceof io .antmedia .plugin .api .IStreamListener ) {
408+ try {
409+ io .antmedia .muxer .IAntMediaStreamHandler app =
410+ (io .antmedia .muxer .IAntMediaStreamHandler ) springCtx .getBean (
411+ io .antmedia .AntMediaApplicationAdapter .BEAN_NAME );
412+ app .removeStreamListener ((io .antmedia .plugin .api .IStreamListener ) bean );
413+ log .info ("Removed stream listener '{}' from context '{}'" , beanName , ctxPath );
414+ } catch (Exception e ) {
415+ log .debug ("Could not remove stream listener: {}" , e .getMessage ());
416+ }
351417 }
418+ registry .destroySingleton (beanName );
419+ log .info ("Destroyed bean '{}' in context '{}'" , beanName , ctxPath );
420+ } catch (Exception e ) {
421+ log .warn ("Failed to destroy bean {} in {}: {}" , beanName , ctxPath , e .getMessage ());
352422 }
353423 }
354424 } catch (Exception e ) {
@@ -357,10 +427,53 @@ Result unloadPlugin(String pluginId) {
357427 }
358428
359429 springPluginBeanNames .remove (pluginId );
430+
431+ // Reload Jersey without the plugin's REST classes
432+ if (!restClassesToRemove .isEmpty ()) {
433+ reloadJerseyWithout (restClassesToRemove );
434+ }
435+
360436 log .info ("Unloaded plugin: {}" , pluginId );
361437 return new Result (true );
362438 }
363439
440+ private void reloadJerseyWithout (List <Class <?>> classesToRemove ) {
441+ for (IApplicationContext appCtx : getApplicationContexts ().values ()) {
442+ if (!(appCtx instanceof TomcatApplicationContext )) continue ;
443+ TomcatApplicationContext tomcatCtx = (TomcatApplicationContext ) appCtx ;
444+ org .apache .catalina .Context catalinaCtx = tomcatCtx .getContext ();
445+ String ctxPath = catalinaCtx .getPath ();
446+ if (ctxPath == null || ctxPath .isEmpty ()) continue ;
447+
448+ try {
449+ if (!(catalinaCtx instanceof StandardContext )) continue ;
450+ Container wrapper = ((StandardContext ) catalinaCtx ).findChild ("jersey-serlvet" );
451+ if (!(wrapper instanceof Wrapper )) continue ;
452+ Servlet servlet = ((Wrapper ) wrapper ).getServlet ();
453+ if (!(servlet instanceof ServletContainer )) continue ;
454+
455+ ServletContainer jerseyContainer = (ServletContainer ) servlet ;
456+ ResourceConfig oldConfig = jerseyContainer .getConfiguration ();
457+
458+ ResourceConfig newConfig = new ResourceConfig ();
459+ // Copy all classes except the ones being removed
460+ for (Class <?> cls : oldConfig .getClasses ()) {
461+ if (!classesToRemove .contains (cls )) {
462+ newConfig .register (cls );
463+ }
464+ }
465+ newConfig .registerInstances (oldConfig .getSingletons ());
466+ newConfig .registerResources (oldConfig .getResources ());
467+ newConfig .addProperties (oldConfig .getProperties ());
468+
469+ jerseyContainer .reload (newConfig );
470+ log .info ("Reloaded Jersey in '{}' without {} removed classes" , ctxPath , classesToRemove .size ());
471+ } catch (Exception e ) {
472+ log .error ("Failed to reload Jersey in '{}': {}" , ctxPath , e .getMessage (), e );
473+ }
474+ }
475+ }
476+
364477 /**
365478 * Creates a new ResourceConfig from the existing Jersey config plus the new classes,
366479 * then reloads the Jersey servlet container in each streaming webapp. This gives
@@ -494,10 +607,91 @@ public PluginRecord getPluginRecord(String pluginName) {
494607 return pluginRecords .get (pluginName );
495608 }
496609
610+ /**
611+ * Finds a plugin record by exact name, pluginId, or slug match.
612+ * Needed because REST calls use the URL slug (e.g. "clip-creator")
613+ * while pluginRecords is keyed by the manifest name (e.g. "Clip Creator Plugin").
614+ */
615+ PluginRecord findPluginRecord (String query ) {
616+ if (query == null ) return null ;
617+
618+ // Exact match by manifest name
619+ PluginRecord record = pluginRecords .get (query );
620+ if (record != null ) return record ;
621+
622+ // Match by pluginId (what the UI sends for uninstall)
623+ for (PluginRecord r : pluginRecords .values ()) {
624+ if (query .equals (r .getPluginId ())) {
625+ return r ;
626+ }
627+ }
628+ return null ;
629+ }
630+
497631 public List <PluginRecord > getAllPluginRecords () {
498632 return new ArrayList <>(pluginRecords .values ());
499633 }
500634
635+ /**
636+ * Scans plugins/ directory and each webapp's WEB-INF/lib/ for jars with
637+ * AMS-Plugin-Name manifest entries. Builds PluginRecord for each and
638+ * populates the in-memory pluginRecords map. Called once at startup.
639+ */
640+ public void scanInstalledPlugins () {
641+ String amsHome = System .getProperty ("red5.root" , "/usr/local/antmedia" );
642+
643+ // Scan plugins/*.jar
644+ File pluginsDir = new File (amsHome , "plugins" );
645+ if (pluginsDir .exists ()) {
646+ scanDirectoryForPluginJars (pluginsDir );
647+ }
648+
649+ // Scan each webapp's WEB-INF/lib/
650+ File webappsDir = new File (amsHome , "webapps" );
651+ if (webappsDir .exists ()) {
652+ File [] apps = webappsDir .listFiles ();
653+ if (apps != null ) {
654+ for (File app : apps ) {
655+ if (!app .isDirectory () || "root" .equals (app .getName ())) continue ;
656+ File libDir = new File (app , "WEB-INF/lib" );
657+ if (libDir .exists ()) {
658+ scanDirectoryForPluginJars (libDir );
659+ }
660+ }
661+ }
662+ }
663+
664+ log .info ("Startup scan found {} installed plugins" , pluginRecords .size ());
665+ }
666+
667+ private void scanDirectoryForPluginJars (File dir ) {
668+ File [] jars = dir .listFiles ((d , name ) -> name .endsWith (".jar" ));
669+ if (jars == null ) return ;
670+
671+ for (File jar : jars ) {
672+ try {
673+ Attributes attrs = readManifestAttributes (jar );
674+ if (attrs == null ) continue ;
675+
676+ String pluginName = attrs .getValue (MANIFEST_PLUGIN_NAME );
677+ if (pluginName == null || pluginName .isEmpty ()) continue ;
678+
679+ // Skip if we already have a record for this plugin
680+ if (pluginRecords .containsKey (pluginName )) continue ;
681+
682+ PluginRecord record = buildPluginRecord (attrs );
683+ record .setState (PluginState .ACTIVE );
684+ record .setJarPath (jar .getAbsolutePath ());
685+ pluginRecords .put (pluginName , record );
686+
687+ log .info ("Found installed plugin: {} v{} in {}" , pluginName ,
688+ record .getVersion (), jar .getAbsolutePath ());
689+ } catch (Exception e ) {
690+ log .debug ("Skipping jar {}: {}" , jar .getName (), e .getMessage ());
691+ }
692+ }
693+ }
694+
501695 protected boolean isSystemClassLoaderServerClassLoader () {
502696 return ClassLoader .getSystemClassLoader () instanceof ServerClassLoader ;
503697 }
0 commit comments