hktv 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 480088f574c5b62191b13bf05b158af33316b6a0
4
+ data.tar.gz: b8cde4170b9e46f412663028818720cea5a21c51
5
+ SHA512:
6
+ metadata.gz: db2ea07db61ffa58cdd23dd7043072bcf02d41f5c03c943c03a33c5cce3b34bfb6e8424118d893c48b881b290af38b16316fded9d69ecf0e3f065defba45f006
7
+ data.tar.gz: ded7e980b52a9555b8320c573dd02c7231be9d0360e66fe462f967c0f401d25bd0eb9eaa087b48b534016ad2624310a832e5a16624f52f485b4859026b8edc06
@@ -0,0 +1,15 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ *.bundle
11
+ *.so
12
+ *.o
13
+ *.a
14
+ mkmf.log
15
+ *.gem
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in hktv.gemspec
4
+ gemspec
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Francis Chong
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,64 @@
1
+ # HKTV
2
+
3
+ Command line utilities to find and download HKTV videos.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'hktv', github: "siuying/ruby-hktv"
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle
16
+
17
+ Or install it via:
18
+
19
+ $ gem install hktv
20
+
21
+
22
+ ## Prerequisite
23
+
24
+ - Ruby 2.x
25
+ - Only tested on OS X Yosemite, it may work on Linux.
26
+ - Requries ``ffmpeg`` for combine video.
27
+
28
+ ## Usage
29
+
30
+ ### Login to HKTV
31
+
32
+ Login to HKTV. This is required for download video.
33
+
34
+ $ hktv login
35
+
36
+ ### List all video of HKTV
37
+
38
+ Print a comma sepeated list of programs.
39
+
40
+ $ hktv list "選戰"
41
+ 選戰 第1集 第1節
42
+ 選戰 第1集 第2節
43
+ ...
44
+ 選戰 第7集 第3節
45
+ 選戰 第7集 第4節
46
+
47
+ ### Download an Episode
48
+
49
+ Download all video files of an episode, and merge them into single file.
50
+
51
+ $ hktv download "選戰 第7集"
52
+ Downloading: 選戰 第7集 第1節
53
+ Downloading: 選戰 第7集 第2節
54
+ Downloading: 選戰 第7集 第3節
55
+ Downloading: 選戰 第7集 第4節
56
+ Merge videos into 選戰_第7集.mp4
57
+
58
+ ## Contributing
59
+
60
+ 1. Fork it ( https://github.com/siuying/ruby-hktv/fork )
61
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
62
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
63
+ 4. Push to the branch (`git push origin my-new-feature`)
64
+ 5. Create a new Pull Request
@@ -0,0 +1,2 @@
1
+ require "bundler/gem_tasks"
2
+
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative '../lib/hktv/command'
3
+
4
+ HKTV::Command.new.run
@@ -0,0 +1,31 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'hktv/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "hktv"
8
+ spec.version = HKTV::VERSION
9
+ spec.authors = ["Francis Chong"]
10
+ spec.email = ["francis@ignition.hk"]
11
+ spec.summary = %q{Find and download HKTV videos.}
12
+ spec.description = %q{Command line utilities to find and download HKTV videos.}
13
+ spec.homepage = "https://github.com/siuying/ruby-hktv"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_development_dependency "bundler", "~> 1.7"
22
+ spec.add_development_dependency "rake", "~> 10.0"
23
+ spec.add_development_dependency "pry", '~> 0'
24
+ spec.add_development_dependency "rspec", '~> 0'
25
+ spec.add_development_dependency "vcr", '~> 0'
26
+ spec.add_development_dependency "webmock", '~> 0'
27
+
28
+ spec.add_dependency "httparty", "~> 0.13", '>= 0.13.1'
29
+ spec.add_dependency "retriable", "~> 1.4", '>= 1.4.1'
30
+ spec.add_dependency 'commander', "~> 4.2", '>= 4.2.1'
31
+ end
@@ -0,0 +1,241 @@
1
+ require_relative "./hktv/version"
2
+
3
+ require "httparty"
4
+ require 'retriable'
5
+
6
+ require "digest/md5"
7
+ require "json"
8
+ require 'securerandom'
9
+
10
+ class HKTV
11
+ include HTTParty
12
+
13
+ # set by auth
14
+ attr_accessor :access_token, :expires_date, :refresh_token
15
+
16
+ # set by token
17
+ attr_accessor :user_id, :user_level, :ott_token, :ott_expires_date
18
+
19
+ base_uri 'webservices.hktv.com.hk'
20
+ headers "Content_type" => "application/x-www-form-urlencoded", "Accept" => "*/*"
21
+
22
+ API_BASE = "/"
23
+ API_TOKEN = "account/token"
24
+ API_FEATURE = "lists/getFeature"
25
+ API_PLAYLIST = "playlist/request"
26
+
27
+ API_SECRET = "43e814b31f8764756672c1cd1217d775"
28
+ API_KI = "12"
29
+ API_VID = "1"
30
+
31
+ API_MUID = "0"
32
+ API_DEVICE = "USB Android TV"
33
+ API_MANUF = "hktv-ruby"
34
+ API_MODEL = "Ruby"
35
+ API_OS = "0.1.0"
36
+ API_MX_RES = "1920"
37
+ API_NETWORK = "fixed"
38
+
39
+ # hardcoded user in HKTV app
40
+ API_USERNAME = "hktv_ios"
41
+ API_PASSWORD = "H*aK#)HM248"
42
+
43
+ def initialize(uuid: SecureRandom.uuid, access_token: nil, expires_date: nil, refresh_token: nil, user_id: "1", user_level: nil, ott_token: nil, ott_expires_date: nil)
44
+ @uuid = uuid
45
+ @access_token = access_token
46
+ @expires_date = expires_date
47
+ @refresh_token = refresh_token
48
+ @ott_token = ott_token
49
+ @ott_expires_date = ott_expires_date
50
+ @user_id = user_id
51
+ @user_level = user_level
52
+ end
53
+
54
+ # return true if client has authenticated, false otherwise
55
+ def authenticated?
56
+ if @expires_date && Time.now > @expires_date
57
+ @access_token = nil
58
+ @expires_date = nil
59
+ @refresh_token = nil
60
+ end
61
+
62
+ !@access_token.nil?
63
+ end
64
+
65
+ # return true if client needs ott token
66
+ def needs_ott_token?
67
+ return @ott_token.nil? || (@ott_expires_date && Time.now > @ott_expires_date)
68
+ end
69
+
70
+ # Authenticate user with given username/password
71
+ # return true if success
72
+ def auth(username, password)
73
+ options = {
74
+ "grant_type" => "password",
75
+ "username" => username,
76
+ "password" => password
77
+ }
78
+
79
+ auth = post_json("https://www.hktvmall.com:443/hktvwebservices/oauth/token?rand=#{Time.now.to_i}", body: options, basic_auth: {username: API_USERNAME, password: API_PASSWORD})
80
+ @access_token = auth["access_token"]
81
+ @expires_date = Time.now + auth["expires_in"]
82
+ @refresh_token = auth["refresh_token"]
83
+ !@access_token.nil?
84
+ end
85
+
86
+ # get the OTT token
87
+ def ott_token
88
+ result = nil
89
+
90
+ # hktv api just fail for unknown reason, retry to workaround it
91
+ Retriable.retriable(tries: 20, base_interval: 1.0) do
92
+ if authenticated?
93
+ result = get_json("https://www.hktvmall.com:443/hktvwebservices/v1/hktv/ott/token?rand=#{Time.now.to_i}", headers: headers)
94
+ else
95
+ ts = Time.now.to_i
96
+ options = {
97
+ "ki" => API_KI,
98
+ "ts" => ts.to_s,
99
+ "s" => sign_request(API_TOKEN, ts, [API_KI, API_MUID]),
100
+ "muid" => API_MUID
101
+ }
102
+ result = self.class.post(API_BASE + API_TOKEN, body: options, headers: headers)
103
+ end
104
+
105
+ if result["errors"]
106
+ raise result["errors"].first["message"]
107
+ end
108
+
109
+ result
110
+ end
111
+
112
+ @user_id = result["user_id"]
113
+ @user_level = result["user_level"]
114
+ @ott_token = result["token"]
115
+
116
+ !@user_id.nil?
117
+ end
118
+
119
+ def logout
120
+ @user_id = nil
121
+ @user_level = nil
122
+ @ott_token = nil
123
+
124
+ @access_token = nil
125
+ @expires_in = nil
126
+ @refresh_token = nil
127
+
128
+ if authenticated?
129
+ self.class.post("https://www.hktvmall.com:443/hktvwebservices/v1/customers/current/logout", headers: headers)["success"]
130
+ else
131
+ true
132
+ end
133
+ end
134
+
135
+ # get a playlist of given video
136
+ # @param video_id the video ID
137
+ # @return URL to the playlist
138
+ def playlist(video_id="1")
139
+ self.ott_token if needs_ott_token?
140
+
141
+ ts = Time.now.to_i
142
+ signature = sign_request(API_PLAYLIST, ts, [API_DEVICE, API_KI, API_MODEL, API_MANUF, API_MX_RES, API_NETWORK, API_OS, @ott_token, @uuid, @user_id, video_id])
143
+ options = {
144
+ "d" => API_DEVICE,
145
+ "ki" => API_KI,
146
+ "mdl" => API_MODEL,
147
+ "mf" => API_MANUF,
148
+ "mxres" => API_MX_RES,
149
+ "net" => API_NETWORK,
150
+ "os" => API_OS,
151
+ "t" => @ott_token,
152
+ "udid" => @uuid,
153
+ "uid" => @user_id,
154
+ "vid" => video_id,
155
+ "ts" => ts.to_s,
156
+ "s" => signature
157
+ }
158
+ self.class.post(API_BASE + API_PLAYLIST, body: options)["m3u8"]
159
+ end
160
+
161
+ # list featured video on HKTV
162
+ # @return nested array of video
163
+ def features(lang="zh-Hant", count=999)
164
+ self.ott_token if needs_ott_token?
165
+
166
+ ts = Time.now.to_i
167
+ options = {
168
+ "lang" => lang,
169
+ "lim" => count.to_s,
170
+ "lut" => "0",
171
+ "_" => ts.to_s,
172
+ "ofs" => "0"
173
+ }
174
+ self.class.get("http://ott-www.hktvmall.com/api/lists/getFeature", query: options)["videos"]
175
+ end
176
+
177
+ # list programs on HKTV
178
+ # @return nested array of video
179
+ def programs(lang="zh-Hant", count=999)
180
+ ts = Time.now.to_i
181
+ options = {
182
+ "lang" => lang,
183
+ "lim" => count.to_s,
184
+ "lut" => "0",
185
+ "_" => ts.to_s,
186
+ "ofs" => "0"
187
+ }
188
+ self.class.get("http://ott-www.hktvmall.com/api/lists/getProgram", query: options)["videos"]
189
+ end
190
+
191
+ def to_hash
192
+ json = {
193
+ uuid: @uuid,
194
+ access_token: @access_token,
195
+ refresh_token: @refresh_token,
196
+ user_id: @user_id,
197
+ user_level: @user_level,
198
+ ott_token: @ott_token
199
+ }
200
+ json[:expires_date] = @expires_date.to_i if @expires_date
201
+ json[:ott_expires_date] = @ott_expires_date.to_i if @ott_expires_date
202
+ return json
203
+ end
204
+
205
+ def self.from_hash(json)
206
+ if json[:"expires_date"]
207
+ json[:expires_date] = Time.at(json[:expires_date])
208
+ end
209
+ if json[:ott_expires_date]
210
+ json[:ott_expires_date] = Time.at(json[:ott_expires_date])
211
+ end
212
+ return HKTV.new(json)
213
+ end
214
+
215
+ private
216
+ def headers
217
+ if authenticated?
218
+ return {
219
+ "Authorization" => "Bearer #{@access_token}"
220
+ }
221
+ else
222
+ return {}
223
+ end
224
+ end
225
+
226
+ def post_json(path, options={})
227
+ response = self.class.post(path, options)
228
+ data = response.body
229
+ JSON.parse(data)
230
+ end
231
+
232
+ def get_json(path, options={})
233
+ response = self.class.get(path, options)
234
+ data = response.body
235
+ JSON.parse(data)
236
+ end
237
+
238
+ def sign_request(path, timestamp, params=[])
239
+ return Digest::MD5.hexdigest(path + params.join("") + API_SECRET + timestamp.to_s)
240
+ end
241
+ end
@@ -0,0 +1,183 @@
1
+ require 'commander'
2
+ require 'fileutils'
3
+ require 'csv'
4
+ require 'open3'
5
+ require_relative './version'
6
+ require_relative '../hktv'
7
+
8
+ class HKTV
9
+ class Command
10
+ include Commander::Methods
11
+
12
+ CONFIG_DIR = "#{Dir.home}/.hktv"
13
+ CONFIG_FILE = "#{Dir.home}/.hktv/hktv.json"
14
+
15
+ def load
16
+ hktv = nil
17
+
18
+ if !File.exists?(CONFIG_DIR)
19
+ FileUtils.mkdir_p(CONFIG_DIR)
20
+ end
21
+
22
+ if File.exists?(CONFIG_FILE)
23
+ hktv = HKTV.from_hash(JSON(File.open(CONFIG_FILE).read, symbolize_names: true))
24
+ else
25
+ hktv = HKTV.new
26
+ end
27
+
28
+ return hktv
29
+ end
30
+
31
+ def save(hktv)
32
+ if !File.exists?(CONFIG_DIR)
33
+ FileUtils.mkdir_p(CONFIG_DIR)
34
+ end
35
+
36
+ File.open(CONFIG_FILE, 'w') do |f|
37
+ f.write(JSON.generate(hktv.to_hash))
38
+ end
39
+ end
40
+
41
+ def run
42
+ program :name, 'hktv'
43
+ program :version, HKTV::VERSION
44
+ program :description, 'Lookup HKTV videos'
45
+ program :help_formatter, :compact
46
+
47
+ command :login do |c|
48
+ c.syntax = 'hktv login'
49
+ c.description = 'Login to HKTV'
50
+ c.action do |args, options|
51
+ username = ask("username: ")
52
+ password = ask("password: ") { |q| q.echo = "*" }
53
+
54
+ hktv = self.load
55
+ hktv.logout if hktv.authenticated?
56
+
57
+ if hktv.auth(username, password)
58
+ puts "Logged in"
59
+ self.save(hktv)
60
+ end
61
+ end
62
+ end
63
+
64
+ command :list do |c|
65
+ c.syntax = 'hktv list [episode-title]'
66
+ c.description = 'Print a comma sepeated list of programs of HKTV'
67
+ c.option '--keys keys', String, 'Output data keys, by default "title", available: [title, video_id, category, thumbnail, url, duration]'
68
+ c.option '--playlist', 'Fetch the playlist URL. By default the URL is not fetched.'
69
+
70
+ c.action do |args, options|
71
+ options.default category: "DRAMA", playlist: false, keys: "title"
72
+ hktv = self.load
73
+ programs = extract_root_videos(hktv.programs)
74
+ keys = options.keys.split(",")
75
+ title = args[0]
76
+
77
+ if options.category
78
+ programs = programs.select {|program| program["category"] == options.category }
79
+ end
80
+
81
+ if title
82
+ programs = programs.select {|program| program["title"].include?(title) }
83
+ end
84
+
85
+ if options.playlist
86
+ programs = programs.select {|program| program["url"] = hktv.playlist(program["video_id"]) }
87
+ end
88
+
89
+ if options.playlist && !keys.include?("url") && options.keys == "title,video_id"
90
+ keys << "url"
91
+ end
92
+
93
+ rows = programs.map do |program|
94
+ keys.collect do |key|
95
+ program[key]
96
+ end.to_csv
97
+ end
98
+
99
+ puts rows.join("")
100
+ end
101
+ end
102
+
103
+ command :download do |c|
104
+ c.syntax = 'hktv download [episode-title] (output-filename)'
105
+ c.description = 'Download an episode of HKTV program.'
106
+
107
+ c.action do |args, options|
108
+ hktv = self.load
109
+
110
+ unless hktv.authenticated?
111
+ puts "You have not login! Try \"hktv login\""
112
+ raise "Not logged in."
113
+ end
114
+
115
+ title = args[0]
116
+ filename = args[1]
117
+ raise "Missing episode title" if title.nil?
118
+
119
+ filename = filename_with_title(title, ".mp4") if filename.nil?
120
+ programs = extract_root_videos(hktv.programs)
121
+ programs = programs.select {|program| program["title"].include?(title) }
122
+
123
+ if programs.size == 0
124
+ puts "No video matching \"#{title}\""
125
+ raise "Video not found"
126
+ end
127
+
128
+ # fetch playlist url
129
+ programs.each {|program| program["url"] = hktv.playlist(program["video_id"]) }
130
+
131
+ # download playlist and merge them
132
+ download_and_merge_programs(programs, filename)
133
+ end
134
+ end
135
+
136
+ run!
137
+ end
138
+
139
+ private
140
+ def extract_root_videos(videos)
141
+ videos.collect do |video|
142
+ if video["child_nodes"]
143
+ extract_root_videos(video["child_nodes"])
144
+ else
145
+ video
146
+ end
147
+ end.flatten
148
+ end
149
+
150
+ def filename_with_title(title, ext=".ts")
151
+ title.gsub(/\s/, "_").gsub(/\//, "") + ext
152
+ end
153
+
154
+ def download_and_merge_programs(videos, output)
155
+ temp_files = videos.map {|video| filename_with_title(video["title"]) }
156
+ failed = false
157
+
158
+ begin
159
+ # download the video and convert them into ts file
160
+ # https://trac.ffmpeg.org/wiki/Concatenate
161
+ videos.each do |video|
162
+ url = video["url"]
163
+ title = video["title"]
164
+ puts "Downloading: #{title}"
165
+ `ffmpeg -i \"#{url}\" -c copy -bsf:v h264_mp4toannexb -f mpegts \"#{filename_with_title(title)}\" 2> /dev/null`
166
+ if $?.to_i != 0
167
+ puts "Failed download file."
168
+ end
169
+ end
170
+
171
+ # losslessly merge these ts
172
+ puts "Merge videos into #{output}"
173
+ `ffmpeg -f mpegts -i \"concat:#{temp_files.join("|")}\" -c copy -bsf:a aac_adtstoasc \"#{output}\" 2> /dev/null`
174
+ if $?.to_i != 0
175
+ puts "Failed merging file."
176
+ end
177
+ ensure
178
+ puts "Remove temp file: #{temp_files.join(" ")}"
179
+ `rm \"#{temp_files.join("\" \"")}\"`
180
+ end
181
+ end
182
+ end
183
+ end