AddCSLuaFile "shared.lua"
include "shared.lua"
---
-- Helper function for converting ISO 8601 time strings.
-- e.g. "PT1H23M45S" -> 5025
--
local function convertISO8601Time( duration )
if not isstring(duration) then return 0 end
local hours = tonumber( string.match(duration, "(%d+)H") ) or 0
local minutes = tonumber( string.match(duration, "(%d+)M") ) or 0
local seconds = tonumber( string.match(duration, "(%d+)S") ) or 0
duration = hours * 3600 + minutes * 60 + seconds
return duration
end
---
-- Get the value for an attribute from a html element
--
local function ParseElementAttribute( element, attribute )
if not element then return end
local output = string.match( element, attribute .. "%s-=%s-%b\"\"" )
if not output then return end
output = string.gsub( output, attribute .. "%s-=%s-", "" )
return string.sub( output, 2, -2 )
end
---
-- Get the contents of a html element by removing tags.
-- Used as fallback for when title cannot be found via meta tag.
--
local function ParseElementContent( element )
if not element then return end
local output = string.gsub( element, "^%s-<%w->%s-", "" )
return string.gsub( output, "%s-%w->%s-$", "" )
end
-- Lua search patterns to find metadata from the html
local patterns = {
["title"] = "",
["title_fallback"] = "
.-",
["duration"] = "",
["live"] = "",
["live_enddate"] = ""
}
---
-- Parse video metadata from a raw YouTube watch page HTML body.
--
function SERVICE:ParseYTMetaDataFromHTML( html )
local metadata = {}
-- Title: prefer og:title meta tag, fall back to element
metadata.title = ParseElementAttribute( string.match(html, patterns["title"]), "content" )
or ParseElementContent( string.match(html, patterns["title_fallback"]) )
-- Decode HTML entities (e.g. & -> &)
metadata.title = url.htmlentities_decode( metadata.title )
-- Live broadcast detection
local isLiveBroadcast = tobool( ParseElementAttribute( string.match(html, patterns["live"]), "content" ) )
local broadcastEndDate = string.match( html, patterns["live_enddate"] )
if isLiveBroadcast and not broadcastEndDate then
-- Ongoing live stream: mark duration as 0
metadata.duration = 0
else
-- Try the legacy tag (ISO 8601) first.
-- YouTube removed this tag around 2021, so it will usually be absent.
local durationISO8601 = ParseElementAttribute( string.match(html, patterns["duration"]), "content" )
if isstring(durationISO8601) then
metadata.duration = math.max( 1, convertISO8601Time(durationISO8601) )
else
-- Modern fallback: parse lengthSeconds from the ytInitialPlayerResponse
-- JSON blob that YouTube embeds directly in the page HTML.
local lengthSeconds = tonumber( string.match(html, '"lengthSeconds"%s*:%s*"(%d+)"') )
if lengthSeconds then
metadata.duration = math.max( 1, lengthSeconds )
end
end
end
return metadata
end
---
-- [FALLBACK] Fetch metadata by scraping the YouTube watch page server-side.
-- Used when the client-side iframe prefetch returns no usable data.
--
function SERVICE:FetchHTMLMetadata( callback )
local videoId = self:GetYouTubeVideoId()
if not videoId then
callback( false, "YouTube HTML fallback: could not resolve video id" )
return
end
local videoUrl = "https://www.youtube.com/watch?v=" .. videoId
self:Fetch( videoUrl,
function( body, length, headers, code )
local status, metadata = pcall( self.ParseYTMetaDataFromHTML, self, body )
if not status or not metadata.title or not isnumber(metadata.duration) then
local errInfo
if istable(metadata) then
errInfo = "title = " .. type(metadata.title) .. ", duration = " .. type(metadata.duration)
else
errInfo = tostring(metadata)
end
callback( false, "YouTube HTML fallback failed: " .. errInfo )
return
end
self:SetMetadata( metadata, true )
MediaPlayer.Metadata:Save( self )
callback( self._metadata )
end,
function( reason )
callback( false, "YouTube HTML fallback fetch failed: " .. tostring(reason) )
end,
{
["User-Agent"] = "Googlebot"
}
)
end
---
-- [PRIMARY] Use metadata gathered client-side by the iframe prefetch
-- (received via NetReadRequest).
--
function SERVICE:UsePrefetchedMetadata( callback )
if not self._metaTitle then
callback( false, "YouTube prefetch: no prefetched title available" )
return
end
local metadata = {}
metadata.title = self._metaTitle
if self._metaisLive then
metadata.duration = 0
else
-- Guard against math.Round(nil) which would throw and leave _metadata unset
metadata.duration = self._metaDuration and math.Round(self._metaDuration) or 0
end
self:SetMetadata( metadata, true )
MediaPlayer.Metadata:Save( self )
callback( self._metadata )
end
---
-- Metadata resolution.
--
-- Default:
-- cache -> iframe prefetch (primary) -> HTML scrape (fallback)
--
-- Debug (ForceHTMLScraping = true):
-- cache -> HTML scrape
--
-- Each step advances to the next only when it fails via callback(false, reason).
--
function SERVICE:GetMetadata( callback )
local cached, found = self:GetCachedMetadata()
if found then
callback(cached)
return
end
-- Debug override: bypass the prefetch and go straight to HTML scraping.
if self.ForceHTMLScraping then
self:FetchHTMLMetadata(function( metadata, htmlReason )
if metadata then
callback( metadata )
return
end
MsgN( "[MediaPlayer] " .. tostring(htmlReason) .. " — no further fallback available." )
callback( metadata, htmlReason )
end)
return
end
-- Primary: client-side iframe prefetch, with HTML scraping as the fallback.
self:UsePrefetchedMetadata(function( metadata, prefetchReason )
if metadata then
callback( metadata )
return
end
MsgN( "[MediaPlayer] " .. tostring(prefetchReason) .. " — falling back to HTML scraping." )
self:FetchHTMLMetadata(function( metadata2, htmlReason )
if metadata2 then
callback( metadata2 )
return
end
MsgN( "[MediaPlayer] HTML scrape also failed: " .. tostring(htmlReason) )
callback( false, prefetchReason or htmlReason )
end)
end)
end
function SERVICE:NetReadRequest()
if not self.PrefetchMetadata then return end
self._metaTitle = net.ReadString()
self._metaDuration = net.ReadUInt( 16 )
self._metaisLive = net.ReadBool()
-- Treat the "Unknown" sentinel (written by cl_init when title was nil/empty)
-- as nil so that the fallback chain can detect missing prefetch data.
if self._metaTitle == "" or self._metaTitle == "Unknown" then
self._metaTitle = nil
end
end