A Python script to help you select the movies you want to record with your Freebox
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

367 lines
13KB

  1. #!/usr/bin/python3
  2. """
  3. Simple script that extracts information from Télé 7 jours and TMDB
  4. to help select and plan the movies you want to record with your Freebox
  5. """
  6. import argparse
  7. import datetime
  8. import json
  9. import logging
  10. import os
  11. import re
  12. import requests
  13. import socket
  14. import textwrap
  15. import tmdbsimple
  16. from pyfbx.pyfbx import Fbx
  17. from bs4 import BeautifulSoup
  18. from collections import deque
  19. class Movie:
  20. def __init__(self):
  21. self.day = ''
  22. self.title = ''
  23. self.genre = ''
  24. self.channel = ''
  25. self.rating = ''
  26. self.original_title = ''
  27. self.overview = ''
  28. self.good = False
  29. self.tmdb_id = ''
  30. self.url = ''
  31. self.user_selected = False
  32. self.date = 0
  33. self.start_time = 0
  34. self.end_time = 0
  35. self.year = ''
  36. def __str__(self):
  37. return '{}: {} - {} ({})\n TMDB: {} - {} - {}\n @ {}\n {}'.format(
  38. 'Today' if self.day == '' else self.day,
  39. self.title,
  40. self.genre,
  41. self.channel,
  42. self.rating,
  43. self.original_title,
  44. self.year,
  45. self.url,
  46. self.overview
  47. )
  48. def __repr__(self):
  49. return "Movie <{} (D:{} — Ch:{} – R:{})>".format(
  50. self.title,
  51. 'Today' if self.day == '' else self.day,
  52. self.channel,
  53. self.rating
  54. )
  55. class TVGuideScraper:
  56. TV_GUIDE_URL = 'https://www.programme-television.org/{}?bouquet=free'
  57. def findAllMovies():
  58. movies = []
  59. days = deque(['lundi', 'mardi', 'mercredi',
  60. 'jeudi', 'vendredi', 'samedi', 'dimanche'])
  61. offset = datetime.datetime.today().weekday()
  62. days.rotate(-1-offset)
  63. days.appendleft('')
  64. date = datetime.date.today()
  65. for day in days:
  66. movies += TVGuideScraper._getMovies(day, date)
  67. date += datetime.timedelta(days=1)
  68. logging.debug('Found the following movies: {}'.format(movies))
  69. return movies
  70. @staticmethod
  71. def _getMovies(day='', date=datetime.date.today()):
  72. url = TVGuideScraper.TV_GUIDE_URL.format(day)
  73. logging.info('Connecting to {}'.format(url))
  74. r = requests.get(url)
  75. r.raise_for_status()
  76. html = BeautifulSoup(r.text, 'html.parser')
  77. movies = []
  78. for channel in html.select('.bloc_cnt'):
  79. if len(channel.select('em')):
  80. for movietag in channel.find_all(TVGuideScraper._tag_is_film):
  81. movie = Movie()
  82. movie.title = \
  83. movietag.select('.texte_titre a')[0]['title']
  84. movie.genre = movietag.select('.texte_cat a')[0].string
  85. movie.channel = channel.select('em')[0]\
  86. .string.replace('Programme ', '')
  87. movie.day = day.title()
  88. movie.date = datetime.date.strftime(date, '%Y-%m-%d')
  89. movie.start_time = datetime.datetime.strptime(
  90. '{} {}'.format(
  91. movie.date,
  92. movietag.select('.horaire')[0].string
  93. ),
  94. '%Y-%m-%d %H:%M'
  95. )
  96. duration = TVGuideScraper._parse_duration(
  97. movietag.select('.texte_cat')[0]
  98. .contents[1].strip(' \n\t()')
  99. )
  100. movie.end_time = movie.start_time + duration
  101. logging.debug('Found movie: {0!r}'.format(movie))
  102. movies.append(movie)
  103. return movies
  104. @staticmethod
  105. def _tag_is_film(tag):
  106. """
  107. Helper to check if a tag is a film
  108. """
  109. return (
  110. tag.has_attr('data-nature')
  111. and
  112. tag['data-nature'] == 'films-telefilms'
  113. )
  114. @staticmethod
  115. def _parse_duration(text):
  116. match = re.match(r"((?P<hours>\d+)h)?(?P<minutes>\d+)mn", text)
  117. if not match:
  118. error = "Could not parse duration '{}'".format(text)
  119. logging.error(error)
  120. raise ValueError(error)
  121. hours = int(match.group('hours')) if match.group('hours') else 0
  122. minutes = int(match.group('minutes'))
  123. return datetime.timedelta(hours=hours, minutes=minutes)
  124. class FreeboxMoviePlanner:
  125. def __init__(self, movies, excluded_channels=[], excluded_directory=[]):
  126. logging.debug('Opening config file: config.json')
  127. with open('config.json') as config_file:
  128. self.config = json.load(config_file)
  129. if(len(self.config['freebox-session-token']) != 64):
  130. self.createAuthenticationToken()
  131. tmdbsimple.API_KEY = self.config['tmdb-api']
  132. self.movies = movies
  133. self.excluded_directory = excluded_directory
  134. logging.info('Opening Freebox session')
  135. self.freebox = Fbx(nomdns=True)
  136. self.freebox.mksession(
  137. app_id='FreeboxMoviePlanner',
  138. token=self.config['freebox-session-token']
  139. )
  140. self.getListOfAvailableChannels(excluded_channels)
  141. self.excludeUnavailableChannels()
  142. self.excludeTelevisionFilm()
  143. self.findMoviesOnTMDB()
  144. self.excludeBadRatings()
  145. for directory in self.excluded_directory:
  146. self.excludeLocalMovies(directory)
  147. self.askForUserSelection()
  148. self.excludeNotSelected()
  149. self.programMovies()
  150. self.checkForConflicts()
  151. def createAuthenticationToken(self):
  152. logging.info('Creating authentication token')
  153. self.freebox = Fbx(nomdns=True)
  154. hostname = socket.gethostname()
  155. print("You don't seem to have an authentication token.")
  156. print("I will now atempt to create one.")
  157. print("Please go to your Freebox and accept the authentication.")
  158. token = self.freebox.register(
  159. "FreeboxMoviePlanner", "FreeboxMoviePlanner", hostname
  160. )
  161. self.config['freebox-session-token'] = token
  162. with open('config.json', 'w') as outfile:
  163. json.dump(self.config, outfile, indent=4, sort_keys=True)
  164. def __repr__(self):
  165. result = 'FreeboxMoviePlanner <Movies:\n'
  166. for movie in self.movies:
  167. result += ' {!r}\n'.format(movie)
  168. result += '>'
  169. return result
  170. def getListOfAvailableChannels(self, excluded_channels):
  171. logging.info('Getting the list of available channels')
  172. self.channels = {}
  173. for channel in self.freebox.Tv.Getting_the_list_of_channels().values():
  174. if channel['available']:
  175. if channel['name'] in excluded_channels:
  176. logging.debug(
  177. "Excluding '{}'".format(channel['name'])
  178. )
  179. else:
  180. self.channels[channel['name']] = channel['uuid']
  181. else:
  182. logging.debug("Dropping '{}'".format(channel['name']))
  183. logging.debug('Got the following channels: {}'.format(self.channels))
  184. def printAllMovies(self):
  185. for movie in self.movies:
  186. print('{!r}'.format(movie))
  187. def askForUserSelection(self):
  188. for movie in self.movies:
  189. print()
  190. print(movie)
  191. reply = input("Interested? (y)es/(N)o/(q)uit: ")
  192. if reply.upper() == "Y":
  193. movie.user_selected = True
  194. elif reply.upper() == "Q":
  195. break
  196. def findMoviesOnTMDB(self):
  197. for movie in self.movies:
  198. tmdb_details = self._findMovieOnTMDB(movie.title)
  199. if tmdb_details:
  200. movie.rating = tmdb_details['vote_average']
  201. movie.original_title = \
  202. tmdb_details['original_title']
  203. movie.overview = '\n '.join(textwrap.wrap(
  204. tmdb_details['overview'], 75)
  205. )
  206. movie.tmdb_id = tmdb_details['id']
  207. movie.good = \
  208. float(movie.rating) >= self.config['minimum-rating']
  209. movie.url = 'https://www.themoviedb.org/movie/{}?language={}' \
  210. .format(movie.tmdb_id, self.config['tmdb-language'])
  211. try:
  212. movie.year = datetime.datetime.strptime(
  213. tmdb_details['release_date'], '%Y-%m-%d'
  214. ).year
  215. except (ValueError, KeyError):
  216. logging.warning(
  217. "No release date for '{!r}'".format(movie)
  218. )
  219. pass
  220. else:
  221. logging.warning(
  222. "'{!r}' not found on TMDB!".format(movie)
  223. )
  224. def _findMovieOnTMDB(self, movie):
  225. logging.info("Searching for '{}' on TMDB".format(movie))
  226. search = tmdbsimple.Search()
  227. search.movie(query=movie, language=self.config['tmdb-language'])
  228. if len(search.results):
  229. logging.info("Found '{}'".format(
  230. search.results[0]['title']
  231. ))
  232. return search.results[0]
  233. else:
  234. logging.info("'{}' not found!".format(movie))
  235. return []
  236. def excludeBadRatings(self):
  237. logging.info('Dropping movies with bad ratings: {}'.format(
  238. [m for m in self.movies if not m.good]
  239. ))
  240. self.movies = [m for m in self.movies if m.good]
  241. logging.debug('Kept {}'.format(self.movies))
  242. def excludeUnavailableChannels(self):
  243. logging.info('Dropping movies on unavailable channels: {}'.format(
  244. [m for m in self.movies if m.channel not in self.channels]
  245. ))
  246. self.movies = [m for m in self.movies if m.channel in self.channels]
  247. logging.debug('Kept {}'.format(self.movies))
  248. def excludeTelevisionFilm(self):
  249. logging.info('Dropping television films')
  250. self.movies = [
  251. m for m in self.movies if not m.genre.startswith("Téléfilm")
  252. ]
  253. def excludeLocalMovies(self, directory):
  254. (_, _, filenames) = next(os.walk(directory))
  255. logging.warning('Dropping movies already recorded: {}'.format(
  256. [ m for m in self.movies if m.title+'.m2ts' in filenames ]
  257. ))
  258. self.movies = [
  259. m for m in self.movies if not m.title+'.m2ts' in filenames
  260. ]
  261. def excludeNotSelected(self):
  262. self.movies = [m for m in self.movies if m.user_selected]
  263. def programMovies(self):
  264. for movie in self.movies:
  265. logging.debug("Programming '{!r}'".format(movie))
  266. data = {
  267. 'channel_uuid': self.channels[movie.channel],
  268. 'start': int(movie.start_time.timestamp()),
  269. 'end': int(movie.end_time.timestamp()),
  270. 'name': movie.title,
  271. 'margin_before': 60*self.config['margin-before'],
  272. 'margin_after': 60*self.config['margin-after']
  273. }
  274. self.freebox.Pvr.Create_a_precord(data)
  275. print("Programmed '{!r}'".format(movie))
  276. def checkForConflicts(self):
  277. programmed_movies = self.freebox.Pvr.Getting_the_list_of_precords()
  278. conflicting_movies = [m for m in programmed_movies if m['conflict']]
  279. if conflicting_movies:
  280. print(
  281. "\n"
  282. "!!!!!!!!!\n"
  283. "!Warning!\n"
  284. "!!!!!!!!!\n"
  285. "Conflicting records detected, please "
  286. "check your Freebox interface"
  287. )
  288. logging.info("Conflicting records detected '{}'".format(
  289. conflicting_movies
  290. ))
  291. if __name__ == '__main__':
  292. logging.basicConfig(
  293. level=logging.WARNING,
  294. format=' %(asctime)s - %(levelname)s - %(message)s'
  295. )
  296. parser = argparse.ArgumentParser(
  297. description='Schedule movie recordings on your Freebox'
  298. )
  299. parser.add_argument(
  300. '-d', '--day',
  301. action='store_true',
  302. help='Search movies for current day only instead of a full week'
  303. )
  304. parser.add_argument(
  305. '-l', '--log',
  306. action='store_true',
  307. help='Display more log messages'
  308. )
  309. parser.add_argument(
  310. '-e', '--exclude',
  311. action='append',
  312. default=[],
  313. help='Exclude the following Channel'
  314. )
  315. parser.add_argument(
  316. '-x', '--exclude-directory',
  317. nargs=1,
  318. help='''Do not display movies available in the following directory.
  319. This will prevent you from recording the same movie multiple
  320. times.'''
  321. )
  322. args = parser.parse_args()
  323. print("Working the magic, please wait…")
  324. if args.log:
  325. logging.getLogger().setLevel(logging.INFO)
  326. if args.day:
  327. movies = TVGuideScraper._getMovies()
  328. else:
  329. movies = TVGuideScraper.findAllMovies()
  330. fmp = FreeboxMoviePlanner(
  331. movies,
  332. excluded_channels=args.exclude,
  333. excluded_directory=args.exclude_directory
  334. )