udfkit 0.1.0__py3-none-any.whl
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.
- udfkit/__init__.py +12 -0
- udfkit/__main__.py +41 -0
- udfkit/bluray.py +492 -0
- udfkit/dvd.py +1086 -0
- udfkit/errors.py +11 -0
- udfkit/info.py +51 -0
- udfkit-0.1.0.dist-info/METADATA +112 -0
- udfkit-0.1.0.dist-info/RECORD +13 -0
- udfkit-0.1.0.dist-info/WHEEL +5 -0
- udfkit-0.1.0.dist-info/entry_points.txt +2 -0
- udfkit-0.1.0.dist-info/licenses/LICENSE +202 -0
- udfkit-0.1.0.dist-info/licenses/NOTICE +5 -0
- udfkit-0.1.0.dist-info/top_level.txt +1 -0
udfkit/dvd.py
ADDED
|
@@ -0,0 +1,1086 @@
|
|
|
1
|
+
"""Write a DVD-Video disc image (UDF 1.02 + ISO 9660 "bridge") in pure Python.
|
|
2
|
+
|
|
3
|
+
DVD players and libdvdread find VIDEO_TS through UDF; older systems and
|
|
4
|
+
`isoinfo` read the ISO 9660 side. Both file systems describe the same file
|
|
5
|
+
data, which is stored once. The layout follows what `genisoimage -dvd-video
|
|
6
|
+
-udf` writes, so images are the same size and have the same structure:
|
|
7
|
+
|
|
8
|
+
sector contents
|
|
9
|
+
0-15 system area (zeros)
|
|
10
|
+
16 ISO 9660 Primary Volume Descriptor
|
|
11
|
+
17 ISO 9660 Volume Descriptor Set Terminator
|
|
12
|
+
18-20 UDF Volume Recognition Sequence: BEA01, NSR02, TEA01
|
|
13
|
+
32-47 UDF Main Volume Descriptor Sequence: Primary VD, Implementation
|
|
14
|
+
Use VD ("*UDF LV Info"), Partition D, Logical Volume D, Unallocated
|
|
15
|
+
Space D, Terminating D (16 sectors reserved, ECMA-167 3/8.4.2)
|
|
16
|
+
48-63 UDF Reserve Volume Descriptor Sequence (the same, relocated)
|
|
17
|
+
64-65 UDF Logical Volume Integrity Sequence: LVID, Terminating D
|
|
18
|
+
256 UDF Anchor Volume Descriptor Pointer
|
|
19
|
+
P = 257 UDF partition start (partition block 0):
|
|
20
|
+
block 0, 1 File Set Descriptor, Terminating D
|
|
21
|
+
then each directory: File Entry + its File
|
|
22
|
+
Identifier Descriptors (root first, pre-order)
|
|
23
|
+
then one File Entry per file
|
|
24
|
+
ISO 9660 type L and type M path tables (each rounded up to an
|
|
25
|
+
even number of sectors, as genisoimage does)
|
|
26
|
+
ISO 9660 directories (path table order)
|
|
27
|
+
D file data, sector aligned, each file contiguous: VIDEO_TS first
|
|
28
|
+
(DVD order, see below), then AUDIO_TS, then DVD-ROM content
|
|
29
|
+
E end of the partition; E .. N-1 hold the Anchor Volume Descriptor
|
|
30
|
+
Pointer again (151 sectors, like genisoimage, which pads 150
|
|
31
|
+
sectors after the end anchor). UDF wants an anchor at 256 and N-1;
|
|
32
|
+
filling the whole tail means a reader that gets the disc size
|
|
33
|
+
slightly wrong still finds one.
|
|
34
|
+
|
|
35
|
+
The 150-sector tail is not required by UDF; it is genisoimage's default
|
|
36
|
+
`-pad` and keeps the image exactly as large as the one genisoimage writes for
|
|
37
|
+
the same input, so size estimates made for either tool hold for both.
|
|
38
|
+
|
|
39
|
+
VIDEO_TS file order and placement
|
|
40
|
+
VIDEO_TS.IFO, VIDEO_TS.VOB, VIDEO_TS.BUP, then for each title set NN:
|
|
41
|
+
VTS_NN_0.IFO, VTS_NN_0.VOB, VTS_NN_1.VOB .. VTS_NN_9.VOB, VTS_NN_0.BUP.
|
|
42
|
+
Players address title sets and their parts by sector offsets stored in the
|
|
43
|
+
IFO files, so when the IFOs can be read their numbers decide where each file
|
|
44
|
+
starts (relative to VIDEO_TS.IFO): the VMG menu VOB at vmgm_vobs, the VMG
|
|
45
|
+
BUP so that it ends at vmg_last_sector, each title set at the start sector
|
|
46
|
+
of the title search pointer table, and within a title set the menu VOB at
|
|
47
|
+
vtsm_vobs, the title VOBs from vtstt_vobs on and the BUP ending at
|
|
48
|
+
vts_last_sector. dvdauthor, for one, declares a 32-sector VMG even without
|
|
49
|
+
a VIDEO_TS.VOB, so there is a gap between VIDEO_TS.IFO and VIDEO_TS.BUP,
|
|
50
|
+
exactly as in genisoimage's image. Everything else is packed without gaps.
|
|
51
|
+
Files that are not IFOs (test data) are simply packed in DVD order.
|
|
52
|
+
|
|
53
|
+
File names
|
|
54
|
+
UDF keeps every name as it is (OSTA CS0: 8-bit when all characters are
|
|
55
|
+
Latin-1, otherwise 16-bit; characters beyond U+FFFF become UTF-16
|
|
56
|
+
surrogate pairs, which UDF 1.02 readers show as two unknown characters).
|
|
57
|
+
Names in VIDEO_TS are upper-cased and must be 8.3.
|
|
58
|
+
ISO 9660 gets level-1 names: upper case, A-Z 0-9 _, 8.3 for files (`NAME.EXT;1`,
|
|
59
|
+
the dot is always there), 8 characters for directories. Other names are
|
|
60
|
+
mapped: accents are stripped (NFKD), other characters become "_", the name
|
|
61
|
+
is cut to 8.3 and a clash with a name already in the directory gets a
|
|
62
|
+
"_N" suffix (`My long name.jpeg` -> `MY_LONG_.JPE`, then `MY_LON_1.JPE`).
|
|
63
|
+
Names that are already valid keep their name. Hidden entries (".name",
|
|
64
|
+
e.g. .DS_Store) are left out at every level.
|
|
65
|
+
|
|
66
|
+
Limits: VIDEO_TS files must not exceed 1 GiB (the DVD-Video limit); other
|
|
67
|
+
files must fit an ISO 9660 extent (< 4 GiB) and are stored in UDF as extents
|
|
68
|
+
of at most 0x3FFFF800 bytes. ISO 9660 allows 8 directory levels.
|
|
69
|
+
|
|
70
|
+
Timestamps all come from one `date` (default: now, UTC), so the same input
|
|
71
|
+
and date give a byte-identical image.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
import datetime
|
|
75
|
+
import os
|
|
76
|
+
import re
|
|
77
|
+
import struct
|
|
78
|
+
import unicodedata
|
|
79
|
+
import zlib
|
|
80
|
+
|
|
81
|
+
from .errors import UdfError
|
|
82
|
+
|
|
83
|
+
SECTOR = 2048
|
|
84
|
+
|
|
85
|
+
# --- sector map (absolute sectors) -------------------------------------------
|
|
86
|
+
ISO_PVD_SECTOR = 16
|
|
87
|
+
ISO_TERMINATOR_SECTOR = 17
|
|
88
|
+
VRS_SECTOR = 18 # BEA01, NSR02, TEA01
|
|
89
|
+
MAIN_VDS_SECTOR = 32
|
|
90
|
+
RESERVE_VDS_SECTOR = 48
|
|
91
|
+
VDS_SECTORS = 16 # ECMA-167 3/8.4.2: at least 16 sectors
|
|
92
|
+
LVIS_SECTOR = 64 # Logical Volume Integrity Sequence
|
|
93
|
+
LVIS_SECTORS = 2
|
|
94
|
+
ANCHOR_SECTOR = 256 # ECMA-167 3/8.4.2.1, UDF 2.2.3
|
|
95
|
+
PARTITION_START = 257
|
|
96
|
+
TAIL_SECTORS = 151 # end anchor + genisoimage's 150-sector pad
|
|
97
|
+
|
|
98
|
+
# --- ECMA-167 descriptor tag identifiers (3/7.2.1, 4/7.2.1) -------------------
|
|
99
|
+
TAG_PRIMARY_VOLUME = 1
|
|
100
|
+
TAG_ANCHOR_POINTER = 2
|
|
101
|
+
TAG_IMPLEMENTATION_USE = 4
|
|
102
|
+
TAG_PARTITION = 5
|
|
103
|
+
TAG_LOGICAL_VOLUME = 6
|
|
104
|
+
TAG_UNALLOCATED_SPACE = 7
|
|
105
|
+
TAG_TERMINATING = 8
|
|
106
|
+
TAG_LOGICAL_VOLUME_INTEGRITY = 9
|
|
107
|
+
TAG_FILE_SET = 256
|
|
108
|
+
TAG_FILE_IDENTIFIER = 257
|
|
109
|
+
TAG_FILE_ENTRY = 261
|
|
110
|
+
DESCRIPTOR_VERSION = 2 # NSR02 (UDF 1.02)
|
|
111
|
+
|
|
112
|
+
# --- other field values ----------------------------------------------------------
|
|
113
|
+
UDF_REVISION = 0x0102
|
|
114
|
+
DOMAIN_ID = b"*OSTA UDF Compliant"
|
|
115
|
+
DOMAIN_FLAGS = 0x03 # hard + soft write protect (read-only disc)
|
|
116
|
+
LV_INFO_ID = b"*UDF LV Info"
|
|
117
|
+
IMPLEMENTATION_ID = b"*udfkit"
|
|
118
|
+
PARTITION_CONTENTS_ID = b"+NSR02"
|
|
119
|
+
OSTA_CHARSET = b"OSTA Compressed Unicode"
|
|
120
|
+
PARTITION_FLAG_ALLOCATED = 1 # ECMA-167 3/10.5.3
|
|
121
|
+
ACCESS_READ_ONLY = 1 # ECMA-167 3/10.5.7
|
|
122
|
+
INTEGRITY_CLOSE = 1 # ECMA-167 3/10.10.3
|
|
123
|
+
FILE_TYPE_DIRECTORY = 4 # ECMA-167 4/14.6.6
|
|
124
|
+
FILE_TYPE_FILE = 5
|
|
125
|
+
ICB_STRATEGY = 4 # ECMA-167 4/A.2 (UDF 1.02: strategy 4 only)
|
|
126
|
+
# ICB tag flags, ECMA-167 4/14.6.8: short_ad (0), non-relocatable, archive,
|
|
127
|
+
# contiguous - the same set genisoimage writes
|
|
128
|
+
ICB_FLAGS = 0x0010 | 0x0020 | 0x0200
|
|
129
|
+
FID_DIRECTORY = 0x02 # ECMA-167 4/14.4.3
|
|
130
|
+
FID_PARENT = 0x08
|
|
131
|
+
# permissions, ECMA-167 4/14.9.5: read for owner, group, other (+ execute
|
|
132
|
+
# = search for directories); no write on read-only media
|
|
133
|
+
PERM_FILE = 0x1000 | 0x0080 | 0x0004
|
|
134
|
+
PERM_DIRECTORY = PERM_FILE | 0x0400 | 0x0020 | 0x0001
|
|
135
|
+
UID_NONE = 0xFFFFFFFF # UDF 3.3.3.3: "not specified"
|
|
136
|
+
MAX_EXTENT = 0x3FFFF800 # < 2^30, a whole number of blocks
|
|
137
|
+
FIRST_UNIQUE_ID = 16 # UDF 3.2.1.1: 1..15 are reserved
|
|
138
|
+
|
|
139
|
+
GIB = 1 << 30
|
|
140
|
+
DVD_FILE_LIMIT = GIB
|
|
141
|
+
ISO_FILE_LIMIT = 0xFFFFFFFF
|
|
142
|
+
ISO_MAX_DEPTH = 8 # ISO 9660 6.8.2.1
|
|
143
|
+
LABEL_RE = re.compile(r"[A-Z0-9_]{1,32}")
|
|
144
|
+
_DCHARS_RE = re.compile(r"[^A-Z0-9_]")
|
|
145
|
+
_83_RE = re.compile(r"([A-Z0-9_]{1,8})(?:\.([A-Z0-9_]{0,3}))?")
|
|
146
|
+
_VMG_RE = re.compile(r"VIDEO_TS\.(IFO|VOB|BUP)")
|
|
147
|
+
_VTS_RE = re.compile(r"VTS_(\d\d)_(\d)\.(IFO|VOB|BUP)")
|
|
148
|
+
_CHUNK = 1 << 20
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ==============================================================================
|
|
152
|
+
# primitives
|
|
153
|
+
# ==============================================================================
|
|
154
|
+
|
|
155
|
+
def _crc_table():
|
|
156
|
+
table = []
|
|
157
|
+
for byte in range(256):
|
|
158
|
+
r = byte << 8
|
|
159
|
+
for _ in range(8):
|
|
160
|
+
r = ((r << 1) ^ 0x1021) if r & 0x8000 else (r << 1)
|
|
161
|
+
table.append(r & 0xFFFF)
|
|
162
|
+
return table
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
_CRC_TABLE = _crc_table()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def crc_itu_t(data):
|
|
169
|
+
"""CRC-ITU-T (polynomial x^16 + x^12 + x^5 + 1, initial 0, no final xor),
|
|
170
|
+
the descriptor CRC of ECMA-167 3/7.2.6."""
|
|
171
|
+
crc = 0
|
|
172
|
+
for b in data:
|
|
173
|
+
crc = ((crc << 8) & 0xFFFF) ^ _CRC_TABLE[((crc >> 8) ^ b) & 0xFF]
|
|
174
|
+
return crc
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def tag_checksum(tag):
|
|
178
|
+
"""ECMA-167 3/7.2.3: sum of tag bytes 0-3 and 5-15, modulo 256."""
|
|
179
|
+
return (sum(tag[0:4]) + sum(tag[5:16])) & 0xFF
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _tagged(buf, tag_id, location):
|
|
183
|
+
"""Fill in the 16-byte descriptor tag (ECMA-167 3/7.2) of `buf`, whose
|
|
184
|
+
length is the descriptor length; the CRC covers everything after the tag."""
|
|
185
|
+
struct.pack_into("<HHBBHHHI", buf, 0, tag_id, DESCRIPTOR_VERSION, 0, 0, 0,
|
|
186
|
+
crc_itu_t(buf[16:]), len(buf) - 16, location)
|
|
187
|
+
buf[4] = tag_checksum(buf)
|
|
188
|
+
return bytes(buf)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cs0(text):
|
|
192
|
+
"""OSTA CS0 d-characters (UDF 2.1.1): compression ID 8 + Latin-1 bytes when
|
|
193
|
+
every character fits, else 16 + UTF-16BE."""
|
|
194
|
+
if all(ord(c) < 256 for c in text):
|
|
195
|
+
return b"\x08" + text.encode("latin-1")
|
|
196
|
+
return b"\x10" + text.encode("utf-16-be", "surrogatepass")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def dstring(text, size):
|
|
200
|
+
"""Fixed-size dstring (ECMA-167 1/7.2.12): CS0 characters, zero padding,
|
|
201
|
+
used length in the last byte. Cut on a character boundary if too long."""
|
|
202
|
+
out = bytearray(size)
|
|
203
|
+
if not text:
|
|
204
|
+
return bytes(out)
|
|
205
|
+
data = cs0(text)
|
|
206
|
+
if len(data) > size - 1:
|
|
207
|
+
step = 1 if data[0] == 8 else 2
|
|
208
|
+
data = data[:1 + (size - 2) // step * step]
|
|
209
|
+
out[:len(data)] = data
|
|
210
|
+
out[-1] = len(data)
|
|
211
|
+
return bytes(out)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def charspec():
|
|
215
|
+
"""ECMA-167 1/7.2.1 charspec: type 0 (CS0) "OSTA Compressed Unicode"."""
|
|
216
|
+
return bytes(1) + OSTA_CHARSET.ljust(63, b"\0")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def entity_id(ident, suffix=b"", flags=0):
|
|
220
|
+
"""ECMA-167 1/7.4 regid: flags, 23-byte identifier, 8-byte suffix."""
|
|
221
|
+
return bytes([flags]) + ident.ljust(23, b"\0") + suffix.ljust(8, b"\0")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _domain_id():
|
|
225
|
+
return entity_id(DOMAIN_ID, struct.pack("<HB", UDF_REVISION, DOMAIN_FLAGS))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _impl_id():
|
|
229
|
+
return entity_id(IMPLEMENTATION_ID)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _local_time(date):
|
|
233
|
+
"""(naive local time, offset in minutes) for timestamps that carry a zone.
|
|
234
|
+
Naive datetimes are taken as UTC; offsets that are not whole quarter hours
|
|
235
|
+
(ISO 9660 cannot store them) are converted to UTC."""
|
|
236
|
+
if date.tzinfo is None or date.utcoffset() is None:
|
|
237
|
+
return date, 0
|
|
238
|
+
seconds = date.utcoffset().total_seconds()
|
|
239
|
+
if seconds % 900:
|
|
240
|
+
return date.astimezone(datetime.timezone.utc).replace(tzinfo=None), 0
|
|
241
|
+
return date.replace(tzinfo=None), int(seconds // 60)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def udf_timestamp(date):
|
|
245
|
+
"""ECMA-167 1/7.3 timestamp, type 1 (local time) with the zone offset in
|
|
246
|
+
minutes as a 12-bit two's complement number."""
|
|
247
|
+
t, offset = _local_time(date)
|
|
248
|
+
us = t.microsecond
|
|
249
|
+
return struct.pack("<HHBBBBBBBB", 0x1000 | (offset & 0x0FFF), t.year, t.month, t.day,
|
|
250
|
+
t.hour, t.minute, t.second, us // 10000, us // 100 % 100, us % 100)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def iso_dir_date(date):
|
|
254
|
+
"""ISO 9660 9.1.5 recording date: 7 bytes, offset in 15-minute units."""
|
|
255
|
+
t, offset = _local_time(date)
|
|
256
|
+
return struct.pack("<BBBBBBb", t.year - 1900, t.month, t.day, t.hour, t.minute,
|
|
257
|
+
t.second, offset // 15)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def iso_volume_date(date):
|
|
261
|
+
"""ISO 9660 8.4.26.1 date: 16 digits (hundredths last) + offset byte."""
|
|
262
|
+
t, offset = _local_time(date)
|
|
263
|
+
digits = "%04d%02d%02d%02d%02d%02d%02d" % (t.year, t.month, t.day, t.hour, t.minute,
|
|
264
|
+
t.second, t.microsecond // 10000)
|
|
265
|
+
return digits.encode("ascii") + struct.pack("<b", offset // 15)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
_ISO_NO_DATE = b"0" * 16 + b"\0"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _both16(v):
|
|
272
|
+
return struct.pack("<H", v) + struct.pack(">H", v)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _both32(v):
|
|
276
|
+
return struct.pack("<I", v) + struct.pack(">I", v)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _sectors(nbytes):
|
|
280
|
+
return (nbytes + SECTOR - 1) // SECTOR
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ==============================================================================
|
|
284
|
+
# UDF volume structures
|
|
285
|
+
# ==============================================================================
|
|
286
|
+
|
|
287
|
+
def volume_recognition(ident):
|
|
288
|
+
"""ECMA-167 2/9.1 volume structure descriptor (BEA01, NSR02, TEA01)."""
|
|
289
|
+
return b"\0" + ident + b"\x01" + bytes(SECTOR - 7)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def anchor_pointer(location, main_vds, reserve_vds):
|
|
293
|
+
"""ECMA-167 3/10.2 Anchor Volume Descriptor Pointer."""
|
|
294
|
+
buf = bytearray(512)
|
|
295
|
+
struct.pack_into("<IIII", buf, 16, VDS_SECTORS * SECTOR, main_vds,
|
|
296
|
+
VDS_SECTORS * SECTOR, reserve_vds)
|
|
297
|
+
return _tagged(buf, TAG_ANCHOR_POINTER, location)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def primary_volume(location, label, volume_set, date):
|
|
301
|
+
"""ECMA-167 3/10.1 Primary Volume Descriptor (UDF 2.2.2). libdvdread reads
|
|
302
|
+
the disc title from its Volume Identifier."""
|
|
303
|
+
buf = bytearray(512)
|
|
304
|
+
struct.pack_into("<II", buf, 16, 0, 0) # VDS number, PVD number
|
|
305
|
+
buf[24:56] = dstring(label, 32)
|
|
306
|
+
struct.pack_into("<HHHHII", buf, 56, 1, 1, 2, 2, 1, 1) # vol seq, max, interchange
|
|
307
|
+
buf[72:200] = dstring(volume_set, 128) # levels 2/2, charset lists 1/1
|
|
308
|
+
buf[200:264] = charspec()
|
|
309
|
+
buf[264:328] = charspec()
|
|
310
|
+
buf[376:388] = udf_timestamp(date)
|
|
311
|
+
buf[388:420] = _impl_id()
|
|
312
|
+
return _tagged(buf, TAG_PRIMARY_VOLUME, location)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def implementation_use_volume(location, label):
|
|
316
|
+
"""ECMA-167 3/10.4 + UDF 2.2.7: "*UDF LV Info" Implementation Use VD."""
|
|
317
|
+
buf = bytearray(512)
|
|
318
|
+
struct.pack_into("<I", buf, 16, 1)
|
|
319
|
+
buf[20:52] = entity_id(LV_INFO_ID, struct.pack("<H", UDF_REVISION))
|
|
320
|
+
buf[52:116] = charspec() # LVICharset
|
|
321
|
+
buf[116:244] = dstring(label, 128) # LogicalVolumeIdentifier
|
|
322
|
+
# LVInfo1..3 (3 x 36 bytes) stay empty
|
|
323
|
+
buf[352:384] = _impl_id()
|
|
324
|
+
return _tagged(buf, TAG_IMPLEMENTATION_USE, location)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def partition(location, start, length):
|
|
328
|
+
"""ECMA-167 3/10.5 Partition Descriptor (UDF 2.2.14): partition 0,
|
|
329
|
+
read-only, no space bitmaps."""
|
|
330
|
+
buf = bytearray(512)
|
|
331
|
+
struct.pack_into("<IHH", buf, 16, 2, PARTITION_FLAG_ALLOCATED, 0)
|
|
332
|
+
buf[24:56] = entity_id(PARTITION_CONTENTS_ID)
|
|
333
|
+
struct.pack_into("<III", buf, 184, ACCESS_READ_ONLY, start, length)
|
|
334
|
+
buf[196:228] = _impl_id()
|
|
335
|
+
return _tagged(buf, TAG_PARTITION, location)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def logical_volume(location, label, integrity_sector):
|
|
339
|
+
"""ECMA-167 3/10.6 Logical Volume Descriptor (UDF 2.2.4) with one type 1
|
|
340
|
+
partition map; the File Set Descriptor is at partition block 0."""
|
|
341
|
+
buf = bytearray(446)
|
|
342
|
+
struct.pack_into("<I", buf, 16, 3)
|
|
343
|
+
buf[20:84] = charspec()
|
|
344
|
+
buf[84:212] = dstring(label, 128)
|
|
345
|
+
struct.pack_into("<I", buf, 212, SECTOR) # logical block size
|
|
346
|
+
buf[216:248] = _domain_id()
|
|
347
|
+
# LogicalVolumeContentsUse: long_ad of the File Set Descriptor sequence
|
|
348
|
+
struct.pack_into("<IIH", buf, 248, 2 * SECTOR, 0, 0)
|
|
349
|
+
struct.pack_into("<II", buf, 264, 6, 1) # map table length, maps
|
|
350
|
+
buf[272:304] = _impl_id()
|
|
351
|
+
struct.pack_into("<II", buf, 432, LVIS_SECTORS * SECTOR, integrity_sector)
|
|
352
|
+
# type 1 partition map (3/10.7.2): type, length, volume seq 1, partition 0
|
|
353
|
+
struct.pack_into("<BBHH", buf, 440, 1, 6, 1, 0)
|
|
354
|
+
return _tagged(buf, TAG_LOGICAL_VOLUME, location)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def unallocated_space(location):
|
|
358
|
+
"""ECMA-167 3/10.8 Unallocated Space Descriptor: no free extents."""
|
|
359
|
+
buf = bytearray(24)
|
|
360
|
+
struct.pack_into("<II", buf, 16, 4, 0)
|
|
361
|
+
return _tagged(buf, TAG_UNALLOCATED_SPACE, location)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def terminating(location):
|
|
365
|
+
"""ECMA-167 3/10.9 Terminating Descriptor."""
|
|
366
|
+
return _tagged(bytearray(512), TAG_TERMINATING, location)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def logical_volume_integrity(location, date, next_unique_id, partition_length,
|
|
370
|
+
files, directories):
|
|
371
|
+
"""ECMA-167 3/10.10 Logical Volume Integrity Descriptor (UDF 2.2.6),
|
|
372
|
+
closed, with the UDF implementation use area (46 bytes)."""
|
|
373
|
+
buf = bytearray(88 + 46)
|
|
374
|
+
buf[16:28] = udf_timestamp(date)
|
|
375
|
+
struct.pack_into("<I", buf, 28, INTEGRITY_CLOSE)
|
|
376
|
+
# next integrity extent (32..39) = none; contents use: next unique id
|
|
377
|
+
struct.pack_into("<Q", buf, 40, next_unique_id)
|
|
378
|
+
struct.pack_into("<IIII", buf, 72, 1, 46, 0, partition_length) # free 0, size
|
|
379
|
+
buf[88:120] = _impl_id()
|
|
380
|
+
struct.pack_into("<IIHHH", buf, 120, files, directories,
|
|
381
|
+
UDF_REVISION, UDF_REVISION, UDF_REVISION)
|
|
382
|
+
return _tagged(buf, TAG_LOGICAL_VOLUME_INTEGRITY, location)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ==============================================================================
|
|
386
|
+
# UDF file structures (locations are partition block numbers)
|
|
387
|
+
# ==============================================================================
|
|
388
|
+
|
|
389
|
+
def file_set(block, label, root_block, date):
|
|
390
|
+
"""ECMA-167 4/14.1 File Set Descriptor (UDF 2.3.2)."""
|
|
391
|
+
buf = bytearray(512)
|
|
392
|
+
buf[16:28] = udf_timestamp(date)
|
|
393
|
+
struct.pack_into("<HHIIII", buf, 28, 3, 3, 1, 1, 0, 0)
|
|
394
|
+
buf[48:112] = charspec()
|
|
395
|
+
buf[112:240] = dstring(label, 128) # logical volume identifier
|
|
396
|
+
buf[240:304] = charspec()
|
|
397
|
+
buf[304:336] = dstring(label, 32) # file set identifier
|
|
398
|
+
struct.pack_into("<IIH", buf, 400, SECTOR, root_block, 0) # root ICB
|
|
399
|
+
buf[416:448] = _domain_id()
|
|
400
|
+
return _tagged(buf, TAG_FILE_SET, block)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def file_identifier(block, name, characteristics, icb_block, unique_id):
|
|
404
|
+
"""ECMA-167 4/14.4 File Identifier Descriptor, padded to 4 bytes. `name`
|
|
405
|
+
None is the parent entry. The ICB long_ad carries the UDF unique id
|
|
406
|
+
(UDF 2.3.4.3). `block` is the block where the descriptor starts."""
|
|
407
|
+
ident = cs0(name) if name is not None else b""
|
|
408
|
+
if len(ident) > 255:
|
|
409
|
+
raise UdfError(f"file name too long for UDF: {name!r}",
|
|
410
|
+
hint="UDF allows 254 Latin-1 or 127 other characters")
|
|
411
|
+
length = (38 + len(ident) + 3) & ~3
|
|
412
|
+
buf = bytearray(length)
|
|
413
|
+
struct.pack_into("<HBB", buf, 16, 1, characteristics, len(ident))
|
|
414
|
+
struct.pack_into("<IIHHIH", buf, 20, SECTOR, icb_block, 0, 0,
|
|
415
|
+
unique_id & 0xFFFFFFFF, 0)
|
|
416
|
+
buf[38:38 + len(ident)] = ident
|
|
417
|
+
return _tagged(buf, TAG_FILE_IDENTIFIER, block)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def file_entry(block, is_dir, size, data_block, unique_id, link_count, date):
|
|
421
|
+
"""ECMA-167 4/14.9 File Entry with short_ad allocation descriptors (one
|
|
422
|
+
per extent of at most MAX_EXTENT bytes; none for an empty file)."""
|
|
423
|
+
ads = bytearray()
|
|
424
|
+
remaining, pos = size, data_block
|
|
425
|
+
while remaining > 0:
|
|
426
|
+
chunk = min(remaining, MAX_EXTENT)
|
|
427
|
+
ads += struct.pack("<II", chunk, pos)
|
|
428
|
+
pos += chunk // SECTOR
|
|
429
|
+
remaining -= chunk
|
|
430
|
+
if 176 + len(ads) > SECTOR:
|
|
431
|
+
raise UdfError("file too large for one UDF File Entry")
|
|
432
|
+
buf = bytearray(176 + len(ads))
|
|
433
|
+
# ICB tag (4/14.6)
|
|
434
|
+
struct.pack_into("<IHHHBB", buf, 16, 0, ICB_STRATEGY, 0, 1, 0,
|
|
435
|
+
FILE_TYPE_DIRECTORY if is_dir else FILE_TYPE_FILE)
|
|
436
|
+
struct.pack_into("<H", buf, 34, ICB_FLAGS)
|
|
437
|
+
struct.pack_into("<IIIHBBI", buf, 36, UID_NONE, UID_NONE,
|
|
438
|
+
PERM_DIRECTORY if is_dir else PERM_FILE, link_count, 0, 0, 0)
|
|
439
|
+
struct.pack_into("<QQ", buf, 56, size, _sectors(size))
|
|
440
|
+
stamp = udf_timestamp(date)
|
|
441
|
+
buf[72:84] = buf[84:96] = buf[96:108] = stamp # access, modification, attribute
|
|
442
|
+
struct.pack_into("<I", buf, 108, 1) # checkpoint
|
|
443
|
+
buf[128:160] = _impl_id()
|
|
444
|
+
struct.pack_into("<QII", buf, 160, unique_id, 0, len(ads))
|
|
445
|
+
buf[176:] = ads
|
|
446
|
+
return _tagged(buf, TAG_FILE_ENTRY, block)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# ==============================================================================
|
|
450
|
+
# ISO 9660 structures
|
|
451
|
+
# ==============================================================================
|
|
452
|
+
|
|
453
|
+
def iso_directory_record(ident, sector, size, is_dir, date):
|
|
454
|
+
"""ISO 9660 9.1 directory record; `ident` is bytes (b"\\0" = ".",
|
|
455
|
+
b"\\1" = "..")."""
|
|
456
|
+
length = 33 + len(ident) + (0 if len(ident) % 2 else 1)
|
|
457
|
+
return (struct.pack("<BB", length, 0) + _both32(sector) + _both32(size)
|
|
458
|
+
+ iso_dir_date(date) + bytes([2 if is_dir else 0, 0, 0]) + _both16(1)
|
|
459
|
+
+ bytes([len(ident)]) + ident + bytes(length - 33 - len(ident)))
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def iso_primary_volume(label, volume_sectors, path_table_size, l_table, m_table,
|
|
463
|
+
root_record, date):
|
|
464
|
+
"""ISO 9660 8.4 Primary Volume Descriptor."""
|
|
465
|
+
buf = bytearray(SECTOR)
|
|
466
|
+
buf[0:7] = b"\x01CD001\x01"
|
|
467
|
+
buf[8:40] = b" " * 32 # system identifier
|
|
468
|
+
buf[40:72] = label.encode("ascii").ljust(32)
|
|
469
|
+
buf[80:88] = _both32(volume_sectors)
|
|
470
|
+
buf[120:124] = _both16(1) # volume set size
|
|
471
|
+
buf[124:128] = _both16(1) # volume sequence number
|
|
472
|
+
buf[128:132] = _both16(SECTOR)
|
|
473
|
+
buf[132:140] = _both32(path_table_size)
|
|
474
|
+
struct.pack_into("<II", buf, 140, l_table, 0)
|
|
475
|
+
struct.pack_into(">II", buf, 148, m_table, 0)
|
|
476
|
+
buf[156:190] = root_record
|
|
477
|
+
buf[190:702] = b" " * 512 # volume set .. preparer
|
|
478
|
+
buf[574:702] = b"UDFKIT".ljust(128) # application identifier
|
|
479
|
+
buf[702:813] = b" " * 111 # copyright, abstract, biblio
|
|
480
|
+
buf[813:830] = buf[830:847] = iso_volume_date(date)
|
|
481
|
+
buf[847:864] = buf[864:881] = _ISO_NO_DATE # expiration, effective
|
|
482
|
+
buf[881] = 1 # file structure version
|
|
483
|
+
return bytes(buf)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def iso_terminator():
|
|
487
|
+
"""ISO 9660 8.3 Volume Descriptor Set Terminator."""
|
|
488
|
+
return b"\xffCD001\x01" + bytes(SECTOR - 7)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def path_table_record(ident, sector, parent, big_endian):
|
|
492
|
+
"""ISO 9660 9.4 path table record."""
|
|
493
|
+
fmt = ">IH" if big_endian else "<IH"
|
|
494
|
+
rec = bytes([len(ident), 0]) + struct.pack(fmt, sector, parent) + ident
|
|
495
|
+
return rec + (b"\0" if len(ident) % 2 else b"")
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# ==============================================================================
|
|
499
|
+
# the file tree
|
|
500
|
+
# ==============================================================================
|
|
501
|
+
|
|
502
|
+
class _Node:
|
|
503
|
+
__slots__ = ("name", "src", "is_dir", "size", "children", "parent", "iso_name",
|
|
504
|
+
"fe_block", "dir_bytes", "unique_id", "sector", "iso_sector",
|
|
505
|
+
"iso_size", "number", "depth")
|
|
506
|
+
|
|
507
|
+
def __init__(self, name, src, is_dir, size=0, parent=None):
|
|
508
|
+
self.name, self.src, self.is_dir, self.size = name, src, is_dir, size
|
|
509
|
+
self.parent = parent
|
|
510
|
+
self.children = []
|
|
511
|
+
self.iso_name = None
|
|
512
|
+
self.fe_block = self.dir_bytes = self.unique_id = None
|
|
513
|
+
self.sector = self.iso_sector = self.iso_size = self.number = None
|
|
514
|
+
self.depth = 0 if parent is None else parent.depth + 1
|
|
515
|
+
|
|
516
|
+
def path(self):
|
|
517
|
+
parts, n = [], self
|
|
518
|
+
while n.parent is not None:
|
|
519
|
+
parts.append(n.name)
|
|
520
|
+
n = n.parent
|
|
521
|
+
return "/" + "/".join(reversed(parts))
|
|
522
|
+
|
|
523
|
+
def walk(self):
|
|
524
|
+
"""Pre-order: this node, then its children's subtrees in order."""
|
|
525
|
+
yield self
|
|
526
|
+
for c in self.children:
|
|
527
|
+
yield from c.walk()
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def validate_label(label):
|
|
531
|
+
if not isinstance(label, str) or not LABEL_RE.fullmatch(label):
|
|
532
|
+
raise UdfError(
|
|
533
|
+
f"invalid volume label {label!r}",
|
|
534
|
+
hint="DVD labels are 1-32 characters from A-Z, 0-9 and _")
|
|
535
|
+
return label
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def dvd_sort_key(name):
|
|
539
|
+
"""Order of the VIDEO_TS files on the disc: the VMG, then each title set
|
|
540
|
+
(IFO, menu VOB, title VOBs 1-9, BUP); anything else last, by name."""
|
|
541
|
+
m = _VMG_RE.fullmatch(name)
|
|
542
|
+
if m:
|
|
543
|
+
return (0, 0, "IVB".index(m.group(1)[0]), "")
|
|
544
|
+
m = _VTS_RE.fullmatch(name)
|
|
545
|
+
if m:
|
|
546
|
+
vts, part, ext = int(m.group(1)), int(m.group(2)), m.group(3)
|
|
547
|
+
if vts >= 1 and (ext == "VOB" or part == 0):
|
|
548
|
+
return (1, vts, {"IFO": 0, "BUP": 11}.get(ext, 1 + part), "")
|
|
549
|
+
return (2, 0, 0, name)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _scan_dir(node, path, skip, in_video_ts=False):
|
|
553
|
+
"""Add the entries of `path` to `node`, recursively."""
|
|
554
|
+
try:
|
|
555
|
+
entries = sorted(os.scandir(path), key=lambda e: e.name)
|
|
556
|
+
except OSError as e:
|
|
557
|
+
raise UdfError(f"cannot read {path}: {e.strerror}") from e
|
|
558
|
+
for e in entries:
|
|
559
|
+
if e.name.startswith(".") or os.path.realpath(e.path) == skip:
|
|
560
|
+
continue
|
|
561
|
+
if e.is_dir():
|
|
562
|
+
if in_video_ts:
|
|
563
|
+
raise UdfError(f"VIDEO_TS must contain only files, found folder {e.name!r}")
|
|
564
|
+
child = _Node(e.name, e.path, True, parent=node)
|
|
565
|
+
if child.depth >= ISO_MAX_DEPTH:
|
|
566
|
+
raise UdfError(
|
|
567
|
+
f"{child.path()} is nested too deeply for ISO 9660",
|
|
568
|
+
hint=f"ISO 9660 allows {ISO_MAX_DEPTH} directory levels including the root")
|
|
569
|
+
_scan_dir(child, e.path, skip,
|
|
570
|
+
in_video_ts=node.parent is None and e.name == "VIDEO_TS")
|
|
571
|
+
elif e.is_file():
|
|
572
|
+
size = e.stat().st_size
|
|
573
|
+
name = e.name.upper() if in_video_ts else e.name
|
|
574
|
+
child = _Node(name, e.path, False, size, parent=node)
|
|
575
|
+
if in_video_ts:
|
|
576
|
+
if not _83_RE.fullmatch(name):
|
|
577
|
+
raise UdfError(f"VIDEO_TS/{e.name} is not an 8.3 file name",
|
|
578
|
+
hint="DVD-Video files are named like VTS_01_1.VOB")
|
|
579
|
+
if size > DVD_FILE_LIMIT:
|
|
580
|
+
raise UdfError(
|
|
581
|
+
f"VIDEO_TS/{e.name} is {size} bytes; DVD-Video files must not "
|
|
582
|
+
f"exceed 1 GiB ({DVD_FILE_LIMIT} bytes)")
|
|
583
|
+
elif size > ISO_FILE_LIMIT:
|
|
584
|
+
raise UdfError(f"{child.path()} is too large for ISO 9660 (4 GiB limit)")
|
|
585
|
+
else:
|
|
586
|
+
continue
|
|
587
|
+
node.children.append(child)
|
|
588
|
+
if in_video_ts:
|
|
589
|
+
names = [c.name for c in node.children]
|
|
590
|
+
dup = {n for n in names if names.count(n) > 1}
|
|
591
|
+
if dup:
|
|
592
|
+
raise UdfError(f"VIDEO_TS has names differing only in case: {sorted(dup)}")
|
|
593
|
+
node.children.sort(key=lambda c: dvd_sort_key(c.name))
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def scan_tree(src_dir, skip=None):
|
|
597
|
+
"""Read `src_dir` into a _Node tree: VIDEO_TS, AUDIO_TS (empty if absent),
|
|
598
|
+
then the other entries by name."""
|
|
599
|
+
video = os.path.join(src_dir, "VIDEO_TS")
|
|
600
|
+
if not os.path.isdir(video):
|
|
601
|
+
raise UdfError(f"{src_dir} has no VIDEO_TS directory")
|
|
602
|
+
root = _Node("", src_dir, True)
|
|
603
|
+
_scan_dir(root, src_dir, skip)
|
|
604
|
+
by_name = {c.name: c for c in root.children}
|
|
605
|
+
vts = by_name["VIDEO_TS"]
|
|
606
|
+
ats = by_name.get("AUDIO_TS")
|
|
607
|
+
if ats is None or not ats.is_dir:
|
|
608
|
+
if ats is not None:
|
|
609
|
+
raise UdfError(f"{src_dir}/AUDIO_TS is not a directory")
|
|
610
|
+
ats = _Node("AUDIO_TS", None, True, parent=root)
|
|
611
|
+
rest = [c for c in root.children if c.name not in ("VIDEO_TS", "AUDIO_TS")]
|
|
612
|
+
root.children = [vts, ats] + rest
|
|
613
|
+
return root
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
# --- ISO 9660 names --------------------------------------------------------------
|
|
617
|
+
|
|
618
|
+
def _iso_parts(name, is_dir):
|
|
619
|
+
"""(base, ext) of the level-1 mapping of `name` (before collision fixes)."""
|
|
620
|
+
def clean(part):
|
|
621
|
+
part = unicodedata.normalize("NFKD", part).encode("ascii", "ignore").decode()
|
|
622
|
+
return _DCHARS_RE.sub("_", part.upper())
|
|
623
|
+
|
|
624
|
+
if is_dir:
|
|
625
|
+
base, ext = name, None
|
|
626
|
+
elif "." in name[1:]:
|
|
627
|
+
base, ext = name.rsplit(".", 1)
|
|
628
|
+
else:
|
|
629
|
+
base, ext = name, ""
|
|
630
|
+
base = clean(base)[:8] or "_"
|
|
631
|
+
if ext is not None:
|
|
632
|
+
ext = clean(ext)[:3]
|
|
633
|
+
return base, ext
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _iso_valid(name, is_dir):
|
|
637
|
+
if is_dir:
|
|
638
|
+
return re.fullmatch(r"[A-Z0-9_]{1,8}", name) is not None
|
|
639
|
+
m = _83_RE.fullmatch(name)
|
|
640
|
+
return m is not None and "." in name
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def assign_iso_names(directory):
|
|
644
|
+
"""Give each child of `directory` a unique ISO 9660 level-1 name. Names
|
|
645
|
+
that are already valid keep it; the others are mapped (see module doc)."""
|
|
646
|
+
taken = set()
|
|
647
|
+
valid = [c for c in directory.children if _iso_valid(c.name, c.is_dir)]
|
|
648
|
+
others = [c for c in directory.children if not _iso_valid(c.name, c.is_dir)]
|
|
649
|
+
for c in valid:
|
|
650
|
+
base, _, ext = c.name.partition(".")
|
|
651
|
+
c.iso_name = (base, ext if not c.is_dir else None)
|
|
652
|
+
taken.add(c.iso_name)
|
|
653
|
+
for c in others:
|
|
654
|
+
base, ext = _iso_parts(c.name, c.is_dir)
|
|
655
|
+
n = 0
|
|
656
|
+
while (base, ext) in taken:
|
|
657
|
+
n += 1
|
|
658
|
+
sfx = f"_{n}"
|
|
659
|
+
base = _iso_parts(c.name, c.is_dir)[0][:8 - len(sfx)] + sfx
|
|
660
|
+
c.iso_name = (base, ext)
|
|
661
|
+
taken.add(c.iso_name)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _iso_ident(node):
|
|
665
|
+
base, ext = node.iso_name
|
|
666
|
+
if node.is_dir:
|
|
667
|
+
return base.encode("ascii")
|
|
668
|
+
return f"{base}.{ext};1".encode("ascii")
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
# ==============================================================================
|
|
672
|
+
# VIDEO_TS placement from the IFO files
|
|
673
|
+
# ==============================================================================
|
|
674
|
+
|
|
675
|
+
def _read_ifo(path, magic):
|
|
676
|
+
"""The IFO header fields that place files (big-endian sector numbers), or
|
|
677
|
+
None if the file is not an IFO of that kind.
|
|
678
|
+
|
|
679
|
+
VMG (VIDEO_TS.IFO): vmg_last_sector 0x0C, vmgm_vobs 0xC0, and from the
|
|
680
|
+
title search pointer table (TT_SRPT, its sector at 0xC4) the start sector
|
|
681
|
+
of each title set. VTS (VTS_NN_0.IFO): vts_last_sector 0x0C, vtsm_vobs
|
|
682
|
+
0xC0, vtstt_vobs 0xC4. All relative to the start of the IFO.
|
|
683
|
+
"""
|
|
684
|
+
try:
|
|
685
|
+
with open(path, "rb") as f:
|
|
686
|
+
head = f.read(SECTOR)
|
|
687
|
+
if head[:12] != magic or len(head) < 0xC8:
|
|
688
|
+
return None
|
|
689
|
+
last, menu, c4 = (struct.unpack_from(">I", head, o)[0] for o in (0x0C, 0xC0, 0xC4))
|
|
690
|
+
if magic == b"DVDVIDEO-VTS":
|
|
691
|
+
return {"last_sector": last, "menu_vobs": menu, "title_vobs": c4}
|
|
692
|
+
# TT_SRPT: u16 title count, 6 bytes, then 12-byte entries with the
|
|
693
|
+
# title set number at +6 and its start sector at +8
|
|
694
|
+
f.seek(c4 * SECTOR)
|
|
695
|
+
tt = f.read(SECTOR)
|
|
696
|
+
starts = {}
|
|
697
|
+
count = struct.unpack_from(">H", tt, 0)[0] if len(tt) >= 8 else 0
|
|
698
|
+
for off in range(8, min(8 + 12 * count, len(tt) - 11), 12):
|
|
699
|
+
vts, start = tt[off + 6], struct.unpack_from(">I", tt, off + 8)[0]
|
|
700
|
+
if starts.setdefault(vts, start) != start:
|
|
701
|
+
raise UdfError(
|
|
702
|
+
f"VIDEO_TS.IFO gives two start sectors for title set {vts}")
|
|
703
|
+
return {"last_sector": last, "menu_vobs": menu, "vts_start": starts}
|
|
704
|
+
except OSError as e:
|
|
705
|
+
raise UdfError(f"cannot read {path}: {e.strerror}") from e
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def video_ts_layout(video_ts):
|
|
709
|
+
"""Sector offsets (relative to VIDEO_TS.IFO) for the files of VIDEO_TS, in
|
|
710
|
+
disc order. Returns ([(node, offset)], total sectors, ifo_layout_used)."""
|
|
711
|
+
files = {c.name: c for c in video_ts.children}
|
|
712
|
+
placed = []
|
|
713
|
+
pos = 0
|
|
714
|
+
used_ifo = False
|
|
715
|
+
|
|
716
|
+
def place(name, at=None, why=""):
|
|
717
|
+
nonlocal pos
|
|
718
|
+
node = files.pop(name, None)
|
|
719
|
+
if node is None:
|
|
720
|
+
return
|
|
721
|
+
if at is None:
|
|
722
|
+
at = pos
|
|
723
|
+
if at < pos:
|
|
724
|
+
raise UdfError(
|
|
725
|
+
f"VIDEO_TS/{name} must start at sector {at} of the DVD ({why}), "
|
|
726
|
+
f"but the files before it need {pos} sectors",
|
|
727
|
+
hint="the IFO files do not match the VOB sizes; author the disc again")
|
|
728
|
+
placed.append((node, at))
|
|
729
|
+
pos = at + _sectors(node.size)
|
|
730
|
+
|
|
731
|
+
def bup_at(last_sector, name):
|
|
732
|
+
"""The BUP ends at the last sector of its VMG / VTS."""
|
|
733
|
+
node = files.get(name)
|
|
734
|
+
return last_sector + 1 - _sectors(node.size) if node else None
|
|
735
|
+
|
|
736
|
+
vmg = _read_ifo(files["VIDEO_TS.IFO"].src, b"DVDVIDEO-VMG") if "VIDEO_TS.IFO" in files \
|
|
737
|
+
else None
|
|
738
|
+
place("VIDEO_TS.IFO", 0)
|
|
739
|
+
if vmg:
|
|
740
|
+
used_ifo = True
|
|
741
|
+
place("VIDEO_TS.VOB", vmg["menu_vobs"] or None, "vmgm_vobs in VIDEO_TS.IFO")
|
|
742
|
+
place("VIDEO_TS.BUP", bup_at(vmg["last_sector"], "VIDEO_TS.BUP"),
|
|
743
|
+
"vmg_last_sector in VIDEO_TS.IFO")
|
|
744
|
+
else:
|
|
745
|
+
place("VIDEO_TS.VOB")
|
|
746
|
+
place("VIDEO_TS.BUP")
|
|
747
|
+
title_sets = sorted({dvd_sort_key(n)[1] for n in files if dvd_sort_key(n)[0] == 1})
|
|
748
|
+
for vts in title_sets:
|
|
749
|
+
ifo_name = f"VTS_{vts:02d}_0.IFO"
|
|
750
|
+
start = vmg["vts_start"].get(vts) if vmg else None
|
|
751
|
+
if ifo_name not in files:
|
|
752
|
+
raise UdfError(f"VIDEO_TS has files of title set {vts} but no {ifo_name}")
|
|
753
|
+
place(ifo_name, start, f"title set {vts} start sector in VIDEO_TS.IFO")
|
|
754
|
+
base = placed[-1][1]
|
|
755
|
+
info = _read_ifo(placed[-1][0].src, b"DVDVIDEO-VTS")
|
|
756
|
+
if info:
|
|
757
|
+
used_ifo = True
|
|
758
|
+
menu = info["menu_vobs"]
|
|
759
|
+
place(f"VTS_{vts:02d}_0.VOB", base + menu if menu else None,
|
|
760
|
+
f"vtsm_vobs in {ifo_name}")
|
|
761
|
+
title = info["title_vobs"]
|
|
762
|
+
for part in range(1, 10):
|
|
763
|
+
at = base + title if part == 1 and title else None
|
|
764
|
+
place(f"VTS_{vts:02d}_{part}.VOB", at, f"vtstt_vobs in {ifo_name}")
|
|
765
|
+
bup = f"VTS_{vts:02d}_0.BUP"
|
|
766
|
+
last = bup_at(info["last_sector"], bup)
|
|
767
|
+
place(bup, base + last if last is not None else None,
|
|
768
|
+
f"vts_last_sector in {ifo_name}")
|
|
769
|
+
else:
|
|
770
|
+
for part in range(0, 10):
|
|
771
|
+
place(f"VTS_{vts:02d}_{part}.VOB")
|
|
772
|
+
place(f"VTS_{vts:02d}_0.BUP")
|
|
773
|
+
for node in video_ts.children: # anything else, in sort order
|
|
774
|
+
if node.name in files:
|
|
775
|
+
place(node.name)
|
|
776
|
+
return placed, pos, used_ifo
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
# ==============================================================================
|
|
780
|
+
# the writer
|
|
781
|
+
# ==============================================================================
|
|
782
|
+
|
|
783
|
+
def write_image(src_dir, out_path, label, *, date=None, on_progress=None, shift=0):
|
|
784
|
+
"""Write a UDF 1.02 + ISO 9660 DVD-Video image of `src_dir` to `out_path`.
|
|
785
|
+
|
|
786
|
+
`src_dir` holds VIDEO_TS (required), AUDIO_TS (created empty in the image
|
|
787
|
+
if missing) and optionally DVD-ROM files and folders. `date` (a datetime)
|
|
788
|
+
stamps every structure; it defaults to now (UTC). `on_progress(fraction)`
|
|
789
|
+
is called as data is written. `shift` empty sectors go before VIDEO_TS
|
|
790
|
+
(to put a dual-layer disc's layer break on an ECC block). Returns
|
|
791
|
+
statistics: sectors, bytes, files, directories, the UDF volume name,
|
|
792
|
+
whether the IFOs placed the VIDEO_TS files, and {"/path": (first sector,
|
|
793
|
+
bytes)} for every file.
|
|
794
|
+
"""
|
|
795
|
+
validate_label(label)
|
|
796
|
+
if date is None:
|
|
797
|
+
date = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
|
|
798
|
+
if not 1970 <= date.year <= 2155:
|
|
799
|
+
raise UdfError(f"date {date} is out of range for ISO 9660 (1970-2155)")
|
|
800
|
+
out_path = os.path.abspath(out_path)
|
|
801
|
+
root = scan_tree(src_dir, skip=os.path.realpath(out_path))
|
|
802
|
+
layout = _plan(root, date, shift)
|
|
803
|
+
try:
|
|
804
|
+
_write(root, layout, out_path, label, date, on_progress)
|
|
805
|
+
except BaseException:
|
|
806
|
+
try:
|
|
807
|
+
os.remove(out_path)
|
|
808
|
+
except OSError:
|
|
809
|
+
pass
|
|
810
|
+
raise
|
|
811
|
+
files = [n for n in root.walk() if not n.is_dir]
|
|
812
|
+
return {
|
|
813
|
+
"path": out_path,
|
|
814
|
+
"sectors": layout["total"],
|
|
815
|
+
"bytes": layout["total"] * SECTOR,
|
|
816
|
+
"files": len(files),
|
|
817
|
+
"directories": sum(1 for n in root.walk() if n.is_dir),
|
|
818
|
+
"label": label,
|
|
819
|
+
"udf_volume": dstring_text(dstring(label, 32)),
|
|
820
|
+
"ifo_layout": layout["ifo_layout"],
|
|
821
|
+
"partition_start": PARTITION_START,
|
|
822
|
+
"data_start": layout["data_start"],
|
|
823
|
+
"extents": {n.path(): (n.sector, n.size) for n in files},
|
|
824
|
+
"iso_names": {n.path(): _iso_path(n) for n in root.walk() if n.parent is not None},
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def plan_image(src_dir):
|
|
829
|
+
"""Where an image of `src_dir` would put VIDEO_TS: {"video_ts": sector of
|
|
830
|
+
VIDEO_TS.IFO, "total": sectors} (nothing is written)."""
|
|
831
|
+
root = scan_tree(src_dir)
|
|
832
|
+
layout = _plan(root, datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc))
|
|
833
|
+
return {"video_ts": layout["data_start"], "total": layout["total"]}
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def dstring_text(raw):
|
|
837
|
+
"""Decode a dstring back to text (for reporting)."""
|
|
838
|
+
length = raw[-1]
|
|
839
|
+
if not length:
|
|
840
|
+
return ""
|
|
841
|
+
body = raw[1:length]
|
|
842
|
+
return body.decode("latin-1") if raw[0] == 8 else body.decode("utf-16-be", "replace")
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _iso_path(node):
|
|
846
|
+
parts = []
|
|
847
|
+
while node.parent is not None:
|
|
848
|
+
parts.append(_iso_ident(node).decode("ascii"))
|
|
849
|
+
node = node.parent
|
|
850
|
+
return "/" + "/".join(reversed(parts))
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _plan(root, date, shift=0):
|
|
854
|
+
"""Assign every structure its place. Returns a dict with the sector map."""
|
|
855
|
+
dirs = [n for n in root.walk() if n.is_dir]
|
|
856
|
+
files = [n for n in root.walk() if not n.is_dir]
|
|
857
|
+
|
|
858
|
+
# UDF directories: File Entry followed by the File Identifier Descriptors
|
|
859
|
+
block = 2 # after the FSD and its terminator
|
|
860
|
+
uid = FIRST_UNIQUE_ID
|
|
861
|
+
for d in dirs:
|
|
862
|
+
if d is root:
|
|
863
|
+
d.unique_id = 0 # UDF 3.2.1.1: the root has 0
|
|
864
|
+
else:
|
|
865
|
+
d.unique_id, uid = uid, uid + 1
|
|
866
|
+
d.dir_bytes = len(file_identifier(0, None, 0, 0, 0)) + sum(
|
|
867
|
+
len(file_identifier(0, c.name, 0, 0, 0)) for c in d.children)
|
|
868
|
+
d.fe_block = block
|
|
869
|
+
block += 1 + _sectors(d.dir_bytes)
|
|
870
|
+
for f in files:
|
|
871
|
+
f.unique_id = uid
|
|
872
|
+
uid += 1
|
|
873
|
+
f.fe_block = block
|
|
874
|
+
block += 1
|
|
875
|
+
sector = PARTITION_START + block
|
|
876
|
+
|
|
877
|
+
# ISO 9660: names, path table (level, parent number, name), directories
|
|
878
|
+
for d in dirs:
|
|
879
|
+
assign_iso_names(d)
|
|
880
|
+
# breadth first, each parent's subdirectories by name: ISO 9660 6.9.1
|
|
881
|
+
# orders the path table by level, parent number, then name
|
|
882
|
+
ordered = [root]
|
|
883
|
+
i = 0
|
|
884
|
+
while i < len(ordered):
|
|
885
|
+
d = ordered[i]
|
|
886
|
+
d.number = i + 1
|
|
887
|
+
ordered.extend(sorted((c for c in d.children if c.is_dir), key=lambda c: c.iso_name[0]))
|
|
888
|
+
i += 1
|
|
889
|
+
path_table_size = sum(len(path_table_record(_pt_ident(d), 0, 0, False)) for d in ordered)
|
|
890
|
+
path_blocks = _sectors(path_table_size)
|
|
891
|
+
path_blocks += path_blocks & 1 # even, as genisoimage
|
|
892
|
+
l_table, m_table = sector, sector + path_blocks
|
|
893
|
+
sector += 2 * path_blocks
|
|
894
|
+
for d in ordered:
|
|
895
|
+
d.iso_size = _iso_dir_size(d, date)
|
|
896
|
+
d.iso_sector = sector
|
|
897
|
+
sector += d.iso_size // SECTOR
|
|
898
|
+
|
|
899
|
+
# file data: VIDEO_TS by the IFOs, then everything else in tree order
|
|
900
|
+
data_start = sector
|
|
901
|
+
video_ts = root.children[0]
|
|
902
|
+
placed, vts_len, ifo_layout = video_ts_layout(video_ts)
|
|
903
|
+
data = [(n, data_start + shift + off) for n, off in placed]
|
|
904
|
+
sector = data_start + shift + vts_len
|
|
905
|
+
for f in files:
|
|
906
|
+
if f.parent is video_ts:
|
|
907
|
+
continue
|
|
908
|
+
data.append((f, sector))
|
|
909
|
+
sector += _sectors(f.size)
|
|
910
|
+
for n, s in data:
|
|
911
|
+
n.sector = s
|
|
912
|
+
end = sector
|
|
913
|
+
return {"dirs": dirs, "files": files, "ordered": ordered, "data": data,
|
|
914
|
+
"path_table_size": path_table_size, "l_table": l_table, "m_table": m_table,
|
|
915
|
+
"data_start": data_start, "end": end, "total": end + TAIL_SECTORS,
|
|
916
|
+
"next_unique_id": uid, "ifo_layout": ifo_layout}
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _pt_ident(d):
|
|
920
|
+
return b"\0" if d.parent is None else _iso_ident(d)
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def _iso_children(d):
|
|
924
|
+
"""Directory records in ISO 9660 9.3 order: by name, then extension."""
|
|
925
|
+
return sorted(d.children, key=lambda c: (c.iso_name[0], c.iso_name[1] or ""))
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _iso_records(d, date):
|
|
929
|
+
"""The records of directory `d` (sizes/sectors filled in when known)."""
|
|
930
|
+
parent = d.parent or d
|
|
931
|
+
recs = [iso_directory_record(b"\0", d.iso_sector or 0, d.iso_size or 0, True, date),
|
|
932
|
+
iso_directory_record(b"\1", parent.iso_sector or 0, parent.iso_size or 0, True,
|
|
933
|
+
date)]
|
|
934
|
+
for c in _iso_children(d):
|
|
935
|
+
if c.is_dir:
|
|
936
|
+
recs.append(iso_directory_record(_iso_ident(c), c.iso_sector or 0,
|
|
937
|
+
c.iso_size or 0, True, date))
|
|
938
|
+
else:
|
|
939
|
+
recs.append(iso_directory_record(_iso_ident(c), c.sector or 0, c.size, False,
|
|
940
|
+
date))
|
|
941
|
+
return recs
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _pack_records(recs):
|
|
945
|
+
"""Lay records out in sectors; a record never crosses a sector boundary."""
|
|
946
|
+
out = bytearray()
|
|
947
|
+
for r in recs:
|
|
948
|
+
room = SECTOR - len(out) % SECTOR
|
|
949
|
+
if len(r) > room:
|
|
950
|
+
out += bytes(room)
|
|
951
|
+
out += r
|
|
952
|
+
out += bytes(-len(out) % SECTOR)
|
|
953
|
+
return bytes(out)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _iso_dir_size(d, date):
|
|
957
|
+
return len(_pack_records(_iso_records(d, date)))
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _write(root, layout, out_path, label, date, on_progress):
|
|
961
|
+
total = layout["total"]
|
|
962
|
+
data_start = layout["data_start"]
|
|
963
|
+
meta = bytearray(data_start * SECTOR)
|
|
964
|
+
|
|
965
|
+
def put(sector, data):
|
|
966
|
+
meta[sector * SECTOR:sector * SECTOR + len(data)] = data
|
|
967
|
+
|
|
968
|
+
p_start = PARTITION_START
|
|
969
|
+
p_length = layout["end"] - p_start
|
|
970
|
+
|
|
971
|
+
# ISO 9660 volume descriptors
|
|
972
|
+
root_record = iso_directory_record(b"\0", root.iso_sector, root.iso_size, True, date)
|
|
973
|
+
put(ISO_PVD_SECTOR, iso_primary_volume(label, total, layout["path_table_size"],
|
|
974
|
+
layout["l_table"], layout["m_table"],
|
|
975
|
+
root_record, date))
|
|
976
|
+
put(ISO_TERMINATOR_SECTOR, iso_terminator())
|
|
977
|
+
for i, ident in enumerate((b"BEA01", b"NSR02", b"TEA01")):
|
|
978
|
+
put(VRS_SECTOR + i, volume_recognition(ident))
|
|
979
|
+
|
|
980
|
+
# UDF volume descriptor sequences (main and reserve), integrity, anchor
|
|
981
|
+
# UDF 2.2.2.5: the volume set identifier starts with 16 "unique" hex
|
|
982
|
+
# digits; derive them from the date and label so output is reproducible
|
|
983
|
+
utc = date if date.tzinfo else date.replace(tzinfo=datetime.timezone.utc)
|
|
984
|
+
volume_set = "%08X%08X" % (int(utc.timestamp()) & 0xFFFFFFFF,
|
|
985
|
+
zlib.crc32(label.encode("ascii")))
|
|
986
|
+
for base in (MAIN_VDS_SECTOR, RESERVE_VDS_SECTOR):
|
|
987
|
+
put(base + 0, primary_volume(base + 0, label, volume_set, date))
|
|
988
|
+
put(base + 1, implementation_use_volume(base + 1, label))
|
|
989
|
+
put(base + 2, partition(base + 2, p_start, p_length))
|
|
990
|
+
put(base + 3, logical_volume(base + 3, label, LVIS_SECTOR))
|
|
991
|
+
put(base + 4, unallocated_space(base + 4))
|
|
992
|
+
put(base + 5, terminating(base + 5))
|
|
993
|
+
put(LVIS_SECTOR, logical_volume_integrity(
|
|
994
|
+
LVIS_SECTOR, date, layout["next_unique_id"], p_length,
|
|
995
|
+
len(layout["files"]), len(layout["dirs"])))
|
|
996
|
+
put(LVIS_SECTOR + 1, terminating(LVIS_SECTOR + 1))
|
|
997
|
+
put(ANCHOR_SECTOR, anchor_pointer(ANCHOR_SECTOR, MAIN_VDS_SECTOR, RESERVE_VDS_SECTOR))
|
|
998
|
+
|
|
999
|
+
# UDF partition: file set, directories, file entries
|
|
1000
|
+
put(p_start + 0, file_set(0, label, root.fe_block, date))
|
|
1001
|
+
put(p_start + 1, terminating(1))
|
|
1002
|
+
for d in layout["dirs"]:
|
|
1003
|
+
links = 1 + sum(1 for c in d.children if c.is_dir)
|
|
1004
|
+
put(p_start + d.fe_block, file_entry(d.fe_block, True, d.dir_bytes, d.fe_block + 1,
|
|
1005
|
+
d.unique_id, links, date))
|
|
1006
|
+
first = d.fe_block + 1
|
|
1007
|
+
parent = d.parent or d
|
|
1008
|
+
fids = bytearray(file_identifier(first, None, FID_DIRECTORY | FID_PARENT,
|
|
1009
|
+
parent.fe_block, parent.unique_id))
|
|
1010
|
+
for c in d.children:
|
|
1011
|
+
fids += file_identifier(first + len(fids) // SECTOR, c.name,
|
|
1012
|
+
FID_DIRECTORY if c.is_dir else 0, c.fe_block, c.unique_id)
|
|
1013
|
+
put(p_start + first, fids)
|
|
1014
|
+
for f in layout["files"]:
|
|
1015
|
+
data_block = f.sector - p_start if f.size else 0
|
|
1016
|
+
put(p_start + f.fe_block, file_entry(f.fe_block, False, f.size, data_block,
|
|
1017
|
+
f.unique_id, 1, date))
|
|
1018
|
+
|
|
1019
|
+
# ISO 9660 path tables and directories
|
|
1020
|
+
for big, sector in ((False, layout["l_table"]), (True, layout["m_table"])):
|
|
1021
|
+
table = b"".join(path_table_record(_pt_ident(d), d.iso_sector,
|
|
1022
|
+
(d.parent or d).number, big)
|
|
1023
|
+
for d in layout["ordered"])
|
|
1024
|
+
put(sector, table)
|
|
1025
|
+
for d in layout["ordered"]:
|
|
1026
|
+
put(d.iso_sector, _pack_records(_iso_records(d, date)))
|
|
1027
|
+
|
|
1028
|
+
# stream everything to the file
|
|
1029
|
+
done = 0
|
|
1030
|
+
total_bytes = total * SECTOR
|
|
1031
|
+
|
|
1032
|
+
def progress(n):
|
|
1033
|
+
nonlocal done
|
|
1034
|
+
done += n
|
|
1035
|
+
if on_progress:
|
|
1036
|
+
on_progress(min(1.0, done / total_bytes))
|
|
1037
|
+
|
|
1038
|
+
if on_progress:
|
|
1039
|
+
on_progress(0.0)
|
|
1040
|
+
zeros = bytes(_CHUNK)
|
|
1041
|
+
with open(out_path, "wb") as out:
|
|
1042
|
+
out.write(meta)
|
|
1043
|
+
progress(len(meta))
|
|
1044
|
+
pos = data_start
|
|
1045
|
+
for node, sector in layout["data"]:
|
|
1046
|
+
_write_zeros(out, (sector - pos) * SECTOR, zeros)
|
|
1047
|
+
progress((sector - pos) * SECTOR)
|
|
1048
|
+
_copy_file(node, out, progress)
|
|
1049
|
+
pad = -node.size % SECTOR
|
|
1050
|
+
out.write(zeros[:pad])
|
|
1051
|
+
pos = sector + _sectors(node.size)
|
|
1052
|
+
_write_zeros(out, (layout["end"] - pos) * SECTOR, zeros)
|
|
1053
|
+
progress((layout["end"] - pos) * SECTOR)
|
|
1054
|
+
for s in range(layout["end"], total):
|
|
1055
|
+
out.write(anchor_pointer(s, MAIN_VDS_SECTOR, RESERVE_VDS_SECTOR).ljust(SECTOR, b"\0"))
|
|
1056
|
+
progress((total - layout["end"]) * SECTOR)
|
|
1057
|
+
if out.tell() != total_bytes:
|
|
1058
|
+
raise UdfError(f"internal error: wrote {out.tell()} bytes, "
|
|
1059
|
+
f"expected {total_bytes}")
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _write_zeros(out, n, zeros):
|
|
1063
|
+
while n > 0:
|
|
1064
|
+
k = min(n, len(zeros))
|
|
1065
|
+
out.write(zeros[:k])
|
|
1066
|
+
n -= k
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _copy_file(node, out, progress):
|
|
1070
|
+
"""Copy a file's bytes, checking it did not change size since the scan."""
|
|
1071
|
+
remaining = node.size
|
|
1072
|
+
try:
|
|
1073
|
+
with open(node.src, "rb") as f:
|
|
1074
|
+
while remaining > 0:
|
|
1075
|
+
chunk = f.read(min(_CHUNK, remaining))
|
|
1076
|
+
if not chunk:
|
|
1077
|
+
break
|
|
1078
|
+
out.write(chunk)
|
|
1079
|
+
remaining -= len(chunk)
|
|
1080
|
+
progress(len(chunk))
|
|
1081
|
+
extra = f.read(1)
|
|
1082
|
+
except OSError as e:
|
|
1083
|
+
raise UdfError(f"cannot read {node.src}: {e.strerror}") from e
|
|
1084
|
+
if remaining or extra:
|
|
1085
|
+
raise UdfError(f"{node.src} changed size while the image was written")
|
|
1086
|
+
progress(-node.size % SECTOR)
|