diff --git a/src/main/java/io/antmedia/AppSettings.java b/src/main/java/io/antmedia/AppSettings.java index 5aececcb6..a5cac1ea7 100644 --- a/src/main/java/io/antmedia/AppSettings.java +++ b/src/main/java/io/antmedia/AppSettings.java @@ -751,7 +751,7 @@ public class AppSettings implements Serializable{ * @hidden */ private static final String SETTINGS_CLUSTER_COMMUNICATION_KEY = "settings.clusterCommunicationKey"; - + /** * For default values * @@ -1847,22 +1847,44 @@ public class AppSettings implements Serializable{ @Value( "${dashHttpStreaming:${"+SETTINGS_DASH_HTTP_STREAMING+":true}}" ) private boolean dashHttpStreaming=true; + /** + * Configures the sub folder path for storing media files. + * This setting is appended to s3StreamsFolderPath in case of S3 upload. + * For instance if s3StreamsFolderPath is "streams"(default value) and subFolder is "someRoom", files will appear as + * streams/someRoom/0001.ts + * + * Path configuration supports dynamic placeholders for files: + * - '%m': Replaces with main track ID if exists + * - '%s': Replaces with stream ID + * + * This is particularly useful for storing conference participant stream HLS recordings in separate folders. + * + * Examples of path configurations in S3 assuming s3StreamsFolderPath is "streams": + * - "" (default) → Basic folder → streams/0001.ts + * - "%m" → Use main track ID as sub folder → streams/mainTrackId/0001.ts + * - "myStreams/%m/%s" → Nested folders with track and stream IDs → streams/myStreams/mainTrackId/streamId/0001.ts + * - "conference/videos/%m/%s" → Custom path with prefixes → streams/conference/videos/mainTrackId/streamId/0001.ts + * + * If main track ID or stream ID are null, they are omitted. + */ + @Value( "${subFolder:}" ) + private String subFolder = ""; /** - * It's S3 streams MP4, WEBM and HLS files storage name . + * It's S3 streams MP4, WEBM and HLS files storage name. * It's streams by default. * */ @Value( "${s3StreamsFolderPath:${"+SETTINGS_S3_STREAMS_FOLDER_PATH+":streams}}" ) - private String s3StreamsFolderPath="streams"; + private String s3StreamsFolderPath="streams"; /** - * It's S3 stream PNG files storage name . + * It's S3 stream PNG files storage name. * It's previews by default. * */ @Value("${s3PreviewsFolderPath:${"+SETTINGS_S3_PREVIEWS_FOLDER_PATH+":previews}}") - private String s3PreviewsFolderPath="previews"; + private String s3PreviewsFolderPath="previews"; /* * Use http endpoint in CMAF/HLS. @@ -4004,6 +4026,14 @@ public void setEncodingQueueSize(int encodingQueueSize) { this.encodingQueueSize = encodingQueueSize; } + public String getSubFolder() { + return subFolder; + } + + public void setSubFolder(String subFolder) { + this.subFolder = subFolder; + } + /** * @return the previewFormat */ diff --git a/src/main/java/io/antmedia/muxer/HLSMuxer.java b/src/main/java/io/antmedia/muxer/HLSMuxer.java index b09970939..2c6db0c6d 100644 --- a/src/main/java/io/antmedia/muxer/HLSMuxer.java +++ b/src/main/java/io/antmedia/muxer/HLSMuxer.java @@ -3,7 +3,6 @@ import static org.bytedeco.ffmpeg.global.avcodec.*; import static org.bytedeco.ffmpeg.global.avformat.avformat_alloc_output_context2; import static org.bytedeco.ffmpeg.global.avutil.*; -import static org.bytedeco.ffmpeg.global.avutil.AV_OPT_SEARCH_CHILDREN; import java.io.File; import java.io.IOException; @@ -17,8 +16,6 @@ import org.bytedeco.ffmpeg.avformat.AVStream; import org.bytedeco.ffmpeg.avutil.AVRational; import org.bytedeco.ffmpeg.global.avcodec; -import org.bytedeco.ffmpeg.global.avformat; -import org.bytedeco.ffmpeg.global.avutil; import org.bytedeco.javacpp.BytePointer; import org.red5.server.api.scope.IScope; import org.slf4j.Logger; @@ -61,7 +58,7 @@ public class HLSMuxer extends Muxer { private String s3StreamsFolderPath = "streams"; private boolean uploadHLSToS3 = true; private String segmentFilename; - + /** * HLS Segment Type. It can be "mpegts" or "fmp4" * @@ -134,7 +131,6 @@ public void setHlsParameters(String hlsListSize, String hlsTime, String hlsPlayL /** * {@inheritDoc} */ - @Override public void init(IScope scope, String name, int resolutionHeight, String subFolder, int bitrate) { if (!isInitialized) { @@ -209,7 +205,6 @@ public String getOutputURL() return super.getOutputURL(); } - public AVFormatContext getOutputFormatContext() { if (outputFormatContext == null) { @@ -634,4 +629,5 @@ protected synchronized void clearResource() { public ByteBuffer getPendingSEIData() { return pendingSEIData; } + } diff --git a/src/main/java/io/antmedia/muxer/MuxAdaptor.java b/src/main/java/io/antmedia/muxer/MuxAdaptor.java index 6fae9f3c7..62480a6f7 100644 --- a/src/main/java/io/antmedia/muxer/MuxAdaptor.java +++ b/src/main/java/io/antmedia/muxer/MuxAdaptor.java @@ -1,6 +1,5 @@ package io.antmedia.muxer; -import static io.antmedia.muxer.IAntMediaStreamHandler.BROADCAST_STATUS_BROADCASTING; import static org.bytedeco.ffmpeg.global.avcodec.AV_CODEC_ID_AAC; import static org.bytedeco.ffmpeg.global.avcodec.AV_CODEC_ID_H264; import static org.bytedeco.ffmpeg.global.avcodec.AV_CODEC_ID_H265; @@ -18,7 +17,6 @@ import static org.bytedeco.ffmpeg.global.avutil.av_malloc; import static org.bytedeco.ffmpeg.global.avutil.av_rescale_q; -import java.io.IOException; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Collections; @@ -26,7 +24,6 @@ import java.util.Iterator; import java.util.List; import java.util.Map; -import java.util.Map.Entry; import java.util.NoSuchElementException; import java.util.Queue; import java.util.concurrent.ConcurrentHashMap; @@ -37,6 +34,8 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import javax.annotation.Nonnull; + import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.exception.ExceptionUtils; import org.apache.mina.core.buffer.IoBuffer; @@ -62,7 +61,6 @@ import org.red5.server.api.stream.IBroadcastStream; import org.red5.server.api.stream.IStreamCapableConnection; import org.red5.server.api.stream.IStreamPacket; -import org.red5.server.net.rtmp.event.AudioData; import org.red5.server.net.rtmp.event.CachedEvent; import org.red5.server.net.rtmp.event.Notify; import org.red5.server.net.rtmp.event.VideoData; @@ -525,7 +523,7 @@ public boolean init(IScope scope, String streamId, boolean isAppend) { } for (Muxer muxer : muxerList) { - muxer.init(scope, streamId, 0, broadcast.getSubFolder(), 0); + muxer.init(scope, streamId, 0, getSubfolder(getBroadcast(), getAppSettings()), 0); } getStreamHandler().muxAdaptorAdded(this); return true; @@ -2225,14 +2223,12 @@ else if(recordType == RecordType.WEBM) { public boolean prepareMuxer(Muxer muxer, int resolutionHeight) { boolean streamAdded = false; - - muxer.init(scope, streamId, resolutionHeight, broadcast != null ? broadcast.getSubFolder(): null, 0); + muxer.init(scope, streamId, resolutionHeight, getSubfolder(getBroadcast(), getAppSettings()), 0); logger.info("prepareMuxer for stream:{} muxer:{}", streamId, muxer.getClass().getSimpleName()); if (streamSourceInputFormatContext != null) { - for (int i = 0; i < streamSourceInputFormatContext.nb_streams(); i++) { if (!muxer.addStream(streamSourceInputFormatContext.streams(i).codecpar(), streamSourceInputFormatContext.streams(i).time_base(), i)) { @@ -2567,6 +2563,55 @@ else if(status == null return result; } + public static String getExtendedSubfolder(String mainTrackId, String streamId, String subFolder) { + if (StringUtils.isBlank(subFolder)) { + return ""; + } + + String result = subFolder; + + if (mainTrackId == null) { + result = result.replace("%m/", "") + .replace("/%m", "") + .replace("%m", ""); + } else { + result = result.replace("%m", mainTrackId); + } + + if (streamId == null) { + result = result.replace("%s/", "") + .replace("/%s", "") + .replace("%s", ""); + } else { + result = result.replace("%s", streamId); + } + + //remove slashes beginning and end of string + result = result.trim().replaceAll("^/+|/+$", ""); + + + return result; + } + + public static String getSubfolder(@Nonnull Broadcast broadcast, @Nonnull AppSettings appSettings) + { + String subfolderTemplate = ""; + + if (StringUtils.isNotBlank(broadcast.getSubFolder())) { + subfolderTemplate = broadcast.getSubFolder(); + } + else { + subfolderTemplate = appSettings.getSubFolder(); + } + + if (StringUtils.isNotBlank(subfolderTemplate)) + { + subfolderTemplate = getExtendedSubfolder(broadcast.getMainTrackStreamId(), broadcast.getStreamId(), subfolderTemplate); + } + + return subfolderTemplate; + } + public boolean isEnableVideo() { return enableVideo; } diff --git a/src/main/java/io/antmedia/muxer/Muxer.java b/src/main/java/io/antmedia/muxer/Muxer.java index b01e84864..a513e66ba 100644 --- a/src/main/java/io/antmedia/muxer/Muxer.java +++ b/src/main/java/io/antmedia/muxer/Muxer.java @@ -47,7 +47,6 @@ import org.bytedeco.ffmpeg.global.avcodec; import org.bytedeco.ffmpeg.global.avformat; import org.bytedeco.javacpp.BytePointer; -import org.json.simple.JSONObject; import org.red5.server.api.IContext; import org.red5.server.api.scope.IScope; import org.red5.server.api.stream.IStreamFilenameGenerator; @@ -639,17 +638,13 @@ public void init(IScope scope, String name, int resolution, String subFolder, in * sample naming -> stream1-yyyy-MM-dd_HH-mm_480p_500kbps.mp4 if datetime is added * stream1_480p.mp4 if no datetime * + * @param name, name of the stream * @param scope - * @param name, - * name of the stream - * @param resolution - * height of the stream, if it is zero, then no resolution will - * be added to resource name - * @param overrideIfExist - * whether override if a file exists with the same name - * @param bitrate - * bitrate of the stream, if it is zero, no bitrate will - * be added to resource name + * @param resolution height of the stream, if it is zero, then no resolution will + * be added to resource name + * @param overrideIfExist whether override if a file exists with the same name + * @param bitrate bitrate of the stream, if it is zero, no bitrate will + * be added to resource name */ public void init(IScope scope, final String name, int resolution, boolean overrideIfExist, String subFolder, int bitrate) { if (!isInitialized) { diff --git a/src/main/java/io/antmedia/servlet/UploadHLSChunk.java b/src/main/java/io/antmedia/servlet/UploadHLSChunk.java index c247bb5f1..75d821cfa 100644 --- a/src/main/java/io/antmedia/servlet/UploadHLSChunk.java +++ b/src/main/java/io/antmedia/servlet/UploadHLSChunk.java @@ -26,7 +26,6 @@ @MultipartConfig public class UploadHLSChunk extends HttpServlet { - private static final long serialVersionUID = 1L; protected static Logger logger = LoggerFactory.getLogger(UploadHLSChunk.class); diff --git a/src/test/java/io/antmedia/test/AppSettingsUnitTest.java b/src/test/java/io/antmedia/test/AppSettingsUnitTest.java index 3d703342b..63fa63a53 100644 --- a/src/test/java/io/antmedia/test/AppSettingsUnitTest.java +++ b/src/test/java/io/antmedia/test/AppSettingsUnitTest.java @@ -379,9 +379,6 @@ public void testBeanAppSettings() { testUnsetAppSettings((AppSettings) applicationContext.getBean("app.settings")); } - - - public void testUnsetAppSettings(AppSettings appSettings) { Field[] declaredFields = appSettings.getClass().getDeclaredFields(); @@ -643,13 +640,17 @@ public void testUnsetAppSettings(AppSettings appSettings) { assertEquals("png", appSettings.getPreviewFormat()); assertEquals(5, appSettings.getPreviewQuality()); + assertEquals("", appSettings.getSubFolder()); + appSettings.setSubFolder("test/folder"); + assertEquals("test/folder", appSettings.getSubFolder()); + //if we add a new field, we just need to check its default value in this test //When a new field is added or removed please update the number of fields and make this test pass //by also checking its default value. assertEquals("New field is added to settings. PAY ATTENTION: Please CHECK ITS DEFAULT VALUE and fix the number of fields.", - 192, numberOfFields); + 193, numberOfFields); } diff --git a/src/test/java/io/antmedia/test/MuxerUnitTest.java b/src/test/java/io/antmedia/test/MuxerUnitTest.java index ba60c7ded..65cc334fc 100644 --- a/src/test/java/io/antmedia/test/MuxerUnitTest.java +++ b/src/test/java/io/antmedia/test/MuxerUnitTest.java @@ -1,6 +1,7 @@ package io.antmedia.test; - +import static io.antmedia.muxer.MuxAdaptor.getExtendedSubfolder; +import static io.antmedia.muxer.MuxAdaptor.getSubfolder; import static org.bytedeco.ffmpeg.global.avcodec.AV_CODEC_ID_AAC; import static org.bytedeco.ffmpeg.global.avcodec.AV_CODEC_ID_AC3; import static org.bytedeco.ffmpeg.global.avcodec.AV_CODEC_ID_H264; @@ -9,6 +10,7 @@ import static org.bytedeco.ffmpeg.global.avcodec.AV_CODEC_ID_HEVC; import static org.bytedeco.ffmpeg.global.avcodec.AV_CODEC_ID_MP3; import static org.bytedeco.ffmpeg.global.avcodec.AV_CODEC_ID_NONE; + import static org.bytedeco.ffmpeg.global.avcodec.AV_CODEC_ID_VP8; import static org.bytedeco.ffmpeg.global.avcodec.AV_PKT_FLAG_KEY; import static org.bytedeco.ffmpeg.global.avcodec.av_init_packet; @@ -23,15 +25,7 @@ import static org.bytedeco.ffmpeg.global.avformat.avformat_find_stream_info; import static org.bytedeco.ffmpeg.global.avformat.avformat_free_context; import static org.bytedeco.ffmpeg.global.avformat.avformat_open_input; -import static org.bytedeco.ffmpeg.global.avutil.AVMEDIA_TYPE_ATTACHMENT; -import static org.bytedeco.ffmpeg.global.avutil.AVMEDIA_TYPE_AUDIO; -import static org.bytedeco.ffmpeg.global.avutil.AVMEDIA_TYPE_DATA; -import static org.bytedeco.ffmpeg.global.avutil.AVMEDIA_TYPE_SUBTITLE; -import static org.bytedeco.ffmpeg.global.avutil.AVMEDIA_TYPE_VIDEO; -import static org.bytedeco.ffmpeg.global.avutil.AV_PIX_FMT_YUV420P; -import static org.bytedeco.ffmpeg.global.avutil.AV_SAMPLE_FMT_FLTP; -import static org.bytedeco.ffmpeg.global.avutil.av_channel_layout_default; -import static org.bytedeco.ffmpeg.global.avutil.av_dict_get; +import static org.bytedeco.ffmpeg.global.avutil.*; import static org.junit.Assert.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -5831,4 +5825,92 @@ public void testAddID3Data() { assertEquals(0x00, endOfString); } + @Test + public void testGetSubfolder() throws Exception { + String mainTrackId = "mainTrackId"; + String streamId = "stream456"; + + AppSettings appSettings = new AppSettings(); + + assertEquals("", getExtendedSubfolder(mainTrackId, streamId, null)); + assertEquals("simplepath", getExtendedSubfolder(mainTrackId, streamId, "simplepath")); + assertEquals("mainTrackId", getExtendedSubfolder(mainTrackId, streamId, "%m")); + assertEquals("stream456", getExtendedSubfolder(mainTrackId, streamId, "%s")); + + assertEquals("mainTrackId/stream456", getExtendedSubfolder(mainTrackId, streamId, "%m/%s")); + assertEquals("stream456/mainTrackId", getExtendedSubfolder(mainTrackId, streamId, "%s/%m")); + assertEquals(appSettings.getSubFolder(), getExtendedSubfolder(mainTrackId, streamId, appSettings.getSubFolder())); + + assertEquals("folder", getExtendedSubfolder(null, null, "folder/%m/%s")); + assertEquals("folder", getExtendedSubfolder(null, null, "/folder/%m/%s/")); + assertEquals("folder", getExtendedSubfolder(null, null, "folder/%m/%s/")); + assertEquals("folder", getExtendedSubfolder(null, null, "/folder/%m/%s")); + + assertEquals("folder", + getExtendedSubfolder(null, null, "folder/%m/%s")); + + assertEquals("folder/stream1", + getExtendedSubfolder(null, "stream1", "folder/%m/%s")); + + assertEquals("folder/track1", + getExtendedSubfolder("track1", null, "folder/%m/%s")); + + assertEquals("folder/track1/stream1", + getExtendedSubfolder("track1", "stream1", "folder/%m/%s")); + + assertEquals("folder/stream1", + getExtendedSubfolder(null, "stream1", "/folder/%m/%s/")); + + assertEquals("lastpeony/mainTrackId/stream456", + getExtendedSubfolder(mainTrackId, streamId, "lastpeony/%m/%s")); + + assertEquals("folder/mainTrackId", + getExtendedSubfolder(mainTrackId, streamId, "folder/%m")); + + assertEquals("folder/mainTrackId", + getExtendedSubfolder(mainTrackId, streamId, "folder/%m/")); + + appSettings.setSubFolder("defaultFolder"); + + assertEquals("defaultFolder", getSubfolder(new Broadcast(), appSettings)); + + Broadcast broadcastWithSubfolder = new Broadcast(); + broadcastWithSubfolder.setSubFolder("customSubfolder"); + + Broadcast broadcastWithIds = new Broadcast(); + broadcastWithIds.setMainTrackStreamId(mainTrackId); + broadcastWithIds.setStreamId(streamId); + + assertEquals("customSubfolder", getSubfolder(broadcastWithSubfolder, appSettings)); + + appSettings.setSubFolder("recordings/%m/%s"); + assertEquals("recordings/mainTrackId/stream456", getSubfolder(broadcastWithIds, appSettings)); + + appSettings.setSubFolder("recordings/%m/%s"); + assertEquals("recordings", getSubfolder(new Broadcast(), appSettings)); + + Broadcast broadcastWithEmptyIds = new Broadcast(); + broadcastWithEmptyIds.setMainTrackStreamId(""); + broadcastWithEmptyIds.setStreamId(""); + assertEquals("recordings", getSubfolder(broadcastWithEmptyIds, appSettings)); + + appSettings.setSubFolder("recordings/%m"); + Broadcast broadcastWithOnlyMainTrack = new Broadcast(); + broadcastWithOnlyMainTrack.setMainTrackStreamId(mainTrackId); + assertEquals("recordings/mainTrackId", getSubfolder(broadcastWithOnlyMainTrack, appSettings)); + + appSettings.setSubFolder("recordings/%s"); + Broadcast broadcastWithOnlyStreamId = new Broadcast(); + broadcastWithOnlyStreamId.setStreamId(streamId); + assertEquals("recordings/stream456", getSubfolder(broadcastWithOnlyStreamId, appSettings)); + + appSettings.setSubFolder("fixedFolder"); + assertEquals("fixedFolder", getSubfolder(broadcastWithIds, appSettings)); + + //broadcast subfolder overwrites app settings sub folder. + assertEquals("customSubfolder", getSubfolder(broadcastWithSubfolder, appSettings)); + + + } + } diff --git a/src/test/java/io/antmedia/test/StreamFetcherUnitTest.java b/src/test/java/io/antmedia/test/StreamFetcherUnitTest.java index 1deddf55e..ba6a9a264 100644 --- a/src/test/java/io/antmedia/test/StreamFetcherUnitTest.java +++ b/src/test/java/io/antmedia/test/StreamFetcherUnitTest.java @@ -11,11 +11,9 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.times; @@ -26,12 +24,6 @@ import java.io.File; import java.io.FileReader; import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.charset.CharacterCodingException; -import java.nio.charset.CharsetDecoder; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.UUID; import java.util.concurrent.ConcurrentSkipListSet; import java.util.concurrent.TimeUnit; @@ -56,7 +48,6 @@ import org.junit.rules.TestRule; import org.junit.rules.TestWatcher; import org.junit.runner.Description; -import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import org.red5.server.scope.WebScope; import org.slf4j.Logger; diff --git a/src/test/java/io/antmedia/test/servlet/UploadHLSChunkTest.java b/src/test/java/io/antmedia/test/servlet/UploadHLSChunkTest.java index 7c41e18d9..1421ea8bd 100644 --- a/src/test/java/io/antmedia/test/servlet/UploadHLSChunkTest.java +++ b/src/test/java/io/antmedia/test/servlet/UploadHLSChunkTest.java @@ -166,7 +166,6 @@ public void testGetStorageClient() { @Test public void testGetS3Key() { - String pathInfo = "test.m3u8"; when(mockRequest.getPathInfo()).thenReturn(pathInfo); @@ -174,18 +173,16 @@ public void testGetS3Key() { String s3Key = UploadHLSChunk.getS3Key(mockRequest, appSettings); assertEquals("streams/test.m3u8", s3Key); - + pathInfo = "/test.m3u8"; s3Key = UploadHLSChunk.getS3Key(mockRequest, appSettings); assertEquals("streams/test.m3u8", s3Key); - - + + pathInfo = "/test.m3u8"; appSettings.setS3StreamsFolderPath("streams/"); s3Key = UploadHLSChunk.getS3Key(mockRequest, appSettings); assertEquals("streams/test.m3u8", s3Key); - } - }