imdb-terminal 0.7.4 → 1.1.0
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.
- checksums.yaml +4 -4
- data/README.md +186 -52
- data/bin/imdb +2414 -782
- metadata +62 -26
data/bin/imdb
CHANGED
@@ -1,12 +1,12 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
|
-
#
|
2
|
+
# frozen_string_literal: true
|
3
3
|
|
4
|
-
# PROGRAM INFO
|
5
|
-
# Name: IMDB-
|
4
|
+
# PROGRAM INFO {{{1
|
5
|
+
# Name: IMDB - Movies and Series for the terminal
|
6
6
|
# Language: Pure Ruby, best viewed in VIM
|
7
7
|
# Author: Geir Isene <g@isene.com>
|
8
8
|
# Web_site: http://isene.com/
|
9
|
-
# Github: https://github.com/isene/
|
9
|
+
# Github: https://github.com/isene/IMDB
|
10
10
|
# License: I release all copyright claims. This code is in the public domain.
|
11
11
|
# Permission is granted to use, copy modify, distribute, and sell
|
12
12
|
# this software for any purpose. I make no guarantee about the
|
@@ -14,787 +14,2419 @@
|
|
14
14
|
# for any damages resulting from its use. Further, I am under no
|
15
15
|
# obligation to maintain or extend this software. It is provided
|
16
16
|
# on an 'as is' basis without any expressed or implied warranty.
|
17
|
-
|
18
|
-
|
19
|
-
#
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
@
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
def
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
17
|
+
# Version: 1.1: Full rewrite based on rcurses with much more functionality
|
18
|
+
|
19
|
+
# REQUIRES AND CONSTANTS {{{1
|
20
|
+
require 'io/console'
|
21
|
+
require 'open-uri'
|
22
|
+
require 'json'
|
23
|
+
require 'yaml'
|
24
|
+
require 'date'
|
25
|
+
require 'time'
|
26
|
+
require 'timeout'
|
27
|
+
require 'shellwords'
|
28
|
+
require 'net/http'
|
29
|
+
require 'uri'
|
30
|
+
require 'cgi'
|
31
|
+
require 'nokogiri'
|
32
|
+
require 'fileutils'
|
33
|
+
require 'concurrent'
|
34
|
+
require 'rcurses'
|
35
|
+
|
36
|
+
include Rcurses
|
37
|
+
include Rcurses::Cursor
|
38
|
+
include Rcurses::Input
|
39
|
+
|
40
|
+
Thread.report_on_exception = false
|
41
|
+
CONFIG_FILE = File.join(Dir.home, '.imdb.yml')
|
42
|
+
DATA_DIR = File.join(Dir.home, '.imdb', 'data')
|
43
|
+
CACHE_MUTEX = Mutex.new
|
44
|
+
MAX_FETCH_RETRIES = 3
|
45
|
+
|
46
|
+
# MAIN CLASS {{{1
|
47
|
+
class IMDBApp
|
48
|
+
# INITIALIZATION {{{1
|
49
|
+
def initialize #{{{2
|
50
|
+
@bg_total = Concurrent::AtomicFixnum.new(0)
|
51
|
+
@bg_fetched = Concurrent::AtomicFixnum.new(0)
|
52
|
+
@fetching = false
|
53
|
+
load_config
|
54
|
+
setup_ui
|
55
|
+
@index_list = []; @movies = []; @series = []
|
56
|
+
|
57
|
+
cache = cache_dir
|
58
|
+
list_file = File.join(cache, 'list.json')
|
59
|
+
if !File.exist?(list_file)
|
60
|
+
answer = @progress.ask("No data found. Scrape now? (Y/n) ", "Y")
|
61
|
+
if answer.strip.empty? || answer =~ /\A[yY]/
|
62
|
+
FileUtils.rm_rf(cache); FileUtils.mkdir_p(cache)
|
63
|
+
@progress.say("Scraping IMDb top lists...")
|
64
|
+
load_top250
|
65
|
+
@progress.say("Starting initial fetch of #{@movies.size + @series.size} items...")
|
66
|
+
start_background_fetch
|
67
|
+
else
|
68
|
+
exit
|
69
|
+
end
|
70
|
+
else
|
71
|
+
load_data
|
72
|
+
end
|
73
|
+
if @tmdb_key.nil? || @tmdb_key.strip.empty?
|
74
|
+
@tmdb_key = @progress.ask("TMDb API key not found. Enter key (or press Enter to skip): ", "").strip
|
75
|
+
end
|
76
|
+
build_genre_list
|
77
|
+
rebuild_index
|
78
|
+
@list.border = true
|
79
|
+
@last_poster_id = nil
|
80
|
+
|
81
|
+
run_loop
|
82
|
+
ensure
|
83
|
+
save_config
|
84
|
+
end
|
85
|
+
|
86
|
+
# CONFIGURATION {{{1
|
87
|
+
def load_config #{{{2
|
88
|
+
cfg = File.exist?(CONFIG_FILE) ? (YAML.load_file(CONFIG_FILE) || {}) : {}
|
89
|
+
@rating_threshold = cfg.fetch('rating_threshold', 7.5)
|
90
|
+
@show_movies = cfg.fetch('show_movies', true)
|
91
|
+
@show_series = cfg.fetch('show_series', false)
|
92
|
+
@movie_limit = cfg.fetch('movie_limit', 250)
|
93
|
+
@series_limit = cfg.fetch('series_limit', 250)
|
94
|
+
@sort_by = cfg.fetch('sort_by', 'rating').to_sym
|
95
|
+
@details_cache = Concurrent::Map.new
|
96
|
+
@genre_filters = cfg.fetch('genre_filters', {}).transform_keys(&:to_s).transform_values(&:to_i)
|
97
|
+
|
98
|
+
# Separate wish and dump lists for movies and series
|
99
|
+
@movie_wish_list = cfg.fetch('movie_wish_list', [])
|
100
|
+
@series_wish_list = cfg.fetch('series_wish_list', [])
|
101
|
+
@movie_dump_list = cfg.fetch('movie_dump_list', [])
|
102
|
+
@series_dump_list = cfg.fetch('series_dump_list', [])
|
103
|
+
|
104
|
+
@tmdb_key = cfg.fetch('tmdb_key', ENV['TMDB_KEY'])
|
105
|
+
@tmdb_region = cfg.fetch('tmdb_region', 'US')
|
106
|
+
@year_min = cfg.fetch('year_min', nil)
|
107
|
+
@year_max = cfg.fetch('year_max', nil)
|
108
|
+
end
|
109
|
+
|
110
|
+
def save_config #{{{2
|
111
|
+
File.write CONFIG_FILE, {
|
112
|
+
'rating_threshold' => @rating_threshold,
|
113
|
+
'show_movies' => @show_movies,
|
114
|
+
'show_series' => @show_series,
|
115
|
+
'movie_limit' => @movie_limit,
|
116
|
+
'series_limit' => @series_limit,
|
117
|
+
'sort_by' => @sort_by.to_s,
|
118
|
+
'genre_filters' => @genre_filters,
|
119
|
+
'movie_wish_list' => @movie_wish_list,
|
120
|
+
'series_wish_list' => @series_wish_list,
|
121
|
+
'movie_dump_list' => @movie_dump_list,
|
122
|
+
'series_dump_list' => @series_dump_list,
|
123
|
+
'tmdb_key' => @tmdb_key,
|
124
|
+
'tmdb_region' => @tmdb_region,
|
125
|
+
'year_min' => @year_min,
|
126
|
+
'year_max' => @year_max
|
127
|
+
}.to_yaml
|
128
|
+
end
|
129
|
+
|
130
|
+
def get_regions #{{{2
|
131
|
+
{
|
132
|
+
'US' => 'United States', 'GB' => 'United Kingdom', 'CA' => 'Canada',
|
133
|
+
'AU' => 'Australia', 'DE' => 'Germany', 'FR' => 'France', 'ES' => 'Spain',
|
134
|
+
'IT' => 'Italy', 'NL' => 'Netherlands', 'SE' => 'Sweden', 'NO' => 'Norway',
|
135
|
+
'DK' => 'Denmark', 'JP' => 'Japan', 'KR' => 'South Korea', 'IN' => 'India',
|
136
|
+
'BR' => 'Brazil', 'MX' => 'Mexico', 'AR' => 'Argentina', 'CL' => 'Chile',
|
137
|
+
'AT' => 'Austria', 'BE' => 'Belgium', 'CH' => 'Switzerland', 'FI' => 'Finland',
|
138
|
+
'IE' => 'Ireland', 'PT' => 'Portugal', 'GR' => 'Greece', 'CZ' => 'Czech Republic',
|
139
|
+
'PL' => 'Poland', 'HU' => 'Hungary', 'RO' => 'Romania', 'SG' => 'Singapore',
|
140
|
+
'MY' => 'Malaysia', 'TH' => 'Thailand', 'ID' => 'Indonesia', 'PH' => 'Philippines',
|
141
|
+
'TW' => 'Taiwan', 'HK' => 'Hong Kong', 'NZ' => 'New Zealand'
|
142
|
+
}
|
143
|
+
end
|
144
|
+
|
145
|
+
# DATA MANAGEMENT {{{1
|
146
|
+
def cache_dir #{{{2
|
147
|
+
FileUtils.mkdir_p(DATA_DIR) unless Dir.exist?(DATA_DIR)
|
148
|
+
DATA_DIR
|
149
|
+
end
|
150
|
+
|
151
|
+
def load_data #{{{2
|
152
|
+
cache = cache_dir
|
153
|
+
list_file = File.join(cache, 'list.json')
|
154
|
+
details_file = File.join(cache, 'details.json')
|
155
|
+
begin
|
156
|
+
load_from_cache(cache)
|
157
|
+
rescue Errno::ENOENT, JSON::ParserError
|
158
|
+
FileUtils.rm_rf(cache); FileUtils.mkdir_p(cache)
|
159
|
+
perform_scrape_and_cache(cache)
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def load_from_cache(cache) #{{{2
|
164
|
+
@footer.say(" Loading cache…")
|
165
|
+
list = JSON.parse(File.read(File.join(cache,'list.json')), symbolize_names: true)
|
166
|
+
@movies, @series = list[:movies], list[:series]
|
167
|
+
raw = JSON.parse(File.read(File.join(cache,'details.json')), symbolize_names: true)
|
168
|
+
raw = JSON.parse(raw, symbolize_names: true) if raw.is_a?(String)
|
169
|
+
@details_cache = Concurrent::Map.new
|
170
|
+
raw.each { |k, v| @details_cache[k.to_s] = v }
|
171
|
+
@footer.say("✓ Loaded cache"); sleep 1.5
|
172
|
+
end
|
173
|
+
|
174
|
+
def perform_scrape_and_cache(cache) #{{{2
|
175
|
+
load_top250
|
176
|
+
@bg_total.value = @movies.size + @series.size
|
177
|
+
@bg_fetched.value = 0
|
178
|
+
@progress.say("Fetching details & posters in background… Go grab a coffee.")
|
179
|
+
start_background_fetch
|
180
|
+
end
|
181
|
+
|
182
|
+
def build_genre_list #{{{2
|
183
|
+
all = @details_cache.values.flat_map{|d| d[:genres]||[]}.uniq.sort
|
184
|
+
@genres_list = all
|
185
|
+
all.each{|g| @genre_filters[g] ||= 0 }
|
186
|
+
@genre_filters.keys.reject{|g| all.include?(g)}.each{|g| @genre_filters.delete(g)}
|
187
|
+
end
|
188
|
+
|
189
|
+
def rebuild_index #{{{2
|
190
|
+
list = []
|
191
|
+
list += @movies if @show_movies
|
192
|
+
list += @series if @show_series
|
193
|
+
list.select! { |e| e[:rating] >= @rating_threshold }
|
194
|
+
|
195
|
+
# Use appropriate dump list based on current view
|
196
|
+
current_dump_list = @show_movies ? @movie_dump_list : @series_dump_list
|
197
|
+
list.reject! { |e| current_dump_list.include?(e[:id]) }
|
198
|
+
|
199
|
+
# Genre filtering logic
|
200
|
+
pos = @genre_filters.select{|_,v| v==1}.keys
|
201
|
+
neg = @genre_filters.select{|_,v| v==-1}.keys
|
202
|
+
|
203
|
+
list.select! do |e|
|
204
|
+
d = @details_cache[e[:id]] || {}
|
205
|
+
gens = Array(d[:genres])
|
206
|
+
|
207
|
+
# Genre filters
|
208
|
+
pos_match = pos.empty? || pos.all? { |genre| gens.include?(genre) }
|
209
|
+
neg_match = (gens & neg).empty?
|
210
|
+
|
211
|
+
# Year filters
|
212
|
+
year_match = true
|
213
|
+
if @year_min || @year_max
|
214
|
+
# For movies, use release_date; for series, use start_date
|
215
|
+
date_str = d[:type] == 'TVSeries' ? d[:start_date] : d[:release_date]
|
216
|
+
year = date_str.to_s[0,4].to_i if date_str
|
217
|
+
|
218
|
+
# For series, also check if it was running during the period
|
219
|
+
if d[:type] == 'TVSeries' && d[:end_date]
|
220
|
+
end_year = d[:end_date].to_s[0,4].to_i
|
221
|
+
# Series matches if it overlaps with the filter period
|
222
|
+
if year && end_year && end_year > 0
|
223
|
+
series_start = year
|
224
|
+
series_end = end_year
|
225
|
+
filter_start = @year_min || 0
|
226
|
+
filter_end = @year_max || 9999
|
227
|
+
year_match = series_end >= filter_start && series_start <= filter_end
|
228
|
+
elsif year && year > 0
|
229
|
+
year_match = (!@year_min || year <= @year_max.to_i) && (!@year_max || year >= @year_min.to_i)
|
230
|
+
else
|
231
|
+
year_match = false
|
232
|
+
end
|
233
|
+
elsif year && year > 0
|
234
|
+
year_match = true
|
235
|
+
year_match = false if @year_min && year < @year_min
|
236
|
+
year_match = false if @year_max && year > @year_max
|
237
|
+
else
|
238
|
+
year_match = false
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
pos_match && neg_match && year_match
|
243
|
+
end
|
244
|
+
|
245
|
+
@index_list = if @sort_by == :alpha
|
246
|
+
list.sort_by { |e| e[:title].downcase }
|
247
|
+
else
|
248
|
+
list.sort_by { |e| -e[:rating] }
|
249
|
+
end
|
250
|
+
|
251
|
+
# clamp index
|
252
|
+
if @index_list.empty?
|
253
|
+
@list.index = 0
|
135
254
|
else
|
136
|
-
@
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
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
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
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
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
when @w_n
|
285
|
-
@active = @w_i
|
286
|
-
end
|
287
|
-
when 'S-TAB'
|
288
|
-
case @active
|
289
|
-
when @w_i
|
290
|
-
@active = @w_n
|
291
|
-
when @w_n
|
292
|
-
@active = @w_m
|
293
|
-
when @w_m
|
294
|
-
@active = @w_g
|
295
|
-
when @w_g
|
296
|
-
@active = @w_i
|
297
|
-
end
|
298
|
-
when 'I'
|
299
|
-
w_b("Loading IMDB data...")
|
300
|
-
loadimdb
|
301
|
-
when 'm'
|
302
|
-
@movies = true
|
303
|
-
when 's'
|
304
|
-
@movies = false
|
305
|
-
when 'r'
|
306
|
-
r = w_b_getstr(" Set MINimum Rating: ", "")
|
307
|
-
@rating = r.to_f unless r == ""
|
308
|
-
when 'y'
|
309
|
-
y = w_b_getstr(" Set MINimum Year: ", "")
|
310
|
-
@yearMin = y.to_i unless y == ""
|
311
|
-
when 'Y'
|
312
|
-
y = w_b_getstr(" Set MAXimum Year: ", "")
|
313
|
-
@yearMax = y.to_i unless y == ""
|
314
|
-
when '/'
|
315
|
-
@search = w_b_getstr(" Search for title: ", "")
|
316
|
-
when '\\'
|
317
|
-
@search = ''
|
318
|
-
when 'G'
|
319
|
-
@genY = @genres.map(&:clone)
|
320
|
-
@genN = []
|
321
|
-
when '+'
|
322
|
-
case @active
|
323
|
-
when @w_i
|
324
|
-
@myY.push(@active.list[@active.index])
|
325
|
-
@active.index = 0 if @active.index > @active.list.size - 2
|
326
|
-
when @w_g
|
327
|
-
@genY.push(@active.list[@active.index]) unless @genN.include?(@active.list[@active.index])
|
328
|
-
@genN.delete(@active.list[@active.index])
|
329
|
-
@active.index = @active.index >= @active.list.size - 1 ? 0 : @active.index + 1
|
330
|
-
when @w_m
|
331
|
-
@myN.delete(@active.list[@active.index])
|
332
|
-
when @w_n
|
333
|
-
@myY.push(@active.list[@active.index])
|
334
|
-
@myN.delete(@active.list[@active.index])
|
335
|
-
@active.index -= 1 if @active.index == @active.list.size
|
336
|
-
end
|
337
|
-
when '-'
|
338
|
-
case @active
|
339
|
-
when @w_i
|
340
|
-
@myN.push(@active.list[@active.index])
|
341
|
-
@active.index = 0 if @active.index > @active.list.size - 2
|
342
|
-
when @w_g
|
343
|
-
@genN.push(@active.list[@active.index]) unless @genY.include?(@active.list[@active.index])
|
344
|
-
@genY.delete(@active.list[@active.index])
|
345
|
-
@active.index = @active.index >= @active.list.size - 1 ? 0 : @active.index + 1
|
346
|
-
when @w_m
|
347
|
-
@myY.delete(@active.list[@active.index])
|
348
|
-
@active.index -= 1 if @active.index == @active.list.size
|
349
|
-
when @w_n
|
350
|
-
@myN.delete(@active.list[@active.index])
|
351
|
-
@active.index -= 1 if @active.index == @active.list.size
|
352
|
-
end
|
353
|
-
when 'd'
|
354
|
-
@w_d.fill
|
355
|
-
w_d(1)
|
356
|
-
@w_d.update = false
|
357
|
-
when 'D'
|
358
|
-
@w_d.fill
|
359
|
-
w_d(2)
|
360
|
-
@w_d.update = false
|
361
|
-
when 'R' # Refresh all windows
|
362
|
-
@break = true
|
363
|
-
when '@' # Enter "Ruby debug"
|
364
|
-
cmd = w_b_getstr("◆ ", "")
|
255
|
+
max_i = @index_list.size - 1
|
256
|
+
@list.index = [[@list.index,0].max, max_i].min
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
def create_error_stub(error_type) #{{{2
|
261
|
+
{
|
262
|
+
title: "", rating: 0.0, votes: 0,
|
263
|
+
genres: [], directors: [], actors: [],
|
264
|
+
summary: "Failed to fetch details",
|
265
|
+
content_rating: "", duration: "",
|
266
|
+
release_date: "", country: "",
|
267
|
+
start_date: "", end_date: "",
|
268
|
+
providers: [], popularity: 0.0,
|
269
|
+
seasons: nil, episodes: nil,
|
270
|
+
error: error_type, type: ""
|
271
|
+
}
|
272
|
+
end
|
273
|
+
|
274
|
+
def save_current_state #{{{2
|
275
|
+
cache = cache_dir
|
276
|
+
|
277
|
+
CACHE_MUTEX.synchronize do
|
278
|
+
File.write(File.join(cache, 'list.json'), { movies: @movies, series: @series }.to_json)
|
279
|
+
plain = {}
|
280
|
+
@details_cache.each_pair { |k, v| plain[k] = v }
|
281
|
+
File.write(File.join(cache, 'details.json'), JSON.pretty_generate(plain))
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
def decode_html_entities(text) #{{{2
|
286
|
+
return "" if text.nil? || text.empty?
|
287
|
+
CGI.unescapeHTML(text.to_s)
|
288
|
+
end
|
289
|
+
|
290
|
+
# UI SETUP {{{1
|
291
|
+
def setup_ui #{{{2
|
292
|
+
Rcurses.clear_screen; Cursor.hide
|
293
|
+
rows, cols = IO.console.winsize
|
294
|
+
@header = Pane.new( 1, 1, cols, 1, 255, 236)
|
295
|
+
@list = Pane.new( 2, 3, 50, rows - 4, 252, 0)
|
296
|
+
@genres = Pane.new( 53, 3, 16, rows - 4, 248, 232)
|
297
|
+
@wish = Pane.new( 70, 3, 30, rows/2 - 2, 64, 232)
|
298
|
+
@dump = Pane.new( 70, rows/2 + 2, 30, rows/2 - 3, 130, 232)
|
299
|
+
@detail = Pane.new(102, 3, cols - 100, rows - 4, 255, 0)
|
300
|
+
@footer = Pane.new( 1, rows, cols, 1, 255, 236)
|
301
|
+
@progress = Pane.new( 1, rows, cols, 1, 255, 17)
|
302
|
+
|
303
|
+
# Add help pane
|
304
|
+
help_w = cols / 2; help_h = rows / 2
|
305
|
+
@help = Pane.new((cols - help_w) / 2 + 1, (rows - help_h) / 2 + 1, help_w, help_h, 252, 234)
|
306
|
+
@help.border = true; @help.index = 0; @help.ix = 0
|
307
|
+
|
308
|
+
[@list, @genres, @wish, @dump].each do |p|
|
309
|
+
p.index = 0
|
310
|
+
p.ix = 0
|
311
|
+
end
|
312
|
+
@focus = @list
|
313
|
+
@list.border = true
|
314
|
+
@help_mode = :hidden
|
315
|
+
end
|
316
|
+
|
317
|
+
def refresh_layout #{{{2
|
318
|
+
rows, cols = IO.console.winsize
|
319
|
+
@header.x = 1; @header.y = 1; @header.w = cols; @header.h = 1
|
320
|
+
@list.x = 2; @list.y = 3; @list.w = 50; @list.h = rows - 4
|
321
|
+
@genres.x = 53; @genres.y = 3; @genres.w = 16; @genres.h = rows - 4
|
322
|
+
@wish.x = 70; @wish.y = 3; @wish.w = 30; @wish.h = (rows/2 - 2)
|
323
|
+
@dump.x = 70; @dump.y = rows/2 + 2; @dump.w = 30; @dump.h = (rows/2 - 3)
|
324
|
+
@detail.x = 102; @detail.y = 3; @detail.w = cols - 100; @detail.h = rows - 4
|
325
|
+
@footer.x = 1; @footer.y = rows; @footer.w = cols; @footer.h = 1
|
326
|
+
@progress.x = 1; @progress.y = rows; @progress.w = cols; @progress.h = 1
|
327
|
+
|
328
|
+
# Update help pane layout
|
329
|
+
help_w = cols / 2; help_h = rows / 2
|
330
|
+
@help.x = (cols - help_w) / 2 + 1; @help.y = (rows - help_h) / 2 + 1; @help.w = help_w; @help.h = help_h
|
331
|
+
end
|
332
|
+
|
333
|
+
# SCRAPING AND FETCHING {{{1
|
334
|
+
def load_top250 #{{{2
|
335
|
+
ua = "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/117.0"
|
336
|
+
@movies = scrape_json_ld('chart/top', ua).take(@movie_limit)
|
337
|
+
@series = scrape_json_ld('chart/toptv', ua).take(@series_limit)
|
338
|
+
@progress.say("✓ Scraped #{@movies.size} movies, #{@series.size} series")
|
339
|
+
end
|
340
|
+
|
341
|
+
def load_additional_lists #{{{2
|
342
|
+
ua = "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/117.0"
|
343
|
+
|
344
|
+
@progress.say("Fetching popular movies...")
|
345
|
+
popular_movies = scrape_json_ld('chart/moviemeter', ua).take(100)
|
346
|
+
|
347
|
+
@progress.say("Fetching popular TV series...")
|
348
|
+
popular_series = scrape_json_ld('chart/tvmeter', ua).take(100)
|
349
|
+
|
350
|
+
@progress.say("Fetching trending content...")
|
351
|
+
# Try different trending endpoints
|
352
|
+
trending_content = []
|
353
|
+
['trending/movie', 'trending/tv'].each do |path|
|
354
|
+
begin
|
355
|
+
trending_content += scrape_trending(path, ua).take(50)
|
356
|
+
rescue
|
357
|
+
# Continue if one fails
|
358
|
+
end
|
359
|
+
end
|
360
|
+
|
361
|
+
# Merge without duplicates
|
362
|
+
initial_movie_count = @movies.size
|
363
|
+
initial_series_count = @series.size
|
364
|
+
|
365
|
+
# Add popular movies (avoid duplicates)
|
366
|
+
popular_movies.each do |movie|
|
367
|
+
unless @movies.any? { |m| m[:id] == movie[:id] }
|
368
|
+
@movies << movie
|
369
|
+
end
|
370
|
+
end
|
371
|
+
|
372
|
+
# Add popular series (avoid duplicates)
|
373
|
+
popular_series.each do |series|
|
374
|
+
unless @series.any? { |s| s[:id] == series[:id] }
|
375
|
+
@series << series
|
376
|
+
end
|
377
|
+
end
|
378
|
+
|
379
|
+
# Add trending content (classify and avoid duplicates)
|
380
|
+
trending_content.each do |item|
|
381
|
+
# Classify as movie or series based on existing data or fetch basic info
|
382
|
+
if item[:type] && item[:type].include?('Series')
|
383
|
+
unless @series.any? { |s| s[:id] == item[:id] }
|
384
|
+
@series << item
|
385
|
+
end
|
386
|
+
else
|
387
|
+
unless @movies.any? { |m| m[:id] == item[:id] }
|
388
|
+
@movies << item
|
389
|
+
end
|
390
|
+
end
|
391
|
+
end
|
392
|
+
|
393
|
+
new_movies = @movies.size - initial_movie_count
|
394
|
+
new_series = @series.size - initial_series_count
|
395
|
+
|
396
|
+
@progress.say("✓ Added #{new_movies} new movies, #{new_series} new series")
|
397
|
+
end
|
398
|
+
|
399
|
+
def scrape_trending(path, ua) #{{{2
|
400
|
+
# Fallback: try to scrape from IMDb trending or popular pages
|
401
|
+
uri = "https://www.imdb.com/#{path}/"
|
402
|
+
|
365
403
|
begin
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
movies: [
|
396
|
-
{
|
397
|
-
title: ".lister-item-header >> text",
|
398
|
-
url: ".lister-item-header a >> href",
|
399
|
-
id: ".userRatingValue >> data-tconst",
|
400
|
-
year: ".lister-item-year >> text",
|
401
|
-
genre: ".genre >> text",
|
402
|
-
votes: "[name=nv] >> text",
|
403
|
-
rating: ".ratings-imdb-rating >> data-value",
|
404
|
-
_parent: ".lister-item",
|
405
|
-
runtime: ".runtime >> text",
|
406
|
-
certificate: ".certificate >> text"
|
404
|
+
raw = `curl -sfL -H "User-Agent: #{ua}" "#{uri}"`
|
405
|
+
html = raw.force_encoding('UTF-8').scrub('')
|
406
|
+
doc = Nokogiri::HTML(html)
|
407
|
+
|
408
|
+
# Try to extract from various possible selectors
|
409
|
+
results = []
|
410
|
+
|
411
|
+
# Look for title links
|
412
|
+
doc.css('a[href*="/title/tt"]').each do |link|
|
413
|
+
href = link['href']
|
414
|
+
next unless href =~ %r{/title/(tt\d+)/}
|
415
|
+
|
416
|
+
id = $1
|
417
|
+
title_text = link.text.strip
|
418
|
+
next if title_text.empty?
|
419
|
+
|
420
|
+
# Try to get rating from nearby elements
|
421
|
+
rating = 0.0
|
422
|
+
rating_elem = link.parent.css('.ratingGroup--imdb-rating, .ipc-rating-star, .rating').first
|
423
|
+
if rating_elem
|
424
|
+
rating_text = rating_elem.text
|
425
|
+
rating = rating_text[/\d+\.?\d*/].to_f if rating_text
|
426
|
+
end
|
427
|
+
|
428
|
+
results << {
|
429
|
+
id: id,
|
430
|
+
title: title_text,
|
431
|
+
rating: rating,
|
432
|
+
type: path.include?('tv') ? 'TVSeries' : 'Movie'
|
407
433
|
}
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
433
|
-
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
468
|
-
|
469
|
-
|
470
|
-
|
471
|
-
|
472
|
-
|
473
|
-
|
474
|
-
|
475
|
-
|
476
|
-
|
477
|
-
|
478
|
-
|
479
|
-
|
480
|
-
|
481
|
-
|
482
|
-
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
434
|
+
|
435
|
+
break if results.size >= 50 # Limit results
|
436
|
+
end
|
437
|
+
|
438
|
+
results.uniq { |r| r[:id] }
|
439
|
+
rescue => e
|
440
|
+
@progress.say("Warning: Could not fetch from #{path}: #{e.message}")
|
441
|
+
[]
|
442
|
+
end
|
443
|
+
end
|
444
|
+
|
445
|
+
def scrape_json_ld(path, ua) #{{{2
|
446
|
+
uri = "https://www.imdb.com/#{path}/"
|
447
|
+
raw = `curl -sfL -H "User-Agent: #{ua}" "#{uri}"`
|
448
|
+
html = raw.force_encoding('UTF-8').scrub('')
|
449
|
+
doc = Nokogiri::HTML(html)
|
450
|
+
|
451
|
+
if (ld = doc.at_css('script[type="application/ld+json"]'))
|
452
|
+
begin
|
453
|
+
data = JSON.parse(ld.text)
|
454
|
+
return data.fetch('itemListElement', []).map do |li|
|
455
|
+
item = li['item'] || {}
|
456
|
+
href = item['url'] || ''
|
457
|
+
id = href[%r{/title/(tt\d+)/},1] or next
|
458
|
+
{
|
459
|
+
id: id,
|
460
|
+
title: decode_html_entities(item['name']).clean_ansi,
|
461
|
+
rating: item.dig('aggregateRating','ratingValue').to_f
|
462
|
+
}
|
463
|
+
end.compact
|
464
|
+
rescue JSON::ParserError
|
465
|
+
# fall back to HTML scrape
|
466
|
+
end
|
467
|
+
end
|
468
|
+
|
469
|
+
doc.css('.lister-list tr').map do |tr|
|
470
|
+
rating = tr.at_css('td.ratingColumn strong')&.text.to_f || 0.0
|
471
|
+
a = tr.at_css('td.titleColumn a') or next
|
472
|
+
id = a['href'][%r{/title/(tt\d+)/},1] or next
|
473
|
+
{ id: id, title: decode_html_entities(a.text).strip, rating: rating }
|
474
|
+
end.compact
|
475
|
+
end
|
476
|
+
|
477
|
+
def fetch_details(tconst) #{{{2
|
478
|
+
cached = @details_cache[tconst]
|
479
|
+
return cached if cached && !cached[:title].to_s.empty? && !cached[:error]
|
480
|
+
|
481
|
+
retries = 0
|
482
|
+
begin
|
483
|
+
uri = URI("https://www.imdb.com/title/#{tconst}/")
|
484
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
485
|
+
http.use_ssl = true
|
486
|
+
http.open_timeout = 10
|
487
|
+
http.read_timeout = 15
|
488
|
+
|
489
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
490
|
+
request['User-Agent'] = 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/117.0'
|
491
|
+
request['Accept'] = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
|
492
|
+
request['Accept-Language'] = 'en-US,en;q=0.9'
|
493
|
+
|
494
|
+
response = http.request(request)
|
495
|
+
html = response.body.force_encoding("UTF-8").scrub("")
|
496
|
+
doc = Nokogiri::HTML(html)
|
497
|
+
|
498
|
+
ld = doc.at_css('script[type="application/ld+json"]')&.text
|
499
|
+
data = if ld
|
500
|
+
begin
|
501
|
+
JSON.parse(ld)
|
502
|
+
rescue JSON::ParserError
|
503
|
+
{}
|
504
|
+
end
|
505
|
+
else
|
506
|
+
{}
|
507
|
+
end
|
508
|
+
|
509
|
+
detail = {
|
510
|
+
title: decode_html_entities(data["name"]).clean_ansi,
|
511
|
+
rating: data.dig("aggregateRating","ratingValue").to_f,
|
512
|
+
votes: data.dig("aggregateRating","ratingCount").to_i,
|
513
|
+
genres: Array(data["genre"]).map { |g| decode_html_entities(g) },
|
514
|
+
directors: Array(data["director"]).map { |d| decode_html_entities(d["name"]) },
|
515
|
+
actors: Array(data["actor"]).map { |a| decode_html_entities(a["name"]) },
|
516
|
+
summary: decode_html_entities(data["description"]),
|
517
|
+
content_rating: decode_html_entities(data["contentRating"]),
|
518
|
+
duration: data["duration"].to_s.sub(/^PT/,""),
|
519
|
+
release_date: data["datePublished"].to_s,
|
520
|
+
country: Array(data["countryOfOrigin"]).map { |c| decode_html_entities(c) }.join(", "),
|
521
|
+
type: data["@type"].to_s
|
522
|
+
}
|
523
|
+
|
524
|
+
if data["@type"] == "TVSeries"
|
525
|
+
detail[:start_date] = data["datePublished"].to_s
|
526
|
+
detail[:end_date] = doc.at_css('time[itemprop="endDate"]')&.text || ""
|
527
|
+
detail[:seasons] = data["numberOfSeasons"]&.to_i
|
528
|
+
detail[:episodes] = data["numberOfEpisodes"]&.to_i
|
529
|
+
end
|
530
|
+
|
531
|
+
# Merge TMDb info
|
532
|
+
tmdb_info = fetch_tmdb_info(tconst)
|
533
|
+
detail.merge!(tmdb_info)
|
534
|
+
|
535
|
+
if detail[:type] == "TVSeries"
|
536
|
+
detail[:start_date] = tmdb_info[:start_date] if detail[:start_date].to_s.empty?
|
537
|
+
detail[:end_date] = tmdb_info[:end_date] if detail[:end_date].to_s.empty?
|
538
|
+
detail[:seasons] = tmdb_info[:seasons] if detail[:seasons].nil?
|
539
|
+
detail[:episodes] = tmdb_info[:episodes] if detail[:episodes].nil?
|
540
|
+
else
|
541
|
+
detail[:start_date] = tmdb_info[:start_date].empty? ? detail[:release_date] : tmdb_info[:start_date]
|
542
|
+
end
|
543
|
+
|
544
|
+
if detail[:title].to_s.empty?
|
545
|
+
raise "No title found for #{tconst}"
|
546
|
+
end
|
547
|
+
|
548
|
+
@details_cache[tconst] = detail
|
549
|
+
return detail
|
550
|
+
|
551
|
+
rescue => ex
|
552
|
+
retries += 1
|
553
|
+
if retries < MAX_FETCH_RETRIES
|
554
|
+
sleep(1.0 * retries)
|
555
|
+
retry
|
556
|
+
else
|
557
|
+
File.open("/tmp/imdb_fetch_errors.log", "a") do |f|
|
558
|
+
f.puts "[#{Time.now.iso8601}] Failed #{tconst} after #{retries} retries: #{ex.class} - #{ex.message}"
|
559
|
+
end
|
560
|
+
error_stub = create_error_stub(:fetch_error)
|
561
|
+
@details_cache[tconst] = error_stub
|
562
|
+
return error_stub
|
563
|
+
end
|
564
|
+
end
|
565
|
+
end
|
566
|
+
|
567
|
+
def fetch_tmdb_info(imdb_id) #{{{2
|
568
|
+
unless @tmdb_key && !@tmdb_key.strip.empty?
|
569
|
+
return { providers: [], start_date:'', end_date:'',
|
570
|
+
popularity: 0.0, seasons: nil, episodes: nil,
|
571
|
+
error: :no_key }
|
572
|
+
end
|
573
|
+
|
574
|
+
begin
|
575
|
+
# Find the movie/TV show on TMDb using IMDb ID with proper timeout handling
|
576
|
+
find_uri = URI("https://api.themoviedb.org/3/find/#{imdb_id}")
|
577
|
+
find_uri.query = URI.encode_www_form(api_key: @tmdb_key, external_source: 'imdb_id')
|
578
|
+
|
579
|
+
http = Net::HTTP.new(find_uri.host, find_uri.port)
|
580
|
+
http.use_ssl = true
|
581
|
+
http.open_timeout = 10
|
582
|
+
http.read_timeout = 15
|
583
|
+
|
584
|
+
request = Net::HTTP::Get.new(find_uri.request_uri)
|
585
|
+
request['User-Agent'] = 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/117.0'
|
586
|
+
|
587
|
+
response = http.request(request)
|
588
|
+
|
589
|
+
if response.code.to_i != 200
|
590
|
+
File.open("/tmp/imdb_tmdb_debug.log", "a") do |f|
|
591
|
+
f.puts "[#{Time.now.iso8601}] TMDb find API returned #{response.code} for #{imdb_id}"
|
592
|
+
end
|
593
|
+
return { providers: [], start_date:'', end_date:'', popularity:0.0, seasons:nil, episodes:nil, error: :api_error }
|
594
|
+
end
|
595
|
+
|
596
|
+
find_res = JSON.parse(response.body)
|
597
|
+
|
598
|
+
# Check for invalid API key
|
599
|
+
if find_res['status_code'] == 7 || find_res['success'] == false
|
600
|
+
return { providers: [], start_date:'', end_date:'', popularity:0.0, seasons:nil, episodes:nil, error: :invalid_key }
|
601
|
+
end
|
602
|
+
|
603
|
+
movie = find_res.dig('movie_results', 0)
|
604
|
+
tv = find_res.dig('tv_results', 0)
|
605
|
+
|
606
|
+
if movie
|
607
|
+
type, tmdb_id = 'movie', movie['id']
|
608
|
+
elsif tv
|
609
|
+
type, tmdb_id = 'tv', tv['id']
|
610
|
+
else
|
611
|
+
File.open("/tmp/imdb_tmdb_debug.log", "a") do |f|
|
612
|
+
f.puts "[#{Time.now.iso8601}] No TMDb results found for #{imdb_id}"
|
613
|
+
end
|
614
|
+
return { providers: [], start_date:'', end_date:'', popularity:0.0, seasons:nil, episodes:nil, error: :not_found }
|
615
|
+
end
|
616
|
+
|
617
|
+
# Get streaming providers with timeout handling
|
618
|
+
providers = []
|
619
|
+
begin
|
620
|
+
prov_uri = URI("https://api.themoviedb.org/3/#{type}/#{tmdb_id}/watch/providers")
|
621
|
+
prov_uri.query = URI.encode_www_form(api_key: @tmdb_key)
|
622
|
+
|
623
|
+
prov_http = Net::HTTP.new(prov_uri.host, prov_uri.port)
|
624
|
+
prov_http.use_ssl = true
|
625
|
+
prov_http.open_timeout = 10
|
626
|
+
prov_http.read_timeout = 15
|
627
|
+
|
628
|
+
prov_request = Net::HTTP::Get.new(prov_uri.request_uri)
|
629
|
+
prov_request['User-Agent'] = 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/117.0'
|
630
|
+
|
631
|
+
prov_response = prov_http.request(prov_request)
|
632
|
+
|
633
|
+
if prov_response.code.to_i == 200
|
634
|
+
prov_res = JSON.parse(prov_response.body)
|
635
|
+
|
636
|
+
# Debug log the provider response
|
637
|
+
File.open("/tmp/imdb_tmdb_debug.log", "a") do |f|
|
638
|
+
region_data = prov_res.dig('results', @tmdb_region)
|
639
|
+
f.puts "[#{Time.now.iso8601}] Provider response for #{imdb_id} (#{@tmdb_region}): #{region_data ? 'found data' : 'no data'}"
|
640
|
+
if region_data
|
641
|
+
f.puts " - flatrate: #{region_data['flatrate']&.size || 0} providers"
|
642
|
+
f.puts " - free: #{region_data['free']&.size || 0} providers"
|
643
|
+
f.puts " - ads: #{region_data['ads']&.size || 0} providers"
|
644
|
+
f.puts " - rent: #{region_data['rent']&.size || 0} providers"
|
645
|
+
f.puts " - buy: #{region_data['buy']&.size || 0} providers"
|
646
|
+
end
|
647
|
+
end
|
648
|
+
|
649
|
+
region_results = prov_res.dig('results', @tmdb_region) || {}
|
650
|
+
|
651
|
+
# Try different provider types in order of preference
|
652
|
+
if region_results['flatrate'] && !region_results['flatrate'].empty?
|
653
|
+
providers = region_results['flatrate'].map { |x| x['provider_name'] }
|
654
|
+
elsif region_results['free'] && !region_results['free'].empty?
|
655
|
+
providers = region_results['free'].map { |x| x['provider_name'] }
|
656
|
+
elsif region_results['ads'] && !region_results['ads'].empty?
|
657
|
+
providers = region_results['ads'].map { |x| x['provider_name'] }
|
658
|
+
else
|
659
|
+
# Fallback to rent/buy options
|
660
|
+
rent_providers = (region_results['rent'] || []).map { |x| x['provider_name'] }
|
661
|
+
buy_providers = (region_results['buy'] || []).map { |x| x['provider_name'] }
|
662
|
+
providers = (rent_providers + buy_providers).uniq
|
663
|
+
end
|
664
|
+
else
|
665
|
+
File.open("/tmp/imdb_tmdb_debug.log", "a") do |f|
|
666
|
+
f.puts "[#{Time.now.iso8601}] Provider API returned #{prov_response.code} for #{imdb_id}"
|
667
|
+
end
|
668
|
+
end
|
669
|
+
|
670
|
+
rescue Net::TimeoutError, Net::ReadTimeout, Net::OpenTimeout => timeout_ex
|
671
|
+
File.open("/tmp/imdb_tmdb_debug.log", "a") do |f|
|
672
|
+
f.puts "[#{Time.now.iso8601}] Provider fetch timeout for #{imdb_id}: #{timeout_ex.class}"
|
673
|
+
end
|
674
|
+
providers = []
|
675
|
+
rescue => provider_ex
|
676
|
+
File.open("/tmp/imdb_tmdb_debug.log", "a") do |f|
|
677
|
+
f.puts "[#{Time.now.iso8601}] Provider fetch error for #{imdb_id}: #{provider_ex.message}"
|
678
|
+
end
|
679
|
+
providers = []
|
680
|
+
end
|
681
|
+
|
682
|
+
# Get movie/TV details with timeout handling
|
683
|
+
start_date = ''
|
684
|
+
end_date = ''
|
685
|
+
seasons = nil
|
686
|
+
episodes = nil
|
687
|
+
popularity = 0.0
|
688
|
+
|
689
|
+
begin
|
690
|
+
det_uri = URI("https://api.themoviedb.org/3/#{type}/#{tmdb_id}")
|
691
|
+
det_uri.query = URI.encode_www_form(api_key: @tmdb_key)
|
692
|
+
|
693
|
+
det_http = Net::HTTP.new(det_uri.host, det_uri.port)
|
694
|
+
det_http.use_ssl = true
|
695
|
+
det_http.open_timeout = 10
|
696
|
+
det_http.read_timeout = 15
|
697
|
+
|
698
|
+
det_request = Net::HTTP::Get.new(det_uri.request_uri)
|
699
|
+
det_request['User-Agent'] = 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/117.0'
|
700
|
+
|
701
|
+
det_response = det_http.request(det_request)
|
702
|
+
|
703
|
+
if det_response.code.to_i == 200
|
704
|
+
det = JSON.parse(det_response.body)
|
705
|
+
|
706
|
+
if type == 'tv'
|
707
|
+
start_date = det['first_air_date'].to_s
|
708
|
+
end_date = det['last_air_date'].to_s
|
709
|
+
seasons = det['number_of_seasons']
|
710
|
+
episodes = det['number_of_episodes']
|
711
|
+
else
|
712
|
+
start_date = det['release_date'].to_s
|
713
|
+
end_date = ''
|
714
|
+
seasons = nil
|
715
|
+
episodes = nil
|
716
|
+
end
|
717
|
+
|
718
|
+
popularity = det['popularity'].to_f
|
719
|
+
else
|
720
|
+
File.open("/tmp/imdb_tmdb_debug.log", "a") do |f|
|
721
|
+
f.puts "[#{Time.now.iso8601}] Details API returned #{det_response.code} for #{imdb_id}"
|
722
|
+
end
|
723
|
+
end
|
724
|
+
|
725
|
+
rescue Net::TimeoutError, Net::ReadTimeout, Net::OpenTimeout => timeout_ex
|
726
|
+
File.open("/tmp/imdb_tmdb_debug.log", "a") do |f|
|
727
|
+
f.puts "[#{Time.now.iso8601}] Details fetch timeout for #{imdb_id}: #{timeout_ex.class}"
|
728
|
+
end
|
729
|
+
rescue => details_ex
|
730
|
+
File.open("/tmp/imdb_tmdb_debug.log", "a") do |f|
|
731
|
+
f.puts "[#{Time.now.iso8601}] Details fetch error for #{imdb_id}: #{details_ex.message}"
|
732
|
+
end
|
733
|
+
end
|
734
|
+
|
735
|
+
{
|
736
|
+
providers: providers,
|
737
|
+
start_date: start_date,
|
738
|
+
end_date: end_date,
|
739
|
+
popularity: popularity,
|
740
|
+
seasons: seasons,
|
741
|
+
episodes: episodes,
|
742
|
+
error: :none
|
743
|
+
}
|
744
|
+
|
745
|
+
rescue Net::TimeoutError, Net::ReadTimeout, Net::OpenTimeout => timeout_ex
|
746
|
+
File.open("/tmp/imdb_tmdb_debug.log", "a") do |f|
|
747
|
+
f.puts "[#{Time.now.iso8601}] TMDb timeout for #{imdb_id}: #{timeout_ex.class}"
|
748
|
+
end
|
749
|
+
{ providers: [], start_date:'', end_date:'', popularity:0.0, seasons:nil, episodes:nil, error: :timeout }
|
750
|
+
rescue => ex
|
751
|
+
File.open("/tmp/imdb_tmdb_debug.log", "a") do |f|
|
752
|
+
f.puts "[#{Time.now.iso8601}] TMDb error for #{imdb_id}: #{ex.class} - #{ex.message}"
|
753
|
+
f.puts ex.backtrace.first(3).join("\n") if ex.backtrace
|
754
|
+
end
|
755
|
+
{ providers: [], start_date:'', end_date:'', popularity:0.0, seasons:nil, episodes:nil, error: :fetch_error }
|
756
|
+
end
|
757
|
+
end
|
758
|
+
|
759
|
+
def download_poster(tconst, cache) #{{{2
|
760
|
+
url = fetch_imdb_poster_url(tconst)
|
761
|
+
return unless url
|
762
|
+
|
763
|
+
dest = File.join(cache, "#{tconst}.jpg")
|
764
|
+
return if File.exist?(dest) && File.size(dest) > 1000
|
765
|
+
|
766
|
+
cmd = [
|
767
|
+
'curl', '-sfL', '--max-time', '10', '--retry', '2', '--retry-delay', '1',
|
768
|
+
'-H', 'User-Agent: Mozilla/5.0', '-o', dest, url
|
769
|
+
].map { |arg| Shellwords.escape(arg) }.join(' ')
|
770
|
+
|
771
|
+
system(cmd, err: File::NULL)
|
772
|
+
end
|
773
|
+
|
774
|
+
def fetch_imdb_poster_url(tconst) #{{{2
|
775
|
+
begin
|
776
|
+
html = URI.open(
|
777
|
+
"https://www.imdb.com/title/#{tconst}/",
|
778
|
+
"User-Agent" => "Mozilla/5.0",
|
779
|
+
"Accept-Encoding" => "identity"
|
780
|
+
).read
|
781
|
+
rescue SocketError, Errno::ECONNREFUSED, OpenURI::HTTPError, Net::HTTPBadResponse
|
782
|
+
return nil
|
783
|
+
end
|
784
|
+
html[/<meta property="og:image" content="([^"]+)"/,1]
|
497
785
|
rescue
|
498
|
-
|
499
|
-
end
|
500
|
-
|
501
|
-
def
|
502
|
-
|
503
|
-
|
504
|
-
|
505
|
-
|
506
|
-
|
507
|
-
|
508
|
-
|
509
|
-
|
510
|
-
|
511
|
-
|
512
|
-
|
513
|
-
|
514
|
-
|
515
|
-
|
516
|
-
|
517
|
-
|
518
|
-
|
519
|
-
|
520
|
-
|
521
|
-
|
522
|
-
|
523
|
-
|
524
|
-
|
525
|
-
|
526
|
-
|
527
|
-
|
528
|
-
|
529
|
-
|
530
|
-
|
531
|
-
|
532
|
-
|
533
|
-
|
534
|
-
|
535
|
-
|
536
|
-
|
537
|
-
|
538
|
-
|
539
|
-
|
540
|
-
end
|
541
|
-
|
542
|
-
|
543
|
-
|
544
|
-
|
545
|
-
|
546
|
-
|
547
|
-
|
548
|
-
|
549
|
-
|
550
|
-
|
551
|
-
|
552
|
-
|
553
|
-
|
554
|
-
|
555
|
-
|
556
|
-
|
557
|
-
|
558
|
-
|
559
|
-
|
560
|
-
|
786
|
+
nil
|
787
|
+
end
|
788
|
+
|
789
|
+
def start_background_fetch #{{{2
|
790
|
+
@cancel_scrape = Concurrent::AtomicBoolean.new(false)
|
791
|
+
@fetching = true
|
792
|
+
@ui_update_queue = Queue.new
|
793
|
+
|
794
|
+
Thread.new do
|
795
|
+
cache = cache_dir
|
796
|
+
entries = @movies + @series
|
797
|
+
@bg_total.value = entries.size
|
798
|
+
@bg_fetched.value = 0
|
799
|
+
|
800
|
+
@ui_update_queue << :initial_progress
|
801
|
+
save_current_state
|
802
|
+
|
803
|
+
entries.each do |entry|
|
804
|
+
break if @cancel_scrape.value
|
805
|
+
|
806
|
+
begin
|
807
|
+
fetch_details(entry[:id])
|
808
|
+
download_poster(entry[:id], cache)
|
809
|
+
@bg_fetched.increment
|
810
|
+
|
811
|
+
if (@bg_fetched.value % 5).zero? || @bg_fetched.value == @bg_total.value
|
812
|
+
@ui_update_queue << :update_progress
|
813
|
+
end
|
814
|
+
|
815
|
+
if (@bg_fetched.value % 25).zero?
|
816
|
+
@ui_update_queue << :rebuild_and_draw
|
817
|
+
end
|
818
|
+
|
819
|
+
if (@bg_fetched.value % 50).zero?
|
820
|
+
save_current_state
|
821
|
+
end
|
822
|
+
|
823
|
+
sleep(0.1) unless @cancel_scrape.value
|
824
|
+
|
825
|
+
rescue => ex
|
826
|
+
File.open("/tmp/imdb_fetch_errors.log", "a") do |f|
|
827
|
+
f.puts "[#{Time.now.iso8601}] Failed to fetch #{entry[:id]}: #{ex.message}"
|
828
|
+
end
|
829
|
+
end
|
830
|
+
end
|
831
|
+
|
832
|
+
save_current_state
|
833
|
+
@ui_update_queue << :fetch_complete
|
834
|
+
|
835
|
+
# Auto-start verification after full fetch
|
836
|
+
sleep(2) # Brief pause before verification
|
837
|
+
@ui_update_queue << :auto_start_verification
|
838
|
+
@fetching = false
|
839
|
+
rescue => ex
|
840
|
+
File.open("/tmp/imdb_bg_error.log","a") do |f|
|
841
|
+
f.puts "[#{Time.now.iso8601}] BG thread error: #{ex.class}: #{ex.message}"
|
842
|
+
f.puts ex.backtrace
|
843
|
+
end
|
844
|
+
@fetching = false
|
845
|
+
end
|
846
|
+
end
|
847
|
+
|
848
|
+
def start_additional_lists_fetch #{{{2
|
849
|
+
return if @fetching
|
850
|
+
@cancel_scrape = Concurrent::AtomicBoolean.new(false)
|
851
|
+
@fetching = true
|
852
|
+
@ui_update_queue = Queue.new
|
853
|
+
|
854
|
+
Thread.new do
|
855
|
+
begin
|
856
|
+
@ui_update_queue << :start_additional_fetch
|
857
|
+
|
858
|
+
ua = "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/117.0"
|
859
|
+
|
860
|
+
# Track initial counts
|
861
|
+
initial_movie_count = @movies.size
|
862
|
+
initial_series_count = @series.size
|
863
|
+
|
864
|
+
# Fetch popular movies
|
865
|
+
@ui_update_queue << :fetching_popular_movies
|
866
|
+
popular_movies = scrape_json_ld('chart/moviemeter', ua).take(100)
|
867
|
+
|
868
|
+
# Add popular movies (avoid duplicates)
|
869
|
+
new_movies_added = 0
|
870
|
+
popular_movies.each do |movie|
|
871
|
+
break if @cancel_scrape.value
|
872
|
+
unless @movies.any? { |m| m[:id] == movie[:id] }
|
873
|
+
@movies << movie
|
874
|
+
new_movies_added += 1
|
875
|
+
end
|
876
|
+
end
|
877
|
+
|
878
|
+
# Fetch popular series
|
879
|
+
@ui_update_queue << :fetching_popular_series
|
880
|
+
popular_series = scrape_json_ld('chart/tvmeter', ua).take(100)
|
881
|
+
|
882
|
+
# Add popular series (avoid duplicates)
|
883
|
+
new_series_added = 0
|
884
|
+
popular_series.each do |series|
|
885
|
+
break if @cancel_scrape.value
|
886
|
+
unless @series.any? { |s| s[:id] == series[:id] }
|
887
|
+
@series << series
|
888
|
+
new_series_added += 1
|
889
|
+
end
|
890
|
+
end
|
891
|
+
|
892
|
+
# Fetch trending content
|
893
|
+
@ui_update_queue << :fetching_trending
|
894
|
+
trending_content = []
|
895
|
+
['trending/movie', 'trending/tv'].each do |path|
|
896
|
+
break if @cancel_scrape.value
|
897
|
+
begin
|
898
|
+
trending_content += scrape_trending(path, ua).take(50)
|
899
|
+
rescue => e
|
900
|
+
# Continue if one fails
|
901
|
+
end
|
902
|
+
end
|
903
|
+
|
904
|
+
# Add trending content (classify and avoid duplicates)
|
905
|
+
trending_movies_added = 0
|
906
|
+
trending_series_added = 0
|
907
|
+
trending_content.each do |item|
|
908
|
+
break if @cancel_scrape.value
|
909
|
+
|
910
|
+
if item[:type] && item[:type].include?('Series')
|
911
|
+
unless @series.any? { |s| s[:id] == item[:id] }
|
912
|
+
@series << item
|
913
|
+
trending_series_added += 1
|
914
|
+
end
|
915
|
+
else
|
916
|
+
unless @movies.any? { |m| m[:id] == item[:id] }
|
917
|
+
@movies << item
|
918
|
+
trending_movies_added += 1
|
919
|
+
end
|
920
|
+
end
|
921
|
+
end
|
922
|
+
|
923
|
+
# Save state and start background detail fetching
|
924
|
+
unless @cancel_scrape.value
|
925
|
+
save_current_state
|
926
|
+
@ui_update_queue << :rebuild_and_refresh
|
927
|
+
|
928
|
+
total_new_movies = new_movies_added + trending_movies_added
|
929
|
+
total_new_series = new_series_added + trending_series_added
|
930
|
+
|
931
|
+
# Start fetching details for new items
|
932
|
+
new_items = []
|
933
|
+
@movies.last(total_new_movies).each { |m| new_items << m }
|
934
|
+
@series.last(total_new_series).each { |s| new_items << s }
|
935
|
+
|
936
|
+
if new_items.any?
|
937
|
+
@ui_update_queue << [:start_detail_fetch, total_new_movies, total_new_series, new_items.size]
|
938
|
+
|
939
|
+
# Set up progress tracking
|
940
|
+
@bg_total.value = new_items.size
|
941
|
+
@bg_fetched.value = 0
|
942
|
+
cache = cache_dir
|
943
|
+
|
944
|
+
# Fetch details for each new item
|
945
|
+
new_items.each_with_index do |item, idx|
|
946
|
+
break if @cancel_scrape.value
|
947
|
+
|
948
|
+
begin
|
949
|
+
fetch_details(item[:id])
|
950
|
+
download_poster(item[:id], cache)
|
951
|
+
@bg_fetched.increment
|
952
|
+
|
953
|
+
if ((@bg_fetched.value % 5).zero?) || (@bg_fetched.value == @bg_total.value)
|
954
|
+
@ui_update_queue << :update_additional_progress
|
955
|
+
end
|
956
|
+
|
957
|
+
if ((@bg_fetched.value % 10).zero?)
|
958
|
+
save_current_state
|
959
|
+
@ui_update_queue << :rebuild_and_refresh
|
960
|
+
end
|
961
|
+
|
962
|
+
sleep(0.1) unless @cancel_scrape.value
|
963
|
+
|
964
|
+
rescue => ex
|
965
|
+
# Log error but continue with other items
|
966
|
+
File.open("/tmp/imdb_fetch_errors.log", "a") do |f|
|
967
|
+
f.puts "[#{Time.now.iso8601}] Failed to fetch additional item #{item[:id]}: #{ex.message}"
|
968
|
+
end
|
969
|
+
end
|
970
|
+
end
|
971
|
+
|
972
|
+
# Final save and completion
|
973
|
+
save_current_state
|
974
|
+
@ui_update_queue << :rebuild_and_refresh
|
975
|
+
end
|
976
|
+
|
977
|
+
@ui_update_queue << [:additional_fetch_complete, total_new_movies, total_new_series]
|
978
|
+
|
979
|
+
# Auto-start verification after additional list fetching
|
980
|
+
if total_new_movies > 0 || total_new_series > 0
|
981
|
+
sleep(2) # Brief pause before verification
|
982
|
+
@ui_update_queue << :auto_start_verification
|
983
|
+
end
|
984
|
+
end
|
985
|
+
|
986
|
+
rescue => ex
|
987
|
+
@ui_update_queue << [:additional_fetch_error, ex.message]
|
988
|
+
ensure
|
989
|
+
@fetching = false
|
990
|
+
end
|
991
|
+
end
|
992
|
+
end
|
993
|
+
|
994
|
+
def start_verification_check #{{{2
|
995
|
+
return if @fetching
|
996
|
+
@cancel_scrape = Concurrent::AtomicBoolean.new(false)
|
997
|
+
@fetching = true
|
998
|
+
@ui_update_queue = Queue.new
|
999
|
+
|
1000
|
+
Thread.new do
|
1001
|
+
begin
|
1002
|
+
cache = cache_dir
|
1003
|
+
all_items = @movies + @series
|
1004
|
+
@ui_update_queue << [:start_verification, all_items.size]
|
1005
|
+
|
1006
|
+
# Check for missing or incomplete data
|
1007
|
+
missing_details = []
|
1008
|
+
missing_posters = []
|
1009
|
+
incomplete_data = []
|
1010
|
+
|
1011
|
+
all_items.each_with_index do |item, idx|
|
1012
|
+
break if @cancel_scrape.value
|
1013
|
+
|
1014
|
+
# Check details
|
1015
|
+
details = @details_cache[item[:id]]
|
1016
|
+
if details.nil? || details[:title].to_s.empty? || details[:error]
|
1017
|
+
missing_details << item
|
1018
|
+
elsif details[:rating] == 0.0 || details[:genres].nil? || details[:genres].empty?
|
1019
|
+
incomplete_data << item
|
1020
|
+
end
|
1021
|
+
|
1022
|
+
# Check poster
|
1023
|
+
poster_file = File.join(cache, "#{item[:id]}.jpg")
|
1024
|
+
if !File.exist?(poster_file) || File.size(poster_file) < 1000
|
1025
|
+
missing_posters << item
|
1026
|
+
end
|
1027
|
+
|
1028
|
+
if (idx % 50).zero?
|
1029
|
+
@ui_update_queue << [:verification_progress, idx + 1, all_items.size]
|
1030
|
+
end
|
1031
|
+
end
|
1032
|
+
|
1033
|
+
# Report findings
|
1034
|
+
total_issues = missing_details.size + missing_posters.size + incomplete_data.size
|
1035
|
+
|
1036
|
+
if total_issues == 0
|
1037
|
+
@ui_update_queue << [:verification_complete, 0, 0, 0, 0]
|
1038
|
+
else
|
1039
|
+
@ui_update_queue << [:verification_issues_found, missing_details.size, missing_posters.size, incomplete_data.size, total_issues]
|
1040
|
+
|
1041
|
+
# Ask user if they want to fix issues
|
1042
|
+
@ui_update_queue << :verification_ask_fix
|
1043
|
+
|
1044
|
+
# Give UI time to display the message before continuing
|
1045
|
+
sleep(1)
|
1046
|
+
|
1047
|
+
# Auto-fix common issues
|
1048
|
+
items_to_fix = (missing_details + incomplete_data).uniq { |item| item[:id] }
|
1049
|
+
|
1050
|
+
if items_to_fix.any?
|
1051
|
+
@ui_update_queue << [:start_fixing, items_to_fix.size]
|
1052
|
+
|
1053
|
+
items_to_fix.each_with_index do |item, idx|
|
1054
|
+
break if @cancel_scrape.value
|
1055
|
+
|
1056
|
+
begin
|
1057
|
+
# Remove old details to force fresh fetch
|
1058
|
+
@details_cache.delete(item[:id])
|
1059
|
+
|
1060
|
+
# Fetch fresh details
|
1061
|
+
new_details = fetch_details(item[:id])
|
1062
|
+
|
1063
|
+
# Update rating in main arrays
|
1064
|
+
movie_item = @movies.find { |m| m[:id] == item[:id] }
|
1065
|
+
series_item = @series.find { |s| s[:id] == item[:id] }
|
1066
|
+
|
1067
|
+
if movie_item && new_details[:rating] && new_details[:rating] > 0
|
1068
|
+
movie_item[:rating] = new_details[:rating]
|
1069
|
+
elsif series_item && new_details[:rating] && new_details[:rating] > 0
|
1070
|
+
series_item[:rating] = new_details[:rating]
|
1071
|
+
end
|
1072
|
+
|
1073
|
+
# Download poster if missing
|
1074
|
+
if missing_posters.include?(item)
|
1075
|
+
download_poster(item[:id], cache)
|
1076
|
+
end
|
1077
|
+
|
1078
|
+
if (idx % 5).zero? || (idx + 1) == items_to_fix.size
|
1079
|
+
@ui_update_queue << [:fix_progress, idx + 1, items_to_fix.size]
|
1080
|
+
end
|
1081
|
+
|
1082
|
+
if (idx % 25).zero?
|
1083
|
+
save_current_state
|
1084
|
+
end
|
1085
|
+
|
1086
|
+
sleep(0.1) unless @cancel_scrape.value
|
1087
|
+
|
1088
|
+
rescue => ex
|
1089
|
+
File.open("/tmp/imdb_verification_errors.log", "a") do |f|
|
1090
|
+
f.puts "[#{Time.now.iso8601}] Failed to fix #{item[:id]}: #{ex.message}"
|
1091
|
+
end
|
1092
|
+
end
|
1093
|
+
end
|
1094
|
+
|
1095
|
+
save_current_state
|
1096
|
+
build_genre_list
|
1097
|
+
rebuild_index
|
1098
|
+
end
|
1099
|
+
|
1100
|
+
@ui_update_queue << [:verification_fix_complete, items_to_fix.size]
|
1101
|
+
end
|
1102
|
+
|
1103
|
+
rescue => ex
|
1104
|
+
@ui_update_queue << [:verification_error, ex.message]
|
1105
|
+
ensure
|
1106
|
+
@fetching = false
|
1107
|
+
end
|
1108
|
+
end
|
1109
|
+
end
|
1110
|
+
|
1111
|
+
def start_incremental_update #{{{2
|
1112
|
+
return if @fetching
|
1113
|
+
@cancel_scrape = Concurrent::AtomicBoolean.new(false)
|
1114
|
+
@fetching = true
|
1115
|
+
|
1116
|
+
Thread.new do
|
1117
|
+
cache = cache_dir
|
1118
|
+
all_items = @movies + @series
|
1119
|
+
missing_items = []
|
1120
|
+
|
1121
|
+
all_items.each do |item|
|
1122
|
+
needs_fetch = false
|
1123
|
+
details = @details_cache[item[:id]]
|
1124
|
+
if details.nil? || details[:title].to_s.empty? || details[:error]
|
1125
|
+
needs_fetch = true
|
1126
|
+
end
|
1127
|
+
|
1128
|
+
poster_file = File.join(cache, "#{item[:id]}.jpg")
|
1129
|
+
if !File.exist?(poster_file) || File.size(poster_file) < 1000
|
1130
|
+
needs_fetch = true
|
1131
|
+
end
|
1132
|
+
|
1133
|
+
missing_items << item if needs_fetch
|
1134
|
+
end
|
1135
|
+
|
1136
|
+
if missing_items.empty?
|
1137
|
+
@progress.say("✓ All items already have details and posters")
|
1138
|
+
@fetching = false
|
1139
|
+
sleep 1.5
|
1140
|
+
return
|
1141
|
+
end
|
1142
|
+
|
1143
|
+
@progress.say("Incremental: fetching #{missing_items.size} missing details/posters…")
|
1144
|
+
|
1145
|
+
missing_items.each_with_index do |item, idx|
|
1146
|
+
break if @cancel_scrape.value
|
1147
|
+
|
1148
|
+
begin
|
1149
|
+
fetch_details(item[:id])
|
1150
|
+
download_poster(item[:id], cache)
|
1151
|
+
|
1152
|
+
if ((idx + 1) % 10).zero? || (idx + 1) == missing_items.size
|
1153
|
+
@progress.say("Incremental: fetched #{idx + 1}/#{missing_items.size}… (press 'c' to stop)")
|
1154
|
+
end
|
1155
|
+
|
1156
|
+
sleep(0.1) unless @cancel_scrape.value
|
1157
|
+
rescue => ex
|
1158
|
+
File.open("/tmp/imdb_fetch_errors.log", "a") do |f|
|
1159
|
+
f.puts "[#{Time.now.iso8601}] Incremental fetch failed for #{item[:id]}: #{ex.message}"
|
1160
|
+
end
|
1161
|
+
end
|
1162
|
+
|
1163
|
+
if ((idx + 1) % 25).zero?
|
1164
|
+
save_current_state
|
1165
|
+
end
|
1166
|
+
end
|
1167
|
+
|
1168
|
+
save_current_state
|
1169
|
+
@progress.say("✓ Incremental update complete")
|
1170
|
+
@fetching = false
|
1171
|
+
sleep 1.5
|
1172
|
+
rescue => ex
|
1173
|
+
File.open("/tmp/imdb_bg_error.log","a"){|f| f.puts ex.full_message}
|
1174
|
+
@fetching = false
|
1175
|
+
end
|
1176
|
+
end
|
1177
|
+
|
1178
|
+
def search_imdb(query, max = 5) #{{{2
|
1179
|
+
uri = URI("https://www.imdb.com/find")
|
1180
|
+
uri.query = URI.encode_www_form(q: query, s: 'tt')
|
1181
|
+
html = nil
|
1182
|
+
begin
|
1183
|
+
Timeout.timeout(5) do
|
1184
|
+
html = URI.open(uri.to_s,
|
1185
|
+
'User-Agent' => 'Mozilla/5.0',
|
1186
|
+
'Accept' => 'text/html,application/xhtml+xml',
|
1187
|
+
'Accept-Language' => 'en-US,en;q=0.9',
|
1188
|
+
'Accept-Encoding' => 'identity'
|
1189
|
+
).read
|
1190
|
+
# Fix encoding issues
|
1191
|
+
html = html.force_encoding('UTF-8').scrub('?')
|
1192
|
+
end
|
1193
|
+
rescue Timeout::Error, Net::ReadTimeout, SocketError, Errno::ECONNREFUSED, OpenURI::HTTPError, Net::HTTPBadResponse, Encoding::CompatibilityError => e
|
1194
|
+
@footer.say(" ✗ Network error: #{e.class.name}")
|
1195
|
+
sleep(1)
|
1196
|
+
return []
|
1197
|
+
end
|
1198
|
+
doc = Nokogiri::HTML(html)
|
1199
|
+
nd = doc.at_css('script#__NEXT_DATA__')&.text
|
1200
|
+
return [] unless nd
|
1201
|
+
payload = JSON.parse(nd) rescue {}
|
1202
|
+
results = payload.dig('props','pageProps','titleResults','results') || []
|
1203
|
+
results.first(max).map do |r|
|
1204
|
+
# Use available data from search results
|
1205
|
+
type_info = r['titleTypeText'] || ""
|
1206
|
+
|
1207
|
+
# Get cast info or other available info as description
|
1208
|
+
cast = r['topCredits'] || []
|
1209
|
+
if !cast.empty?
|
1210
|
+
desc = "Cast: #{cast.first(3).join(', ')}"
|
1211
|
+
elsif r['titlePosterImageModel'] && r['titlePosterImageModel']['caption']
|
1212
|
+
# Extract cast from poster caption as fallback
|
1213
|
+
caption = r['titlePosterImageModel']['caption']
|
1214
|
+
if caption.include?(' in ')
|
1215
|
+
cast_part = caption.split(' in ').first
|
1216
|
+
desc = "Cast: #{cast_part}" if cast_part.length < 50
|
1217
|
+
else
|
1218
|
+
desc = ""
|
1219
|
+
end
|
1220
|
+
else
|
1221
|
+
desc = ""
|
1222
|
+
end
|
1223
|
+
|
1224
|
+
# Check if already in our library
|
1225
|
+
already_have = (@movies + @series).any? { |entry| entry[:id] == r['id'] }
|
1226
|
+
|
1227
|
+
{
|
1228
|
+
id: r['id'],
|
1229
|
+
title: "#{r['titleNameText']} (#{r['titleReleaseText']})".to_s.clean_ansi,
|
1230
|
+
description: desc.clean_ansi,
|
1231
|
+
type: type_info,
|
1232
|
+
already_have: already_have
|
1233
|
+
}
|
1234
|
+
end
|
1235
|
+
end
|
1236
|
+
|
1237
|
+
# HELP SYSTEM {{{1
|
1238
|
+
def show_help(mode = :general) #{{{2
|
1239
|
+
@help_mode = mode
|
1240
|
+
@help.clear
|
1241
|
+
|
1242
|
+
case mode
|
1243
|
+
when :general
|
1244
|
+
@help.text = build_general_help
|
1245
|
+
when :regions
|
1246
|
+
@help.text = build_regions_help
|
1247
|
+
when :search
|
1248
|
+
@search_selection_index = 0
|
1249
|
+
@search_content_scroll = 0
|
1250
|
+
@help.text = build_search_help(@search_results || [], @search_query || "")
|
1251
|
+
update_search_display
|
1252
|
+
when :preview
|
1253
|
+
@help.text = build_preview_help(@preview_item || {})
|
1254
|
+
@help.ix = 0
|
1255
|
+
end
|
1256
|
+
|
1257
|
+
@help.ix = 0
|
1258
|
+
@help.index = 0
|
1259
|
+
@help.refresh
|
1260
|
+
end
|
1261
|
+
|
1262
|
+
def build_general_help #{{{2
|
1263
|
+
help_text = +"IMDb Top Movies & Series Browser - Help\n".b
|
1264
|
+
help_text << "=" * (@help.w - 4) << "\n\n"
|
1265
|
+
|
1266
|
+
help_text << "NAVIGATION:\n".b
|
1267
|
+
help_text << " Tab Switch between panes (List/Genres/Wish/Dump)\n"
|
1268
|
+
help_text << " ↑/↓ Arrows Navigate within current pane\n"
|
1269
|
+
help_text << " PgUp/PgDn Page up/down in current pane\n"
|
1270
|
+
help_text << " Home/End Jump to first/last item\n\n"
|
1271
|
+
|
1272
|
+
help_text << "MAIN ACTIONS:\n".b
|
1273
|
+
help_text << " + Add item to Wish List (or toggle + filter in Genres)\n"
|
1274
|
+
help_text << " - Add item to Dump List (or toggle - filter in Genres)\n"
|
1275
|
+
help_text << " ENTER Refresh screen\n"
|
1276
|
+
help_text << " q/Q Quit application\n\n"
|
1277
|
+
|
1278
|
+
help_text << "FILTERING:\n".b
|
1279
|
+
help_text << " r Set minimum rating threshold\n"
|
1280
|
+
help_text << " l Toggle between Movies and Series view\n"
|
1281
|
+
help_text << " o Toggle sort order (Rating/Alphabetical)\n"
|
1282
|
+
help_text << " y/Y Set year filters (min/max)\n\n"
|
1283
|
+
|
1284
|
+
help_text << "TMDB SETTINGS:\n".b
|
1285
|
+
help_text << " k Set TMDb API key\n"
|
1286
|
+
help_text << " R Select region for streaming providers\n\n"
|
1287
|
+
|
1288
|
+
help_text << "DATA MANAGEMENT:\n".b
|
1289
|
+
help_text << " I Full fetch (scrape and download all)\n"
|
1290
|
+
help_text << " i Refresh cache (incremental update)\n"
|
1291
|
+
help_text << " f Re-fetch current item details\n"
|
1292
|
+
help_text << " v Verify data integrity (check for missing/incomplete data)\n"
|
1293
|
+
help_text << " L Load additional lists (popular + trending)\n"
|
1294
|
+
help_text << " D Remove duplicate entries\n"
|
1295
|
+
help_text << " / Search IMDb\n\n"
|
1296
|
+
|
1297
|
+
help_text << "HELP:\n".b
|
1298
|
+
help_text << " ? Show this help (press again to cycle modes)\n"
|
1299
|
+
help_text << " ESC/ENTER Close help window\n\n"
|
1300
|
+
|
1301
|
+
help_text << "GENRE FILTERING:\n".b
|
1302
|
+
help_text << " When in Genres pane:\n"
|
1303
|
+
help_text << " + Include genre (green +)\n"
|
1304
|
+
help_text << " - Exclude genre (red -)\n"
|
1305
|
+
help_text << " Space Clear genre filter\n\n"
|
1306
|
+
|
1307
|
+
help_text << "PANE DESCRIPTIONS:\n".b
|
1308
|
+
help_text << " List Main movie/series list (filtered results)\n"
|
1309
|
+
help_text << " Genres Available genres with +/- filters\n"
|
1310
|
+
help_text << " Wish List Items you want to watch\n"
|
1311
|
+
help_text << " Dump List Items you want to hide\n"
|
1312
|
+
help_text << " Detail Information about selected item\n\n"
|
1313
|
+
|
1314
|
+
help_text << "TIPS:\n".b.fg(230)
|
1315
|
+
help_text << " • Use genre filters to find specific types of content\n".fg(230)
|
1316
|
+
help_text << " • Dump list removes items from main view permanently\n".fg(230)
|
1317
|
+
help_text << " • TMDb provides streaming provider information\n".fg(230)
|
1318
|
+
help_text << " • Posters are displayed in the terminal using w3mimgdisplay\n".fg(230)
|
1319
|
+
|
1320
|
+
help_text
|
1321
|
+
end
|
1322
|
+
|
1323
|
+
def build_search_help(results, query) #{{{2
|
1324
|
+
# Build fixed header (like @wish pane does)
|
1325
|
+
header = +"IMDb Search Results for: #{query}\n".b
|
1326
|
+
header << "TAB/S-TAB=Select ENTER=Preview ESC=Cancel /=New\n".fg(248)
|
1327
|
+
header << "─" * @help.w << "\n" # Full width
|
1328
|
+
|
1329
|
+
# Build scrollable content
|
1330
|
+
content_lines = []
|
1331
|
+
if results.empty?
|
1332
|
+
content_lines << "No results found.\n".fg(130)
|
1333
|
+
content_lines << "Try different keywords or check spelling."
|
1334
|
+
else
|
1335
|
+
results.each_with_index do |result, i|
|
1336
|
+
mark = i == (@search_selection_index || 0) ? "➤ " : " "
|
1337
|
+
|
1338
|
+
# Dim/mark items already in library
|
1339
|
+
if result[:already_have]
|
1340
|
+
title_line = "#{mark}#{result[:title]} ✓ ALREADY IN LIBRARY".fg(240)
|
1341
|
+
content_lines << title_line
|
1342
|
+
else
|
1343
|
+
content_lines << "#{mark}#{result[:title]}"
|
1344
|
+
end
|
1345
|
+
|
1346
|
+
# Build info line
|
1347
|
+
info_parts = []
|
1348
|
+
info_parts << "ID: #{result[:id]}"
|
1349
|
+
info_parts << "Type: #{result[:type]}" unless result[:type].empty?
|
1350
|
+
|
1351
|
+
if result[:already_have]
|
1352
|
+
content_lines << " #{info_parts.join(' ')}".fg(240)
|
1353
|
+
else
|
1354
|
+
content_lines << " #{info_parts.join(' ')}".fg(248)
|
1355
|
+
end
|
1356
|
+
|
1357
|
+
# Show cast info if available
|
1358
|
+
if result[:description] && !result[:description].empty?
|
1359
|
+
if result[:already_have]
|
1360
|
+
content_lines << " #{result[:description]}".fg(240)
|
1361
|
+
else
|
1362
|
+
content_lines << " #{result[:description]}".fg(230)
|
1363
|
+
end
|
1364
|
+
end
|
1365
|
+
|
1366
|
+
content_lines << "" # blank line
|
1367
|
+
end
|
1368
|
+
end
|
1369
|
+
|
1370
|
+
# Store content separately for scrolling
|
1371
|
+
@search_content_lines = content_lines
|
1372
|
+
|
1373
|
+
# Return complete text with header + content
|
1374
|
+
header + content_lines.join("\n")
|
1375
|
+
end
|
1376
|
+
|
1377
|
+
def build_preview_help(item) #{{{2
|
1378
|
+
# Header with navigation
|
1379
|
+
help_text = +"Preview: #{item[:title] || 'Loading...'}\n".b
|
1380
|
+
help_text << "ENTER=Add to Library ESC=Back to Search\n".fg(248)
|
1381
|
+
help_text << "─" * @help.w << "\n" # Full width
|
1382
|
+
|
1383
|
+
if @preview_details
|
1384
|
+
d = @preview_details
|
1385
|
+
help_text << "\n"
|
1386
|
+
help_text << "Rating: #{d[:rating]} (#{d[:votes] || 0} votes)\n".b
|
1387
|
+
help_text << "Type: #{d[:type] || item[:type]}\n"
|
1388
|
+
help_text << "Released: #{d[:release_date] || d[:start_date]}\n"
|
1389
|
+
help_text << "Runtime: #{d[:duration]}\n" unless d[:duration].to_s.empty?
|
1390
|
+
|
1391
|
+
if d[:type] == 'TVSeries'
|
1392
|
+
help_text << "Seasons: #{d[:seasons]} (#{d[:episodes]} episodes)\n"
|
1393
|
+
help_text << "Years: #{d[:start_date]} – #{d[:end_date]}\n"
|
1394
|
+
end
|
1395
|
+
|
1396
|
+
help_text << "Genres: #{(d[:genres] || []).join(', ')}\n"
|
1397
|
+
help_text << "Director: #{(d[:directors] || []).join(', ')}\n"
|
1398
|
+
help_text << "Cast: #{(d[:actors] || []).take(5).join(', ')}\n"
|
1399
|
+
help_text << "Rated: #{d[:content_rating]}\n" unless d[:content_rating].to_s.empty?
|
1400
|
+
|
1401
|
+
if @tmdb_key && !@tmdb_key.strip.empty?
|
1402
|
+
providers = Array(d[:providers])
|
1403
|
+
where_text = providers.empty? ? "(not available in #{@tmdb_region})" : providers.join(', ')
|
1404
|
+
help_text << "\nStreaming: #{where_text}\n".i
|
1405
|
+
help_text << "Popularity: #{sprintf('%.1f', d[:popularity] || 0)}\n".i
|
1406
|
+
end
|
1407
|
+
|
1408
|
+
help_text << "\nSummary:\n".b
|
1409
|
+
help_text << "#{d[:summary] || 'No summary available.'}\n".fg(230)
|
1410
|
+
else
|
1411
|
+
help_text << "\nFetching detailed information...\n".fg(248)
|
1412
|
+
help_text << "Please wait...\n".fg(248)
|
1413
|
+
end
|
1414
|
+
|
1415
|
+
help_text
|
1416
|
+
end
|
1417
|
+
|
1418
|
+
def build_regions_help #{{{2
|
1419
|
+
help_text = +"TMDb Streaming Provider Regions\n".b
|
1420
|
+
help_text << "=" * (@help.w - 4) << "\n\n"
|
1421
|
+
|
1422
|
+
help_text << "POPULAR REGIONS:\n".b
|
1423
|
+
popular_regions = [
|
1424
|
+
['US', 'United States'], ['GB', 'United Kingdom'], ['CA', 'Canada'],
|
1425
|
+
['AU', 'Australia'], ['DE', 'Germany'], ['FR', 'France'],
|
1426
|
+
['ES', 'Spain'], ['IT', 'Italy'], ['NL', 'Netherlands'],
|
1427
|
+
['SE', 'Sweden'], ['NO', 'Norway'], ['DK', 'Denmark'],
|
1428
|
+
['JP', 'Japan'], ['KR', 'South Korea'], ['IN', 'India'],
|
1429
|
+
['BR', 'Brazil'], ['MX', 'Mexico']
|
1430
|
+
]
|
1431
|
+
|
1432
|
+
popular_regions.each do |code, name|
|
1433
|
+
help_text << " #{code.ljust(3)} #{name}\n"
|
1434
|
+
end
|
1435
|
+
|
1436
|
+
help_text << "\nEUROPE:\n".b
|
1437
|
+
europe = [
|
1438
|
+
['AT', 'Austria'], ['BE', 'Belgium'], ['CH', 'Switzerland'],
|
1439
|
+
['CZ', 'Czech Republic'], ['FI', 'Finland'], ['GR', 'Greece'],
|
1440
|
+
['HU', 'Hungary'], ['IE', 'Ireland'], ['PL', 'Poland'],
|
1441
|
+
['PT', 'Portugal'], ['RO', 'Romania']
|
1442
|
+
]
|
1443
|
+
|
1444
|
+
europe.each do |code, name|
|
1445
|
+
help_text << " #{code.ljust(3)} #{name}\n"
|
1446
|
+
end
|
1447
|
+
|
1448
|
+
help_text << "\nASIA-PACIFIC:\n".b
|
1449
|
+
asia_pacific = [
|
1450
|
+
['SG', 'Singapore'], ['MY', 'Malaysia'], ['TH', 'Thailand'],
|
1451
|
+
['ID', 'Indonesia'], ['PH', 'Philippines'], ['TW', 'Taiwan'],
|
1452
|
+
['HK', 'Hong Kong'], ['NZ', 'New Zealand']
|
1453
|
+
]
|
1454
|
+
|
1455
|
+
asia_pacific.each do |code, name|
|
1456
|
+
help_text << " #{code.ljust(3)} #{name}\n"
|
1457
|
+
end
|
1458
|
+
|
1459
|
+
help_text << "\nAMERICAS:\n".b
|
1460
|
+
americas = [
|
1461
|
+
['AR', 'Argentina'], ['CL', 'Chile'], ['CO', 'Colombia'],
|
1462
|
+
['PE', 'Peru'], ['UY', 'Uruguay'], ['VE', 'Venezuela']
|
1463
|
+
]
|
1464
|
+
|
1465
|
+
americas.each do |code, name|
|
1466
|
+
help_text << " #{code.ljust(3)} #{name}\n"
|
1467
|
+
end
|
1468
|
+
|
1469
|
+
help_text << "\nUSAGE:\n".b
|
1470
|
+
help_text << " 1. Press 'R' to change region\n"
|
1471
|
+
help_text << " 2. Enter a 2-letter country code\n"
|
1472
|
+
help_text << " 3. Or press '?' to see this list\n"
|
1473
|
+
help_text << " 4. Press Enter to confirm\n\n"
|
1474
|
+
|
1475
|
+
help_text << "NOTE:\n".b.fg(230)
|
1476
|
+
help_text << " Different regions have different streaming services.\n".fg(230)
|
1477
|
+
help_text << " Some content may not be available in all regions.\n".fg(230)
|
1478
|
+
|
1479
|
+
help_text
|
1480
|
+
end
|
1481
|
+
|
1482
|
+
def scroll_to_search_selection #{{{2
|
1483
|
+
return unless @help_mode == :search && @search_results && !@search_results.empty?
|
1484
|
+
|
1485
|
+
# Calculate lines per result dynamically based on content
|
1486
|
+
lines_per_result = 4 # title + ID/type + description + blank
|
1487
|
+
|
1488
|
+
# Available height for content
|
1489
|
+
content_height = @help.h - 5 # header(3) + border(2)
|
1490
|
+
|
1491
|
+
# Center the selected item
|
1492
|
+
selected_content_line = @search_selection_index * lines_per_result
|
1493
|
+
center_top = selected_content_line - content_height / 2
|
1494
|
+
center_top = 0 if center_top < 0
|
1495
|
+
|
1496
|
+
# Don't scroll past the end
|
1497
|
+
max_scroll = [@search_content_lines.size - content_height, 0].max
|
1498
|
+
center_top = max_scroll if center_top > max_scroll
|
1499
|
+
|
1500
|
+
# Set scroll to content area only
|
1501
|
+
@search_content_scroll = center_top
|
1502
|
+
update_search_display
|
1503
|
+
end
|
1504
|
+
|
1505
|
+
def update_search_display #{{{2
|
1506
|
+
return unless @help_mode == :search
|
1507
|
+
|
1508
|
+
# Rebuild complete text with fixed header + scrolled content
|
1509
|
+
header = +"IMDb Search Results for: #{@search_query}\n".b
|
1510
|
+
header << "TAB/S-TAB=Select ENTER=Preview ESC=Cancel /=New\n".fg(248)
|
1511
|
+
header << "─" * @help.w << "\n" # Full width
|
1512
|
+
|
1513
|
+
# Get visible content lines based on scroll position
|
1514
|
+
content_height = @help.h - 5 # header(3) + border(2)
|
1515
|
+
scroll_pos = @search_content_scroll || 0
|
1516
|
+
visible_content = @search_content_lines[scroll_pos, content_height] || []
|
1517
|
+
|
1518
|
+
@help.text = header + visible_content.join("\n")
|
1519
|
+
@help.ix = 0 # Never scroll the combined text
|
1520
|
+
@help.refresh
|
1521
|
+
end
|
1522
|
+
|
1523
|
+
def update_help #{{{2
|
1524
|
+
return if @help_mode == :hidden
|
1525
|
+
|
1526
|
+
if @help_mode == :search
|
1527
|
+
update_search_display
|
1528
|
+
return
|
1529
|
+
elsif @help_mode == :preview
|
1530
|
+
# Preview mode uses normal scrolling
|
1531
|
+
@help.ix = 0 # Keep at top
|
1532
|
+
@help.refresh
|
1533
|
+
return
|
1534
|
+
end
|
1535
|
+
|
1536
|
+
lines = @help.text.split("\n")
|
1537
|
+
visible_lines = @help.h - 2 # Account for border
|
1538
|
+
|
1539
|
+
# Handle scrolling
|
1540
|
+
if lines.size > visible_lines
|
1541
|
+
top = @help.ix
|
1542
|
+
max_top = [lines.size - visible_lines, 0].max
|
1543
|
+
@help.ix = [[top, 0].max, max_top].min
|
561
1544
|
else
|
562
|
-
|
563
|
-
end
|
564
|
-
|
565
|
-
|
566
|
-
|
567
|
-
|
568
|
-
|
569
|
-
|
570
|
-
|
571
|
-
|
1545
|
+
@help.ix = 0
|
1546
|
+
end
|
1547
|
+
|
1548
|
+
@help.refresh
|
1549
|
+
end
|
1550
|
+
|
1551
|
+
def hide_help #{{{2
|
1552
|
+
@help_mode = :hidden
|
1553
|
+
Rcurses.clear_screen
|
1554
|
+
[@header, @list, @genres, @wish, @dump, @detail, @footer].each(&:full_refresh)
|
1555
|
+
@last_poster_id = nil
|
1556
|
+
draw_all # Refresh all panes to overwrite help
|
1557
|
+
end
|
1558
|
+
|
1559
|
+
# DISPLAY FUNCTIONS {{{1
|
1560
|
+
def draw_all #{{{2
|
1561
|
+
refresh_layout
|
1562
|
+
update_header; update_list; update_genres; update_wish; update_dump; update_detail; update_footer
|
1563
|
+
|
1564
|
+
# Draw help overlay if visible
|
1565
|
+
if @help_mode != :hidden
|
1566
|
+
update_help
|
1567
|
+
end
|
1568
|
+
end
|
1569
|
+
|
1570
|
+
def update_header #{{{2
|
1571
|
+
_, cols = IO.console.winsize
|
1572
|
+
shown = (@index_list || []).size
|
1573
|
+
actual_total = @show_movies ? @movies.size : @series.size
|
1574
|
+
if @show_movies
|
1575
|
+
@header.bg = 202
|
1576
|
+
type = 'Movies'
|
1577
|
+
else
|
1578
|
+
@header.bg = 23
|
1579
|
+
type = 'Series'
|
1580
|
+
end
|
1581
|
+
hdr = format(" IMDb Top %-6s (%d/%d) Rating ≥%.1f", type, shown, actual_total, @rating_threshold)
|
1582
|
+
if @year_min || @year_max
|
1583
|
+
year_str = " Years: "
|
1584
|
+
year_str += @year_min ? @year_min.to_s : "∞"
|
1585
|
+
year_str += "-"
|
1586
|
+
year_str += @year_max ? @year_max.to_s : "∞"
|
1587
|
+
hdr += year_str
|
1588
|
+
end
|
1589
|
+
hdr += " Sort: #{@sort_by.upcase} "
|
1590
|
+
if @tmdb_key && !@tmdb_key.strip.empty?
|
1591
|
+
hdr += " Region: #{@tmdb_region}"
|
1592
|
+
end
|
1593
|
+
@header.clear
|
1594
|
+
@header.say(hdr.ljust(cols))
|
1595
|
+
@header.refresh
|
1596
|
+
end
|
1597
|
+
|
1598
|
+
def get_current_list_items #{{{2
|
1599
|
+
if @index_list.empty?
|
1600
|
+
list = []
|
1601
|
+
list += @movies if @show_movies
|
1602
|
+
list += @series if @show_series
|
1603
|
+
list
|
1604
|
+
else
|
1605
|
+
@index_list
|
1606
|
+
end
|
1607
|
+
end
|
1608
|
+
|
1609
|
+
def update_list #{{{2
|
1610
|
+
items = get_current_list_items
|
1611
|
+
|
1612
|
+
lines = items.map.with_index do |e,i|
|
1613
|
+
mark = (i == @list.index ? '➤' : ' ')
|
1614
|
+
"%s %4.1f %s" % [mark, e[:rating], e[:title].clean_ansi]
|
1615
|
+
end.map{|l| l[0,@list.w]}
|
1616
|
+
|
1617
|
+
@list.text = lines.join("\n")
|
1618
|
+
|
1619
|
+
if lines.empty?
|
1620
|
+
@list.ix = 0
|
1621
|
+
else
|
1622
|
+
h = @list.h
|
1623
|
+
top = @list.index - h/2
|
1624
|
+
top = 0 if top < 0
|
1625
|
+
max_top = [lines.size - h, 0].max
|
1626
|
+
top = max_top if top > max_top
|
1627
|
+
@list.ix = top
|
1628
|
+
end
|
1629
|
+
|
1630
|
+
@list.refresh
|
1631
|
+
end
|
1632
|
+
|
1633
|
+
def update_genres #{{{2
|
1634
|
+
build_genre_list
|
1635
|
+
@genres.clear
|
1636
|
+
@genres_list.each_with_index do |genre, i|
|
1637
|
+
mark = @genre_filters[genre]
|
1638
|
+
prefix = (mark == 1 ? '+' : mark == -1 ? '-' : ' ')
|
1639
|
+
fg = (mark == 1 ? 2 : mark == -1 ? 1 : @genres.fg)
|
1640
|
+
line = "#{prefix} #{genre}"[0, @genres.w]
|
1641
|
+
if i == @genres.index
|
1642
|
+
line[2..] = line[2..].u
|
1643
|
+
end
|
1644
|
+
@genres.text << line.fg(fg) << "\n"
|
1645
|
+
end
|
1646
|
+
@genres.ix = [
|
1647
|
+
@genres.index - (@genres.h / 2),
|
1648
|
+
0
|
1649
|
+
].max
|
1650
|
+
max_top = [@genres_list.size - @genres.h, 0].max
|
1651
|
+
@genres.ix = max_top if @genres.ix > max_top
|
1652
|
+
@genres.refresh
|
1653
|
+
end
|
1654
|
+
|
1655
|
+
def update_wish #{{{2
|
1656
|
+
current_wish_list = @show_movies ? @movie_wish_list : @series_wish_list
|
1657
|
+
type_name = @show_movies ? "Movie" : "Series"
|
1658
|
+
|
1659
|
+
lines = current_wish_list.each_with_index.map do |tconst, i|
|
1660
|
+
entry = (@movies + @series).find { |e| e[:id] == tconst }
|
1661
|
+
title = (entry ? entry[:title] : tconst).to_s.clean_ansi
|
1662
|
+
mark = (i == @wish.index ? '➤ ' : ' ')
|
1663
|
+
(mark + title)[0, @wish.w]
|
1664
|
+
end
|
1665
|
+
|
1666
|
+
header = "#{type_name} Wish List"
|
1667
|
+
@wish.text = header.b + "\n" + lines.join("\n")
|
1668
|
+
|
1669
|
+
h = @wish.h - 1
|
1670
|
+
top = @wish.index - h/2
|
1671
|
+
top = 0 if top < 0
|
1672
|
+
max_top = [lines.size - h, 0].max
|
1673
|
+
top = max_top if top > max_top
|
1674
|
+
@wish.ix = top
|
1675
|
+
@wish.refresh
|
1676
|
+
end
|
1677
|
+
|
1678
|
+
def update_dump #{{{2
|
1679
|
+
current_dump_list = @show_movies ? @movie_dump_list : @series_dump_list
|
1680
|
+
type_name = @show_movies ? "Movie" : "Series"
|
1681
|
+
|
1682
|
+
lines = current_dump_list.each_with_index.map do |tconst, i|
|
1683
|
+
entry = (@movies + @series).find { |e| e[:id] == tconst }
|
1684
|
+
title = (entry ? entry[:title] : tconst).to_s.clean_ansi
|
1685
|
+
mark = (i == @dump.index ? '➤ ' : ' ')
|
1686
|
+
(mark + title)[0, @dump.w]
|
1687
|
+
end
|
1688
|
+
|
1689
|
+
header = "#{type_name} Discard List"
|
1690
|
+
@dump.text = header.b + "\n" + lines.join("\n")
|
1691
|
+
|
1692
|
+
h = @dump.h - 1
|
1693
|
+
top = @dump.index - h/2
|
1694
|
+
top = 0 if top < 0
|
1695
|
+
max_top = [lines.size - h, 0].max
|
1696
|
+
top = max_top if top > max_top
|
1697
|
+
@dump.ix = top
|
1698
|
+
@dump.refresh
|
1699
|
+
end
|
1700
|
+
|
1701
|
+
def update_detail #{{{2
|
1702
|
+
entry = nil
|
1703
|
+
|
1704
|
+
case @focus
|
1705
|
+
when @list
|
1706
|
+
items = get_current_list_items
|
1707
|
+
entry = items[@list.index] if @list.index < items.size
|
1708
|
+
|
1709
|
+
when @wish
|
1710
|
+
current_wish_list = @show_movies ? @movie_wish_list : @series_wish_list
|
1711
|
+
if @wish.index < current_wish_list.size
|
1712
|
+
tconst = current_wish_list[@wish.index]
|
1713
|
+
entry = (@movies + @series).find { |e| e[:id] == tconst }
|
1714
|
+
end
|
1715
|
+
|
1716
|
+
when @dump
|
1717
|
+
current_dump_list = @show_movies ? @movie_dump_list : @series_dump_list
|
1718
|
+
if @dump.index < current_dump_list.size
|
1719
|
+
tconst = current_dump_list[@dump.index]
|
1720
|
+
entry = (@movies + @series).find { |e| e[:id] == tconst }
|
1721
|
+
end
|
1722
|
+
|
1723
|
+
when @genres
|
1724
|
+
items = get_current_list_items
|
1725
|
+
entry = items[@list.index] if @list.index < items.size
|
1726
|
+
end
|
1727
|
+
|
1728
|
+
if entry
|
1729
|
+
id = entry[:id]
|
1730
|
+
d = @details_cache[id] || {}
|
1731
|
+
providers = Array(d[:providers])
|
1732
|
+
|
1733
|
+
where_text = case d[:error]
|
1734
|
+
when :no_key then "(TMDb: no API key set)"
|
1735
|
+
when :invalid_key then "(TMDb: invalid API key)"
|
1736
|
+
when :fetch_error then "(TMDb: network error)"
|
1737
|
+
when :timeout then "(TMDb: request timeout)"
|
1738
|
+
when :api_error then "(TMDb: API error)"
|
1739
|
+
when :not_found then "(TMDb: not found on TMDb)"
|
1740
|
+
when :none then providers.empty? ? "(not available in #{@tmdb_region})" : providers.join(', ')
|
1741
|
+
else
|
1742
|
+
if providers.empty?
|
1743
|
+
d[:error] ? "(TMDb: #{d[:error]})" : "(not available in #{@tmdb_region})"
|
1744
|
+
else
|
1745
|
+
providers.join(', ')
|
1746
|
+
end
|
1747
|
+
end
|
1748
|
+
|
1749
|
+
pop = (d[:popularity] || 0.0).to_f
|
1750
|
+
buf = +"Title: #{entry[:title]} (#{id})\n".b
|
1751
|
+
buf << "Rating: #{entry[:rating]} (#{d[:votes] || 0} votes)\n"
|
1752
|
+
buf << "Released: #{d[:release_date]} Country: #{d[:country] || ''}\n"
|
1753
|
+
buf << "Genres: #{(d[:genres]||[]).join(', ')}\n"
|
1754
|
+
buf << "Director: #{(d[:directors]||[]).join(', ')}\n"
|
1755
|
+
buf << "Cast: #{(d[:actors]||[]).take(5).join(', ')}\n"
|
1756
|
+
buf << "Rated: #{d[:content_rating] || ''}\n"
|
1757
|
+
buf << "Runtime: #{d[:duration].to_s || ''}\n"
|
1758
|
+
buf << "Years: #{d[:start_date] || ''} – #{d[:end_date] || ''}\n"
|
1759
|
+
buf << "\n#{d[:summary] || ''}\n\n".fg(230)
|
1760
|
+
|
1761
|
+
if @tmdb_key && !@tmdb_key.strip.empty?
|
1762
|
+
buf << "Streaming in #{@tmdb_region} (TMDb)\n".b.i
|
1763
|
+
buf << "Where: #{where_text}\n".i
|
1764
|
+
buf << format("Popularity: %.1f\n", pop).i
|
1765
|
+
|
1766
|
+
# Show helpful hint for network issues
|
1767
|
+
if d[:error] == :timeout || d[:error] == :fetch_error
|
1768
|
+
buf << "(Try 'i' to refresh cache)\n".fg(130).i
|
1769
|
+
end
|
572
1770
|
else
|
573
|
-
|
574
|
-
|
575
|
-
end
|
576
|
-
|
577
|
-
|
578
|
-
|
579
|
-
|
580
|
-
|
581
|
-
|
582
|
-
|
583
|
-
|
584
|
-
|
585
|
-
|
586
|
-
|
587
|
-
|
588
|
-
|
589
|
-
|
590
|
-
|
591
|
-
|
592
|
-
|
593
|
-
|
594
|
-
|
595
|
-
|
596
|
-
|
597
|
-
|
598
|
-
|
599
|
-
|
600
|
-
|
601
|
-
|
602
|
-
|
603
|
-
|
604
|
-
|
605
|
-
|
606
|
-
|
607
|
-
|
608
|
-
|
609
|
-
|
610
|
-
|
611
|
-
|
612
|
-
|
613
|
-
|
614
|
-
|
615
|
-
|
616
|
-
|
617
|
-
|
618
|
-
|
619
|
-
|
620
|
-
|
621
|
-
|
622
|
-
|
623
|
-
|
624
|
-
|
625
|
-
|
626
|
-
|
627
|
-
|
628
|
-
|
629
|
-
|
630
|
-
|
631
|
-
|
632
|
-
|
633
|
-
|
634
|
-
|
635
|
-
|
636
|
-
|
637
|
-
|
638
|
-
|
639
|
-
|
640
|
-
|
641
|
-
|
642
|
-
|
643
|
-
|
644
|
-
|
645
|
-
|
646
|
-
|
647
|
-
|
648
|
-
|
649
|
-
|
650
|
-
|
651
|
-
|
652
|
-
|
653
|
-
|
654
|
-
|
655
|
-
|
656
|
-
|
657
|
-
|
658
|
-
|
659
|
-
|
660
|
-
|
661
|
-
|
662
|
-
|
663
|
-
|
664
|
-
|
665
|
-
|
666
|
-
|
667
|
-
|
668
|
-
|
669
|
-
|
670
|
-
|
671
|
-
|
672
|
-
|
673
|
-
|
674
|
-
|
675
|
-
|
676
|
-
|
677
|
-
|
678
|
-
|
679
|
-
|
680
|
-
|
681
|
-
|
682
|
-
|
683
|
-
|
684
|
-
|
685
|
-
|
686
|
-
|
687
|
-
|
688
|
-
|
689
|
-
|
690
|
-
|
691
|
-
|
692
|
-
|
693
|
-
|
694
|
-
|
695
|
-
|
696
|
-
|
697
|
-
|
698
|
-
|
699
|
-
|
700
|
-
|
701
|
-
|
702
|
-
|
703
|
-
|
704
|
-
|
705
|
-
|
706
|
-
|
707
|
-
|
708
|
-
|
709
|
-
|
710
|
-
|
711
|
-
|
712
|
-
|
713
|
-
|
714
|
-
|
715
|
-
|
716
|
-
|
717
|
-
|
718
|
-
|
719
|
-
|
720
|
-
|
721
|
-
|
722
|
-
|
723
|
-
|
724
|
-
|
725
|
-
|
726
|
-
|
727
|
-
|
728
|
-
|
729
|
-
|
730
|
-
|
731
|
-
|
732
|
-
|
733
|
-
|
734
|
-
|
735
|
-
|
736
|
-
|
737
|
-
|
738
|
-
|
739
|
-
|
740
|
-
|
741
|
-
|
742
|
-
|
743
|
-
|
744
|
-
|
745
|
-
|
746
|
-
|
747
|
-
|
748
|
-
|
749
|
-
|
750
|
-
|
751
|
-
|
752
|
-
|
753
|
-
|
754
|
-
|
755
|
-
|
756
|
-
|
757
|
-
|
758
|
-
|
759
|
-
|
760
|
-
|
761
|
-
|
762
|
-
|
763
|
-
|
764
|
-
|
765
|
-
|
766
|
-
|
767
|
-
|
768
|
-
|
769
|
-
|
770
|
-
|
771
|
-
|
772
|
-
|
773
|
-
@
|
774
|
-
|
775
|
-
|
776
|
-
|
777
|
-
|
778
|
-
|
779
|
-
|
780
|
-
|
781
|
-
|
782
|
-
|
783
|
-
@
|
784
|
-
|
785
|
-
|
786
|
-
|
787
|
-
|
788
|
-
|
789
|
-
|
790
|
-
|
791
|
-
|
792
|
-
|
793
|
-
|
794
|
-
|
795
|
-
|
796
|
-
|
797
|
-
|
798
|
-
|
799
|
-
|
800
|
-
|
1771
|
+
buf << "TMDb Data (no API key)\n".b.i
|
1772
|
+
buf << "Set TMDb key with 'k' for streaming info\n".i
|
1773
|
+
end
|
1774
|
+
|
1775
|
+
if d[:type] == 'TVSeries'
|
1776
|
+
buf << "Seasons/Ep: #{d[:seasons] || 0}/#{d[:episodes] || 0}\n".i
|
1777
|
+
end
|
1778
|
+
@detail.text = buf
|
1779
|
+
else
|
1780
|
+
@detail.text = "No titles match your filter.\n"
|
1781
|
+
end
|
1782
|
+
@detail.refresh
|
1783
|
+
if entry && entry[:id] != @last_poster_id
|
1784
|
+
show_poster(entry[:id])
|
1785
|
+
@last_poster_id = entry[:id]
|
1786
|
+
end
|
1787
|
+
end
|
1788
|
+
|
1789
|
+
def update_footer #{{{2
|
1790
|
+
return if @fetching
|
1791
|
+
_, cols = IO.console.winsize
|
1792
|
+
cmds = [
|
1793
|
+
"Tab=Pane","↑/↓=Scroll", "+/−=Wish/Dump", "r=Rating","y/Y=Year","l=Toggle","o=Sort",
|
1794
|
+
"I=FullFetch", "i=Refresh","f=ReFetch","L=MoreLists","D=DeDupe","k=TMDb","R=Region","/=Search","?=Help","q=Quit"
|
1795
|
+
].join(" ")
|
1796
|
+
@footer.say(cmds.ljust(cols))
|
1797
|
+
@footer.full_refresh
|
1798
|
+
end
|
1799
|
+
|
1800
|
+
def show_poster(tconst) #{{{2
|
1801
|
+
w3m = "/usr/lib/w3m/w3mimgdisplay"
|
1802
|
+
return unless File.executable?(w3m)
|
1803
|
+
cache = cache_dir
|
1804
|
+
file = File.join(cache, "#{tconst}.jpg")
|
1805
|
+
|
1806
|
+
begin
|
1807
|
+
Timeout.timeout(2) do
|
1808
|
+
info = `xwininfo -id $(xdotool getactivewindow) 2>/dev/null`
|
1809
|
+
return unless info =~ /Width:\s*(\d+).*Height:\s*(\d+)/m
|
1810
|
+
term_w, term_h = $1.to_i, $2.to_i
|
1811
|
+
rows, cols = IO.console.winsize
|
1812
|
+
cw = term_w.to_f / cols
|
1813
|
+
ch = term_h.to_f / rows
|
1814
|
+
px = ((@detail.x - 1) * cw).to_i
|
1815
|
+
py = (25 * ch).to_i
|
1816
|
+
max_w = (40 * cw).to_i
|
1817
|
+
max_h = ((rows - 28) * ch).to_i
|
1818
|
+
|
1819
|
+
`echo "6;#{px};#{py};#{max_w+4};#{max_h+4};\n4;\n3;" | #{w3m} 2>/dev/null`
|
1820
|
+
|
1821
|
+
if File.exist?(file) && File.size?(file) > 0
|
1822
|
+
iw, ih = `identify -format "%wx%h" #{file}`.split('x').map(&:to_i)
|
1823
|
+
|
1824
|
+
if iw > max_w
|
1825
|
+
ih = ih * max_w / iw; iw = max_w
|
1826
|
+
end
|
1827
|
+
if ih > max_h
|
1828
|
+
iw = iw * max_h / ih; ih = max_h
|
1829
|
+
end
|
1830
|
+
|
1831
|
+
`echo "0;1;#{px};#{py};#{iw};#{ih};;;;;\"#{file}\"\n4;\n3;" | #{w3m} 2>/dev/null`
|
1832
|
+
end
|
1833
|
+
end
|
1834
|
+
rescue Timeout::Error
|
1835
|
+
# skip on hang
|
1836
|
+
end
|
1837
|
+
end
|
1838
|
+
|
1839
|
+
# MAIN LOOP {{{1
|
1840
|
+
def run_loop #{{{2
|
1841
|
+
@last_poster_id = nil
|
1842
|
+
draw_all
|
1843
|
+
loop do
|
1844
|
+
ready = IO.select([STDIN], nil, nil, 0.05)
|
1845
|
+
if ready
|
1846
|
+
handle_input
|
1847
|
+
end
|
1848
|
+
if defined?(@ui_update_queue) && @ui_update_queue
|
1849
|
+
begin
|
1850
|
+
while !@ui_update_queue.empty?
|
1851
|
+
update = @ui_update_queue.pop(true)
|
1852
|
+
case update
|
1853
|
+
when :initial_progress
|
1854
|
+
@progress.say("Fetching details & posters for #{@bg_total.value} items... Go grab a coffee! (press 'c' to cancel)")
|
1855
|
+
when :update_progress
|
1856
|
+
@progress.say("Fetched #{@bg_fetched.value}/#{@bg_total.value} details & posters... (press 'c' to cancel)")
|
1857
|
+
when :rebuild_and_draw
|
1858
|
+
build_genre_list
|
1859
|
+
rebuild_index
|
1860
|
+
draw_all
|
1861
|
+
when :fetch_complete
|
1862
|
+
@progress.say("✓ All data cached to #{cache_dir}")
|
1863
|
+
sleep 1.0
|
1864
|
+
@progress.clear
|
1865
|
+
update_footer
|
1866
|
+
@footer.refresh
|
1867
|
+
# Additional lists fetch messages
|
1868
|
+
when :start_additional_fetch
|
1869
|
+
@progress.say("Loading additional lists... (press 'c' to cancel)")
|
1870
|
+
when :fetching_popular_movies
|
1871
|
+
@progress.say("Fetching popular movies... (press 'c' to cancel)")
|
1872
|
+
when :fetching_popular_series
|
1873
|
+
@progress.say("Fetching popular TV series... (press 'c' to cancel)")
|
1874
|
+
when :fetching_trending
|
1875
|
+
@progress.say("Fetching trending content... (press 'c' to cancel)")
|
1876
|
+
when :rebuild_and_refresh
|
1877
|
+
build_genre_list
|
1878
|
+
rebuild_index
|
1879
|
+
draw_all
|
1880
|
+
when :update_additional_progress
|
1881
|
+
@progress.say("Fetched details #{@bg_fetched.value}/#{@bg_total.value} for new items... (press 'c' to cancel)")
|
1882
|
+
# Verification messages
|
1883
|
+
when :verification_ask_fix
|
1884
|
+
@progress.say("Auto-fixing issues... (press 'c' to cancel)")
|
1885
|
+
when :auto_start_verification
|
1886
|
+
@progress.say("Starting automatic verification...")
|
1887
|
+
sleep(0.5)
|
1888
|
+
start_verification_check
|
1889
|
+
when Array
|
1890
|
+
if update[0] == :start_detail_fetch
|
1891
|
+
_, new_movies, new_series, total_items = update
|
1892
|
+
@progress.say("Added #{new_movies} movies, #{new_series} series. Fetching details for #{total_items} items...")
|
1893
|
+
elsif update[0] == :additional_fetch_complete
|
1894
|
+
_, new_movies, new_series = update
|
1895
|
+
@progress.say("✓ Added #{new_movies} new movies, #{new_series} new series with full details")
|
1896
|
+
sleep 1.5
|
1897
|
+
@progress.clear
|
1898
|
+
update_footer
|
1899
|
+
@footer.refresh
|
1900
|
+
elsif update[0] == :additional_fetch_error
|
1901
|
+
_, error_msg = update
|
1902
|
+
@progress.say("✗ Error loading lists: #{error_msg}")
|
1903
|
+
sleep 2.0
|
1904
|
+
@progress.clear
|
1905
|
+
update_footer
|
1906
|
+
@footer.refresh
|
1907
|
+
elsif update[0] == :start_verification
|
1908
|
+
_, total_items = update
|
1909
|
+
@progress.say("Verifying #{total_items} items... (press 'c' to cancel)")
|
1910
|
+
elsif update[0] == :verification_progress
|
1911
|
+
_, current, total = update
|
1912
|
+
@progress.say("Verified #{current}/#{total} items... (press 'c' to cancel)")
|
1913
|
+
elsif update[0] == :verification_complete
|
1914
|
+
@progress.say("✓ Verification complete - no issues found")
|
1915
|
+
sleep 1.5
|
1916
|
+
@progress.clear
|
1917
|
+
update_footer
|
1918
|
+
@footer.refresh
|
1919
|
+
elsif update[0] == :verification_issues_found
|
1920
|
+
_, missing_details, missing_posters, incomplete_data, total = update
|
1921
|
+
@progress.say("⚠ Found #{total} issues: #{missing_details} missing details, #{incomplete_data} incomplete, #{missing_posters} missing posters")
|
1922
|
+
sleep 2.0
|
1923
|
+
elsif update[0] == :start_fixing
|
1924
|
+
_, items_to_fix = update
|
1925
|
+
@progress.say("Fixing #{items_to_fix} items with missing/incomplete data... (press 'c' to cancel)")
|
1926
|
+
elsif update[0] == :fix_progress
|
1927
|
+
_, current, total = update
|
1928
|
+
@progress.say("Fixed #{current}/#{total} items... (press 'c' to cancel)")
|
1929
|
+
elsif update[0] == :verification_fix_complete
|
1930
|
+
_, fixed_count = update
|
1931
|
+
@progress.say("✓ Verification complete - fixed #{fixed_count} items")
|
1932
|
+
sleep 2.0
|
1933
|
+
@progress.clear
|
1934
|
+
update_footer
|
1935
|
+
@footer.refresh
|
1936
|
+
elsif update[0] == :verification_error
|
1937
|
+
_, error_msg = update
|
1938
|
+
@progress.say("✗ Verification error: #{error_msg}")
|
1939
|
+
sleep 2.0
|
1940
|
+
@progress.clear
|
1941
|
+
update_footer
|
1942
|
+
@footer.refresh
|
1943
|
+
end
|
1944
|
+
end
|
1945
|
+
end
|
1946
|
+
rescue ThreadError
|
1947
|
+
# Queue was empty, continue
|
1948
|
+
end
|
1949
|
+
end
|
1950
|
+
if !@fetching && defined?(@last_footer_update)
|
1951
|
+
if Time.now - @last_footer_update > 1.0
|
1952
|
+
update_footer
|
1953
|
+
@last_footer_update = Time.now
|
1954
|
+
end
|
1955
|
+
elsif !defined?(@last_footer_update)
|
1956
|
+
@last_footer_update = Time.now
|
1957
|
+
update_footer unless @fetching
|
1958
|
+
end
|
1959
|
+
end
|
1960
|
+
end
|
1961
|
+
|
1962
|
+
# INPUT HANDLING {{{1
|
1963
|
+
def handle_input #{{{2
|
1964
|
+
panes = [@list, @genres, @wish, @dump]
|
1965
|
+
items = @index_list.empty? ? (@show_movies ? @movies : @series) : @index_list
|
1966
|
+
|
1967
|
+
# Handle help mode input separately
|
1968
|
+
if @help_mode != :hidden # {{{3
|
1969
|
+
case getchr
|
1970
|
+
when 'UP' # {{{4
|
1971
|
+
if @help_mode == :search
|
1972
|
+
# Scroll content only, header stays fixed
|
1973
|
+
@search_content_scroll = [(@search_content_scroll || 0) - 1, 0].max
|
1974
|
+
update_search_display
|
1975
|
+
else
|
1976
|
+
@help.ix = [@help.ix - 1, 0].max
|
1977
|
+
update_help
|
1978
|
+
end
|
1979
|
+
return
|
1980
|
+
when 'DOWN' # {{{4
|
1981
|
+
if @help_mode == :search
|
1982
|
+
# Scroll content only, header stays fixed
|
1983
|
+
content_height = @help.h - 5 # header(3) + border(2)
|
1984
|
+
max_scroll = [@search_content_lines.size - content_height, 0].max
|
1985
|
+
@search_content_scroll = [(@search_content_scroll || 0) + 1, max_scroll].min
|
1986
|
+
update_search_display
|
1987
|
+
else
|
1988
|
+
lines = @help.text.split("\n")
|
1989
|
+
visible_lines = @help.h - 2
|
1990
|
+
max_top = [lines.size - visible_lines, 0].max
|
1991
|
+
@help.ix = [@help.ix + 1, max_top].min
|
1992
|
+
update_help
|
1993
|
+
end
|
1994
|
+
return
|
1995
|
+
when 'TAB' # {{{4
|
1996
|
+
if @help_mode == :search && @search_results && !@search_results.empty?
|
1997
|
+
@search_selection_index = ((@search_selection_index || 0) + 1) % @search_results.size
|
1998
|
+
build_search_help(@search_results, @search_query || "")
|
1999
|
+
scroll_to_search_selection
|
2000
|
+
end
|
2001
|
+
return
|
2002
|
+
when 'S-TAB' # {{{4 (Shift-TAB)
|
2003
|
+
if @help_mode == :search && @search_results && !@search_results.empty?
|
2004
|
+
@search_selection_index = ((@search_selection_index || 0) - 1) % @search_results.size
|
2005
|
+
build_search_help(@search_results, @search_query || "")
|
2006
|
+
scroll_to_search_selection
|
2007
|
+
end
|
2008
|
+
return
|
2009
|
+
when 'PgUP' # {{{4
|
2010
|
+
if @help_mode == :search
|
2011
|
+
content_height = @help.h - 5 # header(3) + border(2)
|
2012
|
+
@search_content_scroll = [(@search_content_scroll || 0) - content_height, 0].max
|
2013
|
+
update_search_display
|
2014
|
+
else
|
2015
|
+
@help.ix = [@help.ix - (@help.h - 2), 0].max
|
2016
|
+
update_help
|
2017
|
+
end
|
2018
|
+
return
|
2019
|
+
when 'PgDOWN' # {{{4
|
2020
|
+
if @help_mode == :search
|
2021
|
+
content_height = @help.h - 5 # header(3) + border(2)
|
2022
|
+
max_scroll = [@search_content_lines.size - content_height, 0].max
|
2023
|
+
@search_content_scroll = [(@search_content_scroll || 0) + content_height, max_scroll].min
|
2024
|
+
update_search_display
|
2025
|
+
else
|
2026
|
+
lines = @help.text.split("\n")
|
2027
|
+
visible_lines = @help.h - 2
|
2028
|
+
max_top = [lines.size - visible_lines, 0].max
|
2029
|
+
@help.ix = [@help.ix + (@help.h - 2), max_top].min
|
2030
|
+
update_help
|
2031
|
+
end
|
2032
|
+
return
|
2033
|
+
when '?' # {{{4
|
2034
|
+
# Cycle through help modes
|
2035
|
+
case @help_mode
|
2036
|
+
when :general
|
2037
|
+
show_help(:regions)
|
2038
|
+
when :regions
|
2039
|
+
show_help(:general)
|
2040
|
+
when :search
|
2041
|
+
show_help(:general)
|
2042
|
+
when :preview
|
2043
|
+
show_help(:general)
|
2044
|
+
end
|
2045
|
+
return
|
2046
|
+
when 'ENTER' # {{{4
|
2047
|
+
if @help_mode == :search && @search_results && !@search_results.empty?
|
2048
|
+
# Go to preview mode
|
2049
|
+
selected = @search_results[@search_selection_index || 0]
|
2050
|
+
if selected
|
2051
|
+
if selected[:already_have]
|
2052
|
+
# Jump to existing item in library
|
2053
|
+
hide_help
|
2054
|
+
|
2055
|
+
# Find the item in our library
|
2056
|
+
existing_item = (@movies + @series).find { |entry| entry[:id] == selected[:id] }
|
2057
|
+
if existing_item
|
2058
|
+
# Determine if it's a movie or series and switch to appropriate view
|
2059
|
+
details = @details_cache[selected[:id]] || {}
|
2060
|
+
is_series = details[:type] == 'TVSeries'
|
2061
|
+
|
2062
|
+
if is_series && @show_movies
|
2063
|
+
# Switch to series view
|
2064
|
+
@show_movies = false
|
2065
|
+
@show_series = true
|
2066
|
+
rebuild_index
|
2067
|
+
elsif !is_series && @show_series
|
2068
|
+
# Switch to movies view
|
2069
|
+
@show_movies = true
|
2070
|
+
@show_series = false
|
2071
|
+
rebuild_index
|
2072
|
+
end
|
2073
|
+
|
2074
|
+
# Find the item's position in the filtered list
|
2075
|
+
item_index = @index_list.find_index { |item| item[:id] == selected[:id] }
|
2076
|
+
if item_index
|
2077
|
+
@list.index = item_index
|
2078
|
+
update_list # This will scroll the list to show the selected item
|
2079
|
+
update_detail # Update detail pane to show the item
|
2080
|
+
@footer.say("✓ Jumped to #{selected[:title]} in library")
|
2081
|
+
else
|
2082
|
+
# Debug why it's filtered out
|
2083
|
+
raw_item = (@movies + @series).find { |entry| entry[:id] == selected[:id] }
|
2084
|
+
details = @details_cache[selected[:id]] || {}
|
2085
|
+
|
2086
|
+
# Check each filter condition
|
2087
|
+
rating_ok = raw_item[:rating] >= @rating_threshold
|
2088
|
+
dump_list = is_series ? @series_dump_list : @movie_dump_list
|
2089
|
+
not_dumped = !dump_list.include?(selected[:id])
|
2090
|
+
|
2091
|
+
# Show specific reason
|
2092
|
+
if !rating_ok
|
2093
|
+
@footer.say("⚠ #{selected[:title]} filtered out: rating #{raw_item[:rating]} < #{@rating_threshold}")
|
2094
|
+
elsif !not_dumped
|
2095
|
+
@footer.say("⚠ #{selected[:title]} filtered out: in dump list")
|
2096
|
+
else
|
2097
|
+
@footer.say("⚠ #{selected[:title]} filtered out: year/genre filters")
|
2098
|
+
end
|
2099
|
+
end
|
2100
|
+
sleep(3) # Longer sleep for debug messages
|
2101
|
+
update_footer
|
2102
|
+
end
|
2103
|
+
else
|
2104
|
+
@preview_item = selected
|
2105
|
+
@preview_details = nil
|
2106
|
+
show_help(:preview)
|
2107
|
+
# Fetch details in background
|
2108
|
+
Thread.new do
|
2109
|
+
@preview_details = fetch_details(selected[:id])
|
2110
|
+
@help.text = build_preview_help(@preview_item)
|
2111
|
+
@help.refresh if @help_mode == :preview
|
2112
|
+
end
|
2113
|
+
end
|
2114
|
+
end
|
2115
|
+
elsif @help_mode == :preview && @preview_item
|
2116
|
+
# Add to library from preview
|
2117
|
+
hide_help
|
2118
|
+
|
2119
|
+
# Double-check for duplicates before adding
|
2120
|
+
already_exists = (@movies + @series).any? { |entry| entry[:id] == @preview_item[:id] }
|
2121
|
+
if already_exists
|
2122
|
+
@footer.say("⚠ #{@preview_item[:title]} already in library")
|
2123
|
+
sleep(1.5)
|
2124
|
+
else
|
2125
|
+
@footer.say("Adding #{@preview_item[:title]}...")
|
2126
|
+
if @preview_item[:id].start_with?('tt')
|
2127
|
+
details = @preview_details || fetch_details(@preview_item[:id])
|
2128
|
+
|
2129
|
+
# Debug: log what we're adding
|
2130
|
+
File.open("/tmp/imdb_add_debug.log", "a") do |f|
|
2131
|
+
f.puts "[#{Time.now}] Adding #{@preview_item[:id]} - #{@preview_item[:title]} - Type: #{details[:type]}"
|
2132
|
+
f.puts " Movies before: #{@movies.size}, Series before: #{@series.size}"
|
2133
|
+
end
|
2134
|
+
|
2135
|
+
if details[:type] == 'TVSeries'
|
2136
|
+
@series << { id: @preview_item[:id], title: @preview_item[:title], rating: details[:rating] || 0.0 }
|
2137
|
+
else
|
2138
|
+
@movies << { id: @preview_item[:id], title: @preview_item[:title], rating: details[:rating] || 0.0 }
|
2139
|
+
end
|
2140
|
+
|
2141
|
+
# Debug: log after adding
|
2142
|
+
File.open("/tmp/imdb_add_debug.log", "a") do |f|
|
2143
|
+
f.puts " Movies after: #{@movies.size}, Series after: #{@series.size}"
|
2144
|
+
end
|
2145
|
+
|
2146
|
+
download_poster(@preview_item[:id], cache_dir)
|
2147
|
+
save_current_state
|
2148
|
+
build_genre_list
|
2149
|
+
rebuild_index
|
2150
|
+
@footer.say("✓ Added #{@preview_item[:title]} to library")
|
2151
|
+
sleep(1.5)
|
2152
|
+
end
|
2153
|
+
end
|
2154
|
+
else
|
2155
|
+
hide_help
|
2156
|
+
end
|
2157
|
+
return
|
2158
|
+
when 'ESC', 'q' # {{{4
|
2159
|
+
if @help_mode == :preview
|
2160
|
+
# Go back to search results
|
2161
|
+
show_help(:search)
|
2162
|
+
else
|
2163
|
+
hide_help
|
2164
|
+
end
|
2165
|
+
return
|
2166
|
+
when '/' # {{{4
|
2167
|
+
if @help_mode == :search
|
2168
|
+
hide_help
|
2169
|
+
# Trigger new search
|
2170
|
+
query = @footer.ask("Search IMDb: ", "").strip
|
2171
|
+
return if query.empty?
|
2172
|
+
@footer.say("Searching...")
|
2173
|
+
results = search_imdb(query, 10)
|
2174
|
+
|
2175
|
+
if results.empty?
|
2176
|
+
@footer.say("No results found")
|
2177
|
+
sleep(1)
|
2178
|
+
else
|
2179
|
+
@search_results = results
|
2180
|
+
@search_query = query
|
2181
|
+
show_help(:search)
|
2182
|
+
end
|
2183
|
+
end
|
2184
|
+
return
|
2185
|
+
else
|
2186
|
+
return
|
2187
|
+
end
|
2188
|
+
end
|
2189
|
+
|
2190
|
+
case getchr
|
2191
|
+
when 'c' # {{{3
|
2192
|
+
@cancel_scrape.make_true if defined?(@cancel_scrape)
|
2193
|
+
return
|
2194
|
+
when 'q','Q' then exit # {{{3
|
2195
|
+
when '?' # {{{3
|
2196
|
+
show_help(:general)
|
2197
|
+
return
|
2198
|
+
when 'TAB' # {{{3
|
2199
|
+
panes.each { |p| p.border = false; p.border_refresh }
|
2200
|
+
@focus = panes[(panes.index(@focus) + 1) % panes.size]
|
2201
|
+
@focus.border = true; @focus.border_refresh
|
2202
|
+
when 'UP' # {{{3
|
2203
|
+
max = case @focus
|
2204
|
+
when @list then items.size - 1
|
2205
|
+
when @genres then @genres_list.size - 1
|
2206
|
+
when @wish then (@show_movies ? @movie_wish_list : @series_wish_list).size - 1
|
2207
|
+
when @dump then (@show_movies ? @movie_dump_list : @series_dump_list).size - 1
|
2208
|
+
end
|
2209
|
+
@focus.index = max if (@focus.index -= 1) < 0
|
2210
|
+
when 'DOWN' # {{{3
|
2211
|
+
max = case @focus
|
2212
|
+
when @list then items.size - 1
|
2213
|
+
when @genres then @genres_list.size - 1
|
2214
|
+
when @wish then (@show_movies ? @movie_wish_list : @series_wish_list).size - 1
|
2215
|
+
when @dump then (@show_movies ? @movie_dump_list : @series_dump_list).size - 1
|
2216
|
+
end
|
2217
|
+
@focus.index = 0 if (@focus.index += 1) > max
|
2218
|
+
when 'PgUP' # {{{3
|
2219
|
+
@focus.index = [@focus.index - @focus.h, 0].max
|
2220
|
+
when 'PgDOWN' # {{{3
|
2221
|
+
max = case @focus
|
2222
|
+
when @list then items.size - 1
|
2223
|
+
when @genres then @genres_list.size - 1
|
2224
|
+
when @wish then (@show_movies ? @movie_wish_list : @series_wish_list).size - 1
|
2225
|
+
when @dump then (@show_movies ? @movie_dump_list : @series_dump_list).size - 1
|
2226
|
+
end
|
2227
|
+
@focus.index = [@focus.index + @focus.h, max].min
|
2228
|
+
when 'HOME' # {{{3
|
2229
|
+
@focus.index = 0
|
2230
|
+
when 'END' # {{{3
|
2231
|
+
max = case @focus
|
2232
|
+
when @list then items.size - 1
|
2233
|
+
when @genres then @genres_list.size - 1
|
2234
|
+
when @wish then (@show_movies ? @movie_wish_list : @series_wish_list).size - 1
|
2235
|
+
when @dump then (@show_movies ? @movie_dump_list : @series_dump_list).size - 1
|
2236
|
+
end
|
2237
|
+
@focus.index = max
|
2238
|
+
when '+' # {{{3
|
2239
|
+
if @focus == @list && !items.empty?
|
2240
|
+
id = items[@list.index][:id]
|
2241
|
+
details = @details_cache[id] || {}
|
2242
|
+
if details[:type] == 'TVSeries'
|
2243
|
+
@series_wish_list << id unless @series_wish_list.include?(id)
|
2244
|
+
else
|
2245
|
+
@movie_wish_list << id unless @movie_wish_list.include?(id)
|
2246
|
+
end
|
2247
|
+
elsif @focus == @genres && !@genres_list.empty?
|
2248
|
+
g = @genres_list[@genres.index]
|
2249
|
+
@genre_filters[g] = (@genre_filters[g] == 1 ? 0 : 1)
|
2250
|
+
rebuild_index
|
2251
|
+
end
|
2252
|
+
when '-' # {{{3
|
2253
|
+
if @focus == @list && !items.empty?
|
2254
|
+
id = items[@list.index][:id]
|
2255
|
+
details = @details_cache[id] || {}
|
2256
|
+
if details[:type] == 'TVSeries'
|
2257
|
+
@series_dump_list << id unless @series_dump_list.include?(id)
|
2258
|
+
else
|
2259
|
+
@movie_dump_list << id unless @movie_dump_list.include?(id)
|
2260
|
+
end
|
2261
|
+
rebuild_index
|
2262
|
+
elsif @focus == @genres && !@genres_list.empty?
|
2263
|
+
g = @genres_list[@genres.index]
|
2264
|
+
@genre_filters[g] = (@genre_filters[g] == -1 ? 0 : -1)
|
2265
|
+
rebuild_index
|
2266
|
+
end
|
2267
|
+
when 'r' # {{{3
|
2268
|
+
@rating_threshold = @footer.ask("Min rating? ", @rating_threshold.to_s).to_f
|
2269
|
+
rebuild_index
|
2270
|
+
update_footer
|
2271
|
+
when 'y' # {{{3
|
2272
|
+
@year_min = @footer.ask("Min year (or blank for none)? ", @year_min.to_s).strip
|
2273
|
+
@year_min = @year_min.empty? ? nil : @year_min.to_i
|
2274
|
+
rebuild_index
|
2275
|
+
update_footer
|
2276
|
+
when 'Y' # {{{3
|
2277
|
+
@year_max = @footer.ask("Max year (or blank for none)? ", @year_max.to_s).strip
|
2278
|
+
@year_max = @year_max.empty? ? nil : @year_max.to_i
|
2279
|
+
rebuild_index
|
2280
|
+
update_footer
|
2281
|
+
when 'l' # {{{3
|
2282
|
+
@show_movies = !@show_movies; @show_series = !@show_series
|
2283
|
+
rebuild_index; @list.index = 0
|
2284
|
+
when 'o' # {{{3
|
2285
|
+
@sort_by = (@sort_by == :rating ? :alpha : :rating)
|
2286
|
+
rebuild_index
|
2287
|
+
when 'R' # {{{3
|
2288
|
+
regions = get_regions
|
2289
|
+
current_name = regions[@tmdb_region] || @tmdb_region
|
2290
|
+
input = @footer.ask("Region [#{@tmdb_region}=#{current_name}] (? for help): ", "").strip.upcase
|
2291
|
+
|
2292
|
+
if input.empty?
|
2293
|
+
# Keep current region
|
2294
|
+
elsif input == '?'
|
2295
|
+
show_help(:regions)
|
2296
|
+
return
|
2297
|
+
elsif regions.key?(input)
|
2298
|
+
@tmdb_region = input
|
2299
|
+
@footer.say("Region set to #{input} (#{regions[input]})")
|
2300
|
+
sleep(1)
|
2301
|
+
update_footer
|
2302
|
+
else
|
2303
|
+
@footer.say("Invalid region code. Press '?' for help.")
|
2304
|
+
sleep(1)
|
2305
|
+
update_footer
|
2306
|
+
end
|
2307
|
+
when 'k' # {{{3
|
2308
|
+
new_key = @footer.ask("Enter TMDb API key (or leave blank to disable): ", @tmdb_key.to_s).strip
|
2309
|
+
@tmdb_key = new_key
|
2310
|
+
if @tmdb_key.empty?
|
2311
|
+
@footer.say("TMDb disabled")
|
2312
|
+
else
|
2313
|
+
@footer.say("TMDb key updated")
|
2314
|
+
end
|
2315
|
+
sleep(1)
|
2316
|
+
update_footer
|
2317
|
+
when 'f' # {{{3
|
2318
|
+
# Re-fetch details for current item
|
2319
|
+
items = get_current_list_items
|
2320
|
+
if !items.empty? && @list.index < items.size
|
2321
|
+
current = items[@list.index]
|
2322
|
+
@footer.say("Re-fetching #{current[:title]}...")
|
2323
|
+
|
2324
|
+
# Remove old details from cache to force fresh fetch
|
2325
|
+
@details_cache.delete(current[:id])
|
2326
|
+
|
2327
|
+
# Fetch fresh details
|
2328
|
+
new_details = fetch_details(current[:id])
|
2329
|
+
|
2330
|
+
# Update the rating in our main arrays
|
2331
|
+
movie_item = @movies.find { |m| m[:id] == current[:id] }
|
2332
|
+
series_item = @series.find { |s| s[:id] == current[:id] }
|
2333
|
+
|
2334
|
+
if movie_item
|
2335
|
+
movie_item[:rating] = new_details[:rating] || 0.0
|
2336
|
+
elsif series_item
|
2337
|
+
series_item[:rating] = new_details[:rating] || 0.0
|
2338
|
+
end
|
2339
|
+
|
2340
|
+
# Re-download poster
|
2341
|
+
download_poster(current[:id], cache_dir)
|
2342
|
+
|
2343
|
+
# Save and refresh
|
2344
|
+
save_current_state
|
2345
|
+
rebuild_index
|
2346
|
+
|
2347
|
+
@footer.say("✓ Re-fetched #{current[:title]} - Rating: #{new_details[:rating] || 'N/A'}")
|
2348
|
+
sleep(2)
|
2349
|
+
update_footer
|
2350
|
+
else
|
2351
|
+
@footer.say("No item selected")
|
2352
|
+
sleep(1)
|
2353
|
+
update_footer
|
2354
|
+
end
|
2355
|
+
when 'I' # {{{3
|
2356
|
+
return if @fetching
|
2357
|
+
answer = @footer.ask("Full fetch (scrape & download all)? (y/N) ", "N")
|
2358
|
+
if answer =~ /\A[yY]/
|
2359
|
+
cache = cache_dir
|
2360
|
+
FileUtils.rm_rf(cache); FileUtils.mkdir_p(cache)
|
2361
|
+
@progress.say("Starting full fetch...")
|
2362
|
+
load_top250
|
2363
|
+
start_background_fetch
|
2364
|
+
end
|
2365
|
+
when 'i' # {{{3
|
2366
|
+
return if @fetching
|
2367
|
+
start_incremental_update
|
2368
|
+
when 'L' # {{{3
|
2369
|
+
return if @fetching
|
2370
|
+
answer = @footer.ask("Load additional lists (popular + trending)? (y/N) ", "N")
|
2371
|
+
if answer =~ /\A[yY]/
|
2372
|
+
start_additional_lists_fetch
|
2373
|
+
end
|
2374
|
+
when 'D' # {{{3
|
2375
|
+
# Remove duplicates
|
2376
|
+
movie_before = @movies.size
|
2377
|
+
series_before = @series.size
|
2378
|
+
|
2379
|
+
@movies.uniq! { |m| m[:id] }
|
2380
|
+
@series.uniq! { |s| s[:id] }
|
2381
|
+
|
2382
|
+
movie_removed = movie_before - @movies.size
|
2383
|
+
series_removed = series_before - @series.size
|
2384
|
+
total_removed = movie_removed + series_removed
|
2385
|
+
|
2386
|
+
if total_removed > 0
|
2387
|
+
save_current_state
|
2388
|
+
build_genre_list
|
2389
|
+
rebuild_index
|
2390
|
+
@footer.say("✓ Removed #{total_removed} duplicates (#{movie_removed} movies, #{series_removed} series)")
|
2391
|
+
else
|
2392
|
+
@footer.say("No duplicates found")
|
2393
|
+
end
|
2394
|
+
sleep(2)
|
2395
|
+
update_footer
|
2396
|
+
when 'v' # {{{3
|
2397
|
+
return if @fetching
|
2398
|
+
start_verification_check
|
2399
|
+
when '/' # {{{3
|
2400
|
+
query = @footer.ask("Search IMDb: ", "").strip
|
2401
|
+
return if query.empty?
|
2402
|
+
@footer.say("Searching...")
|
2403
|
+
results = search_imdb(query, 10)
|
2404
|
+
|
2405
|
+
if results.empty?
|
2406
|
+
@footer.say("No results found")
|
2407
|
+
sleep(1)
|
2408
|
+
else
|
2409
|
+
# Show results in help pane with better context
|
2410
|
+
@search_results = results
|
2411
|
+
@search_query = query
|
2412
|
+
show_help(:search)
|
2413
|
+
return
|
2414
|
+
end
|
2415
|
+
when 'ENTER' # {{{3
|
2416
|
+
return if @fetching
|
2417
|
+
Rcurses.clear_screen
|
2418
|
+
[@header, @list, @genres, @wish, @dump, @detail, @footer].each(&:full_refresh)
|
2419
|
+
@last_poster_id = nil
|
2420
|
+
else
|
2421
|
+
return
|
2422
|
+
end
|
2423
|
+
draw_all
|
2424
|
+
end
|
2425
|
+
|
2426
|
+
end # class IMDBApp
|
2427
|
+
|
2428
|
+
# MAIN {{{1
|
2429
|
+
IMDBApp.new
|
2430
|
+
|
2431
|
+
# VIM MODELINE{{{1
|
2432
|
+
# vim: set sw=2 sts=2 et fdm=marker fdn=2 fcs=fold\:\ :
|