hktv 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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