import argparse
import os
import sys
from pathlib import Path


def _key():
  # Steam radio key.
  return bytes.fromhex("""
3490bc53018aa130dbf9678606f7fb91
ee635b12158d1bccabf9394c9303bfa7
b8f1cb7c336bd84dcc2f2073d9fcbbe5
e65ccd13a939cb3f66b32e9619e4d2f3
c296257aca00c834e3a5e854a5dddb5d
b9c8c93becec78d194175a3fe7ee8191
fcfe67d14ae0d791bb0e20bd0c23b244
e129edee41219711b0844c8a729942fc
c18b110734969ce1deb864147f55e647
e90eb8b2595eaf99559c721d5b6d9bb2
abcc909e0b899da1973634fb62ecfed2
4cc7982b872d27b7fcaf6b5bf4e2f33f
2533840d4132415da007fdd957625163
83072cbc6ee59408025ea5e9e182e10f
b85f4bd669391e6164bb3022d04e7495
1a4cc1eb70318e070f299f2d94c4ebae
e0b17370f5f6aa7a5db489796109fec5
7ae7182d20875b319e70c7a99f22789a
28da8b7e69f36de4068248447b3b4780
3f8a8d05005191131be4cce365332e4f
091be16546c8dc4df8b51b17914de352
d29490088237d7190ff6fc6078605349
0e1335a91fcd6f1090be506646b75bb7
dfa6bfaa3a9174cff407f6a35b329e25
9143e3bf46abbbc9770ea6a6fb03141d
9021896189f81ea357b539127031764f
3e3c7c3c5eba38f608347c7c25c7a41f
cf947cc003e51b31c07332406ad56124
8e5e6d83e9937b04dcb1303c646e831a
c771073d39d012f49bd060b29b98d560
dc2a354a4bbedb8d5007807a3c7f37aa
1358b74b8e42795b624e62ed96026a60
d32062d42594efff1da4dbbb0f53364d
5ca91d7f75c2d7e0bf4ca656ed9a85b0
ede083c7e9cd2ec79d2ba0646fa2076e
ddf5854ea0caa322fd6bab70f3caaddd
85dcf6870cb22d914cfa7d29dfe530aa
42ad9f2c62c2639bb70cefe04b1b6944
45844bb81efb43c9b3a4c05ec285286e
f351d9bd6d439db977dee10c76143f80
a7580fbf92e117ec5ea82988ba0a7536
9d81a25704d4b8f8a7243066d73e950f
17e89011fb2d2f77b677f43c3aeb8e9e
899ea89dc8ef4bf5c29c39741202036c
2eb69e51cc36e1f54791c2ddc3a3f904
42293af43e0dcb1d73987bda97f71e05
472267f47775c5d49a971102d9d93ef2
62b1f5e1d6a4b0ea04a885edda882d75
0ddba9609e8361b01be97e2dfef6b1f7
64a84a09154f90db2f5dc5326e3cc62a
d934b34894d83bf5630949c395631b8f
b29ec5be8b65f18c2f17da4cc49b71bf
48db34f00cfdb921dd85206a40c7c026
e7f3e6879c9f3b59be661390601fc382
d223990f675b53ce05df7175718c4958
6e592cf7da46f2ee38db2da3d33f142f
f3aa52f62979be4b7378db99bb3a1b92
c02f0484b8f19f9926d767e981840912
36e2fc3dd3d0424393ffbd2b8149bc51
79e50da1c83bb7e513ea70e70c77f8d7
044cf766e90a9423ffc695be5532a421
032ea5f38d7ab1708985c622c5808adc
f777d1e6ed7d0c77136de1c8b98d698e
f7486cdf693834a5e348d82d514824bd
7cc4194342b32fbc675441dd11c371f5
b2e4cfd9ee0da512cca3256d31274cd6
1cd43c009935745f672c22620e6d63e3
dd844e6690418c229e083a57d7a589c4
4118c9a1467a82fbad4503eaf2e295f4
c397762af0ad4f62e306869fb74c5204
960f209cfafbbffea44072eb72dcae85
1f9eb679827a844f371e988a73933d11
863aed65f94292e4d69ddee90fa3f422
3a0a9df8a750b265f2b0eebc6d04a068
8b1a9190e5d5b14acebdb6691bdf2f57
bf7aa90d03b75021a17e089a38262386
202f99a2412f839be62198ee29f99391
1a50492a0729f31fddc752c846623bf7
def984108eb67e45c97803decb7ae85d
c32c1bf4260df3bb023d6cab1a416f39
22fad06d301529f3e11464cd75d98527
477e8de1030ce683a82fc4c7364b16c6
84a2fc0fd8c20a04b9844b30a8a0ef9b
3fa71f65ff7e43f24c3ed9a12df8d51d
80918789da289702cb5a36aae757aef6
e5683e04affcafa667ea3acd51c235c8
a038fe61b8dd586a82f5a391b56638f5
0601894264ff62fa749544a055319a36
5f07c12b054cb2c978e7027984561305
f9227fa4e0ff227cfdeea6be9bdc9cf1
278e7e4b420c6d8c38b0ffe6d6bfc590
3c3c8e9f878545a77e47cf8ca61e9188
824f8336e285be4f2cc0eb4e3ffac635
57cf35a7ca1962f8704f2e9ef87d3056
0cb97f8079413d6ecec8542420a09c6f
d54d02613d23ff0d6e5740691583de08
1e6ec0be68c76f76aa09c70d1c39cdab
22358321513c6430d2ffa4718db814f9
43bdf5454291cbe34a22b45d952db076
c618189b99dc561f38a9c93bb48566b8
06589db89927e8531894a9b92ff4fb4c
5e764e25888d3c2619f42e4b2d824cc0
e09e067dc0f02d219ee015862b3916a9
33c0944290a68ce1ff52400679183c9e
6e11dd285a972fe9687785584c52751b
ef87998754d0cdce4752ae00fbeebcce
f840932de873451adff879a3e4d8b01d
fe4bab69468476598965c7bd574f44bd
2baca501ce1d263e9fb56fec97453e2c
da7c696cd83a1d2e410c3ab3fdd169f7
6fc6a238ae0d28b2025ee5b8c61094b4
1e9b41058e7a288909b65466520f920a
401f0957dabbe124a1435b7b05c14a63
3339b1b7e9bd3e283b06b45a075f6660
381637c206a1d91f09055eb9c4d2c594
a4c559197d8ca576626ae9048e5b3b87
c14cdb1f926776efa2244df798db9dd4
fcc1968dab4f05e0195442f74b7b97fb
773255c003554e13f811c6aae2352890
96bdf799079ee204a9647caadf38123a
b358325ce924c745865c396646871c74
1c2afbb102f2a55cce0be680ae2a11ce
113ff046b92c79d2c576339f353bdfdc
f787118a3fc4e356d2b8102e4ab93b26
0a471003e4f5d96841df33ca22c8f946
a466d56811ac048e60fd7608256fe6dc
19011e270e0a6f527329b77cafb8d04d
ac37b8ef1a13ac
  """)


def _xor(buf, off, key):
  # Absolute offset.
  if off < 0:
    raise ValueError('Negative ENQ offset.')
  size = len(key)
  idx = off % size
  out = bytearray(buf)
  for i in range(len(out)):
    out[i] ^= key[idx]
    idx += 1
    # Repeating table.
    if idx == size:
      idx = 0
  return bytes(out)


def decode(enq: Path, mp3: Path) -> int:
    """ENQ to MP3.

    Returns byte count.
    Failed writes can
    leave partial output.
    """
    key = _key()
    off = 0
    # No overwrites.
    # Keep the header.
    # ENQ stays intact.
    with enq.open('rb') as fin, mp3.open('xb') as fout:
      while True:
        buf = fin.read(1024 * 1024)
        if not buf:
          break
        fout.write(_xor(buf, off, key))
        # Next read's offset.
        off += len(buf)
    return off


def decode_tree(folder: Path) -> tuple[int, int, int]:
    """Walk radio folders.

    Returns counts:
    decoded, skipped,
    failed. Links are
    not followed.
    """
    if not folder.is_dir():
        raise NotADirectoryError(str(folder))
    done = skipped = failed = 0
    errors = []
    for base, dirs, files in os.walk(folder, onerror=errors.append):
        base = Path(base)
        # Stay in this tree.
        dirs[:] = [name for name in dirs
                   if not (base / name).is_symlink()
                   and not (base / name).is_junction()]
        for name in sorted(files):
            enq = base / name
            # Not linked audio.
            if enq.suffix.lower() != '.enq' or enq.is_symlink():
                continue
            mp3 = enq.with_suffix('.mp3')
            try:
                size = decode(enq, mp3)
            except FileExistsError:
                # Creation races too.
                skipped += 1
                print('Skipped existing output: %s' % mp3)
            except (OSError, ValueError) as exc:
                # Try the next file.
                failed += 1
                print('Failed: %s: %s' % (enq, exc), file=sys.stderr)
            else:
                done += 1
                print('Recovered %d bytes: %s' % (size, mp3))
    for exc in errors:
        # Include scan errors.
        print('Cannot scan: %s' % exc, file=sys.stderr)
    return done, skipped, failed + len(errors)


def _cli():
  cli = argparse.ArgumentParser(description="Rig'n'Roll ENQ to MP3")
  cli.add_argument('folder', type=Path, help='Folder to search recursively for ENQ files')
  opt = cli.parse_args()
  if not opt.folder.is_dir():
    cli.error('Input must be an existing folder')
  return cli, opt


def main():
  cli, opt = _cli()
  try:
    done, skipped, failed = decode_tree(opt.folder)
  except (OSError, ValueError) as exc:
    cli.error(str(exc))
  print('Decoded: %d; skipped: %d; failed: %d' % (done, skipped, failed))
  return 1 if failed else 0


if __name__ == '__main__':
    sys.exit(main())
