toMKV.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. #!/usr/bin/env python3
  2. import sys, re, os, subprocess, pathlib, xattr, langcodes
  3. # Static config
  4. DEBUG = False
  5. mux_vers = 1
  6. mux_xattr = 'zib.mux'
  7. base_path = '/Volumes/Shared/TV/Willow'
  8. min_size = 0.9
  9. lang_default = 'eng'
  10. # CLI config
  11. if len(sys.argv) > 1:
  12. base_path = sys.argv[1]
  13. # Init
  14. root = pathlib.Path(base_path)
  15. re_seaep = re.compile(r'\bS(\d{2,3})E(\d{2,3})\b', re.IGNORECASE)
  16. re_media = re.compile(r'^(.*?)(?:\.\w\w)?(?:\.sdh)?\.(srt|mp4|m4v|mkv)$', re.IGNORECASE)
  17. re_subs = re.compile(r'(?:\.(\w\w))?(?:\.(sdh))?\.srt$', re.IGNORECASE)
  18. if DEBUG:
  19. print(f"Root: {root}")
  20. for (parent, dirs, files) in root.walk():
  21. if DEBUG:
  22. print(f"Processing: {parent}", file=sys.stderr)
  23. # Reset for each directory
  24. media = []
  25. # Scan this directory for media
  26. for file in files:
  27. info = {
  28. 'name': file,
  29. 'path': parent.joinpath(file),
  30. }
  31. # Parse media paths
  32. try:
  33. m = re_media.search(info['name'])
  34. if m:
  35. info['base'] = m.group(1)
  36. info['ext'] = m.group(2).lower()
  37. info['g_base'] = info['base'].lower()
  38. except BaseException as e:
  39. print(f"Parsing error (media): {e}", file=sys.stderr)
  40. sys.exit(-1)
  41. # Skip non-media files
  42. if 'ext' not in info:
  43. if DEBUG:
  44. print(f"Skipping non-media file: {info['name']}", file=sys.stderr)
  45. continue
  46. # Parse season/episode data
  47. try:
  48. m = re_seaep.search(info['name'])
  49. if m:
  50. info['season'] = int(m.group(1))
  51. info['episode'] = int(m.group(2))
  52. info['g_seaep'] = 'S{:02d}E{:02d}'.format(info['season'], info['episode'])
  53. except BaseException as e:
  54. print(f"Parsing error (seaep): {e}", file=sys.stderr)
  55. sys.exit(-1)
  56. # Parse xattrs
  57. try:
  58. if (
  59. 'ext' in info and info['ext'] == 'mkv'
  60. and mux_xattr in xattr.listxattr(info['path'])
  61. ):
  62. info['mux'] = int.from_bytes(xattr.getxattr(info['path'], mux_xattr))
  63. except BaseException as e:
  64. print(f"Parsing error (xattr): {e}", file=sys.stderr)
  65. sys.exit(-1)
  66. if DEBUG:
  67. print(f"Found media: {info}", file=sys.stderr)
  68. media.append(info)
  69. # Find groups
  70. groups = {}
  71. for file in media:
  72. if 'g_base' in file:
  73. g_base = file['g_base']
  74. if file['g_base'] not in groups:
  75. groups[g_base] = []
  76. if file not in groups[g_base]:
  77. groups[g_base].append(file)
  78. # Merge groups
  79. for (g_base, group) in groups.items():
  80. if len(group) == 1:
  81. if 'mux' in group[0] and group[0]['mux'] >= mux_vers:
  82. if DEBUG:
  83. print(f"Skipping non-grouped file with current mux_vers: {group[0]['path']}", file=sys.stderr)
  84. continue
  85. if DEBUG:
  86. print(f"Merging group: {group[0]['base']}", file=sys.stderr)
  87. # Construct args and file lists
  88. size = 0
  89. sources = []
  90. subs = []
  91. video = []
  92. temp = group[0]['path'].parent.joinpath(f'{group[0]['base']}-remux.mkv')
  93. final = group[0]['path'].parent.joinpath(f'{group[0]['base']}.mkv')
  94. args = [ 'mkvmerge', '--default-language', lang_default, '-o', temp ]
  95. for file in group:
  96. # Track all source files
  97. size += file['path'].stat().st_size
  98. sources.append(file['path'])
  99. # Find the video source
  100. if file['ext'] in [ 'mkv', 'mp4', 'm4v' ]:
  101. if DEBUG:
  102. print(f"Found video: {file['path'].name}", file=sys.stderr)
  103. video.append(file['path'])
  104. # Parse subtitle languages
  105. else:
  106. if DEBUG:
  107. print(f"Found subtitle: {file['path'].name}", file=sys.stderr)
  108. sub = { 'path': file['path'], 'lang': lang_default, 'sdh': False }
  109. m = re_subs.search(file['path'].name)
  110. if m:
  111. for part in m.groups():
  112. if part.lower() == 'sdh':
  113. sub['sdh'] = True
  114. else:
  115. code = langcodes.Language.get(part.lower())
  116. if code:
  117. sub['lang'] = code.to_alpha3()
  118. else:
  119. print(f"Cound not match language ({file['path']}): {part}", file=sys.stderr)
  120. if DEBUG:
  121. print(f"Using language: {sub['lang']}", file=sys.stderr)
  122. subs.append(sub)
  123. # We should find exactly one video source
  124. if len(video) != 1:
  125. print(f"Did not find exactly 1 video: {group[0]['base']}", file=sys.stderr)
  126. continue
  127. args.extend(video)
  128. # Subtitles get language and SDH metadata
  129. for sub in subs:
  130. args.extend([ '--language', f"0:{sub['lang']}" ])
  131. if sub['sdh']:
  132. args.extend([ '--hearing-impaired-flag', '0' ])
  133. args.append(sub['path'])
  134. if DEBUG:
  135. print(f"Mux command: {args}", file=sys.stderr)
  136. # mkvmerge
  137. if temp.exists():
  138. print(f"Mux output exists: {temp}", file=sys.stderr)
  139. continue
  140. merge = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
  141. if DEBUG:
  142. print(f"Muxing result: {merge}", file=sys.stderr)
  143. # Verify
  144. if merge.returncode != 0 or not temp.exists() or temp.stat().st_size < (min_size * size):
  145. print(f"Mux error: {merge.stdout.decode('utf-8')}", file=sys.stderr)
  146. continue
  147. # Set xattr
  148. try:
  149. xattr.setxattr(temp, mux_xattr, int(1).to_bytes())
  150. except BaseException as e:
  151. print(f"xattrs error ({temp}): {e}", file=sys.stderr)
  152. continue
  153. # Remove sources
  154. try:
  155. for source in sources:
  156. if DEBUG:
  157. print(f"Removing: {source}", file=sys.stderr)
  158. source.unlink()
  159. except BaseException as e:
  160. print(f"Unlink error: {e}", file=sys.stderr)
  161. continue
  162. # Rename
  163. err = None
  164. try:
  165. if DEBUG:
  166. print(f"Renaming: {temp} -> {final}", file=sys.stderr)
  167. temp.rename(final)
  168. except BaseException as e:
  169. err = e
  170. if not final.exists() or final.stat().st_size < (min_size * size):
  171. err = 'Missing output'
  172. if err is not None:
  173. print(f"Rename error ({final}): {e}", file=sys.stderr)
  174. continue