PageRenderTime 51ms CodeModel.GetById 13ms RepoModel.GetById 0ms app.codeStats 1ms

/picard/acoustid/__init__.py

https://github.com/phw/picard
Python | 300 lines | 239 code | 31 blank | 30 comment | 51 complexity | 92ccad56aae7893ea1a0c1d7edc52244 MD5 | raw file
  1. # -*- coding: utf-8 -*-
  2. #
  3. # Picard, the next-generation MusicBrainz tagger
  4. #
  5. # Copyright (C) 2011 Lukáš Lalinský
  6. # Copyright (C) 2017-2018 Sambhav Kothari
  7. # Copyright (C) 2018 Vishal Choudhary
  8. # Copyright (C) 2018-2021 Laurent Monin
  9. # Copyright (C) 2018-2022 Philipp Wolfer
  10. #
  11. # This program is free software; you can redistribute it and/or
  12. # modify it under the terms of the GNU General Public License
  13. # as published by the Free Software Foundation; either version 2
  14. # of the License, or (at your option) any later version.
  15. #
  16. # This program is distributed in the hope that it will be useful,
  17. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  18. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  19. # GNU General Public License for more details.
  20. #
  21. # You should have received a copy of the GNU General Public License
  22. # along with this program; if not, write to the Free Software
  23. # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  24. from collections import (
  25. deque,
  26. namedtuple,
  27. )
  28. from functools import partial
  29. import json
  30. from PyQt5 import QtCore
  31. from picard import log
  32. from picard.acoustid.json_helpers import parse_recording
  33. from picard.config import get_config
  34. from picard.const import FPCALC_NAMES
  35. from picard.const.sys import IS_WIN
  36. from picard.file import File
  37. from picard.util import (
  38. find_executable,
  39. win_prefix_longpath,
  40. )
  41. def get_score(node):
  42. try:
  43. return float(node.get('score', 1.0))
  44. except (TypeError, ValueError):
  45. return 1.0
  46. def get_fpcalc(config=None):
  47. if not config:
  48. config = get_config()
  49. fpcalc_path = config.setting["acoustid_fpcalc"]
  50. if not fpcalc_path:
  51. fpcalc_path = find_fpcalc()
  52. return fpcalc_path or 'fpcalc'
  53. def find_fpcalc():
  54. return find_executable(*FPCALC_NAMES)
  55. AcoustIDTask = namedtuple('AcoustIDTask', ('file', 'next_func'))
  56. class AcoustIDClient(QtCore.QObject):
  57. def __init__(self, acoustid_api):
  58. super().__init__()
  59. self._queue = deque()
  60. self._running = 0
  61. self._max_processes = 2
  62. self._acoustid_api = acoustid_api
  63. def init(self):
  64. pass
  65. def done(self):
  66. pass
  67. def _on_lookup_finished(self, task, document, http, error):
  68. doc = {}
  69. if error:
  70. mparms = {
  71. 'error': http.errorString(),
  72. 'body': document,
  73. 'filename': task.file.filename,
  74. }
  75. log.error(
  76. "AcoustID: Lookup network error for '%(filename)s': %(error)r, %(body)s" %
  77. mparms)
  78. self.tagger.window.set_statusbar_message(
  79. N_("AcoustID lookup network error for '%(filename)s'!"),
  80. mparms,
  81. echo=None
  82. )
  83. else:
  84. try:
  85. recording_list = doc['recordings'] = []
  86. status = document['status']
  87. if status == 'ok':
  88. results = document.get('results') or []
  89. for result in results:
  90. recordings = result.get('recordings') or []
  91. max_sources = max([r.get('sources', 1) for r in recordings] + [1])
  92. result_score = get_score(result)
  93. for recording in recordings:
  94. parsed_recording = parse_recording(recording)
  95. if parsed_recording is not None:
  96. # Calculate a score based on result score and sources for this
  97. # recording relative to other recordings in this result
  98. score = recording.get('sources', 1) / max_sources * 100
  99. parsed_recording['score'] = score * result_score
  100. parsed_recording['acoustid'] = result['id']
  101. recording_list.append(parsed_recording)
  102. if results:
  103. if not recording_list:
  104. # Set AcoustID in tags if there was no matching recording
  105. task.file.metadata['acoustid_id'] = results[0]['id']
  106. task.file.update()
  107. log.debug(
  108. "AcoustID: Found no matching recordings for '%s',"
  109. " setting acoustid_id tag to %r",
  110. task.file.filename, results[0]['id']
  111. )
  112. else:
  113. log.debug(
  114. "AcoustID: Lookup successful for '%s' (recordings: %d)",
  115. task.file.filename,
  116. len(recording_list)
  117. )
  118. else:
  119. mparms = {
  120. 'error': document['error']['message'],
  121. 'filename': task.file.filename
  122. }
  123. log.error(
  124. "AcoustID: Lookup error for '%(filename)s': %(error)r" %
  125. mparms)
  126. self.tagger.window.set_statusbar_message(
  127. N_("AcoustID lookup failed for '%(filename)s'!"),
  128. mparms,
  129. echo=None
  130. )
  131. except (AttributeError, KeyError, TypeError) as e:
  132. log.error("AcoustID: Error reading response", exc_info=True)
  133. error = e
  134. task.next_func(doc, http, error)
  135. def _lookup_fingerprint(self, task, result=None, error=None):
  136. if task.file.state == File.REMOVED:
  137. log.debug("File %r was removed", task.file)
  138. return
  139. mparms = {
  140. 'filename': task.file.filename
  141. }
  142. if not result:
  143. log.debug(
  144. "AcoustID: lookup returned no result for file '%(filename)s'" %
  145. mparms
  146. )
  147. self.tagger.window.set_statusbar_message(
  148. N_("AcoustID lookup returned no result for file '%(filename)s'"),
  149. mparms,
  150. echo=None
  151. )
  152. task.file.clear_pending()
  153. return
  154. log.debug(
  155. "AcoustID: looking up the fingerprint for file '%(filename)s'" %
  156. mparms
  157. )
  158. self.tagger.window.set_statusbar_message(
  159. N_("Looking up the fingerprint for file '%(filename)s' ..."),
  160. mparms,
  161. echo=None
  162. )
  163. params = dict(meta='recordings releasegroups releases tracks compress sources')
  164. if result[0] == 'fingerprint':
  165. fp_type, fingerprint, length = result
  166. params['fingerprint'] = fingerprint
  167. params['duration'] = str(length)
  168. else:
  169. fp_type, recordingid = result
  170. params['recordingid'] = recordingid
  171. self._acoustid_api.query_acoustid(partial(self._on_lookup_finished, task), **params)
  172. def _on_fpcalc_finished(self, task, exit_code, exit_status):
  173. process = self.sender()
  174. finished = process.property('picard_finished')
  175. if finished:
  176. return
  177. process.setProperty('picard_finished', True)
  178. result = None
  179. try:
  180. self._running -= 1
  181. self._run_next_task()
  182. if exit_code == 0 and exit_status == 0:
  183. output = bytes(process.readAllStandardOutput()).decode()
  184. jsondata = json.loads(output)
  185. # Use only integer part of duration, floats are not allowed in lookup
  186. duration = int(jsondata.get('duration'))
  187. fingerprint = jsondata.get('fingerprint')
  188. if fingerprint and duration:
  189. result = 'fingerprint', fingerprint, duration
  190. else:
  191. log.error(
  192. "Fingerprint calculator failed exit code = %r, exit status = %r, error = %s",
  193. exit_code,
  194. exit_status,
  195. process.errorString())
  196. except (json.decoder.JSONDecodeError, UnicodeDecodeError, ValueError):
  197. log.error("Error reading fingerprint calculator output", exc_info=True)
  198. finally:
  199. if result and result[0] == 'fingerprint':
  200. fp_type, fingerprint, length = result
  201. task.file.set_acoustid_fingerprint(fingerprint, length)
  202. task.next_func(result)
  203. def _on_fpcalc_error(self, task, error):
  204. process = self.sender()
  205. finished = process.property('picard_finished')
  206. if finished:
  207. return
  208. process.setProperty('picard_finished', True)
  209. try:
  210. self._running -= 1
  211. self._run_next_task()
  212. log.error(
  213. "Fingerprint calculator failed error= %s (%r) program=%r arguments=%r",
  214. process.errorString(), error, process.program(), process.arguments()
  215. )
  216. finally:
  217. task.next_func(None)
  218. def _run_next_task(self):
  219. try:
  220. task = self._queue.popleft()
  221. except IndexError:
  222. return
  223. if task.file.state == File.REMOVED:
  224. log.debug("File %r was removed", task.file)
  225. return
  226. self._running += 1
  227. process = QtCore.QProcess(self)
  228. process.setProperty('picard_finished', False)
  229. process.finished.connect(partial(self._on_fpcalc_finished, task))
  230. process.error.connect(partial(self._on_fpcalc_error, task))
  231. file_path = task.file.filename
  232. if IS_WIN:
  233. file_path = win_prefix_longpath(file_path)
  234. process.start(self._fpcalc, ["-json", "-length", "120", file_path])
  235. log.debug("Starting fingerprint calculator %r %r", self._fpcalc, task.file.filename)
  236. def analyze(self, file, next_func):
  237. fpcalc_next = partial(self._lookup_fingerprint, AcoustIDTask(file, next_func))
  238. task = AcoustIDTask(file, fpcalc_next)
  239. config = get_config()
  240. fingerprint = task.file.acoustid_fingerprint
  241. if not fingerprint and not config.setting["ignore_existing_acoustid_fingerprints"]:
  242. # use cached fingerprint from file metadata
  243. fingerprints = task.file.metadata.getall('acoustid_fingerprint')
  244. if fingerprints:
  245. fingerprint = fingerprints[0]
  246. task.file.set_acoustid_fingerprint(fingerprint)
  247. # If the fingerprint already exists skip calling fpcalc
  248. if fingerprint:
  249. length = task.file.acoustid_length
  250. fpcalc_next(result=('fingerprint', fingerprint, length))
  251. return
  252. # calculate the fingerprint
  253. self._fingerprint(task)
  254. def _fingerprint(self, task):
  255. if task.file.state == File.REMOVED:
  256. log.debug("File %r was removed", task.file)
  257. return
  258. self._queue.append(task)
  259. self._fpcalc = get_fpcalc()
  260. if self._running < self._max_processes:
  261. self._run_next_task()
  262. def fingerprint(self, file, next_func):
  263. self._fingerprint(AcoustIDTask(file, next_func))
  264. def stop_analyze(self, file):
  265. new_queue = deque()
  266. for task in self._queue:
  267. if task.file != file and task.file.state != File.REMOVED:
  268. new_queue.appendleft(task)
  269. self._queue = new_queue