feed 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769
  1. #!/usr/bin/env python3
  2. """
  3. Feed parser with many features
  4. from a feed, it supports filtering, subslicing, random picking
  5. Beside feeds, it supports picking files from directories
  6. """
  7. import datetime
  8. import logging
  9. import os
  10. import glob
  11. import posixpath
  12. import random
  13. import re
  14. import sys
  15. import urllib.request
  16. from argparse import ArgumentParser, ArgumentTypeError
  17. from bisect import bisect
  18. from collections import OrderedDict
  19. from subprocess import CalledProcessError, check_output
  20. from urllib.parse import unquote, urlparse
  21. import shutil
  22. import requests
  23. from lxml import html
  24. from pytimeparse.timeparse import timeparse
  25. DEBUG = False
  26. class UnsupportedFeedtype(Exception):
  27. pass
  28. class DurationNotFound(Exception):
  29. pass
  30. class EmptySequenceError(Exception):
  31. pass
  32. class WeightZeroError(Exception):
  33. pass
  34. def debug(*args, **kwargs):
  35. if not DEBUG:
  36. return
  37. kwargs.setdefault("file", sys.stderr)
  38. print(*args, **kwargs)
  39. def get_int(s):
  40. return int(re.findall(r"\d+", s)[0])
  41. def DurationType(arg):
  42. if arg.isdecimal():
  43. secs = int(arg)
  44. else:
  45. secs = timeparse(arg)
  46. if secs is None:
  47. raise ArgumentTypeError("%r is not a valid duration" % arg)
  48. return secs
  49. def TimeDeltaType(arg):
  50. if arg.isdecimal():
  51. secs = int(arg)
  52. else:
  53. secs = timeparse(arg)
  54. if secs is None:
  55. raise ArgumentTypeError("%r is not a valid time range" % arg)
  56. return datetime.timedelta(seconds=secs)
  57. def weighted_choice(values, weights):
  58. """
  59. random.choice with weights
  60. weights must be integers greater than 0.
  61. Their meaning is "relative", that is [1,2,3] is the same as [2,4,6]
  62. """
  63. assert len(values) == len(weights)
  64. if not values:
  65. raise EmptySequenceError() # Cannot do weighted choice from an empty sequence
  66. if sum(weights) == 0:
  67. raise WeightZeroError() # Cannot do weighted choice where weight=0
  68. total = 0
  69. cum_weights = []
  70. for w in weights:
  71. total += w
  72. cum_weights.append(total)
  73. x = random.random() * total
  74. i = bisect(cum_weights, x)
  75. return values[i]
  76. def delta_humanreadable(tdelta):
  77. if tdelta is None:
  78. return ""
  79. days = tdelta.days
  80. hours = (tdelta - datetime.timedelta(days=days)).seconds // 3600
  81. if days:
  82. return "{}d{}h".format(days, hours)
  83. return "{}h".format(hours)
  84. def duration_humanreadable(seconds):
  85. hours = seconds // 3600
  86. minutes = (seconds - hours * 3600) // 60
  87. seconds = seconds % 60
  88. if hours > 0:
  89. return "{}h{}m{}s".format(hours, minutes, seconds)
  90. return "{}m{}s".format(minutes, seconds)
  91. class Audio(object):
  92. def __init__(self, url, duration=None, date=None):
  93. self.url = url
  94. self._duration = duration
  95. self.date = date
  96. self.end_date = datetime.datetime(9999, 12, 31, tzinfo=datetime.timezone.utc)
  97. @classmethod
  98. def from_trusted(cls, url_or_path) -> 'Audio':
  99. if url_or_path.startswith('/'):
  100. return cls('file://' + url_or_path)
  101. return cls(url_or_path)
  102. def __str__(self):
  103. return self.url
  104. def __repr__(self):
  105. return "<Audio {} ({} {})>".format(
  106. self.url,
  107. duration_humanreadable(self.duration),
  108. delta_humanreadable(self.age),
  109. )
  110. @property
  111. def duration(self):
  112. if self._duration is None:
  113. self._duration = get_duration(self.url)
  114. return self._duration
  115. @property
  116. def urls(self):
  117. return [self.url]
  118. @property
  119. def age(self):
  120. if self.date is None:
  121. return None
  122. now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
  123. return now - self.date
  124. @property
  125. def valid(self):
  126. return self.end_date >= datetime.datetime.utcnow().replace(
  127. tzinfo=datetime.timezone.utc
  128. )
  129. class AudioGroup(list):
  130. def __init__(self, description=None):
  131. self.description = description or ""
  132. self.audios = []
  133. def __len__(self):
  134. return len(self.audios)
  135. def append(self, arg):
  136. self.audios.append(arg)
  137. def __str__(self):
  138. return "\n".join(str(a) for a in self.audios)
  139. def __repr__(self):
  140. return '<AudioGroup "{}" ({} {})\n{} >'.format(
  141. self.description,
  142. duration_humanreadable(self.duration),
  143. delta_humanreadable(self.age),
  144. "\n".join(" " + repr(a) for a in self.audios),
  145. )
  146. @property
  147. def duration(self):
  148. return sum(a.duration for a in self.audios if a.duration is not None)
  149. @property
  150. def urls(self):
  151. return [a.url for a in self.audios]
  152. @property
  153. def date(self):
  154. for a in self.audios:
  155. if hasattr(a, "date"):
  156. return a.date
  157. return None
  158. @property
  159. def age(self):
  160. if self.date is None:
  161. return None
  162. now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
  163. return now - self.date
  164. @property
  165. def valid(self):
  166. return len(self.audios) > 0
  167. def get_tree(feed_url):
  168. if feed_url.startswith("http:") or feed_url.startswith("https:"):
  169. tree = html.fromstring(requests.get(feed_url).content)
  170. else:
  171. if not os.path.exists(feed_url):
  172. raise FileNotFoundError(feed_url)
  173. tree = html.parse(open(feed_url))
  174. return tree
  175. def get_audio_from_description(text):
  176. # non-empty lines
  177. lines = [line.strip() for line in text.split("\n") if line.strip()]
  178. url = lines[0]
  179. duration = None
  180. metadata = {}
  181. for line in text.split("\n")[1:]:
  182. if line.strip() and "=" in line:
  183. metadata[line.split("=")[0]] = line.split("=")[1]
  184. if "durata" in metadata:
  185. try:
  186. durata = get_int(metadata["durata"])
  187. except Exception as exc:
  188. logging.info("Could not get duration: %s" % exc)
  189. del metadata['durata']
  190. else:
  191. metadata["durata"] = durata
  192. if "txdate" in metadata:
  193. try:
  194. metadata["txdate"] = datetime.datetime.strptime(
  195. metadata["txdate"], "%Y-%m-%dT%H:%M:%S%z"
  196. )
  197. except ValueError:
  198. logging.warning("could not parse txdate %s", metadata["txdate"])
  199. del metadata["txdate"]
  200. a = Audio(
  201. unquote(url),
  202. duration=metadata.get("durata", None),
  203. date=metadata.get("txdate", None),
  204. )
  205. if "txdate" in metadata and "replica" in metadata:
  206. if metadata["replica"].endswith("g"):
  207. a.end_date = metadata["txdate"] + datetime.timedelta(
  208. days=get_int(metadata["replica"])
  209. )
  210. return a
  211. def is_audio_file(fpath, extensions=("mp3", "oga", "wav", "ogg")):
  212. if fpath.split(".")[-1].lower() in extensions:
  213. return True
  214. return False
  215. # copied from larigira.fsutils
  216. def scan_dir_audio(dirname):
  217. for root, dirnames, filenames in os.walk(dirname):
  218. for fname in filenames:
  219. if is_audio_file(fname):
  220. path = os.path.join(root, fname)
  221. yield path
  222. def get_audio_from_file(fpath):
  223. a = Audio(
  224. "file://" + os.path.realpath(fpath),
  225. date=datetime.datetime.fromtimestamp(os.path.getmtime(fpath)).replace(
  226. tzinfo=datetime.timezone.utc
  227. ),
  228. )
  229. return [a]
  230. def get_audio_from_dir(dirpath):
  231. fpaths = scan_dir_audio(dirpath)
  232. ret = []
  233. for u in fpaths:
  234. try:
  235. a = Audio(
  236. "file://" + os.path.realpath(u),
  237. date=datetime.datetime.fromtimestamp(os.path.getmtime(u)).replace(
  238. tzinfo=datetime.timezone.utc
  239. ),
  240. )
  241. except ValueError:
  242. continue
  243. ret.append(a)
  244. return ret
  245. def get_item_date(el):
  246. el_date = el.find("pubdate")
  247. # Wed, 15 Jan 2020 22:45:33 +0000
  248. formats = ["%a, %d %b %Y %H:%M:%S %z", "%Y-%m-%dT%H:%M:%S%z"]
  249. if el_date is not None:
  250. for fmt in formats:
  251. try:
  252. return datetime.datetime.strptime(el_date.text, fmt)
  253. except ValueError:
  254. pass
  255. return None
  256. def get_urls_generic(tree, url_selector="description[text()]", metadata_in_body=True):
  257. items = tree.xpath("//item")
  258. for it in items:
  259. title = it.find("title").text
  260. el_body = it.find("description")
  261. if metadata_in_body and el_body is not None:
  262. url = el_body.text
  263. try:
  264. audio = get_audio_from_description(url)
  265. except Exception as exc:
  266. logging.info("error getting duration for `%s`: %s" % (title, exc))
  267. continue
  268. if audio.date is None:
  269. audio.date = get_item_date(it)
  270. yield audio
  271. else:
  272. try:
  273. url = it.xpath(url_selector)[0]
  274. except IndexError:
  275. logging.warning("no audio found in %s", title)
  276. else:
  277. audio = Audio(url)
  278. audio.date = get_item_date(it)
  279. yield audio
  280. def get_urls_from_podcast(tree):
  281. return get_urls_generic(tree, url_selector="enclosure/@url", metadata_in_body=False)
  282. def get_urls_from_custom_feed(tree):
  283. return get_urls_generic(tree, metadata_in_body=True)
  284. def get_urls_factory(url, args):
  285. if args.feed_type == "customrss":
  286. return get_urls_from_custom_feed
  287. if args.feed_type == "podcast":
  288. return get_urls_from_podcast
  289. raise UnsupportedFeedtype(args.feed_type)
  290. def get_grouped_urls(tree):
  291. groups = OrderedDict()
  292. items = tree.xpath("//item")
  293. for item in items:
  294. guid = item.xpath("guid")[0].text.strip()
  295. if guid not in groups:
  296. groups[guid] = AudioGroup(guid)
  297. audio = get_audio_from_description(item.xpath("description")[0].text)
  298. audio.date = get_item_date(item)
  299. if audio.valid:
  300. groups[guid].append(audio)
  301. return groups
  302. def get_duration(url):
  303. try:
  304. lineout = check_output(
  305. ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-i", url]
  306. ).split(b"\n")
  307. except CalledProcessError as exc:
  308. raise DurationNotFound(url) from exc
  309. duration = next(l for l in lineout if l.startswith(b"duration="))
  310. value = duration.split(b"=")[1]
  311. return int(float(value))
  312. HELP = """
  313. Collect audio informations from multiple sources (XML feeds).
  314. Audios are (in that order):
  315. 1. Collected from feeds; (grouped by article if --group is used)
  316. 2. Filtered; everything that does not match with requirements is excluded
  317. 3. Sorted; even randomly
  318. 4. Sliced; take HOWMANY elements, skipping START elements
  319. 5. (if --copy) Copied
  320. Usage: """
  321. def get_parser():
  322. p = ArgumentParser(HELP)
  323. parsing = p.add_argument_group("parsing", "Feed parsing")
  324. parsing.add_argument(
  325. "--feed-type", type=str, choices=["customrss", "podcast"], default="customrss"
  326. )
  327. src = p.add_argument_group("sources", "How to deal with sources")
  328. src.add_argument(
  329. "--source-weights", help='Select only one "source" based on this weights'
  330. )
  331. src.add_argument(
  332. "--group",
  333. default=False,
  334. action="store_true",
  335. help="Group audios that belong to the same article",
  336. )
  337. src.add_argument(
  338. "--glob",
  339. default=False,
  340. action="store_true",
  341. help="Wildcards in filenames are interpreted",
  342. )
  343. filters = p.add_argument_group(
  344. "filters", "Select only items that match " "these conditions"
  345. )
  346. filters.add_argument(
  347. "--min-len",
  348. default=0,
  349. type=DurationType,
  350. help="Exclude any audio that is shorter " "than MIN_LEN seconds",
  351. )
  352. filters.add_argument(
  353. "--max-len",
  354. default=0,
  355. type=DurationType,
  356. help="Exclude any audio that is longer " "than MAX_LEN seconds",
  357. )
  358. filters.add_argument(
  359. "--sort-by", default="no", type=str, choices=("random", "date", "duration")
  360. )
  361. filters.add_argument(
  362. '--random-seed', default=None, help='Initialize the random generator. For debug only')
  363. filters.add_argument(
  364. "--reverse", default=False, action="store_true", help="Reverse list order"
  365. )
  366. filters.add_argument(
  367. "--min-age",
  368. default=datetime.timedelta(),
  369. type=TimeDeltaType,
  370. help="Exclude audio more recent than MIN_AGE",
  371. )
  372. filters.add_argument(
  373. "--max-age",
  374. default=datetime.timedelta(),
  375. type=TimeDeltaType,
  376. help="Exclude audio older than MAX_AGE",
  377. )
  378. fill = p.add_argument_group(
  379. "fill", "Fill a 'block' with as many contents as possible"
  380. )
  381. fill.add_argument(
  382. "--fill",
  383. default=0,
  384. type=DurationType,
  385. help="Fill a block of duration LEN",
  386. metavar="LEN",
  387. )
  388. fill.add_argument(
  389. "--fill-reverse",
  390. default=False,
  391. action="store_true",
  392. help="Reverse list order after the fill algorithm",
  393. )
  394. fill.add_argument(
  395. "--fill-interleave-dir",
  396. default=None,
  397. type=str, # FIXME: does it even work?
  398. help="Between each item, put a random file from DIR",
  399. )
  400. intro = p.add_argument_group(
  401. "intro", "Add intro/outro to output, but only if at least one audio will be output"
  402. )
  403. intro.add_argument("--intro", default=None, type=str, metavar="PATH")
  404. intro.add_argument("--outro", default=None, type=str, metavar="PATH")
  405. p.add_argument(
  406. "--start",
  407. default=0,
  408. type=int,
  409. help="0-indexed start number. " "By default, play from most recent",
  410. )
  411. p.add_argument(
  412. "--howmany", default=1, type=int, help="If not specified, only 1 will be played"
  413. )
  414. p.add_argument(
  415. "--slotsize", type=int, help="Seconds between each audio. Still unsupported"
  416. )
  417. general = p.add_argument_group("general", "General options")
  418. general.add_argument(
  419. "--copy", help="Copy files to $TMPDIR", default=False, action="store_true"
  420. )
  421. general.add_argument(
  422. "--debug", help="Debug messages", default=False, action="store_true"
  423. )
  424. p.add_argument("urls", metavar="URL", nargs="+")
  425. return p
  426. def downloader(url, dest):
  427. headers = {}
  428. mode = "wb"
  429. if os.path.exists(dest):
  430. headers["Range"] = "bytes=%d-" % os.stat(dest).st_size
  431. mode = "ab"
  432. r = requests.get(url, stream=True, headers=headers)
  433. if r.status_code == 416: # range not satisfiable
  434. return
  435. with open(dest, mode) as f:
  436. for chunk in r.iter_content(chunk_size=1 << 16):
  437. f.write(chunk)
  438. def put(audio, copy=False):
  439. if not copy:
  440. for url in audio.urls:
  441. print(url)
  442. else:
  443. destdir = os.environ.get("TMPDIR", ".")
  444. os.makedirs(destdir, exist_ok=True)
  445. for url in audio.urls:
  446. if url.split(":")[0] in ("http", "https"):
  447. fname = posixpath.basename(urlparse(url).path)
  448. # sanitize
  449. fname = "".join(
  450. c for c in fname if c.isalnum() or c in list("._-")
  451. ).rstrip()
  452. dest = os.path.join(destdir, "feed-" + fname)
  453. downloader(url, dest)
  454. print("file://%s" % os.path.realpath(dest))
  455. elif url.startswith("file:///"):
  456. src = url[len('file://'):]
  457. dest = os.path.join(destdir, os.path.basename(src))
  458. shutil.copy(src, dest)
  459. print("file://%s" % os.path.realpath(dest))
  460. else:
  461. # what's that? let's just copy it
  462. print(url)
  463. def retrieve(url, args):
  464. """
  465. returns a list of Audios or a list of AudioGroups
  466. """
  467. if not args.group:
  468. if os.path.isdir(url):
  469. audiodir = get_audio_from_dir(url)
  470. return audiodir
  471. elif os.path.isfile(url) and is_audio_file(url):
  472. return get_audio_from_file(url)
  473. elif url.startswith("http:") or url.startswith("https:") or os.path.isfile(url):
  474. getter = get_urls_factory(url, args)
  475. tree = get_tree(url)
  476. return getter(tree)
  477. else:
  478. logging.info("unsupported url `%s`", url)
  479. return []
  480. else: # group
  481. if os.path.isdir(url):
  482. audiodir = get_audio_from_dir(url)
  483. agroups = []
  484. for a in audiodir:
  485. ag = AudioGroup(os.path.basename(a.url))
  486. ag.append(a)
  487. agroups.append(ag)
  488. return agroups
  489. elif os.path.isfile(url) and is_audio_file(url):
  490. audio = get_audio_from_file(url)[0]
  491. ag = AudioGroup(url)
  492. ag.append(audio)
  493. return [ag]
  494. elif url.startswith("http:") or url.startswith("https:") or os.path.isfile(url):
  495. groups = get_grouped_urls(get_tree(url))
  496. return groups.values()
  497. else:
  498. logging.info("unsupported url `%s`", url)
  499. return []
  500. def audio_passes_filters(audio, args):
  501. try:
  502. logging.debug(audio.end_date)
  503. if not audio.valid:
  504. return False
  505. if args.max_len and audio.duration > args.max_len:
  506. return False
  507. if args.fill and audio.duration > args.fill:
  508. return False
  509. if args.min_len and audio.duration < args.min_len:
  510. return False
  511. if args.min_age.total_seconds() and audio.age < args.min_age:
  512. return False
  513. if args.max_age.total_seconds() and audio.age > args.max_age:
  514. return False
  515. return True
  516. except DurationNotFound:
  517. return False
  518. def expand_glob(sources: list, weights: list) -> tuple:
  519. '''
  520. Let's say that sources=["foo", "bar*"] and weight=["2", "3"] and on filesystem there are bar1 and bar2.
  521. Result: ["foo", "bar1", "bar2"], ["2", "1.5", "1.5"]
  522. '''
  523. new_sources = []
  524. new_weights = []
  525. for src, weight in zip(sources, weights):
  526. if not src.startswith('http://') and not src.startswith('https://') and '*' in src:
  527. expanded_source = glob.glob(src)
  528. else:
  529. expanded_source = [src]
  530. logging.debug("glob: %s -> %s", src, expanded_source)
  531. expanded_weight = [weight / len(expanded_source)] * len(expanded_source)
  532. new_sources += expanded_source
  533. new_weights += expanded_weight
  534. return new_sources, new_weights
  535. def get_audio_by_source(args, parser) -> tuple[OrderedDict, list]:
  536. sources = args.urls
  537. if args.source_weights:
  538. weights = list(map(int, args.source_weights.split(":")))
  539. if len(weights) != len(sources):
  540. parser.exit(
  541. status=2,
  542. message="Weight must be in the same number as sources\n",
  543. )
  544. else:
  545. weights = [1] * len(sources)
  546. if sum(weights) == 0:
  547. return [], []
  548. if args.glob:
  549. sources, weights = expand_glob(sources, weights)
  550. audio_by_source = OrderedDict()
  551. for i, url in enumerate(sources):
  552. url_audios = list(retrieve(url, args))
  553. logging.debug("Found %d audios in %s", len(url_audios), url)
  554. url_audios = [au for au in url_audios if audio_passes_filters(au, args)]
  555. logging.debug("%d of those are passing filters", len(url_audios))
  556. audio_by_source[url] = url_audios
  557. if not url_audios:
  558. weights[i] = 0
  559. if sum(weights) == 0:
  560. return [], []
  561. sources = [weighted_choice(sources, weights)]
  562. return audio_by_source, sources
  563. def add_intro_outro(audios: list, args) -> list:
  564. if not audios:
  565. return audios
  566. audios = audios.copy()
  567. if args.intro:
  568. audios.insert(0, Audio.from_trusted(args.intro))
  569. if args.outro:
  570. audios.append(Audio.from_trusted(args.outro))
  571. return audios
  572. def main():
  573. parser = get_parser()
  574. args = parser.parse_args()
  575. if not args.debug:
  576. logging.basicConfig(level=logging.WARNING)
  577. else:
  578. global DEBUG
  579. DEBUG = True
  580. logging.basicConfig(level=logging.DEBUG)
  581. if args.random_seed is not None:
  582. random.seed(args.random_seed)
  583. audio_by_source, sources = get_audio_by_source(args, parser)
  584. audios = []
  585. for source_url in sources:
  586. audios += audio_by_source[source_url]
  587. logging.debug("Found %d audios", len(audios))
  588. # sort
  589. if args.sort_by == "random":
  590. random.shuffle(audios)
  591. elif args.sort_by == "date":
  592. audios.sort(key=lambda x: x.age)
  593. elif args.sort_by == "duration":
  594. audios.sort(key=lambda x: x.duration)
  595. if args.reverse:
  596. audios.reverse()
  597. # slice
  598. audios = audios[args.start :]
  599. if not args.fill:
  600. audios = audios[: args.howmany]
  601. if args.fill and audios:
  602. fill_audios = [audios.pop(0)]
  603. duration = fill_audios[0].duration
  604. for next_audio in audios:
  605. next_duration = next_audio.duration
  606. if args.fill_interleave_dir:
  607. interleaving = Audio(
  608. "file://"
  609. + random.choice(list(scan_dir_audio(args.fill_interleave_dir)))
  610. )
  611. # logging.info("%r", interleaving)
  612. next_duration += interleaving.duration
  613. if args.fill - duration > next_duration:
  614. if args.fill_interleave_dir:
  615. fill_audios.append(interleaving)
  616. fill_audios.append(next_audio)
  617. duration += next_duration
  618. audios = fill_audios
  619. if args.fill_reverse:
  620. audios.reverse()
  621. debug(f"Filled {duration}s out of {args.fill}s; left {args.fill - duration}s")
  622. # the for loop excludes the last one
  623. # this is to support the --slotsize option
  624. if not audios:
  625. return
  626. audios = add_intro_outro(audios, args)
  627. for audio in audios[:-1]:
  628. if args.debug:
  629. debug(repr(audio))
  630. else:
  631. put(audio, args.copy)
  632. if args.slotsize is not None:
  633. duration = audio.duration
  634. if duration < args.slotsize:
  635. # TODO: prendi musica da un'altra cartella
  636. print("## musica per {} secondi".format(args.slotsize - duration))
  637. # finally, the last one
  638. if args.debug:
  639. debug(repr(audios[-1]))
  640. else:
  641. put(audios[-1], args.copy)
  642. # else: # grouping; TODO: support slotsize
  643. # for item in groups:
  644. # if args.debug:
  645. # print('#', item, groups[item].duration)
  646. # print(groups[item])
  647. if __name__ == "__main__":
  648. main()