gamerom 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 33c71545b14feb811985d98d9e1e4677b7101f7e86676865c50770eb53b99169
4
- data.tar.gz: 3388785345996bdb7711c90150ad03cc8bc3cf08afaad281144690ce8da85144
3
+ metadata.gz: 597dfe815ad760060e224b1320e8c2fe3684ced9f41e722e14470a5861a5a4a1
4
+ data.tar.gz: 417408b1b42bdc2c01f55a1878e46d63052e3c74d4a829775d8378aaeb024a33
5
5
  SHA512:
6
- metadata.gz: '0694ea12b96bac2fb769f0176ca0b459804eb1b955c51c44fb57dc60046d63136836a0b206c696fb6cbee9096c211d475cf038a9dc14c4a2ff43b232af3cd87d'
7
- data.tar.gz: ff44e316119690d3732f629ff162af18feef6ccb512881063625b899782adf4d95100c9a49829e619f09c82ce4fb944bd793490d0358c0cc3bbbb36158d06603
6
+ metadata.gz: 1de1c51f9f57a6baeaca2ae3943ab4564e622d5857ca433a322bbc2022de5e9465c26711f4c92a7efeafa6996c82c89618fb165da51c0aed7b7fccd04f52e594
7
+ data.tar.gz: eaafa9d35704dbc2ebf352cc31d5ae7a20e964f3c07ecdc3a15c3ca914728ecf836948354758e2b000fef9eded8cba00b339f2308aaef2f3d2a1290512e77e22
data/.gitignore CHANGED
@@ -9,3 +9,6 @@
9
9
 
10
10
  # rspec failure tracking
11
11
  .rspec_status
12
+
13
+ # built gems
14
+ *.gem
data/CHANGELOG.md CHANGED
@@ -6,6 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.0] - 2026-11-01
10
+ - feat: recover - match files by normalized name and tags, so re-releases such as "Mega Man 2 (Capcom Town)" and editions such as "Jeopardy! Deluxe Edition" no longer end up in the plain game's state
11
+ - feat: edgeemu and vimm repos - multi-region releases that include USA stay under USA, otherwise the alphabetically first region
12
+ - feat: vimm repo - read the game region from the flags shown on the list pages instead of assuming USA
13
+ - fix: vimm repo - list pages gained a media icon before the title link, which made update_database write empty databases
14
+ - fix: update_database refuses to replace a populated database with an empty crawl
15
+ - fix: honor Retry-After on 429 responses and keep updating the other databases when one platform fails
16
+
9
17
  ## [0.6.0] - 2026-11-01
10
18
  - feat: add edgeemu repo (https://edgeemu.net), 48 platforms
11
19
  - feat: recover - do not claim files that belong to a game with a longer name (e.g. "Kung-Fu" vs "Kung-Fu Master")
@@ -53,7 +61,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
53
61
  ## [0.1.0] - 2021-05-07
54
62
  - Initial release
55
63
 
56
- [Unreleased]: https://github.com/lucasmundim/gamerom/compare/v0.6.0...HEAD
64
+ [Unreleased]: https://github.com/lucasmundim/gamerom/compare/v0.7.0...HEAD
65
+ [0.7.0]: https://github.com/lucasmundim/gamerom/compare/v0.6.0...v0.7.0
57
66
  [0.6.0]: https://github.com/lucasmundim/gamerom/compare/v0.5.0...v0.6.0
58
67
  [0.5.0]: https://github.com/lucasmundim/gamerom/compare/v0.4.1...v0.5.0
59
68
  [0.4.1]: https://github.com/lucasmundim/gamerom/compare/v0.4.0...v0.4.1
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gamerom (0.6.0)
4
+ gamerom (0.7.0)
5
5
  mechanize (~> 2.14.2)
6
6
  mechanize-progressbar (~> 0.2.0)
7
7
  nokogiri (~> 1.19.4)
data/README.md CHANGED
@@ -15,7 +15,7 @@ It currently supports the following repositories:
15
15
  Add this to your .bashrc, .bash_aliases, etc:
16
16
 
17
17
  ```
18
- alias gamerom="docker run -e UID=$(id -u) -e GROUP_ID=$(id -g) --rm -it -v ~/.gamerom:/home/runuser/.gamerom --platform=linux/amd64 docker.io/lucasmundim/gamerom:0.6.0"
18
+ alias gamerom="docker run -e UID=$(id -u) -e GROUP_ID=$(id -g) --rm -it -v ~/.gamerom:/home/runuser/.gamerom --platform=linux/amd64 docker.io/lucasmundim/gamerom:0.7.0"
19
19
  ```
20
20
 
21
21
  Use it as:
@@ -618,7 +618,7 @@ Print program version
618
618
 
619
619
  ```
620
620
  $ gamerom version
621
- 0.6.0
621
+ 0.7.0
622
622
  ```
623
623
 
624
624
  ## Development
data/gamerom.gemspec CHANGED
@@ -16,7 +16,6 @@ Gem::Specification.new do |spec|
16
16
 
17
17
  spec.metadata['allowed_push_host'] = 'https://rubygems.org'
18
18
 
19
- spec.metadata['homepage_uri'] = spec.homepage
20
19
  spec.metadata['source_code_uri'] = spec.homepage
21
20
  spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/master/CHANGELOG.md"
22
21
  spec.metadata['rubygems_mfa_required'] = 'true'
data/lib/gamerom/cli.rb CHANGED
@@ -124,8 +124,8 @@ module Gamerom
124
124
  validate_platform repo, options[:platform]
125
125
  puts "recovering state of roms for #{options[:platform]} platform on #{options[:repo]} repo..."
126
126
  games = repo.games options[:platform]
127
- game_names = games.to_h { |game| [game.id, game.name] }
128
- results = games.group_by { |game| recover_game(game, game_names) }
127
+ parsed_names = games.to_h { |game| [game.id, parse_rom_name(game.name)] }
128
+ results = games.group_by { |game| recover_game(game, parsed_names) }
129
129
 
130
130
  print_recover_summary(results.fetch(:found, []), results.fetch(:already_installed, []), results.fetch(:not_found, []))
131
131
  rescue StandardError => e
@@ -268,11 +268,19 @@ module Gamerom
268
268
  def update_all_databases
269
269
  puts "updating all databases on #{options[:repo]} repo..."
270
270
  repo = Repo.new(options[:repo])
271
- repo.platforms.each_key do |platform|
272
- repo = Repo.new(options[:repo])
271
+ failed = repo.platforms.keys.reject do |platform|
273
272
  repo.update_database platform
273
+ true
274
+ rescue StandardError => e
275
+ shell.say "Error updating #{platform}: #{e.message}", :red
276
+ false
277
+ end
278
+ if failed.empty?
279
+ shell.say 'All game databases updated', :green
280
+ else
281
+ shell.say "Failed to update: #{failed.join(", ")}", :red
282
+ exit 1
274
283
  end
275
- shell.say 'All game databases updated', :green
276
284
  rescue StandardError => e
277
285
  render_error e, options
278
286
  exit 1
@@ -327,7 +335,7 @@ module Gamerom
327
335
 
328
336
  # Recover helpers
329
337
 
330
- def recover_game(game, game_names)
338
+ def recover_game(game, parsed_names)
331
339
  puts
332
340
  puts "Checking game #{game}"
333
341
  if game.filenames.any?
@@ -335,7 +343,7 @@ module Gamerom
335
343
  return :already_installed
336
344
  end
337
345
 
338
- game_files = glob_game_files(game).select { |file| file_belongs_to_game?(file, game, game_names) }.sort
346
+ game_files = glob_game_files(game).select { |file| file_belongs_to_game?(file, game, parsed_names) }.sort
339
347
  if game_files.empty?
340
348
  puts "#{shell.set_color("*", :red)} not found"
341
349
  return :not_found
@@ -362,56 +370,29 @@ module Gamerom
362
370
  .gsub('(', '*\(')
363
371
  end
364
372
 
365
- # A globbed file is kept for a game unless it is a better match for another
366
- # game whose name starts with this game's name (e.g. "Kung-Fu" vs "Kung-Fu Master").
367
- def file_belongs_to_game?(file, game, game_names)
368
- filename = File.basename(file).downcase
369
- return true if exact_filename_match?(filename, game.name.downcase)
370
-
371
- number = sequel_number(game.name)
372
- return true if number && filename.include?(number)
373
- return true if game.name.include?('Limited Run Games') && filename.include?('limited_run')
374
-
375
- !claimed_by_other_game?(filename, game, game_names)
376
- end
377
-
378
- # "Game.zip", "Game_Name.zip" or "Game (USA).zip"
379
- def exact_filename_match?(filename, name)
380
- [name, name.tr(' ', '_')].any? do |variant|
381
- filename.match?(/\A#{Regexp.escape(variant)}(\s*\(\w+\))?\.\w+\z/i)
373
+ # A globbed file belongs to a game when, ignoring case and punctuation, the file name
374
+ # without its tags equals the game name without its tags, and every tag in the game
375
+ # name is present in the file. Among games with the same base name the one whose tags
376
+ # match the file best wins, so "Mega Man 2 (Capcom Town)" takes
377
+ # "Mega_Man_2_(USA)_(Capcom_Town).zip" and plain "Mega Man 2" leaves it alone.
378
+ def file_belongs_to_game?(file, game, parsed_names)
379
+ base, tags = parse_rom_name(File.basename(file).sub(/(\.[a-z0-9]{1,4})+\z/, '')) # strips ".zip", ".nkit.iso"
380
+ name_base, name_tags = parsed_names[game.id]
381
+ return false unless base == name_base && (name_tags - tags).empty?
382
+
383
+ parsed_names.none? do |other_id, (other_base, other_tags)|
384
+ other_id != game.id && other_base == base && (other_tags - tags).empty? && other_tags.size > name_tags.size
382
385
  end
383
386
  end
384
387
 
385
- # The number that identifies a sequel, e.g. "2" in "Tony Hawk's Pro Skater 2"
386
- def sequel_number(name)
387
- match = name.match(/(\d+|\s+\d+)[\s':]/) || name.match(/\s(\d+)$/)
388
- match && match[1].strip
388
+ # "Legend of Zelda, The: Ocarina of Time (USA) (GameCube)" => ["legendofzeldatheocarinaoftime", ["usa", "gamecube"]]
389
+ def parse_rom_name(str)
390
+ tags = str.scan(/[(\[]([^)\]]+)[)\]]/).flatten.map { |tag| normalize_rom_name(tag) }
391
+ [normalize_rom_name(str.gsub(/[(\[][^)\]]*[)\]]/, '')), tags]
389
392
  end
390
393
 
391
- def claimed_by_other_game?(filename, game, game_names)
392
- name = game.name.downcase
393
- game_names.any? do |other_id, other_name|
394
- next false if other_id == game.id
395
-
396
- other = other_name.downcase
397
- next false unless other.length > name.length && other.start_with?(name)
398
-
399
- filename_matches_other_game?(filename, other_name)
400
- end
401
- end
402
-
403
- def filename_matches_other_game?(filename, other_name)
404
- other = other_name.downcase
405
- other_underscored = other.tr(' ', '_')
406
- return false unless filename.include?(other_underscored) || filename.include?(other)
407
-
408
- number = sequel_number(other_name)
409
- return filename.include?(number) if number
410
-
411
- filename.start_with?(other_underscored) ||
412
- filename.start_with?(other) ||
413
- filename.include?(other_underscored.tr(':', '_')) ||
414
- filename.include?(other.tr(':', '-'))
394
+ def normalize_rom_name(str)
395
+ str.downcase.gsub(/[^a-z0-9]/, '')
415
396
  end
416
397
 
417
398
  def print_recovered_files(game_files)
data/lib/gamerom/repo.rb CHANGED
@@ -77,8 +77,14 @@ module Gamerom
77
77
 
78
78
  def update_database(platform)
79
79
  games = @repo.games platform
80
- FileUtils.mkdir_p("#{Gamerom::CACHE_DIR}/#{@name}")
81
- File.write("#{Gamerom::CACHE_DIR}/#{@name}/#{platform}.yml", games.to_yaml)
80
+ database = "#{Gamerom::CACHE_DIR}/#{@name}/#{platform}.yml"
81
+ # An empty crawl means the site changed or blocked us; never wipe a good database over it.
82
+ if games.empty? && File.exist?(database) && !YAML.load_file(database).empty?
83
+ raise Gamerom::Error, "no games found for #{platform} on #{@name}, keeping the existing database"
84
+ end
85
+
86
+ FileUtils.mkdir_p(File.dirname(database))
87
+ File.write(database, games.to_yaml)
82
88
  end
83
89
  end
84
90
  end
@@ -2,12 +2,26 @@
2
2
 
3
3
  require 'nokogiri'
4
4
  require 'rest-client'
5
+ require 'uri'
5
6
 
6
7
  module Gamerom
7
8
  # RepoAdapter - Common adapter methods
8
9
  module RepoAdapter
10
+ MAX_RETRIES = 5
11
+
12
+ # Sites rate limit the section crawl; honor the Retry-After of a 429 instead of failing.
9
13
  def nokogiri_get(url)
10
- Nokogiri::HTML(RestClient.get(url))
14
+ retries = 0
15
+ loop do
16
+ return Nokogiri::HTML(RestClient.get(url))
17
+ rescue RestClient::TooManyRequests => e
18
+ retries += 1
19
+ raise if retries > MAX_RETRIES
20
+
21
+ delay = (e.response.headers[:retry_after] || 60).to_i
22
+ puts "\nrate limited by #{URI.parse(url).host}, retrying in #{delay}s (#{retries}/#{MAX_RETRIES})"
23
+ sleep delay
24
+ end
11
25
  end
12
26
 
13
27
  def games(platform)
@@ -105,10 +105,15 @@ module Gamerom
105
105
  }
106
106
  end
107
107
 
108
- # "Espial (USA)" => "USA", "Game (USA, Europe) (Proto)" => "USA, Europe"
108
+ # One region per game from the No-Intro tags: "Espial (USA)" => "USA".
109
+ # Multi-region releases that include USA stay under USA ("Game (USA, Europe)" => "USA"),
110
+ # otherwise the alphabetically first region ("Game (Japan, Europe)" => "Europe").
109
111
  def self.region(name)
110
- groups = name.scan(/\(([^)]+)\)/).flatten
111
- groups.find { |group| REGIONS.include?(group.split(',').first.strip) } || 'Unknown'
112
+ groups = name.scan(/\(([^)]+)\)/).flatten.map { |group| group.split(',').map(&:strip) }
113
+ regions = groups.find { |tokens| REGIONS.include?(tokens.first) }
114
+ return 'Unknown' unless regions
115
+
116
+ regions.include?('USA') ? 'USA' : regions.min
112
117
  end
113
118
 
114
119
  def self.install(game)
@@ -52,21 +52,36 @@ module Gamerom
52
52
  def self.extract_games(platform)
53
53
  sections.each_with_index do |section, index|
54
54
  page = nokogiri_get("https://vimm.net/vault/?p=list&system=#{platform}&section=#{section}")
55
- game_links = page.css('table.hovertable td:first-child a:first-child')
56
- yield game_links.map { |game_link| game(game_link) }, index
55
+ rows = page.css('table.hovertable tr').select { |row| row.at_css(TITLE_LINK) }
56
+ yield rows.map { |row| game(row) }, index
57
57
  rescue RestClient::NotFound
58
58
  yield [], index
59
59
  end
60
60
  end
61
61
 
62
- def self.game(game_link)
62
+ # Each row: <td><img class="media-icon"><a href="/vault/2640">Mace: The Dark Age</a></td>
63
+ # <td><img class="flag" title="USA"></td>...
64
+ TITLE_LINK = 'td:first-child a[href^="/vault/"]'
65
+
66
+ def self.game(row)
67
+ link = row.at_css(TITLE_LINK)
63
68
  {
64
- id: game_link['href'].split('/').last.to_i,
65
- name: game_link.text,
66
- region: 'USA',
69
+ id: link['href'].split('/').last.to_i,
70
+ name: link.text,
71
+ region: region(row),
67
72
  }
68
73
  end
69
74
 
75
+ # One region per game, taken from the flag icons. Multi-region releases that include
76
+ # USA stay under USA, so the vault keeps its US-centric layout; otherwise the
77
+ # alphabetically first flag.
78
+ def self.region(row)
79
+ flags = row.css('img.flag').map { |flag| flag['title'] }.compact
80
+ return 'USA' if flags.empty? || flags.include?('USA')
81
+
82
+ flags.min
83
+ end
84
+
70
85
  # Vimm's Lair protects game pages with a browser challenge, so games have to be
71
86
  # downloaded by hand. Prepare the target directory and tell the user what to do.
72
87
  def self.install(game)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gamerom
4
- VERSION = '0.6.0'
4
+ VERSION = '0.7.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gamerom
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lucas Mundim
@@ -139,7 +139,6 @@ licenses:
139
139
  - MIT
140
140
  metadata:
141
141
  allowed_push_host: https://rubygems.org
142
- homepage_uri: https://github.com/lucasmundim/gamerom
143
142
  source_code_uri: https://github.com/lucasmundim/gamerom
144
143
  changelog_uri: https://github.com/lucasmundim/gamerom/blob/master/CHANGELOG.md
145
144
  rubygems_mfa_required: 'true'