rom-distillery 0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Gemfile +6 -0
- data/LICENSE +287 -0
- data/README.md +24 -0
- data/bin/rhum +6 -0
- data/distillery.gemspec +38 -0
- data/lib/distillery.rb +10 -0
- data/lib/distillery/archiver.rb +372 -0
- data/lib/distillery/archiver/archive.rb +102 -0
- data/lib/distillery/archiver/external.rb +182 -0
- data/lib/distillery/archiver/external.yaml +31 -0
- data/lib/distillery/archiver/libarchive.rb +105 -0
- data/lib/distillery/archiver/zip.rb +88 -0
- data/lib/distillery/cli.rb +234 -0
- data/lib/distillery/cli/check.rb +100 -0
- data/lib/distillery/cli/clean.rb +60 -0
- data/lib/distillery/cli/header.rb +61 -0
- data/lib/distillery/cli/index.rb +65 -0
- data/lib/distillery/cli/overlap.rb +39 -0
- data/lib/distillery/cli/rebuild.rb +47 -0
- data/lib/distillery/cli/rename.rb +34 -0
- data/lib/distillery/cli/repack.rb +113 -0
- data/lib/distillery/cli/validate.rb +171 -0
- data/lib/distillery/datfile.rb +180 -0
- data/lib/distillery/error.rb +13 -0
- data/lib/distillery/game.rb +70 -0
- data/lib/distillery/game/release.rb +40 -0
- data/lib/distillery/refinements.rb +41 -0
- data/lib/distillery/rom-archive.rb +266 -0
- data/lib/distillery/rom.rb +585 -0
- data/lib/distillery/rom/path.rb +110 -0
- data/lib/distillery/rom/path/archive.rb +103 -0
- data/lib/distillery/rom/path/file.rb +100 -0
- data/lib/distillery/rom/path/virtual.rb +70 -0
- data/lib/distillery/storage.rb +170 -0
- data/lib/distillery/vault.rb +433 -0
- data/lib/distillery/version.rb +7 -0
- metadata +192 -0
@@ -0,0 +1,65 @@
|
|
1
|
+
# SPDX-License-Identifier: EUPL-1.2
|
2
|
+
|
3
|
+
module Distillery
|
4
|
+
class CLI
|
5
|
+
|
6
|
+
# Print index (hash and path of each ROM)
|
7
|
+
#
|
8
|
+
# @param romdirs [Array<String>] ROMs directories
|
9
|
+
# @param type [Symbol,nil] type of checksum to use
|
10
|
+
#
|
11
|
+
# @return [self]
|
12
|
+
#
|
13
|
+
def index(romdirs, type: nil, separator: nil)
|
14
|
+
list = make_storage(romdirs).index(type, separator)
|
15
|
+
|
16
|
+
if (@output_mode == :fancy) || (@output_mode == :text)
|
17
|
+
list.each {|hash, path|
|
18
|
+
@io.puts "#{hash} #{path}"
|
19
|
+
}
|
20
|
+
|
21
|
+
elsif @output_mode == :json
|
22
|
+
@io.puts Hash[list.each.to_a].to_json
|
23
|
+
|
24
|
+
else
|
25
|
+
raise Assert
|
26
|
+
end
|
27
|
+
|
28
|
+
self
|
29
|
+
end
|
30
|
+
|
31
|
+
|
32
|
+
# -----------------------------------------------------------------
|
33
|
+
|
34
|
+
|
35
|
+
# Parser for index command
|
36
|
+
IndexParser = OptionParser.new do |opts|
|
37
|
+
opts.banner = "Usage: #{PROGNAME} index [options] ROMDIR..."
|
38
|
+
|
39
|
+
opts.separator ""
|
40
|
+
opts.separator "Generate hash index"
|
41
|
+
opts.separator ""
|
42
|
+
opts.separator "Options:"
|
43
|
+
opts.on '-c', '--cksum=CHECKSUM', ROM::CHECKSUMS,
|
44
|
+
"Checksum used for indexing (#{ROM::FS_CHECKSUM})",
|
45
|
+
" Value: #{ROM::CHECKSUMS.join(', ')}"
|
46
|
+
opts.on '-s', '--separator=CHAR', String,
|
47
|
+
"Separator for archive entry (#{ROM::Path::Archive.separator})"
|
48
|
+
opts.separator ""
|
49
|
+
end
|
50
|
+
|
51
|
+
|
52
|
+
# Register index command
|
53
|
+
subcommand :index, "Generate hash index",
|
54
|
+
IndexParser do |argv, **opts|
|
55
|
+
|
56
|
+
if argv.empty?
|
57
|
+
warn "At least one rom directory is required"
|
58
|
+
exit
|
59
|
+
end
|
60
|
+
|
61
|
+
[ argv, type: opts[:cksum], separator: opts[:separator] ]
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# SPDX-License-Identifier: EUPL-1.2
|
2
|
+
|
3
|
+
module Distillery
|
4
|
+
class CLI
|
5
|
+
|
6
|
+
def overlap(index, romdirs)
|
7
|
+
index = Hash[File.readlines(index).map {|line| line.split(' ', 2) }]
|
8
|
+
storage = make_storage(romdirs)
|
9
|
+
storage.roms.select {|rom| index.include?(rom.sha1) }
|
10
|
+
.each {|rom|
|
11
|
+
@io.puts rom.path
|
12
|
+
}
|
13
|
+
|
14
|
+
end
|
15
|
+
|
16
|
+
|
17
|
+
# -----------------------------------------------------------------
|
18
|
+
|
19
|
+
# Parser for overlap command
|
20
|
+
OverlapParser = OptionParser.new do |opts|
|
21
|
+
opts.banner = "Usage: #{PROGNAME} overlap [options] ROMDIR..."
|
22
|
+
|
23
|
+
opts.separator ""
|
24
|
+
opts.separator "Check ROMs status, and display missing or extra files."
|
25
|
+
opts.separator ""
|
26
|
+
opts.separator "Options:"
|
27
|
+
opts.on '-r', '--revert', "Display present files instead"
|
28
|
+
opts.separator ""
|
29
|
+
end
|
30
|
+
|
31
|
+
|
32
|
+
# Register overlap command
|
33
|
+
subcommand :overlap, "Check for overlaping ROM" do |argv, **opts|
|
34
|
+
opts[:romdirs] = argv
|
35
|
+
|
36
|
+
[ opts[:index], opts[:romdirs] ]
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# SPDX-License-Identifier: EUPL-1.2
|
2
|
+
|
3
|
+
module Distillery
|
4
|
+
class CLI
|
5
|
+
|
6
|
+
def rebuild(gamedir, datfile, romdirs)
|
7
|
+
dat = make_dat(datfile)
|
8
|
+
storage = make_storage(*romdirs)
|
9
|
+
|
10
|
+
# gamedir can be one of the romdir we must find a clever
|
11
|
+
# way to avoid overwriting file
|
12
|
+
|
13
|
+
romsdir = File.join(gamedir, '.roms')
|
14
|
+
storage.build_roms_directory(romsdir, delete: true)
|
15
|
+
|
16
|
+
vault = ROMVault.new
|
17
|
+
vault.add_from_dir(romsdir)
|
18
|
+
|
19
|
+
storage.build_games_archives(gamedir, dat, vault, '7z')
|
20
|
+
FileUtils.remove_dir(romsdir)
|
21
|
+
end
|
22
|
+
|
23
|
+
# -----------------------------------------------------------------
|
24
|
+
|
25
|
+
|
26
|
+
# Parser for header command
|
27
|
+
RebuildParser = OptionParser.new do |opts|
|
28
|
+
opts.banner = "Usage: #{PROGNAME} rebuild ROMDIR..."
|
29
|
+
end
|
30
|
+
|
31
|
+
|
32
|
+
# Register rebuild command
|
33
|
+
subcommand :rebuild, "Rebuild according to DAT file",
|
34
|
+
RebuildParser do |argv, **opts|
|
35
|
+
opts[:romdirs] = argv
|
36
|
+
if opts[:dat].nil? && (opts[:romdirs].size >= 1)
|
37
|
+
opts[:dat] = File.join(opts[:romdirs].first, '.dat')
|
38
|
+
end
|
39
|
+
|
40
|
+
if opts[:destdir].nil? && (opts[:romdirs].size >= 1)
|
41
|
+
opts[:destdir] = opts[:romdirs].first
|
42
|
+
end
|
43
|
+
|
44
|
+
[ opts[:destdir], opts[:dat], opts[:romdirs] ]
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# SPDX-License-Identifier: EUPL-1.2
|
2
|
+
|
3
|
+
module Distillery
|
4
|
+
class CLI
|
5
|
+
|
6
|
+
def rename(datfile, romdirs)
|
7
|
+
dat = Distillery::DatFile.new(datfile)
|
8
|
+
storage = create_storage(romdirs)
|
9
|
+
|
10
|
+
storage.rename(dat)
|
11
|
+
end
|
12
|
+
|
13
|
+
# -----------------------------------------------------------------
|
14
|
+
|
15
|
+
|
16
|
+
# Register rename command
|
17
|
+
subcommand :rename, "Rename ROMs according to DAT" do |argv, **opts|
|
18
|
+
opts[:romdirs] = argv
|
19
|
+
if opts[:dat].nil? && (opts[:romdirs].size == 1)
|
20
|
+
opts[:dat] = File.join(opts[:romdirs].first, '.dat')
|
21
|
+
end
|
22
|
+
if opts[:dat].nil?
|
23
|
+
warn "missing datfile"
|
24
|
+
exit
|
25
|
+
end
|
26
|
+
if opts[:romdirs].empty?
|
27
|
+
warn "missing ROM directory"
|
28
|
+
exit
|
29
|
+
end
|
30
|
+
|
31
|
+
[ opts[:dat], opts[:romdirs] ]
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,113 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
# SPDX-License-Identifier: EUPL-1.2
|
3
|
+
|
4
|
+
require 'securerandom'
|
5
|
+
|
6
|
+
module Distillery
|
7
|
+
class CLI
|
8
|
+
using Distillery::StringY
|
9
|
+
|
10
|
+
def repack(romdirs, type = nil)
|
11
|
+
type ||= ROMArchive::PREFERED
|
12
|
+
|
13
|
+
decorator =
|
14
|
+
if @output_mode == :fancy
|
15
|
+
lambda {|file, type, &block|
|
16
|
+
spinner = TTY::Spinner.new("[:spinner] :file",
|
17
|
+
:hide_cursor => true,
|
18
|
+
:output => @io)
|
19
|
+
width = TTY::Screen.width - 8
|
20
|
+
spinner.update(:file => file.ellipsize(width, :middle))
|
21
|
+
spinner.auto_spin
|
22
|
+
case v = block.call
|
23
|
+
when String then spinner.error("(#{v})")
|
24
|
+
else spinner.success("-> #{type}")
|
25
|
+
end
|
26
|
+
}
|
27
|
+
|
28
|
+
elsif @output_mode == :text
|
29
|
+
lambda {|file, type, &block|
|
30
|
+
case v = block.call
|
31
|
+
when String
|
32
|
+
@io.puts "FAILED: #{file} (#{v})"
|
33
|
+
@io.puts "OK : #{file} -> #{type}" if @verbose
|
34
|
+
end
|
35
|
+
}
|
36
|
+
|
37
|
+
else
|
38
|
+
|
39
|
+
raise Assert
|
40
|
+
end
|
41
|
+
|
42
|
+
|
43
|
+
from_romdirs(romdirs) { | srcfile, dir: |
|
44
|
+
# Destination file according to archive type
|
45
|
+
dstfile = srcfile.dup
|
46
|
+
dstfile += ".#{type}" unless dstfile.sub!(/\.[^.\/]*$/, ".#{type}")
|
47
|
+
|
48
|
+
# Path for src and dst
|
49
|
+
src = File.join(dir, srcfile)
|
50
|
+
dst = File.join(dir, dstfile)
|
51
|
+
|
52
|
+
# If source and destination are the same
|
53
|
+
# - move source out of the way as we could recompress
|
54
|
+
# using another algorithm
|
55
|
+
if srcfile == dstfile
|
56
|
+
phyfile = srcfile + '.' + SecureRandom.alphanumeric(10)
|
57
|
+
phy = File.join(dir, phyfile)
|
58
|
+
File.rename(src, phy)
|
59
|
+
else
|
60
|
+
phyfile = srcfile
|
61
|
+
phy = src
|
62
|
+
end
|
63
|
+
|
64
|
+
# Recompress
|
65
|
+
decorator.(srcfile, type) {
|
66
|
+
next "#{type} exists" if File.exists?(dst)
|
67
|
+
archive = Distillery::Archiver.for(dst)
|
68
|
+
Distillery::Archiver.for(phy).each {|entry, i|
|
69
|
+
archive.writer(entry) {|o|
|
70
|
+
while data = i.read(32 * 1024)
|
71
|
+
o.write(data)
|
72
|
+
end
|
73
|
+
}
|
74
|
+
}
|
75
|
+
File.unlink(phy)
|
76
|
+
}
|
77
|
+
}
|
78
|
+
end
|
79
|
+
|
80
|
+
|
81
|
+
# -----------------------------------------------------------------
|
82
|
+
|
83
|
+
|
84
|
+
# Parser for repack command
|
85
|
+
RepackParser = OptionParser.new do |opts|
|
86
|
+
types = ROMArchive::EXTENSIONS.to_a
|
87
|
+
opts.banner = "Usage: #{PROGNAME} repack [options] ROMDIR..."
|
88
|
+
|
89
|
+
opts.separator ""
|
90
|
+
opts.separator "Repack archives to the specified format"
|
91
|
+
opts.separator ""
|
92
|
+
opts.separator "NOTE: if an archive in the new format already exists the operation"
|
93
|
+
|
94
|
+
opts.separator " won't be carried out"
|
95
|
+
opts.separator ""
|
96
|
+
opts.separator "Options:"
|
97
|
+
opts.on '-F', '--format=FORMAT', types,
|
98
|
+
"Archive format (#{ROMArchive::PREFERED})",
|
99
|
+
" Value: #{types.join(', ')}"
|
100
|
+
opts.separator ""
|
101
|
+
end
|
102
|
+
|
103
|
+
|
104
|
+
# Register repack command
|
105
|
+
subcommand :repack, "Recompress archives",
|
106
|
+
RepackParser do |argv, **opts|
|
107
|
+
opts[:romdirs] = argv
|
108
|
+
|
109
|
+
[ opts[:romdirs], opts[:format] ]
|
110
|
+
end
|
111
|
+
|
112
|
+
end
|
113
|
+
end
|
@@ -0,0 +1,171 @@
|
|
1
|
+
# SPDX-License-Identifier: EUPL-1.2
|
2
|
+
|
3
|
+
module Distillery
|
4
|
+
class CLI
|
5
|
+
using Distillery::StringY
|
6
|
+
|
7
|
+
# Validate ROMs according to DAT/Index file.
|
8
|
+
#
|
9
|
+
# @param romdirs [Array<String>] ROMs directories
|
10
|
+
# @param datfile [String] DAT file
|
11
|
+
#
|
12
|
+
# @return [self]
|
13
|
+
#
|
14
|
+
def validate(romdirs, datfile: nil, summarize: false)
|
15
|
+
dat = make_dat(datfile)
|
16
|
+
storage = make_storage(romdirs)
|
17
|
+
count = { :not_found => 0,
|
18
|
+
:name_mismatch => 0,
|
19
|
+
:wrong_place => 0 }
|
20
|
+
summarizer = lambda {|io|
|
21
|
+
io.puts
|
22
|
+
io.puts "Not found : #{count[:not_found ]}"
|
23
|
+
io.puts "Name mismatch : #{count[:name_mismatch]}"
|
24
|
+
io.puts "Wrong place : #{count[:wrong_place ]}"
|
25
|
+
}
|
26
|
+
checker = lambda {|game, rom|
|
27
|
+
m = storage.roms.match(rom)
|
28
|
+
|
29
|
+
if m.nil? || m.empty?
|
30
|
+
count[:not_found] += 1
|
31
|
+
"not found"
|
32
|
+
elsif (m = m.select {|r| r.name == rom.name }).empty?
|
33
|
+
count[:name_mismatch] += 1
|
34
|
+
"name mismatch"
|
35
|
+
elsif (m = m.select {|r|
|
36
|
+
store = File.basename(r.path.storage)
|
37
|
+
ROMArchive::EXTENSIONS.any? {|ext|
|
38
|
+
ext = Regexp.escape(ext)
|
39
|
+
store.gsub(/\.#{ext}$/i, '') == game.name
|
40
|
+
} || (store == game.name) || romdirs.include?(store)
|
41
|
+
}).empty?
|
42
|
+
count[:wrong_place] += 1
|
43
|
+
"wrong place"
|
44
|
+
end
|
45
|
+
}
|
46
|
+
|
47
|
+
|
48
|
+
|
49
|
+
if @output_mode == :fancy
|
50
|
+
dat.each_game {|game|
|
51
|
+
s_width = TTY::Screen.width
|
52
|
+
r_width = s_width - 25
|
53
|
+
g_width = s_width - 10
|
54
|
+
|
55
|
+
game_name = game.name.ellipsize(g_width, :middle)
|
56
|
+
gspinner = TTY::Spinner::Multi.new("[:spinner] #{game_name}",
|
57
|
+
:hide_cursor => true,
|
58
|
+
:output => @io)
|
59
|
+
|
60
|
+
game.each_rom {|rom|
|
61
|
+
rom_name = rom.name.ellipsize(r_width, :middle)
|
62
|
+
rspinner = gspinner.register "[:spinner] :rom"
|
63
|
+
rspinner.update(:rom => rom_name)
|
64
|
+
rspinner.auto_spin
|
65
|
+
|
66
|
+
case v = checker.(game, rom)
|
67
|
+
when String then rspinner.error("-> #{v}")
|
68
|
+
when nil then rspinner.success
|
69
|
+
else raise Assert
|
70
|
+
end
|
71
|
+
}
|
72
|
+
}
|
73
|
+
if summarize
|
74
|
+
summarize.(@io)
|
75
|
+
end
|
76
|
+
|
77
|
+
|
78
|
+
elsif (@output_mode == :text) && @verbose
|
79
|
+
dat.each_game {|game|
|
80
|
+
@io.puts "#{game}:"
|
81
|
+
game.each_rom {|rom|
|
82
|
+
case v = checker.(game, rom)
|
83
|
+
when String then @io.puts " - FAILED: #{rom} -> #{v}"
|
84
|
+
when nil then @io.puts " - OK : #{rom}"
|
85
|
+
else raise Assert
|
86
|
+
end
|
87
|
+
}
|
88
|
+
}
|
89
|
+
if summarize
|
90
|
+
summarize.(@io)
|
91
|
+
end
|
92
|
+
|
93
|
+
elsif @output_mode == :text
|
94
|
+
dat.each_game.flat_map {|game|
|
95
|
+
game.each_rom.map {|rom|
|
96
|
+
case v = checker.(game, rom)
|
97
|
+
when String then [ game.name, rom, v ]
|
98
|
+
when nil
|
99
|
+
else raise Assert
|
100
|
+
end
|
101
|
+
}.compact
|
102
|
+
}.compact.group_by {|game,| game }.each {|game, list|
|
103
|
+
@io.puts "#{game}"
|
104
|
+
list.each {|_, rom, err|
|
105
|
+
@io.puts " - FAILED: #{rom} -> #{err}"
|
106
|
+
}
|
107
|
+
}
|
108
|
+
|
109
|
+
elsif @output_mode == :json
|
110
|
+
@io.puts dat.each_game.map {|game|
|
111
|
+
{ :game => game.name,
|
112
|
+
:roms => game.each_rom.map {|rom|
|
113
|
+
case v = checker.(game, rom)
|
114
|
+
when String, nil then [ game.name, rom, v ]
|
115
|
+
else raise Assert
|
116
|
+
end
|
117
|
+
{ :rom => rom.path.entry,
|
118
|
+
:success => v.nil?,
|
119
|
+
:reason => v
|
120
|
+
}.compact
|
121
|
+
}
|
122
|
+
}
|
123
|
+
}.to_json
|
124
|
+
else
|
125
|
+
|
126
|
+
raise Assert
|
127
|
+
end
|
128
|
+
|
129
|
+
self
|
130
|
+
end
|
131
|
+
|
132
|
+
|
133
|
+
# -----------------------------------------------------------------
|
134
|
+
|
135
|
+
|
136
|
+
# Parser for validate command
|
137
|
+
ValidateParser = OptionParser.new do |opts|
|
138
|
+
opts.banner = "Usage: #{PROGNAME} validate [options] ROMDIR..."
|
139
|
+
|
140
|
+
opts.separator ""
|
141
|
+
opts.separator "Validate ROMs according to DAT file"
|
142
|
+
opts.separator ""
|
143
|
+
opts.separator "Options:"
|
144
|
+
opts.on '-s', '--summarize', "Summarize results"
|
145
|
+
opts.separator ""
|
146
|
+
|
147
|
+
end
|
148
|
+
|
149
|
+
|
150
|
+
# Register validate command
|
151
|
+
subcommand :validate, "Validate ROMs according to DAT file",
|
152
|
+
ValidateParser do |argv, **opts|
|
153
|
+
opts[:romdirs] = argv
|
154
|
+
|
155
|
+
if opts[:dat].nil? && (opts[:romdirs].size >= 1)
|
156
|
+
opts[:dat] = File.join(opts[:romdirs].first, '.dat')
|
157
|
+
end
|
158
|
+
if opts[:dat].nil?
|
159
|
+
warn "missing datfile"
|
160
|
+
exit
|
161
|
+
end
|
162
|
+
if opts[:romdirs].empty?
|
163
|
+
warn "missing ROM directory"
|
164
|
+
exit
|
165
|
+
end
|
166
|
+
|
167
|
+
[ opts[:romdirs], datfile: opts[:dat] ]
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
end
|