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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -52
  3. data/bin/imdb +2414 -782
  4. metadata +62 -26
data/bin/imdb CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env ruby
2
- # encoding: utf-8
2
+ # frozen_string_literal: true
3
3
 
4
- # PROGRAM INFO
5
- # Name: IMDB-term
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/imdb-term
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
- @version = "0.7.4"
18
-
19
- # PRELIMINARIES
20
- @help = <<HELPTEXT
21
- IMDB-term (https://github.com/isene/IMDB) Help text:
22
-
23
- Keys | Function
24
- -------------+--------------------
25
- TAB or S-TAB | Cycle panes (active is gray) or backwards
26
- Arrow keys | UP, DOWN, PgUP, PgDOWN, HOME, END in lists
27
- + or - | Depends on pane (intuitive)
28
- I | Load fresh IMDB data (be patient)
29
- m or s | Show MOVIES or SERIES
30
- r | Select MINimum IMDB rating (in bottom line)
31
- y or Y | Select MINimum or MAXimum production year
32
- / or \\ | Enter or clear search for movie/series title
33
- G | Set genres to match every movie/series
34
- d | Get details on selected movie/series
35
- D | Show where you can stream the movie/series
36
- R | Refresh all panes
37
- v | Show version (and latest RybyGems version)
38
- w or W | Write changes to config or save IMDB data
39
- q or Q | Quit w/saving config or w/o saving config
40
- HELPTEXT
41
- begin # BASIC SETUP
42
- require 'net/http'
43
- require 'open-uri'
44
- require 'rest_client'
45
- require 'json'
46
- require 'readline'
47
- require 'io/console'
48
- require 'curses'
49
- include Curses
50
-
51
- def cmd?(command)
52
- system("which #{command} > /dev/null 2>&1")
53
- end
54
- if cmd?('/usr/lib/w3m/w3mimgdisplay')
55
- @w3mimgdisplay = "/usr/lib/w3m/w3mimgdisplay"
56
- @showimage = true
57
- else
58
- @showimage = false
59
- end
60
- @showimage = false unless (cmd?('xwininfo') and cmd?('xdotool'))
61
-
62
- begin # Check if network is available
63
- URI.open("https://www.google.com/", :open_timeout=>5)
64
- rescue
65
- puts "\nNo network. Running offline.\n\n"
66
- end
67
-
68
- # INITIALIZE BASIC VARIABLES
69
- ## These can be set in .imdb.conf
70
- @rating = 0
71
- @yearMin = 0
72
- @yearMax = 2100
73
- @myMY = []
74
- @myMN = []
75
- @mySY = []
76
- @mySN = []
77
- ## These are IMDB specific
78
- @urlmovies = "https://www.imdb.com/search/title/?groups=top_1000&start=[1, 1001, 50]"
79
- @urlseries = "https://www.imdb.com/search/title/?title_type=tv_series&start=[1, 1001, 50]"
80
- @country = "no"
81
- @lang = "en"
82
- @genres = ["Action", "Adult", "Adventure", "Animation", "Biography", "Comedy", "Crime", "Documentary", "Drama",
83
- "Family", "Fantasy", "Film Noir", "Game Show", "History", "Horror", "Musical", "Music", "Mystery",
84
- "News", "Reality-TV", "Romance", "Sci-Fi", "Short", "Sport", "Talk-Show", "Thriller", "War", "Western"]
85
- @genY = @genres.map(&:clone)
86
- @genN = []
87
- ## These are internal variables
88
- @imdbmovies = []
89
- @imdbseries = []
90
- @imdbsel = []
91
- @search = ''
92
- @new = false
93
- @movies = true
94
- @noimage = false
95
-
96
- ## Color constants (e.g. Window x fg)
97
- WtMfg = 232
98
- WtSfg = 255
99
- WtMbg = 214
100
- WtMbgS = 202
101
- WtSbg = 23
102
- WtSbgS = 58
103
- Wifg = 230
104
- Wgfg = 195
105
- WgYfg = 34
106
- WgNfg = 241
107
- Wmfg = 117
108
- Wnfg = 204
109
- Wdfg = 255
110
- Wpfg = 242
111
- Wbfg = 252
112
- Wbbg = 238
113
- Mark = 240
114
- WAbg = 233
115
- WIbg = 232
116
- end
117
-
118
- # CLASSES
119
- class Curses::Window # CLASS EXTENSION
120
- # General extensions (see https://github.com/isene/Ruby-Curses-Class-Extension)
121
- # This is a class extension to Ruby Curses - a class in dire need of such.
122
- # self.pair keeps a registry of colors as they are encountered - defined with:
123
- # init_pair(index, foreground, background)
124
- # self.fg is set for the foreground color
125
- # self.bg is set for the background color
126
- # self.attr is set for text attributes like Curses::A_BOLD
127
- # self.update can be used to indicate if a window should be updated (true/false)
128
- # self.index can be used to keep track of the current list item in a window
129
- attr_accessor :fg, :bg, :attr, :text, :update, :index, :list
130
- def self.pair(fg, bg)
131
- @p = [[]] if @p == nil
132
- fg = fg.to_i; bg = bg.to_i
133
- if @p.include?([fg,bg])
134
- @p.index([fg,bg])
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
- @p.push([fg,bg])
137
- cp = @p.index([fg,bg])
138
- init_pair(cp, fg, bg)
139
- @p.index([fg,bg])
140
- end
141
- end
142
- def clr # Clears the whole window
143
- self.setpos(0, 0)
144
- self.maxy.times {self.deleteln()}
145
- self.refresh
146
- self.setpos(0, 0)
147
- end
148
- def fill # Fill window with color as set by self.color (or self.bg if not set)
149
- self.setpos(0, 0)
150
- self.fill_from_cur_pos
151
- end
152
- def fill_to_cur_pos # Fills the window up to current line
153
- x = self.curx
154
- y = self.cury
155
- self.setpos(0, 0)
156
- self.bg = 0 if self.bg == nil
157
- self.fg = 255 if self.fg == nil
158
- blank = " " * self.maxx
159
- cp = Curses::Window.pair(self.fg, self.bg)
160
- y.times {self.attron(color_pair(cp)) {self << blank}}
161
- self.refresh
162
- self.setpos(y, x)
163
- end
164
- def fill_from_cur_pos # Fills the rest of the window from current line
165
- x = self.curx
166
- y = self.cury
167
- self.setpos(y, 0)
168
- self.bg = 0 if self.bg == nil
169
- self.fg = 255 if self.fg == nil
170
- blank = " " * self.maxx
171
- cp = Curses::Window.pair(self.fg, self.bg)
172
- self.maxy.times {self.attron(color_pair(cp)) {self << blank}}
173
- self.refresh
174
- self.setpos(y, x)
175
- end
176
- def p(fg = self.fg, bg = self.bg, attr = self.attr, text) # Puts text to window with full set of attributes
177
- fg = 255 if fg == nil
178
- bg = 0 if bg == nil
179
- attr = 0 if attr == nil
180
- cp = Curses::Window.pair(fg, bg)
181
- self.attron(color_pair(cp) | attr) { self << text }
182
- self.refresh
183
- end
184
- def nl(bg = self.bg)
185
- bg = 232 if bg == nil
186
- f = " " * (self.maxx - self.curx)
187
- self.p(self.fg, bg, self.attr, f)
188
- end
189
- def format(text) # Format text so that it linebreaks neatly inside window
190
- return "\n" + text.gsub(/(.{1,#{self.maxx-1}})( +|$\n?)|(.{1,#{self.maxx-1}})/, "\\1\\3\n")
191
- end
192
- alias :puts :p
193
- end
194
-
195
- # GENERIC FUNCTIONS
196
- def firstrun
197
- puts "Welcome to IMDB-term, the IMDB application for the terminal."
198
- puts "\nFind your next movie or series to binge. Narrow down your preferences from a 1000 movies and almost 500 series."
199
- puts "Select a minimum IMDB rating, range of production years, genres you like and dislike to get your preferred list."
200
- puts "Get detailed information on movies and series and where you can stream them. Even the movie poster in the terminal."
201
- puts "\nLet's first look at the help text (accessible in the program via the key '?'):\n\n"
202
- puts @help
203
- print "\nPress any key... "; STDIN.getch
204
- system("clear")
205
- puts "We will now walk you through the steps you need to do to make use of this application:"
206
- puts "\n 1. Go to the website https://www.page2api.com/"
207
- print " Create a free account and paste your API_KEY here (then press ENTER): "
208
- conf = "@imdbkey = '"
209
- conf += gets.chomp + "'\n"
210
- puts "\n 2. Go to the website https://www.omdbapi.com/apikey.aspx"
211
- print " Create a free account and paste your API KEY here (then press ENTER): "
212
- conf += "@omdbkey = '"
213
- conf += gets.chomp + "'\n"
214
- puts "\n 3. Go to the website https://rapidapi.com/movie-of-the-night-movie-of-the-night-default/api/streaming-availability"
215
- print " Create a free account and paste your X-RapidAPI-Key here (then press ENTER): "
216
- conf += "@streamkey = '"
217
- conf += gets.chomp + "'\n"
218
- File.write(Dir.home+'/.imdb.conf', conf)
219
- puts "\n\nYour keys have now been written to the configuration file (.imdb.conf). You can edit this file manually if needed."
220
- print "\nPress 'y' to start imdb-term "; y = STDIN.getch
221
- exit if y != "y"
222
- end
223
- def getchr # PROCESS KEY PRESSES
224
- c = STDIN.getch #(min: 0, time: 1)
225
- case c
226
- when "\e" # ANSI escape sequences
227
- case $stdin.getc
228
- when '[' # CSI
229
- case $stdin.getc
230
- when 'A' then chr = "UP"
231
- when 'B' then chr = "DOWN"
232
- when 'C' then chr = "RIGHT"
233
- when 'D' then chr = "LEFT"
234
- when 'Z' then chr = "S-TAB"
235
- when '2' then chr = "INS" ; STDIN.getc
236
- when '3' then chr = "DEL" ; STDIN.getc
237
- when '5' then chr = "PgUP" ; STDIN.getc
238
- when '6' then chr = "PgDOWN" ; STDIN.getc
239
- when '7' then chr = "HOME" ; STDIN.getc
240
- when '8' then chr = "END" ; STDIN.getc
241
- end
242
- end
243
- when "", "" then chr = "BACK"
244
- when "" then chr = "C-C"
245
- when "" then chr = "C-G"
246
- when "" then chr = "C-T"
247
- when "" then chr = "LDEL"
248
- when "" then chr = "WBACK"
249
- when "\r" then chr = "ENTER"
250
- when "\t" then chr = "TAB"
251
- when /./ then chr = c
252
- end
253
- return chr
254
- end
255
- def getkey # GET KEY FROM USER
256
- chr = getchr
257
- case chr
258
- when '?' # Show helptext in right window
259
- @w_d.fill
260
- @w_d.p(@help)
261
- @w_d.update = false
262
- when 'UP'
263
- @active.index = @active.index <= 0 ? @active.list.size - 1 : @active.index - 1
264
- when 'DOWN'
265
- @active.index = @active.index >= @active.list.size - 1 ? 0 : @active.index + 1
266
- when 'PgUP'
267
- @active.index -= @active.maxy - 2
268
- @active.index = 0 if @active.index < 0
269
- when 'PgDOWN'
270
- @active.index += @active.maxy - 2
271
- @active.index = @active.list.size - 1 if @active.index > @active.list.size - 1
272
- when 'HOME'
273
- @active.index = 0
274
- when 'END'
275
- @active.index = @active.list.size - 1
276
- when 'TAB'
277
- case @active
278
- when @w_i
279
- @active = @w_g
280
- when @w_g
281
- @active = @w_m
282
- when @w_m
283
- @active = @w_n
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
- @w_d.fill
367
- @w_d.p(eval(cmd))
368
- rescue StandardError => e
369
- w_b("Error: #{e.inspect}")
370
- end
371
- @w_b.update = false
372
- when 'v'
373
- w_b("Version = #{@version} (latest RubyGems version is #{Gem.latest_version_for("imdb-terminal").version} - https://github.com/isene/IMDB)")
374
- when 'w'
375
- saveconf
376
- when 'W'
377
- saveimdb
378
- when 'q' # Exit
379
- saveconf
380
- exit 0
381
- when 'Q' # EXIT
382
- exit 0
383
- end
384
- end
385
- def getimdb(url)
386
- api_url = "https://www.page2api.com/api/v1/scrape"
387
- payload = {
388
- api_key: @imdbkey,
389
- batch: {
390
- urls: url,
391
- concurrency: 1,
392
- merge_results: true
393
- },
394
- parse: {
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
- datacenter_proxy: "us"
411
- }
412
- response = RestClient::Request.execute(
413
- method: :post,
414
- payload: payload.to_json,
415
- url: api_url,
416
- headers: { "Content-type" => "application/json" },
417
- ).body
418
- res = JSON.parse(response)
419
- imdb = []
420
- res["result"]["movies"].each {|m| imdb.push [m["title"].sub(/^\d+\. /, ''), m["rating"].to_f, m["year"].delete("^0-9").to_i, m["genre"], m["id"]]}
421
- imdb
422
- end
423
- def loadimdb
424
- @imdbmovies = getimdb(@urlmovies)
425
- @imdbseries = getimdb(@urlseries)
426
- @new = true
427
- end
428
- def imdbmovies
429
- if @search == ''
430
- @imdbsel = @imdbmovies.map{|m| m if (m[1] >= @rating) and ((@yearMin..@yearMax) === m[2])}
431
- else
432
- @imdbsel = @imdbmovies.map{|m| m if (m[0] =~ /#{@search}/)}
433
- end
434
- @imdbsel.select! do |i|
435
- ig = i[3].split(", ") unless i == nil
436
- (ig & @genY).any? unless ig == nil
437
- end
438
- @imdbsel.select! {|i| (i[3].split(", ") & @genN).empty?}
439
- @imdbsel = @imdbsel - @myMN - @myMY
440
- @myMYsel = @myMY - @myMN
441
- [@imdbsel, @myMYsel, @myMY, @myMN, @genY, @genN].each do |arr|
442
- arr.uniq!
443
- arr.compact!
444
- arr.sort_by! {|m| m[0]}
445
- end
446
- @w_i.list = @imdbsel
447
- @w_i.index = 0 if @w_i.index > @w_i.list.size - 1
448
- @w_m.list = @myMYsel
449
- @w_n.list = @myMN
450
- @myN = @myMN
451
- @myY = @myMY
452
- end
453
- def imdbseries
454
- if @search == ''
455
- @imdbsel = @imdbseries.map{|m| m if (m[1] > @rating) and ((@yearMin..@yearMax) === m[2])}
456
- else
457
- @imdbsel = @imdbseries.map{|m| m if (m[0] =~ /#{@search}/)}
458
- end
459
- @imdbsel.select! do |i|
460
- ig = i[3].split(", ") unless i == nil
461
- (ig & @genY).any? unless ig == nil
462
- end
463
- @imdbsel.select! {|i| (i[3].split(", ") & @genN).empty?}
464
- @imdbsel = @imdbsel - @mySN - @mySY
465
- @mySYsel = @mySY - @mySN
466
- [@imdbsel, @mySYsel, @mySY, @mySN, @genY, @genN].each do |arr|
467
- arr.uniq!
468
- arr.compact!
469
- arr.sort_by! {|m| m[0]}
470
- end
471
- @w_i.list = @imdbsel
472
- @w_i.index = 0 if @w_i.index > @w_i.list.size - 2
473
- @w_m.list = @mySYsel
474
- @w_n.list = @mySN
475
- @myN = @mySN
476
- @myY = @mySY
477
- end
478
- def getomdb(id)
479
- @urldetails = "http://www.omdbapi.com/?apikey=#{@omdbkey}&i=#{id}"
480
- details = Net::HTTP.get(URI(@urldetails))
481
- det = JSON.parse(details)
482
- return det
483
- end
484
- def getstreaming(id) # Returns array of outlets
485
- url = URI("https://streaming-availability.p.rapidapi.com/v2/get/basic?country=#{@country}&imdb_id=#{id}&output_language=#{@lang}")
486
- http = Net::HTTP.new(url.host, url.port)
487
- http.use_ssl = true
488
- request = Net::HTTP::Get.new(url)
489
- request["X-RapidAPI-Key"] = @streamkey
490
- request["X-RapidAPI-Host"] = 'streaming-availability.p.rapidapi.com'
491
- response = http.request(request)
492
- res = JSON.parse(response.read_body)
493
- outlets = []
494
- begin
495
- res["result"]["streamingInfo"][@country].each{|k,v| outlets.push(k)}
496
- return outlets
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
- return [outlets.to_s]
499
- end
500
- end
501
- def saveconf
502
- if File.exist?(Dir.home+'/.imdb.conf')
503
- conf = File.read(Dir.home+'/.imdb.conf')
504
- else
505
- conf = ""
506
- end
507
- conf.gsub!(/^@rating.*\n/, "")
508
- conf += "@rating = #{@rating}\n"
509
- conf.gsub!(/^@yearMin.*\n/, "")
510
- conf += "@yearMin = #{@yearMin}\n"
511
- conf.gsub!(/^@yearMax.*\n/, "")
512
- conf += "@yearMax = #{@yearMax}\n"
513
- conf.gsub!(/^@genY.*\n/, "")
514
- conf += "@genY = #{@genY}\n"
515
- conf.gsub!(/^@genN.*\n/, "")
516
- conf += "@genN = #{@genN}\n"
517
- conf.gsub!(/^@myMY.*\n/, "")
518
- conf += "@myMY = #{@myMY}\n"
519
- conf.gsub!(/^@myMN.*\n/, "")
520
- conf += "@myMN = #{@myMN}\n"
521
- conf.gsub!(/^@mySY.*\n/, "")
522
- conf += "@mySY = #{@mySY}\n"
523
- conf.gsub!(/^@mySN.*\n/, "")
524
- conf += "@mySN = #{@mySN}\n"
525
- w_b("Configuration written to .imdb.conf")
526
- File.write(Dir.home+'/.imdb.conf', conf)
527
- end
528
- def saveimdb
529
- if File.exist?(Dir.home+'/.imdb')
530
- data = File.read(Dir.home+'/.imdb')
531
- else
532
- data = ""
533
- end
534
- data.gsub!(/^@imdbmovies.*\n/, "")
535
- data += "@imdbmovies = #{@imdbmovies}\n"
536
- data.gsub!(/^@imdbseries.*\n/, "")
537
- data += "@imdbseries = #{@imdbseries}\n"
538
- w_b("IMDB data written to .imdb")
539
- File.write(Dir.home+'/.imdb', data)
540
- end
541
-
542
- # BASIC WINDOW FUNCTIONS
543
- def w_t # SHOW INFO IN @w_t
544
- @w_t.clr
545
- @movies ? text = " MOVIES :: " : text = " SERIES :: "
546
- text += "Rating MIN: #{@rating} - Year MIN: #{@yearMin} - Year MAX: #{@yearMax} :: Selection = #{@imdbsel.size}"
547
- @w_t.p(text)
548
- @w_t.nl
549
- end
550
- def w_list(win) # LIST IN WINDOW
551
- win.attr = 0
552
- win == @active ? win.bg = WAbg : win.bg = WIbg
553
- win.fill
554
- ix = 0; t = 0
555
- ix = win.index - win.maxy/2 if win.index > win.maxy/2 and win.list.size > win.maxy - 1
556
- while ix < win.list.size and t < win.maxy do
557
- str = win.list[ix][0]
558
- str = win.list[ix] if win == @w_g
559
- if ix == win.index and win == @active
560
- win.p("→ ")
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
- win.p(" ")
563
- end
564
- if win == @w_g
565
- if @genY.include?(str)
566
- win.fg = WgYfg
567
- win.attr = Curses::A_BOLD
568
- win.p("+")
569
- elsif @genN.include?(str)
570
- win.fg = WgNfg
571
- win.p("-")
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
- win.fg = Wgfg
574
- win.p(" ")
575
- end
576
- end
577
- win.attr = win.attr | Curses::A_UNDERLINE if ix == win.index
578
- str = str[0..(win.maxx - 6)] + "…" if str.length > win.maxx - 4
579
- win.p(str)
580
- win.attr = 0
581
- win.nl
582
- ix += 1; t += 1
583
- end
584
- if win.index > win.maxy/2
585
- win.setpos(0, win.maxx - 1)
586
- win.p(Mark,"∆")
587
- end
588
- if win.list.length > win.maxy - 1 and win.list.length > win.index + win.maxy/2 - 1
589
- win.setpos(win.maxy - 1, win.maxx - 1)
590
- win.p(Mark,"∇")
591
- end
592
- end
593
- def w_d(ext = 0) # SHOW INFO IN @w_d and @w_p
594
- return if @active == @w_g
595
- list = @active.list
596
- return if list.empty? # Skip if list is empty
597
- id = list[@active.index][4]
598
- @w_d.clr
599
- @w_d.attr = Curses::A_BOLD
600
- @w_d.p("#{list[@active.index][0]}\n\n")
601
- @w_d.attr = 0
602
- @w_d.p(153,232,Curses::A_BOLD,"Rating: " + list[@active.index][1].to_s.ljust(14) + "Genres: #{list[@active.index][3]}\n")
603
- return unless ext > 0 # Skip if no details are to be displayed
604
- det = getomdb(id)
605
- text = "\nRated: " + det["Rated"].ljust(14) + "Runtime: #{det["Runtime"]}"
606
- text += " (#{det["totalSeasons"]})" unless det["totalSeasons"] == nil
607
- text += "\n"
608
- text += "Released: " + det["Released"].ljust(14)
609
- @w_d.p(230,text)
610
- @w_d.p(244,"(#{id})\n")
611
- width = Curses.cols - 104
612
- @w_d.p(228,@w_d.format(det["Plot"]))
613
- text = "\n"
614
- text += "Awards: " + det["Awards"] + "\n"
615
- text += "Director: " + det["Director"] + "\n"
616
- text += "Actors: " + det["Actors"] + "\n\n"
617
- @w_d.p(223,text)
618
- text = "Metascore: " + det["Metascore"] + "\n\n"
619
- @w_d.p(230,text)
620
- # Display the poster
621
- poster = det["Poster"]
622
- `curl -s "#{poster}" > /tmp/imdb.jpg`
623
- imageshow("/tmp/imdb.jpg")
624
- return unless ext > 1 # Skip if no outlets are to be displayed
625
- outlets = ""
626
- otl = getstreaming(id)
627
- otl.each{|o| outlets += "#{o} "}
628
- text = "Outlets: " + outlets
629
- @w_d.p(112,text)
630
- end
631
- def imageshow(image)
632
- begin
633
- terminfo = `xwininfo -id $(xdotool getactivewindow)`
634
- term_w = terminfo.match(/Width: (\d+)/)[1].to_i
635
- term_h = terminfo.match(/Height: (\d+)/)[1].to_i
636
- char_w = term_w / Curses.cols
637
- char_h = term_h / Curses.lines
638
- img_x = char_w * 105
639
- img_y = char_h * (Curses.lines / 2 + 2)
640
- img_max_w = char_w * (Curses.cols - (Curses.cols - 104) - 2)
641
- img_max_h = char_h * (@w_d.maxy - 2)
642
- # Clear previous images
643
- `echo "6;#{img_x};#{img_y};#{img_max_w+2};#{img_max_h+2};\n4;\n3;" | #{@w3mimgdisplay} 2>/dev/null`
644
- return if image == ""
645
- img_w,img_h = `identify -format "%[fx:w]x%[fx:h]" #{image} 2>/dev/null`.split('x')
646
- img_w = img_w.to_i
647
- img_h = img_h.to_i
648
- if img_w > img_max_w
649
- img_h = img_h * img_max_w / img_w
650
- img_w = img_max_w
651
- end
652
- if img_h > img_max_h
653
- img_w = img_w * img_max_h / img_h
654
- img_h = img_max_h
655
- end
656
- `echo "0;1;#{img_x};#{img_y};#{img_w};#{img_h};;;;;\"#{image}\"\n4;\n3;" | #{@w3mimgdisplay} 2>/dev/null`
657
- rescue
658
- w_b("Error showing image")
659
- end
660
- end
661
-
662
- # BOTTOM WINDOW FUNCTIONS
663
- def w_b(info) # SHOW INFO IN @W_B
664
- @w_b.clr
665
- info = "Use TAB to cycle through windows. Press ? for help. " if info == nil
666
- info = info[1..(@w_b.maxx - 3)] + "…" if info.length + 3 > @w_b.maxx
667
- info += " " * (@w_b.maxx - info.length) if info.length < @w_b.maxx
668
- @w_b.p(info)
669
- @w_b.update = false
670
- end
671
- def w_b_getstr(pretext, text) # A SIMPLE READLINE-LIKE ROUTINE
672
- Curses.curs_set(1)
673
- Curses.echo
674
- stk = 0
675
- pos = text.length
676
- chr = ""
677
- while chr != "ENTER"
678
- @w_b.setpos(0,0)
679
- @w_b.p(pretext + text)
680
- @w_b.nl
681
- @w_b.setpos(0,pretext.length + pos)
682
- @w_b.refresh
683
- chr = getchr
684
- case chr
685
- when 'C-C', 'C-G'
686
- return ""
687
- when 'RIGHT'
688
- pos += 1 unless pos > text.length
689
- when 'LEFT'
690
- pos -= 1 unless pos == 0
691
- when 'HOME'
692
- pos = 0
693
- when 'END'
694
- pos = text.length
695
- when 'DEL'
696
- text[pos] = ""
697
- when 'BACK'
698
- unless pos == 0
699
- pos -= 1
700
- text[pos] = ""
701
- end
702
- when 'LDEL'
703
- text = ""
704
- pos = 0
705
- when /^.$/
706
- text.insert(pos,chr)
707
- pos += 1
708
- end
709
- end
710
- Curses.curs_set(0)
711
- Curses.noecho
712
- return text
713
- end
714
-
715
- # BEFORE WE START
716
- begin
717
- if File.exist?(Dir.home+'/.imdb.conf')
718
- load(Dir.home+'/.imdb.conf')
719
- else
720
- firstrun
721
- end
722
- if File.exist?(Dir.home+'/.imdb')
723
- load(Dir.home+'/.imdb')
724
- else
725
- puts "Loading IMDB data... (this may take som time, go get some coffee)"
726
- loadimdb
727
- saveimdb
728
- end
729
- ## Curses setup
730
- Curses.init_screen
731
- Curses.start_color
732
- Curses.curs_set(0)
733
- Curses.noecho
734
- Curses.cbreak
735
- Curses.stdscr.keypad = true
736
- end
737
-
738
- # MAIN PROGRAM
739
- loop do # OUTER LOOP - (catching refreshes via 'r')
740
- @break = false # Initialize @break variable (set if user hits 'r')
741
- begin # Create the windows/panels
742
- maxx = Curses.cols
743
- maxy = Curses.lines
744
- init_pair(255, 0, 234)
745
- maxy.times {Curses.stdscr.attron(color_pair(255)) {Curses.stdscr << " " * maxx}}
746
- Curses.stdscr.refresh
747
- # Curses::Window.new( h, w, y, x )
748
- @w_t = Curses::Window.new( 1, maxx, 0, 0 )
749
- @w_i = Curses::Window.new( maxy-2, 40, 1, 0 )
750
- @w_g = Curses::Window.new( maxy-2, 20, 1, 41 )
751
- @w_m = Curses::Window.new( maxy/2-1, 40, 1, 62 )
752
- @w_n = Curses::Window.new( maxy/2-1, 40, maxy/2+1, 62 )
753
- @w_d = Curses::Window.new( maxy/2-1, maxx-103, 1, 103 )
754
- @w_p = Curses::Window.new( maxy/2-1, maxx-103, maxy/2+1, 103 )
755
- @w_b = Curses::Window.new( 1, maxx, maxy-1, 0 )
756
- @w_i.fg, @w_i.bg = Wifg, WIbg
757
- @w_g.fg, @w_g.bg = Wgfg, WIbg
758
- @w_m.fg, @w_m.bg = Wmfg, WIbg
759
- @w_n.fg, @w_n.bg = Wnfg, WIbg
760
- @w_d.fg, @w_d.bg = Wdfg, WIbg
761
- @w_p.fg, @w_p.bg = Wpfg, WIbg
762
- @w_b.fg, @w_b.bg = Wbfg, Wbbg
763
- [@w_i, @w_g, @w_m, @w_n].each{|w| w.index = 0}
764
- @w_g.list = @genres
765
- @w_b.update = true
766
- @w_d.update = true
767
- @active = @w_i
768
- loop do # INNER, CORE LOOP
769
- if @movies
770
- imdbmovies
771
- @w_t.fg = WtMfg
772
- @search == '' ? @w_t.bg = WtMbg : @w_t.bg = WtMbgS
773
- @w_t.attr = Curses::A_BOLD
774
- else
775
- imdbseries
776
- @w_t.fg = WtSfg
777
- @search == '' ? @w_t.bg = WtSbg : @w_t.bg = WtSbgS
778
- @w_t.attr = Curses::A_BOLD
779
- end
780
- @w_i.fill; @w_g.fill; @w_m.fill; @w_n.fill; @w_p.fill
781
- w_t; w_list(@w_i); w_list(@w_g); w_list(@w_m); w_list(@w_n)
782
- if @w_d.update
783
- @w_d.fill
784
- w_d
785
- end
786
- imageshow("") if @w_d.update
787
- @w_d.update = true
788
- w_b(nil) if @w_b.update
789
- @w_b.update = true
790
- getkey # Get key from user
791
- break if @break # Break to outer loop, redrawing windows, if user hit 'r'
792
- break if Curses.cols != maxx or Curses.lines != maxy # break on terminal resize
793
- end
794
- ensure # On exit: clear image, close curses
795
- imageshow("")
796
- close_screen
797
- end
798
- end
799
-
800
- # vim: set sw=2 sts=2 et fdm=syntax fdn=2 fcs=fold\:\ :
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\:\ :