├── 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 | <%= video.title %> 12 |
    13 | <%= video.pubdate %> 14 |
    15 | <%= video.title %> 16 |
    17 | 24 |
  • 25 | -------------------------------------------------------------------------------- /web/views/aside.ejs: -------------------------------------------------------------------------------- 1 |
    2 | 12 | 16 |
    17 |
    18 | 19 |
    20 |
    21 |
    22 | * Contact: kenu@okdevtv.com, OKdevTV: https://youtube.com/@KenuHeo 23 |
    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 | 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 | 120 | 121 | 122 | 123 | <% }); } else { %> 124 | 125 | 126 | 127 | <% } %> 128 | 129 |
    카테고리비디오 수비율
    <%= stat.category %><%= stat.count %><%= stat.percentage %>%
    데이터가 없습니다.
    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 |
    35 | 36 | 40 | 45 | 46 |
    47 |
    48 |

    Latest channels

    49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | <% channels.forEach(function(channel) { %> 62 | 63 | 68 | 72 | 77 | 80 | 83 | 86 | 89 | 92 | 93 | <% }) %> 94 | 95 |
    channelchannel idcustom urlcountcategorylanglast@cre@
    64 | <%= channel.title %> 65 | <%= channel.title %> 66 | 67 | 69 | <%= channel.channelId %> 70 | 71 | 73 | 74 | <%= channel.customUrl %> 75 | 76 | 78 | <%= channel.cnt %> 79 | 81 | <%= channel.category %> 82 | 84 | <%= channel.lang %> 85 | 87 | <%= channel.pubdate %> 88 | 90 | <%= channel.credate %> 91 |
    96 | 97 |
    98 | 100 |
    101 |
    102 | 103 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /web/views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%- include('header.ejs') %> 5 | 6 | 7 | 8 |
    9 |

    10 | 11 | 12 | odevtube: <%= title %> 13 | 14 |

    15 |
    16 | 17 | 18 |
    19 |
    20 | 38 | 39 | <%- include('search.ejs') %> 40 | <% if (uri === 'kpop' ) { %> 41 |
    42 | ➡️ 2022 교차편집 모음 (2022 Stage mix playlist) 43 |
    44 | <% } %> 45 | 46 | 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 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | <% videos.forEach(function(video) { %> 83 | 84 | 88 | 93 | 96 | 99 | 102 | 103 | <% }) %> 104 | 105 |
    channeltitlepub@cre@del
    85 | 86 | <%= video.Channel.title %> 87 | 89 | 90 | <%= video.title %> 91 | 92 | 94 | <%= video.pubdate %> 95 | 97 | <%= video.credate %> 98 | 100 | x 101 |
    106 | 107 |
    108 | 130 |
    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 |
    7 |

    8 | 9 | 10 | odevtube: 통계 11 | 12 |

    13 |
    14 | 15 | 16 |
    17 |
    18 | 39 | 40 |
    41 |

    비디오 통계

    42 | 43 | 44 |
    45 |
    46 |

    전체 통계

    47 |
    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 |
    74 |
    75 |

    연도별 업로드 현황

    76 |
    77 |
    78 | 79 |
    80 |
    81 | 82 | 83 |
    84 |
    85 |

    최근 1년간 월별 업로드 추이

    86 |
    87 |
    88 | 89 |
    90 |
    91 | 92 | 93 |
    94 |
    95 |

    상위 채널별 비디오 수

    96 |
    97 |
    98 |
    99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | <% stats.topChannels.forEach((channel, index) => { %> 110 | 111 | 112 | 113 | 114 | 125 | 126 | <% }) %> 127 | 128 |
    순위채널비디오 수점유율
    <%= index + 1 %><%= channel.customUrl %><%= channel.video_count.toLocaleString() %> 115 |
    116 |
    121 | <%= (channel.video_count / stats.totalVideos * 100).toFixed(1) %>% 122 |
    123 |
    124 |
    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 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | <% }); } else { %> 226 | 227 | 228 | 229 | <% } %> 230 | 231 |
    시간레벨소스사용자메시지상세
    <%= log.timestamp %><%= log.level %><%= log.source %><%= log.user || 'N/A' %><%= log.message %>
    로그 데이터가 없습니다.
    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 | --------------------------------------------------------------------------------