프로그래밍/프로젝트

[Sunlapse] #3 확인용 서버 만들기

LuiGee 2021. 2. 13. 15:23

지난 글 마지막에 필요함을 느낀 확인용 서버를 별도로 만들기로 했다. 매번 라즈베리 파이에 SSH로 붙어서 확인하는 건 너무 귀찮은 일이 될 것 같았고 CSS로 꾸미는 것도 없이 단순히 HTML과 서버 REST API가 통신하는 정도로만 만들기로 했다.

 

서버에 포함될 기능은 다음과 같다.

 

1. 타임랩스 촬영 프로그램 동작 여부

2. 타임랩스 촬영 프로그램 로그 확인

3. 촬영 영상을 브라우저에서 재생

4. 촬영 영상을 브라우저에서 다운로드

 

1. 프로그램 동작 여부

router.get('/status', function (req, res) {
    let status;
    try {
        cp.execSync('ps -e -o command | grep "^node sunlapse.js"');
        status = 200;
    } catch (error) {
        console.error(error);
        status = 404;
    }
    res.status(status).end();
});

프로그램 동작 여부는 리눅스 명령어를 사용해서 해결했다. ps로 영상 촬영 프로그램의 실행 여부를 확인한다. execSync에서 ps를 쓰고 결과가 없으면 빈 문자열이 리턴이 되는게 아니라 error가 던져지기 때문에 try-catch로 처리했다. 브라우저 자바스크립트에서는 응답 코드를 받아 응답 코드에 따라 정상-오류를 표시한다.

 

2. 프로그램 로그 확인

router.get('/logs', function (req, res) {
    const logs = {};

    try {
        const log = fs.readFileSync('/var/log/sunlapse/sunlapse.log');
        logs.log = log.toString();
        const errorLog = fs.readFileSync('/var/log/sunlapse/sunlapse.err');
        logs.errorLog = errorLog.toString();
    } catch (error) {
        console.error(error);
        logs.log = logs.log || "";
        logs.errorLog = logs.errorLog || "";
    }

    res.json(JSON.stringify(logs)).end();
});

로그는 로그 파일을 그대로 읽어서 응답으로 보냈다. 하루 동안 발생하는 로그가 거의 없어 매번 통째로 불러와도 괜찮을 것이라고 생각했다. 정상 로그와 오류 로그 모두 객체 하나에 담아 전송하고 브라우저에서는 이 로그를 읽어 HTML 페이지의 textarea에 내용으로 추가한다.

 

작동 상태 확인 화면

위의 두 가지 기능은 다음과 같이 한 페이지에서 확인할 수 있다. CSS를 전혀 적용하지 않다보니 좀 없어보이긴 하지만 확인용으로는 충분하다.

 

3. 영상을 브라우저에서 재생

router.get('/videos', function (req, res) {
    const response = {};
    const root = '/var/opt/sunlapse';

    try {
        const videos = fs.readdirSync(root);
        response.videos = videos;
    } catch (error) {
        console.error(error);
        response.videos = ['timelapse-2021-01-05.mp4']; // 파일 없을 때 테스트용으로
    }

    res.json(JSON.stringify(response)).end();
});

영상을 재생하고 다운로드하기 전에 우선 페이지에 전체 목록을 보여줘야 한다. fs 모듈의 readdir을 이용해 영상 디렉토리의 파일명 목록을 모두 불러오고 그 결과를 응답한다.

 

function fetchVideos() {
    fetch('/ajax/videos')
        .then((res) => res.json())
        .then(json => {
            const response = JSON.parse(json);
            const videos = response.videos.reverse();

            const videoList = [];

            for (let video of videos) {
                videoList.push(makeVideoDiv(video));
            }
            const videosDiv = document.getElementById('videos');
            videosDiv.innerHTML = videoList.join('');
        })
        .catch((err) => console.error(err));
}

function makeVideoDiv(video) {
    const date = video.split('.')[0].split('-').splice(1, 3).join('-');
    const videoDiv = [`<div id="${date}">`];
    const videoDate = `<p>${date}</p>`;
    const playButton = `<button onclick="playVideo('${video}')">재생</button>`;
    const downloadButton = `<a class="button" href="/video/file/${video}">다운로드</a>`;
    const end = `</div>`;

    videoDiv.push(videoDate, playButton, downloadButton, end);
    return videoDiv.join('');
}

function playVideo(video) {
    const date = video.split('.')[0].split('-').splice(1, 3).join('-');
    const videoDiv = document.getElementById(date);

    const videoPlayer = [`<video src="/video/player/${video}" autoplay controls preload="none"></video>`];
    const downloadButton = `<a class="button" href="/video/file/${video}">다운로드</a>`;
    videoPlayer.push(downloadButton);
    videoDiv.innerHTML = videoPlayer.join('');
}

영상 목록을 받으면 브라우저 자바스크립트로 화면을 구성한다. 실제 HTML에는 div 하나가 있고 그 div의 하위 태그를 모두 자바스크립트로 생성하는 방식이다. 매일 영상이 생기지만 최대 1년 정도를 생각하고 있는 프로젝트이기 때문에 별도의 페이징은 고려하지 않아도 된다고 생각했다.

 

playVideo를 누르면 다른 페이지로 넘어가는 것이 아니라 날짜와 버튼명만 있던 div에서 재생 버튼이 사라지고 video 태그가 새로 추가되어 생성되도록 했다.

 

영상 재생을 클릭했을 때

가장 위에는 재생 버튼을 눌러 video 태그가 나왔고 아래는 아직 누르지 않아 날짜와 버튼만 존재하는 목록이다. button 태그와 a 태그를 통일하고 싶어 버튼에만 CSS를 사용했다.

router.use('/player/:filename', function(req, res) {
    const filepath = `${root}/${req.params.filename}`;
    const stat = fs.statSync(filepath);
    const fileSize = stat.size;
    const range = req.headers.range;

    console.log('Range: ' + range);

    if (range) {
        const parts = range.replace(/bytes=/, "").split("-");
        const start = parseInt(parts[0]);
        const end = parts[1] ? parseInt(parts[1]) : fileSize - 1;
        const chunkSize = end - start + 1;

        console.log(`Range: ${start}-${end}`);

        const file = fs.createReadStream(filepath, { start, end });
        const head = {
            'Content-Range': `bytes ${start}-${end}/${fileSize}`,
            'Accept-Ranges': 'bytes',
            'Content-Length': chunkSize,
            'Content-Type': 'video/mp4',
        };
        res.writeHead(206, head);
        file.pipe(res);
    } else {
        const head = {
            'Content-Length': fileSize,
            'Content-Type': 'video/mp4',
        };
        res.writeHead(200, head);
        fs.createReadStream(filepath).pipe(res);
    }
});

영상 재생을 요청하면 서버에서는 위의 처리를 한다. 먼저 요청 받은 영상 파일을 읽어와 파일 크기를 알아낸다. 그리고 브라우저에서 요청한 데이터의 범위를 파악한다. 영상을 모두 다운로드받고 재생하는 것이 아니라 스트리밍 방식으로 재생하게 되는데 어디부터 얼마나 요청할지 브라우저에서 Range 헤더에 써서 요청한다. 파일을 일부만 읽었다면 서버는 206: Partial Content 라는 응답 코드를 담아 응답한다. 요청한 내용의 전체를 보낸 것이 아니니 더 보고 싶으면 범위를 지정해 다시 요청해달라는 의미이다. 그렇게 모든 파일을 볼 때까지 206을 반복하고 200으로 마무리한다. 

4. 영상을 브라우저에서 다운로드

router.use('/file/:filename', function(req, res) {
    res.download(`${root}/${req.params.filename}`);
});

다운로드는 간단하다. 파일 위치를 지정하고 download에 전달한다.

const downloadButton = `<a class="button" href="/video/file/${video}">다운로드</a>`;

브라우저에서도 단순하게 a 태그에 주소만 담아 요청한다. a 태그는 download 속성도 있는데 서버가 다운로드 처리를 해서 그런지 이번에는 필요하지 않았다.

다운로드를 눌렀을 때

이렇게 하고 며칠 째 작동 중인데 다행히 문제없이 영상을 잘 촬영하고 있었다. 다음에는 마지막으로 프로그래밍 부분이 아니라 라즈베리 파이를 어떻게 설치해놨고 쿨링은 어떻게 하고 있는지 등을 정리하고 프로젝트 소개를 마무리하려고 한다.