Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Restore related videos, recommendations, and auto-play next functionality #551

Merged
merged 66 commits into from
Jan 15, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
a398b3a
Fix regression in player monitor after 9be2f82
MoojMidge Dec 26, 2023
c10b06c
Fix RunScript deprecation log spam
MoojMidge Dec 26, 2023
996e282
Add some missing entries to the menu_items module
MoojMidge Dec 27, 2023
2787809
Use snake case consistently for watch later
MoojMidge Dec 27, 2023
b2be81a
Fix #549
MoojMidge Dec 27, 2023
86c9d91
Fix directory items sort order
MoojMidge Dec 27, 2023
3c9b315
Move set_content_type to XbmcContext
MoojMidge Dec 27, 2023
960cde1
Add sort for local history
MoojMidge Dec 29, 2023
a018257
Update datetime handling for utcnow deprecation
MoojMidge Dec 29, 2023
fc94cac
Add sort for local watch later
MoojMidge Dec 29, 2023
0c88a32
Misc tidy ups
MoojMidge Dec 29, 2023
74fa647
Only set count infolabel for video items
MoojMidge Jan 1, 2024
14bfaf3
Add as_str=True param to friendly_number
MoojMidge Jan 1, 2024
048fe09
Add track number for all video items
MoojMidge Jan 1, 2024
88c1486
Update YouTubeRequestClient.json_traverse
MoojMidge Jan 3, 2024
0cb2c0f
Move ListItem methods to kodion.ui.xbmc.xbmc_items
MoojMidge Jan 3, 2024
32c794b
Remove instance variables from YouTubeRequestsClient
MoojMidge Jan 4, 2024
6054678
Restore functionality of Youtube.get_related_videos
MoojMidge Jan 4, 2024
d713443
Check for valid request response when not using raise_exc
MoojMidge Jan 4, 2024
10896ac
Follow up to d713443
MoojMidge Jan 4, 2024
a1e3177
Follow up to b2be81a and 996e282
MoojMidge Jan 4, 2024
a4907c5
Remove unnecessary sleep
MoojMidge Jan 4, 2024
ff08062
Make use of progress dialog context manager
MoojMidge Jan 4, 2024
24b4426
Enable item icons in root menu
MoojMidge Jan 7, 2024
08b59a0
Extend thread join timeout for slower devices
MoojMidge Jan 7, 2024
5393dec
Update YouTube._get_recommendations_for_home
MoojMidge Jan 7, 2024
9c638ed
Get recommendations recursively and allow removal
MoojMidge Jan 8, 2024
0563496
Misc tidy ups
MoojMidge Jan 8, 2024
0795387
Move infotagger import to compatibility module
MoojMidge Jan 8, 2024
3cbae78
Workaround for Kodi play action on action DirectoryItems
MoojMidge Jan 8, 2024
ee1afb5
Add plugin category labels
MoojMidge Jan 8, 2024
08fdee1
Fix unnecessary double resolve on playback
MoojMidge Jan 8, 2024
29fb7fc
Merge remote-tracking branch 'upstream/master' into wip-unstable
MoojMidge Jan 8, 2024
2961cdc
Update settings
MoojMidge Jan 8, 2024
db58f43
Ensure data from fake v3 API result is not cached
MoojMidge Jan 9, 2024
70459c6
Cache recommendations after ranking and sorting
MoojMidge Jan 9, 2024
4ae9cf1
Use correct log level for error logging
MoojMidge Jan 10, 2024
1092f3b
Fix regression after 32c794b
MoojMidge Jan 10, 2024
d770077
Fix edge case where API request fails for existing play_data
MoojMidge Jan 10, 2024
398fb6e
Improve request error logging
MoojMidge Jan 10, 2024
e2e7d13
Consolidate client details and methods for API requests
MoojMidge Jan 10, 2024
75488c3
Update root menu items
MoojMidge Jan 10, 2024
569e7a9
Remove "YouTube - " prefix in category labels
MoojMidge Jan 11, 2024
d4de81b
Partially revert 75488c3
MoojMidge Jan 11, 2024
67765b9
Allow setting constants to be accessed from class/instance
MoojMidge Jan 12, 2024
addeef8
Fix showing error status when set setting fails
MoojMidge Jan 12, 2024
44a37ea
Use default content and mediatype constants
MoojMidge Jan 12, 2024
8952ba2
Misc tidy ups
MoojMidge Jan 12, 2024
25b0f96
Fix settings exception handling
MoojMidge Jan 12, 2024
420e755
Fix incorrect requests exception raising
MoojMidge Jan 12, 2024
280543e
Don't hide missing localised strings
MoojMidge Jan 12, 2024
880da42
Move XbmcContextUI.get_info_label to XbmcContext.get_infolabel
MoojMidge Jan 12, 2024
bae3670
Simplify setup wizard and start in XbmcRunner.run
MoojMidge Jan 12, 2024
e2a630b
Remove remaining Container.Update call
MoojMidge Jan 12, 2024
33b307d
Move maintenance/config/user operations to script endpoint
MoojMidge Jan 13, 2024
e4dc267
Fix checking str type in Python 2
MoojMidge Jan 13, 2024
a504769
Restore deprecated log levels in compatibility module
MoojMidge Jan 13, 2024
884c35d
Fix for AccessManager data not being updated
MoojMidge Jan 13, 2024
d06c142
Update default cache size in code
MoojMidge Jan 13, 2024
bad56aa
Use per user local history, search, watch later, and favorites
MoojMidge Jan 13, 2024
e38725e
Consistently check for custom history and watch later playlists
MoojMidge Jan 14, 2024
5d4d7b5
Fix incorrectly setting channel fanart after 82b9525
MoojMidge Jan 14, 2024
5fda40c
Force YouTube.get_related_videos to not use login details
MoojMidge Jan 14, 2024
b715b79
Update workflows
MoojMidge Jan 14, 2024
a973128
Improve Python datetime.strptime bug workaround
MoojMidge Jan 14, 2024
1f4c5ac
Add basic checks for valid My Subscription request data
MoojMidge Jan 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Update YouTube._get_recommendations_for_home
- Use new Youtube.get_related_videos and local history
- Fix #508
  • Loading branch information
MoojMidge committed Jan 7, 2024
commit 5393dec4a1dbc22da7e4c608785a8c6ec19eb6fa
292 changes: 192 additions & 100 deletions resources/lib/youtube_plugin/youtube/client/youtube.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
from __future__ import absolute_import, division, unicode_literals

import copy
import re
import threading
import xml.etree.ElementTree as ET
from itertools import chain
from random import randint

from .login_client import LoginClient
from ..helper.video_info import VideoInfo
Expand Down Expand Up @@ -369,129 +370,189 @@ def get_video_categories(self, page_token='', **kwargs):
**kwargs)

def _get_recommendations_for_home(self):
# YouTube has deprecated this API, so use history and related items to form
# a recommended set. We cache aggressively because searches incur a high
# quota cost of 100 on the YouTube API.
# Note this is a first stab attempt and can be refined a lot more.
# YouTube has deprecated this API, so we use history and related items
# to form a recommended set.
# We cache aggressively because searches can be slow.
# Note this is a naive implementation and can be refined a lot more.

# Do we have a cached result?
cache = self._context.get_data_cache()
# Caching of complete recommendation results currently disabled to allow
# for some variation in recommendations
# cache_home_key = 'get-activities-home'
# cached = cache.get_item(cache_home_key, cache.ONE_HOUR)
# if cached and cached['items']:
# return cached

payload = {
'kind': 'youtube#activityListResponse',
'items': []
}

local_history = self._context.get_settings().use_local_history()
history_id = self._context.get_access_manager().get_watch_history_id()
if not history_id or history_id == 'HL':
return payload

cache = self._context.get_data_cache()

# Do we have a cached result?
cache_home_key = 'get-activities-home'
cached = cache.get_item(cache_home_key, cache.ONE_HOUR * 4)
cached = cached and cached.get('items')
if cached:
return cached
if local_history:
history = self._context.get_playback_history()
video_ids = history.get_items()
else:
return payload
else:
history = self.get_playlist_items(history_id, max_results=50)
if history and 'items' in history:
history_items = history['items'] or []
video_ids = []
else:
return payload
for item in history_items:
try:
video_ids.append(item['snippet']['resourceId']['videoId'])
except KeyError:
continue

# Fetch existing list of items, if any
cache_items_key = 'get-activities-home-items'
cached = cache.get_item(cache_items_key, cache.ONE_WEEK * 2)
items = cached if cached else []
cached = cache.get_item(cache_items_key, cache.ONE_WEEK * 2) or []

items_per_page = self._max_results
items = [[] for _ in range(len(video_ids))]
counts = {
'_counter': 0,
'_pages': {},
'_related': {},
}

# Fetch history and recommended items. Use threads for faster execution.
def helper(video_id, responses):
self._context.log_debug(
'Method get_activities: doing expensive API fetch for related'
'items for video %s' % video_id
)
di = self.get_related_videos(video_id, max_results=10)
if 'items' in di:
# Record for which video we fetched the items
for item in di['items']:
item['plugin_fetched_for'] = video_id
responses.extend(di['items'])
def update_counts(items, item_store=None, original_ids=None):
if original_ids is not None:
original_ids = list(original_ids)

for item in items:
related = item['related_video_id']
channel = item['related_channel_id']
video_id = item['id']

counts['_related'].setdefault(related, 0)
counts['_related'][related] += 1

if video_id in counts:
item_count = counts[video_id]
item_count['related'].setdefault(related, 0)
item_count['related'][related] += 1
item_count['channels'].setdefault(channel, 0)
item_count['channels'][channel] += 1
else:
counts[video_id] = {
'related': {related: 1},
'channels': {channel: 1}
}
if item_store is None:
continue
if original_ids and related in original_ids:
idx = original_ids.index(related)
else:
idx = 0
item['order'] = items_per_page * idx + len(item_store[idx])
item_store[idx].append(item)

history = self.get_playlist_items(history_id, max_results=50)
update_counts(cached)

if not history.get('items'):
return payload
# Fetch history and recommended items. Use threads for faster execution.
def helper(video_id, responses):
related_videos = self.get_related_videos(video_id)
if related_videos:
responses.extend(related_videos['items'][:items_per_page])

running = 0
threads = []
candidates = []
already_fetched_for_video_ids = [item['plugin_fetched_for'] for item in items]
history_items = [item for item in history['items']
if re.match(r'(?P<video_id>[\w-]{11})',
item['snippet']['resourceId']['videoId'])]
for video_id in video_ids:
if video_id in counts['_related']:
continue
running += 1
thread = threading.Thread(
target=helper,
args=(video_id, candidates),
)
thread.daemon = True
threads.append(thread)
thread.start()

# TODO:
# It would be nice to make this 8 user configurable
for item in history_items[:8]:
video_id = item['snippet']['resourceId']['videoId']
if video_id not in already_fetched_for_video_ids:
thread = threading.Thread(target=helper, args=(video_id, candidates))
threads.append(thread)
thread.start()

for thread in threads:
thread.join()

# Prepend new candidates to items
seen = [item['id']['videoId'] for item in items]
for candidate in candidates:
vid = candidate['id']['videoId']
if vid not in seen:
seen.append(vid)
candidate['plugin_created_date'] = datetime_parser.since_epoch()
items.insert(0, candidate)
while running:
for thread in threads:
thread.join(5)
if not thread.is_alive():
running -= 1

update_counts(candidates[:500], items, video_ids)

# Truncate items to keep it manageable, and cache
items = items[:500]
items = list(chain.from_iterable(items))
counts['_counter'] = len(items)
remaining = 500 - counts['_counter']
if remaining > 0:
items.extend(cached[:remaining])
elif remaining:
items = items[:500]
cache.set_item(cache_items_key, items)

# Build the result set
items.sort(
key=lambda a: a.get('plugin_created_date', 0),
reverse=True
)
sorted_items = []
counter = 0
channel_counts = {}
while items:
counter += 1

# Hard stop on iteration. Good enough for our purposes.
if counter >= 1000:
break
# Finally sort items per page by rank and date for a better distribution
def rank_and_sort(item):
if 'order' not in item:
counts['_counter'] += 1
item['order'] = counts['_counter']

# Reset channel counts on a new page
if counter % 50 == 0:
channel_counts = {}
page = 1 + item['order'] // items_per_page
page_count = counts['_pages'].setdefault(page, {'_counter': 0})
while page_count['_counter'] < items_per_page and page > 1:
page -= 1
page_count = counts['_pages'].setdefault(page, {'_counter': 0})

# Ensure a single channel isn't hogging the page
item = items.pop()
related_video = item['related_video_id']
related_channel = item['related_channel_id']
channel_id = item.get('snippet', {}).get('channelId')
if not channel_id:
continue

channel_counts.setdefault(channel_id, 0)
if channel_counts[channel_id] <= 3:
# Use the item
channel_counts[channel_id] += 1
item["page_number"] = counter // 50
sorted_items.append(item)
else:
# Move the item to the end of the list
items.append(item)

# Finally sort items per page by date for a better distribution
def _sort_by_date_time(item):
return (item['page_number'],
-datetime_parser.since_epoch(datetime_parser.parse(
item['snippet']['publishedAt']
)))
# Video channel and related channel can be the same which can double
# up the channel count. Checking for this allows more similar videos
# in the recommendation, ignoring it allows for more variety.
# Currently prefer not to check for this to allow more variety.
# if channel_id == related_channel:
# channel_id = None
diversity_limits = items_per_page // 5
while (page_count['_counter'] >= items_per_page
or (related_video in page_count
and page_count[related_video] >= diversity_limits)
or (related_channel and related_channel in page_count
and page_count[related_channel] >= diversity_limits)
or (channel_id and channel_id in page_count
and page_count[channel_id] >= diversity_limits)
):
page += 1
page_count = counts['_pages'].setdefault(page, {'_counter': 0})

page_count.setdefault(related_video, 0)
page_count[related_video] += 1
if related_channel:
page_count.setdefault(related_channel, 0)
page_count[related_channel] += 1
if channel_id:
page_count.setdefault(channel_id, 0)
page_count[channel_id] += 1
page_count['_counter'] += 1
item['page'] = page

item_count = counts[item['id']]
item['rank'] = (2 * sum(item_count['channels'].values())
+ sum(item_count['related'].values()))

return (
-item['page'],
item['rank'],
-randint(0, item['order'])
)

sorted_items.sort(key=_sort_by_date_time)
items.sort(key=rank_and_sort, reverse=True)

# Finalize result
payload['items'] = sorted_items
payload['items'] = items
"""
# TODO:
# Enable pagination
Expand All @@ -501,9 +562,9 @@ def _sort_by_date_time(item):
}
"""
# Update cache
cache.set_item(cache_home_key, payload)
# Currently disabled to allow some variation in recommendations
# cache.set_item(cache_home_key, payload)

# If there are no sorted_items we fall back to default API behaviour
return payload

def get_activities(self, channel_id, page_token='', **kwargs):
Expand All @@ -514,7 +575,7 @@ def get_activities(self, channel_id, page_token='', **kwargs):

if channel_id == 'home':
recommended = self._get_recommendations_for_home()
if 'items' in recommended and recommended.get('items'):
if 'items' in recommended and recommended['items']:
return recommended
if channel_id == 'home':
params['home'] = 'true'
Expand Down Expand Up @@ -781,18 +842,49 @@ def get_related_videos(self,
if not related_videos:
return []

channel_id = self.json_traverse(
result,
path=(
'contents',
'twoColumnWatchNextResults',
'results',
'results',
'contents',
1,
'videoSecondaryInfoRenderer',
'owner',
'videoOwnerRenderer',
'title',
'runs',
0,
'navigationEndpoint',
'browseEndpoint',
'browseId'
)
)

v3_response = {
'kind': 'youtube#videoListResponse',
'items': [
{
'kind': "youtube#video",
'id': video['videoId'],
'related_video_id': video_id,
'related_channel_id': channel_id,
'snippet': {
'title': video['title']['simpleText'],
'thumbnails': dict(zip(
('default', 'high'),
video['thumbnail']['thumbnails'],
)),
'channelId': self.json_traverse(video, (
('longBylineText', 'shortBylineText'),
'runs',
0,
'navigationEndpoint',
'browseEndpoint',
'browseId',
)),
}
}
for video in related_videos
Expand Down
Loading