aboutsummaryrefslogtreecommitdiff
path: root/addons/metadata.generic.albums
diff options
context:
space:
mode:
authorronie <ronie@kodi.tv>2019-03-04 02:07:02 +0100
committerronie <ronie@kodi.tv>2020-06-22 23:29:04 +0200
commit8a805fd53fbcf5e87356a388ebdefdc638821ab1 (patch)
treeb9bbbf6561a551425c22bfbe48be32f5f26794b8 /addons/metadata.generic.albums
parentbf424ee5bd9d3cb65e5f15b36ff9361c79920045 (diff)
add python album scraper
Diffstat (limited to 'addons/metadata.generic.albums')
-rw-r--r--addons/metadata.generic.albums/LICENSE.txt282
-rw-r--r--addons/metadata.generic.albums/addon.xml20
-rw-r--r--addons/metadata.generic.albums/changelog.txt31
-rw-r--r--addons/metadata.generic.albums/default.py32
-rw-r--r--addons/metadata.generic.albums/lib/allmusic.py122
-rw-r--r--addons/metadata.generic.albums/lib/discogs.py59
-rw-r--r--addons/metadata.generic.albums/lib/fanarttv.py45
-rw-r--r--addons/metadata.generic.albums/lib/musicbrainz.py154
-rw-r--r--addons/metadata.generic.albums/lib/nfo.py8
-rw-r--r--addons/metadata.generic.albums/lib/scraper.py442
-rw-r--r--addons/metadata.generic.albums/lib/theaudiodb.py124
-rw-r--r--addons/metadata.generic.albums/lib/utils.py24
-rw-r--r--addons/metadata.generic.albums/resources/icon.pngbin0 -> 15700 bytes
-rw-r--r--addons/metadata.generic.albums/resources/language/resource.language.en_gb/strings.po85
-rw-r--r--addons/metadata.generic.albums/resources/settings.xml107
15 files changed, 1535 insertions, 0 deletions
diff --git a/addons/metadata.generic.albums/LICENSE.txt b/addons/metadata.generic.albums/LICENSE.txt
new file mode 100644
index 0000000000..4f8e8eb30c
--- /dev/null
+++ b/addons/metadata.generic.albums/LICENSE.txt
@@ -0,0 +1,282 @@
+
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.
+ 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Library General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+-------------------------------------------------------------------------
diff --git a/addons/metadata.generic.albums/addon.xml b/addons/metadata.generic.albums/addon.xml
new file mode 100644
index 0000000000..acdc7b4f16
--- /dev/null
+++ b/addons/metadata.generic.albums/addon.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<addon id="metadata.generic.albums" name="Generic Album Scraper" version="1.0.7" provider-name="Team Kodi">
+ <requires>
+ <import addon="xbmc.python" version="3.0.0"/>
+ <import addon="xbmc.metadata" version="2.1.0"/>
+ </requires>
+ <extension point="xbmc.metadata.scraper.albums" library="default.py"/>
+ <extension point="xbmc.addon.metadata">
+ <summary lang="en_GB">Generic music scraper for albums</summary>
+ <description lang="en_GB">Searches for album information and artwork across multiple websites.</description>
+ <platform>all</platform>
+ <license>GPL-2.0-only</license>
+ <forum>https://forum.kodi.tv/showthread.php?tid=351570</forum>
+ <source>https://gitlab.com/ronie/metadata.generic.albums/</source>
+ <assets>
+ <icon>resources/icon.png</icon>
+ </assets>
+ <news>- first release</news>
+ </extension>
+</addon>
diff --git a/addons/metadata.generic.albums/changelog.txt b/addons/metadata.generic.albums/changelog.txt
new file mode 100644
index 0000000000..9d67dccb9f
--- /dev/null
+++ b/addons/metadata.generic.albums/changelog.txt
@@ -0,0 +1,31 @@
+v1.0.7
+- fix crash when album type is absent or empty in the api response
+- filter inaccurate search results from discogs
+- filter inaccurate albumdetails from allmusic
+- filter blank allmusic album thumb
+- consider both score and releasedate when selecting the top release from releasegroup
+
+v1.0.6
+- improve custom scoring
+- add support for original release date
+- fix release date from musicbrainz
+- use releasegroup id to fetch coverartarchive artwork
+- only use one release from each releasegroup
+- provide detailed search results
+
+v1.0.5
+- don't set releasetype
+- fix types from musicbrainz
+- add release status
+
+v1.0.4
+- catch time-outs
+
+v1.0.3
+- replace beautifulsoup with regex
+
+v1.0.2
+- replace requests with urllib
+
+v1.0.1
+- release
diff --git a/addons/metadata.generic.albums/default.py b/addons/metadata.generic.albums/default.py
new file mode 100644
index 0000000000..85aeb90dff
--- /dev/null
+++ b/addons/metadata.generic.albums/default.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+import sys
+from urllib.parse import parse_qsl
+from lib.scraper import Scraper
+
+
+class Main:
+ def __init__(self):
+ action, key, artist, album, url, nfo, settings = self._parse_argv()
+ Scraper(action, key, artist, album, url, nfo, settings)
+
+ def _parse_argv(self):
+ params = dict(parse_qsl(sys.argv[2].lstrip('?')))
+ # actions: resolveid, find, getdetails, NfoUrl
+ action = params['action']
+ # key: musicbrainz id
+ key = params.get('key', '')
+ # artist: artistname
+ artist = params.get('artist', '')
+ # album: albumtitle
+ album = params.get('title', '')
+ # url: provided by the scraper on previous run
+ url = params.get('url', '')
+ # nfo: musicbrainz url from .nfo file
+ nfo = params.get('nfo', '')
+ # path specific settings
+ settings = params.get('pathSettings', {})
+ return action, key, artist, album, url, nfo, settings
+
+
+if (__name__ == '__main__'):
+ Main()
diff --git a/addons/metadata.generic.albums/lib/allmusic.py b/addons/metadata.generic.albums/lib/allmusic.py
new file mode 100644
index 0000000000..ede10368c0
--- /dev/null
+++ b/addons/metadata.generic.albums/lib/allmusic.py
@@ -0,0 +1,122 @@
+# -*- coding: utf-8 -*-
+
+import datetime
+import difflib
+import time
+import re
+
+# not used for 'find', but needed for 'getdetails'
+def allmusic_albumfind(data, artist, album):
+ data = data.decode('utf-8')
+ albums = []
+ albumlist = re.findall('class="album">\s*(.*?)\s*</li', data, re.S)
+ for item in albumlist:
+ albumdata = {}
+ albumartist = re.search('class="artist">.*?>(.*?)</a', item, re.S)
+ if albumartist:
+ albumdata['artist'] = albumartist.group(1)
+ else: # classical album
+ continue
+ albumname = re.search('class="title">.*?>(.*?)</a', item, re.S)
+ if albumname:
+ albumdata['album'] = albumname.group(1)
+ else: # not likely to happen, but just in case
+ continue
+ # filter inaccurate results
+ artistmatch = difflib.SequenceMatcher(None, artist.decode('utf-8').lower(), albumdata['artist'].lower()).ratio()
+ albummatch = difflib.SequenceMatcher(None, album.decode('utf-8').lower(), albumdata['album'].lower()).ratio()
+ if artistmatch > 0.90 and albummatch > 0.90:
+ albumurl = re.search('class="title">\s*<a href="(.*?)"', item)
+ if albumurl:
+ albumdata['url'] = albumurl.group(1)
+ else: # not likely to happen, but just in case
+ continue
+ albumyear = re.search('class="year">\s*(.*?)\s*<', item, re.S)
+ if albumyear:
+ albumdata['year'] = albumyear.group(1)
+ else:
+ albumdata['year'] = ''
+ albumthumb = re.search('img src="(.*?)"', item)
+ if albumthumb:
+ albumdata['thumb'] = albumthumb.group(1)
+ else:
+ albumdata['thumb'] = ''
+ albums.append(albumdata)
+ return albums
+
+def allmusic_albumdetails(data):
+ data = data.decode('utf-8')
+ albumdata = {}
+ releasedata = re.search('class="release-date">.*?<span>(.*?)<', data, re.S)
+ if releasedata:
+ dateformat = releasedata.group(1)
+ if len(dateformat) > 4:
+ try:
+ # month day, year
+ albumdata['releasedate'] = datetime.datetime(*(time.strptime(dateformat, '%B %d, %Y')[0:3])).strftime('%Y-%m-%d')
+ except:
+ # month, year
+ albumdata['releasedate'] = datetime.datetime(*(time.strptime(dateformat, '%B, %Y')[0:3])).strftime('%Y-%m')
+ else:
+ # year
+ albumdata['releasedate'] = dateformat
+ yeardata = re.search('class="year".*?>\s*(.*?)\s*<', data)
+ if yeardata:
+ albumdata['year'] = yeardata.group(1)
+ genredata = re.search('class="genre">.*?">(.*?)<', data, re.S)
+ if genredata:
+ albumdata['genre'] = genredata.group(1)
+ styledata = re.search('class="styles">.*?div>\s*(.*?)\s*</div', data, re.S)
+ if styledata:
+ stylelist = re.findall('">(.*?)<', styledata.group(1))
+ if stylelist:
+ albumdata['styles'] = ' / '.join(stylelist)
+ mooddata = re.search('class="moods">.*?div>\s*(.*?)\s*</div', data, re.S)
+ if mooddata:
+ moodlist = re.findall('">(.*?)<', mooddata.group(1))
+ if moodlist:
+ albumdata['moods'] = ' / '.join(moodlist)
+ themedata = re.search('class="themes">.*?div>\s*(.*?)\s*</div', data, re.S)
+ if themedata:
+ themelist = re.findall('">(.*?)<', themedata.group(1))
+ if themelist:
+ albumdata['themes'] = ' / '.join(themelist)
+ ratingdata = re.search('itemprop="ratingValue">\s*(.*?)\s*</div', data)
+ if ratingdata:
+ albumdata['rating'] = ratingdata.group(1)
+ albumdata['votes'] = ''
+ titledata = re.search('class="album-title".*?>\s*(.*?)\s*<', data, re.S)
+ if titledata:
+ albumdata['album'] = titledata.group(1)
+ labeldata = re.search('class="label-catalog".*?<.*?>(.*?)<', data, re.S)
+ if labeldata:
+ albumdata['label'] = labeldata.group(1)
+ artistdata = re.search('class="album-artist".*?<span.*?>\s*(.*?)\s*</span', data, re.S)
+ if artistdata:
+ artistlist = re.findall('">(.*?)<', artistdata.group(1))
+ artists = []
+ for item in artistlist:
+ artistinfo = {}
+ artistinfo['artist'] = item
+ artists.append(artistinfo)
+ if artists:
+ albumdata['artist'] = artists
+ albumdata['artist_description'] = ' / '.join(artistlist)
+ thumbsdata = re.search('class="album-contain".*?src="(.*?)"', data, re.S)
+ if thumbsdata:
+ thumbs = []
+ thumbdata = {}
+ thumb = thumbsdata.group(1).rstrip('?partner=allrovi.com')
+ # ignore internal blank thumb
+ if thumb.startswith('http'):
+ # 0=largest / 1=75 / 2=150 / 3=250 / 4=400 / 5=500 / 6=1080
+ if thumb.endswith('f=5'):
+ thumbdata['image'] = thumb.replace('f=5', 'f=0')
+ thumbdata['preview'] = thumb.replace('f=5', 'f=2')
+ else:
+ thumbdata['image'] = thumb
+ thumbdata['preview'] = thumb
+ thumbdata['aspect'] = 'thumb'
+ thumbs.append(thumbdata)
+ albumdata['thumb'] = thumbs
+ return albumdata
diff --git a/addons/metadata.generic.albums/lib/discogs.py b/addons/metadata.generic.albums/lib/discogs.py
new file mode 100644
index 0000000000..09730ef0fc
--- /dev/null
+++ b/addons/metadata.generic.albums/lib/discogs.py
@@ -0,0 +1,59 @@
+# -*- coding: utf-8 -*-
+import difflib
+
+def discogs_albumfind(data, artist, album):
+ albums = []
+ masters = []
+ # sort results by lowest release id (first version of a release)
+ releases = sorted(data.get('results',[]), key=lambda k: k['id'])
+ for item in releases:
+ masterid = item['master_id']
+ # we are not interested in multiple versions that belong to the same master release
+ if masterid not in masters:
+ masters.append(masterid)
+ albumdata = {}
+ albumdata['artist'] = item['title'].split(' - ',1)[0]
+ albumdata['album'] = item['title'].split(' - ',1)[1]
+ albumdata['artist_description'] = item['title'].split(' - ',1)[0]
+ albumdata['year'] = str(item.get('year', ''))
+ albumdata['label'] = item['label'][0]
+ albumdata['thumb'] = item['thumb']
+ albumdata['dcalbumid'] = item['id']
+ # discogs does not provide relevance, use our own
+ artistmatch = difflib.SequenceMatcher(None, artist.lower(), albumdata['artist'].lower()).ratio()
+ albummatch = difflib.SequenceMatcher(None, album.lower(), albumdata['album'].lower()).ratio()
+ artistscore = round(artistmatch, 2)
+ albumscore = round(albummatch, 2)
+ score = round(((artistscore + albumscore) / 2), 2)
+ albumdata['relevance'] = str(score)
+ albums.append(albumdata)
+ return albums
+
+def discogs_albumdetails(data):
+ albumdata = {}
+ albumdata['album'] = data['title']
+ if 'styles' in data:
+ albumdata['styles'] = ' / '.join(data['styles'])
+ albumdata['genres'] = ' / '.join(data['genres'])
+ albumdata['year'] = str(data['year'])
+ albumdata['label'] = data['labels'][0]['name']
+ artists = []
+ for artist in data['artists']:
+ artistdata = {}
+ artistdata['artist'] = artist['name']
+ artists.append(artistdata)
+ albumdata['artist'] = artists
+ albumdata['artist_description'] = data['artists_sort']
+ albumdata['rating'] = str(int((float(data['community']['rating']['average']) * 2) + 0.5))
+ albumdata['votes'] = str(data['community']['rating']['count'])
+ if 'images' in data:
+ thumbs = []
+ for thumb in data['images']:
+ thumbdata = {}
+ thumbdata['image'] = thumb['uri']
+ thumbdata['preview'] = thumb['uri150']
+ # not accurate: discogs can provide any art type, there is no indication if it is an album front cover (thumb)
+ thumbdata['aspect'] = 'thumb'
+ thumbs.append(thumbdata)
+ albumdata['thumb'] = thumbs
+ return albumdata
diff --git a/addons/metadata.generic.albums/lib/fanarttv.py b/addons/metadata.generic.albums/lib/fanarttv.py
new file mode 100644
index 0000000000..1724945078
--- /dev/null
+++ b/addons/metadata.generic.albums/lib/fanarttv.py
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+
+def fanarttv_albumart(data):
+ if 'albums' in data:
+ albumdata = {}
+ thumbs = []
+ extras = []
+ discs = {}
+ for mbid, art in data['albums'].items():
+ if 'albumcover' in art:
+ for thumb in art['albumcover']:
+ thumbdata = {}
+ thumbdata['image'] = thumb['url']
+ thumbdata['preview'] = thumb['url'].replace('/fanart/', '/preview/')
+ thumbdata['aspect'] = 'thumb'
+ thumbs.append(thumbdata)
+ if 'cdart' in art:
+ albumdata['discart'] = art['cdart'][0]['url']
+ for cdart in art['cdart']:
+ extradata = {}
+ extradata['image'] = cdart['url']
+ extradata['preview'] = cdart['url'].replace('/fanart/', '/preview/')
+ extradata['aspect'] = 'discart'
+ extras.append(extradata)
+ # support for multi-disc albums
+ multidata = {}
+ num = cdart['disc']
+ multidata['image'] = cdart['url']
+ multidata['preview'] = cdart['url'].replace('/fanart/', '/preview/')
+ multidata['aspect'] = 'discart%s' % num
+ if not num in discs:
+ discs[num] = [multidata]
+ else:
+ discs[num].append(multidata)
+ if thumbs:
+ albumdata['thumb'] = thumbs
+ # only return for multi-discs, not single discs
+ if len(discs) > 1:
+ albumdata['multidiscart'] = discs
+ for k, v in discs.items():
+ for item in v:
+ extras.append(item)
+ if extras:
+ albumdata['extras'] = extras
+ return albumdata
diff --git a/addons/metadata.generic.albums/lib/musicbrainz.py b/addons/metadata.generic.albums/lib/musicbrainz.py
new file mode 100644
index 0000000000..0b7c9a36f6
--- /dev/null
+++ b/addons/metadata.generic.albums/lib/musicbrainz.py
@@ -0,0 +1,154 @@
+# -*- coding: utf-8 -*-
+
+def musicbrainz_albumfind(data, artist, album):
+ albums = []
+ # count how often each releasegroup occurs in the release results
+ # keep track of the release with the highest score and earliest releasedate in each releasegroup
+ releasegroups = {}
+ for item in data.get('releases'):
+ mbid = item['id']
+ score = item.get('score', 0)
+ releasegroup = item['release-group']['id']
+ if 'date' in item and item['date']:
+ date = item['date'].replace('-','')
+ if len(date) == 4:
+ date = date + '9999'
+ else:
+ date = '99999999'
+ if releasegroup in releasegroups:
+ count = releasegroups[releasegroup][0] + 1
+ topmbid = releasegroups[releasegroup][1]
+ topdate = releasegroups[releasegroup][2]
+ topscore = releasegroups[releasegroup][3]
+ if date < topdate and score >= topscore:
+ topdate = date
+ topmbid = mbid
+ releasegroups[releasegroup] = [count, topmbid, topdate, topscore]
+ else:
+ releasegroups[releasegroup] = [1, mbid, date, score]
+ if releasegroups:
+ # get the highest releasegroup count
+ maxcount = max(releasegroups.values())[0]
+ # get the releasegroup(s) that match this highest value
+ topgroups = [k for k, v in releasegroups.items() if v[0] == maxcount]
+ for item in data.get('releases'):
+ # only use the 'top' release from each releasegroup
+ if item['id'] != releasegroups[item['release-group']['id']][1]:
+ continue
+ albumdata = {}
+ if item.get('artist-credit'):
+ artists = []
+ artistdisp = ""
+ for artist in item['artist-credit']:
+ artistdata = {}
+ artistdata['artist'] = artist['artist']['name']
+ artistdata['mbartistid'] = artist['artist']['id']
+ artistdata['artistsort'] = artist['artist']['sort-name']
+ artistdisp = artistdisp + artist['artist']['name']
+ artistdisp = artistdisp + artist.get('joinphrase', '')
+ artists.append(artistdata)
+ albumdata['artist'] = artists
+ albumdata['artist_description'] = artistdisp
+ if item.get('label-info','') and item['label-info'][0].get('label','') and item['label-info'][0]['label'].get('name',''):
+ albumdata['label'] = item['label-info'][0]['label']['name']
+ albumdata['album'] = item['title']
+ if item.get('date',''):
+ albumdata['year'] = item['date'][:4]
+ albumdata['thumb'] = 'https://coverartarchive.org/release-group/%s/front-250' % item['release-group']['id']
+ if item.get('label-info','') and item['label-info'][0].get('label','') and item['label-info'][0]['label'].get('name',''):
+ albumdata['label'] = item['label-info'][0]['label']['name']
+ if item.get('status',''):
+ albumdata['releasestatus'] = item['status']
+ albumdata['type'] = item['release-group'].get('primary-type')
+ albumdata['mbalbumid'] = item['id']
+ albumdata['mbreleasegroupid'] = item['release-group']['id']
+ if item.get('score'):
+ releasescore = item['score'] / 100.0
+ # if the release is in the releasegroup with most releases, it is considered the most accurate one
+ # (this also helps with prefering official releases over bootlegs, assuming there are more variations of an official release than of a bootleg)
+ if item['release-group']['id'] not in topgroups:
+ releasescore -= 0.001
+ # if the release is an album, prefer it over singles/ep's
+ # (this needs to be the double of the above, as me might have just given the album a lesser score if the single happened to be in the topgroup)
+ if item['release-group'].get('primary-type') != 'Album':
+ releasescore -= 0.002
+ albumdata['relevance'] = str(releasescore)
+ albums.append(albumdata)
+ return albums
+
+def musicbrainz_albumdetails(data):
+ albumdata = {}
+ albumdata['album'] = data['title']
+ albumdata['mbalbumid'] = data['id']
+ if data.get('release-group',''):
+ albumdata['mbreleasegroupid'] = data['release-group']['id']
+ if data['release-group']['rating'] and data['release-group']['rating']['value']:
+ albumdata['rating'] = str(int((float(data['release-group']['rating']['value']) * 2) + 0.5))
+ albumdata['votes'] = str(data['release-group']['rating']['votes-count'])
+ if data['release-group'].get('primary-type'):
+ albumtypes = [data['release-group']['primary-type']] + data['release-group']['secondary-types']
+ albumdata['type'] = ' / '.join(albumtypes)
+ if 'Compilation' in albumtypes:
+ albumdata['compilation'] = 'true'
+ if data['release-group'].get('first-release-date',''):
+ albumdata['originaldate'] = data['release-group']['first-release-date']
+ if data.get('release-events',''):
+ albumdata['year'] = data['release-events'][0]['date'][:4]
+ albumdata['releasedate'] = data['release-events'][0]['date']
+ if data.get('label-info','') and data['label-info'][0].get('label','') and data['label-info'][0]['label'].get('name',''):
+ albumdata['label'] = data['label-info'][0]['label']['name']
+ if data.get('status',''):
+ albumdata['releasestatus'] = data['status']
+ if data.get('artist-credit'):
+ artists = []
+ artistdisp = ''
+ for artist in data['artist-credit']:
+ artistdata = {}
+ artistdata['artist'] = artist['name']
+ artistdata['mbartistid'] = artist['artist']['id']
+ artistdata['artistsort'] = artist['artist']['sort-name']
+ artistdisp = artistdisp + artist['name']
+ artistdisp = artistdisp + artist.get('joinphrase', '')
+ artists.append(artistdata)
+ albumdata['artist'] = artists
+ albumdata['artist_description'] = artistdisp
+ return albumdata
+
+def musicbrainz_albumart(data):
+ albumdata = {}
+ thumbs = []
+ extras = []
+ for item in data['images']:
+ if 'Front' in item['types']:
+ thumbdata = {}
+ thumbdata['image'] = item['image']
+ thumbdata['preview'] = item['thumbnails']['small']
+ thumbdata['aspect'] = 'thumb'
+ thumbs.append(thumbdata)
+ if 'Back' in item['types']:
+ albumdata['back'] = item['image']
+ backdata = {}
+ backdata['image'] = item['image']
+ backdata['preview'] = item['thumbnails']['small']
+ backdata['aspect'] = 'back'
+ extras.append(backdata)
+ if 'Medium' in item['types']:
+ albumdata['discart'] = item['image']
+ discartdata = {}
+ discartdata['image'] = item['image']
+ discartdata['preview'] = item['thumbnails']['small']
+ discartdata['aspect'] = 'discart'
+ extras.append(discartdata)
+ # exculde spine+back images
+ if 'Spine' in item['types'] and len(item['types']) == 1:
+ albumdata['spine'] = item['image']
+ spinedata = {}
+ spinedata['image'] = item['image']
+ spinedata['preview'] = item['thumbnails']['small']
+ spinedata['aspect'] = 'spine'
+ extras.append(spinedata)
+ if thumbs:
+ albumdata['thumb'] = thumbs
+ if extras:
+ albumdata['extras'] = extras
+ return albumdata
diff --git a/addons/metadata.generic.albums/lib/nfo.py b/addons/metadata.generic.albums/lib/nfo.py
new file mode 100644
index 0000000000..ef996ce52b
--- /dev/null
+++ b/addons/metadata.generic.albums/lib/nfo.py
@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+
+import re
+
+def nfo_geturl(data):
+ result = re.search('https://musicbrainz.org/(ws/2/)?release/([0-9a-z\-]*)', data)
+ if result:
+ return result.group(2)
diff --git a/addons/metadata.generic.albums/lib/scraper.py b/addons/metadata.generic.albums/lib/scraper.py
new file mode 100644
index 0000000000..7a05a11c58
--- /dev/null
+++ b/addons/metadata.generic.albums/lib/scraper.py
@@ -0,0 +1,442 @@
+# -*- coding: utf-8 -*-
+
+import json
+import socket
+import sys
+import time
+import urllib.parse
+import urllib.request
+import _strptime # https://bugs.python.org/issue7980
+from threading import Thread
+from urllib.error import HTTPError, URLError
+from socket import timeout
+import xbmc
+import xbmcgui
+import xbmcplugin
+import xbmcaddon
+from .theaudiodb import theaudiodb_albumdetails
+from .musicbrainz import musicbrainz_albumfind
+from .musicbrainz import musicbrainz_albumdetails
+from .musicbrainz import musicbrainz_albumart
+from .discogs import discogs_albumfind
+from .discogs import discogs_albumdetails
+from .allmusic import allmusic_albumfind
+from .allmusic import allmusic_albumdetails
+from .nfo import nfo_geturl
+from .fanarttv import fanarttv_albumart
+from .utils import *
+
+ADDONID = xbmcaddon.Addon().getAddonInfo('id')
+ADDONNAME = xbmcaddon.Addon().getAddonInfo('name')
+ADDONVERSION = xbmcaddon.Addon().getAddonInfo('version')
+
+
+def log(txt):
+ message = '%s: %s' % (ADDONID, txt)
+ xbmc.log(msg=message, level=xbmc.LOGDEBUG)
+
+def get_data(url, jsonformat):
+ try:
+ headers = {}
+ headers['User-Agent'] = '%s/%s ( http://kodi.tv )' % (ADDONNAME, ADDONVERSION)
+ req = urllib.request.Request(url, headers=headers)
+ resp = urllib.request.urlopen(req, timeout=5)
+ respdata = resp.read()
+ except URLError as e:
+ log('URLError: %s - %s' % (e.reason, url))
+ return
+ except HTTPError as e:
+ log('HTTPError: %s - %s' % (e.reason, url))
+ return
+ except socket.timeout as e:
+ log('socket: %s - %s' % (e, url))
+ return
+ if resp.getcode() == 503:
+ log('exceeding musicbrainz api limit')
+ return
+ elif resp.getcode() == 429:
+ log('exceeding discogs api limit')
+ return
+ if jsonformat:
+ respdata = json.loads(respdata)
+ return respdata
+
+
+class Scraper():
+ def __init__(self, action, key, artist, album, url, nfo, settings):
+ # get start time in milliseconds
+ self.start = int(round(time.time() * 1000))
+ # parse path settings
+ self.parse_settings(settings)
+ # return a dummy result, this is just for backward compitability with xml based scrapers https://github.com/xbmc/xbmc/pull/11632
+ if action == 'resolveid':
+ result = self.resolve_mbid(key)
+ if result:
+ self.return_resolved(result)
+ # search for artist name / album title matches
+ elif action == 'find':
+ # both musicbrainz and discogs allow 1 api per second. this query requires 1 musicbrainz api call and optionally 1 discogs api call
+ RATELIMIT = 1000
+ # try musicbrainz first
+ result = self.find_album(artist, album, 'musicbrainz')
+ if result:
+ self.return_search(result)
+ # fallback to discogs
+ else:
+ result = self.find_album(artist, album, 'discogs')
+ if result:
+ self.return_search(result)
+ # return info using artistname / albumtitle / id's
+ elif action == 'getdetails':
+ details = {}
+ url = json.loads(url)
+ artist = url['artist'].encode('utf-8')
+ album = url['album'].encode('utf-8')
+ mbid = url.get('mbalbumid', '')
+ dcid = url.get('dcalbumid', '')
+ mbreleasegroupid = url.get('mbreleasegroupid', '')
+ threads = []
+ # we have a musicbrainz album id, but no musicbrainz releasegroupid
+ if mbid and not mbreleasegroupid:
+ # musicbrainz allows 1 api per second.
+ RATELIMIT = 1000
+ for item in [[mbid, 'musicbrainz']]:
+ thread = Thread(target = self.get_details, args = (item[0], item[1], details))
+ threads.append(thread)
+ thread.start()
+ # wait for musicbrainz to finish
+ threads[0].join()
+ # check if we have a result:
+ if 'musicbrainz' in details:
+ artist = details['musicbrainz']['artist_description'].encode('utf-8')
+ album = details['musicbrainz']['album'].encode('utf-8')
+ mbreleasegroupid = details['musicbrainz']['mbreleasegroupid']
+ scrapers = [[mbreleasegroupid, 'theaudiodb'], [mbreleasegroupid, 'fanarttv'], [mbreleasegroupid, 'coverarchive'], [[artist, album], 'allmusic']]
+ if self.usediscogs == 1:
+ scrapers.append([[artist, album, dcid], 'discogs'])
+ # discogs allows 1 api per second. this query requires 2 discogs api calls
+ RATELIMIT = 2000
+ for item in scrapers:
+ thread = Thread(target = self.get_details, args = (item[0], item[1], details))
+ threads.append(thread)
+ thread.start()
+ # we have a discogs id and artistname and albumtitle
+ elif dcid:
+ # discogs allows 1 api per second. this query requires 1 discogs api call
+ RATELIMIT = 1000
+ for item in [[[artist, album, dcid], 'discogs'], [[artist, album], 'allmusic']]:
+ thread = Thread(target = self.get_details, args = (item[0], item[1], details))
+ threads.append(thread)
+ thread.start()
+ # we have musicbrainz album id, musicbrainz releasegroupid, artistname and albumtitle
+ else:
+ # musicbrainz allows 1 api per second.
+ RATELIMIT = 1000
+ scrapers = [[mbid, 'musicbrainz'], [mbreleasegroupid, 'theaudiodb'], [mbreleasegroupid, 'fanarttv'], [mbreleasegroupid, 'coverarchive'], [[artist, album], 'allmusic']]
+ if self.usediscogs == 1:
+ scrapers.append([[artist, album, dcid], 'discogs'])
+ # discogs allows 1 api per second. this query requires 2 discogs api calls
+ RATELIMIT = 2000
+ for item in scrapers:
+ thread = Thread(target = self.get_details, args = (item[0], item[1], details))
+ threads.append(thread)
+ thread.start()
+ for thread in threads:
+ thread.join()
+ result = self.compile_results(details)
+ if result:
+ self.return_details(result)
+ # extract the mbid from the provided musicbrainz url
+ elif action == 'NfoUrl':
+ mbid = nfo_geturl(nfo)
+ if mbid:
+ # create a dummy item
+ result = self.resolve_mbid(mbid)
+ if result:
+ self.return_nfourl(result)
+ # get end time in milliseconds
+ self.end = int(round(time.time() * 1000))
+ # handle musicbrainz and discogs ratelimit
+ if action == 'find' or action == 'getdetails':
+ if self.end - self.start < RATELIMIT:
+ # wait max 2 seconds
+ diff = RATELIMIT - (self.end - self.start)
+ xbmc.sleep(diff)
+ xbmcplugin.endOfDirectory(int(sys.argv[1]))
+
+ def parse_settings(self, data):
+ settings = json.loads(data)
+ # note: path settings are taken from the db, they may not reflect the current settings.xml file
+ self.genre = settings['genre']
+ self.lang = settings['lang']
+ self.mood = settings['mood']
+ self.rating = settings['rating']
+ self.style = settings['style']
+ self.theme = settings['theme']
+ self.usediscogs = settings['usediscogs']
+
+ def resolve_mbid(self, mbid):
+ # create dummy result
+ item = {}
+ item['artist_description'] = ''
+ item['album'] = ''
+ item['mbalbumid'] = mbid
+ item['mbreleasegroupid'] = ''
+ return item
+
+ def find_album(self, artist, album, site):
+ json = True
+ # musicbrainz
+ if site == 'musicbrainz':
+ url = MUSICBRAINZURL % (MUSICBRAINZSEARCH % (urllib.parse.quote_plus(album), urllib.parse.quote_plus(artist), urllib.parse.quote_plus(artist)))
+ scraper = musicbrainz_albumfind
+ # discogs
+ elif site == 'discogs':
+ url = DISCOGSURL % (DISCOGSSEARCH % (urllib.parse.quote_plus(album), urllib.parse.quote_plus(artist), DISCOGSKEY , DISCOGSSECRET))
+ scraper = discogs_albumfind
+ # allmusic
+ elif site == 'allmusic':
+ url = ALLMUSICURL % (ALLMUSICSEARCH % (urllib.parse.quote_plus(artist), urllib.parse.quote_plus(album)))
+ scraper = allmusic_albumfind
+ json = False
+ result = get_data(url, json)
+ if not result:
+ return
+ albumresults = scraper(result, artist, album)
+ return albumresults
+
+ def get_details(self, param, site, details):
+ json = True
+ # theaudiodb
+ if site == 'theaudiodb':
+ url = AUDIODBURL % (AUDIODBKEY, AUDIODBDETAILS % param)
+ albumscraper = theaudiodb_albumdetails
+ # musicbrainz
+ elif site == 'musicbrainz':
+ url = MUSICBRAINZURL % (MUSICBRAINZDETAILS % param)
+ albumscraper = musicbrainz_albumdetails
+ # fanarttv
+ elif site == 'fanarttv':
+ url = FANARTVURL % (param, FANARTVKEY)
+ albumscraper = fanarttv_albumart
+ # coverarchive
+ elif site == 'coverarchive':
+ url = MUSICBRAINZART % (param)
+ albumscraper = musicbrainz_albumart
+ # discogs
+ elif site == 'discogs':
+ dcalbumid = param[2]
+ if not dcalbumid:
+ # search
+ found = self.find_album(param[0], param[1], 'discogs')
+ if found:
+ # get details
+ dcalbumid = found[0]['dcalbumid']
+ else:
+ return
+ url = DISCOGSURL % (DISCOGSDETAILS % (dcalbumid, DISCOGSKEY, DISCOGSSECRET))
+ albumscraper = discogs_albumdetails
+ # allmusic
+ elif site == 'allmusic':
+ # search
+ found = self.find_album(param[0], param[1], 'allmusic')
+ if found:
+ # get details
+ url = ALLMUSICDETAILS % found[0]['url']
+ albumscraper = allmusic_albumdetails
+ json = False
+ else:
+ return
+ result = get_data(url, json)
+ if not result:
+ return
+ albumresults = albumscraper(result)
+ if not albumresults:
+ return
+ details[site] = albumresults
+ return details
+
+ def compile_results(self, details):
+ result = {}
+ thumbs = []
+ extras = []
+ # merge metadata results, start with the least accurate sources
+ if 'discogs' in details:
+ for k, v in details['discogs'].items():
+ result[k] = v
+ if k == 'thumb':
+ thumbs.append(v)
+ if 'allmusic' in details:
+ for k, v in details['allmusic'].items():
+ result[k] = v
+ if k == 'thumb':
+ thumbs.append(v)
+ if 'theaudiodb' in details:
+ for k, v in details['theaudiodb'].items():
+ result[k] = v
+ if k == 'thumb':
+ thumbs.append(v)
+ if k == 'extras':
+ extras.append(v)
+ if 'musicbrainz' in details:
+ for k, v in details['musicbrainz'].items():
+ result[k] = v
+ if 'coverarchive' in details:
+ for k, v in details['coverarchive'].items():
+ result[k] = v
+ if k == 'thumb':
+ thumbs.append(v)
+ if k == 'extras':
+ extras.append(v)
+ # prefer artwork from fanarttv
+ if 'fanarttv' in details:
+ for k, v in details['fanarttv'].items():
+ result[k] = v
+ if k == 'thumb':
+ thumbs.append(v)
+ if k == 'extras':
+ extras.append(v)
+ # use musicbrainz artist list as they provide mbid's, these can be passed to the artist scraper
+ if 'musicbrainz' in details:
+ result['artist'] = details['musicbrainz']['artist']
+ # provide artwork from all scrapers for getthumb option
+ if result:
+ # thumb list from most accurate sources first
+ thumbs.reverse()
+ thumbnails = []
+ for thumblist in thumbs:
+ for item in thumblist:
+ thumbnails.append(item)
+ # the order for extra art does not matter
+ extraart = []
+ for extralist in extras:
+ for item in extralist:
+ extraart.append(item)
+ # add the extra art to the end of the thumb list
+ thumbnails.extend(extraart)
+ result['thumb'] = thumbnails
+ data = self.user_prefs(details, result)
+ return data
+
+ def user_prefs(self, details, result):
+ # user preferences
+ lang = 'description' + self.lang
+ if 'theaudiodb' in details:
+ if lang in details['theaudiodb']:
+ result['description'] = details['theaudiodb'][lang]
+ elif 'descriptionEN' in details['theaudiodb']:
+ result['description'] = details['theaudiodb']['descriptionEN']
+ if (self.genre in details) and ('genre' in details[self.genre]):
+ result['genre'] = details[self.genre]['genre']
+ if (self.style in details) and ('styles' in details[self.style]):
+ result['styles'] = details[self.style]['styles']
+ if (self.mood in details) and ('moods' in details[self.mood]):
+ result['moods'] = details[self.mood]['moods']
+ if (self.theme in details) and ('themes' in details[self.theme]):
+ result['themes'] = details[self.theme]['themes']
+ if (self.rating in details) and ('rating' in details[self.rating]):
+ result['rating'] = details[self.rating]['rating']
+ result['votes'] = details[self.rating]['votes']
+ return result
+
+ def return_search(self, data):
+ for count, item in enumerate(data):
+ listitem = xbmcgui.ListItem(item['album'], offscreen=True)
+ listitem.setArt({'thumb': item['thumb']})
+ listitem.setProperty('album.artist', item['artist_description'])
+ listitem.setProperty('album.year', item.get('year',''))
+ listitem.setProperty('album.type', item.get('type',''))
+ listitem.setProperty('album.releasestatus', item.get('releasestatus',''))
+ listitem.setProperty('album.label', item.get('label',''))
+ listitem.setProperty('relevance', item['relevance'])
+ url = {'artist':item['artist_description'], 'album':item['album']}
+ if 'mbalbumid' in item:
+ url['mbalbumid'] = item['mbalbumid']
+ if 'mbreleasegroupid' in item:
+ url['mbreleasegroupid'] = item['mbreleasegroupid']
+ if 'dcalbumid' in item:
+ url['dcalbumid'] = item['dcalbumid']
+ xbmcplugin.addDirectoryItem(handle=int(sys.argv[1]), url=json.dumps(url), listitem=listitem, isFolder=True)
+
+ def return_nfourl(self, item):
+ url = {'artist':item['artist_description'], 'album':item['album'], 'mbalbumid':item['mbalbumid'], 'mbreleasegroupid':item['mbreleasegroupid']}
+ listitem = xbmcgui.ListItem(offscreen=True)
+ xbmcplugin.addDirectoryItem(handle=int(sys.argv[1]), url=json.dumps(url), listitem=listitem, isFolder=True)
+
+ def return_resolved(self, item):
+ url = {'artist':item['artist_description'], 'album':item['album'], 'mbalbumid':item['mbalbumid'], 'mbreleasegroupid':item['mbreleasegroupid']}
+ listitem = xbmcgui.ListItem(path=json.dumps(url), offscreen=True)
+ xbmcplugin.setResolvedUrl(handle=int(sys.argv[1]), succeeded=True, listitem=listitem)
+
+ def return_details(self, item):
+ if not 'album' in item:
+ return
+ listitem = xbmcgui.ListItem(item['album'], offscreen=True)
+ if 'mbalbumid' in item:
+ listitem.setProperty('album.musicbrainzid', item['mbalbumid'])
+ listitem.setProperty('album.releaseid', item['mbalbumid'])
+ if 'mbreleasegroupid' in item:
+ listitem.setProperty('album.releasegroupid', item['mbreleasegroupid'])
+ if 'scrapedmbid' in item:
+ listitem.setProperty('album.scrapedmbid', item['scrapedmbid'])
+ if 'artist' in item:
+ listitem.setProperty('album.artists', str(len(item['artist'])))
+ for count, artist in enumerate(item['artist']):
+ listitem.setProperty('album.artist%i.name' % (count + 1), artist['artist'])
+ listitem.setProperty('album.artist%i.musicbrainzid' % (count + 1), artist.get('mbartistid', ''))
+ listitem.setProperty('album.artist%i.sortname' % (count + 1), artist.get('artistsort', ''))
+ if 'genre' in item:
+ listitem.setProperty('album.genre', item['genre'])
+ if 'styles' in item:
+ listitem.setProperty('album.styles', item['styles'])
+ if 'moods' in item:
+ listitem.setProperty('album.moods', item['moods'])
+ if 'themes' in item:
+ listitem.setProperty('album.themes', item['themes'])
+ if 'description' in item:
+ listitem.setProperty('album.review', item['description'])
+ if 'releasedate' in item:
+ listitem.setProperty('album.releasedate', item['releasedate'])
+ if 'originaldate' in item:
+ listitem.setProperty('album.originaldate', item['originaldate'])
+ if 'releasestatus' in item:
+ listitem.setProperty('album.releasestatus', item['releasestatus'])
+ if 'artist_description' in item:
+ listitem.setProperty('album.artist_description', item['artist_description'])
+ if 'label' in item:
+ listitem.setProperty('album.label', item['label'])
+ if 'type' in item:
+ listitem.setProperty('album.type', item['type'])
+ if 'compilation' in item:
+ listitem.setProperty('album.compilation', item['compilation'])
+ if 'year' in item:
+ listitem.setProperty('album.year', item['year'])
+ if 'rating' in item:
+ listitem.setProperty('album.rating', item['rating'])
+ if 'votes' in item:
+ listitem.setProperty('album.votes', item['votes'])
+ art = {}
+ if 'discart' in item:
+ art['discart'] = item['discart']
+ if 'multidiscart' in item:
+ for k, v in item['multidiscart'].items():
+ discart = 'discart%s' % k
+ art[discart] = v[0]['image']
+ if 'back' in item:
+ art['back'] = item['back']
+ if 'spine' in item:
+ art['spine'] = item['spine']
+ if '3dcase' in item:
+ art['3dcase'] = item['3dcase']
+ if '3dflat' in item:
+ art['3dflat'] = item['3dflat']
+ if '3dface' in item:
+ art['3dface'] = item['3dface']
+ listitem.setArt(art)
+ if 'thumb' in item:
+ listitem.setProperty('album.thumbs', str(len(item['thumb'])))
+ for count, thumb in enumerate(item['thumb']):
+ listitem.setProperty('album.thumb%i.url' % (count + 1), thumb['image'])
+ listitem.setProperty('album.thumb%i.aspect' % (count + 1), thumb['aspect'])
+ listitem.setProperty('album.thumb%i.preview' % (count + 1), thumb['preview'])
+ xbmcplugin.setResolvedUrl(handle=int(sys.argv[1]), succeeded=True, listitem=listitem)
diff --git a/addons/metadata.generic.albums/lib/theaudiodb.py b/addons/metadata.generic.albums/lib/theaudiodb.py
new file mode 100644
index 0000000000..196e195c9a
--- /dev/null
+++ b/addons/metadata.generic.albums/lib/theaudiodb.py
@@ -0,0 +1,124 @@
+# -*- coding: utf-8 -*-
+
+def theaudiodb_albumdetails(data):
+ if data.get('album'):
+ item = data['album'][0]
+ albumdata = {}
+ albumdata['album'] = item['strAlbum']
+ if item.get('intYearReleased',''):
+ albumdata['year'] = item['intYearReleased']
+ if item.get('strStyle',''):
+ albumdata['styles'] = item['strStyle']
+ if item.get('strGenre',''):
+ albumdata['genre'] = item['strGenre']
+ if item.get('strLabel',''):
+ albumdata['label'] = item['strLabel']
+ if item.get('strReleaseFormat',''):
+ albumdata['type'] = item['strReleaseFormat']
+ if item.get('intScore',''):
+ albumdata['rating'] = str(int(float(item['intScore']) + 0.5))
+ if item.get('intScoreVotes',''):
+ albumdata['votes'] = item['intScoreVotes']
+ if item.get('strMood',''):
+ albumdata['moods'] = item['strMood']
+ if item.get('strTheme',''):
+ albumdata['themes'] = item['strTheme']
+ if item.get('strMusicBrainzID',''):
+ albumdata['mbreleasegroupid'] = item['strMusicBrainzID']
+ # api inconsistent
+ if item.get('strDescription',''):
+ albumdata['descriptionEN'] = item['strDescription']
+ elif item.get('strDescriptionEN',''):
+ albumdata['descriptionEN'] = item['strDescriptionEN']
+ if item.get('strDescriptionDE',''):
+ albumdata['descriptionDE'] = item['strDescriptionDE']
+ if item.get('strDescriptionFR',''):
+ albumdata['descriptionFR'] = item['strDescriptionFR']
+ if item.get('strDescriptionCN',''):
+ albumdata['descriptionCN'] = item['strDescriptionCN']
+ if item.get('strDescriptionIT',''):
+ albumdata['descriptionIT'] = item['strDescriptionIT']
+ if item.get('strDescriptionJP',''):
+ albumdata['descriptionJP'] = item['strDescriptionJP']
+ if item.get('strDescriptionRU',''):
+ albumdata['descriptionRU'] = item['strDescriptionRU']
+ if item.get('strDescriptionES',''):
+ albumdata['descriptionES'] = item['strDescriptionES']
+ if item.get('strDescriptionPT',''):
+ albumdata['descriptionPT'] = item['strDescriptionPT']
+ if item.get('strDescriptionSE',''):
+ albumdata['descriptionSE'] = item['strDescriptionSE']
+ if item.get('strDescriptionNL',''):
+ albumdata['descriptionNL'] = item['strDescriptionNL']
+ if item.get('strDescriptionHU',''):
+ albumdata['descriptionHU'] = item['strDescriptionHU']
+ if item.get('strDescriptionNO',''):
+ albumdata['descriptionNO'] = item['strDescriptionNO']
+ if item.get('strDescriptionIL',''):
+ albumdata['descriptionIL'] = item['strDescriptionIL']
+ if item.get('strDescriptionPL',''):
+ albumdata['descriptionPL'] = item['strDescriptionPL']
+ if item.get('strArtist',''):
+ albumdata['artist_description'] = item['strArtist']
+ artists = []
+ artistdata = {}
+ artistdata['artist'] = item['strArtist']
+ if item.get('strMusicBrainzArtistID',''):
+ artistdata['mbartistid'] = item['strMusicBrainzArtistID']
+ artists.append(artistdata)
+ albumdata['artist'] = artists
+ thumbs = []
+ extras = []
+ if item.get('strAlbumThumb',''):
+ thumbdata = {}
+ thumbdata['image'] = item['strAlbumThumb']
+ thumbdata['preview'] = item['strAlbumThumb'] + '/preview'
+ thumbdata['aspect'] = 'thumb'
+ thumbs.append(thumbdata)
+ if item.get('strAlbumThumbBack',''):
+ albumdata['back'] = item['strAlbumThumbBack']
+ extradata = {}
+ extradata['image'] = item['strAlbumThumbBack']
+ extradata['preview'] = item['strAlbumThumbBack'] + '/preview'
+ extradata['aspect'] = 'back'
+ extras.append(extradata)
+ if item.get('strAlbumSpine',''):
+ albumdata['spine'] = item['strAlbumSpine']
+ extradata = {}
+ extradata['image'] = item['strAlbumSpine']
+ extradata['preview'] = item['strAlbumSpine'] + '/preview'
+ extradata['aspect'] = 'spine'
+ extras.append(extradata)
+ if item.get('strAlbumCDart',''):
+ albumdata['discart'] = item['strAlbumCDart']
+ extradata = {}
+ extradata['image'] = item['strAlbumCDart']
+ extradata['preview'] = item['strAlbumCDart'] + '/preview'
+ extradata['aspect'] = 'discart'
+ extras.append(extradata)
+ if item.get('strAlbum3DCase',''):
+ albumdata['3dcase'] = item['strAlbum3DCase']
+ extradata = {}
+ extradata['image'] = item['strAlbum3DCase']
+ extradata['preview'] = item['strAlbum3DCase'] + '/preview'
+ extradata['aspect'] = '3dcase'
+ extras.append(extradata)
+ if item.get('strAlbum3DFlat',''):
+ albumdata['3dflat'] = item['strAlbum3DFlat']
+ extradata = {}
+ extradata['image'] = item['strAlbum3DFlat']
+ extradata['preview'] = item['strAlbum3DFlat'] + '/preview'
+ extradata['aspect'] = '3dflat'
+ extras.append(extradata)
+ if item.get('strAlbum3DFace',''):
+ albumdata['3dface'] = item['strAlbum3DFace']
+ extradata = {}
+ extradata['image'] = item['strAlbum3DFace']
+ extradata['preview'] = item['strAlbum3DFace'] + '/preview'
+ extradata['aspect'] = '3dface'
+ extras.append(extradata)
+ if thumbs:
+ albumdata['thumb'] = thumbs
+ if extras:
+ albumdata['extras'] = extras
+ return albumdata
diff --git a/addons/metadata.generic.albums/lib/utils.py b/addons/metadata.generic.albums/lib/utils.py
new file mode 100644
index 0000000000..e0ef3083c6
--- /dev/null
+++ b/addons/metadata.generic.albums/lib/utils.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+
+AUDIODBKEY = '58424d43204d6564696120'
+AUDIODBURL = 'https://www.theaudiodb.com/api/v1/json/%s/%s'
+AUDIODBSEARCH = 'searchalbum.php?s=%s&a=%s'
+AUDIODBDETAILS = 'album-mb.php?i=%s'
+
+MUSICBRAINZURL = 'https://musicbrainz.org/ws/2/release/%s'
+MUSICBRAINZSEARCH = '?query=release:"%s"%%20AND%%20(artistname:"%s"%%20OR%%20artist:"%s")&fmt=json'
+MUSICBRAINZDETAILS = '%s?inc=recordings+release-groups+artists+labels+ratings&fmt=json'
+MUSICBRAINZART = 'https://coverartarchive.org/release-group/%s'
+
+DISCOGSKEY = 'zACPgktOmNegwbwKWMaC'
+DISCOGSSECRET = 'wGuSOeMtfdkQxtERKQKPquyBwExSHdQq'
+DISCOGSURL = 'https://api.discogs.com/%s'
+DISCOGSSEARCH = 'database/search?release_title=%s&type=release&artist=%s&page=1&per_page=100&key=%s&secret=%s'
+DISCOGSDETAILS = 'releases/%i?key=%s&secret=%s'
+
+ALLMUSICURL = 'https://www.allmusic.com/%s'
+ALLMUSICSEARCH = 'search/albums/%s+%s'
+ALLMUSICDETAILS = '%s/releases'
+
+FANARTVKEY = 'ed4b784f97227358b31ca4dd966a04f1'
+FANARTVURL = 'https://webservice.fanart.tv/v3/music/albums/%s?api_key=%s'
diff --git a/addons/metadata.generic.albums/resources/icon.png b/addons/metadata.generic.albums/resources/icon.png
new file mode 100644
index 0000000000..b6748b3f54
--- /dev/null
+++ b/addons/metadata.generic.albums/resources/icon.png
Binary files differ
diff --git a/addons/metadata.generic.albums/resources/language/resource.language.en_gb/strings.po b/addons/metadata.generic.albums/resources/language/resource.language.en_gb/strings.po
new file mode 100644
index 0000000000..f0e777ee6b
--- /dev/null
+++ b/addons/metadata.generic.albums/resources/language/resource.language.en_gb/strings.po
@@ -0,0 +1,85 @@
+# Kodi Media Center language file
+# Addon Name: Generic Album Scraper
+# Addon id: metadata.generic.albums
+# Addon Provider: Team Kodi
+msgid ""
+msgstr ""
+"Project-Id-Version: KODI Main\n"
+"Report-Msgid-Bugs-To: https://github.com/xbmc/xbmc/issues\n"
+"POT-Creation-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: Kodi Translation Team\n"
+"Language-Team: English (United Kingdom) (http://www.transifex.com/projects/p/kodi-main/language/en_GB/)\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: en_GB\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+msgctxt "#30000"
+msgid "Preferences"
+msgstr ""
+
+msgctxt "#30001"
+msgid "Prefered language for album review"
+msgstr ""
+
+msgctxt "#30002"
+msgid "Prefer genres from"
+msgstr ""
+
+msgctxt "#30003"
+msgid "Prefer styles from"
+msgstr ""
+
+msgctxt "#30004"
+msgid "Prefer moods from"
+msgstr ""
+
+msgctxt "#30005"
+msgid "Prefer themes from"
+msgstr ""
+
+msgctxt "#30006"
+msgid "Prefer rating from"
+msgstr ""
+
+msgctxt "#30101"
+msgid "Use Discogs.com"
+msgstr ""
+
+msgctxt "#30102"
+msgid "As fallback only"
+msgstr ""
+
+msgctxt "#30103"
+msgid "Always"
+msgstr ""
+
+msgctxt "#30201"
+msgid "If available, the album review will be downloaded in the selected language. It will fallback to english."
+msgstr ""
+
+msgctxt "#30202"
+msgid "Try to get genre info using the selected scraper. Other scrapers will be used if the prefered scraper returns no results."
+msgstr ""
+
+msgctxt "#30203"
+msgid "Try to get style info using the selected scraper. Other scrapers will be used if the prefered scraper returns no results."
+msgstr ""
+
+msgctxt "#30204"
+msgid "Try to get mood info using the selected scraper. Other scrapers will be used if the prefered scraper returns no results."
+msgstr ""
+
+msgctxt "#30205"
+msgid "Try to get theme info using the selected scraper. Other scrapers will be used if the prefered scraper returns no results."
+msgstr ""
+
+msgctxt "#30206"
+msgid "Try to get rating info using the selected scraper. Other scrapers will be used if the prefered scraper returns no results."
+msgstr ""
+
+msgctxt "#30301"
+msgid "Fallback: only use the discogs scraper if the album can't be found on MusicBrainz (faster, but could result in less complete results). Always: retrieve info from discogs.com for each album (slower, but results could be more complete)"
+msgstr ""
diff --git a/addons/metadata.generic.albums/resources/settings.xml b/addons/metadata.generic.albums/resources/settings.xml
new file mode 100644
index 0000000000..51150c2c52
--- /dev/null
+++ b/addons/metadata.generic.albums/resources/settings.xml
@@ -0,0 +1,107 @@
+<?xml version="1.0" ?>
+<settings version="1">
+ <section id="metadata.generic.albums">
+ <category id="preferences" label="30000">
+ <group id="1">
+ <setting help="30201" id="lang" label="30001" type="string">
+ <level>0</level>
+ <default>EN</default>
+ <control format="string" type="spinner"/>
+ <constraints>
+ <options>
+ <option label="CN">CN</option>
+ <option label="DE">DE</option>
+ <option label="EN">EN</option>
+ <option label="ES">ES</option>
+ <option label="FR">FR</option>
+ <option label="HU">HU</option>
+ <option label="IL">IL</option>
+ <option label="IT">IT</option>
+ <option label="JP">JP</option>
+ <option label="NL">NL</option>
+ <option label="NO">NO</option>
+ <option label="PL">PL</option>
+ <option label="PT">PT</option>
+ <option label="RU">RU</option>
+ <option label="SE">SE</option>
+ </options>
+ </constraints>
+ </setting>
+ <setting help="30202" id="genre" label="30002" type="string">
+ <level>0</level>
+ <default>theaudiodb</default>
+ <control format="string" type="spinner"/>
+ <constraints>
+ <options>
+ <option label="allmusic">allmusic</option>
+ <option label="discogs">discogs</option>
+ <option label="theaudiodb">theaudiodb</option>
+ </options>
+ </constraints>
+ </setting>
+ <setting help="30203" id="style" label="30003" type="string">
+ <level>0</level>
+ <default>theaudiodb</default>
+ <control format="string" type="spinner"/>
+ <constraints>
+ <options>
+ <option label="allmusic">allmusic</option>
+ <option label="discogs">discogs</option>
+ <option label="theaudiodb">theaudiodb</option>
+ </options>
+ </constraints>
+ </setting>
+ <setting help="30204" id="mood" label="30004" type="string">
+ <level>0</level>
+ <default>theaudiodb</default>
+ <control format="string" type="spinner"/>
+ <constraints>
+ <options>
+ <option label="allmusic">allmusic</option>
+ <option label="theaudiodb">theaudiodb</option>
+ </options>
+ </constraints>
+ </setting>
+ <setting help="30205" id="theme" label="30005" type="string">
+ <level>0</level>
+ <default>theaudiodb</default>
+ <control format="string" type="spinner"/>
+ <constraints>
+ <options>
+ <option label="allmusic">allmusic</option>
+ <option label="theaudiodb">theaudiodb</option>
+ </options>
+ </constraints>
+ </setting>
+ <setting help="30206" id="rating" label="30006" type="string">
+ <level>0</level>
+ <default>musicbrainz</default>
+ <control format="string" type="spinner"/>
+ <constraints>
+ <options>
+ <option label="allmusic">allmusic</option>
+ <option label="discogs">discogs</option>
+ <option label="musicbrainz">musicbrainz</option>
+ <option label="theaudiodb">theaudiodb</option>
+ </options>
+ </constraints>
+ </setting>
+ </group>
+ </category>
+ <category id="options" label="33063">
+ <group id="1">
+ <setting help="30301" id="usediscogs" label="30101" type="integer">
+ <level>0</level>
+ <default>0</default>
+ <control format="string" type="spinner"/>
+ <constraints>
+ <options>
+ <option label="30102">0</option>
+ <option label="30103">1</option>
+ </options>
+ </constraints>
+ </setting>
+ </group>
+ </category>
+ </section>
+</settings>