package com.doumee.service.business.impl.collection; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.doumee.biz.system.SystemDictDataBiz; import com.doumee.core.constants.ResponseStatus; import com.doumee.core.exception.BusinessException; import com.doumee.core.haikang.isapi.IsapiClient; import com.doumee.core.haikang.isapi.IsapiConstants; import com.doumee.core.haikang.isapi.model.MediaItemDTO; import com.doumee.core.utils.Constants; import com.doumee.core.utils.DateUtil; import com.doumee.core.utils.FtpUtil; import com.doumee.core.utils.VideoTranscodeUtil; import com.doumee.dao.admin.request.CollectionMediaSyncRequest; import com.doumee.dao.business.CollectionMediaMapper; import com.doumee.dao.business.CollectionStationMapper; import com.doumee.dao.business.model.CollectionMedia; import com.doumee.dao.business.model.CollectionStation; import com.doumee.service.business.CollectionMediaSyncService; import com.doumee.service.business.third.model.PageData; import com.doumee.service.business.third.model.PageWrap; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.concurrent.Executor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.StandardCopyOption; import java.util.Calendar; import java.util.Date; import java.util.List; import java.util.UUID; @Service @Slf4j public class CollectionMediaSyncServiceImpl implements CollectionMediaSyncService { @Autowired private CollectionMediaMapper collectionMediaMapper; @Autowired private CollectionStationMapper collectionStationMapper; @Autowired private SystemDictDataBiz systemDictDataBiz; @Resource(name = "asyncExecutor") private Executor asyncExecutor; private final IsapiClient isapiClient = new IsapiClient(); private static FtpUtil ftp; @Override public String syncMediaList(CollectionMediaSyncRequest request) { if (Constants.DEALING_HK_COLLECTION_MEDIA) { throw new BusinessException(ResponseStatus.NOT_ALLOWED.getCode(), "媒体同步任务正在执行,请稍后"); } Constants.DEALING_HK_COLLECTION_MEDIA = true; try { Date endTime = request.getEndTime() != null ? request.getEndTime() : new Date(); Date startTime = request.getStartTime(); if (startTime == null) { Calendar cal = Calendar.getInstance(); cal.setTime(endTime); cal.add(Calendar.DAY_OF_MONTH, -7); startTime = cal.getTime(); } String trackId = getTrackId(); int totalNew = 0; if (request.getStationId() != null) { totalNew += syncStationMedia(request.getStationId(), startTime, endTime, trackId); } else { List stations = collectionStationMapper.selectList(new QueryWrapper().lambda() .eq(CollectionStation::getIsdeleted, Constants.ZERO) .eq(CollectionStation::getStatus, Constants.ONE)); for (CollectionStation station : stations) { try { totalNew += syncStationMedia(station.getId(), startTime, endTime, trackId); } catch (Exception e) { log.error("采集站媒体索引同步失败 stationId={}: {}", station.getId(), e.getMessage()); } } } return "同步完成,新增索引【" + totalNew + "】条"; } finally { Constants.DEALING_HK_COLLECTION_MEDIA = false; } } private int syncStationMedia(Integer stationId, Date startTime, Date endTime, String trackId) { CollectionStation station = collectionStationMapper.selectById(stationId); if (station == null || Constants.equalsInteger(station.getIsdeleted(), Constants.ONE)) { throw new BusinessException(ResponseStatus.DATA_EMPTY); } List items = isapiClient.searchMediaAll(station, startTime, endTime, trackId, IsapiConstants.MAX_PAGE_RESULTS); log.info("采集站媒体检索 stationId={} ip={} range={}~{} track={} found={}", stationId, station.getIp(), startTime, endTime, trackId, items.size()); int count = 0; Date now = new Date(); for (MediaItemDTO item : items) { if (StringUtils.isBlank(item.getFileIndex())) { continue; } Long exists = collectionMediaMapper.selectCount(new QueryWrapper().lambda() .eq(CollectionMedia::getStationId, stationId) .eq(CollectionMedia::getFileIndex, item.getFileIndex()) .eq(CollectionMedia::getIsdeleted, Constants.ZERO)); if (exists != null && exists > 0) { continue; } CollectionMedia media = new CollectionMedia(); media.setStationId(stationId); media.setFileIndex(item.getFileIndex()); media.setTrackId(item.getTrackId()); media.setFileName(item.getFileName()); media.setPlaybackUri(item.getPlaybackUri()); media.setMediaType(item.getMediaType()); media.setContentType(item.getContentType()); media.setFileSize(item.getFileSize()); media.setStartTime(item.getStartTime()); media.setEndTime(item.getEndTime()); media.setRecorderSn(item.getRecorderSn()); media.setUserName(item.getUserName()); media.setDownloadStatus(Constants.ZERO); media.setCreateDate(now); media.setIsdeleted(Constants.ZERO); collectionMediaMapper.insert(media); count++; } return count; } @Override public String downloadMedia(Integer mediaId) { CollectionMedia media = collectionMediaMapper.selectById(mediaId); if (media == null || Constants.equalsInteger(media.getIsdeleted(), Constants.ONE)) { throw new BusinessException(ResponseStatus.DATA_EMPTY); } if (Constants.equalsInteger(media.getDownloadStatus(), Constants.ONE)) { throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "文件已下载,无需重复下载"); } if (Constants.equalsInteger(media.getDownloadStatus(), Constants.COLLECTION_MEDIA_DOWNLOADING)) { throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "文件正在下载中,请稍后刷新"); } CollectionStation station = collectionStationMapper.selectById(media.getStationId()); if (station == null) { throw new BusinessException(ResponseStatus.DATA_EMPTY); } CollectionMedia downloading = new CollectionMedia(); downloading.setDownloadStatus(Constants.COLLECTION_MEDIA_DOWNLOADING); int updated = collectionMediaMapper.update(downloading, new QueryWrapper().lambda() .eq(CollectionMedia::getId, mediaId) .in(CollectionMedia::getDownloadStatus, Constants.ZERO, 2)); if (updated <= 0) { throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "无法提交下载,请刷新后重试"); } asyncExecutor.execute(() -> executeDownloadAsync(mediaId)); return "已提交下载任务,请稍后刷新查看状态"; } private void executeDownloadAsync(Integer mediaId) { try { CollectionMedia media = collectionMediaMapper.selectById(mediaId); if (media == null || Constants.equalsInteger(media.getIsdeleted(), Constants.ONE)) { return; } CollectionStation station = collectionStationMapper.selectById(media.getStationId()); if (station == null) { markDownloadFailed(mediaId); return; } String path = downloadToFtp(station, media); if (StringUtils.isBlank(path)) { markDownloadFailed(mediaId); log.error("异步下载失败 mediaId={}", mediaId); return; } CollectionMedia update = new CollectionMedia(); update.setId(mediaId); update.setFilePathLocal(path); update.setDownloadStatus(Constants.ONE); update.setDownloadTime(new Date()); collectionMediaMapper.updateById(update); log.info("异步下载成功 mediaId={} path={}", mediaId, path); } catch (Exception e) { markDownloadFailed(mediaId); log.error("异步下载异常 mediaId={}: {}", mediaId, e.getMessage(), e); } } private void markDownloadFailed(Integer mediaId) { CollectionMedia fail = new CollectionMedia(); fail.setId(mediaId); fail.setDownloadStatus(2); collectionMediaMapper.updateById(fail); } @Override public String batchDownload(CollectionMediaSyncRequest request) { int limit = request.getLimit() != null ? request.getLimit() : getBatchSize(); QueryWrapper wrapper = new QueryWrapper<>(); wrapper.lambda() .eq(CollectionMedia::getIsdeleted, Constants.ZERO) .eq(CollectionMedia::getDownloadStatus, Constants.ZERO) .eq(request.getStationId() != null, CollectionMedia::getStationId, request.getStationId()) .orderByAsc(CollectionMedia::getId) .last("limit " + limit); List list = collectionMediaMapper.selectList(wrapper); int submitted = 0; int skip = 0; for (CollectionMedia media : list) { try { downloadMedia(media.getId()); submitted++; } catch (BusinessException e) { skip++; log.warn("批量下载跳过 mediaId={}: {}", media.getId(), e.getMessage()); } catch (Exception e) { skip++; log.error("批量下载提交失败 mediaId={}: {}", media.getId(), e.getMessage()); } } return "已提交下载【" + submitted + "】条,跳过【" + skip + "】条"; } @Override public PageData findPage(PageWrap pageWrap) { CollectionMedia model = pageWrap.getModel() != null ? pageWrap.getModel() : new CollectionMedia(); QueryWrapper wrapper = new QueryWrapper<>(); wrapper.lambda() .eq(CollectionMedia::getIsdeleted, Constants.ZERO) .eq(model.getStationId() != null, CollectionMedia::getStationId, model.getStationId()) .eq(model.getDownloadStatus() != null, CollectionMedia::getDownloadStatus, model.getDownloadStatus()) .eq(model.getMediaType() != null, CollectionMedia::getMediaType, model.getMediaType()) .orderByDesc(CollectionMedia::getId); IPage page = collectionMediaMapper.selectPage( new Page<>(pageWrap.getPage(), pageWrap.getCapacity()), wrapper); page.getRecords().forEach(this::fillMediaAccessUrl); return PageData.from(page); } @Override public void previewMedia(Integer mediaId, HttpServletRequest request, HttpServletResponse response) { streamMediaFile(mediaId, request, response, false); } @Override public void downloadMediaFile(Integer mediaId, HttpServletRequest request, HttpServletResponse response) { streamMediaFile(mediaId, request, response, true); } private void streamMediaFile(Integer mediaId, HttpServletRequest request, HttpServletResponse response, boolean attachment) { CollectionMedia media = collectionMediaMapper.selectById(mediaId); if (media == null || Constants.equalsInteger(media.getIsdeleted(), Constants.ONE)) { throw new BusinessException(ResponseStatus.DATA_EMPTY); } if (!Constants.equalsInteger(media.getDownloadStatus(), Constants.ONE) || StringUtils.isBlank(media.getFilePathLocal())) { throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "文件尚未下载,无法访问"); } String remotePath = getMediaFolder() + media.getFilePathLocal(); FtpUtil ftpClient = null; try { ftpClient = createFtpClient(); if (!ftpClient.connect()) { throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), "FTP连接失败"); } String contentType = resolvePreviewContentType(media); String fileName = StringUtils.defaultIfBlank(media.getFileName(), "media" + resolveExt(media)); long fileSize = ftpClient.getRemoteFileSize(remotePath); if (fileSize <= 0 && media.getFileSize() != null && media.getFileSize() > 0) { fileSize = media.getFileSize(); } if (fileSize <= 0) { log.warn("媒体文件无法获取大小 mediaId={} remotePath={}", mediaId, remotePath); } response.setContentType(contentType); String disposition = (attachment ? "attachment" : "inline") + ";filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()); response.setHeader("Content-Disposition", disposition); if (attachment) { response.setHeader("eva-download-filename", URLEncoder.encode(fileName, StandardCharsets.UTF_8.name())); } response.setHeader("Accept-Ranges", "bytes"); response.setHeader("eva-opera-type", attachment ? "download" : "preview"); response.setHeader("Cache-Control", "private, max-age=3600"); RangeSpec range = fileSize > 0 ? parseRangeHeader(request != null ? request.getHeader("Range") : null, fileSize) : null; OutputStream out = response.getOutputStream(); boolean streamed; if (range != null) { response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); response.setHeader("Content-Range", "bytes " + range.start + "-" + range.end + "/" + fileSize); response.setHeader("Content-Length", String.valueOf(range.length())); streamed = ftpClient.streamRemoteFileRange(remotePath, out, range.start, range.length()); } else { if (fileSize > 0) { response.setHeader("Content-Length", String.valueOf(fileSize)); } streamed = ftpClient.streamRemoteFile(remotePath, out); } if (!streamed) { log.warn("媒体文件 FTP 读取失败 mediaId={} remotePath={}", mediaId, remotePath); if (!response.isCommitted()) { response.resetBuffer(); response.setStatus(HttpServletResponse.SC_NOT_FOUND); response.setContentType("text/plain;charset=UTF-8"); out.write("文件读取失败".getBytes(StandardCharsets.UTF_8)); } return; } out.flush(); } catch (BusinessException e) { throw e; } catch (Exception e) { log.error("媒体文件输出失败 mediaId={}: {}", mediaId, e.getMessage()); if (!response.isCommitted()) { try { response.resetBuffer(); response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); response.setContentType("text/plain;charset=UTF-8"); response.getOutputStream().write("文件读取失败".getBytes(StandardCharsets.UTF_8)); } catch (Exception ignored) { } } } finally { try { if (ftpClient != null) { ftpClient.disconnect(); } } catch (Exception ignored) { } } } private void fillMediaAccessUrl(CollectionMedia media) { if (!Constants.equalsInteger(media.getDownloadStatus(), Constants.ONE) || StringUtils.isBlank(media.getFilePathLocal())) { return; } try { String prefix = systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_RESOURCE_PATH).getCode(); media.setFileUrlFull(prefix + getMediaFolder() + media.getFilePathLocal()); } catch (Exception e) { log.warn("构建媒体访问URL失败 mediaId={}: {}", media.getId(), e.getMessage()); } } private FtpUtil createFtpClient() throws IOException { return new FtpUtil( systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_HOST).getCode(), Integer.parseInt(systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_PORT).getCode()), systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_USERNAME).getCode(), systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_PWD).getCode()); } private String resolvePreviewContentType(CollectionMedia media) { String name = StringUtils.defaultString(media.getFileName()).toLowerCase(); if (name.endsWith(".jpg") || name.endsWith(".jpeg")) { return "image/jpeg"; } if (name.endsWith(".png")) { return "image/png"; } if (name.endsWith(".gif")) { return "image/gif"; } if (name.endsWith(".bmp")) { return "image/bmp"; } if (name.endsWith(".mp3")) { return "audio/mpeg"; } if (name.endsWith(".mp4") || name.endsWith(".m4v")) { return "video/mp4"; } if (name.endsWith(".wav")) { return "audio/wav"; } if (name.endsWith(".txt") || name.endsWith(".log")) { return "text/plain;charset=UTF-8"; } if (media.getMediaType() != null && media.getMediaType() == 1) { return "image/jpeg"; } if (media.getMediaType() != null && media.getMediaType() == 2) { return "audio/mpeg"; } return "video/mp4"; } private String downloadToFtp(CollectionStation station, CollectionMedia media) { InputStream is = null; File tempSource = null; File tempTarget = null; try { is = isapiClient.downloadMedia(station, media.getPlaybackUri(), media.getFileIndex(), media.getFileName(), media.getTrackId(), media.getStartTime(), media.getEndTime(), media.getFileSize(), media.getMediaType()); if (is == null) { log.error("ISAPI下载无响应 mediaId={} mediaID={} fileName={}", media.getId(), media.getFileIndex(), media.getFileName()); return null; } tempSource = File.createTempFile("hk_media_src_", resolveTempSuffix(media)); Files.copy(is, tempSource.toPath(), StandardCopyOption.REPLACE_EXISTING); is.close(); is = null; if (!validateDownloadFile(tempSource, media)) { log.error("ISAPI下载内容无效 mediaId={} fileName={} size={}", media.getId(), media.getFileName(), tempSource.length()); return null; } File uploadFile = tempSource; String ext = resolveUploadExt(media, tempSource); if (shouldTranscodeMp4(media, tempSource)) { tempTarget = File.createTempFile("hk_media_out_", ".mp4"); log.info("开始 MP4 视频转码 mediaId={} fileName={} size={}", media.getId(), media.getFileName(), tempSource.length()); if (VideoTranscodeUtil.transcodeToBrowserMp4(getFfmpegPath(), tempSource, tempTarget)) { uploadFile = tempTarget; ext = ".mp4"; } else { log.warn("MP4 视频转码未成功,上传原始文件 mediaId={} fileName={} size={}", media.getId(), media.getFileName(), tempSource.length()); uploadFile = tempSource; ext = resolveUploadExt(media, tempSource); } } if (ftp == null) { ftp = new FtpUtil( systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_HOST).getCode(), Integer.parseInt(systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_PORT).getCode()), systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_USERNAME).getCode(), systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_PWD).getCode()); } else { ftp.connect(); } String folder = getMediaFolder(); String date = DateUtil.getNowShortDate(); String fName = date + "/" + UUID.randomUUID() + ext; String fileName = folder + fName; try (InputStream uploadStream = new FileInputStream(uploadFile)) { boolean uploaded = ftp.uploadInputstream(uploadStream, fileName); if (uploaded) { log.info("采集站媒体上传FTP成功 stationId={} file={} size={}", station.getId(), fName, uploadFile.length()); return fName; } } } catch (Exception e) { log.error("采集站媒体下载上传失败 mediaId={} fileName={}: {}", media.getId(), media.getFileName(), e.getMessage(), e); } finally { if (is != null) { try { is.close(); } catch (Exception ignored) { } } deleteQuietly(tempSource); deleteQuietly(tempTarget); } return null; } private String resolveTempSuffix(CollectionMedia media) { if (StringUtils.isNotBlank(media.getFileName()) && media.getFileName().contains(".")) { return media.getFileName().substring(media.getFileName().lastIndexOf('.')).toLowerCase(); } return resolveExt(media); } /** 仅扩展名为 .mp4 的视频走 FFmpeg 转码(海康常见 .mp4 内为 MPEG-PS,仍在此处理) */ private boolean shouldTranscodeMp4(CollectionMedia media, File sourceFile) { return isVideoMedia(media) && isMp4FileName(media.getFileName()); } private static boolean isMp4FileName(String fileName) { if (StringUtils.isBlank(fileName)) { return false; } String lower = fileName.toLowerCase(); return lower.endsWith(".mp4"); } private String resolveUploadExt(CollectionMedia media, File sourceFile) { if (StringUtils.isNotBlank(media.getFileName()) && media.getFileName().contains(".")) { return media.getFileName().substring(media.getFileName().lastIndexOf('.')).toLowerCase(); } return resolveExt(media); } private void deleteQuietly(File file) { if (file != null && file.exists() && !file.delete()) { log.warn("临时文件删除失败: {}", file.getAbsolutePath()); } } private String getFfmpegPath() { try { return systemDictDataBiz.queryByCode(Constants.CS_PARAM, Constants.CS_FFMPEG_PATH).getCode(); } catch (Exception e) { return "ffmpeg"; } } private boolean validateDownloadFile(File file, CollectionMedia media) throws IOException { if (file.length() <= 0) { log.warn("ISAPI下载内容为空 mediaId={}", media.getId()); return false; } byte[] head = new byte[4096]; int n; try (InputStream in = new FileInputStream(file)) { n = in.read(head); } if (n <= 0) { log.warn("ISAPI下载内容为空 mediaId={}", media.getId()); return false; } if (isErrorPayload(head, n)) { log.error("ISAPI下载返回错误 mediaId={} snippet={}", media.getId(), new String(head, 0, Math.min(n, 200), StandardCharsets.UTF_8)); return false; } if (isVideoMedia(media) && isMpegPs(head, n)) { log.info("检测到MPEG-PS视频流 mediaId={}(非 MP4,不转码)", media.getId()); } if (isVideoMedia(media) && !isLikelyPlayableVideo(head, n) && !isMp4FileName(media.getFileName())) { log.info("下载内容非 MP4 视频 mediaId={} fileName={}(不转码)", media.getId(), media.getFileName()); } return true; } private String getMediaFolder() { try { return systemDictDataBiz.queryByCode(Constants.FTP, Constants.COLLECTION_MEDIA_FOLDER).getCode(); } catch (Exception e) { return "/collection_media/"; } } private String resolveExt(CollectionMedia media) { if (StringUtils.isNotBlank(media.getFileName())) { String lower = media.getFileName().toLowerCase(); if (lower.endsWith(".txt") || lower.endsWith(".log")) { return lower.substring(lower.lastIndexOf('.')); } if (lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png")) { return lower.substring(lower.lastIndexOf('.')); } if (lower.endsWith(".mp3") || lower.endsWith(".wav")) { return lower.substring(lower.lastIndexOf('.')); } if (lower.contains(".")) { return lower.substring(lower.lastIndexOf('.')); } } if (media.getMediaType() != null && media.getMediaType() == 1) { return ".jpg"; } if (media.getMediaType() != null && media.getMediaType() == 2) { return ".mp3"; } return ".dat"; } private String getTrackId() { try { String val = systemDictDataBiz.queryByCode(Constants.CS_PARAM, Constants.CS_SEARCH_TRACK_ID).getCode(); if (StringUtils.isBlank(val) || "auto".equalsIgnoreCase(val.trim()) || "*".equals(val.trim())) { return null; } return val.trim(); } catch (Exception e) { return null; } } private int getBatchSize() { try { return Integer.parseInt(systemDictDataBiz.queryByCode(Constants.CS_PARAM, Constants.CS_DOWNLOAD_BATCH_SIZE).getCode()); } catch (Exception e) { return 10; } } private static boolean isVideoMedia(CollectionMedia media) { if (media.getMediaType() != null) { return media.getMediaType() == 0; } String name = StringUtils.defaultString(media.getFileName()).toLowerCase(); return name.endsWith(".mp4") || name.endsWith(".mov") || name.endsWith(".avi") || name.endsWith(".mkv") || name.endsWith(".m4v"); } private static boolean isErrorPayload(byte[] head, int n) { String snippet = new String(head, 0, Math.min(n, 64), StandardCharsets.UTF_8).trim(); return snippet.startsWith("= 4 && head[0] == 0 && head[1] == 0 && head[2] == 1 && (head[3] & 0xFF) == 0xBA; } private static boolean isLikelyPlayableVideo(byte[] head, int n) { if (n >= 8 && head[4] == 'f' && head[5] == 't' && head[6] == 'y' && head[7] == 'p') { return true; } return n >= 12 && head[0] == 'R' && head[1] == 'I' && head[2] == 'F' && head[3] == 'F' && head[8] == 'A' && head[9] == 'V' && head[10] == 'I'; } private static RangeSpec parseRangeHeader(String rangeHeader, long fileSize) { if (StringUtils.isBlank(rangeHeader) || fileSize <= 0 || !rangeHeader.startsWith("bytes=")) { return null; } String spec = rangeHeader.substring("bytes=".length()).trim(); int dash = spec.indexOf('-'); if (dash < 0) { return null; } try { long start = Long.parseLong(spec.substring(0, dash)); String endPart = spec.substring(dash + 1).trim(); long end = endPart.isEmpty() ? fileSize - 1 : Long.parseLong(endPart); if (start < 0 || end < start || start >= fileSize) { return null; } if (end >= fileSize) { end = fileSize - 1; } return new RangeSpec(start, end); } catch (NumberFormatException e) { return null; } } private static final class RangeSpec { private final long start; private final long end; private RangeSpec(long start, long end) { this.start = start; this.end = end; } private long length() { return end - start + 1; } } }