Commit a463b676e24e34ef65684ac4411d8ce5d1bfe6c1
1 parent
b19a885f
feat: 点播开始后的截图任务,判断启用https后使用https_fmp4流地址
Signed-off-by: duzeng <duzengrass@163.com>
Showing
2 changed files
with
50 additions
and
47 deletions
.gitignore
src/main/java/com/genersoft/iot/vmp/service/impl/PlayServiceImpl.java
| ... | ... | @@ -21,6 +21,7 @@ import org.slf4j.Logger; |
| 21 | 21 | import org.slf4j.LoggerFactory; |
| 22 | 22 | import org.springframework.beans.factory.annotation.Autowired; |
| 23 | 23 | import org.springframework.beans.factory.annotation.Qualifier; |
| 24 | +import org.springframework.beans.factory.annotation.Value; | |
| 24 | 25 | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; |
| 25 | 26 | import org.springframework.stereotype.Service; |
| 26 | 27 | import org.springframework.web.context.request.async.DeferredResult; |
| ... | ... | @@ -112,7 +113,8 @@ public class PlayServiceImpl implements IPlayService { |
| 112 | 113 | @Autowired |
| 113 | 114 | private ThreadPoolTaskExecutor taskExecutor; |
| 114 | 115 | |
| 115 | - | |
| 116 | + @Value("${server.ssl.enabled}") | |
| 117 | + private boolean sslEnabled; | |
| 116 | 118 | |
| 117 | 119 | @Override |
| 118 | 120 | public PlayResult play(MediaServerItem mediaServerItem, String deviceId, String channelId, |
| ... | ... | @@ -137,17 +139,18 @@ public class PlayServiceImpl implements IPlayService { |
| 137 | 139 | StreamInfo streamInfo = redisCatchStorage.queryPlayByDevice(deviceId, channelId); |
| 138 | 140 | playResult.setDevice(device); |
| 139 | 141 | |
| 140 | - result.onCompletion(()->{ | |
| 142 | + result.onCompletion(() -> { | |
| 141 | 143 | // 点播结束时调用截图接口 |
| 142 | - taskExecutor.execute(()->{ | |
| 144 | + taskExecutor.execute(() -> { | |
| 143 | 145 | // TODO 应该在上流时调用更好,结束也可能是错误结束 |
| 144 | - String path = "snap"; | |
| 145 | - String fileName = deviceId + "_" + channelId + ".jpg"; | |
| 146 | - WVPResult wvpResult = (WVPResult)result.getResult(); | |
| 146 | + String path = "snap"; | |
| 147 | + String fileName = deviceId + "_" + channelId + ".jpg"; | |
| 148 | + WVPResult wvpResult = (WVPResult) result.getResult(); | |
| 147 | 149 | if (Objects.requireNonNull(wvpResult).getCode() == 0) { |
| 148 | - StreamInfo streamInfoForSuccess = (StreamInfo)wvpResult.getData(); | |
| 150 | + StreamInfo streamInfoForSuccess = (StreamInfo) wvpResult.getData(); | |
| 149 | 151 | MediaServerItem mediaInfo = mediaServerService.getOne(streamInfoForSuccess.getMediaServerId()); |
| 150 | - String streamUrl = streamInfoForSuccess.getFmp4(); | |
| 152 | + String streamUrl = sslEnabled ? streamInfoForSuccess.getHttps_fmp4() : streamInfoForSuccess.getFmp4(); | |
| 153 | + | |
| 151 | 154 | // 请求截图 |
| 152 | 155 | logger.info("[请求截图]: " + fileName); |
| 153 | 156 | zlmresTfulUtils.getSnap(mediaInfo, streamUrl, 15, 1, path, fileName); |
| ... | ... | @@ -169,7 +172,7 @@ public class PlayServiceImpl implements IPlayService { |
| 169 | 172 | MediaServerItem mediaInfo = mediaServerService.getOne(mediaServerId); |
| 170 | 173 | |
| 171 | 174 | JSONObject rtpInfo = zlmresTfulUtils.getRtpInfo(mediaInfo, streamId); |
| 172 | - if(rtpInfo.getInteger("code") == 0){ | |
| 175 | + if (rtpInfo.getInteger("code") == 0) { | |
| 173 | 176 | if (rtpInfo.getBoolean("exist")) { |
| 174 | 177 | int localPort = rtpInfo.getInteger("local_port"); |
| 175 | 178 | if (localPort == 0) { |
| ... | ... | @@ -182,7 +185,7 @@ public class PlayServiceImpl implements IPlayService { |
| 182 | 185 | |
| 183 | 186 | resultHolder.invokeAllResult(msg); |
| 184 | 187 | return playResult; |
| 185 | - }else { | |
| 188 | + } else { | |
| 186 | 189 | WVPResult wvpResult = new WVPResult(); |
| 187 | 190 | wvpResult.setCode(ErrorCode.SUCCESS.getCode()); |
| 188 | 191 | wvpResult.setMsg(ErrorCode.SUCCESS.getMsg()); |
| ... | ... | @@ -195,12 +198,12 @@ public class PlayServiceImpl implements IPlayService { |
| 195 | 198 | } |
| 196 | 199 | } |
| 197 | 200 | |
| 198 | - }else { | |
| 201 | + } else { | |
| 199 | 202 | redisCatchStorage.stopPlay(streamInfo); |
| 200 | 203 | storager.stopPlay(streamInfo.getDeviceID(), streamInfo.getChannelId()); |
| 201 | 204 | streamInfo = null; |
| 202 | 205 | } |
| 203 | - }else { | |
| 206 | + } else { | |
| 204 | 207 | //zlm连接失败 |
| 205 | 208 | redisCatchStorage.stopPlay(streamInfo); |
| 206 | 209 | storager.stopPlay(streamInfo.getDeviceID(), streamInfo.getChannelId()); |
| ... | ... | @@ -215,7 +218,7 @@ public class PlayServiceImpl implements IPlayService { |
| 215 | 218 | } |
| 216 | 219 | SSRCInfo ssrcInfo = mediaServerService.openRTPServer(mediaServerItem, streamId, device.isSsrcCheck(), false); |
| 217 | 220 | logger.info(JSONObject.toJSONString(ssrcInfo)); |
| 218 | - play(mediaServerItem, ssrcInfo, device, channelId, (mediaServerItemInUse, response)->{ | |
| 221 | + play(mediaServerItem, ssrcInfo, device, channelId, (mediaServerItemInUse, response) -> { | |
| 219 | 222 | if (hookEvent != null) { |
| 220 | 223 | hookEvent.response(mediaServerItem, response); |
| 221 | 224 | } |
| ... | ... | @@ -229,13 +232,13 @@ public class PlayServiceImpl implements IPlayService { |
| 229 | 232 | if (errorEvent != null) { |
| 230 | 233 | errorEvent.response(event); |
| 231 | 234 | } |
| 232 | - }, (code, msgStr)->{ | |
| 235 | + }, (code, msgStr) -> { | |
| 233 | 236 | // invite点播超时 |
| 234 | 237 | WVPResult wvpResult = new WVPResult(); |
| 235 | 238 | wvpResult.setCode(ErrorCode.ERROR100.getCode()); |
| 236 | 239 | if (code == 0) { |
| 237 | 240 | wvpResult.setMsg("点播超时,请稍候重试"); |
| 238 | - }else if (code == 1) { | |
| 241 | + } else if (code == 1) { | |
| 239 | 242 | wvpResult.setMsg("收流超时,请稍候重试"); |
| 240 | 243 | } |
| 241 | 244 | msg.setData(wvpResult); |
| ... | ... | @@ -247,7 +250,6 @@ public class PlayServiceImpl implements IPlayService { |
| 247 | 250 | } |
| 248 | 251 | |
| 249 | 252 | |
| 250 | - | |
| 251 | 253 | @Override |
| 252 | 254 | public void play(MediaServerItem mediaServerItem, SSRCInfo ssrcInfo, Device device, String channelId, |
| 253 | 255 | ZlmHttpHookSubscribe.Event hookEvent, SipSubscribe.Event errorEvent, |
| ... | ... | @@ -260,12 +262,12 @@ public class PlayServiceImpl implements IPlayService { |
| 260 | 262 | if (ssrcInfo == null) { |
| 261 | 263 | ssrcInfo = mediaServerService.openRTPServer(mediaServerItem, streamId, device.isSsrcCheck(), false); |
| 262 | 264 | } |
| 263 | - logger.info("[点播开始] deviceId: {}, channelId: {},收流端口: {}, 收流模式:{}, SSRC: {}, SSRC校验:{}", device.getDeviceId(), channelId, ssrcInfo.getPort(), device.getStreamMode(), ssrcInfo.getSsrc(), device.isSsrcCheck() ); | |
| 265 | + logger.info("[点播开始] deviceId: {}, channelId: {},收流端口: {}, 收流模式:{}, SSRC: {}, SSRC校验:{}", device.getDeviceId(), channelId, ssrcInfo.getPort(), device.getStreamMode(), ssrcInfo.getSsrc(), device.isSsrcCheck()); | |
| 264 | 266 | // 超时处理 |
| 265 | 267 | String timeOutTaskKey = UUID.randomUUID().toString(); |
| 266 | 268 | SSRCInfo finalSsrcInfo = ssrcInfo; |
| 267 | 269 | System.out.println("设置超时任务: " + timeOutTaskKey); |
| 268 | - dynamicTask.startDelay( timeOutTaskKey,()->{ | |
| 270 | + dynamicTask.startDelay(timeOutTaskKey, () -> { | |
| 269 | 271 | |
| 270 | 272 | logger.info("[点播超时] 收流超时 deviceId: {}, channelId: {},端口:{}, SSRC: {}", device.getDeviceId(), channelId, finalSsrcInfo.getPort(), finalSsrcInfo.getSsrc()); |
| 271 | 273 | timeoutCallback.run(1, "收流超时"); |
| ... | ... | @@ -284,7 +286,7 @@ public class PlayServiceImpl implements IPlayService { |
| 284 | 286 | final String ssrc = ssrcInfo.getSsrc(); |
| 285 | 287 | final String stream = ssrcInfo.getStream(); |
| 286 | 288 | //端口获取失败的ssrcInfo 没有必要发送点播指令 |
| 287 | - if(ssrcInfo.getPort() <= 0){ | |
| 289 | + if (ssrcInfo.getPort() <= 0) { | |
| 288 | 290 | logger.info("[点播端口分配异常],deviceId={},channelId={},ssrcInfo={}", device.getDeviceId(), channelId, ssrcInfo); |
| 289 | 291 | return; |
| 290 | 292 | } |
| ... | ... | @@ -299,7 +301,7 @@ public class PlayServiceImpl implements IPlayService { |
| 299 | 301 | logger.info("[点播成功] deviceId: {}, channelId: {}", device.getDeviceId(), channelId); |
| 300 | 302 | |
| 301 | 303 | }, (event) -> { |
| 302 | - ResponseEvent responseEvent = (ResponseEvent)event.event; | |
| 304 | + ResponseEvent responseEvent = (ResponseEvent) event.event; | |
| 303 | 305 | String contentString = new String(responseEvent.getResponse().getRawContent()); |
| 304 | 306 | // 获取ssrc |
| 305 | 307 | int ssrcIndex = contentString.indexOf("y="); |
| ... | ... | @@ -311,7 +313,7 @@ public class PlayServiceImpl implements IPlayService { |
| 311 | 313 | if (ssrc.equals(ssrcInResponse)) { |
| 312 | 314 | return; |
| 313 | 315 | } |
| 314 | - logger.info("[点播消息] 收到invite 200, 发现下级自定义了ssrc: {}", ssrcInResponse ); | |
| 316 | + logger.info("[点播消息] 收到invite 200, 发现下级自定义了ssrc: {}", ssrcInResponse); | |
| 315 | 317 | if (!mediaServerItem.isRtpEnable() || device.isSsrcCheck()) { |
| 316 | 318 | logger.info("[点播消息] SSRC修正 {}->{}", ssrc, ssrcInResponse); |
| 317 | 319 | |
| ... | ... | @@ -332,13 +334,13 @@ public class PlayServiceImpl implements IPlayService { |
| 332 | 334 | HookSubscribeForStreamChange hookSubscribe = HookSubscribeFactory.on_stream_changed("rtp", stream, true, "rtsp", mediaServerItem.getId()); |
| 333 | 335 | subscribe.removeSubscribe(hookSubscribe); |
| 334 | 336 | hookSubscribe.getContent().put("stream", String.format("%08x", Integer.parseInt(ssrcInResponse)).toUpperCase()); |
| 335 | - subscribe.addSubscribe(hookSubscribe, (MediaServerItem mediaServerItemInUse, JSONObject response)->{ | |
| 336 | - logger.info("[ZLM HOOK] ssrc修正后收到订阅消息: " + response.toJSONString()); | |
| 337 | - dynamicTask.stop(timeOutTaskKey); | |
| 338 | - // hook响应 | |
| 339 | - onPublishHandlerForPlay(mediaServerItemInUse, response, device.getDeviceId(), channelId, uuid); | |
| 340 | - hookEvent.response(mediaServerItemInUse, response); | |
| 341 | - }); | |
| 337 | + subscribe.addSubscribe(hookSubscribe, (MediaServerItem mediaServerItemInUse, JSONObject response) -> { | |
| 338 | + logger.info("[ZLM HOOK] ssrc修正后收到订阅消息: " + response.toJSONString()); | |
| 339 | + dynamicTask.stop(timeOutTaskKey); | |
| 340 | + // hook响应 | |
| 341 | + onPublishHandlerForPlay(mediaServerItemInUse, response, device.getDeviceId(), channelId, uuid); | |
| 342 | + hookEvent.response(mediaServerItemInUse, response); | |
| 343 | + }); | |
| 342 | 344 | } |
| 343 | 345 | // 关闭rtp server |
| 344 | 346 | mediaServerService.closeRTPServer(mediaServerItem, finalSsrcInfo.getStream()); |
| ... | ... | @@ -410,7 +412,7 @@ public class PlayServiceImpl implements IPlayService { |
| 410 | 412 | MediaServerItem mediaServerItem; |
| 411 | 413 | if (mediaServerId == null) { |
| 412 | 414 | mediaServerItem = mediaServerService.getMediaServerForMinimumLoad(); |
| 413 | - }else { | |
| 415 | + } else { | |
| 414 | 416 | mediaServerItem = mediaServerService.getOne(mediaServerId); |
| 415 | 417 | } |
| 416 | 418 | if (mediaServerItem == null) { |
| ... | ... | @@ -421,8 +423,8 @@ public class PlayServiceImpl implements IPlayService { |
| 421 | 423 | |
| 422 | 424 | @Override |
| 423 | 425 | public DeferredResult<WVPResult<StreamInfo>> playBack(String deviceId, String channelId, String startTime, |
| 424 | - String endTime,InviteStreamCallback inviteStreamCallback, | |
| 425 | - PlayBackCallback callback) { | |
| 426 | + String endTime, InviteStreamCallback inviteStreamCallback, | |
| 427 | + PlayBackCallback callback) { | |
| 426 | 428 | Device device = storager.queryVideoDevice(deviceId); |
| 427 | 429 | if (device == null) { |
| 428 | 430 | return null; |
| ... | ... | @@ -435,9 +437,9 @@ public class PlayServiceImpl implements IPlayService { |
| 435 | 437 | |
| 436 | 438 | @Override |
| 437 | 439 | public DeferredResult<WVPResult<StreamInfo>> playBack(MediaServerItem mediaServerItem, SSRCInfo ssrcInfo, |
| 438 | - String deviceId, String channelId, String startTime, | |
| 439 | - String endTime, InviteStreamCallback infoCallBack, | |
| 440 | - PlayBackCallback playBackCallback) { | |
| 440 | + String deviceId, String channelId, String startTime, | |
| 441 | + String endTime, InviteStreamCallback infoCallBack, | |
| 442 | + PlayBackCallback playBackCallback) { | |
| 441 | 443 | if (mediaServerItem == null || ssrcInfo == null) { |
| 442 | 444 | return null; |
| 443 | 445 | } |
| ... | ... | @@ -454,7 +456,7 @@ public class PlayServiceImpl implements IPlayService { |
| 454 | 456 | requestMessage.setKey(key); |
| 455 | 457 | PlayBackResult<RequestMessage> playBackResult = new PlayBackResult<>(); |
| 456 | 458 | String playBackTimeOutTaskKey = UUID.randomUUID().toString(); |
| 457 | - dynamicTask.startDelay(playBackTimeOutTaskKey, ()->{ | |
| 459 | + dynamicTask.startDelay(playBackTimeOutTaskKey, () -> { | |
| 458 | 460 | logger.warn(String.format("设备回放超时,deviceId:%s ,channelId:%s", deviceId, channelId)); |
| 459 | 461 | playBackResult.setCode(ErrorCode.ERROR100.getCode()); |
| 460 | 462 | playBackResult.setMsg("回放超时"); |
| ... | ... | @@ -514,7 +516,7 @@ public class PlayServiceImpl implements IPlayService { |
| 514 | 516 | cmder.playbackStreamCmd(mediaServerItem, ssrcInfo, device, channelId, startTime, endTime, infoCallBack, |
| 515 | 517 | hookEvent, eventResult -> { |
| 516 | 518 | if (eventResult.type == SipSubscribe.EventResultType.response) { |
| 517 | - ResponseEvent responseEvent = (ResponseEvent)eventResult.event; | |
| 519 | + ResponseEvent responseEvent = (ResponseEvent) eventResult.event; | |
| 518 | 520 | String contentString = new String(responseEvent.getResponse().getRawContent()); |
| 519 | 521 | // 获取ssrc |
| 520 | 522 | int ssrcIndex = contentString.indexOf("y="); |
| ... | ... | @@ -526,7 +528,7 @@ public class PlayServiceImpl implements IPlayService { |
| 526 | 528 | if (ssrcInfo.getSsrc().equals(ssrcInResponse)) { |
| 527 | 529 | return; |
| 528 | 530 | } |
| 529 | - logger.info("[回放消息] 收到invite 200, 发现下级自定义了ssrc: {}", ssrcInResponse ); | |
| 531 | + logger.info("[回放消息] 收到invite 200, 发现下级自定义了ssrc: {}", ssrcInResponse); | |
| 530 | 532 | if (!mediaServerItem.isRtpEnable() || device.isSsrcCheck()) { |
| 531 | 533 | logger.info("[回放消息] SSRC修正 {}->{}", ssrcInfo.getSsrc(), ssrcInResponse); |
| 532 | 534 | |
| ... | ... | @@ -547,7 +549,7 @@ public class PlayServiceImpl implements IPlayService { |
| 547 | 549 | HookSubscribeForStreamChange hookSubscribe = HookSubscribeFactory.on_stream_changed("rtp", ssrcInfo.getStream(), true, "rtsp", mediaServerItem.getId()); |
| 548 | 550 | subscribe.removeSubscribe(hookSubscribe); |
| 549 | 551 | hookSubscribe.getContent().put("stream", String.format("%08x", Integer.parseInt(ssrcInResponse)).toUpperCase()); |
| 550 | - subscribe.addSubscribe(hookSubscribe, (MediaServerItem mediaServerItemInUse, JSONObject response)->{ | |
| 552 | + subscribe.addSubscribe(hookSubscribe, (MediaServerItem mediaServerItemInUse, JSONObject response) -> { | |
| 551 | 553 | logger.info("[ZLM HOOK] ssrc修正后收到订阅消息: " + response.toJSONString()); |
| 552 | 554 | dynamicTask.stop(playBackTimeOutTaskKey); |
| 553 | 555 | // hook响应 |
| ... | ... | @@ -583,7 +585,7 @@ public class PlayServiceImpl implements IPlayService { |
| 583 | 585 | MediaServerItem newMediaServerItem = getNewMediaServerItem(device); |
| 584 | 586 | SSRCInfo ssrcInfo = mediaServerService.openRTPServer(newMediaServerItem, null, true, true); |
| 585 | 587 | |
| 586 | - return download(newMediaServerItem, ssrcInfo, deviceId, channelId, startTime, endTime, downloadSpeed,infoCallBack, hookCallBack); | |
| 588 | + return download(newMediaServerItem, ssrcInfo, deviceId, channelId, startTime, endTime, downloadSpeed, infoCallBack, hookCallBack); | |
| 587 | 589 | } |
| 588 | 590 | |
| 589 | 591 | @Override |
| ... | ... | @@ -609,7 +611,7 @@ public class PlayServiceImpl implements IPlayService { |
| 609 | 611 | downloadResult.setData(requestMessage); |
| 610 | 612 | |
| 611 | 613 | String downLoadTimeOutTaskKey = UUID.randomUUID().toString(); |
| 612 | - dynamicTask.startDelay(downLoadTimeOutTaskKey, ()->{ | |
| 614 | + dynamicTask.startDelay(downLoadTimeOutTaskKey, () -> { | |
| 613 | 615 | logger.warn(String.format("录像下载请求超时,deviceId:%s ,channelId:%s", deviceId, channelId)); |
| 614 | 616 | wvpResult.setCode(ErrorCode.ERROR100.getCode()); |
| 615 | 617 | wvpResult.setMsg("录像下载请求超时"); |
| ... | ... | @@ -692,15 +694,15 @@ public class PlayServiceImpl implements IPlayService { |
| 692 | 694 | |
| 693 | 695 | if (duration == 0) { |
| 694 | 696 | streamInfo.setProgress(0); |
| 695 | - }else { | |
| 697 | + } else { | |
| 696 | 698 | String startTime = streamInfo.getStartTime(); |
| 697 | 699 | String endTime = streamInfo.getEndTime(); |
| 698 | 700 | long start = DateUtil.yyyy_MM_dd_HH_mm_ssToTimestamp(startTime); |
| 699 | 701 | long end = DateUtil.yyyy_MM_dd_HH_mm_ssToTimestamp(endTime); |
| 700 | 702 | |
| 701 | - BigDecimal currentCount = new BigDecimal(duration/1000); | |
| 702 | - BigDecimal totalCount = new BigDecimal(end-start); | |
| 703 | - BigDecimal divide = currentCount.divide(totalCount,2, RoundingMode.HALF_UP); | |
| 703 | + BigDecimal currentCount = new BigDecimal(duration / 1000); | |
| 704 | + BigDecimal totalCount = new BigDecimal(end - start); | |
| 705 | + BigDecimal divide = currentCount.divide(totalCount, 2, RoundingMode.HALF_UP); | |
| 704 | 706 | double process = divide.doubleValue(); |
| 705 | 707 | streamInfo.setProgress(process); |
| 706 | 708 | } |
| ... | ... | @@ -731,7 +733,7 @@ public class PlayServiceImpl implements IPlayService { |
| 731 | 733 | public StreamInfo onPublishHandler(MediaServerItem mediaServerItem, JSONObject resonse, String deviceId, String channelId) { |
| 732 | 734 | String streamId = resonse.getString("stream"); |
| 733 | 735 | JSONArray tracks = resonse.getJSONArray("tracks"); |
| 734 | - StreamInfo streamInfo = mediaService.getStreamInfoByAppAndStream(mediaServerItem,"rtp", streamId, tracks, null); | |
| 736 | + StreamInfo streamInfo = mediaService.getStreamInfoByAppAndStream(mediaServerItem, "rtp", streamId, tracks, null); | |
| 735 | 737 | streamInfo.setDeviceID(deviceId); |
| 736 | 738 | streamInfo.setChannelId(channelId); |
| 737 | 739 | return streamInfo; |
| ... | ... | @@ -757,7 +759,7 @@ public class PlayServiceImpl implements IPlayService { |
| 757 | 759 | List<SsrcTransaction> allSsrc = streamSession.getAllSsrc(); |
| 758 | 760 | if (allSsrc.size() > 0) { |
| 759 | 761 | for (SsrcTransaction ssrcTransaction : allSsrc) { |
| 760 | - if(ssrcTransaction.getMediaServerId().equals(mediaServerId)) { | |
| 762 | + if (ssrcTransaction.getMediaServerId().equals(mediaServerId)) { | |
| 761 | 763 | Device device = deviceService.queryDevice(ssrcTransaction.getDeviceId()); |
| 762 | 764 | if (device == null) { |
| 763 | 765 | continue; |
| ... | ... | @@ -766,7 +768,7 @@ public class PlayServiceImpl implements IPlayService { |
| 766 | 768 | cmder.streamByeCmd(device, ssrcTransaction.getChannelId(), |
| 767 | 769 | ssrcTransaction.getStream(), null); |
| 768 | 770 | } catch (InvalidArgumentException | ParseException | SipException | |
| 769 | - SsrcTransactionNotFoundException e) { | |
| 771 | + SsrcTransactionNotFoundException e) { | |
| 770 | 772 | logger.error("[zlm离线]为正在使用此zlm的设备, 发送BYE失败 {}", e.getMessage()); |
| 771 | 773 | } |
| 772 | 774 | } | ... | ... |