]> git.phdru.name Git - extfs.d.git/blob - torrent
Feat(torrent): Try harder to guess encoding
[extfs.d.git] / torrent
1 #! /usr/bin/env python3
2 """Torrent Virtual FileSystem for Midnight Commander
3
4 The script requires Midnight Commander 3.1+
5 (http://www.midnight-commander.org/), Python 2.4+ (http://www.python.org/),
6 module eff_bdecode.py (http://effbot.org/zone/bencode.htm).
7
8 For mc 4.7+ just put the script in $HOME/[.local/share/].mc/extfs.d.
9 For older versions put it in /usr/[local/][lib|share]/mc/extfs
10 and add a line "torrent" to the /usr/[local/][lib|share]/mc/extfs/extfs.ini.
11 Make the script executable.
12
13 For mc 4.7+ run this "cd" command in the Midnight Commander (in the "bindings"
14 file the command is "%cd"): cd file/torrent://; In older versions it is
15 cd file#torrent, where "file" is the name of your torrent metafile.
16
17 See detailed installation instructions at
18 https://phdru.name/Software/mc/torrent_INSTALL.html.
19
20 The VFS lists all files and directories from the torrent metafile; all files
21 appear empty, of course, but the sizes are shown. Filenames are reencoded from
22 the metafile's encoding/codepage to the current locale.
23
24 Along with the files/directories in the torrent metafile the VFS also presents
25 meta information - in the form of files in .META directory. The size and
26 contents of these files are taken from the corresponding fields in the torrent
27 metafile. The script doesn't check if the torrent consists of a .META file or
28 directory (quite unlikely).
29
30 Date/time for all directories/files is set to the value of 'creation date'
31 field, if it exists; if not date/time is set to the last modification time of
32 the torrent file itself.
33
34 The filesystem is, naturally, read-only.
35
36 """
37
38 __version__ = "1.3.1"
39 __author__ = "Oleg Broytman <phd@phdru.name>"
40 __copyright__ = "Copyright (C) 2010-2023 PhiloSoft Design"
41 __license__ = "GPL"
42
43
44 from datetime import datetime
45 from os.path import dirname, getmtime
46 import sys
47 from time import localtime, asctime
48 from eff_bdecode import decode
49
50 try:
51     import locale
52     use_locale = True
53 except ImportError:
54     use_locale = False
55
56 if use_locale:
57     # Get the default charset.
58     try:
59         if sys.version_info[:2] < (3, 11):
60             lcAll = locale.getdefaultlocale()
61         else:
62             lcAll = []
63     except locale.Error as err:
64         #print("WARNING:", err, file=sys.stderr)
65         lcAll = []
66
67     if len(lcAll) == 2:
68         default_encoding = lcAll[1]
69     else:
70         try:
71             default_encoding = locale.getpreferredencoding()
72         except locale.Error as err:
73             #print("WARNING:", err, file=sys.stderr)
74             default_encoding = sys.getdefaultencoding()
75 else:
76     default_encoding = sys.getdefaultencoding()
77
78 import logging
79 logger = logging.getLogger('torrent-mcextfs')
80 log_err_handler = logging.StreamHandler(sys.stderr)
81 logger.addHandler(log_err_handler)
82 logger.setLevel(logging.INFO)
83
84 if len(sys.argv) < 3:
85     logger.critical("""\
86 Torrent Virtual FileSystem for Midnight Commander version %s
87 Author: %s
88 %s
89
90 This is not a program. Put the script in $HOME/[.local/share/].mc/extfs.d or
91 /usr/[local/][lib|share]/mc/extfs. For more information read the source!""",
92                     __version__, __author__, __copyright__)
93     sys.exit(1)
94
95 locale.setlocale(locale.LC_ALL, '')
96
97 PY3 = (sys.version_info[0] >= 3)
98 if PY3:
99     def output(s):
100         sys.stdout.buffer.write(s.encode(default_encoding, 'replace') + b'\n')
101 else:
102     def output(s):
103         sys.stdout.write(s + '\n')
104
105
106 def mctorrent_list():
107     """List the entire VFS"""
108
109     info = torrent['info']
110     if 'name' not in info and 'name.utf-8' not in info:
111         torrent_error('Unknown name')
112
113     codepage = torrent.get('codepage', None)
114     encoding = torrent.get('encoding', None)
115     if not encoding and codepage:
116         encoding = str(codepage)
117
118     name = info['name']
119     name_utf8 = info.get('name.utf-8', None)
120
121     dt = None
122     if 'files' in info:
123         files = info['files']
124         paths = []
125         for file in files:
126             if 'path' not in file and 'path.utf-8' not in file:
127                 torrent_error('Unknown path')
128             if 'length' not in file:
129                 torrent_error('Unknown length')
130             if 'path.utf-8' in file:
131                 if name_utf8:
132                     path = '/'.join([name_utf8] + file['path.utf-8'])
133                 else:
134                     path = '/'.join([name] + file['path.utf-8'])
135             else:
136                 if name_utf8:
137                     path = '/'.join([name_utf8] + path)
138                 else:
139                     path = '/'.join([name] + file['path'])
140             length = file['length']
141             paths.append((path, length))
142     else:  # One-file torrent
143         if 'length' not in info:
144             torrent_error('Unknown length')
145         length = info['length']
146         if name_utf8:
147             name = name_utf8
148         paths = [(name, length)]
149
150     meta = []
151     for name in 'announce', 'announce-list', 'codepage', 'comment', \
152                 'created by', 'creation date', 'encoding', \
153                 'nodes', 'publisher', 'publisher-url':
154         if name == 'comment' and 'comment.utf-8' in torrent:
155             data = torrent['comment.utf-8']
156             meta.append(('.META/' + name, len(data)))
157         elif name in torrent:
158             if name == 'announce-list':
159                 data = decode_announce_list(torrent[name])
160             elif name == 'codepage':
161                 data = str(torrent[name])
162             elif name == 'creation date':
163                 dt = torrent[name]
164                 data = decode_datetime_asc(dt)
165                 dt = decode_datetime(dt)
166             elif name == 'nodes':
167                 data = ['%s:%s' % (host, port) for host, port in torrent[name]]
168                 data = '\n'.join(data)
169             else:
170                 data = torrent[name]
171             meta.append(('.META/' + name, len(data)))
172
173     if 'private' in info:
174         meta.append(('.META/private', 1))
175
176     if 'piece length' in info:
177         meta.append(('.META/piece length', len(str(info['piece length']))))
178
179     paths += meta
180     dirs = set()
181     for name, size in paths:
182         if '/' in name:
183             dirs.add(dirname(name))
184
185     if not dt:
186         dt = decode_datetime(getmtime(sys.argv[2]))
187
188     for name in sorted(dirs):
189         output("dr-xr-xr-x 1 user group 0 %s %s" % (dt, name))
190
191     for name, size in sorted(paths):
192         output("-r--r--r-- 1 user group %d %s %s" % (size, dt, name))
193
194
195 def mctorrent_copyout():
196     """Extract a file from the VFS"""
197
198     torrent_filename = sys.argv[3]
199     real_filename = sys.argv[4]
200     data = None
201
202     for name in 'announce', 'announce-list', 'codepage', 'comment', \
203                 'created by', 'creation date', 'encoding', \
204                 'nodes', 'publisher', 'publisher-url':
205         if name == 'comment' and 'comment.utf-8' in torrent:
206             data = torrent['comment.utf-8']
207         elif torrent_filename == '.META/' + name:
208             if name in torrent:
209                 if name == 'announce-list':
210                     data = decode_announce_list(torrent[name])
211                 elif name == 'codepage':
212                     data = str(torrent[name])
213                 elif name == 'creation date':
214                     data = decode_datetime_asc(torrent[name])
215                 elif name == 'nodes':
216                     data = ['%s:%s' % (host, port)
217                             for host, port in torrent[name]]
218                     data = '\n'.join(data)
219                 else:
220                     data = str(torrent[name])
221             else:
222                 torrent_error('Unknown ' + name)
223             break
224
225     if torrent_filename in ('.META/private', '.META/piece length'):
226         info = torrent['info']
227         if torrent_filename == '.META/private':
228             if 'private' not in info:
229                 torrent_error('Private absent')
230         if torrent_filename == '.META/piece length':
231             if 'piece length' not in info:
232                 torrent_error('Piece length absent')
233         data = str(info[torrent_filename[len('.META/'):]])
234
235     if not torrent_filename.startswith('.META/'):
236         data = ''
237
238     if data is None:
239         torrent_error('Unknown file name')
240     else:
241         outfile = open(real_filename, 'wt')
242         outfile.write(data)
243         outfile.close()
244
245
246 def mctorrent_copyin():
247     """Put a file to the VFS"""
248     sys.exit("Torrent VFS doesn't support adding/overwriting files "
249              "(read-only filesystem)")
250
251
252 def mctorrent_rm():
253     """Remove a file from the VFS"""
254     sys.exit("Torrent VFS doesn't support removing files/directories "
255              "(read-only filesystem)")
256
257
258 mctorrent_rmdir = mctorrent_rm
259
260
261 def mctorrent_mkdir():
262     """Create a directory in the VFS"""
263     sys.exit("Torrent VFS doesn't support creating directories "
264              "(read-only filesystem)")
265
266
267 def torrent_error(error_str):
268     logger.critical("Error parsing the torrent metafile: %s", error_str)
269     sys.exit(1)
270
271
272 def decode_dict(d, encoding):
273     new_d = {}
274     for k in d:
275         v = d[k]
276         k = k.decode(encoding)
277         if isinstance(v, dict):
278             v = decode_dict(v, encoding)
279         elif isinstance(v, list):
280             v = decode_list(v, encoding)
281         elif isinstance(v, bytes):
282             v = v.decode(encoding)
283         new_d[k] = v
284     return new_d
285
286
287 def decode_list(l, encoding):
288     new_l = []
289     for v in l:
290         if isinstance(v, dict):
291             v = decode_dict(v, encoding)
292         elif isinstance(v, list):
293             v = decode_list(v, encoding)
294         elif isinstance(v, bytes):
295             v = v.decode(encoding)
296         new_l.append(v)
297     return new_l
298
299
300 def decode_torrent():
301     try:
302         torrent_file = open(sys.argv[2], 'rb')
303         data = torrent_file.read()
304         torrent_file.close()
305         torrent = decode(data)
306     except IOError as error_str:
307         torrent_error(error_str)
308
309     del torrent[b'info'][b'pieces']
310     if b'info' not in torrent:
311         torrent_error('Info absent')
312
313     if PY3:
314         codepage = torrent.get(b'codepage', None)
315         encoding = torrent.get(b'encoding', None)
316         if encoding:
317             encoding = encoding.decode('ascii')
318         elif codepage:
319             encoding = codepage.decode('ascii')
320         else:
321             for encoding in ('ascii', 'utf-8', default_encoding):
322                 try:
323                     return decode_dict(torrent, encoding)
324                 except UnicodeDecodeError:
325                     pass
326             torrent_error('UnicodeDecodeError')
327         return decode_dict(torrent, encoding)
328
329     return torrent
330
331
332 def decode_datetime_asc(dt):
333     try:
334         return asctime(localtime(float(dt)))
335     except ValueError:
336         return datetime.max.ctime()
337
338
339 def decode_datetime(dt):
340     try:
341         Y, m, d, H, M = localtime(float(dt))[0:5]
342     except ValueError:
343         return datetime.max.ctime()
344     else:
345         return "%02d-%02d-%d %02d:%02d" % (m, d, Y, H, M)
346
347
348 def decode_announce_list(announce):
349     return '\n'.join(a[0] for a in announce if a)
350
351
352 command = sys.argv[1]
353 procname = "mctorrent_" + command
354
355 g = globals()
356 if procname not in g:
357     logger.critical("Unknown command %s", command)
358     sys.exit(1)
359
360 torrent = decode_torrent()
361
362 try:
363     g[procname]()
364 except SystemExit:
365     raise
366 except Exception:
367     logger.exception("Error during run")