backend.py 5.66 KB
Newer Older
dz0ny's avatar
dz0ny committed
1
2
3
# -*- coding: utf-8 -*-

from __future__ import unicode_literals
4

dz0ny's avatar
dz0ny committed
5
6
import re
import string
dz0ny's avatar
dz0ny committed
7
import unicodedata
8
9
from multiprocessing.pool import ThreadPool
from urlparse import parse_qs, urlparse
dz0ny's avatar
dz0ny committed
10

dz0ny's avatar
dz0ny committed
11
from mopidy import backend
12
13
14
15
from mopidy.models import Album, SearchResult, Track

import pafy

dz0ny's avatar
dz0ny committed
16
import pykka
17

dz0ny's avatar
dz0ny committed
18
import requests
19

dz0ny's avatar
dz0ny committed
20
21
from mopidy_youtube import logger

dz0ny's avatar
dz0ny committed
22
yt_api_endpoint = 'https://www.googleapis.com/youtube/v3/'
dz0ny's avatar
dz0ny committed
23
yt_key = 'AIzaSyAl1Xq9DwdE_KD4AtPaE4EJl3WZe2zCqg4'
dz0ny's avatar
dz0ny committed
24
session = requests.Session()
dz0ny's avatar
dz0ny committed
25

26
27
28
video_uri_prefix = 'youtube:video'
search_uri = 'youtube:search'

dz0ny's avatar
dz0ny committed
29

dz0ny's avatar
dz0ny committed
30
def resolve_track(track, stream=False):
31
    logger.debug("Resolving YouTube for track '%s'", track)
dz0ny's avatar
dz0ny committed
32
33
34
35
36
    if hasattr(track, 'uri'):
        return resolve_url(track.comment, stream)
    else:
        return resolve_url(track.split('.')[-1], stream)

dz0ny's avatar
dz0ny committed
37

dz0ny's avatar
dz0ny committed
38
39
def safe_url(uri):
    valid_chars = "-_.() %s%s" % (string.ascii_letters, string.digits)
dz0ny's avatar
dz0ny committed
40
41
42
43
44
45
46
47
48
    safe_uri = unicodedata.normalize(
        'NFKD',
        unicode(uri)
    ).encode('ASCII', 'ignore')
    return re.sub(
        '\s+',
        ' ',
        ''.join(c for c in safe_uri if c in valid_chars)
    ).strip()
dz0ny's avatar
dz0ny committed
49

dz0ny's avatar
dz0ny committed
50
51

def resolve_url(url, stream=False):
dz0ny's avatar
dz0ny committed
52
53
54
    try:
        video = pafy.new(url)
        if not stream:
55
56
            uri = '%s/%s.%s' % (
                video_uri_prefix, safe_url(video.title), video.videoid)
dz0ny's avatar
dz0ny committed
57
58
59
60
61
62
63
64
65
66
67
68
        else:
            uri = video.getbestaudio()
            if not uri:  # get video url
                uri = video.getbest()
            logger.debug('%s - %s %s %s' % (
                video.title, uri.bitrate, uri.mediatype, uri.extension))
            uri = uri.url
        if not uri:
            return
    except Exception as e:
        # Video is private or doesn't exist
        logger.info(e.message)
dz0ny's avatar
dz0ny committed
69
70
        return

71
    images = []
Koen Schmeets's avatar
Koen Schmeets committed
72
    if video.bigthumb is not None:
73
        images.append(video.bigthumb)
Koen Schmeets's avatar
Koen Schmeets committed
74
    if video.bigthumbhd is not None:
75
76
        images.append(video.bigthumbhd)

Remco Brink's avatar
Remco Brink committed
77
78
79
    track = Track(
        name=video.title,
        comment=video.videoid,
dz0ny's avatar
dz0ny committed
80
        length=video.length * 1000,
Remco Brink's avatar
Remco Brink committed
81
        album=Album(
82
            name='YouTube',
83
            images=images
Remco Brink's avatar
Remco Brink committed
84
85
86
        ),
        uri=uri
    )
dz0ny's avatar
dz0ny committed
87
88
89
    return track


dz0ny's avatar
dz0ny committed
90
91
92
93
94
95
96
97
def search_youtube(q):
    query = {
        'part': 'id',
        'maxResults': 15,
        'type': 'video',
        'q': q,
        'key': yt_key
    }
Nicolas Delaby's avatar
Nicolas Delaby committed
98
    result = session.get(yt_api_endpoint + 'search', params=query)
dz0ny's avatar
dz0ny committed
99
100
101
102
103
104
105
106
    data = result.json()

    resolve_pool = ThreadPool(processes=16)
    playlist = [item['id']['videoId'] for item in data['items']]

    playlist = resolve_pool.map(resolve_url, playlist)
    resolve_pool.close()
    return [item for item in playlist if item]
dz0ny's avatar
dz0ny committed
107
108


dz0ny's avatar
dz0ny committed
109
def resolve_playlist(url):
dz0ny's avatar
dz0ny committed
110
    resolve_pool = ThreadPool(processes=16)
111
    logger.info("Resolving YouTube-Playlist '%s'", url)
dz0ny's avatar
dz0ny committed
112
    playlist = []
113
114
115
116
117
118
119
120
121
122

    page = 'first'
    while page:
        params = {
            'playlistId': url,
            'maxResults': 50,
            'key': yt_key,
            'part': 'contentDetails'
        }
        if page and page != "first":
123
            logger.debug("Get YouTube-Playlist '%s' page %s", url, page)
124
125
            params['pageToken'] = page

Nicolas Delaby's avatar
Nicolas Delaby committed
126
        result = session.get(yt_api_endpoint + 'playlistItems', params=params)
127
128
129
130
131
132
133
        data = result.json()
        page = data.get('nextPageToken')

        for item in data["items"]:
            video_id = item['contentDetails']['videoId']
            playlist.append(video_id)

dz0ny's avatar
dz0ny committed
134
135
136
    playlist = resolve_pool.map(resolve_url, playlist)
    resolve_pool.close()
    return [item for item in playlist if item]
dz0ny's avatar
dz0ny committed
137

dz0ny's avatar
dz0ny committed
138

139
class YouTubeBackend(pykka.ThreadingActor, backend.Backend):
dz0ny's avatar
dz0ny committed
140
    def __init__(self, config, audio):
141
        super(YouTubeBackend, self).__init__()
dz0ny's avatar
dz0ny committed
142
        self.config = config
143
144
        self.library = YouTubeLibraryProvider(backend=self)
        self.playback = YouTubePlaybackProvider(audio=audio, backend=self)
dz0ny's avatar
dz0ny committed
145

dz0ny's avatar
dz0ny committed
146
        self.uri_schemes = ['youtube', 'yt']
dz0ny's avatar
dz0ny committed
147
148


149
class YouTubeLibraryProvider(backend.LibraryProvider):
dz0ny's avatar
dz0ny committed
150
    def lookup(self, track):
dz0ny's avatar
dz0ny committed
151
152
        if 'yt:' in track:
            track = track.replace('yt:', '')
dz0ny's avatar
dz0ny committed
153
154
155
156
157
158
159

        if 'youtube.com' in track:
            url = urlparse(track)
            req = parse_qs(url.query)
            if 'list' in req:
                return resolve_playlist(req.get('list')[0])
            else:
160
                return [item for item in [resolve_url(track)] if item]
dz0ny's avatar
dz0ny committed
161
        else:
162
            return [item for item in [resolve_track(track)] if item]
dz0ny's avatar
dz0ny committed
163

164
165
166
    def search(self, query=None, uris=None, exact=False):
        # TODO Support exact search

dz0ny's avatar
dz0ny committed
167
168
169
170
171
172
173
        if not query:
            return

        if 'uri' in query:
            search_query = ''.join(query['uri'])
            url = urlparse(search_query)
            if 'youtube.com' in url.netloc:
dz0ny's avatar
dz0ny committed
174
175
176
                req = parse_qs(url.query)
                if 'list' in req:
                    return SearchResult(
177
                        uri=search_uri,
dz0ny's avatar
dz0ny committed
178
179
180
                        tracks=resolve_playlist(req.get('list')[0])
                    )
                else:
dz0ny's avatar
dz0ny committed
181
                    logger.info(
182
                        "Resolving YouTube for track '%s'", search_query)
dz0ny's avatar
dz0ny committed
183
                    return SearchResult(
184
                        uri=search_uri,
185
                        tracks=[t for t in [resolve_url(search_query)] if t]
dz0ny's avatar
dz0ny committed
186
                    )
dz0ny's avatar
dz0ny committed
187
188
        else:
            search_query = ' '.join(query.values()[0])
189
            logger.info("Searching YouTube for query '%s'", search_query)
dz0ny's avatar
dz0ny committed
190
            return SearchResult(
191
                uri=search_uri,
dz0ny's avatar
dz0ny committed
192
193
                tracks=search_youtube(search_query)
            )
dz0ny's avatar
dz0ny committed
194
195


196
class YouTubePlaybackProvider(backend.PlaybackProvider):
197
198
199
200
201
202
203

    def translate_uri(self, uri):
        track = resolve_track(uri, True)
        if track is not None:
            return track.uri
        else:
            return None