├── web
├── public
│ ├── robots.txt
│ ├── images
│ │ ├── logo.webp
│ │ ├── outlink.webp
│ │ ├── 개발자mbti.webp
│ │ └── github-mark.svg
│ ├── favicon
│ │ ├── favicon.ico
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ ├── apple-touch-icon.png
│ │ ├── android-chrome-192x192.png
│ │ ├── android-chrome-512x512.png
│ │ └── site.webmanifest
│ ├── js
│ │ ├── odevtube-utils.js
│ │ ├── modal.js
│ │ ├── index.js
│ │ └── admin.js
│ └── css
│ │ ├── odevtube-common.css
│ │ ├── profile.css
│ │ ├── admin.css
│ │ └── style.css
├── views
│ ├── error.ejs
│ ├── home.ejs
│ ├── search.ejs
│ ├── footer.ejs
│ ├── admin
│ │ ├── nav.ejs
│ │ ├── stats.ejs
│ │ ├── channel.ejs
│ │ ├── video.ejs
│ │ ├── security.ejs
│ │ ├── logs.ejs
│ │ └── settings.ejs
│ ├── login.ejs
│ ├── video.ejs
│ ├── aside.ejs
│ ├── header.ejs
│ ├── profile.ejs
│ ├── index.ejs
│ └── statistics.ejs
├── utils
│ ├── summary.js
│ ├── uri.js
│ └── transcriptUtil.js
├── .env.sample
├── package.json
├── bin
│ └── odevtube.js
├── app.js
└── routes
│ ├── admin.js
│ └── index.js
├── .prettierrc.json
├── babel.config.json
├── tests
├── add.js
├── add.test.js
├── youtub.js
├── transcript.test.js
├── uri.test.js
├── channel.test.js
├── ch.js
├── single.test.js
├── youtubedao.test.js
├── sqluelize.join.sample.js
├── simplemail.js
├── transcriptdao.test.js
├── test.test.js
└── account.test.js
├── youtube.js
├── .gitignore
├── cron
├── cron-video.js
├── cron-channel.js
└── cron-hourly.js
├── sonar-project.properties.sample
├── .aidigestignore
├── .env.example
├── scripts
├── deploy-odevtube.sh
├── cron-video.sh
└── deploy-docker.sh
├── channels.js
├── docs
├── overview
│ └── README.md
├── README.md
├── development
│ └── README.md
├── architecture
│ └── README.md
├── operation
│ └── README.md
├── deployment
│ └── README.md
└── api
│ └── README.md
├── package.json
├── docker-compose
├── docker-compose.yml
└── init.sql
├── README.md
├── dailyDao.js
├── .github
└── workflows
│ └── main.yml
├── LICENSE
├── services
├── channel.js
└── video.js
├── CODE_OF_CONDUCT.md
└── youtubeDao.js
/web/public/robots.txt:
--------------------------------------------------------------------------------
1 | Allow: /
2 |
3 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | { "semi": false, "singleQuote": true }
2 |
--------------------------------------------------------------------------------
/babel.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/preset-env"]
3 | }
4 |
--------------------------------------------------------------------------------
/tests/add.js:
--------------------------------------------------------------------------------
1 | const add = (a, b) => a + b
2 |
3 | export default add
4 |
--------------------------------------------------------------------------------
/web/public/images/logo.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kenu/odevtube/HEAD/web/public/images/logo.webp
--------------------------------------------------------------------------------
/web/public/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kenu/odevtube/HEAD/web/public/favicon/favicon.ico
--------------------------------------------------------------------------------
/web/public/images/outlink.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kenu/odevtube/HEAD/web/public/images/outlink.webp
--------------------------------------------------------------------------------
/web/public/images/개발자mbti.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kenu/odevtube/HEAD/web/public/images/개발자mbti.webp
--------------------------------------------------------------------------------
/web/views/error.ejs:
--------------------------------------------------------------------------------
1 |
<%= message %>
2 | <%= error.status %>
3 | <%= error.stack %>
4 |
--------------------------------------------------------------------------------
/web/public/favicon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kenu/odevtube/HEAD/web/public/favicon/favicon-16x16.png
--------------------------------------------------------------------------------
/web/public/favicon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kenu/odevtube/HEAD/web/public/favicon/favicon-32x32.png
--------------------------------------------------------------------------------
/web/public/favicon/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kenu/odevtube/HEAD/web/public/favicon/apple-touch-icon.png
--------------------------------------------------------------------------------
/tests/add.test.js:
--------------------------------------------------------------------------------
1 | import add from './add'
2 |
3 | test('adds 1 + 2 to equal 3', () => {
4 | expect(add(1, 2)).toBe(3)
5 | })
6 |
--------------------------------------------------------------------------------
/web/public/favicon/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kenu/odevtube/HEAD/web/public/favicon/android-chrome-192x192.png
--------------------------------------------------------------------------------
/web/public/favicon/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kenu/odevtube/HEAD/web/public/favicon/android-chrome-512x512.png
--------------------------------------------------------------------------------
/youtube.js:
--------------------------------------------------------------------------------
1 | import { google } from 'googleapis'
2 |
3 | const youtube = google.youtube({
4 | version: 'v3',
5 | auth: process.env.YOUTUBE_API_KEY,
6 | })
7 |
8 | export default youtube
9 |
--------------------------------------------------------------------------------
/tests/youtub.js:
--------------------------------------------------------------------------------
1 | import { google } from 'googleapis'
2 |
3 | const youtube = google.youtube({
4 | version: 'v3',
5 | auth: process.env.YOUTUBE_API_KEY,
6 | })
7 |
8 | export default youtube
9 |
--------------------------------------------------------------------------------
/web/views/home.ejs:
--------------------------------------------------------------------------------
1 | <% if (!user) { %>
2 | Welcome! Please log in.
3 | <% } else { %>
4 | Hello, <%= user.username %>. View your profile.
5 | <% } %>
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .idea
3 | package-lock.json
4 | pnpm-lock.yaml
5 | *.local
6 | a.json
7 | coverage
8 | sonar-project.properties
9 | .scannerwork
10 | .env
11 | codebase.md
12 | .DS_Store
13 | .Thumbs.db
14 |
--------------------------------------------------------------------------------
/cron/cron-video.js:
--------------------------------------------------------------------------------
1 | import dao from '../youtubeDao.js'
2 | import vapi from '../services/video.js'
3 |
4 | ;(async () => {
5 | const list = await dao.findAllChannelList(914)
6 | list.map((item) => item.channelId).forEach(vapi.addVideos)
7 | })()
8 |
--------------------------------------------------------------------------------
/sonar-project.properties.sample:
--------------------------------------------------------------------------------
1 | sonar.projectKey=ok:mp4
2 | sonar.projectName=mp4
3 | sonar.projectVersion=1.0
4 | sonar.sources=.
5 | sonar.token=${SONAR_TOKEN}
6 | sonar.javascript.lcov.reportPaths=./coverage/lcov.info
7 | sonar.exclusions=web/public/js/**/*.js
8 |
--------------------------------------------------------------------------------
/.aidigestignore:
--------------------------------------------------------------------------------
1 | .aidigestignore
2 | sonar-project.*
3 | *.local
4 | a.json
5 | README.md
6 | LICENSE
7 | CODE_OF_CONDUCT.md
8 | .prettierrc.json
9 | .gitignore
10 | tests/add*
11 | .scannerwork/
12 | web/public/favicon/
13 | web/public/images/
14 | .github/
15 | scripts/
16 | cron/
17 | simplemail.js
18 |
--------------------------------------------------------------------------------
/web/public/favicon/site.webmanifest:
--------------------------------------------------------------------------------
1 | {"name":"","short_name":"","icons":[{"src":"/favicon/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/favicon/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
2 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # 서버 설정
2 | NODE_ENV=production
3 | PORT=4000
4 | HOST=http://localhost:4000
5 |
6 | # 데이터베이스 설정
7 | DB_NAME=odevtube
8 | DB_USER=devuser
9 | DB_PASSWORD=devpass
10 |
11 | # YouTube 설정
12 | YOUTUBE_API_KEY=your_youtube_api_key
13 | YOUTUBE_GTAG=your_youtube_gtag
14 | YOUTUBE_WCS=your_youtube_wcs
15 |
--------------------------------------------------------------------------------
/web/utils/summary.js:
--------------------------------------------------------------------------------
1 | import OpenAI from 'openai'
2 |
3 | async function summarize(messages) {
4 | const openai = new OpenAI()
5 | const completion = await openai.chat.completions.create({
6 | messages: messages,
7 | model: 'gpt-4o-mini',
8 | })
9 |
10 | return completion.choices[0].message.content
11 | }
12 |
13 | export default summarize
14 |
--------------------------------------------------------------------------------
/web/utils/uri.js:
--------------------------------------------------------------------------------
1 | function getUri(category = 'dev', lang = '') {
2 | if (category === 'dev') {
3 | category = ''
4 | }
5 | if (lang === 'ko') {
6 | lang = ''
7 | }
8 | let uri = '/' + category + '/' + lang
9 | uri = uri.replace('//', '/')
10 | uri = uri.replace('/#', '#')
11 | return uri
12 | }
13 |
14 | export default { getUri }
15 |
--------------------------------------------------------------------------------
/scripts/deploy-odevtube.sh:
--------------------------------------------------------------------------------
1 | #!/bin/zsh
2 | . ~/.zshrc
3 |
4 | cd ~/git/odevtube
5 | git pull origin main
6 | pnpm i
7 | cd ~/git/odevtube/web
8 | pnpm i
9 | pm2 restart odevtube --update-env
10 | sleep 2
11 | pm2 list
12 |
13 |
14 | curl -X POST -H 'Content-type: application/json' --data '{"content":"📦 Deploy Finished!\nhttps://mp4.okdevtv.com/"}' $WEBHOOK_DISCORD_MP4_URL
15 |
--------------------------------------------------------------------------------
/scripts/cron-video.sh:
--------------------------------------------------------------------------------
1 | #!/bin/zsh
2 | . ~/.zshrc
3 |
4 | node ~/git/odevtube/cron/cron-channel.js >> /tmp/channel.log
5 | node ~/git/odevtube/cron/cron-video.js >> /tmp/video.log
6 | node ~/git/odevtube/cron/cron-hourly.js >> /tmp/hourly.log
7 |
8 | curl -X POST -H 'Content-type: application/json' --data '{"content":"⏱️ Cron Job Finished!\nhttps://mp4.okdevtv.com/"}' $WEBHOOK_DISCORD_MP4_URL
9 |
--------------------------------------------------------------------------------
/tests/transcript.test.js:
--------------------------------------------------------------------------------
1 | import { getFullText } from '../web/utils/transcriptUtil'
2 |
3 | test('getTextOnly', async () => {
4 | const fullText = await getFullText('g09Qn23cYJ8')
5 | expect(fullText).toContain('니다')
6 | })
7 |
8 | test('remove [Object, Object]', async () => {
9 | const fullText = await getFullText('g09Qn23cYJ8')
10 | expect(fullText).not.toContain('Object')
11 | })
12 |
--------------------------------------------------------------------------------
/web/.env.sample:
--------------------------------------------------------------------------------
1 | # GitHub OAuth 설정 https://github.com/settings/developers
2 | GITHUB_CLIENT_ID=github_client_id
3 | GITHUB_CLIENT_SECRET=client_secret
4 | HOST=http://localhost:4000
5 | YOUDB_NAME='odevtube'
6 | YOUDB_USER='devuser'
7 | YOUDB_PASS='devpass'
8 | YOUTUBE_API_KEY=youtube_api_key
9 | YOUTUBE_GTAG=youtube_gtag
10 | YOUTUBE_WCS=youtube_wcs
11 | WEBHOOK_DISCORD_MP4_URL=webhook_discord_mp4_url
12 |
--------------------------------------------------------------------------------
/tests/uri.test.js:
--------------------------------------------------------------------------------
1 | import util from '../web/utils/uri'
2 |
3 | test('base URI', () => {
4 | const uri = util.getUri('dev', 'ko')
5 | expect(uri).toBe('/')
6 | })
7 |
8 | test('category URI', () => {
9 | const uri = util.getUri('food', 'ko')
10 | expect(uri).toBe('/food/')
11 | })
12 |
13 | test('lang URI', () => {
14 | const uri = util.getUri('dev', 'en')
15 | expect(uri).toBe('/en')
16 | })
17 |
--------------------------------------------------------------------------------
/web/utils/transcriptUtil.js:
--------------------------------------------------------------------------------
1 | import { YoutubeTranscript } from "youtube-transcript";
2 |
3 | async function getFullText(videoId) {
4 | const transcript = await YoutubeTranscript.fetchTranscript(videoId);
5 | const fullText = transcript.map((item) => item.text).join(" ");
6 | const pattern = /(니다|이죠|하죠|하겠죠|고요|까요|네요|데요|세요|에요|아요|어요)\s/g
7 | return fullText.replace(pattern, "$1. ");
8 | }
9 |
10 | export { getFullText };
11 |
--------------------------------------------------------------------------------
/cron/cron-channel.js:
--------------------------------------------------------------------------------
1 | import dao from '../youtubeDao.js'
2 | import capi from '../services/channel.js'
3 |
4 | async function processChannels() {
5 | const channelList = await dao.findAllEmpty()
6 | for (const channel of channelList) {
7 | if (channel.title) {
8 | continue
9 | }
10 | const data = await capi.getChannelInfo(channel.channelId)
11 | await dao.create(data)
12 | }
13 | }
14 |
15 | processChannels()
16 |
--------------------------------------------------------------------------------
/web/views/search.ejs:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/tests/channel.test.js:
--------------------------------------------------------------------------------
1 | import dao from '../youtubeDao'
2 |
3 | test('채널 목록 조회', async () => {
4 | const channelList = await dao.findAllChannelList()
5 | expect(channelList).not.toBeNull()
6 | expect(channelList.length).not.toBe(0)
7 | })
8 |
9 | test('최근 업데이트 목록', async () => {
10 | const channelList = await dao.findAllChannelList(14)
11 | console.log(channelList.length)
12 | expect(channelList).not.toBeNull()
13 | expect(channelList.length).not.toBe(0)
14 | })
15 |
--------------------------------------------------------------------------------
/channels.js:
--------------------------------------------------------------------------------
1 | const channels = {
2 | dev: [
3 | [
4 | 'UCvnY2N2UEGJ6nzBuOEuxG-A',
5 | 'UC8lT6sXcuLvFNstnnSqrsIA',
6 | ],
7 | [
8 | 'UCL0q0BtIDkOhTCMdtwNgLBg',
9 | 'UCOxWrX5MIdXIeRNaXC3sqIg',
10 | ],
11 | ],
12 | food: [
13 | [
14 | 'UCyn-K7rZLXjGl7VXGweIlcA',
15 | 'UCg-p3lQIqmhh7gHpyaOmOiQ',
16 | ],
17 | ],
18 | kpop: [
19 | [
20 | 'UCe52oeb7Xv_KaJsEzcKXJJg',
21 | 'UCaO6TYtlC8U5ttz62hTrZgg',
22 | 'UC3IZKseVpdzPSBaWxBxundA',
23 | ]
24 | ]
25 | }
26 |
27 | export default channels
28 |
--------------------------------------------------------------------------------
/docs/overview/README.md:
--------------------------------------------------------------------------------
1 | # 프로젝트 개요
2 |
3 | ## ODevTube 소개
4 |
5 | ODevTube는 개발자를 위한 YouTube 채널 큐레이션 및 콘텐츠 공유 플랫폼입니다. 이 프로젝트는 개발자들이 양질의 기술 콘텐츠를 쉽게 찾고 공유할 수 있도록 지원합니다.
6 |
7 | ## 프로젝트 목적
8 |
9 | - 개발자 커뮤니티를 위한 양질의 YouTube 콘텐츠 큐레이션
10 | - 기술 분야별 최신 동향 및 교육 자료 제공
11 | - 개발자들 간의 지식 공유 활성화
12 |
13 | ## 주요 기능
14 |
15 | - 개발 관련 YouTube 채널 및 동영상 큐레이션
16 | - 기술 분야별 콘텐츠 분류 및 검색
17 | - 사용자 추천 시스템
18 | - 정기적인 콘텐츠 업데이트
19 |
20 | ## 기대 효과
21 |
22 | - 개발자들의 학습 리소스 접근성 향상
23 | - 기술 지식 공유 활성화
24 | - 개발 커뮤니티 강화
25 |
26 | ## 프로젝트 이해관계자
27 |
28 | - 개발자 및 기술 학습자
29 | - 콘텐츠 제작자
30 | - 기술 커뮤니티
31 |
--------------------------------------------------------------------------------
/scripts/deploy-docker.sh:
--------------------------------------------------------------------------------
1 | #!/bin/zsh
2 | . ~/.zshrc
3 |
4 | echo "🚀 Docker 배포 시작..."
5 |
6 | # 프로젝트 디렉토리로 이동
7 | cd ~/git/odevtube
8 |
9 | # 최신 코드 가져오기
10 | git pull origin main
11 |
12 | # Docker 컨테이너 재시작
13 | echo "🐳 Docker 컨테이너 재시작 중..."
14 | docker-compose down
15 | docker-compose up -d --build
16 |
17 | # 배포 상태 확인
18 | echo "✅ 배포 상태 확인:"
19 | docker-compose ps
20 |
21 | echo "📦 Docker 배포 완료!"
22 |
23 | # Discord 웹훅 알림 (기존 웹훅 URL 사용)
24 | curl -X POST -H 'Content-type: application/json' --data '{"content":"🐳 Docker 배포 완료!\nhttps://mp4.okdevtv.com/"}' $WEBHOOK_DISCORD_MP4_URL
25 |
--------------------------------------------------------------------------------
/tests/ch.js:
--------------------------------------------------------------------------------
1 | import dao from '../youtubeDao.js'
2 | import channels from '../channels.js'
3 | import cr from '../cron/cron-channel.js'
4 |
5 | // 채널 ID를 입력하여 실행합니다.
6 | channels.dev[0].forEach(async (channelId) => {
7 | const data = await cr.getChannelInfo(channelId)
8 | data.lang = 'ko'
9 | data.category = 'dev'
10 | dao.create(data)
11 | })
12 | // 채널 ID를 입력하여 실행합니다.
13 | channels.dev[1].forEach(async (channelId) => {
14 | const data = await cr.getChannelInfo(channelId)
15 | data.lang = 'en'
16 | data.category = 'dev'
17 | dao.create(data)
18 | })
19 |
20 | cr.findChannelInfo('@kenuheo')
21 |
--------------------------------------------------------------------------------
/tests/single.test.js:
--------------------------------------------------------------------------------
1 | import youtube from './youtub'
2 |
3 | test('channel', channel)
4 |
5 | function channel() {
6 | youtube.activities?.list(
7 | {
8 | channelId: 'UCHbXBo1fQAg7j0D7HKKYHJg',
9 | maxResults: 50, // 가져올 동영상의 최대 수
10 | order: 'date', // 최신 순으로 정렬
11 | part: 'snippet,contentDetails', // 필요한 정보를 지정합니다.
12 | },
13 | (err, res) => {
14 | if (err) return console.log('The API returned an error: ' + err)
15 | const { items } = res.data
16 | const list = items.map((item) => {
17 | return (item.snippet.type)
18 | })
19 | console.log(list)
20 | }
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/web/views/footer.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
14 |
21 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "module",
3 | "scripts": {
4 | "test": "node --verbose --experimental-vm-modules node_modules/jest/bin/jest.js --coverage tests/test.test.js"
5 | },
6 | "dependencies": {
7 | "axios": "^1.6.8",
8 | "dayjs": "^1.11.10",
9 | "googleapis": "^134.0.0",
10 | "helmet": "^7.1.0",
11 | "mariadb": "^3.3.0",
12 | "node-html-parser": "^6.1.13",
13 | "nodemailer": "^6.9.13",
14 | "openai": "^4.38.2",
15 | "sequelize": "^6.37.3",
16 | "youtube-transcript": "^1.2.1"
17 | },
18 | "devDependencies": {
19 | "@babel/core": "^7.24.4",
20 | "@babel/preset-env": "^7.24.4",
21 | "jest": "^29.7.0"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/docker-compose/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | mariadb:
3 | image: mariadb:latest
4 | environment: # 환경변수
5 | MYSQL_DATABASE: odevtube
6 | MYSQL_USER: devuser
7 | MYSQL_PASSWORD: devpass
8 | MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
9 | MYSQL_ROOT_HOST: "%"
10 | TZ: Asia/Seoul
11 | command: # 명령어 실행 - characterset 지정
12 | - --character-set-server=utf8mb4
13 | - --collation-server=utf8mb4_unicode_ci
14 | - --lower_case_table_names=1
15 | platform: linux/arm64 #apple silicon에서 플랫폼을 명시해주지 않으면 에러남
16 | ports:
17 | - "3306:3306"
18 | volumes:
19 | - ./init.sql:/docker-entrypoint-initdb.d/init.sql
20 | container_name: mariadb
21 |
--------------------------------------------------------------------------------
/tests/youtubedao.test.js:
--------------------------------------------------------------------------------
1 | import dao from '../youtubeDao'
2 |
3 | // pagination test
4 | test('pagination', async () => {
5 | const result = await dao.getPagedVideos({
6 | page: 1,
7 | pageSize: 3,
8 | })
9 | expect(result).not.toBeNull()
10 | expect(result.count).not.toBe(0)
11 | expect(result.rows.length).toBe(3)
12 |
13 | const result2 = await dao.getPagedVideos({
14 | page: 2,
15 | pageSize: 3,
16 | })
17 | expect(result2).not.toBeNull()
18 | expect(result2.count).not.toBe(0)
19 | expect(result2.rows.length).toBe(3)
20 |
21 | // check result, result2 should be different
22 | expect(result.rows).not.toEqual(result2.rows)
23 | expect(result.rows[0].videoId).not.toEqual(result2.rows[0].videoId)
24 | })
25 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # ODevTube 프로젝트 문서
2 |
3 | 이 문서는 ODevTube 프로젝트의 기술 문서를 포함하고 있습니다.
4 |
5 | ## 목차
6 |
7 | 1. [프로젝트 개요](./overview/README.md)
8 | 2. [아키텍처](./architecture/README.md)
9 | 3. [개발 가이드](./development/README.md)
10 | 4. [배포 가이드](./deployment/README.md)
11 | 5. [API 문서](./api/README.md)
12 | 6. [운영 가이드](./operation/README.md)
13 |
14 | ## 문서 구조
15 |
16 | ```
17 | docs/
18 | ├── overview/ # 프로젝트 개요 및 목적
19 | ├── architecture/ # 시스템 아키텍처 설명
20 | ├── development/ # 개발 환경 설정 및 가이드
21 | ├── deployment/ # 배포 프로세스 및 환경 설정
22 | ├── api/ # API 명세 및 사용 방법
23 | └── operation/ # 운영 및 유지보수 가이드
24 | ```
25 |
26 | ## 문서 작성 가이드
27 |
28 | - 모든 문서는 마크다운(.md) 형식으로 작성합니다.
29 | - 이미지는 각 폴더 내의 `images` 디렉토리에 저장합니다.
30 | - 코드 예제는 해당 언어의 구문 강조를 사용하여 작성합니다.
31 |
--------------------------------------------------------------------------------
/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web",
3 | "version": "0.0.0",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "dev": "nodemon ./bin/odevtube.js"
8 | },
9 | "dependencies": {
10 | "body-parser": "^2.2.0",
11 | "connect-ensure-login": "^0.1.1",
12 | "cookie-parser": "~1.4.7",
13 | "dayjs": "^1.11.18",
14 | "debug": "~4.4.3",
15 | "dotenv": "^17.2.3",
16 | "ejs": "~3.1.10",
17 | "express": "~5.1.0",
18 | "express-session": "^1.18.2",
19 | "http-errors": "~2.0.0",
20 | "mariadb": "^3.4.5",
21 | "morgan": "~1.10.1",
22 | "passport": "^0.7.0",
23 | "passport-github2": "^0.1.12",
24 | "sequelize": "^6.37.7",
25 | "youtube-transcript": "^1.2.1"
26 | },
27 | "devDependencies": {
28 | "nodemon": "^3.1.10"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/cron/cron-hourly.js:
--------------------------------------------------------------------------------
1 | import dao from '../youtubeDao.js'
2 | import axios from 'axios'
3 |
4 | async function getNewHourly() {
5 | const newList = await dao.newList()
6 | const messages = newList.map((data) => {
7 | // videoId, title
8 | return `https://youtu.be/${data.videoId} ${data.title}`
9 | })
10 | if (messages.length === 0) {
11 | return
12 | }
13 |
14 | const webhookUrl = process.env.WEBHOOK_DISCORD_MP4_URL
15 | const data = {
16 | content: 'Data Added!\n' + messages.join('\n'),
17 | }
18 |
19 | axios
20 | .post(webhookUrl, data)
21 | .then((response) => {
22 | console.log('Message sent successfully!')
23 | console.log('Status Code:', response.status)
24 | })
25 | .catch((error) => {
26 | console.error('Error sending message:', error)
27 | })
28 | }
29 |
30 | getNewHourly()
31 |
--------------------------------------------------------------------------------
/web/public/js/odevtube-utils.js:
--------------------------------------------------------------------------------
1 | /**
2 | * ODevTube 공통 유틸리티 함수
3 | */
4 |
5 | /**
6 | * 탭 전환 함수 - 차트 또는 섹션 표시
7 | * @param {string} tabName - 표시할 탭 이름
8 | * @param {string} sectionType - 섹션 타입 (chart 또는 section)
9 | */
10 | function showTab(tabName, sectionType = 'chart') {
11 | // 모든 탭과 섹션 비활성화
12 | document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
13 | document.querySelectorAll('.chart-section').forEach(section => section.classList.remove('active'));
14 |
15 | // 선택한 탭과 섹션 활성화
16 | document.querySelector(`.tab[onclick*="${tabName}"]`).classList.add('active');
17 |
18 | // 섹션 타입에 따라 ID 형식 결정
19 | const sectionId = sectionType === 'chart'
20 | ? `${tabName}-chart`
21 | : `${tabName}-section`;
22 |
23 | document.getElementById(sectionId).classList.add('active');
24 | }
25 |
--------------------------------------------------------------------------------
/web/views/admin/nav.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
21 |
22 |
--------------------------------------------------------------------------------
/tests/sqluelize.join.sample.js:
--------------------------------------------------------------------------------
1 | import { Sequelize, DataTypes } from 'sequelize'
2 | const sequelize = new Sequelize(
3 | process.env.YOUDB_NAME || 'odevtube',
4 | process.env.YOUDB_USER || 'devuser',
5 | process.env.YOUDB_PASS || 'devpass',
6 | {
7 | host: 'localhost',
8 | dialect: 'mariadb',
9 | timezone: 'Asia/Seoul',
10 | logging: false,
11 | }
12 | )
13 | const Channel = sequelize.define('Channel', {
14 | channelId: { type: DataTypes.STRING, unique: true },
15 | title: DataTypes.STRING,
16 | thumbnail: DataTypes.STRING,
17 | customUrl: DataTypes.STRING,
18 | })
19 | const Video = sequelize.define('Video', {
20 | title: DataTypes.STRING,
21 | videoId: { type: DataTypes.STRING, unique: true },
22 | thumbnail: DataTypes.STRING,
23 | publishedAt: DataTypes.DATE,
24 | })
25 |
26 | Channel.hasMany(Video)
27 | Video.belongsTo(Channel)
28 | ;(async () => {
29 | await sequelize.sync()
30 | })()
31 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Video API
2 |
3 | - Node.js v18+
4 | - Google Developers Console에 접속하여 새 프로젝트를 생성
5 | - https://console.cloud.google.com/
6 | - YouTube Data API v3를 활성화
7 | - 프로젝트에서 신규 API 키를 생성
8 | - 환경변수 YOUTUBE_API_KEY 설정
9 | - [package.json](https://github.com/kenu/odevtube/blob/main/package.json)에 youtube-api를 추가 `"googleapis": "^134.0.0"`
10 |
11 | ```
12 | {
13 | channelId: ''
14 | title: '00 Git 그리고 VS Code',
15 | videoId: 'GfccCjzhDU4',
16 | publishedAt: '2024-03-06T01:05:26Z'
17 | }
18 | ```
19 |
20 | ## DB
21 | - `docker-compose/docker-compose.yml`을 참고
22 | - `docker-compose/init.sql`을 참고
23 | ```sql
24 | create database odevtube default character set utf8mb4 collate utf8mb4_unicode_ci;
25 | GRANT ALL PRIVILEGES ON odevtube.* TO devuser@localhost IDENTIFIED BY 'devpass';
26 | ```
27 | - `cd docker-compose`
28 | - `docker-compose up -d`로 실행
29 | - `docker-compose down`로 종료
30 |
31 | ## LICENSE
32 |
33 | - MIT License
34 |
--------------------------------------------------------------------------------
/web/public/images/github-mark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/simplemail.js:
--------------------------------------------------------------------------------
1 | const nodemailer = require("nodemailer");
2 |
3 | const transporter = nodemailer.createTransport({
4 | host: "smtp.gmail.com",
5 | port: 465,
6 | secure: true,
7 | requireTLS: true,
8 | secured: true,
9 | auth: {
10 | user: "kenu.heo@xmail.com",
11 | pass: "**** qiym **** bhlq", // https://myaccount.google.com/apppasswords
12 | },
13 | });
14 |
15 | // async..await is not allowed in global scope, must use a wrapper
16 | async function main() {
17 | // send mail with defined transport object
18 | const info = await transporter.sendMail({
19 | from: '"Kenu Heo 👻" ', // sender address
20 | to: "kenux@xkdevtv.com, heogn@xaver.com", // list of receivers
21 | subject: "Hello ✔", // Subject line
22 | text: "Hello world?", // plain text body
23 | html: "Hello world?", // html body
24 | });
25 |
26 | console.log("Message sent: %s", info.messageId);
27 | }
28 |
29 | main().catch(console.error);
30 |
--------------------------------------------------------------------------------
/web/views/login.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Login - ODevTube
7 |
8 |
9 |
10 |
11 |
Welcome to ODevTube
12 |
13 |
14 | Log In with GitHub
15 |
16 |
17 |
18 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/dailyDao.js:
--------------------------------------------------------------------------------
1 | import { Sequelize } from 'sequelize'
2 | import dayjs from 'dayjs'
3 |
4 | const sequelize = new Sequelize(
5 | process.env.YOUDB_NAME || 'odevtube',
6 | process.env.YOUDB_USER || 'devuser',
7 | process.env.YOUDB_PASS || 'devpass',
8 | {
9 | host: 'localhost',
10 | dialect: 'mariadb',
11 | timezone: 'Asia/Seoul',
12 | logging: true,
13 | }
14 | )
15 |
16 | async function newList(date) {
17 | const dddd = dayjs(date).format('YYYY-MM-DD hh')
18 | const list = await sequelize.query(
19 | `select y.title, y.videoId, y.thumbnail, y.publishedAt,
20 | c.title ctitle, c.thumbnail cthumbnail, c.category
21 | from Videos y
22 | join Channels c on c.id = y.ChannelId and c.lang = 'ko' and c.category in ('dev', 'food')
23 | where y.publishedAt > $date order by y.publishedAt desc;`,
24 | {
25 | bind: { date: dddd },
26 | type: sequelize.QueryTypes.SELECT,
27 | }
28 | )
29 | return list
30 | }
31 |
32 | export default {
33 | newList,
34 | }
35 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Deploy ODevTube
2 | on:
3 | push:
4 | branches: [main]
5 | workflow_dispatch:
6 | inputs:
7 | deploy_type:
8 | description: '배포 방식 선택'
9 | required: true
10 | default: 'standard'
11 | type: choice
12 | options:
13 | - standard
14 | - docker
15 |
16 | jobs:
17 | deploy:
18 | name: Deploy Application
19 | runs-on: ubuntu-latest
20 | steps:
21 | - name: SSH Remote Commands
22 | uses: appleboy/ssh-action@v1.0.3
23 | with:
24 | host: ${{ secrets.HOST }}
25 | username: ${{ secrets.USERNAME }}
26 | key: ${{ secrets.KEY }}
27 | port: ${{ secrets.PORT }}
28 | script: |
29 | if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.event.inputs.deploy_type }}" == "docker" ]]; then
30 | echo "🐳 Docker 배포 실행 중..."
31 | ./scripts/deploy-docker.sh
32 | else
33 | echo "📦 표준 배포 실행 중..."
34 | ./scripts/deploy-odevtube.sh
35 | fi
36 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 kenu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/web/views/video.ejs:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
12 |
13 | <%= video.pubdate %>
14 |
15 | <%= video.title %>
16 |
17 |
24 |
25 |
--------------------------------------------------------------------------------
/web/views/aside.ejs:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
24 |
--------------------------------------------------------------------------------
/services/channel.js:
--------------------------------------------------------------------------------
1 | import youtube from '../youtube.js'
2 |
3 | async function getChannelInfo(channelId) {
4 | try {
5 | const response = await youtube.channels.list({
6 | id: channelId,
7 | part: 'snippet,contentDetails',
8 | })
9 | const items = response.data.items[0]
10 | const data = {
11 | title: items.snippet.title,
12 | customUrl: items.snippet.customUrl,
13 | thumbnail: items.snippet.thumbnails.medium.url,
14 | channelId: items.id,
15 | }
16 | return data
17 | } catch (error) {
18 | console.error('Error:', error)
19 | }
20 | }
21 |
22 | async function findChannelInfo(forHandle) {
23 | try {
24 | const response = await youtube.channels.list({
25 | forHandle,
26 | part: 'id,snippet,contentDetails',
27 | })
28 | const item = response.data.items[0]
29 | const data = {
30 | title: item.snippet.title,
31 | customUrl: item.snippet.customUrl,
32 | channelId: item.id,
33 | thumbnail: item.snippet.thumbnails.medium.url,
34 | }
35 | return data
36 | } catch (error) {
37 | console.error('Error:', error)
38 | throw error
39 | }
40 | }
41 |
42 | export default {
43 | getChannelInfo,
44 | findChannelInfo,
45 | }
46 |
--------------------------------------------------------------------------------
/web/views/header.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 | <%= title %>
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
×
23 |
24 |
25 |
Loading text ...
26 |
27 |
28 |
--------------------------------------------------------------------------------
/tests/transcriptdao.test.js:
--------------------------------------------------------------------------------
1 | import dao from '../youtubeDao'
2 | /*
3 | find by videoId
4 | if empty get from youtube
5 | save with videoId
6 | */
7 |
8 | const videoId = 'cWn3WjTdpMw'
9 | test('find by videoId', async () => {
10 | const transcript = await dao.findTranscriptByVideoId(videoId)
11 | expect(transcript).toBeNull()
12 | await dao.removeTranscript(videoId)
13 | })
14 |
15 | test('save with videoId', async () => {
16 | const data = {
17 | videoId: videoId,
18 | content: 'test1',
19 | }
20 | const result = await dao.createTranscript(data)
21 | expect(result).not.toBeNull()
22 | const resultnull = await dao.createTranscript({})
23 | expect(resultnull).toBeUndefined()
24 | })
25 |
26 | afterEach(async () => {
27 | return await dao.removeTranscript(videoId)
28 | })
29 |
30 | test('newList', async () => {
31 | const newList = await dao.newList()
32 | expect(newList).not.toBeNull()
33 | expect(newList.length).toBe(0)
34 | })
35 |
36 | test('findOneByChannelId', async () => {
37 | const channelId = 'UC_x5XG1OV2P6uZZ5FSM9Ttw'
38 | const channel = await dao.findOneByChannelId(channelId)
39 | expect(channel).toBeNull()
40 | })
41 |
42 | test('findAndCountAllVideo', async () => {
43 | const result = await dao.findAndCountAllVideo()
44 | expect(result).not.toBeNull()
45 | expect(result.count).not.toBe(0)
46 | })
47 |
--------------------------------------------------------------------------------
/web/public/css/odevtube-common.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
3 | margin: 0;
4 | padding: 20px;
5 | background-color: #f5f5f5;
6 | }
7 | .container {
8 | max-width: 1200px;
9 | margin: 0 auto;
10 | background-color: white;
11 | padding: 20px;
12 | border-radius: 10px;
13 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
14 | }
15 | h1, h2, h3 {
16 | color: #333;
17 | text-align: center;
18 | }
19 | h1 {
20 | margin-bottom: 30px;
21 | }
22 | .tab-container {
23 | display: flex;
24 | justify-content: center;
25 | margin-bottom: 20px;
26 | }
27 | .tab {
28 | padding: 10px 20px;
29 | cursor: pointer;
30 | background-color: #eee;
31 | border: none;
32 | outline: none;
33 | border-radius: 5px;
34 | margin: 0 5px;
35 | font-weight: bold;
36 | }
37 | .tab.active {
38 | background-color: #4CAF50;
39 | color: white;
40 | }
41 | .chart-section {
42 | display: none;
43 | margin-bottom: 40px;
44 | }
45 | .chart-section.active {
46 | display: block;
47 | }
48 | .chart-container {
49 | position: relative;
50 | margin: auto;
51 | height: 400px;
52 | margin-bottom: 50px;
53 | }
54 | .summary {
55 | background-color: #f9f9f9;
56 | padding: 15px;
57 | border-radius: 5px;
58 | margin-top: 20px;
59 | line-height: 1.6;
60 | }
61 | .highlight {
62 | color: #4CAF50;
63 | font-weight: bold;
64 | }
65 |
--------------------------------------------------------------------------------
/services/video.js:
--------------------------------------------------------------------------------
1 | import youtube from '../youtube.js'
2 | import dao from '../youtubeDao.js'
3 |
4 | async function getLatestVideos(channelId) {
5 | try {
6 | const response = await youtube.activities.list({
7 | channelId,
8 | maxResults: 150, // 가져올 동영상 activity의 최대 수
9 | order: 'date',
10 | part: 'snippet,contentDetails',
11 | })
12 |
13 | const videos = response.data.items.map((item) => {
14 | const thumbnail = item.snippet.thumbnails.medium.url
15 | if (!item.contentDetails.upload) {
16 | return null
17 | }
18 | const videoId = item.contentDetails.upload.videoId
19 | return {
20 | channelId,
21 | videoId,
22 | title: item.snippet.title,
23 | thumbnail,
24 | publishedAt: item.snippet.publishedAt,
25 | }
26 | })
27 |
28 | return videos.filter((video) => video)
29 | } catch (error) {
30 | console.error('Error:', error)
31 | }
32 | }
33 |
34 | async function addVideos(channelId) {
35 | const videos = await getLatestVideos(channelId)
36 | const channel = await dao.findOneByChannelId(channelId)
37 | if (!channel) {
38 | return
39 | }
40 | for (const data of videos || []) {
41 | data.ChannelId = channel.id
42 | await dao.createVideo(data)
43 | }
44 | }
45 |
46 | async function remove(videoId) {
47 | await dao.removeVideo(videoId)
48 | }
49 |
50 | export default {
51 | getLatestVideos,
52 | addVideos,
53 | remove,
54 | }
55 |
--------------------------------------------------------------------------------
/tests/test.test.js:
--------------------------------------------------------------------------------
1 | import youtube from '../youtube.js'
2 |
3 | describe('test', () => {
4 | it('test', () => {
5 | const thumbnail = 'https://i.ytimg.com/vi/hHAqR-qKZjo/default.jpg'
6 | const videoId = 'hHAqR-qKZjo'
7 | expect(getVideoId(thumbnail)).toBe(videoId)
8 | })
9 | })
10 | function getVideoId(thumbnail) {
11 | const regex = /^https:\/\/i\.ytimg\.com\/vi\/([^/]+)\/.*$/
12 | const match = thumbnail.match(regex)
13 | if (match) {
14 | return match[1]
15 | }
16 | return null
17 | }
18 |
19 | describe('get channel info by id', () => {
20 | it('test', async () => {
21 | const channelId = 'UCdNSo3yB5-FRTFGbUNKNnwQ'
22 | const channelInfo = await getChannelInfo(channelId)
23 | expect(channelInfo.title).toBe('프로그래머 김플 스튜디오')
24 | expect(channelInfo.thumbnail).toBe(
25 | 'https://yt3.ggpht.com/SrCeLz3yIf5kVvXOZz8VzenrpyYOIolN9xAdyQI9X6G-_JhiGKqR0nRQ_OcaK5c5cYkyeA0OFQ=s240-c-k-c0x00ffffff-no-rj'
26 | )
27 | })
28 | })
29 |
30 | async function getChannelInfo(channelId) {
31 | try {
32 | const response = await youtube.channels.list({
33 | id: channelId,
34 | part: 'snippet,contentDetails',
35 | })
36 | const items = response.data.items[0]
37 | const data = {
38 | title: items.snippet.title,
39 | customUrl: items.snippet.customUrl,
40 | thumbnail: items.snippet.thumbnails.medium.url,
41 | channelId: items.id,
42 | }
43 | return data
44 | } catch (error) {
45 | console.error('Error:', error)
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/docs/development/README.md:
--------------------------------------------------------------------------------
1 | # 개발 가이드
2 |
3 | ## 개발 환경 설정
4 |
5 | ### 필수 요구사항
6 | - Node.js (v14 이상)
7 | - npm 또는 pnpm
8 | - Git
9 |
10 | ### 로컬 개발 환경 구성
11 |
12 | 1. 저장소 클론
13 | ```bash
14 | git clone https://github.com/username/odevtube.git
15 | cd odevtube
16 | ```
17 |
18 | 2. 의존성 설치
19 | ```bash
20 | pnpm install
21 | ```
22 |
23 | 3. 환경 변수 설정
24 | ```bash
25 | cp .env.example .env
26 | # .env 파일을 편집하여 필요한 API 키와 설정 추가
27 | ```
28 |
29 | 4. 개발 서버 실행
30 | ```bash
31 | pnpm start
32 | ```
33 |
34 | ## 코드 구조
35 |
36 | ```
37 | odevtube/
38 | ├── cron/ # 크론 작업 스크립트
39 | ├── scripts/ # 유틸리티 스크립트
40 | ├── services/ # 백엔드 서비스
41 | ├── tests/ # 테스트 코드
42 | ├── web/ # 프론트엔드 코드
43 | ├── channels.js # 채널 관련 기능
44 | ├── dailyDao.js # 일일 데이터 액세스 객체
45 | ├── youtube.js # YouTube API 연동
46 | └── youtubeDao.js # YouTube 데이터 액세스 객체
47 | ```
48 |
49 | ## 브랜치 전략
50 |
51 | - `main`: 프로덕션 환경에 배포되는 안정적인 코드
52 | - `develop`: 개발 중인 코드가 통합되는 브랜치
53 | - `feature/*`: 새로운 기능 개발 브랜치
54 | - `bugfix/*`: 버그 수정 브랜치
55 | - `release/*`: 릴리스 준비 브랜치
56 |
57 | ## 코딩 컨벤션
58 |
59 | - JavaScript 표준 스타일 가이드를 따릅니다
60 | - Prettier를 사용하여 코드 포맷팅을 유지합니다
61 | - 함수와 변수에는 명확한 이름을 사용합니다
62 | - 주요 함수와 클래스에는 JSDoc 주석을 추가합니다
63 |
64 | ## 테스트
65 |
66 | 테스트는 `tests` 디렉토리에 위치하며 다음 명령으로 실행할 수 있습니다:
67 |
68 | ```bash
69 | pnpm test
70 | ```
71 |
72 | ## Pull Request 프로세스
73 |
74 | 1. 기능 개발 또는 버그 수정 후 `develop` 브랜치로 PR을 생성합니다
75 | 2. 코드 리뷰를 통과해야 합니다
76 | 3. 모든 테스트가 통과해야 합니다
77 | 4. 승인 후 `develop` 브랜치에 병합됩니다
78 |
--------------------------------------------------------------------------------
/web/public/js/modal.js:
--------------------------------------------------------------------------------
1 | function openModal() {
2 | document.getElementById('mp4Modal').style.display = 'block'
3 | }
4 |
5 | function closeModal() {
6 | document.getElementById('mp4Modal').style.display = 'none'
7 | document.getElementById('modalContent').innerText = 'Loading text ...'
8 | }
9 |
10 | function copyTranscript() {
11 | const text = document.getElementById('modalContent').innerText
12 | navigator.clipboard.writeText(text)
13 | alert('클립보드에 복사되었습니다.')
14 | }
15 |
16 | // DOM이 로드된 후 모달 관련 이벤트 리스너 등록
17 | document.addEventListener('DOMContentLoaded', function() {
18 | // 모달 열기 버튼에 이벤트 리스너 등록
19 | document.querySelectorAll('.openModal').forEach(i => {
20 | i.addEventListener('click', openModal)
21 | })
22 |
23 | // 모달 닫기 버튼에 이벤트 리스너 등록
24 | const closeModalBtn = document.getElementById('closeModal')
25 | if (closeModalBtn) {
26 | closeModalBtn.addEventListener('click', closeModal)
27 | }
28 |
29 | // 클립보드 복사 버튼에 이벤트 리스너 등록
30 | const clipboardBtn = document.getElementById('clipboardBtn')
31 | if (clipboardBtn) {
32 | clipboardBtn.addEventListener('click', copyTranscript)
33 | }
34 |
35 | // 모달 콘텐츠 더블클릭 시 복사 기능 추가
36 | const modalContent = document.getElementById('modalContent')
37 | if (modalContent) {
38 | modalContent.addEventListener('dblclick', copyTranscript)
39 | }
40 |
41 | // 모달창 바깥 클릭 시 모달창 숨기기
42 | window.addEventListener('click', function(event) {
43 | if (event.target == document.getElementById('mp4Modal')) {
44 | closeModal()
45 | }
46 | })
47 | })
48 |
--------------------------------------------------------------------------------
/web/bin/odevtube.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import app from '../app.js'
4 | import http from 'http'
5 |
6 | const port = normalizePort(process.env.PORT || '4000')
7 | app.set('port', port)
8 |
9 | /**
10 | * Create HTTP server.
11 | */
12 |
13 | const server = http.createServer(app)
14 |
15 | /**
16 | * Listen on provided port, on all network interfaces.
17 | */
18 |
19 | server.listen(port)
20 | server.on('error', onError)
21 | server.on('listening', onListening)
22 |
23 | function normalizePort(val) {
24 | const port = parseInt(val, 10)
25 |
26 | if (isNaN(port)) {
27 | return val
28 | }
29 |
30 | if (port >= 0) {
31 | return port
32 | }
33 |
34 | return false
35 | }
36 |
37 | function onError(error) {
38 | if (error.syscall !== 'listen') {
39 | throw error
40 | }
41 |
42 | const bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port
43 |
44 | // handle specific listen errors with friendly messages
45 | switch (error.code) {
46 | case 'EACCES':
47 | console.error(bind + ' requires elevated privileges')
48 | process.exit(1)
49 | break
50 | case 'EADDRINUSE':
51 | console.error(bind + ' is already in use')
52 | process.exit(1)
53 | break
54 | default:
55 | throw error
56 | }
57 | }
58 |
59 | /**
60 | * Event listener for HTTP server "listening" event.
61 | */
62 |
63 | function onListening() {
64 | const addr = server.address()
65 | const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port
66 | console.log('Listening on ' + bind)
67 | }
68 |
--------------------------------------------------------------------------------
/docs/architecture/README.md:
--------------------------------------------------------------------------------
1 | # 시스템 아키텍처
2 |
3 | ## 전체 아키텍처 개요
4 |
5 | ODevTube는 Node.js 기반의 백엔드와 웹 프론트엔드로 구성된 애플리케이션입니다. Docker를 통해 배포되며 GitHub Actions를 통한 CI/CD 파이프라인이 구성되어 있습니다.
6 |
7 | ```
8 | ┌─────────────┐
9 | │ GitHub │
10 | │ Actions │
11 | └──────┬──────┘
12 | │
13 | ▼
14 | ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐
15 | │ YouTube │◄───┤ Backend │◄───┤ Deployment │◄───┤ AWS EC2 │
16 | │ API │ │ (Node.js) │ │ (Docker) │ │ Server │
17 | └─────────────┘ └──────┬──────┘ └──────────────┘ └─────────────┘
18 | │
19 | ▼
20 | ┌─────────────┐
21 | │ Frontend │
22 | │ (Web) │
23 | └─────────────┘
24 | ```
25 |
26 | ## 컴포넌트 구성
27 |
28 | ### 백엔드 (Node.js)
29 | - YouTube API 연동 서비스
30 | - 데이터 처리 및 저장
31 | - REST API 제공
32 | - 크론 작업 스케줄링
33 |
34 | ### 프론트엔드 (Web)
35 | - 사용자 인터페이스
36 | - 콘텐츠 표시 및 상호작용
37 | - 반응형 디자인
38 |
39 | ### 인프라
40 | - Docker 컨테이너화
41 | - AWS EC2 호스팅
42 | - GitHub Actions CI/CD
43 |
44 | ## 데이터 흐름
45 |
46 | 1. 크론 작업을 통해 주기적으로 YouTube API에서 데이터 수집
47 | 2. 수집된 데이터 처리 및 저장
48 | 3. 웹 프론트엔드에서 API를 통해 데이터 요청
49 | 4. 사용자에게 처리된 데이터 표시
50 |
51 | ## 기술 스택
52 |
53 | - **백엔드**: Node.js
54 | - **프론트엔드**: JavaScript/HTML/CSS
55 | - **데이터베이스**: (프로젝트에 맞게 명시)
56 | - **배포**: Docker, GitHub Actions
57 | - **인프라**: AWS EC2
58 |
--------------------------------------------------------------------------------
/docker-compose/init.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE accounts (
2 | id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
3 | accountId VARCHAR(255) NOT NULL,
4 | username VARCHAR(255),
5 | email VARCHAR(255),
6 | photo VARCHAR(255),
7 | provider VARCHAR(255),
8 | createdAt DATETIME,
9 | updatedAt DATETIME
10 | );
11 |
12 | CREATE TABLE channels (
13 | id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
14 | channelId VARCHAR(255) UNIQUE NOT NULL,
15 | title VARCHAR(255),
16 | customUrl VARCHAR(255),
17 | thumbnail VARCHAR(255),
18 | category VARCHAR(255),
19 | lang VARCHAR(255),
20 | publishedAt DATETIME,
21 | createdAt DATETIME,
22 | updatedAt DATETIME
23 | );
24 |
25 | INSERT INTO channels
26 | (id, channelId, title, thumbnail, customUrl, lang, category, createdAt, updatedAt)
27 | VALUES(30, 'UCsvqVGtbbyHaMoevxPAq9Fg', 'Simplilearn', 'https://yt3.ggpht.com/r6M4Ex4bNj3_UuUpCRtEm8B_qAvl_n31BlNzj5Z1pxOlcE-JQFSddJltwLT6M7Qp7ROUEXCYeQ=s240-c-k-c0x00ffffff-no-rj', '@simplilearnofficial', 'en', 'dev', '2024-03-18 18:12:42.000', '2025-01-22 17:47:18.000');
28 |
29 | CREATE TABLE `videos` (
30 | `id` int(11) NOT NULL AUTO_INCREMENT,
31 | `title` varchar(255) DEFAULT NULL,
32 | `videoId` varchar(255) DEFAULT NULL,
33 | `thumbnail` varchar(255) DEFAULT NULL,
34 | `publishedAt` datetime DEFAULT NULL,
35 | `createdAt` datetime NOT NULL,
36 | `updatedAt` datetime NOT NULL,
37 | `channelId` int(11) DEFAULT NULL,
38 | PRIMARY KEY (`id`),
39 | UNIQUE KEY `videoId` (`videoId`),
40 | KEY `channelId` (`channelId`),
41 | CONSTRAINT `videos_ibfk_1` FOREIGN KEY (`channelId`) REFERENCES `channels` (`id`)
42 | );
43 | INSERT INTO videos
44 | (title, videoId, thumbnail, publishedAt, createdAt, updatedAt, channelId)
45 | VALUES('🔥Data Analyst Salary in 2025 #shorts #simplilearn', 'NhMfqhT_wOk', 'https://i.ytimg.com/vi/NhMfqhT_wOk/mqdefault.jpg', '2025-09-27 01:30:10.000', '2025-09-27 02:05:19.000', '2025-09-27 02:05:19.000', 30);
46 |
--------------------------------------------------------------------------------
/web/views/profile.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | User Profile
8 |
9 |
10 |
11 |
12 |
13 |
14 |
User Profile
15 |
16 |
ID: <%= user.id %>
17 |
Username: <%= user.username %>
18 |
Name: <%= user.displayName %>
19 | <% if (user.emails) { %>
20 |
Email: <%= user.emails[0].value %>
21 | <% } %>
22 |
23 |
Log Out
24 |
Home
25 |
Admin
26 |
27 |
28 |
73 |
74 |
75 |
76 |
--------------------------------------------------------------------------------
/docs/operation/README.md:
--------------------------------------------------------------------------------
1 | # 운영 가이드
2 |
3 | ## 시스템 모니터링
4 |
5 | ### 서버 상태 모니터링
6 |
7 | ODevTube 애플리케이션의 상태를 모니터링하기 위한 방법입니다:
8 |
9 | 1. Docker 컨테이너 상태 확인
10 | ```bash
11 | docker ps
12 | docker stats
13 | ```
14 |
15 | 2. 로그 확인
16 | ```bash
17 | docker logs -f odevtube
18 | ```
19 |
20 | 3. 시스템 리소스 모니터링
21 | ```bash
22 | htop
23 | df -h
24 | ```
25 |
26 | ## 백업 및 복구
27 |
28 | ### 데이터 백업
29 |
30 | 정기적인 데이터 백업을 위한 절차:
31 |
32 | 1. 데이터베이스 백업
33 | ```bash
34 | # 데이터베이스 백업 명령어 (사용 중인 DB에 맞게 수정)
35 | ```
36 |
37 | 2. 설정 파일 백업
38 | ```bash
39 | cp .env .env.backup
40 | cp docker-compose.yml docker-compose.yml.backup
41 | ```
42 |
43 | 3. 백업 파일 외부 저장소 전송
44 | ```bash
45 | # AWS S3, Google Drive 등에 백업 파일 전송
46 | ```
47 |
48 | ### 복구 절차
49 |
50 | 시스템 장애 발생 시 복구 절차:
51 |
52 | 1. 최신 백업에서 데이터 복원
53 | 2. 애플리케이션 재시작
54 | ```bash
55 | docker-compose down
56 | docker-compose up -d
57 | ```
58 |
59 | ## 정기 유지보수
60 |
61 | ### 업데이트 및 패치
62 |
63 | 1. 보안 업데이트 적용
64 | ```bash
65 | # 시스템 업데이트
66 | sudo apt update && sudo apt upgrade -y
67 |
68 | # Docker 이미지 업데이트
69 | docker-compose pull
70 | docker-compose up -d
71 | ```
72 |
73 | 2. 의존성 패키지 업데이트
74 | ```bash
75 | pnpm update
76 | ```
77 |
78 | ### 로그 관리
79 |
80 | 1. 로그 순환 설정
81 | ```bash
82 | # logrotate 설정 예시
83 | ```
84 |
85 | 2. 오래된 로그 정리
86 | ```bash
87 | find /path/to/logs -name "*.log" -mtime +30 -delete
88 | ```
89 |
90 | ## 장애 대응
91 |
92 | ### 일반적인 문제 해결
93 |
94 | 1. 애플리케이션이 응답하지 않는 경우
95 | ```bash
96 | # 컨테이너 재시작
97 | docker-compose restart
98 |
99 | # 로그 확인
100 | docker-compose logs -f
101 | ```
102 |
103 | 2. 메모리 부족 문제
104 | ```bash
105 | # 메모리 사용량 확인
106 | free -m
107 |
108 | # 캐시 정리
109 | echo 3 > /proc/sys/vm/drop_caches
110 | ```
111 |
112 | 3. 디스크 공간 부족
113 | ```bash
114 | # 디스크 사용량 확인
115 | df -h
116 |
117 | # 불필요한 파일 정리
118 | docker system prune -a
119 | ```
120 |
121 | ### 에스컬레이션 절차
122 |
123 | 1. 1차 대응: 시스템 관리자
124 | 2. 2차 대응: 개발팀
125 | 3. 3차 대응: 외부 전문가 지원
126 |
127 | ## 성능 최적화
128 |
129 | ### 성능 모니터링
130 |
131 | 1. 애플리케이션 성능 지표
132 | - 응답 시간
133 | - 처리량
134 | - 오류율
135 |
136 | 2. 시스템 성능 지표
137 | - CPU 사용률
138 | - 메모리 사용량
139 | - 디스크 I/O
140 | - 네트워크 트래픽
141 |
142 | ### 최적화 방안
143 |
144 | 1. 캐싱 전략
145 | 2. 데이터베이스 쿼리 최적화
146 | 3. 정적 자산 최적화
147 | 4. 로드 밸런싱 구성
148 |
149 | ## 보안 관리
150 |
151 | ### 보안 점검 항목
152 |
153 | 1. 정기적인 보안 업데이트 적용
154 | 2. 방화벽 규칙 검토
155 | 3. 액세스 로그 모니터링
156 | 4. 취약점 스캔 실행
157 |
158 | ### 보안 사고 대응
159 |
160 | 1. 사고 감지 및 보고
161 | 2. 영향 평가
162 | 3. 격리 및 복구
163 | 4. 사후 분석 및 예방 조치
164 |
165 | ## API 키 관리
166 |
167 | 1. YouTube API 키 관리
168 | - 정기적인 키 순환
169 | - 사용량 모니터링
170 | - 할당량 관리
171 |
172 | 2. 기타 외부 서비스 인증 정보 관리
173 |
--------------------------------------------------------------------------------
/web/app.js:
--------------------------------------------------------------------------------
1 | import createError from 'http-errors'
2 | import express from 'express'
3 | import path from 'path'
4 | import cookieParser from 'cookie-parser'
5 | import logger from 'morgan'
6 | import helmet from 'helmet'
7 | import { fileURLToPath } from 'url'
8 | import bodyParser from 'body-parser'
9 | import expressSession from 'express-session'
10 |
11 | const __filename = fileURLToPath(import.meta.url)
12 | const __dirname = path.dirname(__filename)
13 |
14 | const app = express()
15 | app.use(bodyParser.urlencoded({ extended: true }))
16 | app.use(
17 | expressSession({
18 | secret: 'github cat',
19 | resave: true,
20 | saveUninitialized: true,
21 | })
22 | )
23 |
24 | // view engine setup
25 | app.set('views', path.join(__dirname, 'views'))
26 | app.set('view engine', 'ejs')
27 | app.set('view options', { rmWhitespace: true })
28 |
29 | app.use(helmet.hidePoweredBy())
30 | app.use(logger('dev'))
31 | app.use(express.json())
32 | app.use(express.urlencoded({ extended: false }))
33 | app.use(cookieParser())
34 | app.use(express.static(path.join(__dirname, 'public')))
35 |
36 | import 'dotenv/config'
37 | import passport from 'passport'
38 | import GitHub from 'passport-github2'
39 | import dao from '../youtubeDao.js'
40 |
41 | try {
42 | passport.use(
43 | new GitHub.Strategy(
44 | {
45 | clientID: process.env['GITHUB_CLIENT_ID'],
46 | clientSecret: process.env['GITHUB_CLIENT_SECRET'],
47 | callbackURL: process.env['HOST']+'/login/github/return',
48 | },
49 | async function (accessToken, _refreshToken, profile, cb) {
50 | console.log('accessToken', accessToken)
51 | const account = {
52 | accountId: profile.id,
53 | username: profile.username,
54 | email: profile._json.email,
55 | photo: profile.photos[0].value,
56 | provider: profile.provider,
57 | }
58 | await dao.createAccount(account)
59 | return cb(null, profile)
60 | }
61 | )
62 | )
63 | } catch(e) {
64 | console.log(e)
65 | }
66 |
67 | passport.serializeUser(function (user, cb) {
68 | cb(null, user)
69 | })
70 |
71 | passport.deserializeUser(function (obj, cb) {
72 | cb(null, obj)
73 | })
74 |
75 | import indexRouter from './routes/index.js'
76 | app.use('/', indexRouter)
77 | import adminRouter from './routes/admin.js'
78 | app.use('/', adminRouter)
79 |
80 | // catch 404 and forward to error handler
81 | app.use(function (req, res, next) {
82 | next(createError(404))
83 | })
84 |
85 | // error handler
86 | app.use(function (err, req, res, next) {
87 | // set locals, only providing error in development
88 | res.locals.message = err.message
89 | res.locals.error = req.app.get('env') === 'development' ? err : {}
90 |
91 | res.status(err.status || 500)
92 | res.render('error')
93 | })
94 |
95 | export default app
96 |
--------------------------------------------------------------------------------
/web/public/css/profile.css:
--------------------------------------------------------------------------------
1 | /* 테마 설정 */
2 | :root {
3 | --bg-color: #f0f0f0;
4 | --container-bg: white;
5 | --text-color: #333;
6 | --text-secondary: #555;
7 | --border-color: #ddd;
8 | --border-light: #eee;
9 | --button-bg: #f0f0f0;
10 | --button-hover: #e0e0e0;
11 | }
12 |
13 | [data-theme="dark"] {
14 | --bg-color: #1a1a1a;
15 | --container-bg: #2d2d2d;
16 | --text-color: #e0e0e0;
17 | --text-secondary: #b0b0b0;
18 | --border-color: #444;
19 | --border-light: #555;
20 | --button-bg: #3d3d3d;
21 | --button-hover: #4d4d4d;
22 | }
23 |
24 | /* General styles */
25 | body {
26 | font-family: Arial, sans-serif;
27 | margin: 0;
28 | padding: 20px;
29 | background-color: var(--bg-color);
30 | color: var(--text-color);
31 | transition: background-color 0.3s ease, color 0.3s ease;
32 | }
33 |
34 | /* Container for profile information */
35 | .profile-container {
36 | background-color: var(--container-bg);
37 | border: 1px solid var(--border-color);
38 | border-radius: 4px;
39 | padding: 20px;
40 | max-width: 600px;
41 | margin: 0 auto;
42 | }
43 |
44 | /* Header styles */
45 | h1 {
46 | font-size: 24px;
47 | color: var(--text-color);
48 | margin-bottom: 20px;
49 | padding-bottom: 10px;
50 | border-bottom: 1px solid var(--border-light);
51 | }
52 |
53 | /* Profile information styles */
54 | .profile-info p {
55 | margin: 10px 0;
56 | font-size: 14px;
57 | color: var(--text-secondary);
58 | }
59 |
60 | /* Logout link styles */
61 | .logout-link {
62 | display: inline-block;
63 | margin-top: 20px;
64 | padding: 8px 16px;
65 | background-color: var(--button-bg);
66 | color: var(--text-color);
67 | text-decoration: none;
68 | border-radius: 4px;
69 | font-size: 14px;
70 | border: 1px solid var(--border-color);
71 | transition: background-color 0.3s;
72 | }
73 |
74 | .logout-link:hover {
75 | background-color: var(--button-hover);
76 | }
77 |
78 | /* 테마 토글 버튼 */
79 | .theme-toggle {
80 | position: fixed;
81 | right: 20px;
82 | top: 20px;
83 | z-index: 999;
84 | background: var(--button-bg);
85 | color: var(--text-color);
86 | border: 1px solid var(--border-color);
87 | padding: 6px 10px;
88 | cursor: pointer;
89 | border-radius: 4px;
90 | font-size: 13px;
91 | backdrop-filter: blur(10px);
92 | transition: all 0.2s ease;
93 | }
94 |
95 | .theme-toggle:hover {
96 | opacity: 0.8;
97 | transform: scale(1.05);
98 | }
99 |
100 | .theme-toggle:active {
101 | transform: scale(0.95);
102 | }
103 |
104 | /* Responsive design */
105 | @media (max-width: 768px) {
106 | body {
107 | padding: 15px;
108 | }
109 |
110 | .profile-container {
111 | padding: 15px;
112 | margin: 20px auto;
113 | }
114 |
115 | h1 {
116 | font-size: 20px;
117 | }
118 |
119 | .theme-toggle {
120 | right: 10px;
121 | top: 10px;
122 | padding: 5px 8px;
123 | font-size: 12px;
124 | }
125 |
126 | .profile-info p {
127 | font-size: 13px;
128 | }
129 |
130 | .logout-link {
131 | padding: 6px 12px;
132 | font-size: 13px;
133 | margin: 5px !important;
134 | display: inline-block;
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/docs/deployment/README.md:
--------------------------------------------------------------------------------
1 | # 배포 가이드
2 |
3 | ## 배포 환경
4 |
5 | ODevTube는 Docker를 사용하여 AWS EC2 인스턴스에 배포됩니다. GitHub Actions를 통한 CI/CD 파이프라인이 구성되어 있어 자동화된 빌드 및 배포가 가능합니다.
6 |
7 | ## 배포 아키텍처
8 |
9 | ```
10 | ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
11 | │ GitHub │────▶│ GitHub │────▶│ AWS EC2 │
12 | │ Repository │ │ Actions │ │ Server │
13 | └─────────────┘ └─────────────┘ └─────────────┘
14 | │
15 | ▼
16 | ┌─────────────┐
17 | │ Docker │
18 | │ Containers │
19 | └─────────────┘
20 | ```
21 |
22 | ## 필수 요구사항
23 |
24 | - AWS EC2 인스턴스
25 | - Docker 및 Docker Compose 설치
26 | - GitHub 저장소 접근 권한
27 | - AWS 접근 키 (IAM 사용자)
28 |
29 | ## 배포 프로세스
30 |
31 | ### 수동 배포
32 |
33 | 1. 서버에 SSH 접속
34 | ```bash
35 | ssh user@your-ec2-instance
36 | ```
37 |
38 | 2. 프로젝트 디렉토리로 이동
39 | ```bash
40 | cd /path/to/odevtube
41 | ```
42 |
43 | 3. 최신 코드 가져오기
44 | ```bash
45 | git pull origin main
46 | ```
47 |
48 | 4. Docker 컨테이너 빌드 및 실행
49 | ```bash
50 | docker-compose up -d --build
51 | ```
52 |
53 | ### GitHub Actions를 통한 자동 배포
54 |
55 | ODevTube는 GitHub Actions를 사용하여 자동 배포 파이프라인이 구성되어 있습니다. `main` 브랜치에 변경사항이 푸시되면 다음 과정이 자동으로 실행됩니다:
56 |
57 | 1. 코드 체크아웃
58 | 2. 의존성 설치
59 | 3. 테스트 실행
60 | 4. Docker 이미지 빌드
61 | 5. AWS EC2 서버에 배포
62 |
63 | ## 배포 설정 파일
64 |
65 | ### Dockerfile
66 | ```dockerfile
67 | FROM node:20-alpine
68 |
69 | WORKDIR /app
70 |
71 | COPY package.json pnpm-lock.yaml ./
72 | RUN npm install -g pnpm && pnpm install
73 |
74 | COPY . .
75 |
76 | EXPOSE 4000
77 |
78 | CMD ["pnpm", "start"]
79 | ```
80 |
81 | ### docker-compose.yml
82 | ```yaml
83 | version: '3'
84 | services:
85 | odevtube:
86 | build: .
87 | ports:
88 | - "4000:4000"
89 | environment:
90 | - NODE_ENV=production
91 | restart: always
92 | ```
93 |
94 | ### GitHub Actions 워크플로우 (.github/workflows/deploy.yml)
95 | ```yaml
96 | name: Deploy to EC2
97 |
98 | on:
99 | push:
100 | branches: [ main ]
101 |
102 | jobs:
103 | deploy:
104 | runs-on: ubuntu-latest
105 | steps:
106 | - uses: actions/checkout@v2
107 |
108 | - name: Setup Node.js
109 | uses: actions/setup-node@v2
110 | with:
111 | node-version: '20'
112 |
113 | - name: Install dependencies
114 | run: npm install -g pnpm && pnpm install
115 |
116 | - name: Run tests
117 | run: pnpm test
118 |
119 | - name: Deploy to EC2
120 | uses: appleboy/ssh-action@master
121 | with:
122 | host: ${{ secrets.EC2_HOST }}
123 | username: ${{ secrets.EC2_USERNAME }}
124 | key: ${{ secrets.EC2_PRIVATE_KEY }}
125 | script: |
126 | cd /path/to/odevtube
127 | git pull origin main
128 | docker-compose up -d --build
129 | ```
130 |
131 | ## 환경 변수 설정
132 |
133 | 프로덕션 환경에서는 다음 환경 변수를 설정해야 합니다:
134 |
135 | - `NODE_ENV`: 애플리케이션 환경 (production)
136 | - `PORT`: 애플리케이션 포트
137 | - `YOUTUBE_API_KEY`: YouTube API 키
138 | - 기타 필요한 환경 변수
139 |
140 | ## 모니터링 및 로깅
141 |
142 | - Docker 로그 확인:
143 | ```bash
144 | docker-compose logs -f
145 | ```
146 |
147 | - 애플리케이션 상태 확인:
148 | ```bash
149 | docker-compose ps
150 | ```
151 |
--------------------------------------------------------------------------------
/web/public/js/index.js:
--------------------------------------------------------------------------------
1 | let repo = []
2 | let repoAdded = []
3 | function search() {
4 | const keyword = keywordEl.value.toLowerCase()
5 | if (!repo.length) {
6 | repo = document.querySelectorAll('#list>li')
7 | }
8 | }
9 |
10 | function clearKeyword() {
11 | keywordEl.value = ''
12 | document.getElementById('channelLink').innerHTML = ''
13 | search()
14 | }
15 |
16 | function showChannel(name, customUrl) {
17 | keywordEl.value = name
18 | search()
19 | window.scrollTo(0, 0)
20 | const html = `➡️ ${name}`
21 | document.getElementById('channelLink').innerHTML = html
22 | wcs?.event('showChannel', name)
23 | gtag('event', 'level_end', {
24 | level_name: '시작됩니다...' + name,
25 | success: true,
26 | })
27 | }
28 |
29 | // global element
30 | let keywordEl
31 | window.onload = function () {
32 | keywordEl = document.getElementById('keyword')
33 | keywordEl.addEventListener('keyup', search)
34 |
35 | // whole page event listener escape keyup clean keyword
36 | document.addEventListener('keyup', function (e) {
37 | if (e.key === 'Escape') {
38 | clearKeyword()
39 | }
40 | })
41 | }
42 |
43 | function localData() {
44 | let videos = []
45 | const url = location.pathname + '?a=1'
46 | const dataKey = `data${location.pathname}/`.replace(/\/\//g, '/')
47 | fetch(url)
48 | .then((res) => res.json())
49 | .then((res) => {
50 | videos = res
51 | if (!localStorage) {
52 | return
53 | }
54 | localStorage?.setItem(dataKey, JSON.stringify({ list: videos }))
55 | const data = localStorage.getItem(dataKey)
56 | const json = JSON.parse(data)
57 | const list = json.list
58 | if (list.length === 0) {
59 | return
60 | }
61 | const lastLi = document.querySelector('#list>li:last-child')
62 | if (!lastLi) {
63 | return
64 | }
65 | const lastVideoId = lastLi.dataset.v
66 | const lastIndex = list.findIndex((v) => v.videoId === lastVideoId)
67 | const added = list.map((v, index) => {
68 | if (index <= lastIndex) {
69 | return ''
70 | }
71 | return `${v.title}`
72 | })
73 | document.getElementById('listAdded').innerHTML = added.join('')
74 | })
75 | }
76 |
77 | function openTranscript(videoId) {
78 | openModal()
79 | const url = '/transcript/' + videoId
80 | wcs?.event('transcript', videoId)
81 | fetch(url)
82 | .then((res) => res.json())
83 | .then((res) => {
84 | document.getElementById('modalContent').innerHTML =
85 | res.summary + '
' + res.text
86 | })
87 | .catch((err) => {
88 | console.log(err)
89 | document.getElementById('modalContent').innerHTML =
90 | '🤔 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'
91 | })
92 | }
93 |
94 | function shareTwitter(title, videoId) {
95 | const url = `https://youtu.be/${videoId}`;
96 | const text = encodeURIComponent(`${title} ${url}`);
97 | const twitterUrl = `https://twitter.com/intent/tweet?text=${text}`;
98 |
99 | window.open(twitterUrl, '_blank');
100 |
101 | if (typeof wcs !== 'undefined') {
102 | wcs.event('shareTwitter', videoId);
103 | }
104 | if (typeof gtag !== 'undefined') {
105 | gtag('event', 'share', {
106 | method: 'Twitter',
107 | content_type: 'video',
108 | item_id: videoId,
109 | });
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/web/views/admin/stats.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | 사용자 통계 관리
8 |
9 |
10 |
58 |
59 |
60 |
61 | <%- include('nav.ejs') %>
62 |
63 |
📊 사용자 통계 관리
64 |
65 |
66 | <% if (!user) { %>
67 | Welcome! Please log in.
68 | <% } else { %>
69 | Hello, <%= user.username %>. logout. profile.
70 | <% } %>
71 |
72 |
73 |
74 |
75 |
총 사용자
76 |
<%= totalUsers || 0 %>
77 |
명
78 |
79 |
80 |
81 |
총 비디오
82 |
<%= totalVideos || 0 %>
83 |
개
84 |
85 |
86 |
87 |
총 채널
88 |
<%= totalChannels || 0 %>
89 |
개
90 |
91 |
92 |
93 |
오늘 방문자
94 |
<%= todayVisitors || 0 %>
95 |
명
96 |
97 |
98 |
99 |
100 |
주간 활동 통계
101 |
최근 7일간의 활동 데이터가 여기에 표시됩니다.
102 |
103 |
104 |
105 |
106 |
카테고리별 비디오 분포
107 |
108 |
109 |
110 | | 카테고리 |
111 | 비디오 수 |
112 | 비율 |
113 |
114 |
115 |
116 | <% if (categoryStats && categoryStats.length > 0) {
117 | categoryStats.forEach(function(stat) { %>
118 |
119 | | <%= stat.category %> |
120 | <%= stat.count %> |
121 | <%= stat.percentage %>% |
122 |
123 | <% }); } else { %>
124 |
125 | | 데이터가 없습니다. |
126 |
127 | <% } %>
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
--------------------------------------------------------------------------------
/web/public/js/admin.js:
--------------------------------------------------------------------------------
1 | function createChannel() {
2 | const data = {
3 | channelId: document.getElementById('channelId').value,
4 | lang: document.getElementById('lang').value,
5 | category: document.getElementById('category').value,
6 | }
7 | fetch('/api/channel', {
8 | method: 'POST',
9 | headers: {
10 | 'Content-Type': 'application/json',
11 | },
12 | body: JSON.stringify(data),
13 | })
14 | .then((res) => res.json())
15 | .then((res) => {
16 | alert('success: ' + JSON.stringify(res))
17 | location.reload()
18 | })
19 | .catch((err) => {
20 | console.error(err)
21 | })
22 | }
23 |
24 | function sortTable(columnIndex) {
25 | const table = document.getElementById('channelTable')
26 | let switchCount = 0
27 | let switching = true
28 | let dir = 'asc'
29 |
30 | let rows = table.rows
31 | while (switching) {
32 | switching = false
33 |
34 | for (let i = 1; i < rows.length - 1; i++) {
35 | const currentRow = rows[i]
36 | const nextRow = rows[i + 1]
37 |
38 | const currentCell = currentRow.getElementsByTagName('TD')[columnIndex]
39 | const nextCell = nextRow.getElementsByTagName('TD')[columnIndex]
40 |
41 | let shouldSwitch = decideSwitch(currentCell, nextCell, columnIndex, dir)
42 |
43 | if (shouldSwitch) {
44 | rows[i].parentNode.insertBefore(rows[i + 1], rows[i])
45 | switching = true
46 | switchCount++
47 | }
48 | }
49 |
50 | if (switchCount === 0 && dir === 'asc') {
51 | dir = 'desc'
52 | switching = true
53 | }
54 | }
55 | }
56 |
57 | function decideSwitch(currentCell, nextCell, columnIndex, dir) {
58 | let currentValue = currentCell.innerHTML.toLowerCase()
59 | let nextValue = nextCell.innerHTML.toLowerCase()
60 | if (columnIndex === 3) {
61 | currentValue = +currentValue
62 | nextValue = +nextValue
63 | }
64 |
65 | let shouldSwitch = false
66 | if (dir === 'asc') {
67 | shouldSwitch = currentValue > nextValue
68 | } else {
69 | shouldSwitch = currentValue < nextValue
70 | }
71 | return shouldSwitch
72 | }
73 |
74 | // 슬라이딩 메뉴 토글 기능
75 | window.onload = function () {
76 | document.querySelector('.menu-toggle').addEventListener('click', function () {
77 | document.querySelector('.sliding-menu').classList.toggle('open')
78 | })
79 |
80 | // 저장된 테마 불러오기
81 | loadTheme()
82 | }
83 |
84 | // 테마 관리 함수
85 | function toggleTheme(event) {
86 | if (event) {
87 | event.preventDefault();
88 | event.stopPropagation();
89 | }
90 |
91 | const currentTheme = localStorage.getItem('theme') || 'light'
92 | let newTheme
93 |
94 | if (currentTheme === 'light') {
95 | newTheme = 'dark'
96 | } else {
97 | newTheme = 'light'
98 | }
99 |
100 | localStorage.setItem('theme', newTheme)
101 | applyTheme(newTheme)
102 | updateThemeButton(newTheme)
103 | }
104 |
105 | function applyTheme(theme) {
106 | if (theme === 'dark') {
107 | document.documentElement.setAttribute('data-theme', 'dark')
108 | } else {
109 | document.documentElement.setAttribute('data-theme', 'light')
110 | }
111 | }
112 |
113 | function updateThemeButton(theme) {
114 | const button = document.querySelector('.theme-toggle')
115 | if (button) {
116 | if (theme === 'light') {
117 | button.textContent = '☀️'
118 | } else {
119 | button.textContent = '🌙'
120 | }
121 | }
122 | }
123 |
124 | function loadTheme() {
125 | const savedTheme = localStorage.getItem('theme') || 'light'
126 | applyTheme(savedTheme)
127 | updateThemeButton(savedTheme)
128 | }
129 |
130 | function remove(videoId) {
131 | if (confirm('Are you sure?')) {
132 | fetch('/api/video', {
133 | method: 'DELETE',
134 | headers: {
135 | 'Content-Type': 'application/json',
136 | videoId,
137 | },
138 | }).then(function (response) {
139 | if (response.ok) {
140 | location.reload()
141 | } else {
142 | alert('Something went wrong, please try again later.')
143 | }
144 | })
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/web/views/admin/channel.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Admin Channel
8 |
9 |
10 |
11 |
12 |
13 | <%- include('nav.ejs') %>
14 | Admin Channel
15 |
16 | <% if (!user) { %>
17 | Welcome! Please log in.
18 | <% } else { %>
19 | Hello, <%= user.username %>. logout. profile.
20 | <% } %>
21 |
22 |
30 |
31 |
34 |
47 |
48 | Latest channels
49 |
96 |
97 |
101 |
102 |
103 |
104 |
105 |
106 |
--------------------------------------------------------------------------------
/web/views/index.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | <%- include('header.ejs') %>
5 |
6 |
7 |
8 |
20 |
38 |
39 | <%- include('search.ejs') %>
40 | <% if (uri === 'kpop' ) { %>
41 |
44 | <% } %>
45 |
46 |
47 | <% list.forEach(video => { %>
48 | <%- include('video.ejs', { video }) %>
49 | <% }) %>
50 |
51 |
52 |
53 |
72 | <%- include('aside.ejs') %>
73 |
74 |
75 |
76 |
77 |
78 | <%- include('footer.ejs') %>
79 |
80 |
125 |
126 |
127 |
--------------------------------------------------------------------------------
/docs/api/README.md:
--------------------------------------------------------------------------------
1 | # API 문서
2 |
3 | ## API 개요
4 |
5 | ODevTube는 YouTube 채널 및 동영상 데이터에 접근할 수 있는 RESTful API를 제공합니다. 이 문서는 API의 엔드포인트, 요청 및 응답 형식, 그리고 사용 예시를 설명합니다.
6 |
7 | ## 기본 URL
8 |
9 | ```
10 | https://api.odevtube.com/v1
11 | ```
12 |
13 | ## 인증
14 |
15 | API 요청에는 인증이 필요할 수 있습니다. 인증 방식은 다음과 같습니다:
16 |
17 | ```
18 | Authorization: Bearer
19 | ```
20 |
21 | ## 엔드포인트
22 |
23 | ### 채널 관련 API
24 |
25 | #### 채널 목록 조회
26 |
27 | ```
28 | GET /channels
29 | ```
30 |
31 | **요청 파라미터**
32 |
33 | | 파라미터 | 타입 | 필수 | 설명 |
34 | |----------|------|------|------|
35 | | page | integer | 아니오 | 페이지 번호 (기본값: 1) |
36 | | limit | integer | 아니오 | 페이지당 항목 수 (기본값: 20, 최대: 100) |
37 | | category | string | 아니오 | 카테고리별 필터링 |
38 |
39 | **응답 예시**
40 |
41 | ```json
42 | {
43 | "status": "success",
44 | "data": {
45 | "channels": [
46 | {
47 | "id": "UC-lHJZR3Gqxm24_Vd_AJ5Yw",
48 | "title": "Channel Name",
49 | "description": "Channel description",
50 | "thumbnailUrl": "https://example.com/thumbnail.jpg",
51 | "subscriberCount": 1000000,
52 | "videoCount": 500,
53 | "category": "programming"
54 | }
55 | ],
56 | "pagination": {
57 | "total": 150,
58 | "page": 1,
59 | "limit": 20,
60 | "pages": 8
61 | }
62 | }
63 | }
64 | ```
65 |
66 | #### 특정 채널 조회
67 |
68 | ```
69 | GET /channels/{channelId}
70 | ```
71 |
72 | **응답 예시**
73 |
74 | ```json
75 | {
76 | "status": "success",
77 | "data": {
78 | "channel": {
79 | "id": "UC-lHJZR3Gqxm24_Vd_AJ5Yw",
80 | "title": "Channel Name",
81 | "description": "Channel description",
82 | "thumbnailUrl": "https://example.com/thumbnail.jpg",
83 | "subscriberCount": 1000000,
84 | "videoCount": 500,
85 | "category": "programming",
86 | "createdAt": "2010-01-01T00:00:00Z",
87 | "updatedAt": "2023-01-01T00:00:00Z"
88 | }
89 | }
90 | }
91 | ```
92 |
93 | ### 동영상 관련 API
94 |
95 | #### 채널별 동영상 목록 조회
96 |
97 | ```
98 | GET /channels/{channelId}/videos
99 | ```
100 |
101 | **요청 파라미터**
102 |
103 | | 파라미터 | 타입 | 필수 | 설명 |
104 | |----------|------|------|------|
105 | | page | integer | 아니오 | 페이지 번호 (기본값: 1) |
106 | | limit | integer | 아니오 | 페이지당 항목 수 (기본값: 20, 최대: 100) |
107 | | sort | string | 아니오 | 정렬 기준 (publishedAt, viewCount) |
108 | | order | string | 아니오 | 정렬 순서 (asc, desc) |
109 |
110 | **응답 예시**
111 |
112 | ```json
113 | {
114 | "status": "success",
115 | "data": {
116 | "videos": [
117 | {
118 | "id": "dQw4w9WgXcQ",
119 | "title": "Video Title",
120 | "description": "Video description",
121 | "thumbnailUrl": "https://example.com/thumbnail.jpg",
122 | "publishedAt": "2023-01-01T00:00:00Z",
123 | "viewCount": 10000000,
124 | "likeCount": 500000,
125 | "duration": "PT4M20S"
126 | }
127 | ],
128 | "pagination": {
129 | "total": 500,
130 | "page": 1,
131 | "limit": 20,
132 | "pages": 25
133 | }
134 | }
135 | }
136 | ```
137 |
138 | #### 특정 동영상 조회
139 |
140 | ```
141 | GET /videos/{videoId}
142 | ```
143 |
144 | **응답 예시**
145 |
146 | ```json
147 | {
148 | "status": "success",
149 | "data": {
150 | "video": {
151 | "id": "dQw4w9WgXcQ",
152 | "channelId": "UC-lHJZR3Gqxm24_Vd_AJ5Yw",
153 | "title": "Video Title",
154 | "description": "Video description",
155 | "thumbnailUrl": "https://example.com/thumbnail.jpg",
156 | "publishedAt": "2023-01-01T00:00:00Z",
157 | "viewCount": 10000000,
158 | "likeCount": 500000,
159 | "commentCount": 100000,
160 | "duration": "PT4M20S",
161 | "tags": ["programming", "tutorial"]
162 | }
163 | }
164 | }
165 | ```
166 |
167 | ### 카테고리 관련 API
168 |
169 | #### 카테고리 목록 조회
170 |
171 | ```
172 | GET /categories
173 | ```
174 |
175 | **응답 예시**
176 |
177 | ```json
178 | {
179 | "status": "success",
180 | "data": {
181 | "categories": [
182 | {
183 | "id": "programming",
184 | "name": "Programming",
185 | "description": "Programming tutorials and guides",
186 | "channelCount": 50
187 | }
188 | ]
189 | }
190 | }
191 | ```
192 |
193 | ## 오류 응답
194 |
195 | API 요청이 실패하면 다음과 같은 형식의 오류 응답이 반환됩니다:
196 |
197 | ```json
198 | {
199 | "status": "error",
200 | "error": {
201 | "code": "NOT_FOUND",
202 | "message": "Resource not found"
203 | }
204 | }
205 | ```
206 |
207 | **오류 코드**
208 |
209 | | 코드 | 설명 |
210 | |------|------|
211 | | BAD_REQUEST | 잘못된 요청 파라미터 |
212 | | UNAUTHORIZED | 인증 실패 |
213 | | FORBIDDEN | 접근 권한 없음 |
214 | | NOT_FOUND | 리소스를 찾을 수 없음 |
215 | | INTERNAL_SERVER_ERROR | 서버 내부 오류 |
216 |
217 | ## 사용 예시
218 |
219 | ### cURL
220 |
221 | ```bash
222 | curl -X GET "https://api.odevtube.com/v1/channels?category=programming" \
223 | -H "Authorization: Bearer YOUR_API_KEY"
224 | ```
225 |
226 | ### JavaScript
227 |
228 | ```javascript
229 | fetch('https://api.odevtube.com/v1/channels?category=programming', {
230 | headers: {
231 | 'Authorization': 'Bearer YOUR_API_KEY'
232 | }
233 | })
234 | .then(response => response.json())
235 | .then(data => console.log(data))
236 | .catch(error => console.error('Error:', error));
237 | ```
238 |
--------------------------------------------------------------------------------
/web/views/admin/video.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Admin Video1
8 |
9 |
10 |
43 |
44 |
45 |
46 | <%- include('nav.ejs') %>
47 | Admin Video
48 |
49 | <% if (!user) { %>
50 | Welcome! Please log in.
51 | <% } else { %>
52 | Hello, <%= user.username %>. logout. profile.
53 | <% } %>
54 |
55 |
63 |
64 |
67 |
68 |
69 |
70 |
71 |
72 | Latest videos
73 |
106 |
107 |
131 |
132 |
133 |
147 |
148 |
149 |
150 |
--------------------------------------------------------------------------------
/web/routes/admin.js:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import dao from '../../youtubeDao.js'
3 | import capi from '../../services/channel.js'
4 | import vapi from '../../services/video.js'
5 | import dayjs from 'dayjs'
6 | import passport from 'passport'
7 | import util from '../utils/uri.js'
8 |
9 | const router = express.Router()
10 | router.use(passport.initialize())
11 | router.use(passport.session())
12 |
13 | router.get('/admin', async function (req, res, next) {
14 | const channelId = req.query.channel;
15 | const channelQuery = req.query.q;
16 | const category = req.query.c
17 | const lang = req.query.l
18 | let page = +req.query.p
19 | if (!page) {
20 | page = 1
21 | }
22 | const pageSize = 60
23 | const whereClause = {
24 | category,
25 | lang,
26 | page,
27 | pageSize: pageSize,
28 | channelId,
29 | channelQuery,
30 | }
31 | const data = await dao.getPagedVideos(whereClause)
32 | const videos = data.rows
33 | videos.forEach((v) => {
34 | v.pubdate = dayjs(v.publishedAt).format('MM-DD HH:mm:ss')
35 | v.credate = dayjs(v.createdAt).format('MM-DD HH:mm:ss')
36 | v.uri = util.getUri(v.Channel.category, v.Channel.lang)
37 | })
38 | const maxVisiblePages = 7
39 | const totalPages = Math.ceil(data.count / pageSize)
40 | let startPage = Math.max(1, page - Math.floor(maxVisiblePages / 2))
41 | let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1)
42 | const area = `c=${category || 'dev'}&l=${lang || 'ko'}` + '&'
43 | const channel = channelId ? `channel=${channelId}&` : ''
44 | const query = channelQuery ? `q=${channelQuery}&` : ''
45 | res.render('admin/video', {
46 | videos,
47 | user: req.user,
48 | area,
49 | channel,
50 | query,
51 | currentPage: page,
52 | totalPages,
53 | startPage,
54 | endPage,
55 | maxVisiblePages,
56 | })
57 | })
58 |
59 | router.get('/admin/channel', async function (req, res, next) {
60 | const channelList = await dao.findAllChannelList()
61 | channelList.forEach((item) => {
62 | item.credate = dayjs(item.createdAt).format('MM-DD')
63 | item.pubdate = dayjs(item.publishedAt).format('YYYY-MM-DD')
64 | item.uri = util.getUri(item.category, item.lang)
65 | })
66 | res.render('admin/channel', {
67 | channels: channelList,
68 | user: req.user,
69 | })
70 | })
71 |
72 | // 사용자 통계 관리 페이지
73 | router.get('/admin/stats', async function (req, res, next) {
74 | // 실제 데이터는 DB에서 가져와야 함
75 | const stats = {
76 | totalUsers: 150,
77 | totalVideos: await dao.getVideosCount() || 1234,
78 | totalChannels: await dao.getChannelsCount() || 56,
79 | todayVisitors: 45,
80 | categoryStats: [
81 | { category: 'dev', count: 800, percentage: 64.8 },
82 | { category: 'kpop', count: 300, percentage: 24.3 },
83 | { category: 'food', count: 134, percentage: 10.9 }
84 | ]
85 | }
86 |
87 | res.render('admin/stats', {
88 | user: req.user,
89 | ...stats
90 | })
91 | })
92 |
93 | // 보안 설정 관리 페이지
94 | router.get('/admin/security', function (req, res, next) {
95 | // 샘플 데이터
96 | const securityData = {
97 | whitelistedIPs: ['127.0.0.1', '192.168.1.100'],
98 | apiKeys: [
99 | { id: 1, key: 'sk_live_***************', createdAt: '2024-01-15' },
100 | { id: 2, key: 'sk_test_***************', createdAt: '2024-02-20' }
101 | ]
102 | }
103 |
104 | res.render('admin/security', {
105 | user: req.user,
106 | ...securityData
107 | })
108 | })
109 |
110 | // 로그 조회 페이지
111 | router.get('/admin/logs', function (req, res, next) {
112 | const { level, source, start, end } = req.query
113 |
114 | // 샘플 로그 데이터
115 | const sampleLogs = [
116 | {
117 | id: 1,
118 | timestamp: dayjs().subtract(1, 'hour').format('YYYY-MM-DD HH:mm:ss'),
119 | level: 'info',
120 | source: 'api',
121 | user: 'admin',
122 | message: 'API 호출 성공: GET /admin/channel'
123 | },
124 | {
125 | id: 2,
126 | timestamp: dayjs().subtract(2, 'hour').format('YYYY-MM-DD HH:mm:ss'),
127 | level: 'warning',
128 | source: 'auth',
129 | user: 'user123',
130 | message: '로그인 실패 시도 감지'
131 | },
132 |
133 | ]
134 |
135 | res.render('admin/logs', {
136 | user: req.user,
137 | logs: sampleLogs
138 | })
139 | })
140 |
141 | // 시스템 설정 페이지
142 | router.get('/admin/settings', function (req, res, next) {
143 | res.render('admin/settings', {
144 | user: req.user,
145 | process: process
146 | })
147 | })
148 |
149 | router.delete('/api/video', auth, async function (req, res, next) {
150 | const videoId = req.headers.videoid
151 | const result = await vapi.remove(videoId)
152 | res.json(result)
153 | })
154 |
155 | router.post('/api/channel', async function (req, res, next) {
156 | const channelId = req.body.channelId
157 | let channel
158 | if (channelId.indexOf('@') === 0) {
159 | channel = await capi.findChannelInfo(channelId)
160 | } else {
161 | channel = await capi.getChannelInfo(channelId)
162 | }
163 | channel = {
164 | ...req.body,
165 | ...channel,
166 | }
167 |
168 | const result = await dao.create(channel)
169 | await addVideos(channel.channelId)
170 | res.json(result.dataValues)
171 | })
172 |
173 | async function addVideos(channelId) {
174 | const videos = await vapi.getLatestVideos(channelId)
175 | await videos
176 | .map((item) => item.channelId)
177 | .forEach(async (channelId) => {
178 | vapi.addVideos(channelId)
179 | })
180 | }
181 |
182 | function auth(req, res, next) {
183 | if (req.user) {
184 | next()
185 | } else {
186 | res.redirect('/login')
187 | }
188 | }
189 |
190 | export default router
191 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | * Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | kenu@okdevtv.com.
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
122 | enforcement ladder](https://github.com/mozilla/diversity).
123 |
124 | [homepage]: https://www.contributor-covenant.org
125 |
126 | For answers to common questions about this code of conduct, see the FAQ at
127 | https://www.contributor-covenant.org/faq. Translations are available at
128 | https://www.contributor-covenant.org/translations.
129 |
--------------------------------------------------------------------------------
/tests/account.test.js:
--------------------------------------------------------------------------------
1 | import dao from '../youtubeDao.js'
2 | // github profile 저장
3 | // login update
4 | // 탈퇴
5 | // github login data
6 |
7 | const githubDataString = { id: '733631',
8 | nodeId: 'MDQ6pXNlcjcxODY5MQ==',
9 | displayName: 'kenu',
10 | username: 'kenu',
11 | profileUrl: 'https://github.com/kenu',
12 | emails: [ { value: 'kenu.heo@gmail.com' } ],
13 | photos: [ { value: 'https://avatars.githubusercontent.com/u/733631?v=4' } ],
14 | provider: 'github',
15 | _raw: '{"login":"kenu","id":733631,"node_id":"MDQ6pXNlcjcxODY5MQ==","avatar_url":"https://avatars.githubusercontent.com/u/733631?v=4","gravatar_id":"","url":"https://api.github.com/users/kenu","html_url":"https://github.com/kenu","followers_url":"https://api.github.com/users/kenu/followers","following_url":"https://api.github.com/users/kenu/following{/other_user}","gists_url":"https://api.github.com/users/kenu/gists{/gist_id}","starred_url":"https://api.github.com/users/kenu/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/kenu/subscriptions","organizations_url":"https://api.github.com/users/kenu/orgs","repos_url":"https://api.github.com/users/kenu/repos","events_url":"https://api.github.com/users/kenu/events{/privacy}","received_events_url":"https://api.github.com/users/kenu/received_events","type":"User","site_admin":false,"name":"kenu","company":"OKKY(OKJSP), OKdevTV","blog":"https://okdevtv.com","location":"seoul","email":"kenu.heo@gmail.com","hireable":true,"bio":"https://okdevtv.com\\r\\nhttps://youtube.com/@KenuHeo\\r\\nhttps://okky.kr\\r\\n","twitter_username":"kenu0000","public_repos":261,"public_gists":17,"followers":654,"following":334,"created_at":"2011-04-09T03:22:05Z","updated_at":"2024-04-14T05:25:00Z"}',
16 | _json: {
17 | login: 'kenu',
18 | id: 733631,
19 | node_id: 'MDQ6pXNlcjcxODY5MQ==',
20 | avatar_url: 'https://avatars.githubusercontent.com/u/733631?v=4',
21 | gravatar_id: '',
22 | url: 'https://api.github.com/users/kenu',
23 | html_url: 'https://github.com/kenu',
24 | followers_url: 'https://api.github.com/users/kenu/followers',
25 | following_url: 'https://api.github.com/users/kenu/following{/other_user}',
26 | gists_url: 'https://api.github.com/users/kenu/gists{/gist_id}',
27 | starred_url: 'https://api.github.com/users/kenu/starred{/owner}{/repo}',
28 | subscriptions_url: 'https://api.github.com/users/kenu/subscriptions',
29 | organizations_url: 'https://api.github.com/users/kenu/orgs',
30 | repos_url: 'https://api.github.com/users/kenu/repos',
31 | events_url: 'https://api.github.com/users/kenu/events{/privacy}',
32 | received_events_url: 'https://api.github.com/users/kenu/received_events',
33 | type: 'User',
34 | site_admin: false,
35 | name: 'kenu',
36 | company: 'OKKY(OKJSP), OKdevTV',
37 | blog: 'https://okdevtv.com',
38 | location: 'seoul',
39 | email: 'kenu.heo@gmail.com',
40 | hireable: true,
41 | bio: 'https://okdevtv.com\r\nhttps://youtube.com/@KenuHeo\r\nhttps://okky.kr\r\n',
42 | twitter_username: 'kenu0000',
43 | public_repos: 261,
44 | public_gists: 17,
45 | followers: 654,
46 | following: 334,
47 | created_at: '2011-04-09T03:22:05Z',
48 | updated_at: '2024-04-14T05:25:00Z'
49 | }
50 | }
51 |
52 | const githubDataString2 = {
53 | id: '190916919',
54 | nodeId: 'U_kgDOC8kAVw',
55 | displayName: null,
56 | username: 'kenuheo',
57 | profileUrl: 'https://github.com/kenuheo',
58 | photos: [
59 | { value: 'https://avatars.githubusercontent.com/u/190916919?v=4' }
60 | ],
61 | provider: 'github',
62 | _raw: '{"login":"kenuheo","id":190916919,"node_id":"U_kgDOC8kAVw","avatar_url":"https://avatars.githubusercontent.com/u/190916919?v=4","gravatar_id":"","url":"https://api.github.com/users/kenuheo","html_url":"https://github.com/kenuheo","followers_url":"https://api.github.com/users/kenuheo/followers","following_url":"https://api.github.com/users/kenuheo/following{/other_user}","gists_url":"https://api.github.com/users/kenuheo/gists{/gist_id}","starred_url":"https://api.github.com/users/kenuheo/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/kenuheo/subscriptions","organizations_url":"https://api.github.com/users/kenuheo/orgs","repos_url":"https://api.github.com/users/kenuheo/repos","events_url":"https://api.github.com/users/kenuheo/events{/privacy}","received_events_url":"https://api.github.com/users/kenuheo/received_events","type":"User","site_admin":false,"name":null,"company":null,"blog":"","location":null,"email":null,"hireable":null,"bio":null,"twitter_username":null,"public_repos":3,"public_gists":0,"followers":5,"following":18,"created_at":"2023-04-13T00:03:29Z","updated_at":"2024-04-14T20:26:08Z"}',
63 | _json: {
64 | login: 'kenuheo',
65 | id: 190916919,
66 | node_id: 'U_kgDOC8kAVw',
67 | avatar_url: 'https://avatars.githubusercontent.com/u/190916919?v=4',
68 | gravatar_id: '',
69 | url: 'https://api.github.com/users/kenuheo',
70 | html_url: 'https://github.com/kenuheo',
71 | followers_url: 'https://api.github.com/users/kenuheo/followers',
72 | following_url: 'https://api.github.com/users/kenuheo/following{/other_user}',
73 | gists_url: 'https://api.github.com/users/kenuheo/gists{/gist_id}',
74 | starred_url: 'https://api.github.com/users/kenuheo/starred{/owner}{/repo}',
75 | subscriptions_url: 'https://api.github.com/users/kenuheo/subscriptions',
76 | organizations_url: 'https://api.github.com/users/kenuheo/orgs',
77 | repos_url: 'https://api.github.com/users/kenuheo/repos',
78 | events_url: 'https://api.github.com/users/kenuheo/events{/privacy}',
79 | received_events_url: 'https://api.github.com/users/kenuheo/received_events',
80 | type: 'User',
81 | site_admin: false,
82 | name: null,
83 | company: null,
84 | blog: '',
85 | location: null,
86 | email: null,
87 | hireable: null,
88 | bio: null,
89 | twitter_username: null,
90 | public_repos: 3,
91 | public_gists: 0,
92 | followers: 5,
93 | following: 18,
94 | created_at: '2023-04-13T00:03:29Z',
95 | updated_at: '2024-04-14T20:26:08Z'
96 | }
97 | }
98 |
99 | test('github login', async () => {
100 | const data = githubDataString2
101 | const account = {
102 | accountId: data.id,
103 | username: data.username,
104 | email: data._json.email,
105 | photo: data.photos[0].value,
106 | provider: data.provider,
107 | }
108 | const acnt = await dao.createAccount(account)
109 | console.log(acnt)
110 | })
111 |
--------------------------------------------------------------------------------
/web/views/statistics.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 | <%- include('header.ejs') %>
4 |
5 |
6 |
18 |
39 |
40 |
41 |
비디오 통계
42 |
43 |
44 |
45 |
48 |
49 |
50 |
51 |
52 |
<%= stats.totalVideos.toLocaleString() %>
53 |
전체 비디오 수
54 |
55 |
56 |
57 |
58 |
<%= stats.totalChannels %>
59 |
채널 수
60 |
61 |
62 |
63 |
64 |
<%= stats.avgVideosPerChannel.toFixed(1) %>
65 |
채널당 평균 비디오
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
81 |
82 |
83 |
91 |
92 |
93 |
94 |
97 |
98 |
99 |
100 |
101 |
102 | | 순위 |
103 | 채널 |
104 | 비디오 수 |
105 | 점유율 |
106 |
107 |
108 |
109 | <% stats.topChannels.forEach((channel, index) => { %>
110 |
111 | | <%= index + 1 %> |
112 | <%= channel.customUrl %> |
113 | <%= channel.video_count.toLocaleString() %> |
114 |
115 |
116 |
121 | <%= (channel.video_count / stats.totalVideos * 100).toFixed(1) %>%
122 |
123 |
124 | |
125 |
126 | <% }) %>
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 | <%- include('footer.ejs') %>
135 |
136 |
137 |
186 |
187 |
213 |
214 |
215 |
--------------------------------------------------------------------------------
/web/routes/index.js:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import dayjs from 'dayjs'
3 | import passport from 'passport'
4 | import { getFullText } from '../utils/transcriptUtil.js'
5 | import dao from '../../youtubeDao.js'
6 |
7 | // Add a counter to track page calls
8 | let pageCallCounter = 0;
9 |
10 | const router = express.Router()
11 | router.use(passport.initialize())
12 | router.use(passport.session())
13 |
14 | router.get('/', async function (req, res, _next) {
15 | const uri = 'dev'
16 | const title = '개발 관련 유튜브'
17 | const isApi = req.query.a === '1'
18 | const page = parseInt(req.query.page) || 1
19 | await goRenderPage(req, res, uri, '', title, isApi, page)
20 | })
21 |
22 | router.get('/en', async function (req, res, _next) {
23 | const uri = 'dev'
24 | const title = 'YouTube for Developers'
25 | const lang = 'en'
26 | const isApi = req.query.a === '1'
27 | const page = parseInt(req.query.page) || 1
28 | await goRenderPage(req, res, uri, lang, title, isApi, page)
29 | })
30 |
31 | router.get('/food', async function (req, res, _next) {
32 | const uri = 'food'
33 | const title = '요리 관련 유튜브'
34 | const isApi = req.query.a === '1'
35 | const page = parseInt(req.query.page) || 1
36 | await goRenderPage(req, res, uri, '', title, isApi, page)
37 | })
38 |
39 | router.get('/kpop', async function (req, res, _next) {
40 | const uri = 'kpop'
41 | const title = 'K-POP YouTube Videos'
42 | const isApi = req.query.a === '1'
43 | const page = parseInt(req.query.page) || 1
44 | await goRenderPage(req, res, uri, '', title, isApi, page)
45 | })
46 |
47 | async function goRenderPage(
48 | req,
49 | res,
50 | uri,
51 | lang,
52 | title,
53 | isApi = false,
54 | page
55 | ) {
56 | // Increment the page call counter
57 | pageCallCounter++;
58 |
59 | // Check if garbage collection should be triggered
60 | if (pageCallCounter >= 10) {
61 | if (global.gc) {
62 | console.log('Triggering garbage collection after 10 page calls');
63 | global.gc();
64 | } else {
65 | console.log('Manual garbage collection not available. Run with --expose-gc flag to enable.');
66 | }
67 | // Reset the counter
68 | pageCallCounter = 0;
69 | }
70 |
71 | const locale = lang === 'en' ? 'en_US' : 'ko_KR';
72 | const stime = Date.now();
73 | const pageSize = 60;
74 | const searchKeyword = req.query.search || '';
75 |
76 | const data = await dao.getPagedVideosWithSearch({
77 | category: uri,
78 | lang,
79 | page,
80 | pageSize,
81 | searchKeyword,
82 | });
83 |
84 | const etime = Date.now();
85 | console.log('elapsed time: ', etime - stime);
86 | const user = req.user;
87 | building(data.rows);
88 | const totalPages = Math.ceil(data.count / pageSize);
89 |
90 | if (isApi) {
91 | res.json(data.rows);
92 | } else {
93 | res.render('index', {
94 | title,
95 | list: data.rows,
96 | totalCount: data.count,
97 | locale,
98 | uri,
99 | user,
100 | currentPage: page,
101 | totalPages,
102 | pageSize,
103 | searchKeyword, // Pass the search keyword to the view
104 | });
105 | }
106 | }
107 |
108 | function building(list) {
109 | list.forEach((item) => {
110 | item.pubdate = dayjs(item.publishedAt).format('YYYY-MM-DD')
111 | item.profile = item.Channel.dataValues.thumbnail
112 | item.channame = item.Channel.dataValues.title
113 | item.customUrl = item.Channel.dataValues.customUrl
114 | delete item.dataValues.Channel
115 | delete item.dataValues.ChannelId
116 | delete item.dataValues.createdAt
117 | delete item.dataValues.updatedAt
118 | })
119 | }
120 |
121 | import summarize from '../utils/summary.js'
122 | router.get('/transcript/:videoId', async function (req, res, next) {
123 | const videoId = req.params.videoId
124 | // find by videoId
125 | const item = await dao.findTranscriptByVideoId(videoId)
126 | if (item) {
127 | res.json({ videoId, summary: item.summary, text: item.content })
128 | return
129 | }
130 | await upsertTranscript(res, videoId)
131 | })
132 |
133 | async function upsertTranscript(res, videoId) {
134 | try {
135 | let fullText = await getFullText(videoId)
136 | const cmd = "3줄 단문에, 명사형 어미로 요약(예)'있습니다.' 대신 '있음', '설명드립니다' 대신 '설명함' :\n"
137 | const messages = [
138 | {
139 | role: 'user',
140 | content: cmd + fullText,
141 | },
142 | ]
143 | const summary = await summarize(messages)
144 | await dao.createTranscript({
145 | videoId,
146 | content: fullText,
147 | summary: summary,
148 | })
149 | res.json({ videoId, summary, text: fullText })
150 | } catch (error) {
151 | console.error(error)
152 | res.json({ videoId, summary: '', text: 'Not Available ' + error.message })
153 | }
154 | }
155 |
156 | router.get('/login', function (req, res) {
157 | res.render('login')
158 | })
159 |
160 | router.get('/login/github', passport.authenticate('github'))
161 |
162 | router.get(
163 | '/login/github/return',
164 | passport.authenticate('github', { failureRedirect: '/login' }),
165 | function (req, res) {
166 | let prevSession = req.session
167 | req.session.regenerate((err) => {
168 | Object.assign(req.session, prevSession)
169 | res.redirect('/admin')
170 | })
171 | }
172 | )
173 | router.get('/home', function (req, res) {
174 | res.render('home', { user: req.user })
175 | })
176 |
177 | import connectEnsureLogin from 'connect-ensure-login'
178 | router.get(
179 | '/profile',
180 | connectEnsureLogin.ensureLoggedIn(),
181 | function (req, res) {
182 | res.render('profile', { user: req.user })
183 | }
184 | )
185 |
186 | router.get('/logout', function (req, res, next) {
187 | req.logout((err) => {
188 | if (err) {
189 | return next(err)
190 | }
191 | res.redirect('/')
192 | })
193 | })
194 |
195 | router.get('/statistics', async function (req, res, next) {
196 | try {
197 | // Get total videos count
198 | const totalVideos = (await dao.getVideosCount()) || 0;
199 |
200 | // Get unique channels count
201 | const totalChannels = (await dao.getChannelsCount()) || 0;
202 |
203 | // Calculate average videos per channel
204 | const avgVideosPerChannel = totalChannels > 0 ? totalVideos / totalChannels : 0;
205 |
206 | // Get yearly stats
207 | const yearlyStats = await dao.getYearlyVideoStats();
208 |
209 | // Get monthly stats (last 12 months)
210 | const monthlyStats = await dao.getMonthlyVideoStats(12);
211 |
212 | // Get top channels
213 | const topChannels = await dao.getTopChannels(10);
214 |
215 | res.render('statistics', {
216 | user: req.user,
217 | stats: {
218 | totalVideos,
219 | totalChannels,
220 | avgVideosPerChannel,
221 | yearlyStats,
222 | monthlyStats,
223 | topChannels
224 | }
225 | });
226 | } catch (error) {
227 | console.error('Error fetching statistics:', error);
228 | next(error);
229 | }
230 | });
231 |
232 | export default router
233 |
--------------------------------------------------------------------------------
/web/views/admin/security.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | 보안 설정 관리
8 |
9 |
10 |
92 |
93 |
94 |
95 | <%- include('nav.ejs') %>
96 |
97 |
🔐 보안 설정 관리
98 |
99 |
100 | <% if (!user) { %>
101 | Welcome! Please log in.
102 | <% } else { %>
103 | Hello, <%= user.username %>. logout. profile.
104 | <% } %>
105 |
106 |
107 |
108 |
세션 설정
109 |
110 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
IP 화이트리스트
124 |
125 |
126 |
127 |
128 |
129 |
130 |
등록된 IP 목록
131 |
132 | <% if (whitelistedIPs && whitelistedIPs.length > 0) {
133 | whitelistedIPs.forEach(function(ip) { %>
134 | -
135 | <%= ip %>
136 |
137 |
138 | <% }); } else { %>
139 | - 등록된 IP가 없습니다.
140 | <% } %>
141 |
142 |
143 |
144 |
145 |
API 키 관리
146 |
147 |
151 |
152 |
153 |
154 |
활성 API 키
155 |
156 | <% if (apiKeys && apiKeys.length > 0) {
157 | apiKeys.forEach(function(key) { %>
158 | -
159 | <%= key.key %> (생성일: <%= key.createdAt %>)
160 |
161 |
162 | <% }); } else { %>
163 | - 활성화된 API 키가 없습니다.
164 | <% } %>
165 |
166 |
167 |
168 |
169 |
보안 정책
170 |
171 |
175 |
176 |
177 |
181 |
182 |
183 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
236 |
237 |
238 |
239 |
--------------------------------------------------------------------------------
/web/views/admin/logs.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | 로그 조회
8 |
9 |
10 |
148 |
149 |
150 |
151 | <%- include('nav.ejs') %>
152 |
153 |
📝 로그 조회
154 |
155 |
156 | <% if (!user) { %>
157 | Welcome! Please log in.
158 | <% } else { %>
159 | Hello, <%= user.username %>. logout. profile.
160 | <% } %>
161 |
162 |
163 |
164 |
필터
165 |
166 |
167 |
168 |
175 |
176 |
177 |
178 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
로그 목록 (총 <%= logs ? logs.length : 0 %>건)
203 |
204 |
205 |
206 | | 시간 |
207 | 레벨 |
208 | 소스 |
209 | 사용자 |
210 | 메시지 |
211 | 상세 |
212 |
213 |
214 |
215 | <% if (logs && logs.length > 0) {
216 | logs.forEach(function(log) { %>
217 |
218 | | <%= log.timestamp %> |
219 | <%= log.level %> |
220 | <%= log.source %> |
221 | <%= log.user || 'N/A' %> |
222 | <%= log.message %> |
223 | |
224 |
225 | <% }); } else { %>
226 |
227 | | 로그 데이터가 없습니다. |
228 |
229 | <% } %>
230 |
231 |
232 |
233 |
234 |
235 |
236 |
267 |
268 |
269 |
270 |
--------------------------------------------------------------------------------
/youtubeDao.js:
--------------------------------------------------------------------------------
1 | import { Sequelize, DataTypes } from 'sequelize'
2 | const sequelize = new Sequelize(
3 | process.env.YOUDB_NAME || 'odevtube',
4 | process.env.YOUDB_USER || 'devuser',
5 | process.env.YOUDB_PASS || 'devpass',
6 | {
7 | host: 'localhost',
8 | dialect: 'mariadb',
9 | timezone: 'Asia/Seoul',
10 | logging: true,
11 | }
12 | )
13 |
14 | const Channel = sequelize.define('Channel', {
15 | channelId: { type: DataTypes.STRING, unique: true },
16 | title: DataTypes.STRING,
17 | thumbnail: DataTypes.STRING,
18 | customUrl: DataTypes.STRING,
19 | lang: DataTypes.STRING(2),
20 | category: DataTypes.STRING,
21 | })
22 |
23 | const Video = sequelize.define('Video', {
24 | title: DataTypes.STRING,
25 | videoId: { type: DataTypes.STRING, unique: true },
26 | thumbnail: DataTypes.STRING,
27 | publishedAt: DataTypes.DATE,
28 | })
29 |
30 | const Transcript = sequelize.define('Transcript', {
31 | videoId: {
32 | type: DataTypes.STRING,
33 | references: {
34 | model: Video,
35 | key: 'videoId',
36 | },
37 | },
38 | content: DataTypes.TEXT,
39 | summary: DataTypes.TEXT,
40 | })
41 |
42 | Channel.hasMany(Video)
43 | Video.belongsTo(Channel)
44 |
45 | Transcript.belongsTo(Video, { as: 'video', foreignKey: 'videoId' })
46 | Video.hasOne(Transcript, { as: 'transcripts', foreignKey: 'videoId' })
47 |
48 | const Account = sequelize.define('Account', {
49 | accountId: { type: DataTypes.STRING, unique: true },
50 | username: DataTypes.STRING,
51 | email: DataTypes.STRING,
52 | photo: DataTypes.STRING,
53 | provider: DataTypes.STRING,
54 | })
55 |
56 | ;(async () => {
57 | await sequelize.sync()
58 | })()
59 |
60 | async function create(data) {
61 | console.log(data, 'created')
62 | await Channel.upsert(data)
63 | return await Channel.findOne({ where: { channelId: data.channelId } })
64 | }
65 |
66 | async function findAll() {
67 | return await Channel.findAll({
68 | order: [['publishedAt', 'DESC']],
69 | })
70 | }
71 |
72 | async function findAllEmpty() {
73 | return await Channel.findAll({
74 | where: { title: null },
75 | })
76 | }
77 |
78 | async function createVideo(data) {
79 | if (!data.videoId) {
80 | return
81 | }
82 | try {
83 | const [video, created] = await Video.findOrCreate({
84 | where: { videoId: data.videoId },
85 | defaults: data,
86 | })
87 | return video
88 | } catch (error) {
89 | if (error.original?.errno === 1062) {
90 | // 중복 키 에러는 무시 (이미 존재하는 비디오)
91 | return null
92 | }
93 | throw error
94 | }
95 | }
96 |
97 | async function removeVideo(videoId) {
98 | const one = await Video.findOne({
99 | where: { videoId: videoId },
100 | })
101 | if (one) {
102 | await one.destroy()
103 | }
104 | }
105 |
106 | async function findAllVideo(category, lang) {
107 | return await Video.findAll({
108 | include: [
109 | {
110 | model: Channel,
111 | where: { category: category || 'dev', lang: lang || 'ko' },
112 | required: true,
113 | },
114 | ],
115 | order: [['publishedAt', 'DESC']],
116 | })
117 | }
118 |
119 | async function getPagedVideos(options) {
120 | const offset = (options.page - 1) * options.pageSize;
121 | const result = await findAndCountAllVideo(
122 | options.category,
123 | options.lang,
124 | offset,
125 | options.pageSize,
126 | options.channelQuery,
127 | options.channelId
128 | );
129 | return result;
130 | }
131 |
132 | async function getPagedVideosWithSearch(options) {
133 | const offset = (options.page - 1) * options.pageSize;
134 | const result = await findAndCountAllVideo(
135 | options.category,
136 | options.lang,
137 | offset,
138 | options.pageSize,
139 | options.channelQuery,
140 | options.channelId,
141 | options.searchKeyword
142 | );
143 | return result;
144 | }
145 |
146 | async function findAndCountAllVideo(
147 | category,
148 | lang,
149 | offset = 0,
150 | pageSize = 60,
151 | channelQuery = '',
152 | channelId,
153 | searchKeyword
154 | ) {
155 | let whereClause = {};
156 |
157 | if (channelQuery) {
158 | whereClause = {
159 | '$Channel.title$': { [Sequelize.Op.like]: `%${channelQuery}%` }
160 | };
161 | }
162 |
163 | if (searchKeyword) {
164 | whereClause = {
165 | [Sequelize.Op.or]: [
166 | { '$Video.title$': { [Sequelize.Op.like]: `%${searchKeyword}%` } },
167 | { '$Channel.title$': { [Sequelize.Op.like]: `%${searchKeyword}%` } }
168 | ]
169 | };
170 | }
171 |
172 | if (channelId) {
173 | whereClause = {
174 | ...whereClause,
175 | '$Channel.channelId$': channelId
176 | };
177 | }
178 |
179 | return await Video.findAndCountAll({
180 | include: [
181 | {
182 | model: Channel,
183 | where: {
184 | category: category || 'dev',
185 | lang: lang || 'ko'
186 | },
187 | required: true,
188 | },
189 | ],
190 | where: whereClause,
191 | order: [['publishedAt', 'DESC']],
192 | offset: offset,
193 | limit: pageSize,
194 | });
195 | }
196 |
197 | async function findOneByChannelId(channelId) {
198 | return await Channel.findOne({
199 | where: { channelId: channelId },
200 | })
201 | }
202 |
203 | import dayjs from 'dayjs'
204 | async function findAllChannelList(dayOffset) {
205 | // Query to get the channel list and the last update
206 | const list = await sequelize.query(
207 | `select
208 | max(y.publishedAt) publishedAt, count(y.id) cnt,
209 | c.id, c.channelId, c.title, c.thumbnail, c.customUrl, c.lang, c.category, c.createdAt, c.updatedAt
210 | from channels c
211 | left join videos y on c.id = y.ChannelId
212 | group by c.id
213 | order by y.publishedAt desc;`,
214 | {
215 | type: sequelize.QueryTypes.SELECT,
216 | }
217 | )
218 | if (dayOffset) {
219 | const baseDate = dayjs().subtract(dayOffset, 'day').toISOString()
220 | const lastUpdate = list.filter((item) => {
221 | if (!item.publishedAt) {
222 | return true
223 | }
224 | return dayjs(item.publishedAt).toISOString() > baseDate
225 | })
226 | return lastUpdate
227 | } else {
228 | return list
229 | }
230 | }
231 |
232 | async function newList() {
233 | const list = await sequelize.query(
234 | `select y.videoId, y.title from Videos y
235 | join Channels c on y.ChannelId = c.id
236 | where DATE_SUB(NOW(), INTERVAL 1 HOUR) < y.createdAt
237 | and c.lang = 'ko'
238 | and c.category = 'dev'
239 | limit 10;
240 | `,
241 | {
242 | type: sequelize.QueryTypes.SELECT,
243 | }
244 | )
245 | return list
246 | }
247 |
248 | async function findTranscriptByVideoId(videoId) {
249 | return await Transcript.findOne({
250 | where: { videoId: videoId },
251 | })
252 | }
253 |
254 | async function createTranscript(data) {
255 | if (!data.videoId) {
256 | return
257 | }
258 | const one = await Transcript.findOne({
259 | where: { videoId: data.videoId },
260 | })
261 | if (!one) {
262 | const result = await Transcript.create(data)
263 | return result.toJSON()
264 | }
265 | }
266 |
267 | async function removeTranscript(videoId) {
268 | const one = await Transcript.findOne({
269 | where: { videoId: videoId },
270 | })
271 | if (one) {
272 | await one.destroy()
273 | }
274 | }
275 |
276 | async function createAccount(data) {
277 | await Account.upsert(data)
278 | }
279 |
280 | async function getVideosCount() {
281 | const result = await Video.count();
282 | return result;
283 | }
284 |
285 | async function getChannelsCount() {
286 | const result = await Channel.count();
287 | return result;
288 | }
289 |
290 | async function getYearlyVideoStats() {
291 | const result = await Video.findAll({
292 | attributes: [
293 | [sequelize.fn('YEAR', sequelize.col('publishedAt')), 'year'],
294 | [sequelize.fn('COUNT', sequelize.col('id')), 'count']
295 | ],
296 | group: [sequelize.fn('YEAR', sequelize.col('publishedAt'))],
297 | order: [[sequelize.fn('YEAR', sequelize.col('publishedAt')), 'ASC']],
298 | raw: true
299 | });
300 | return result;
301 | }
302 |
303 | async function getMonthlyVideoStats(months = 12) {
304 | const result = await Video.findAll({
305 | attributes: [
306 | [sequelize.fn('strftime', '%Y-%m', sequelize.col('publishedAt')), 'month'],
307 | [sequelize.fn('COUNT', sequelize.col('id')), 'count']
308 | ],
309 | where: {
310 | publishedAt: {
311 | [Sequelize.Op.gte]: new Date(new Date().setMonth(new Date().getMonth() - months + 1))
312 | }
313 | },
314 | group: ['month'],
315 | order: [['month', 'DESC']],
316 | raw: true
317 | });
318 | return result;
319 | }
320 |
321 | async function getTopChannels(limit = 10) {
322 | const result = await Video.findAll({
323 | attributes: [
324 | 'customUrl',
325 | [sequelize.fn('COUNT', sequelize.col('Video.id')), 'video_count']
326 | ],
327 | group: ['customUrl'],
328 | order: [[sequelize.literal('video_count'), 'DESC']],
329 | limit: limit,
330 | raw: true
331 | });
332 | return result;
333 | }
334 |
335 | export default {
336 | create,
337 | findOneByChannelId,
338 | findAllChannelList,
339 | findAll,
340 | findAllEmpty,
341 | createVideo,
342 | removeVideo,
343 | findAllVideo,
344 | findAndCountAllVideo,
345 | getPagedVideos,
346 | getPagedVideosWithSearch,
347 | newList,
348 | findTranscriptByVideoId,
349 | createTranscript,
350 | removeTranscript,
351 | createAccount,
352 | getVideosCount,
353 | getChannelsCount,
354 | getYearlyVideoStats,
355 | getMonthlyVideoStats,
356 | getTopChannels
357 | }
358 |
--------------------------------------------------------------------------------
/web/public/css/admin.css:
--------------------------------------------------------------------------------
1 | h1 {
2 | padding: 0.4rem 1rem 0.4rem 1rem;
3 | color: var(--text-color);
4 | text-shadow: none;
5 | text-align: center;
6 | }
7 | .videos,
8 | .channels {
9 | width: 100%;
10 | border: 1px solid #ccc;
11 | }
12 | .videos thead,
13 | .channels thead {
14 | background-color: #0ff3;
15 | }
16 | tr:hover {
17 | background-color: rgba(131, 131, 46, 0.148);
18 | }
19 | .videos td,
20 | .videos th,
21 | .channels td,
22 | .channels th {
23 | height: 2.1rem;
24 | border-bottom: 1px solid var(--table-border);
25 | padding: 2px 5px 4px;
26 | color: var(--text-color);
27 | }
28 | .channels .count,
29 | .channels .lang,
30 | .channels .category {
31 | text-align: center;
32 | }
33 | .channel {
34 | width: 20%;
35 | }
36 | .thumbnail {
37 | border-radius: 10px;
38 | border: 1px solid #888;
39 | border-top-color: #ccc;
40 | border-left-color: #ccc;
41 | height: 40px;
42 | width: auto;
43 | vertical-align: middle;
44 | }
45 | .channel-id {
46 | font-family: monospace;
47 | }
48 |
49 | .date {
50 | width: 12%;
51 | }
52 | .datecell {
53 | font-family: cursive;
54 | text-align: center;
55 | font-weight: bold;
56 | }
57 |
58 | .pagination {
59 | display: flex;
60 | justify-content: center;
61 | margin-top: 20px;
62 | }
63 |
64 | .pagination a {
65 | color: var(--text-color);
66 | padding: 8px 16px;
67 | text-decoration: none;
68 | transition: background-color 0.3s;
69 | background-color: var(--search-bg);
70 | border: 1px solid var(--table-border);
71 | margin: 0 2px;
72 | }
73 |
74 | .pagination a.active {
75 | background-color: #4caf50;
76 | color: white;
77 | }
78 |
79 | .pagination a:hover:not(.active) {
80 | background-color: var(--table-hover);
81 | opacity: 0.8;
82 | }
83 |
84 | .pagination a.prev,
85 | .pagination a.next,
86 | .pagination a.latest,
87 | .pagination a.end {
88 | background-color: var(--search-bg);
89 | font-weight: bold;
90 | }
91 |
92 | /* 슬라이딩 메뉴 스타일 */
93 | .menu-toggle {
94 | position: fixed;
95 | left: 10px;
96 | top: 10px;
97 | z-index: 999;
98 | background: #333;
99 | color: white;
100 | border: none;
101 | padding: 10px;
102 | cursor: pointer;
103 | }
104 |
105 | .sliding-menu {
106 | position: fixed;
107 | left: -250px;
108 | top: 0;
109 | width: 250px;
110 | height: 100%;
111 | background: #333;
112 | transition: 0.3s;
113 | z-index: 998;
114 | padding-top: 60px;
115 | }
116 |
117 | .sliding-menu.open {
118 | left: 0;
119 | }
120 |
121 | .sliding-menu a {
122 | display: block;
123 | color: white;
124 | padding: 10px 15px;
125 | text-decoration: none;
126 | }
127 |
128 | .sliding-menu a:hover {
129 | background: #444;
130 | }
131 |
132 | /* 본문 조정 */
133 | body {
134 | padding-left: 20px;
135 | }
136 |
137 | /* 테마 설정 */
138 | :root {
139 | --bg-color: #ffffff;
140 | --text-color: #333333;
141 | --link-color: #0066cc;
142 | --header-bg: #fffd;
143 | --table-border: #ccc;
144 | --table-hover: rgba(131, 131, 46, 0.148);
145 | --table-bg: #ffffff;
146 | --thead-bg: #0ff3;
147 | --menu-bg: #333;
148 | --menu-text: white;
149 | --search-bg: #f8f9fa;
150 | --input-border: #ddd;
151 | --button-bg: #28a745;
152 | --button-hover: #218838;
153 | }
154 |
155 | [data-theme="dark"] {
156 | --bg-color: #1a1a1a;
157 | --text-color: #e0e0e0;
158 | --link-color: #66b3ff;
159 | --header-bg: #2d2d2d;
160 | --table-border: #444;
161 | --table-hover: rgba(255, 255, 255, 0.15);
162 | --table-bg: #252525;
163 | --thead-bg: rgba(0, 255, 255, 0.15);
164 | --menu-bg: #1a1a1a;
165 | --menu-text: #e0e0e0;
166 | --search-bg: #2d2d2d;
167 | --input-border: #555;
168 | --button-bg: #28a745;
169 | --button-hover: #34ce57;
170 | }
171 |
172 | body {
173 | background-color: var(--bg-color);
174 | color: var(--text-color);
175 | transition: background-color 0.3s ease, color 0.3s ease;
176 | }
177 |
178 | .videos,
179 | .channels {
180 | border-color: var(--table-border);
181 | background-color: var(--table-bg);
182 | }
183 |
184 | .videos thead,
185 | .channels thead {
186 | background-color: var(--thead-bg);
187 | }
188 |
189 | tr:hover {
190 | background-color: var(--table-hover);
191 | }
192 |
193 | .search-container {
194 | background-color: var(--search-bg);
195 | }
196 |
197 | .search-container input {
198 | background-color: var(--bg-color);
199 | color: var(--text-color);
200 | border-color: var(--input-border);
201 | }
202 |
203 | .search-container button {
204 | background-color: var(--button-bg);
205 | }
206 |
207 | .search-container button:hover {
208 | background-color: var(--button-hover);
209 | }
210 |
211 | /* 링크 색상 */
212 | a {
213 | color: var(--link-color);
214 | transition: opacity 0.2s;
215 | }
216 |
217 | a:hover {
218 | opacity: 0.8;
219 | }
220 |
221 | /* 테이블 행 배경 */
222 | .videos tbody tr,
223 | .channels tbody tr {
224 | background-color: var(--table-bg);
225 | }
226 |
227 | /* aside 스타일 */
228 | aside.github {
229 | color: var(--text-color);
230 | }
231 |
232 | aside.github a {
233 | color: var(--link-color);
234 | }
235 |
236 | /* section 스타일 */
237 | section {
238 | color: var(--text-color);
239 | position: relative;
240 | z-index: 10;
241 | }
242 |
243 | section p {
244 | color: var(--text-color);
245 | }
246 |
247 | section a {
248 | color: var(--link-color);
249 | position: relative;
250 | z-index: 102;
251 | pointer-events: auto;
252 | }
253 |
254 | /* nav 스타일 */
255 | nav {
256 | color: var(--text-color);
257 | }
258 |
259 | nav a {
260 | color: var(--link-color);
261 | }
262 |
263 | /* 입력 필드 및 버튼 공통 스타일 */
264 | input[type="text"],
265 | input[type="number"],
266 | input[type="email"],
267 | select,
268 | textarea {
269 | background-color: var(--bg-color);
270 | color: var(--text-color);
271 | border: 1px solid var(--input-border);
272 | }
273 |
274 | button {
275 | background-color: var(--button-bg);
276 | color: white;
277 | border: none;
278 | }
279 |
280 | button:hover {
281 | background-color: var(--button-hover);
282 | }
283 |
284 | /* 채널 폼 스타일 */
285 | section input[type="text"],
286 | section select {
287 | padding: 8px;
288 | margin-right: 8px;
289 | border-radius: 4px;
290 | }
291 |
292 | section button {
293 | padding: 8px 16px;
294 | border-radius: 4px;
295 | cursor: pointer;
296 | }
297 |
298 | /* 테마 토글 버튼 */
299 | .theme-toggle {
300 | position: fixed;
301 | top: 2.8rem;
302 | background: var(--menu-bg);
303 | color: var(--menu-text);
304 | border: 1px solid var(--input-border);
305 | padding: 6px 2px;
306 | cursor: pointer;
307 | border-radius: 4px;
308 | font-size: 13px;
309 | backdrop-filter: blur(10px);
310 | transition: all 0.2s ease;
311 | right: 0.8rem;
312 | width: 66px;
313 | z-index: 101;
314 | }
315 |
316 | .theme-toggle:hover {
317 | opacity: 0.8;
318 | transform: scale(1.05);
319 | }
320 |
321 | .theme-toggle:active {
322 | transform: scale(0.95);
323 | }
324 |
325 | /* GitHub 로고 다크모드 지원 */
326 | .github-logo {
327 | transition: filter 0.3s ease;
328 | }
329 |
330 | [data-theme="dark"] .github-logo {
331 | filter: invert(1);
332 | }
333 |
334 | /* aside.github 영역 다크모드 개선 */
335 | aside.github {
336 | position: fixed;
337 | top: 2px;
338 | right: 0.5rem;
339 | z-index: 100;
340 | background-color: var(--header-bg);
341 | padding: 4px 8px;
342 | border-radius: 4px;
343 | font-size: 14px;
344 | }
345 |
346 | aside.github a {
347 | color: #66b3ff;
348 | margin: 0 3px;
349 | text-decoration: none;
350 | font-weight: 500;
351 | }
352 |
353 | aside.github a:hover {
354 | text-decoration: underline;
355 | }
356 |
357 | aside.github .k {
358 | color: var(--text-color);
359 | }
360 |
361 | [data-theme="dark"] aside.github a {
362 | color: #66b3ff;
363 | }
364 |
365 |
366 | /* 모바일 최적화 */
367 | @media (max-width: 768px) {
368 | body {
369 | padding-left: 4px;
370 | padding-right: 4px;
371 | }
372 |
373 | .theme-toggle {
374 | right: 0.5rem;
375 | left: auto;
376 | top: 2.3rem;
377 | padding: 4px 8px;
378 | font-size: 11px;
379 | max-width: 80px;
380 | }
381 |
382 | aside.github {
383 | right: 0.3rem;
384 | padding: 2px 4px;
385 | font-size: 11px;
386 | top: 1px;
387 | }
388 |
389 | aside.github .k {
390 | display: none;
391 | }
392 |
393 | aside.github a {
394 | margin: 0 2px;
395 | }
396 |
397 | .github-logo {
398 | width: 1.8em;
399 | }
400 |
401 | h1 {
402 | padding: 0.4rem 0.5rem 0.4rem 0.5rem;
403 | font-size: 0.9rem;
404 | text-align: center;
405 | z-index: 100;
406 | }
407 |
408 | section {
409 | font-size: 12px;
410 | padding: 16px 0 0;
411 | }
412 |
413 | section p {
414 | width: 60%;
415 | }
416 |
417 | nav {
418 | font-size: 13px;
419 | padding: 8px 10px;
420 | }
421 |
422 | /* 테이블 스크롤 가능하게 */
423 | .videos,
424 | .channels {
425 | font-size: 11px;
426 | display: block;
427 | overflow-x: auto;
428 | -webkit-overflow-scrolling: touch;
429 | white-space: nowrap;
430 | }
431 |
432 | .videos thead,
433 | .channels thead {
434 | display: table;
435 | width: 100%;
436 | table-layout: fixed;
437 | position: sticky;
438 | top: 0;
439 | z-index: 10;
440 | }
441 |
442 | .videos tbody,
443 | .channels tbody {
444 | display: table;
445 | width: 100%;
446 | }
447 |
448 | .videos td,
449 | .videos th,
450 | .channels td,
451 | .channels th {
452 | padding: 6px 4px;
453 | font-size: 10px;
454 | vertical-align: middle;
455 | }
456 |
457 | .videos th,
458 | .channels th {
459 | font-weight: bold;
460 | text-align: center;
461 | }
462 |
463 | .thumbnail {
464 | height: 25px;
465 | width: auto;
466 | }
467 |
468 | .channel {
469 | min-width: 80px;
470 | }
471 |
472 | .title {
473 | min-width: 150px;
474 | }
475 |
476 | .date {
477 | min-width: 70px;
478 | }
479 |
480 | h3 {
481 | font-size: 1rem;
482 | margin: 10px 0;
483 | }
484 |
485 | .search-container {
486 | padding: 8px;
487 | margin: 10px 0;
488 | }
489 |
490 | .search-container input {
491 | width: calc(100% - 100px);
492 | max-width: 200px;
493 | font-size: 13px;
494 | padding: 6px;
495 | }
496 |
497 | .search-container button {
498 | padding: 6px 12px;
499 | font-size: 13px;
500 | }
501 |
502 | .pagination {
503 | flex-wrap: wrap;
504 | gap: 4px;
505 | }
506 |
507 | .pagination a {
508 | padding: 6px 10px;
509 | font-size: 11px;
510 | margin: 2px;
511 | }
512 |
513 | .pagination a.active {
514 | padding: 6px 10px;
515 | }
516 |
517 | .pagination-wrapper {
518 | overflow-x: auto;
519 | padding: 10px 0;
520 | }
521 |
522 | .menu-toggle {
523 | left: 5px;
524 | top: 2px;
525 | padding: 4px;
526 | font-size: 14px;
527 | }
528 |
529 | .sliding-menu {
530 | width: 200px;
531 | font-size: 14px;
532 | }
533 |
534 | .sliding-menu a {
535 | padding: 8px 12px;
536 | font-size: 13px;
537 | }
538 |
539 | /* 테이블 링크 가독성 개선 */
540 | .videos a,
541 | .channels a {
542 | word-break: break-word;
543 | white-space: normal;
544 | display: inline-block;
545 | line-height: 1.3;
546 | }
547 |
548 | .datecell {
549 | font-size: 9px;
550 | }
551 | }
552 |
--------------------------------------------------------------------------------
/web/views/admin/settings.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | 시스템 설정
8 |
9 |
10 |
134 |
135 |
136 |
137 | <%- include('nav.ejs') %>
138 |
139 |
⚙️ 시스템 설정
140 |
141 |
142 | <% if (!user) { %>
143 | Welcome! Please log in.
144 | <% } else { %>
145 | Hello, <%= user.username %>. logout. profile.
146 | <% } %>
147 |
148 |
149 |
150 |
일반 설정
151 |
152 |
153 | 사이트 제목
154 | 브라우저 탭에 표시되는 제목
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 | 사이트 설명
164 | 검색 엔진에 표시될 설명
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 | 페이지당 항목 수
174 | 목록에 표시할 항목 개수
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 | 타임존
184 | 시스템 시간대 설정
185 |
186 |
187 |
193 |
194 |
195 |
196 |
197 |
198 |
이메일 설정
199 |
200 |
201 | SMTP 서버
202 | 이메일 발송 서버 주소
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 | SMTP 포트
212 | 서버 포트 번호
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 | 발신자 이메일
222 | 시스템에서 발송하는 이메일 주소
223 |
224 |
225 |
226 |
227 |
228 |
229 |
230 |
231 | 이메일 알림
232 | 시스템 이벤트 이메일 알림
233 |
234 |
235 |
236 |
237 |
238 |
239 |
240 |
241 |
API 설정
242 |
243 |
244 | YouTube API 키
245 | YouTube 데이터 API v3 키
246 |
247 |
248 |
249 |
250 |
251 |
252 |
253 |
254 | API 요청 제한
255 | 분당 최대 요청 수
256 |
257 |
258 |
259 |
260 |
261 |
262 |
263 |
264 | API 상태
265 | 현재 API 서비스 상태
266 |
267 |
268 |
269 | 정상 작동 중
270 |
271 |
272 |
273 |
274 |
275 |
크론 작업 설정
276 |
277 |
278 | 자동 업데이트
279 | 비디오 자동 업데이트 활성화
280 |
281 |
282 |
283 |
284 |
285 |
286 |
287 |
288 | 업데이트 주기
289 | 크론 작업 실행 간격 (시간)
290 |
291 |
292 |
293 |
294 |
295 |
296 |
297 |
298 | 데이터베이스 백업
299 | 자동 백업 활성화
300 |
301 |
302 |
303 |
304 |
305 |
306 |
307 |
308 |
성능 설정
309 |
310 |
311 | 캐시 활성화
312 | 응답 속도 향상을 위한 캐싱
313 |
314 |
315 |
316 |
317 |
318 |
319 |
320 |
321 | 캐시 유효 시간
322 | 캐시 만료 시간 (초)
323 |
324 |
325 |
326 |
327 |
328 |
329 |
330 |
331 | 압축 활성화
332 | gzip 압축으로 전송량 감소
333 |
334 |
335 |
336 |
337 |
338 |
339 |
340 |
341 |
342 |
343 |
344 |
345 |
346 |
347 |
382 |
383 |
384 |
385 |
--------------------------------------------------------------------------------
/web/public/css/style.css:
--------------------------------------------------------------------------------
1 | @import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css');
2 |
3 | /* 테마 설정 */
4 | :root {
5 | --bg-color: ivory;
6 | --text-color: #333;
7 | --link-color: #333;
8 | --header-bg: #fffd;
9 | --nav-bg: #f9000020;
10 | --li-bg: #f4f4f4;
11 | --border-color: #ddd;
12 | --shadow-color: rgba(0, 0, 0, 0.1);
13 | --nav-shadow: rgba(0, 0, 0, 0.15);
14 | --text-shadow: rgba(0, 0, 0, 0.3);
15 | --button-bg: #fff6;
16 | --ad-bg: #ccebebee;
17 | }
18 |
19 | [data-theme="dark"] {
20 | --bg-color: #1a1a1a;
21 | --text-color: #e0e0e0;
22 | --link-color: #66b3ff;
23 | --header-bg: #2d2d2d;
24 | --nav-bg: rgba(45, 45, 45, 0.9);
25 | --li-bg: #252525;
26 | --border-color: #444;
27 | --shadow-color: rgba(255, 255, 255, 0.1);
28 | --nav-shadow: rgba(0, 0, 0, 0.5);
29 | --text-shadow: rgba(0, 0, 0, 0.8);
30 | --button-bg: rgba(255, 255, 255, 0.1);
31 | --ad-bg: rgba(45, 45, 45, 0.95);
32 | }
33 |
34 | body {
35 | font-family: 'Verdana', sans-serif;
36 | margin: 0;
37 | background-color: var(--bg-color);
38 | color: var(--text-color);
39 | transition: background-color 0.3s ease, color 0.3s ease;
40 | }
41 |
42 | h1 {
43 | position: fixed;
44 | top: 0;
45 | margin-top: 0;
46 | left: 0;
47 | width: 100%;
48 | background-color: var(--header-bg);
49 | color: var(--text-color);
50 | z-index: 10;
51 | }
52 |
53 | ul {
54 | list-style: none;
55 | padding: 0;
56 | display: grid;
57 | grid-template-columns: repeat(auto-fill, minmax(40%, 1fr));
58 | }
59 |
60 | li {
61 | box-shadow: 0 2px 2px var(--shadow-color);
62 | position: relative;
63 | margin: 0.6rem 0.1rem;
64 | min-height: 12rem;
65 | padding: 0.2rem 0.1rem 1.4rem;
66 | background-color: var(--li-bg);
67 | border: 1px solid var(--border-color);
68 | }
69 |
70 | img {
71 | width: 100%;
72 | height: auto;
73 | border-radius: 6.5%;
74 | }
75 |
76 | a {
77 | color: var(--link-color);
78 | text-decoration: none;
79 | }
80 |
81 | a:hover {
82 | text-decoration: underline;
83 | }
84 |
85 | .outlink {
86 | width: 0.8rem;
87 | }
88 |
89 | #keyword {
90 | margin: 6px 0;
91 | }
92 |
93 | .ad {
94 | display: none;
95 | position: fixed;
96 | bottom: 0;
97 | background-color: var(--ad-bg);
98 | color: var(--text-color);
99 | }
100 |
101 | .ad a {
102 | color: var(--link-color);
103 | }
104 |
105 | .top {
106 | position: fixed;
107 | right: 1.5rem;
108 | bottom: 1.5rem;
109 | padding: 10px;
110 | border-radius: 45%;
111 | background-color: var(--button-bg);
112 | color: var(--text-color);
113 | border: 1px solid var(--border-color);
114 | cursor: pointer;
115 | backdrop-filter: blur(10px);
116 | }
117 |
118 | .top:hover {
119 | opacity: 0.8;
120 | }
121 |
122 | #login-page {
123 | margin: auto;
124 | text-align: center;
125 | width: 100%;
126 | max-width: 22rem;
127 | min-height: 30vh;
128 | display: flex;
129 | flex-direction: column;
130 | justify-content: center;
131 | align-items: center;
132 | }
133 |
134 | #login-page h1 {
135 | font-size: clamp(1.5rem, 5vw, 2rem);
136 | margin-bottom: 40px;
137 | color: var(--text-color);
138 | position: static !important;
139 | background: none !important;
140 | width: 100%;
141 | }
142 |
143 | #login-page a {
144 | display: inline-flex;
145 | align-items: center;
146 | gap: 10px;
147 | padding: 14px 28px;
148 | background-color: var(--li-bg);
149 | color: var(--text-color);
150 | border: 1px solid var(--border-color);
151 | border-radius: 8px;
152 | font-size: 16px;
153 | font-weight: 500;
154 | transition: all 0.3s ease;
155 | text-decoration: none;
156 | width: 100%;
157 | max-width: 280px;
158 | justify-content: center;
159 | }
160 |
161 | #login-page a:hover {
162 | background-color: var(--button-bg);
163 | transform: translateY(-2px);
164 | box-shadow: 0 4px 12px var(--shadow-color);
165 | }
166 |
167 | #login-page .github-logo {
168 | width: 24px;
169 | height: 24px;
170 | }
171 |
172 | /* 모바일 최적화 */
173 | @media (max-width: 768px) {
174 | #login-page {
175 | padding: 20px 15px;
176 | }
177 |
178 | #login-page h1 {
179 | font-size: 1.5rem;
180 | margin-bottom: 30px;
181 | }
182 |
183 | #login-page a {
184 | padding: 16px 24px;
185 | font-size: 15px;
186 | max-width: 100%;
187 | width: calc(100% - 30px);
188 | }
189 | }
190 |
191 | .github {
192 | position: fixed;
193 | top: 0;
194 | right: 0.3rem;
195 | z-index: 100;
196 | background-color: var(--header-bg);
197 | padding: 2px 8px;
198 | border-radius: 4px;
199 | }
200 |
201 | .github-logo {
202 | width: 2.3em;
203 | transition: filter 0.3s ease;
204 | }
205 |
206 | [data-theme="dark"] .github-logo {
207 | filter: invert(1);
208 | }
209 |
210 | .okdevtv-logo {
211 | width: 1.95em;
212 | vertical-align: bottom;
213 | transition: filter 0.3s ease;
214 | }
215 |
216 | [data-theme="dark"] .okdevtv-logo {
217 | filter: invert(1);
218 | }
219 |
220 | .main-nav {
221 | position: sticky;
222 | top: 2.0rem;
223 | background-color: var(--nav-bg);
224 | padding: 0.4rem;
225 | text-align: right;
226 | z-index: 11;
227 | box-shadow: 0 2px 8px var(--nav-shadow);
228 | backdrop-filter: blur(10px);
229 | }
230 |
231 | .main-nav a {
232 | text-shadow: 0 1px 2px var(--text-shadow);
233 | font-weight: 500;
234 | }
235 |
236 | #title {
237 | padding: 0.2rem;
238 | }
239 | .title {
240 | font-size: larger;
241 | }
242 |
243 | #count {
244 | margin-left: 20px;
245 | }
246 |
247 | .channel {
248 | margin-bottom: 6px;
249 | text-overflow: ellipsis;
250 | overflow: hidden;
251 | white-space: nowrap;
252 | }
253 |
254 | .channame {
255 | font-size: 1rem;
256 | font-weight: 400;
257 | }
258 |
259 | .profile {
260 | width: 1.2rem;
261 | vertical-align: bottom;
262 | }
263 |
264 | #list li a {
265 | overflow: hidden;
266 | overflow-wrap: break-word;
267 | }
268 |
269 | li button.transcript {
270 | display: block;
271 | position: absolute;
272 | bottom: 6px;
273 | right: 10px;
274 | padding: 4px;
275 | background-color: var(--button-bg);
276 | border: 1px solid var(--border-color);
277 | border-radius: 0.3rem;
278 | color: var(--text-color);
279 | cursor: pointer;
280 | }
281 |
282 | .modal {
283 | display: none;
284 | position: fixed;
285 | z-index: 20;
286 | left: 0;
287 | top: 0;
288 | width: 100%;
289 | height: 100%;
290 | overflow: auto;
291 | background-color: rgba(0, 0, 0, 0.4);
292 | }
293 |
294 | .modal-content {
295 | position: relative;
296 | background-color: var(--li-bg);
297 | margin: 15% auto;
298 | padding: 20px;
299 | border: 1px solid var(--border-color);
300 | width: 80%;
301 | color: var(--text-color);
302 | }
303 |
304 | .clipboard-icon {
305 | position: absolute;
306 | right: 2rem;
307 | }
308 |
309 | .search {
310 | display: block;
311 | margin: 0.3rem auto 0.8rem;
312 | padding: 0.2rem;
313 | }
314 |
315 | .search #keyword {
316 | font-size: 1.2rem;
317 | width: 50%;
318 | background-color: var(--li-bg);
319 | color: var(--text-color);
320 | border: 1px solid var(--border-color);
321 | padding: 8px;
322 | border-radius: 4px;
323 | }
324 |
325 | .search button {
326 | font-size: 1.2rem;
327 | background-color: var(--button-bg);
328 | color: var(--text-color);
329 | border: 1px solid var(--border-color);
330 | padding: 8px 16px;
331 | border-radius: 4px;
332 | cursor: pointer;
333 | }
334 |
335 | .search button:hover {
336 | opacity: 0.8;
337 | }
338 |
339 | /* Desktop styles */
340 | @media only screen and (min-width: 992px) {
341 | body {
342 | font-size: large;
343 | padding: 3.1em 2.5rem;
344 | }
345 |
346 | .title {
347 | margin: 0;
348 | padding: 0.2rem 2.5rem;
349 | }
350 |
351 | .profile {
352 | width: 1.4rem;
353 | vertical-align: bottom;
354 | }
355 |
356 | .search {
357 | margin-bottom: 1rem;
358 | align-items: center;
359 | }
360 |
361 | .search #keyword {
362 | font-size: 1rem;
363 | }
364 |
365 | .search button {
366 | font-size: 1rem;
367 | margin-right: 10px;
368 | }
369 |
370 | #channelLink {
371 | height: 3rem;
372 | background-color: #fabe9833;
373 | }
374 |
375 | ul {
376 | list-style-type: none;
377 | margin: 0;
378 | padding: 0 0.2rem;
379 | display: grid;
380 | grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
381 | grid-gap: 10px;
382 | }
383 |
384 | li {
385 | background-color: var(--li-bg);
386 | border: 1px solid var(--border-color);
387 | border-radius: 3%;
388 | padding: 6px 10px 10px;
389 | box-shadow: 0 2px 2px var(--shadow-color);
390 | position: relative;
391 | width: initial;
392 | }
393 |
394 | li a {
395 | font-size: medium;
396 | }
397 |
398 | .channame {
399 | font-size: 1.2rem;
400 | font-weight: 400;
401 | }
402 |
403 | .ad {
404 | display: block;
405 | background-color: var(--ad-bg);
406 | color: var(--text-color);
407 | padding: 10px 2.5rem;
408 | position: fixed;
409 | bottom: 0;
410 | left: 0;
411 | width: 100%;
412 | border-top: 1px solid var(--border-color);
413 | }
414 |
415 | .ad a {
416 | color: var(--link-color);
417 | }
418 |
419 | .ad code {
420 | background-color: var(--button-bg);
421 | color: var(--text-color);
422 | padding: 2px 6px;
423 | border-radius: 3px;
424 | border: 1px solid var(--border-color);
425 | }
426 |
427 | .ad button {
428 | background-color: var(--button-bg);
429 | color: var(--text-color);
430 | border: 1px solid var(--border-color);
431 | }
432 |
433 | .contact {
434 | background-color: var(--ad-bg);
435 | color: var(--text-color);
436 | padding: 10px 2.5rem;
437 | border-top: 1px solid var(--border-color);
438 | }
439 |
440 | .contact a {
441 | color: var(--link-color);
442 | }
443 | }
444 |
445 | li button.share-twitter {
446 | display: block;
447 | position: absolute;
448 | bottom: 6px;
449 | right: 10px;
450 | background-color: #000000c0;
451 | color: white;
452 | border: none;
453 | border-radius: 0.3rem;
454 | padding: 3px 6px;
455 | cursor: pointer;
456 | }
457 |
458 | li button.share-twitter:hover {
459 | background-color: #0c85d0;
460 | }
461 |
462 | .pagination {
463 | display: flex;
464 | justify-content: center;
465 | margin-top: 20px;
466 | }
467 |
468 | .pagination a,
469 | .pagination span {
470 | color: var(--text-color);
471 | float: left;
472 | padding: 8px 16px;
473 | text-decoration: none;
474 | transition: background-color 0.3s;
475 | border: 1px solid var(--border-color);
476 | margin: 0 4px;
477 | background-color: var(--li-bg);
478 | }
479 |
480 | .pagination a:hover:not(.active) {
481 | background-color: var(--button-bg);
482 | opacity: 0.8;
483 | }
484 |
485 | .pagination .current {
486 | background-color: #4caf50;
487 | color: white;
488 | border: 1px solid #4caf50;
489 | }
490 |
491 | /* 테마 토글 버튼 */
492 | .theme-toggle {
493 | position: fixed;
494 | right: 3.5rem;
495 | top: 0.2rem;
496 | z-index: 101;
497 | background: var(--button-bg);
498 | color: var(--text-color);
499 | border: 1px solid var(--border-color);
500 | padding: 6px 10px;
501 | cursor: pointer;
502 | border-radius: 4px;
503 | font-size: 13px;
504 | backdrop-filter: blur(10px);
505 | transition: all 0.2s ease;
506 | }
507 |
508 | .theme-toggle:hover {
509 | opacity: 0.8;
510 | transform: scale(1.05);
511 | }
512 |
513 | .theme-toggle:active {
514 | transform: scale(0.95);
515 | }
516 |
517 | /* 모바일 최적화 */
518 | @media (max-width: 768px) {
519 | body {
520 | padding: 0 0.5rem;
521 | }
522 |
523 | h1 {
524 | font-size: 1rem;
525 | padding: 0.2rem 0.5rem;
526 | }
527 |
528 | .theme-toggle {
529 | right: 2.8rem;
530 | top: 0.1rem;
531 | padding: 4px 8px;
532 | font-size: 11px;
533 | }
534 |
535 | .github {
536 | right: 0.2rem;
537 | padding: 1px 4px;
538 | }
539 |
540 | .github-logo {
541 | width: 1.8em;
542 | }
543 |
544 | .okdevtv-logo {
545 | width: 1em;
546 | }
547 |
548 | .main-nav {
549 | font-size: 13px;
550 | padding: 0.3rem;
551 | top: 2rem;
552 | }
553 |
554 | .search {
555 | margin: 2.5rem auto 0.5rem;
556 | }
557 |
558 | .search #keyword {
559 | font-size: 1rem;
560 | width: 70%;
561 | padding: 6px;
562 | }
563 |
564 | .search button {
565 | font-size: 1rem;
566 | padding: 6px 12px;
567 | }
568 |
569 | ul {
570 | grid-template-columns: repeat(auto-fill, minmax(45%, 1fr));
571 | gap: 8px;
572 | padding: 0 0.1rem;
573 | }
574 |
575 | li {
576 | margin: 0.3rem 0;
577 | padding: 0.1rem 0.1rem 1rem;
578 | min-height: 10rem;
579 | font-size: 13px;
580 | }
581 |
582 | .channel {
583 | font-size: 12px;
584 | }
585 |
586 | .channame {
587 | font-size: 13px;
588 | }
589 |
590 | li button.transcript {
591 | padding: 3px;
592 | font-size: 11px;
593 | bottom: 4px;
594 | right: 6px;
595 | }
596 |
597 | .pagination a,
598 | .pagination span {
599 | padding: 6px 10px;
600 | font-size: 12px;
601 | margin: 0 2px;
602 | }
603 |
604 | .modal-content {
605 | width: 95%;
606 | margin: 10% auto;
607 | padding: 15px;
608 | font-size: 14px;
609 | }
610 |
611 | .ad {
612 | font-size: 12px;
613 | padding: 8px 1rem;
614 | }
615 |
616 | .contact {
617 | font-size: 12px;
618 | padding: 8px 1rem;
619 | }
620 |
621 | .top {
622 | right: 1rem;
623 | bottom: 1rem;
624 | padding: 8px;
625 | font-size: 13px;
626 | }
627 | }
628 |
--------------------------------------------------------------------------------