]> git.gir.st - subscriptionfeed.git/blob - app/dangerous/__init__.py
implement channel and playlist endpoints using innertube
[subscriptionfeed.git] / app / dangerous / __init__.py
1 # this is an alternative to proxying through invidious. the search endpoint has only been (loosely) tested by
2 #17:50 < perflyst[m]> appears to be working
3 #, so i hopeā„¢ this works. if not, that's why it's in the 'dangerous' blueprint
4 import requests
5 from flask import Blueprint, render_template, request, flash, g, url_for
6
7 from ..common.common import *
8 from ..common.innertube import *
9 from .lib import *
10 from .protobuf import make_sp, make_channel_params, make_playlist_params
11
12 frontend = Blueprint('dangerous', __name__,
13 template_folder='templates',
14 static_folder='static',
15 static_url_path='/static/ys')
16
17 @frontend.route('/search')
18 def search():
19 #token = getattr(current_user, 'token', 'guest')
20 q = request.args.get('q')
21 page = int(request.args.get('page', 1))
22
23 sp = make_sp(**{
24 k:v for k,v in request.args.items()
25 if k in ['sort','date','type','len']
26 })
27
28 if q:
29 yt_results = fetch_searchresults(q, page, sp)
30
31 results = prepare_searchresults(yt_results)
32 else:
33 results = None
34
35 return render_template('search.html.j2', rows=results, query=q, page=page)
36
37 # TODO: channels, playlists:
38 # https://github.com/iv-org/invidious/blob/452d1e8307d6344dd51c5437ccd032a566291c34/src/invidious/channels.cr#L399
39
40 @frontend.route('/channel/<channel_id>/')
41 @frontend.route('/channel/<channel_id>/<subpage>')
42 def channel(channel_id, subpage="videos"):
43 #TODO: if anything goes wrong, fall back to xmlfeed
44 if subpage == "videos":
45 page = int(request.args.get('page', 1))
46 sort_by = request.args.get('sort', "newest")
47 elif subpage == "playlists":
48 page = None # TODO: cursor
49 sort_by = request.args.get('sort', "modified")
50 else:
51 return "not found", 404
52
53 result = fetch_ajax(make_channel_params(channel_id, subpage, page, sort_by))
54
55 title, descr, thumb, rows = prepare_channel_items(result, channel_id)
56 # TODO: add is_pinned/is_hidden
57
58 return render_template('channel.html.j2',
59 title=title,
60 subpage=subpage,
61 rows=rows,
62 channel_id=channel_id,
63 channel_img=thumb,
64 channel_desc=descr,
65 page=page)
66
67 ############
68
69 # TODO: this belongs in common.innertube
70 def prepare_channel_items(result, channel_id):
71 response = listfind(result,'response')
72
73 meta1 = response.get('metadata',{}).get('channelMetadataRenderer',{})
74 meta2 = response.get('microformat',{}).get('microformatDataRenderer',{})
75 title = meta1.get('title', meta2.get('title'))
76 descr = meta1.get('description', meta2.get('description')) # meta2.description is capped at 160chars
77 thumb = mkthumbs(meta2.get('thumbnail',meta1.get('avatar',{})).get('thumbnails',{})) # .avatar ~ 900px
78
79 if 'continuationContents' in response.keys():
80 contents = response['continuationContents']
81 try: # TODO: cleanup
82 items = parse_channel_items(contents['gridContinuation']['items'], channel_id, title)
83 except:
84 try:
85 items = parse_channel_items(contents['sectionListContinuation']['contents'], channel_id, title)
86 except:
87 from flask import current_app
88 current_app.logger.error(result)
89 items = []
90 else: # if absent, we reach end of list
91 items = []
92
93 return title, descr, thumb, items
94
95 def parse_channel_items(items, channel_id, author):
96 result = []
97 for item in items:
98 key = next(iter(item.keys()), None)
99 content = item[key]
100 if key == "gridVideoRenderer" or key == "videoRenderer":
101 result.append({'type': 'VIDEO', 'content': {
102 'video_id': content['videoId'],
103 'title': content['title']['simpleText'],
104 'author': author,
105 'channel_id': channel_id,
106 'length': listfind(content.get('thumbnailOverlays',[]),'thumbnailOverlayTimeStatusRenderer').get('text',{}).get('simpleText'),
107 'views': toInt(content.get('viewCountText',{}).get('simpleText')),
108 'published': age(content.get('publishedTimeText',{}).get('simpleText')),
109 }})
110 elif key == "gridPlaylistRenderer" or key == "playlistRenderer":
111 result.append({'type': 'PLAYLIST', 'content': {
112 'playlist_id': content['navigationEndpoint']['watchEndpoint']['playlistId'],
113 'video_id': content['navigationEndpoint']['watchEndpoint']['videoId'],
114 'title': (content['title'].get('simpleText') or # playlistRenderer
115 content['title']['runs'][0]['text']), # gridPlaylistRenderer
116 'author': author,
117 'channel_id': channel_id,
118 'n_videos': toInt(content.get('videoCount') or # playlistRenderer
119 content.get('videoCountShortText',{}).get('simpleText') or # grid(1)
120 content.get('videoCountText',{}).get('runs',[{}])[0].get('text')), # grid(2)
121 }})
122 elif key == "itemSectionRenderer":
123 result.extend(parse_channel_items(content['contents'], channel_id, author))
124 else:
125 raise Exception(item) # XXX TODO
126
127 return result
128
129 def prepare_playlist(result):
130 contents = listfind(result,'response')['continuationContents']['playlistVideoListContinuation'] \
131 .get('contents',[]) # no .contents if overran end of playlist
132 return list(filter(None, map(parse_playlist, contents)))
133
134 def parse_playlist(item):
135 key = next(iter(item.keys()), None)
136 content = item[key]
137 if key == "playlistVideoRenderer":
138 if not content.get('isPlayable', False):
139 return None # private or deleted video
140
141 return {'type': 'VIDEO', 'content': {
142 'video_id': content['videoId'],
143 'title': (content['title'].get('simpleText') or # playable videos
144 content['title'].get('runs',[{}])[0].get('text')), # "[Private video]"
145 'playlist_id': content['navigationEndpoint']['watchEndpoint']['playlistId'],
146 'index': content['navigationEndpoint']['watchEndpoint']['index'], #or int(content['index']['simpleText'])
147 # rest is missing from unplayable videos:
148 'author': content.get('shortBylineText',{}).get('runs',[{}])[0].get('text'),
149 'channel_id':content.get('shortBylineText',{}).get('runs',[{}])[0].get('navigationEndpoint',{}).get('browseEndpoint',{}).get('browseId'),
150 'length': (content.get("lengthText",{}).get("simpleText") or # "8:51"
151 int(content.get("lengthSeconds", 0))), # "531"
152 'starttime': content['navigationEndpoint']['watchEndpoint'].get('startTimeSeconds'),
153 }}
154 else:
155 raise Exception(item) # XXX TODO
156
157 ############
158
159 @frontend.route('/playlist')
160 def playlist():
161 #TODO: if anything goes wrong, fall back to xmlfeed
162 playlist_id = request.args.get('list')
163 if not playlist_id:
164 return "bad list id", 400 # todo
165 page = int(request.args.get('page', 1))
166
167 offset = (page-1)*100 # each call returns 100 items
168 result = fetch_ajax(make_playlist_params(playlist_id, offset))
169
170 rows = prepare_playlist(result)
171
172 return render_template('playlist.html.j2',
173 title="playlist", # XXX: can't get playlist metadata from this, get from xmlfeed!
174 rows=rows,
175 page=page)
176
177 @frontend.before_app_request
178 def inject_button():
179 if not 'header_items' in g:
180 g.header_items = []
181 g.header_items.append({
182 'name': 'search',
183 'url': url_for('dangerous.search'),
184 'parent': frontend.name,
185 'priority': 15,
186 })
Imprint / Impressum