소스 검색

Working search, add, ui and conversion. to add download

Benoit Sida 3 년 전
부모
커밋
d670bbfe6f
17개의 변경된 파일615개의 추가작업 그리고 90개의 파일을 삭제
  1. 2
    0
      .gitignore
  2. 8
    0
      .meteor/packages
  3. 6
    0
      .meteor/versions
  4. 138
    15
      client/main.css
  5. 77
    15
      imports/api/links.js
  6. 80
    37
      imports/ui/App.jsx
  7. 19
    0
      imports/ui/Input.jsx
  8. 22
    22
      imports/ui/Link.jsx
  9. 27
    0
      imports/ui/Links.jsx
  10. 51
    0
      imports/ui/Options.jsx
  11. 43
    0
      imports/ui/OptionsAll.jsx
  12. 42
    0
      imports/ui/Result.jsx
  13. 21
    0
      imports/ui/Results.jsx
  14. 25
    0
      imports/ui/Video.js
  15. 42
    0
      imports/ui/YouTubeAPI.js
  16. 8
    1
      package.json
  17. 4
    0
      packages.json

+ 2
- 0
.gitignore 파일 보기

1
 node_modules/
1
 node_modules/
2
+uploaded/
3
+packages/

+ 8
- 0
.meteor/packages 파일 보기

21
 react-meteor-data
21
 react-meteor-data
22
 accounts-ui
22
 accounts-ui
23
 accounts-password
23
 accounts-password
24
+underscore
25
+fortawesome:fontawesome
26
+numeral:numeral
27
+meteorhacks:npm
28
+
29
+
30
+npm-container
31
+momentjs:moment

+ 6
- 0
.meteor/versions 파일 보기

29
 email@1.1.18
29
 email@1.1.18
30
 es5-shim@4.6.15
30
 es5-shim@4.6.15
31
 fastclick@1.0.13
31
 fastclick@1.0.13
32
+fortawesome:fontawesome@4.7.0
32
 geojson-utils@1.0.10
33
 geojson-utils@1.0.10
33
 hot-code-push@1.0.4
34
 hot-code-push@1.0.4
34
 html-tools@1.0.11
35
 html-tools@1.0.11
43
 logging@1.1.17
44
 logging@1.1.17
44
 meteor@1.6.1
45
 meteor@1.6.1
45
 meteor-base@1.0.4
46
 meteor-base@1.0.4
47
+meteorhacks:async@1.0.0
48
+meteorhacks:npm@1.5.0
46
 minifier-css@1.2.16
49
 minifier-css@1.2.16
47
 minifier-js@1.2.17
50
 minifier-js@1.2.17
48
 minimongo@1.0.20
51
 minimongo@1.0.20
50
 mobile-status-bar@1.0.14
53
 mobile-status-bar@1.0.14
51
 modules@0.7.9
54
 modules@0.7.9
52
 modules-runtime@0.7.9
55
 modules-runtime@0.7.9
56
+momentjs:moment@2.18.1
53
 mongo@1.1.15
57
 mongo@1.1.15
54
 mongo-id@1.0.6
58
 mongo-id@1.0.6
55
 npm-bcrypt@0.9.2
59
 npm-bcrypt@0.9.2
60
+npm-container@1.2.0
56
 npm-mongo@2.2.16_1
61
 npm-mongo@2.2.16_1
62
+numeral:numeral@1.5.3_1
57
 observe-sequence@1.0.15
63
 observe-sequence@1.0.15
58
 ordered-dict@1.0.9
64
 ordered-dict@1.0.9
59
 promise@0.8.8
65
 promise@0.8.8

+ 138
- 15
client/main.css 파일 보기

56
   margin-right: 1em;
56
   margin-right: 1em;
57
 }
57
 }
58
  
58
  
59
-form {
60
-  margin-top: 10px;
61
-  margin-bottom: -10px;
62
-  position: relative;
63
-}
64
- 
65
-.new-link input {
59
+input.new-link {
66
   box-sizing: border-box;
60
   box-sizing: border-box;
67
-  padding: 10px 0;
68
   background: transparent;
61
   background: transparent;
69
   border: none;
62
   border: none;
70
   width: 100%;
63
   width: 100%;
71
-  padding-right: 80px;
72
-  font-size: 1em;
64
+  padding: 10px;
65
+  font-size: 1.5em;
66
+  color: rgba(20, 20, 20);
73
 }
67
 }
74
  
68
  
75
-.new-link input:focus{
69
+input.new-link:focus{
76
   outline: 0;
70
   outline: 0;
77
 }
71
 }
78
- 
72
+
79
 ul {
73
 ul {
74
+  padding: 0;
75
+  margin: 0;
76
+}
77
+
78
+.options-all {
79
+  background: #d2edf4;
80
+  background-image: linear-gradient(to top, #d0edf5, #e1e5f0 100%);
81
+  display: flex;
82
+  padding: 20px 15px 15px 15px;
83
+}
84
+
85
+button.option {
86
+  background: white;
87
+  border: none;
88
+  margin: 0 5px;
89
+  padding: 5px 10px;
90
+  border: 1px solid rgba(0, 0, 0, 0.2);
91
+  position: relative;
92
+}
93
+
94
+button.unreachable {
95
+  background: rgba(117, 0, 0, 0.51);
96
+  color: white;
97
+}
98
+
99
+button.option:hover {
100
+  -webkit-box-shadow:  0 1px 5px 0 rgba(0, 0, 0, 0.3);
101
+  -moz-box-shadow:  0 1px 5px 0 rgba(0, 0, 0, 0.3);
102
+  box-shadow:  0 1px 5px 0 rgba(0, 0, 0, 0.3);
103
+  cursor: pointer;
104
+}
105
+
106
+button.unreachable:hover {
107
+  cursor: not-allowed;
108
+}
109
+
110
+button.option .option-progress {
111
+  background: rgba(0, 128, 0, 0.25);
112
+  height: 100%;
113
+  position: absolute;
114
+  top: 0;
115
+  left: 0;
116
+}
117
+
118
+.options-all .label {
119
+  flex: 2;
120
+}
121
+ 
122
+.box-container {
80
   margin: 0;
123
   margin: 0;
81
   padding: 0;
124
   padding: 0;
82
   background: white;
125
   background: white;
83
 }
126
 }
127
+
128
+.link {
129
+  display: flex;
130
+  flex-direction: row;
131
+}
132
+
133
+.link .link-text {
134
+  flex: 10;
135
+  -ms-text-overflow: ellipsis;
136
+  text-overflow: ellipsis;
137
+  white-space: nowrap;
138
+  overflow: hidden;
139
+  padding: 8px 10px 0;
140
+}
141
+
142
+.link .link-options {
143
+  flex: 4;
144
+}
84
  
145
  
85
-.delete {
86
-  float: right;
146
+.link .link-delete {
87
   font-weight: bold;
147
   font-weight: bold;
88
   background: none;
148
   background: none;
89
   font-size: 1em;
149
   font-size: 1em;
90
   border: none;
150
   border: none;
91
-  position: relative;
151
+  flex: 1;
152
+  cursor: pointer;
92
 }
153
 }
93
  
154
  
94
 li {
155
 li {
136
   .new-link input {
197
   .new-link input {
137
     padding-bottom: 5px;
198
     padding-bottom: 5px;
138
   }
199
   }
200
+}
201
+
202
+#search-result {
203
+  margin-top:20px;
204
+  background: none;
205
+}
206
+
207
+#search-result .result-link {
208
+  display: block;
209
+  padding: 10px;
210
+  border-bottom: 1px solid rgba(0,0,0,0.1);
211
+  background: rgba(255,255,255,0.3);
212
+  height: 45px;
213
+}
214
+
215
+#search-result .result-link:hover {
216
+  background: rgba(255,255,255,0.5);
217
+  -webkit-box-shadow:  0 5px 5px -5px black;
218
+  -moz-box-shadow:  0 5px 5px -5px black;
219
+  box-shadow:  0 5px 5px -5px black;
220
+  cursor: pointer;
221
+}
222
+
223
+#search-result .result-thumb {
224
+  float: left;
225
+}
226
+
227
+#search-result .result-info {
228
+  vertical-align: top;
229
+  margin-left: 100px;
230
+  //line-height: 45px;
231
+  display: flex;
232
+  flex-direction: column;
233
+}
234
+
235
+#search-result .result-info .result-head {
236
+  flex-direction: row;
237
+  display: flex;
238
+}
239
+
240
+#search-result .result-info .result-head .result-duration {
241
+  flex: 1;
242
+  padding: 5px 0 10px;
243
+  text-align: right;
244
+}
245
+
246
+#search-result .result-info .result-head .result-title {
247
+  -ms-text-overflow: ellipsis;
248
+  text-overflow: ellipsis;
249
+  white-space: nowrap;
250
+  overflow: hidden;
251
+  padding: 5px 10px 10px 0;
252
+  flex:10;
253
+}
254
+#search-result .result-info .result-stats {
255
+  display: flex;
256
+  flex-direction: row;
257
+  font-size: 0.8em;
258
+}
259
+
260
+#search-result .result-info .result-stats div {
261
+  flex: 1;
139
 }
262
 }

+ 77
- 15
imports/api/links.js 파일 보기

2
 import { Mongo } from 'meteor/mongo';
2
 import { Mongo } from 'meteor/mongo';
3
 import { check, Match } from 'meteor/check';
3
 import { check, Match } from 'meteor/check';
4
 
4
 
5
-export const Links = new Mongo.Collection('links');
5
+export const Converted = new Mongo.Collection('links');
6
 
6
 
7
 const YTExp = /http(?:s?):\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-_]*)/;
7
 const YTExp = /http(?:s?):\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-_]*)/;
8
 
8
 
9
 if (Meteor.isServer) {
9
 if (Meteor.isServer) {
10
-	// This code only runs on the server
11
 	Meteor.publish('links', function linksPublication() {
10
 	Meteor.publish('links', function linksPublication() {
12
-		return Links.find({
13
-			 completed: {$ne: true}
14
-		});
11
+		return Converted.find();
15
 	});
12
 	});
16
 }
13
 }
17
 
14
 
15
+const convert = (url, filename, id) => {
16
+	if (Meteor.isServer) {
17
+		console.log('Converting ...');
18
+		const fs = Meteor.npmRequire("fs");
19
+		const ytdl = Meteor.npmRequire('ytdl-core');
20
+		const path = Meteor.npmRequire('path');
21
+		const ffmpeg = Meteor.npmRequire('fluent-ffmpeg');
22
+		const filePath = path.resolve('/home/kod3/projects/SoundWave/uploaded/', filename)
23
+		const videoPath = filePath + '.mp4';
24
+		const audioPath = filePath + '.mp3';
25
+		ytdl.getInfo(url, {}, Meteor.bindEnvironment((err, info) => {
26
+			console.info(info ? 'ok' : 'ko');
27
+			if (err) {
28
+				console.log(url);
29
+				console.error(err);
30
+				Converted.update({ _id: id }, { $set: { error: 'Video is unreachable' } });
31
+			} else {
32
+				const totalSeconds = info ? info.length_seconds : 0;
33
+				const dl = ytdl(url);
34
+				dl.pipe(fs.createWriteStream(videoPath));
35
+				ffmpeg(dl)
36
+					.noVideo()
37
+					.on('end', Meteor.bindEnvironment(() => {
38
+						Converted.update({ _id: id }, { $set: { audio: audioPath, audio_progress: 100 } });
39
+					}))
40
+					.on('progress', Meteor.bindEnvironment((params) => {
41
+						const time = moment(new Date(...[0, 0, 0, ...params.timemark.split(':')]));
42
+						const seconds = time.hours() * 3600 + time.minutes() * 60 + time.seconds();
43
+						let percent = ((seconds / totalSeconds) * 100).toFixed(0);
44
+						Converted.update({ _id: id }, { $set: { audio_progress: percent } });
45
+					}))
46
+					.on('error', (err) => { console.error('MP3 encoding error: ' + err.message); })
47
+					.format('mp3')
48
+					.output(fs.createWriteStream(audioPath))
49
+					.run();
50
+				dl.on('info', (info, format) => console.log);
51
+				dl.on('response', Meteor.bindEnvironment((res) => {
52
+					const totalSize = res.headers['content-length'];
53
+					let dataRead = 0;
54
+					res.on('data', Meteor.bindEnvironment((data) => {
55
+						dataRead += data.length;
56
+						let percent = dataRead / totalSize;
57
+						Converted.update({ _id: id }, { $set: { video_progress: (percent * 100).toFixed(0) } });
58
+					}));
59
+					res.on('end', Meteor.bindEnvironment(() => {
60
+						Converted.update({ _id: id }, { $set: { video: videoPath, video_progress: 100 } });
61
+					}));
62
+					res.on('error', (err) => { console.error('Error: ' + err.message) });
63
+				}));
64
+			}
65
+		}));
66
+	} else return false;
67
+};
68
+
18
 Meteor.methods({
69
 Meteor.methods({
19
 
70
 
20
-  'links.insert'(url) {
21
-	check(url, Match.Where((url) => YTExp.test(url)));
22
-    Links.insert({
23
-      url,
24
-      createdAt: new Date(),
25
-    });
26
-  },
71
+  'links.add'({video, type}) {
72
+  	check(video.url, Match.Where(url => YTExp.test(url)));
73
+	check(type, Match.Where(t => ['audio'].includes(t)));
27
 
74
 
28
-  'links.remove'(linkId) {
29
-    check(linkId, String);
30
-    Links.remove(linkId);
75
+ 	let converted = Converted.findOne({
76
+		url: video.url
77
+	});
78
+
79
+	if (!converted) {
80
+		const filename = video.title.replace(/ /g, '_');
81
+		var inserted_id = Converted.insert({
82
+			id: video.id,
83
+			url: video.url,
84
+			title: video.title,
85
+			video: '',
86
+			video_progress: 0,
87
+			audio: '',
88
+			audio_progress: 0,
89
+			createdAt: new Date()
90
+		});
91
+		convert(video.url, filename, inserted_id);
92
+	}
31
   },
93
   },
32
 
94
 
33
 });
95
 });

+ 80
- 37
imports/ui/App.jsx 파일 보기

1
 import React, { Component, PropTypes } from 'react';
1
 import React, { Component, PropTypes } from 'react';
2
-import ReactDOM from 'react-dom';
3
 import { Meteor } from 'meteor/meteor';
2
 import { Meteor } from 'meteor/meteor';
3
+import { Tracker } from 'meteor/tracker';
4
 import { createContainer } from 'meteor/react-meteor-data';
4
 import { createContainer } from 'meteor/react-meteor-data';
5
-import { Links } from '../api/links.js';
6
-import Link from './Link.jsx';
7
-import AccountsUIWrapper from './AccountsUIWrapper.jsx';
5
+import { Converted } from '../api/links';
6
+import Links from './Links';
7
+import { YouTubeSearch, YouTubeVideoInfo } from './YouTubeAPI';
8
+import Video from './Video';
9
+import Input from "./Input";
10
+import Results from "./Results";
11
+import OptionsAll from "./OptionsAll";
8
 
12
 
9
-// App component - represents the whole app
10
 class App extends Component {
13
 class App extends Component {
11
 	constructor(props) {
14
 	constructor(props) {
12
 		super(props);
15
 		super(props);
13
-		this.state = { };
14
-	}
15
-
16
-	renderLinks() {
17
-		return this.props.links.map((link) => {
18
-			return (
19
-				<Link key={link._id} link={link} />
20
-			)
21
-		});
22
-	}
23
-
24
-	handleSubmit(event) {
25
-		event.preventDefault();
26
-
27
-		// Find the text field via the React ref
28
-		const text = ReactDOM.findDOMNode(this.refs.textInput).value.trim();
29
-
30
-		Meteor.call('links.insert', text);
31
-
32
-		// Clear form
33
-		ReactDOM.findDOMNode(this.refs.textInput).value = '';
16
+		this.state = { result: [], links: [], value: '' };
17
+		this.options = [
18
+			{ type: 'MP3', handler: this.downloadAudio.bind(this) },
19
+			{ type: 'MP4', handler: this.downloadVideo.bind(this) },
20
+		];
21
+		this.optionsAll = [
22
+			{ type: 'MP3', handler: this.downloadAllAudio.bind(this) },
23
+			{ type: 'MP4', handler: this.downloadAllVideo.bind(this) },
24
+		];
34
 	}
25
 	}
35
 
26
 
36
 	render() {
27
 	render() {
39
 				<h1 className="logo">SoundWave</h1>
30
 				<h1 className="logo">SoundWave</h1>
40
 				<div className="box-container">
31
 				<div className="box-container">
41
 					<header>
32
 					<header>
42
-						<AccountsUIWrapper />
43
-						<form className="new-link" onSubmit={this.handleSubmit.bind(this)}>
44
-							<input type="text" ref="textInput" placeholder="Search a video" />
45
-						</form>
33
+						<Input value={this.state.value} change={this.inputChange.bind(this)} />
34
+						<Results results={this.state.result} select={this.selectResult.bind(this)} />
46
 					</header>
35
 					</header>
47
-					<ul>{ this.renderLinks() }</ul>
36
+					<Links links={this.state.links} remove={this.removeLink.bind(this)} handlers={this.options}/>
37
+					<OptionsAll count={this.state.links.length} handlers={this.optionsAll}
38
+								links={this.state.links} converted={this.props.converted} />
48
 				</div>
39
 				</div>
49
 			</div>
40
 			</div>
50
 		);
41
 		);
51
 	}
42
 	}
43
+
44
+	downloadAudio(video) {
45
+		console.log('Requesting mp3 for ' + video.id);
46
+	}
47
+
48
+	downloadVideo(video) {
49
+		console.log('Requesting mp4 for ' + video.id);
50
+	}
51
+
52
+	downloadAllAudio() {
53
+		console.log('Requesting mp3 for all');
54
+	}
55
+
56
+	downloadAllVideo() {
57
+		console.log('Requesting mp4 for all');
58
+	}
59
+
60
+	inputChange(text) {
61
+		this.setState({ value: text }, this.search);
62
+	}
63
+
64
+	removeLink(link) {
65
+		if (_.some(this.state.links, e => e.id === link.id))
66
+			this.setState({ links: _.reject(this.state.links, e => e.id === link.id) });
67
+	}
68
+
69
+	selectResult(video) {
70
+		if (_.every(this.state.links, e => e.id !== video.id)) {
71
+			if (_.some(this.props.converted, e => e.id === video.id)) {
72
+				video.converted = _.find(this.props.converted, e => e.id === video.id)._id;
73
+			} else
74
+				Meteor.call('links.add', {video, type: 'audio'}, (err, res) => {
75
+					if (err)
76
+						console.error(err);
77
+					else
78
+						video.converted = _.find(this.props.converted, e => e.id === video.id)._id;
79
+				});
80
+			this.setState({links: [...this.state.links, video]});
81
+		}
82
+		this.setState({ value: '', result: [] });
83
+	}
84
+
85
+	search() {
86
+		if (!this.state.value) {
87
+			this.setState({ result: [] });
88
+		} else {
89
+			YouTubeSearch(this.state.value)
90
+				.then(e => e.items.map(e => new Video(e)))
91
+				.then(e => { this.setState({ result: e }); return e; })
92
+				.then(e => e.forEach(i => YouTubeVideoInfo(i.id)
93
+					.then(r => i.update(r.items[0].statistics, r.items[0].contentDetails.duration))
94
+					.then(() => this.setState({ result: e }))
95
+				));
96
+		}
97
+	}
52
 }
98
 }
53
 
99
 
54
 App.propTypes = {
100
 App.propTypes = {
55
-	links: PropTypes.array.isRequired,
56
-	currentUser: PropTypes.object,
101
+	converted: PropTypes.array.isRequired,
57
 };
102
 };
58
 
103
 
59
 export default createContainer(() => {
104
 export default createContainer(() => {
60
 	Meteor.subscribe('links');
105
 	Meteor.subscribe('links');
61
-
62
 	return {
106
 	return {
63
-		links: Links.find({}, {sort: {createdAt: -1}}).fetch(),
64
-		currentUser: Meteor.user(),
107
+		converted: Converted.find({}).fetch(),
65
 	};
108
 	};
66
-}, App);
109
+}, App)

+ 19
- 0
imports/ui/Input.jsx 파일 보기

1
+import React, {Component, PropTypes} from 'react';
2
+
3
+export default class Input extends Component {
4
+	handleChange(e) {
5
+		this.props.change(e.target.value);
6
+	}
7
+
8
+	render() {
9
+		return (
10
+			<input type="text" ref="textInput" placeholder="Search a video" autoFocus
11
+				   value={this.props.value} className="new-link" onChange={this.handleChange.bind(this)} />
12
+		);
13
+	}
14
+}
15
+
16
+Input.propTypes = {
17
+	change: PropTypes.func.isRequired,
18
+	value: PropTypes.string.isRequired
19
+};

+ 22
- 22
imports/ui/Link.jsx 파일 보기

1
 import React, {Component, PropTypes} from 'react';
1
 import React, {Component, PropTypes} from 'react';
2
-import classnames from 'classnames';
3
-
4
-// Task component - represents a single todo item
5
-export default class Link extends Component {
6
-
7
-	deleteThisLink() {
8
-		Meteor.call('links.remove', this.props.link._id);
9
-	}
2
+import { createContainer } from 'meteor/react-meteor-data';
3
+import { Converted } from '../api/links';
4
+import Options from './Options';
5
+import Video from './Video';
10
 
6
 
7
+class Link extends Component {
11
 	render() {
8
 	render() {
12
-		// Give tasks a different className when they are checked off,
13
-		// so that we can style them nicely in CSS
14
-		const linkClassName = classnames({
15
-			completed: this.props.link.completed,
16
-		});
17
-
18
 		return (
9
 		return (
19
-			<li className={linkClassName}>
20
-				<button className="delete" onClick={this.deleteThisLink.bind(this)}>
21
-					&times;
22
-				</button>
23
-				<span className="text">{this.props.link.url}</span>
10
+			<li className="link">
11
+				<span className="link-text" title={this.props.link.title}>{this.props.link.title}</span>
12
+				<Options className="link-options" handlers={this.props.options}
13
+						 video={this.props.link} converted={this.props.converted}/>
14
+				<button className="link-delete" onClick={this.props.remove}>&times;</button>
24
 			</li>
15
 			</li>
25
 		);
16
 		);
26
 	}
17
 	}
27
 }
18
 }
28
 
19
 
29
 Link.propTypes = {
20
 Link.propTypes = {
30
-	// This component gets the task to display through a React prop.
31
-	// We can use propTypes to indicate it is required
32
-	link: PropTypes.object.isRequired,
21
+	link: PropTypes.instanceOf(Video).isRequired,
22
+	remove: PropTypes.func.isRequired,
23
+	options: PropTypes.arrayOf(PropTypes.shape({
24
+		type: PropTypes.string,
25
+		handler: PropTypes.func
26
+	})).isRequired,
27
+	converted: PropTypes.object
33
 };
28
 };
29
+
30
+export default createContainer(({link, converted_id, remove, options}) => {
31
+	const converted = Converted.findOne(converted_id);
32
+	return { link, remove, options, converted }
33
+}, Link);

+ 27
- 0
imports/ui/Links.jsx 파일 보기

1
+import React, {Component, PropTypes} from 'react';
2
+import { createContainer } from 'meteor/react-meteor-data';
3
+import Link from './Link';
4
+import Video from './Video';
5
+
6
+export default class Links extends Component {
7
+	render() {
8
+		return (
9
+			<ul>
10
+				{ this.props.links.map(link => (
11
+					<Link key={link.id} converted_id={link.converted}
12
+						  link={link} options={this.props.handlers}
13
+						  remove={() => this.props.remove(link)}/>
14
+				)) }
15
+			</ul>
16
+		);
17
+	}
18
+}
19
+
20
+Links.propTypes = {
21
+	links: PropTypes.arrayOf(PropTypes.instanceOf(Video)).isRequired,
22
+	remove: PropTypes.func.isRequired,
23
+	handlers: PropTypes.arrayOf(PropTypes.shape({
24
+		type: PropTypes.string,
25
+		handler: PropTypes.func
26
+	})).isRequired
27
+};

+ 51
- 0
imports/ui/Options.jsx 파일 보기

1
+import React, {Component, PropTypes} from 'react';
2
+import Video from './Video';
3
+import classnames from 'classnames';
4
+
5
+export default class Options extends Component {
6
+
7
+	getProgress(type) {
8
+		return !this.props.converted ? 0 :
9
+			this.props.converted[type === 'MP3' ? 'audio_progress' : 'video_progress'];
10
+	}
11
+
12
+	isDisabled(type) {
13
+		return !this.props.converted ||
14
+			!this.props.converted[type === 'MP3' ? 'audio' : 'video'];
15
+	}
16
+
17
+	getClasses() {
18
+		return classnames({
19
+			option: true,
20
+			unreachable: this.props.converted.error
21
+		});
22
+	}
23
+
24
+	getErrorMessage() {
25
+		return this.props.converted.error || null;
26
+	}
27
+
28
+	render() {
29
+		return (
30
+			<div>
31
+				{ _.map(this.props.handlers, (h) => (
32
+					<button key={h.type} className={this.getClasses()}
33
+							onClick={() => h.handler(this.props.video)}
34
+							disabled={this.isDisabled(h.type)} title={this.getErrorMessage()}>
35
+						<div className="option-progress" style={{width: this.getProgress(h.type) + '%'}}></div>
36
+						<i className="fa fa-download"/> {h.type}
37
+					</button>
38
+				)) }
39
+			</div>
40
+		);
41
+	}
42
+}
43
+
44
+Options.propTypes = {
45
+	handlers: PropTypes.arrayOf(PropTypes.shape({
46
+		type: PropTypes.string,
47
+		handler: PropTypes.func
48
+	})),
49
+	video: PropTypes.instanceOf(Video),
50
+	converted: PropTypes.object
51
+};

+ 43
- 0
imports/ui/OptionsAll.jsx 파일 보기

1
+import React, {Component, PropTypes} from 'react';
2
+import Video from './Video';
3
+
4
+export default class OptionsAll extends Component {
5
+
6
+	renderCount() {
7
+		return (this.props.count || 0) + ' link' + (this.props.count > 1 ? 's' : '' );
8
+	}
9
+
10
+	isDisabled(type) {
11
+		const prop = (type === 'MP3' ? 'audio' : 'video');
12
+		return !_.every(this.props.links, (e) => {
13
+			const converted = _.find(this.props.converted, i => e.id === i.id);
14
+			return converted ? converted[prop] : true;
15
+		});
16
+	}
17
+
18
+	render() {
19
+		if (!this.props.count) return null;
20
+		return (
21
+			<div className="options-all">
22
+				<div className="label">ALL: {this.renderCount()}</div>
23
+				<div>
24
+					{ _.map(this.props.handlers, (h) => (
25
+						<button key={h.type} className="option" onClick={h.handler} disabled={this.isDisabled(h.type)}>
26
+							<i className="fa fa-download"/> {h.type}
27
+						</button>
28
+					)) }
29
+				</div>
30
+			</div>
31
+		);
32
+	}
33
+}
34
+
35
+OptionsAll.propTypes = {
36
+	count: PropTypes.number.isRequired,
37
+	links: PropTypes.arrayOf(PropTypes.instanceOf(Video)),
38
+	converted: PropTypes.arrayOf(PropTypes.object),
39
+	handlers: PropTypes.arrayOf(PropTypes.shape({
40
+		type: PropTypes.string,
41
+		handler: PropTypes.func
42
+	})).isRequired,
43
+};

+ 42
- 0
imports/ui/Result.jsx 파일 보기

1
+import React, {Component, PropTypes} from 'react';
2
+import Video from './Video';
3
+
4
+export default class Result extends Component {
5
+	render() {
6
+		return (
7
+			<a className="result-link" onClick={this.props.click}>
8
+				<img src={this.props.result.thumb.url} alt="thumbnail" className="result-thumb"
9
+					 height={this.props.result.thumb.height / 2}
10
+					 width={this.props.result.thumb.width / 2}/>
11
+				<div className="result-info">
12
+					<div className="result-head" title={this.props.result.title}>
13
+						<div className="result-title">{this.props.result.title}</div>
14
+						<div className="result-duration">{this.props.result.duration}</div>
15
+					</div>
16
+					<div className="result-stats">
17
+						<div className="result-views" title={this.props.result.stats.viewCount}>
18
+							<i className="fa fa-eye"/> {this.props.result.stats.viewCount}
19
+						</div>
20
+						<div className="result-likes" title={this.props.result.stats.likeCount}>
21
+							<i className="fa fa-thumbs-o-up"/> {this.props.result.stats.likeCount}
22
+						</div>
23
+						<div className="result-dislikes" title={this.props.result.stats.dislikeCount}>
24
+							<i className="fa fa-thumbs-o-down"/> {this.props.result.stats.dislikeCount}
25
+						</div>
26
+						<div className="result-comments" title={this.props.result.stats.commentCount}>
27
+							<i className="fa fa-comments-o"/> {this.props.result.stats.commentCount}
28
+						</div>
29
+						<div className="result-favorites" title={this.props.result.stats.favoriteCount}>
30
+							<i className="fa fa-star-o"/> {this.props.result.stats.favoriteCount}
31
+						</div>
32
+					</div>
33
+				</div>
34
+			</a>
35
+		);
36
+	}
37
+}
38
+
39
+Result.propTypes = {
40
+	result: PropTypes.instanceOf(Video).isRequired,
41
+	click: PropTypes.func
42
+};

+ 21
- 0
imports/ui/Results.jsx 파일 보기

1
+import React, {Component, PropTypes} from 'react';
2
+import Video from './Video';
3
+import Result from './Result';
4
+
5
+export default class Results extends Component {
6
+	render() {
7
+		if (!this.props.results.length) return null;
8
+		return (
9
+			<ul id="search-result">
10
+				{ this.props.results.map(res => (
11
+					<Result key={res.id} result={res} click={() => this.props.select(res) }/>
12
+				)) }
13
+			</ul>
14
+		);
15
+	}
16
+}
17
+
18
+Results.propTypes = {
19
+	results: PropTypes.arrayOf(PropTypes.instanceOf(Video)).isRequired,
20
+	select: PropTypes.func.isRequired
21
+};

+ 25
- 0
imports/ui/Video.js 파일 보기

1
+export default class Video {
2
+	constructor(result) {
3
+		this.id = result.id.videoId;
4
+		this.title = result.snippet.title;
5
+		this.thumb = result.snippet.thumbnails.default;
6
+		this.url = `https://www.youtube.com/watch?v=${this.id}`;
7
+		this.stats = {
8
+			viewCount: 0,
9
+			likeCount: 0,
10
+			dislikeCount: 0,
11
+			commentCount: 0,
12
+			favoriteCount: 0,
13
+		};
14
+		this.duration = 0;
15
+		this.converted = {};
16
+	}
17
+
18
+	update(stats, duration) {
19
+		for (count in stats)
20
+			this.stats[count] = numeral(stats[count]).format('0.[00]a');
21
+		console.log(duration);
22
+		const time = moment.utc(moment.duration(duration).asMilliseconds())
23
+		this.duration = time.format(time.hours() ? 'k:mm:ss' : 'm:ss');
24
+	}
25
+}

+ 42
- 0
imports/ui/YouTubeAPI.js 파일 보기

1
+import 'whatwg-fetch';
2
+import querystring from 'querystring';
3
+
4
+const KEY = 'AIzaSyDh641dLzxYexCASA3TdYMlc6zzS793ppQ';
5
+const ENDPOINT = 'https://www.googleapis.com/youtube/v3';
6
+
7
+const checkStatus = (response) => {
8
+	if (response.status >= 200 && response.status < 300) {
9
+		return response
10
+	} else {
11
+		let error = new Error(response.statusText);
12
+		error.response = response;
13
+		throw error
14
+	}
15
+};
16
+
17
+const parseJson = response => response.json();
18
+
19
+export function YouTubeSearch(query) {
20
+	const params = {
21
+		part: 'snippet',
22
+		type: 'video',
23
+		key: KEY,
24
+		q: query
25
+	};
26
+
27
+	return fetch(`${ENDPOINT}/search?${querystring.stringify(params)}`)
28
+		.then(checkStatus)
29
+		.then(parseJson);
30
+}
31
+
32
+export function YouTubeVideoInfo(id) {
33
+	const params = {
34
+		part: 'statistics,contentDetails',
35
+		key: KEY,
36
+		id: id
37
+	};
38
+
39
+	return fetch(`${ENDPOINT}/videos?${querystring.stringify(params)}`)
40
+		.then(checkStatus)
41
+		.then(parseJson);
42
+}

+ 8
- 1
package.json 파일 보기

8
     "babel-runtime": "^6.20.0",
8
     "babel-runtime": "^6.20.0",
9
     "bcrypt": "^1.0.2",
9
     "bcrypt": "^1.0.2",
10
     "classnames": "^2.2.5",
10
     "classnames": "^2.2.5",
11
+    "fetch": "^1.1.0",
12
+    "gapi": "0.0.3",
11
     "meteor-node-stubs": "~0.2.4",
13
     "meteor-node-stubs": "~0.2.4",
14
+    "querystring": "^0.2.0",
12
     "react": "^15.4.2",
15
     "react": "^15.4.2",
13
     "react-addons-pure-render-mixin": "^15.4.2",
16
     "react-addons-pure-render-mixin": "^15.4.2",
14
-    "react-dom": "^15.4.2"
17
+    "react-dom": "^15.4.2",
18
+    "youtube-dl": "^1.11.1"
19
+  },
20
+  "devDependencies": {
21
+    "babel-plugin-transform-class-properties": "^6.23.0"
15
   }
22
   }
16
 }
23
 }

+ 4
- 0
packages.json 파일 보기

1
+{
2
+  "ytdl-core":"0.7.9",
3
+  "fluent-ffmpeg":"2.1.0"
4
+}