Files
nadeko_invidious/src/invidious/videos/parser.cr
Fijxu f248024b65 Merge squash 'unixfox:invidious-companion':
commit a5acddefa92c454fced4a9176df10dc85efdb516
Author: Emilien <4016501+unixfox@users.noreply.github.com>
Date:   Mon Dec 30 22:25:13 2024 +0100

    missing ,

commit 84b87bedadbd4d35190b1f4d6b3e4fc1abf2440a
Author: Emilien <4016501+unixfox@users.noreply.github.com>
Date:   Mon Dec 30 22:19:45 2024 +0100

    fixing format

commit bfaf72b3038c3c8cad6d5e68f9f2ad3a49c2a9fc
Author: Emilien <4016501+unixfox@users.noreply.github.com>
Date:   Mon Dec 30 21:52:34 2024 +0100

    skip proxy for invidious companion

commit f550359ae941d84cdaee0a966ed332354ef18f42
Author: Emilien <4016501+unixfox@users.noreply.github.com>
Date:   Mon Dec 30 21:52:07 2024 +0100

    !empty? to present?

commit e9c354d5a34df636306b1819dd17fff9e01b1a1e
Author: Émilien (perso) <4016501+unixfox@users.noreply.github.com>
Date:   Tue Dec 24 17:43:54 2024 +0000

    Better doc for invidious_companion_key

commit 0dba7675a2c1d51988b3f2911a9fb3a1f91bae52
Author: Émilien (perso) <4016501+unixfox@users.noreply.github.com>
Date:   Tue Dec 24 16:18:58 2024 +0000

    Better document private_url and public_url

commit 1de20546182421e1280ec2b68c6d347abead7c54
Author: Emilien <4016501+unixfox@users.noreply.github.com>
Date:   Fri Dec 13 20:08:57 2024 +0100

    add ability for invidious companion to check request from invidious

commit ab72bbad7afb7d143883a7d0610145f68c06bac8
Author: Emilien <4016501+unixfox@users.noreply.github.com>
Date:   Sun Dec 8 22:24:57 2024 +0100

    fix ameba Redundant use of `Object#to_s` in interpolation

commit a571eeaa381523f5efb29dea0f5fe097f4f1252c
Author: Emilien <4016501+unixfox@users.noreply.github.com>
Date:   Sun Dec 8 22:22:08 2024 +0100

    format watch.cr

commit f710dd37bf4327748b43067d75025cc915b5639c
Author: Emilien <4016501+unixfox@users.noreply.github.com>
Date:   Sun Dec 8 22:21:10 2024 +0100

    apply all the suggestions + rework invidious_companion parameter

commit 7a070fa710b7807cdda061d413ca9369a0962353
Author: Emilien <4016501+unixfox@users.noreply.github.com>
Date:   Mon Nov 18 12:30:37 2024 +0100

    invidious companion always used so always add CSP and redirect latest_version

commit 1f51edd0b915ca64df7f195aa271f74c7ef093cb
Author: Emilien <4016501+unixfox@users.noreply.github.com>
Date:   Mon Nov 18 12:22:23 2024 +0100

    fix linting

commit 734e72503f88f9741279ab385e86f5d2b340c71b
Author: Emilien <4016501+unixfox@users.noreply.github.com>
Date:   Sun Nov 17 19:18:29 2024 +0100

    fix download function when invidious companion used

commit bb2e3b2a3e5f53610b9dd602f8507303ec641450
Author: Emilien <4016501+unixfox@users.noreply.github.com>
Date:   Sun Nov 17 12:26:35 2024 +0100

    crystal handle decompression already by itself

commit b51770dbdbdcca04d04849d37e5f11ce20948c73
Author: Emilien <4016501+unixfox@users.noreply.github.com>
Date:   Sat Nov 16 23:00:48 2024 +0100

    fix linting + use .empty?

commit 9f846127aea9b4f392acb062d662fff2cc58d1d0
Author: Emilien <4016501+unixfox@users.noreply.github.com>
Date:   Sat Nov 16 22:38:00 2024 +0100

    fixing "end" misplacement

commit 1aa154b9787eddcdee960d06aed4c1c91f17c1c3
Author: Emilien <4016501+unixfox@users.noreply.github.com>
Date:   Sat Nov 16 22:33:28 2024 +0100

    separate invidious_companion logic + better config.yaml config

commit ff3305d52175c517b035d79b3c0c6a84809cbd0f
Author: Emilien <4016501+unixfox@users.noreply.github.com>
Date:   Fri Nov 8 21:05:17 2024 +0100

    move config checks for invidious companion

commit 409df4cff3cc69c5565a12feb307441eed36f937
Author: Émilien (perso) <4016501+unixfox@users.noreply.github.com>
Date:   Tue Nov 5 15:50:59 2024 +0100

    modify the description for config.example.yaml about invidious companion

commit 27b24f51abcccd1c68f4dc1c29c0c62ca26e604c
Author: Émilien (perso) <4016501+unixfox@users.noreply.github.com>
Date:   Tue Nov 5 15:31:45 2024 +0100

    Remove debug puts functions

    Co-authored-by: syeopite <70992037+syeopite@users.noreply.github.com>

commit 1c9f5b0a2b38ad94fb8972764ffae98df1e41dc9
Author: Émilien (perso) <4016501+unixfox@users.noreply.github.com>
Date:   Tue Nov 5 15:31:21 2024 +0100

    Use sample instead of Random.rand

    Co-authored-by: syeopite <70992037+syeopite@users.noreply.github.com>

commit 2cc204a0457665f8e334970d7e54b1843a667ab6
Author: Emilien <4016501+unixfox@users.noreply.github.com>
Date:   Fri Nov 1 21:30:58 2024 +0100

    throw error if inv_sig_helper and invidious_companion used same time

commit c612423a4d64f0adbef135074fc55dcc1c362f84
Author: Emilien Devos <4016501+unixfox@users.noreply.github.com>
Date:   Mon Oct 21 01:20:16 2024 +0200

    fixing condition for Content-Security-Policy

commit 195446337159d2cb92b48510af7311fe0cc0f5bb
Author: Emilien Devos <4016501+unixfox@users.noreply.github.com>
Date:   Sun Oct 20 23:53:08 2024 +0200

    fix Shadowing outer local variable `response`

commit 73c84baf9fa6eaf9c5d4981bc199f81306ebe5a2
Author: Emilien Devos <4016501+unixfox@users.noreply.github.com>
Date:   Sun Oct 20 23:51:00 2024 +0200

    redirect latest_version and dash manifest to invidious companion

commit 3dff7a76cf9f64ec70aac0a057a3b0bfa1edfc82
Author: Emilien Devos <4016501+unixfox@users.noreply.github.com>
Date:   Sun Oct 20 02:10:55 2024 +0200

    add support for invidious companion
2024-12-30 18:31:52 -03:00

491 lines
18 KiB
Crystal

require "json"
# Use to parse both "compactVideoRenderer" and "endScreenVideoRenderer".
# The former is preferred as it has more videos in it. The second has
# the same 11 first entries as the compact rendered.
#
# TODO: "compactRadioRenderer" (Mix) and
# TODO: Use a proper struct/class instead of a hacky JSON object
def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
return nil if !related["videoId"]?
# The compact renderer has video length in seconds, where the end
# screen rendered has a full text version ("42:40")
length = related["lengthInSeconds"]?.try &.as_i.to_s
length ||= related.dig?("lengthText", "simpleText").try do |box|
decode_length_seconds(box.as_s).to_s
end
# Both have "short", so the "long" option shouldn't be required
channel_info = (related["shortBylineText"]? || related["longBylineText"]?)
.try &.dig?("runs", 0)
author = channel_info.try &.dig?("text")
author_verified = has_verified_badge?(related["ownerBadges"]?).to_s
ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) }
# "4,088,033 views", only available on compact renderer
# and when video is not a livestream
view_count = related.dig?("viewCountText", "simpleText")
.try &.as_s.gsub(/\D/, "")
short_view_count = related.try do |r|
HelperExtractors.get_short_view_count(r).to_s
end
LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container")
# TODO: when refactoring video types, make a struct for related videos
# or reuse an existing type, if that fits.
return {
"id" => related["videoId"],
"title" => related["title"]["simpleText"],
"author" => author || JSON::Any.new(""),
"ucid" => JSON::Any.new(ucid || ""),
"length_seconds" => JSON::Any.new(length || "0"),
"view_count" => JSON::Any.new(view_count || "0"),
"short_view_count" => JSON::Any.new(short_view_count || "0"),
"author_verified" => JSON::Any.new(author_verified),
}
end
def extract_video_info(video_id : String)
# Init client config for the API
client_config = YoutubeAPI::ClientConfig.new
# Fetch data from the player endpoint
player_response = YoutubeAPI.player(video_id: video_id, params: "2AMB", client_config: client_config)
playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s
if playability_status != "OK"
subreason = player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "subreason")
reason = subreason.try &.[]?("simpleText").try &.as_s
reason ||= subreason.try &.[]("runs").as_a.map(&.[]("text")).join("")
reason ||= player_response.dig("playabilityStatus", "reason").as_s
# Stop here if video is not a scheduled livestream or
# for LOGIN_REQUIRED when videoDetails element is not found because retrying won't help
if !{"LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) ||
playability_status == "LOGIN_REQUIRED" && !player_response.dig?("videoDetails")
return {
"version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64),
"reason" => JSON::Any.new(reason),
}
end
elsif video_id != player_response.dig("videoDetails", "videoId")
# YouTube may return a different video player response than expected.
# See: https://github.com/TeamNewPipe/NewPipe/issues/8713
# Line to be reverted if one day we solve the video not available issue.
# Although technically not a call to /videoplayback the fact that YouTube is returning the
# wrong video means that we should count it as a failure.
get_playback_statistic()["totalRequests"] += 1
return {
"version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64),
"reason" => JSON::Any.new("Can't load the video on this Invidious instance. YouTube is currently trying to block Invidious instances. <a href=\"https://github.com/iv-org/invidious/issues/3822\">Click here for more info about the issue.</a>"),
}
else
reason = nil
end
# Don't fetch the next endpoint if the video is unavailable.
if {"OK", "LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status)
next_response = YoutubeAPI.next({"videoId": video_id, "params": ""})
player_response = player_response.merge(next_response)
end
params = parse_video_info(video_id, player_response)
params["reason"] = JSON::Any.new(reason) if reason
if CONFIG.invidious_companion.present?
new_player_response = nil
# Don't use Android test suite client if po_token is passed because po_token doesn't
# work for Android test suite client.
if reason.nil? && CONFIG.po_token.nil?
# Fetch the video streams using an Android client in order to get the
# decrypted URLs and maybe fix throttling issues (#2194). See the
# following issue for an explanation about decrypted URLs:
# https://github.com/TeamNewPipe/NewPipeExtractor/issues/562
client_config.client_type = YoutubeAPI::ClientType::AndroidTestSuite
new_player_response = try_fetch_streaming_data(video_id, client_config)
end
# Replace player response and reset reason
if !new_player_response.nil?
# Preserve captions & storyboard data before replacement
new_player_response["storyboards"] = player_response["storyboards"] if player_response["storyboards"]?
new_player_response["captions"] = player_response["captions"] if player_response["captions"]?
player_response = new_player_response
params.delete("reason")
end
end
{"captions", "playabilityStatus", "playerConfig", "storyboards", "invidiousCompanion"}.each do |f|
params[f] = player_response[f] if player_response[f]?
end
# Convert URLs, if those are present
if streaming_data = player_response["streamingData"]?
%w[formats adaptiveFormats].each do |key|
streaming_data.as_h[key]?.try &.as_a.each do |format|
format.as_h["url"] = JSON::Any.new(convert_url(format))
end
end
params["streamingData"] = streaming_data
end
# Data structure version, for cache control
params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64)
return params
end
def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConfig) : Hash(String, JSON::Any)?
LOGGER.debug("try_fetch_streaming_data: [#{id}] Using #{client_config.client_type} client.")
response = YoutubeAPI.player(video_id: id, params: "2AMB", client_config: client_config)
playability_status = response["playabilityStatus"]["status"]
LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.")
if id != response.dig("videoDetails", "videoId")
# YouTube may return a different video player response than expected.
# See: https://github.com/TeamNewPipe/NewPipe/issues/8713
raise InfoException.new(
"The video returned by YouTube isn't the requested one. (#{client_config.client_type} client)"
)
elsif playability_status == "OK"
return response
else
return nil
end
end
def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any)
# Top level elements
main_results = player_response.dig?("contents", "twoColumnWatchNextResults")
raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results
# Primary results are not available on Music videos
# See: https://github.com/iv-org/invidious/pull/3238#issuecomment-1207193725
if primary_results = main_results.dig?("results", "results", "contents")
video_primary_renderer = primary_results
.as_a.find(&.["videoPrimaryInfoRenderer"]?)
.try &.["videoPrimaryInfoRenderer"]
video_secondary_renderer = primary_results
.as_a.find(&.["videoSecondaryInfoRenderer"]?)
.try &.["videoSecondaryInfoRenderer"]
raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer
raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer
end
video_details = player_response.dig?("videoDetails")
microformat = player_response.dig?("microformat", "playerMicroformatRenderer")
raise BrokenTubeException.new("videoDetails") if !video_details
raise BrokenTubeException.new("microformat") if !microformat
# Basic video infos
title = video_details["title"]?.try &.as_s
# We have to try to extract viewCount from videoPrimaryInfoRenderer first,
# then from videoDetails, as the latter is "0" for livestreams (we want
# to get the amount of viewers watching).
views_txt = extract_text(
video_primary_renderer
.try &.dig?("viewCount", "videoViewCountRenderer", "viewCount")
)
views_txt ||= video_details["viewCount"]?.try &.as_s || ""
views = views_txt.gsub(/\D/, "").to_i64?
length_txt = (microformat["lengthSeconds"]? || video_details["lengthSeconds"])
.try &.as_s.to_i64
published = microformat["publishDate"]?
.try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc
premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp")
.try { |t| Time.parse_rfc3339(t.as_s) }
premiere_timestamp ||= player_response.dig?(
"playabilityStatus", "liveStreamability",
"liveStreamabilityRenderer", "offlineSlate",
"liveStreamOfflineSlateRenderer", "scheduledStartTime"
)
.try &.as_s.to_i64
.try { |t| Time.unix(t) }
live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow")
.try &.as_bool
live_now ||= video_details.dig?("isLive").try &.as_bool || false
post_live_dvr = video_details.dig?("isPostLiveDvr")
.try &.as_bool || false
# Extra video infos
allowed_regions = microformat["availableCountries"]?
.try &.as_a.map &.as_s || [] of String
allow_ratings = video_details["allowRatings"]?.try &.as_bool
family_friendly = microformat["isFamilySafe"].try &.as_bool
is_listed = video_details["isCrawlable"]?.try &.as_bool
is_upcoming = video_details["isUpcoming"]?.try &.as_bool
keywords = video_details["keywords"]?
.try &.as_a.map &.as_s || [] of String
# Related videos
LOGGER.debug("extract_video_info: parsing related videos...")
related = [] of JSON::Any
# Parse "compactVideoRenderer" items (under secondary results)
secondary_results = main_results
.dig?("secondaryResults", "secondaryResults", "results")
secondary_results.try &.as_a.each do |element|
if item = element["compactVideoRenderer"]?
related_video = parse_related_video(item)
related << JSON::Any.new(related_video) if related_video
end
end
# If nothing was found previously, fall back to end screen renderer
if related.empty?
# Container for "endScreenVideoRenderer" items
player_overlays = player_response.dig?(
"playerOverlays", "playerOverlayRenderer",
"endScreen", "watchNextEndScreenRenderer", "results"
)
player_overlays.try &.as_a.each do |element|
if item = element["endScreenVideoRenderer"]?
related_video = parse_related_video(item)
related << JSON::Any.new(related_video) if related_video
end
end
end
# Likes
toplevel_buttons = video_primary_renderer
.try &.dig?("videoActions", "menuRenderer", "topLevelButtons")
if toplevel_buttons
# New Format as of december 2023
likes_button = toplevel_buttons.dig?(0,
"segmentedLikeDislikeButtonViewModel",
"likeButtonViewModel",
"likeButtonViewModel",
"toggleButtonViewModel",
"toggleButtonViewModel",
"defaultButtonViewModel",
"buttonViewModel"
)
likes_button ||= toplevel_buttons.try &.as_a
.find(&.dig?("toggleButtonRenderer", "defaultIcon", "iconType").=== "LIKE")
.try &.["toggleButtonRenderer"]
# New format as of september 2022
likes_button ||= toplevel_buttons.try &.as_a
.find(&.["segmentedLikeDislikeButtonRenderer"]?)
.try &.dig?(
"segmentedLikeDislikeButtonRenderer",
"likeButton", "toggleButtonRenderer"
)
if likes_button
likes_txt = likes_button.dig?("accessibilityText")
# Note: The like count from `toggledText` is off by one, as it would
# represent the new like count in the event where the user clicks on "like".
likes_txt ||= (likes_button["defaultText"]? || likes_button["toggledText"]?)
.try &.dig?("accessibility", "accessibilityData", "label")
likes = likes_txt.as_s.gsub(/\D/, "").to_i64? if likes_txt
LOGGER.trace("extract_video_info: Found \"likes\" button. Button text is \"#{likes_txt}\"")
LOGGER.debug("extract_video_info: Likes count is #{likes}") if likes
end
end
# Description
description = microformat.dig?("description", "simpleText").try &.as_s || ""
short_description = player_response.dig?("videoDetails", "shortDescription")
# description_html = video_secondary_renderer.try &.dig?("description", "runs")
# .try &.as_a.try { |t| content_to_comment_html(t, video_id) }
description_html = parse_description(video_secondary_renderer.try &.dig?("attributedDescription"), video_id)
# Video metadata
metadata = video_secondary_renderer
.try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows")
.try &.as_a
genre = microformat["category"]?
genre_ucid = nil
license = nil
metadata.try &.each do |row|
metadata_title = extract_text(row.dig?("metadataRowRenderer", "title"))
contents = row.dig?("metadataRowRenderer", "contents", 0)
if metadata_title == "Category"
contents = contents.try &.dig?("runs", 0)
genre = contents.try &.["text"]?
genre_ucid = contents.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId")
elsif metadata_title == "License"
license = contents.try &.dig?("runs", 0, "text")
elsif metadata_title == "Licensed to YouTube by"
license = contents.try &.["simpleText"]?
end
end
# Music section
music_list = [] of VideoMusic
music_desclist = player_response.dig?(
"engagementPanels", 1, "engagementPanelSectionListRenderer",
"content", "structuredDescriptionContentRenderer", "items", 2,
"videoDescriptionMusicSectionRenderer", "carouselLockups"
)
music_desclist.try &.as_a.each do |music_desc|
artist = nil
album = nil
music_license = nil
# Used when the video has multiple songs
if song_title = music_desc.dig?("carouselLockupRenderer", "videoLockup", "compactVideoRenderer", "title")
# "simpleText" for plain text / "runs" when song has a link
song = song_title["simpleText"]? || song_title.dig?("runs", 0, "text")
# some videos can have empty tracks. See: https://www.youtube.com/watch?v=eBGIQ7ZuuiU
next if !song
end
music_desc.dig?("carouselLockupRenderer", "infoRows").try &.as_a.each do |desc|
desc_title = extract_text(desc.dig?("infoRowRenderer", "title"))
if desc_title == "ARTIST"
artist = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata"))
elsif desc_title == "SONG"
song = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata"))
elsif desc_title == "ALBUM"
album = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata"))
elsif desc_title == "LICENSES"
music_license = extract_text(desc.dig?("infoRowRenderer", "expandedMetadata"))
end
end
music_list << VideoMusic.new(song.to_s, album.to_s, artist.to_s, music_license.to_s)
end
# Author infos
author = video_details["author"]?.try &.as_s
ucid = video_details["channelId"]?.try &.as_s
if author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer")
author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url")
author_verified = has_verified_badge?(author_info["badges"]?)
subs_text = author_info["subscriberCountText"]?
.try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") }
.try &.as_s.split(" ", 2)[0]
end
# Return data
if live_now
video_type = VideoType::Livestream
elsif !premiere_timestamp.nil?
video_type = VideoType::Scheduled
published = premiere_timestamp || Time.utc
else
video_type = VideoType::Video
end
params = {
"videoType" => JSON::Any.new(video_type.to_s),
# Basic video infos
"title" => JSON::Any.new(title || ""),
"views" => JSON::Any.new(views || 0_i64),
"likes" => JSON::Any.new(likes || 0_i64),
"lengthSeconds" => JSON::Any.new(length_txt || 0_i64),
"published" => JSON::Any.new(published.to_rfc3339),
# Extra video infos
"allowedRegions" => JSON::Any.new(allowed_regions.map { |v| JSON::Any.new(v) }),
"allowRatings" => JSON::Any.new(allow_ratings || false),
"isFamilyFriendly" => JSON::Any.new(family_friendly || false),
"isListed" => JSON::Any.new(is_listed || false),
"isUpcoming" => JSON::Any.new(is_upcoming || false),
"keywords" => JSON::Any.new(keywords.map { |v| JSON::Any.new(v) }),
"isPostLiveDvr" => JSON::Any.new(post_live_dvr),
# Related videos
"relatedVideos" => JSON::Any.new(related),
# Description
"description" => JSON::Any.new(description || ""),
"descriptionHtml" => JSON::Any.new(description_html || "<p></p>"),
"shortDescription" => JSON::Any.new(short_description.try &.as_s || nil),
# Video metadata
"genre" => JSON::Any.new(genre.try &.as_s || ""),
"genreUcid" => JSON::Any.new(genre_ucid.try &.as_s?),
"license" => JSON::Any.new(license.try &.as_s || ""),
# Music section
"music" => JSON.parse(music_list.to_json),
# Author infos
"author" => JSON::Any.new(author || ""),
"ucid" => JSON::Any.new(ucid || ""),
"authorThumbnail" => JSON::Any.new(author_thumbnail.try &.as_s || ""),
"authorVerified" => JSON::Any.new(author_verified || false),
"subCountText" => JSON::Any.new(subs_text || "-"),
}
return params
end
private def convert_url(fmt)
if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) }
sp = cfr["sp"]
url = URI.parse(cfr["url"])
params = url.query_params
LOGGER.debug("convert_url: Decoding '#{cfr}'")
unsig = DECRYPT_FUNCTION.try &.decrypt_signature(cfr["s"])
params[sp] = unsig if unsig
else
url = URI.parse(fmt["url"].as_s)
params = url.query_params
end
n = DECRYPT_FUNCTION.try &.decrypt_nsig(params["n"])
params["n"] = n if n
if token = CONFIG.po_token
params["pot"] = token
end
url.query_params = params
LOGGER.trace("convert_url: new url is '#{url}'")
return url.to_s
rescue ex
LOGGER.debug("convert_url: Error when parsing video URL")
LOGGER.trace(ex.inspect_with_backtrace)
return ""
end