add player response model
This commit is contained in:
parent
b85b9893a8
commit
030fd9934e
25 changed files with 11765 additions and 121 deletions
14
.editorconfig
Normal file
14
.editorconfig
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
max_line_length = 88
|
||||
|
||||
[{Makefile,*.go}]
|
||||
indent_style = tab
|
||||
|
||||
[*.{json,md,rst,ini,yml,yaml,xml,html,js,jsx,ts,tsx,vue,kt}]
|
||||
indent_size = 2
|
||||
|
|
@ -13,12 +13,16 @@ anyhow = "1.0"
|
|||
thiserror = "1.0.31"
|
||||
url = "2.2.2"
|
||||
log = "0.4.17"
|
||||
reqwest = "0.11.11"
|
||||
reqwest = {version = "0.11.11", features = ["json", "gzip", "brotli"]}
|
||||
tokio = {version = "1.20.0", features = ["macros"]}
|
||||
serde_json = "1.0.82"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_with = {version = "2.0.0", features = ["json"] }
|
||||
rand = "0.8.5"
|
||||
async-trait = "0.1.56"
|
||||
chrono = {version = "0.4.19", features = ["serde"]}
|
||||
|
||||
[dev-dependencies]
|
||||
env_logger = "0.9.0"
|
||||
test-log = "0.2.11"
|
||||
rstest = "0.15.0"
|
||||
|
|
|
|||
194
notes/player/web_agegate.json
Normal file
194
notes/player/web_agegate.json
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
{
|
||||
"responseContext": {
|
||||
"visitorData": "CgtuR3hENUtwQzBvayiPzPuWBg%3D%3D",
|
||||
"serviceTrackingParams": [
|
||||
{
|
||||
"service": "GFEEDBACK",
|
||||
"params": [
|
||||
{ "key": "is_viewed_live", "value": "False" },
|
||||
{ "key": "logged_in", "value": "0" },
|
||||
{
|
||||
"key": "e",
|
||||
"value": "24233755,24199774,24246200,24002022,24219713,24037232,23918597,24007246,24223706,24085811,24227532,24002025,24198067,24245161,24237632,24248297,24199724,24248385,24199710,24220892,24108448,24120820,24215196,24187043,23882503,24236723,24185614,23986034,24197276,24036947,24080738,24209349,24245745,24222953,24187377,24164186,1714250,24001373,24241875,24191629,24152442,23966208,23804281,24252017,24181174,24034168,24135310,23983296,24198982,23946420,24219033,24244808,24140247,24161116,24227844,24198739,24166867,24230151,24241708,24184446,24114244,24077266,24230625,24241936,23744176,24248085,24225483,39321934,24246937,24169501,23998056,23934970,24238983,24004644,24077241,24222379,24229161,24251439"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "CSI",
|
||||
"params": [
|
||||
{ "key": "c", "value": "WEB" },
|
||||
{ "key": "cver", "value": "2.20220721.05.00" },
|
||||
{ "key": "yt_li", "value": "0" },
|
||||
{ "key": "GetPlayer_rid", "value": "0xaa89ca294db4f880" }
|
||||
]
|
||||
},
|
||||
{ "service": "GUIDED_HELP", "params": [{ "key": "logged_in", "value": "0" }] },
|
||||
{
|
||||
"service": "ECATCHER",
|
||||
"params": [
|
||||
{ "key": "client.version", "value": "2.20220721" },
|
||||
{ "key": "client.name", "value": "WEB" },
|
||||
{
|
||||
"key": "client.fexp",
|
||||
"value": "24233755,24199774,24246200,24002022,24219713,24037232,23918597,24007246,24223706,24085811,24227532,24002025,24198067,24245161,24237632,24248297,24199724,24248385,24199710,24220892,24108448,24120820,24215196,24187043,23882503,24236723,24185614,23986034,24197276,24036947,24080738,24209349,24245745,24222953,24187377,24164186,1714250,24001373,24241875,24191629,24152442,23966208,23804281,24252017,24181174,24034168,24135310,23983296,24198982,23946420,24219033,24244808,24140247,24161116,24227844,24198739,24166867,24230151,24241708,24184446,24114244,24077266,24230625,24241936,23744176,24248085,24225483,39321934,24246937,24169501,23998056,23934970,24238983,24004644,24077241,24222379,24229161,24251439"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"mainAppWebResponseContext": { "loggedOut": true },
|
||||
"webResponseContextExtensionData": { "hasDecorated": true }
|
||||
},
|
||||
"playabilityStatus": {
|
||||
"status": "LOGIN_REQUIRED",
|
||||
"reason": "Sign in to confirm your age",
|
||||
"errorScreen": {
|
||||
"playerErrorMessageRenderer": {
|
||||
"subreason": {
|
||||
"runs": [{ "text": "This video may be inappropriate for some users." }]
|
||||
},
|
||||
"reason": { "simpleText": "Sign in to confirm your age" },
|
||||
"proceedButton": {
|
||||
"buttonRenderer": {
|
||||
"style": "STYLE_OVERLAY",
|
||||
"size": "SIZE_DEFAULT",
|
||||
"isDisabled": false,
|
||||
"text": { "simpleText": "Sign in" },
|
||||
"navigationEndpoint": {
|
||||
"clickTrackingParams": "CAEQ8FsiEwiakLHP2pT5AhWCvlUKHbnWCWc=",
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"url": "https://accounts.google.com/ServiceLogin?service=youtube&uilel=3&passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Faction_handle_signin%3Dtrue%26app%3Ddesktop%26hl%3Den&hl=en",
|
||||
"webPageType": "WEB_PAGE_TYPE_UNKNOWN",
|
||||
"rootVe": 83769
|
||||
}
|
||||
},
|
||||
"signInEndpoint": {
|
||||
"nextEndpoint": {
|
||||
"clickTrackingParams": "CAEQ8FsiEwiakLHP2pT5AhWCvlUKHbnWCWc=",
|
||||
"urlEndpoint": { "url": "" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackingParams": "CAEQ8FsiEwiakLHP2pT5AhWCvlUKHbnWCWc="
|
||||
}
|
||||
},
|
||||
"thumbnail": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "//s.ytimg.com/yts/img/meh7-vflGevej7.png",
|
||||
"width": 140,
|
||||
"height": 100
|
||||
}
|
||||
]
|
||||
},
|
||||
"icon": { "iconType": "ERROR_OUTLINE" }
|
||||
}
|
||||
},
|
||||
"desktopLegacyAgeGateReason": 1,
|
||||
"contextParams": "Q0FFU0FnZ0I="
|
||||
},
|
||||
"videoDetails": {
|
||||
"videoId": "laru0QoJUmI",
|
||||
"title": "DJ Robin x Schürze - Layla (Official Video)",
|
||||
"lengthSeconds": "188",
|
||||
"channelId": "UCkJfSrMnLonOZWh-q5os5bg",
|
||||
"isOwnerViewing": false,
|
||||
"shortDescription": "#layla #mallorca #bierkönig \nJetzt downloaden & streamen: https://save-it.cc/summerfield/layla\n\nEndlich ist es soweit! Zwei Männer aus dem Schwabenland bereisen die große weite Welt und stoßen dabei zufällig auf einen mysteriösen Mann. Dieser erzählt den beiden voller Stolz eine geheimnisvolle Geschichte. Sie handelt von seiner schönen und atemberaubenden Puffmama namens Layla, die in seinem Laden der absolute Hingucker ist. Vor lauter Begeisterung schreiben die zwei Männer, DJ Robin und Schürze, ein Lied zu Ehren der wunderschönen Layla! Freut euch drauf und feiert mit ihr bis zum Morgengrauen!\n\nMusik: Michael Müller, Robin Leutner, Thomas Wendt, Dennis Geist\nText: Michael Müller und Robin Leutner \npublished by : Edition Summerfield Tunes / ROBA\nProduced by: Thomas Wendt, Dennis Geist\n\nProduktion: AMELITFilms\nKamera und Schnitt: Jan Schmidt \nBeleuchter: Nils Pröschel\nGraphics: Zendis Media\n\n℗ Summerfield Records 2022\n© Summerfield Records",
|
||||
"isCrawlable": true,
|
||||
"thumbnail": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "https://i.ytimg.com/vi/laru0QoJUmI/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDra0LCNJYw-oyulUk8gRI2ZKJGng",
|
||||
"width": 168,
|
||||
"height": 94
|
||||
},
|
||||
{
|
||||
"url": "https://i.ytimg.com/vi/laru0QoJUmI/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLB5IhvWK_LJN-97jj0FrlxSijOMUw",
|
||||
"width": 196,
|
||||
"height": 110
|
||||
},
|
||||
{
|
||||
"url": "https://i.ytimg.com/vi/laru0QoJUmI/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDEjYSDo4cTBfgJ8b9Ir8EoCnHC4A",
|
||||
"width": 246,
|
||||
"height": 138
|
||||
},
|
||||
{
|
||||
"url": "https://i.ytimg.com/vi/laru0QoJUmI/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDKyOJebaOTIRC2GX_LFJHFheOiSA",
|
||||
"width": 336,
|
||||
"height": 188
|
||||
},
|
||||
{
|
||||
"url": "https://i.ytimg.com/vi_webp/laru0QoJUmI/maxresdefault.webp?v=627e1953",
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
}
|
||||
]
|
||||
},
|
||||
"allowRatings": true,
|
||||
"viewCount": "8876416",
|
||||
"author": "Summerfield Records",
|
||||
"isLowLatencyLiveStream": false,
|
||||
"isPrivate": false,
|
||||
"isUnpluggedCorpus": false,
|
||||
"latencyClass": "MDE_STREAM_OPTIMIZATIONS_RENDERER_LATENCY_NORMAL",
|
||||
"isLiveContent": false
|
||||
},
|
||||
"microformat": {
|
||||
"playerMicroformatRenderer": {
|
||||
"thumbnail": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "https://i.ytimg.com/vi/laru0QoJUmI/maxresdefault.jpg",
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
}
|
||||
]
|
||||
},
|
||||
"embed": {
|
||||
"iframeUrl": "https://www.youtube.com/embed/laru0QoJUmI",
|
||||
"flashUrl": "http://www.youtube.com/v/laru0QoJUmI?version=3&autohide=1",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"flashSecureUrl": "https://www.youtube.com/v/laru0QoJUmI?version=3&autohide=1"
|
||||
},
|
||||
"title": { "simpleText": "DJ Robin x Schürze - Layla (Official Video)" },
|
||||
"description": {
|
||||
"simpleText": "#layla #mallorca #bierkönig \nJetzt downloaden & streamen: https://save-it.cc/summerfield/layla\n\nEndlich ist es soweit! Zwei Männer aus dem Schwabenland bereisen die große weite Welt und stoßen dabei zufällig auf einen mysteriösen Mann. Dieser erzählt den beiden voller Stolz eine geheimnisvolle Geschichte. Sie handelt von seiner schönen und atemberaubenden Puffmama namens Layla, die in seinem Laden der absolute Hingucker ist. Vor lauter Begeisterung schreiben die zwei Männer, DJ Robin und Schürze, ein Lied zu Ehren der wunderschönen Layla! Freut euch drauf und feiert mit ihr bis zum Morgengrauen!\n\nMusik: Michael Müller, Robin Leutner, Thomas Wendt, Dennis Geist\nText: Michael Müller und Robin Leutner \npublished by : Edition Summerfield Tunes / ROBA\nProduced by: Thomas Wendt, Dennis Geist\n\nProduktion: AMELITFilms\nKamera und Schnitt: Jan Schmidt \nBeleuchter: Nils Pröschel\nGraphics: Zendis Media\n\n℗ Summerfield Records 2022\n© Summerfield Records"
|
||||
},
|
||||
"lengthSeconds": "188",
|
||||
"ownerProfileUrl": "http://www.youtube.com/user/IkkeHueftgold",
|
||||
"externalChannelId": "UCkJfSrMnLonOZWh-q5os5bg",
|
||||
"isFamilySafe": false,
|
||||
"isUnlisted": false,
|
||||
"hasYpcMetadata": false,
|
||||
"viewCount": "8876416",
|
||||
"category": "Music",
|
||||
"publishDate": "2022-05-13",
|
||||
"ownerChannelName": "Summerfield Records",
|
||||
"liveBroadcastDetails": {
|
||||
"isLiveNow": false,
|
||||
"startTimestamp": "2022-05-13T16:00:15+00:00",
|
||||
"endTimestamp": "2022-05-13T16:05:14+00:00"
|
||||
},
|
||||
"uploadDate": "2022-05-13"
|
||||
}
|
||||
},
|
||||
"trackingParams": "CAAQu2kiEwiakLHP2pT5AhWCvlUKHbnWCWc=",
|
||||
"frameworkUpdates": {
|
||||
"entityBatchUpdate": {
|
||||
"mutations": [
|
||||
{
|
||||
"entityKey": "Eg0KC2xhcnUwUW9KVW1JIPYBKAE%3D",
|
||||
"type": "ENTITY_MUTATION_TYPE_REPLACE",
|
||||
"payload": {
|
||||
"offlineabilityEntity": {
|
||||
"key": "Eg0KC2xhcnUwUW9KVW1JIPYBKAE%3D",
|
||||
"addToOfflineButtonState": "ADD_TO_OFFLINE_BUTTON_STATE_UNKNOWN"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": { "seconds": "1658775055", "nanos": 167050046 }
|
||||
}
|
||||
}
|
||||
}
|
||||
100
notes/player/web_censored.json
Normal file
100
notes/player/web_censored.json
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
{
|
||||
"responseContext": {
|
||||
"serviceTrackingParams": [
|
||||
{
|
||||
"service": "GFEEDBACK",
|
||||
"params": [
|
||||
{ "key": "is_viewed_live", "value": "False" },
|
||||
{ "key": "logged_in", "value": "0" },
|
||||
{
|
||||
"key": "e",
|
||||
"value": "24166867,24247767,24244808,24140247,24234835,24135692,24246750,23735347,24215797,23918597,23983296,23946420,23804281,23966208,24252017,24152443,23986032,24199774,24219713,24080738,24004644,24135310,24222953,24238983,24248384,24187377,24164186,24229161,24219034,24248296,39321934,23998056,24185614,24036947,24007246,23934970,24114244,24230625,24245746,24187043,24248085,24241936,24191629,24251607,24220892,24230151,24037231,24199724,24215196,24241708,24161116,24226334,24245161,23882685,24246938,1714256,24002025,24251538,24198982,24210567,24242720,24085811,24233755,24034168,24181174,24108447,24233639,24199709,24002022,24227843,24077241,24001373,24222379,24225188,24225483,24169501,24077266,23744176,24198739,24228637,24252146,24220209,24120820"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "CSI",
|
||||
"params": [
|
||||
{ "key": "c", "value": "WEB" },
|
||||
{ "key": "cver", "value": "2.20220721.05.00" },
|
||||
{ "key": "yt_li", "value": "0" },
|
||||
{ "key": "GetPlayer_rid", "value": "0xd25273734a9b032c" }
|
||||
]
|
||||
},
|
||||
{ "service": "GUIDED_HELP", "params": [{ "key": "logged_in", "value": "0" }] },
|
||||
{
|
||||
"service": "ECATCHER",
|
||||
"params": [
|
||||
{ "key": "client.version", "value": "2.20220721" },
|
||||
{ "key": "client.name", "value": "WEB" },
|
||||
{
|
||||
"key": "client.fexp",
|
||||
"value": "24166867,24247767,24244808,24140247,24234835,24135692,24246750,23735347,24215797,23918597,23983296,23946420,23804281,23966208,24252017,24152443,23986032,24199774,24219713,24080738,24004644,24135310,24222953,24238983,24248384,24187377,24164186,24229161,24219034,24248296,39321934,23998056,24185614,24036947,24007246,23934970,24114244,24230625,24245746,24187043,24248085,24241936,24191629,24251607,24220892,24230151,24037231,24199724,24215196,24241708,24161116,24226334,24245161,23882685,24246938,1714256,24002025,24251538,24198982,24210567,24242720,24085811,24233755,24034168,24181174,24108447,24233639,24199709,24002022,24227843,24077241,24001373,24222379,24225188,24225483,24169501,24077266,23744176,24198739,24228637,24252146,24220209,24120820"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"mainAppWebResponseContext": { "loggedOut": true },
|
||||
"webResponseContextExtensionData": { "hasDecorated": true }
|
||||
},
|
||||
"playabilityStatus": {
|
||||
"status": "ERROR",
|
||||
"reason": "This video has been removed for violating YouTube's policy on hate speech. Learn more about combating hate speech in your country.",
|
||||
"errorScreen": {
|
||||
"playerErrorMessageRenderer": {
|
||||
"learnMore": {
|
||||
"runs": [
|
||||
{
|
||||
"text": "Learn more",
|
||||
"navigationEndpoint": {
|
||||
"clickTrackingParams": "CAAQu2kiEwjhrsW72ZT5AhWMOvEFHTEXDe4=",
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"url": "//support.google.com/youtube/answer/2801939?hl=en",
|
||||
"webPageType": "WEB_PAGE_TYPE_UNKNOWN",
|
||||
"rootVe": 83769
|
||||
}
|
||||
},
|
||||
"urlEndpoint": {
|
||||
"url": "//support.google.com/youtube/answer/2801939?hl=en"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": {
|
||||
"simpleText": "This video has been removed for violating YouTube's policy on hate speech. Learn more about combating hate speech in your country."
|
||||
},
|
||||
"thumbnail": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "//s.ytimg.com/yts/img/meh7-vflGevej7.png",
|
||||
"width": 140,
|
||||
"height": 100
|
||||
}
|
||||
]
|
||||
},
|
||||
"icon": { "iconType": "ERROR_OUTLINE" }
|
||||
}
|
||||
},
|
||||
"contextParams": "Q0FBU0FnZ0E="
|
||||
},
|
||||
"trackingParams": "CAAQu2kiEwjhrsW72ZT5AhWMOvEFHTEXDe4=",
|
||||
"frameworkUpdates": {
|
||||
"entityBatchUpdate": {
|
||||
"mutations": [
|
||||
{
|
||||
"entityKey": "Eg0KCzZTSk5WYjBHblBJIPYBKAE%3D",
|
||||
"type": "ENTITY_MUTATION_TYPE_REPLACE",
|
||||
"payload": {
|
||||
"offlineabilityEntity": {
|
||||
"key": "Eg0KCzZTSk5WYjBHblBJIPYBKAE%3D",
|
||||
"addToOfflineButtonState": "ADD_TO_OFFLINE_BUTTON_STATE_UNKNOWN"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": { "seconds": "1658774745", "nanos": 123096088 }
|
||||
}
|
||||
}
|
||||
}
|
||||
955
notes/player/web_drm.json
Normal file
955
notes/player/web_drm.json
Normal file
File diff suppressed because one or more lines are too long
179
notes/player/web_geoblock.json
Normal file
179
notes/player/web_geoblock.json
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
{
|
||||
"responseContext": {
|
||||
"visitorData": "CgtNcDlkYU9JV1ROMCj_zPuWBg%3D%3D",
|
||||
"serviceTrackingParams": [
|
||||
{
|
||||
"service": "GFEEDBACK",
|
||||
"params": [
|
||||
{ "key": "is_viewed_live", "value": "False" },
|
||||
{ "key": "logged_in", "value": "0" },
|
||||
{
|
||||
"key": "e",
|
||||
"value": "24211854,24001373,24222379,24161116,24248295,24244808,23882685,24077266,24207398,23744176,24225483,23946420,24145391,24241377,24077241,24252017,24227843,24034168,23804281,24181174,24198739,24090350,24152442,24590925,24229161,24169501,23858057,24238983,24248384,24002022,23934970,23940248,24237059,24246814,24219033,24246937,24002025,24210567,24198982,24248085,24120819,24230625,24230151,24166867,24241936,24199709,24191629,24222953,24248367,39321934,24187043,23998056,24114244,24245161,23952866,24253522,23748146,24085811,23984881,24007246,24233755,24004644,23986022,24220892,24187377,24199724,24215196,23966208,1714258,24140247,24080738,24164186,24245745,24036947,24185614,23918597,23983296,24135310,24219713,24037231,24247767,24199774"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "CSI",
|
||||
"params": [
|
||||
{ "key": "c", "value": "WEB" },
|
||||
{ "key": "cver", "value": "2.20220721.05.00" },
|
||||
{ "key": "yt_li", "value": "0" },
|
||||
{ "key": "GetPlayer_rid", "value": "0xfdce27fbe9b74ee1" }
|
||||
]
|
||||
},
|
||||
{ "service": "GUIDED_HELP", "params": [{ "key": "logged_in", "value": "0" }] },
|
||||
{
|
||||
"service": "ECATCHER",
|
||||
"params": [
|
||||
{ "key": "client.version", "value": "2.20220721" },
|
||||
{ "key": "client.name", "value": "WEB" },
|
||||
{
|
||||
"key": "client.fexp",
|
||||
"value": "24211854,24001373,24222379,24161116,24248295,24244808,23882685,24077266,24207398,23744176,24225483,23946420,24145391,24241377,24077241,24252017,24227843,24034168,23804281,24181174,24198739,24090350,24152442,24590925,24229161,24169501,23858057,24238983,24248384,24002022,23934970,23940248,24237059,24246814,24219033,24246937,24002025,24210567,24198982,24248085,24120819,24230625,24230151,24166867,24241936,24199709,24191629,24222953,24248367,39321934,24187043,23998056,24114244,24245161,23952866,24253522,23748146,24085811,23984881,24007246,24233755,24004644,23986022,24220892,24187377,24199724,24215196,23966208,1714258,24140247,24080738,24164186,24245745,24036947,24185614,23918597,23983296,24135310,24219713,24037231,24247767,24199774"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"mainAppWebResponseContext": { "loggedOut": true },
|
||||
"webResponseContextExtensionData": { "hasDecorated": true }
|
||||
},
|
||||
"playabilityStatus": {
|
||||
"status": "UNPLAYABLE",
|
||||
"reason": "Video unavailable",
|
||||
"errorScreen": {
|
||||
"playerErrorMessageRenderer": {
|
||||
"subreason": {
|
||||
"runs": [
|
||||
{ "text": "The uploader has not made this video available in your country" }
|
||||
]
|
||||
},
|
||||
"reason": { "simpleText": "Video unavailable" },
|
||||
"thumbnail": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "//s.ytimg.com/yts/img/meh7-vflGevej7.png",
|
||||
"width": 140,
|
||||
"height": 100
|
||||
}
|
||||
]
|
||||
},
|
||||
"icon": { "iconType": "ERROR_OUTLINE" }
|
||||
}
|
||||
},
|
||||
"contextParams": "Q0FFU0FnZ0I="
|
||||
},
|
||||
"videoDetails": {
|
||||
"videoId": "sJL6WA-aGkQ",
|
||||
"title": "西野カナ 『Dear Bride』MV(Short Ver.)",
|
||||
"lengthSeconds": "128",
|
||||
"keywords": [
|
||||
"PV",
|
||||
"MV",
|
||||
"西野カナ",
|
||||
"ニシノカナ",
|
||||
"『Dear Bride』MV(Short Ver.)",
|
||||
"ディアーブライドミュージックビデオショートヴァージョン",
|
||||
"カナやん",
|
||||
"dear bride",
|
||||
"めざまし",
|
||||
"ミュージックビデオ",
|
||||
"トリセツ",
|
||||
"darling",
|
||||
"pv",
|
||||
"mv"
|
||||
],
|
||||
"channelId": "UCXq19VXLNkTtVE9LfRBKd5Q",
|
||||
"isOwnerViewing": false,
|
||||
"shortDescription": "『Dear Bride』…2016年10月発売シングル/2018年11月発売BEST AL「Love Collection 2 ~pink~」収録/2017年11月発売AL「LOVE it」収録\n西野カナDL/STREAMING→https://nishinokana.lnk.to/DLST\nサブスク未配信曲106曲を追加し、全181曲配信中!\nこれまで配信していたシングル表題曲・アルバムリード曲・ベストアルバム“75曲”に加え、未配信だったアルバム曲・カップリング曲・ベストアルバム新録曲など“106曲”を追加。西野カナの楽曲【全181曲】がサブスクで聴けるようになりました!\n\n【未配信曲追加・全曲解禁によって変わること】\n・オリジナルアルバムが、1枚の作品として通して聴けるようになります。\n・シングルパッケージが、カップリング曲も含めて通して聴けるようになります。\n・ベストアルバムも、新録曲を含めて通して聴けるようになります。\n・プレイリストのバリエーションが広がります。\n・これまで未配信だったSpotifyの配信もスタート。\n\n“恋する気持ちをもう一度”\nサブスクで西野カナを聴こう。\n\n■「Dear Bride」歌詞・コード譜面\nhttps://www.ufret.jp/song.php?data=34562\n\n■サブスク・ダウンロード\nhttps://nishinokana.lnk.to/DLST\n■CD・DVD・Blu-rayパッケージ\nhttps://www.sonymusicshop.jp/m/arti/artiItm.php?site=S&ima=3019&cd=70005022\n■西野カナOfficial Site\nhttp://www.nishinokana.com/\n■公式SNS\n[Twitter] https://twitter.com/kanayanofficial\n[Facebook] https://ja-jp.facebook.com/nishinokanaofficial\n\n#西野カナサブスク全曲解禁 #恋する気持ちをもう一度",
|
||||
"isCrawlable": true,
|
||||
"thumbnail": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "https://i.ytimg.com/vi/sJL6WA-aGkQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDLeA8dm_3OaLR4m47aZ9XVw-OZ7A",
|
||||
"width": 168,
|
||||
"height": 94
|
||||
},
|
||||
{
|
||||
"url": "https://i.ytimg.com/vi/sJL6WA-aGkQ/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAfIDhZP7sVYgDUwVy_3IxRlXfUMw",
|
||||
"width": 196,
|
||||
"height": 110
|
||||
},
|
||||
{
|
||||
"url": "https://i.ytimg.com/vi/sJL6WA-aGkQ/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAiaxIvmIVy0Kbltsi7W94KLJ5s3g",
|
||||
"width": 246,
|
||||
"height": 138
|
||||
},
|
||||
{
|
||||
"url": "https://i.ytimg.com/vi/sJL6WA-aGkQ/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLChbEO-iC-P8BTEaRnc1pKlFzDUXQ",
|
||||
"width": 336,
|
||||
"height": 188
|
||||
},
|
||||
{
|
||||
"url": "https://i.ytimg.com/vi_webp/sJL6WA-aGkQ/maxresdefault.webp",
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
}
|
||||
]
|
||||
},
|
||||
"allowRatings": true,
|
||||
"viewCount": "19926664",
|
||||
"author": "西野カナ Official YouTube Channel",
|
||||
"isPrivate": false,
|
||||
"isUnpluggedCorpus": false,
|
||||
"isLiveContent": false
|
||||
},
|
||||
"microformat": {
|
||||
"playerMicroformatRenderer": {
|
||||
"thumbnail": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "https://i.ytimg.com/vi/sJL6WA-aGkQ/maxresdefault.jpg",
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
}
|
||||
]
|
||||
},
|
||||
"embed": {
|
||||
"iframeUrl": "https://www.youtube.com/embed/sJL6WA-aGkQ",
|
||||
"flashUrl": "http://www.youtube.com/v/sJL6WA-aGkQ?version=3&autohide=1",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"flashSecureUrl": "https://www.youtube.com/v/sJL6WA-aGkQ?version=3&autohide=1"
|
||||
},
|
||||
"title": { "simpleText": "西野カナ 『Dear Bride』MV(Short Ver.)" },
|
||||
"description": {
|
||||
"simpleText": "『Dear Bride』…2016年10月発売シングル/2018年11月発売BEST AL「Love Collection 2 ~pink~」収録/2017年11月発売AL「LOVE it」収録\n西野カナDL/STREAMING→https://nishinokana.lnk.to/DLST\nサブスク未配信曲106曲を追加し、全181曲配信中!\nこれまで配信していたシングル表題曲・アルバムリード曲・ベストアルバム“75曲”に加え、未配信だったアルバム曲・カップリング曲・ベストアルバム新録曲など“106曲”を追加。西野カナの楽曲【全181曲】がサブスクで聴けるようになりました!\n\n【未配信曲追加・全曲解禁によって変わること】\n・オリジナルアルバムが、1枚の作品として通して聴けるようになります。\n・シングルパッケージが、カップリング曲も含めて通して聴けるようになります。\n・ベストアルバムも、新録曲を含めて通して聴けるようになります。\n・プレイリストのバリエーションが広がります。\n・これまで未配信だったSpotifyの配信もスタート。\n\n“恋する気持ちをもう一度”\nサブスクで西野カナを聴こう。\n\n■「Dear Bride」歌詞・コード譜面\nhttps://www.ufret.jp/song.php?data=34562\n\n■サブスク・ダウンロード\nhttps://nishinokana.lnk.to/DLST\n■CD・DVD・Blu-rayパッケージ\nhttps://www.sonymusicshop.jp/m/arti/artiItm.php?site=S&ima=3019&cd=70005022\n■西野カナOfficial Site\nhttp://www.nishinokana.com/\n■公式SNS\n[Twitter] https://twitter.com/kanayanofficial\n[Facebook] https://ja-jp.facebook.com/nishinokanaofficial\n\n#西野カナサブスク全曲解禁 #恋する気持ちをもう一度"
|
||||
},
|
||||
"lengthSeconds": "129",
|
||||
"ownerProfileUrl": "http://www.youtube.com/user/kananishinoSMEJ",
|
||||
"externalChannelId": "UCXq19VXLNkTtVE9LfRBKd5Q",
|
||||
"isFamilySafe": false,
|
||||
"isUnlisted": false,
|
||||
"hasYpcMetadata": false,
|
||||
"viewCount": "19926664",
|
||||
"category": "Music",
|
||||
"publishDate": "2016-10-04",
|
||||
"ownerChannelName": "西野カナ Official YouTube Channel",
|
||||
"uploadDate": "2016-10-04"
|
||||
}
|
||||
},
|
||||
"trackingParams": "CAAQu2kiEwiNpJCF25T5AhUOm3wKHUHoC_E=",
|
||||
"frameworkUpdates": {
|
||||
"entityBatchUpdate": {
|
||||
"mutations": [
|
||||
{
|
||||
"entityKey": "Eg0KC3NKTDZXQS1hR2tRIPYBKAE%3D",
|
||||
"type": "ENTITY_MUTATION_TYPE_REPLACE",
|
||||
"payload": {
|
||||
"offlineabilityEntity": {
|
||||
"key": "Eg0KC3NKTDZXQS1hR2tRIPYBKAE%3D",
|
||||
"addToOfflineButtonState": "ADD_TO_OFFLINE_BUTTON_STATE_UNKNOWN"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": { "seconds": "1658775167", "nanos": 882892976 }
|
||||
}
|
||||
}
|
||||
}
|
||||
1225
notes/player/web_live.json
Normal file
1225
notes/player/web_live.json
Normal file
File diff suppressed because one or more lines are too long
1377
notes/player/web_music.json
Normal file
1377
notes/player/web_music.json
Normal file
File diff suppressed because one or more lines are too long
109
notes/player/web_private.json
Normal file
109
notes/player/web_private.json
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
{
|
||||
"responseContext": {
|
||||
"visitorData": "CgtJcWNtbUJhTFFHQSiCz_uWBg%3D%3D",
|
||||
"serviceTrackingParams": [
|
||||
{
|
||||
"service": "GFEEDBACK",
|
||||
"params": [
|
||||
{ "key": "is_viewed_live", "value": "False" },
|
||||
{ "key": "logged_in", "value": "0" },
|
||||
{
|
||||
"key": "e",
|
||||
"value": "24077266,23744176,24108448,24166867,24198739,24248294,24120820,24222379,24001373,24077241,24225483,24169501,23934970,23984878,24243977,24187920,24198982,24085811,24246200,23882502,24233755,24034168,24229161,24002022,24181174,24233639,24244167,24230151,24161116,24245161,24227844,24002025,24246938,24214510,24219033,24230625,24187043,24114244,24245746,9406121,24211242,24241936,24226335,24037231,24220892,24191629,24248085,24239355,24199724,24215196,24004644,24187377,24222953,24238983,24164186,23998056,39321934,23940248,24248384,24007246,24185614,24209350,23918597,23983296,23946420,23804281,23966208,24219713,24246704,24152443,24252017,24224085,23986032,24135310,24080738,24251866,1714244,24199774,24140247,24244808,24036948,24227765"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "CSI",
|
||||
"params": [
|
||||
{ "key": "c", "value": "WEB" },
|
||||
{ "key": "cver", "value": "2.20220721.05.00" },
|
||||
{ "key": "yt_li", "value": "0" },
|
||||
{ "key": "GetPlayer_rid", "value": "0x586e4ea5d95d616a" }
|
||||
]
|
||||
},
|
||||
{ "service": "GUIDED_HELP", "params": [{ "key": "logged_in", "value": "0" }] },
|
||||
{
|
||||
"service": "ECATCHER",
|
||||
"params": [
|
||||
{ "key": "client.version", "value": "2.20220721" },
|
||||
{ "key": "client.name", "value": "WEB" },
|
||||
{
|
||||
"key": "client.fexp",
|
||||
"value": "24077266,23744176,24108448,24166867,24198739,24248294,24120820,24222379,24001373,24077241,24225483,24169501,23934970,23984878,24243977,24187920,24198982,24085811,24246200,23882502,24233755,24034168,24229161,24002022,24181174,24233639,24244167,24230151,24161116,24245161,24227844,24002025,24246938,24214510,24219033,24230625,24187043,24114244,24245746,9406121,24211242,24241936,24226335,24037231,24220892,24191629,24248085,24239355,24199724,24215196,24004644,24187377,24222953,24238983,24164186,23998056,39321934,23940248,24248384,24007246,24185614,24209350,23918597,23983296,23946420,23804281,23966208,24219713,24246704,24152443,24252017,24224085,23986032,24135310,24080738,24251866,1714244,24199774,24140247,24244808,24036948,24227765"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"mainAppWebResponseContext": { "loggedOut": true },
|
||||
"webResponseContextExtensionData": { "hasDecorated": true }
|
||||
},
|
||||
"playabilityStatus": {
|
||||
"status": "LOGIN_REQUIRED",
|
||||
"messages": [
|
||||
"This is a private video. Please sign in to verify that you may see it."
|
||||
],
|
||||
"errorScreen": {
|
||||
"playerErrorMessageRenderer": {
|
||||
"subreason": {
|
||||
"simpleText": "Sign in if you've been granted access to this video"
|
||||
},
|
||||
"reason": { "simpleText": "Private video" },
|
||||
"proceedButton": {
|
||||
"buttonRenderer": {
|
||||
"style": "STYLE_OVERLAY",
|
||||
"size": "SIZE_DEFAULT",
|
||||
"isDisabled": false,
|
||||
"text": { "simpleText": "Sign in" },
|
||||
"navigationEndpoint": {
|
||||
"clickTrackingParams": "CAEQ8FsiEwjv4KWA3JT5AhUdjVUKHV2TBy4=",
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"url": "https://accounts.google.com/ServiceLogin?service=youtube&uilel=3&passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Faction_handle_signin%3Dtrue%26app%3Ddesktop%26hl%3Den&hl=en",
|
||||
"webPageType": "WEB_PAGE_TYPE_UNKNOWN",
|
||||
"rootVe": 83769
|
||||
}
|
||||
},
|
||||
"signInEndpoint": {
|
||||
"nextEndpoint": {
|
||||
"clickTrackingParams": "CAEQ8FsiEwjv4KWA3JT5AhUdjVUKHV2TBy4=",
|
||||
"urlEndpoint": { "url": "" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackingParams": "CAEQ8FsiEwjv4KWA3JT5AhUdjVUKHV2TBy4="
|
||||
}
|
||||
},
|
||||
"thumbnail": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "//s.ytimg.com/yts/img/meh7-vflGevej7.png",
|
||||
"width": 140,
|
||||
"height": 100
|
||||
}
|
||||
]
|
||||
},
|
||||
"icon": { "iconType": "ERROR_OUTLINE" }
|
||||
}
|
||||
},
|
||||
"contextParams": "Q0FBU0FnZ0E="
|
||||
},
|
||||
"trackingParams": "CAAQu2kiEwjv4KWA3JT5AhUdjVUKHV2TBy4=",
|
||||
"frameworkUpdates": {
|
||||
"entityBatchUpdate": {
|
||||
"mutations": [
|
||||
{
|
||||
"entityKey": "Eg0KC3M3X3FJNl9tSVhjIPYBKAE%3D",
|
||||
"type": "ENTITY_MUTATION_TYPE_REPLACE",
|
||||
"payload": {
|
||||
"offlineabilityEntity": {
|
||||
"key": "Eg0KC3M3X3FJNl9tSVhjIPYBKAE%3D",
|
||||
"addToOfflineButtonState": "ADD_TO_OFFLINE_BUTTON_STATE_UNKNOWN"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": { "seconds": "1658775426", "nanos": 182312648 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1744,9 +1744,7 @@
|
|||
}
|
||||
},
|
||||
"unsubscribeEndpoint": {
|
||||
"channelIds": [
|
||||
"UCe52oeb7Xv_KaJsEzcKXJJg"
|
||||
],
|
||||
"channelIds": ["UCe52oeb7Xv_KaJsEzcKXJJg"],
|
||||
"params": "CgIIBBgA"
|
||||
}
|
||||
},
|
||||
2888
notes/player/web_video8K.json
Normal file
2888
notes/player/web_video8K.json
Normal file
File diff suppressed because one or more lines are too long
2000
notes/player/web_videoHDR.json
Normal file
2000
notes/player/web_videoHDR.json
Normal file
File diff suppressed because one or more lines are too long
1873
notes/player/web_waslive.json
Normal file
1873
notes/player/web_waslive.json
Normal file
File diff suppressed because one or more lines are too long
15
notes/video_ids.txt
Normal file
15
notes/video_ids.txt
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
Video: h7pDGP1XjZM
|
||||
4K: LXb3EKWsInQ
|
||||
8K: Zv11L-ZfrSg
|
||||
Music: ihUZMeYFZHA
|
||||
|
||||
# Livestreams
|
||||
Live: 64DYi_8ESh0
|
||||
Was live: pxY4OXVyMe4
|
||||
|
||||
# Errors
|
||||
Agegate: laru0QoJUmI
|
||||
Censored: 6SJNVb0GnPI
|
||||
Geoblocked: sJL6WA-aGkQ (Japan only)
|
||||
Private: s7_qI6_mIXc
|
||||
DRM: 1bfOsni7EgI
|
||||
|
|
@ -1,15 +1,19 @@
|
|||
use std::time::Instant;
|
||||
mod player;
|
||||
mod response;
|
||||
|
||||
use std::{sync::Arc, time::Instant};
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use fancy_regex::Regex;
|
||||
use log::{debug, error, info, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use rand::Rng;
|
||||
use reqwest::{header, Client, ClientBuilder, Request};
|
||||
use serde::Serialize;
|
||||
use reqwest::{header, Client, ClientBuilder, Method, Request, RequestBuilder, Response};
|
||||
use serde::{Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::util;
|
||||
use crate::{deobfuscate::Deobfuscator, util};
|
||||
|
||||
pub enum ClientType {
|
||||
Desktop,
|
||||
|
|
@ -19,14 +23,14 @@ pub enum ClientType {
|
|||
Ios,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(rename = "camelCase")]
|
||||
struct BaseRequest {
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BaseRequest {
|
||||
context: ContextYT,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(rename = "camelCase")]
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ContextYT {
|
||||
client: ClientInfo,
|
||||
/// only used on desktop
|
||||
|
|
@ -38,8 +42,8 @@ struct ContextYT {
|
|||
third_party: Option<ThirdParty>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(rename = "camelCase")]
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ClientInfo {
|
||||
client_name: String,
|
||||
client_version: String,
|
||||
|
|
@ -54,8 +58,8 @@ struct ClientInfo {
|
|||
gl: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(rename = "camelCase")]
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RequestYT {
|
||||
internal_experiment_flags: Vec<String>,
|
||||
use_ssl: bool,
|
||||
|
|
@ -70,20 +74,98 @@ impl Default for RequestYT {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Default)]
|
||||
#[serde(rename = "camelCase")]
|
||||
#[derive(Clone, Debug, Serialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct User {
|
||||
// TO DO: provide a way to enable restricted mode with:
|
||||
// "enableSafetyMode": true
|
||||
locked_safety_mode: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(rename = "camelCase")]
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ThirdParty {
|
||||
embed_url: String,
|
||||
}
|
||||
|
||||
const DEFAULT_UA: &str =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; rv:107.0) Gecko/20100101 Firefox/107.0";
|
||||
|
||||
const CONSENT_COOKIE: &str = "CONSENT";
|
||||
const CONSENT_COOKIE_YES: &str = "YES+yt.462272069.de+FX+";
|
||||
const CONSENT_COOKIE_NO: &str = "PENDING+";
|
||||
|
||||
const DESKTOP_CLIENT_VERSION: &str = "2.20220721.05.00_1";
|
||||
const DESKTOP_API_KEY: &str = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8";
|
||||
|
||||
const YOUTUBEI_V1_URL: &str = "https://www.youtube.com/youtubei/v1/";
|
||||
const YOUTUBEI_V1_GAPIS_URL: &str = "https://youtubei.googleapis.com/youtubei/v1/";
|
||||
|
||||
const DISABLE_PRETTY_PRINT_PARAMETER: &str = "&prettyPrint=false";
|
||||
|
||||
pub struct RustyTube {
|
||||
pub locale: Arc<Locale>,
|
||||
pub desktop_client: DesktopClient,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Locale {
|
||||
lang: String,
|
||||
country: String,
|
||||
}
|
||||
|
||||
impl RustyTube {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::new_with_ua("en", "US")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new_with_ua(lang: &str, country: &str) -> Self {
|
||||
let locale = Arc::new(Locale {
|
||||
lang: lang.to_owned(),
|
||||
country: country.to_owned(),
|
||||
});
|
||||
|
||||
Self {
|
||||
locale: locale.clone(),
|
||||
desktop_client: DesktopClient::new(locale),
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
pub fn get_ytclient(&self, client_type: ClientType) -> impl YTClient {
|
||||
match client_type {
|
||||
ClientType::Desktop => self.desktop_client,
|
||||
ClientType::DesktopMusic => todo!(),
|
||||
ClientType::TvHtml5Embed => todo!(),
|
||||
ClientType::Android => todo!(),
|
||||
ClientType::Ios => todo!(),
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait YTClient {
|
||||
fn new(locale: Arc<Locale>) -> Self;
|
||||
|
||||
async fn get_base_request_body(&self, localized: bool) -> BaseRequest;
|
||||
async fn request_builder(&self, method: Method, url: &str) -> RequestBuilder;
|
||||
async fn exec_request(&self, request: Request) -> Result<Response>;
|
||||
async fn exec_request_text(&self, request: Request) -> Result<String>;
|
||||
}
|
||||
|
||||
pub struct DesktopClient {
|
||||
locale: Arc<Locale>,
|
||||
http: Client,
|
||||
data: Mutex<DesktopClientData>,
|
||||
consent_cookie_yes: String,
|
||||
consent_cookie_no: String,
|
||||
deobf: Deobfuscator,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DesktopClientData {
|
||||
last_update: Option<Instant>,
|
||||
client_version: String,
|
||||
|
|
@ -108,50 +190,24 @@ impl DesktopClientData {
|
|||
}
|
||||
}
|
||||
|
||||
const DEFAULT_UA: &str =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; rv:107.0) Gecko/20100101 Firefox/107.0";
|
||||
#[async_trait]
|
||||
impl YTClient for DesktopClient {
|
||||
fn new(locale: Arc<Locale>) -> Self {
|
||||
let mut rng = rand::thread_rng();
|
||||
|
||||
const CONSENT_COOKIE: &str = "CONSENT";
|
||||
const CONSENT_COOKIE_YES: &str = "YES+yt.462272069.de+FX+";
|
||||
const CONSENT_COOKIE_NO: &str = "PENDING+";
|
||||
|
||||
const DESKTOP_CLIENT_VERSION: &str = "2.20220721.05.00_1";
|
||||
const DESKTOP_API_KEY: &str = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8";
|
||||
|
||||
const CONTENT_PLAYBACK_NONCE_ALPHABET: &[u8; 64] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
|
||||
pub struct RustyTube {
|
||||
http: Client,
|
||||
desktop_client_data: Mutex<DesktopClientData>,
|
||||
|
||||
lang: String,
|
||||
country: String,
|
||||
|
||||
consent_cookie_yes: String,
|
||||
consent_cookie_no: String,
|
||||
}
|
||||
|
||||
impl RustyTube {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::new_with_ua("en", "US", DEFAULT_UA)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new_with_ua(lang: &str, country: &str, user_agent: &str) -> Self {
|
||||
let http = ClientBuilder::new()
|
||||
.user_agent(user_agent)
|
||||
.user_agent(DEFAULT_UA)
|
||||
.gzip(true)
|
||||
.brotli(true)
|
||||
.build()
|
||||
.expect("unable to build the HTTP client");
|
||||
|
||||
let mut rng = rand::thread_rng();
|
||||
let deobf = Deobfuscator::new(http.clone());
|
||||
|
||||
Self {
|
||||
locale,
|
||||
http,
|
||||
desktop_client_data: Mutex::new(DesktopClientData::default()),
|
||||
lang: lang.to_owned(),
|
||||
country: country.to_owned(),
|
||||
data: Mutex::new(DesktopClientData::default()),
|
||||
consent_cookie_yes: format!(
|
||||
"{}={}{}",
|
||||
CONSENT_COOKIE,
|
||||
|
|
@ -164,11 +220,92 @@ impl RustyTube {
|
|||
CONSENT_COOKIE_NO,
|
||||
rng.gen_range(100..1000)
|
||||
),
|
||||
deobf,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_base_request_body(&self, localized: bool) -> BaseRequest {
|
||||
BaseRequest {
|
||||
context: ContextYT {
|
||||
client: ClientInfo {
|
||||
client_name: "WEB".to_owned(),
|
||||
client_version: self.get_client_version().await,
|
||||
client_screen: None,
|
||||
platform: "DESKTOP".to_owned(),
|
||||
original_url: Some("https://www.youtube.com".to_owned()),
|
||||
hl: match localized {
|
||||
true => self.locale.lang.to_owned(),
|
||||
false => "en".to_owned(),
|
||||
},
|
||||
gl: match localized {
|
||||
true => self.locale.country.to_owned(),
|
||||
false => "US".to_owned(),
|
||||
},
|
||||
},
|
||||
request: Some(RequestYT::default()),
|
||||
user: User::default(),
|
||||
third_party: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_builder(&self, method: Method, endpoint: &str) -> RequestBuilder {
|
||||
self.http
|
||||
.request(
|
||||
method,
|
||||
format!(
|
||||
"{}{}?key={}{}",
|
||||
YOUTUBEI_V1_URL, endpoint, DESKTOP_API_KEY, DISABLE_PRETTY_PRINT_PARAMETER
|
||||
),
|
||||
)
|
||||
.header(header::ORIGIN, "https://www.youtube.com")
|
||||
.header(header::REFERER, "https://www.youtube.com")
|
||||
.header(header::COOKIE, self.consent_cookie_no.to_owned())
|
||||
.header("X-YouTube-Client-Name", "1")
|
||||
.header("X-YouTube-Client-Version", self.get_client_version().await)
|
||||
}
|
||||
|
||||
async fn exec_request(&self, request: Request) -> Result<Response> {
|
||||
Ok(self.http.execute(request).await?.error_for_status()?)
|
||||
}
|
||||
|
||||
async fn exec_request_text(&self, request: Request) -> Result<String> {
|
||||
Ok(self.exec_request(request).await?.text().await?)
|
||||
}
|
||||
}
|
||||
|
||||
impl DesktopClient {
|
||||
async fn extract_client_version_from_swjs(&self) -> Result<Option<String>> {
|
||||
let swjs = self
|
||||
.exec_request_text(
|
||||
self.http
|
||||
.get("https://www.youtube.com/sw.js")
|
||||
.header(header::ORIGIN, "https://www.youtube.com")
|
||||
.header(header::REFERER, "https://www.youtube.com")
|
||||
.header(header::COOKIE, self.consent_cookie_yes.to_owned())
|
||||
.build()
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.context("Failed to download sw.js")?;
|
||||
|
||||
static CLIENT_VERSION_PATTERNS: Lazy<[Regex; 3]> = Lazy::new(|| {
|
||||
[
|
||||
Regex::new("INNERTUBE_CONTEXT_CLIENT_VERSION\":\"([0-9\\.]+?)\"").unwrap(),
|
||||
Regex::new("innertube_context_client_version\":\"([0-9\\.]+?)\"").unwrap(),
|
||||
Regex::new("client.version=([0-9\\.]+)").unwrap(),
|
||||
]
|
||||
});
|
||||
|
||||
Ok(util::get_cg_from_regexes(
|
||||
CLIENT_VERSION_PATTERNS.iter(),
|
||||
&swjs,
|
||||
1,
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_client_version(&self) -> String {
|
||||
let mut client_data = self.desktop_client_data.lock().await;
|
||||
let mut client_data = self.data.lock().await;
|
||||
|
||||
if client_data.is_old() {
|
||||
let client_version = self.extract_client_version_from_swjs().await;
|
||||
|
|
@ -194,58 +331,7 @@ impl RustyTube {
|
|||
last_update: Some(Instant::now()),
|
||||
}
|
||||
}
|
||||
|
||||
client_data.client_version.to_string()
|
||||
}
|
||||
|
||||
async fn extract_client_version_from_swjs(&self) -> Result<Option<String>> {
|
||||
let swjs = self
|
||||
.exec_request(
|
||||
self.http
|
||||
.get("https://www.youtube.com/sw.js")
|
||||
.header(header::ORIGIN, "https://www.youtube.com")
|
||||
.header(header::REFERER, "https://www.youtube.com")
|
||||
.header(header::COOKIE, self.consent_cookie_yes.to_owned())
|
||||
.build()
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.context("Failed to download sw.js")?;
|
||||
|
||||
static CLIENT_VERSION_PATTERNS: Lazy<[Regex; 3]> = Lazy::new(|| {
|
||||
[
|
||||
Regex::new("INNERTUBE_CONTEXT_CLIENT_VERSION\":\"([0-9\\.]+?)\"").unwrap(),
|
||||
Regex::new("innertube_context_client_version\":\"([0-9\\.]+?)\"").unwrap(),
|
||||
Regex::new("client.version=([0-9\\.]+)").unwrap(),
|
||||
]
|
||||
});
|
||||
|
||||
/*
|
||||
static API_KEY_PATTERNS: Lazy<[Regex; 2]> = Lazy::new(|| {
|
||||
[
|
||||
Regex::new("INNERTUBE_API_KEY\":\"([0-9a-zA-Z_-]+?)\"").unwrap(),
|
||||
Regex::new("innertubeApiKey\":\"([0-9a-zA-Z_-]+?)\"").unwrap(),
|
||||
]
|
||||
});*/
|
||||
|
||||
Ok(util::get_cg_from_regexes(
|
||||
CLIENT_VERSION_PATTERNS.iter(),
|
||||
&swjs,
|
||||
1,
|
||||
))
|
||||
}
|
||||
|
||||
fn generate_content_playback_nonce() -> String {
|
||||
util::random_string(CONTENT_PLAYBACK_NONCE_ALPHABET, 16)
|
||||
}
|
||||
|
||||
fn generate_t_parameter() -> String {
|
||||
util::random_string(CONTENT_PLAYBACK_NONCE_ALPHABET, 12)
|
||||
}
|
||||
|
||||
async fn exec_request(&self, request: Request) -> Result<String> {
|
||||
let resp = self.http.execute(request).await?.error_for_status()?;
|
||||
Ok(resp.text().await?)
|
||||
client_data.client_version.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -254,6 +340,7 @@ mod tests {
|
|||
use super::*;
|
||||
use test_log::test;
|
||||
|
||||
/*
|
||||
#[test(tokio::test)]
|
||||
async fn t_extract_client_version_from_swjs() {
|
||||
let rt = RustyTube::new();
|
||||
|
|
@ -300,4 +387,5 @@ mod tests {
|
|||
let request_str = serde_json::to_string_pretty(&request).unwrap();
|
||||
println!("{}", request_str);
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
|
|
|||
85
src/client/player.rs
Normal file
85
src/client/player.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use reqwest::Method;
|
||||
use serde::{Serialize};
|
||||
|
||||
use super::{response, BaseRequest, RustyTube, YTClient};
|
||||
use crate::util;
|
||||
|
||||
// REQUEST
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QPlayer {
|
||||
#[serde(flatten)]
|
||||
base: BaseRequest,
|
||||
/// Website playback context
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
playback_context: Option<QPlaybackContext>,
|
||||
/// Content playback nonce (16 random chars)
|
||||
cpn: String,
|
||||
/// YouTube video ID
|
||||
video_id: String,
|
||||
/// Set to true to allow extraction of streams with sensitive content
|
||||
content_check_ok: bool,
|
||||
/// Probably refers to allowing sensitive content, too
|
||||
racy_check_ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QPlaybackContext {
|
||||
content_playback_context: QContentPlaybackContext,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QContentPlaybackContext {
|
||||
/// Signature timestamp extracted from player.js
|
||||
signature_timestamp: String,
|
||||
/// Referer URL from website
|
||||
referer: String,
|
||||
}
|
||||
|
||||
impl RustyTube {
|
||||
pub async fn fetch_player(&self, video_id: &str) -> Result<response::Player> {
|
||||
let sts = self.desktop_client.deobf.get_sts().await?;
|
||||
|
||||
let request_body = QPlayer {
|
||||
base: self.desktop_client.get_base_request_body(false).await,
|
||||
playback_context: Some(QPlaybackContext {
|
||||
content_playback_context: QContentPlaybackContext {
|
||||
signature_timestamp: sts,
|
||||
referer: format!("https://www.youtube.com/watch?v={}", video_id),
|
||||
},
|
||||
}),
|
||||
cpn: util::generate_content_playback_nonce(),
|
||||
video_id: video_id.to_owned(),
|
||||
content_check_ok: true,
|
||||
racy_check_ok: true,
|
||||
};
|
||||
|
||||
let resp = self
|
||||
.desktop_client
|
||||
.request_builder(Method::POST, "player")
|
||||
.await
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
Ok(resp.json::<response::Player>().await?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use test_log::test;
|
||||
|
||||
#[test(tokio::test)]
|
||||
async fn t_fetch_stream() {
|
||||
let rt = RustyTube::new();
|
||||
let stream = rt.fetch_player("ZeerrnuLi5E").await.unwrap();
|
||||
|
||||
dbg!(stream);
|
||||
}
|
||||
}
|
||||
3
src/client/response/mod.rs
Normal file
3
src/client/response/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod player;
|
||||
|
||||
pub use player::Player;
|
||||
246
src/client/response/player.rs
Normal file
246
src/client/response/player.rs
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
use std::ops::Range;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::Deserialize;
|
||||
use serde_with::serde_as;
|
||||
use serde_with::{json::JsonString, DefaultOnError, VecSkipError};
|
||||
|
||||
use crate::serializer::mime_type::MimeType;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Player {
|
||||
pub playability_status: PlayabilityStatus,
|
||||
pub streaming_data: Option<StreamingData>,
|
||||
pub captions: Option<Captions>,
|
||||
pub video_details: Option<VideoDetails>,
|
||||
pub microformat: Option<Microformat>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(tag = "status", rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum PlayabilityStatus {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Ok { live_streamability: Option<Empty> },
|
||||
/// Video cant be played because of DRM / Geoblock
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Unplayable {
|
||||
reason: String,
|
||||
// error_screen: Option<ErrorScreen>,
|
||||
},
|
||||
/// Age limit / Private video
|
||||
#[serde(rename_all = "camelCase")]
|
||||
LoginRequired {
|
||||
reason: String,
|
||||
// error_screen: Option<ErrorScreen>
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
LiveStreamOffline {
|
||||
reason: String,
|
||||
// error_screen: Option<ErrorScreen>
|
||||
},
|
||||
/// Video was censored / deleted
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Error {
|
||||
reason: String,
|
||||
// error_screen: Option<ErrorScreen>
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Empty {}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StreamingData {
|
||||
#[serde_as(as = "JsonString")]
|
||||
pub expires_in_seconds: u64,
|
||||
#[serde_as(as = "Option<VecSkipError<_>>")]
|
||||
pub formats: Option<Vec<Format>>,
|
||||
#[serde_as(as = "Option<VecSkipError<_>>")]
|
||||
pub adaptive_formats: Option<Vec<Format>>,
|
||||
/// Only on livestreams
|
||||
pub dash_manifest_url: Option<String>,
|
||||
/// Only on livestreams
|
||||
pub hls_manifest_url: Option<String>,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Format {
|
||||
pub itag: u32,
|
||||
pub url: Option<String>,
|
||||
|
||||
#[serde(default, rename = "type")]
|
||||
pub format_type: FormatType,
|
||||
|
||||
#[serde(with = "crate::serializer::mime_type")]
|
||||
pub mime_type: MimeType,
|
||||
|
||||
pub bitrate: Option<u64>,
|
||||
|
||||
pub width: Option<u64>,
|
||||
pub height: Option<u64>,
|
||||
|
||||
#[serde_as(as = "Option<crate::serializer::range::Range>")]
|
||||
pub index_range: Option<Range<u32>>,
|
||||
#[serde_as(as = "Option<crate::serializer::range::Range>")]
|
||||
pub init_range: Option<Range<u32>>,
|
||||
|
||||
#[serde_as(as = "Option<JsonString>")]
|
||||
pub content_length: Option<u32>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||
pub quality: Option<Quality>,
|
||||
pub fps: Option<u8>,
|
||||
pub quality_label: Option<String>,
|
||||
pub average_bitrate: Option<u32>,
|
||||
pub color_info: Option<ColorInfo>,
|
||||
|
||||
// Audio only
|
||||
#[serde(default)]
|
||||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||
pub audio_quality: Option<AudioQuality>,
|
||||
|
||||
// #[serde_as(as = "Option<JsonString>")]
|
||||
// pub approx_duration_ms: Option<u32>,
|
||||
|
||||
// Audio only
|
||||
#[serde_as(as = "Option<JsonString>")]
|
||||
pub audio_sample_rate: Option<u32>,
|
||||
pub audio_channels: Option<u8>,
|
||||
pub loudness_db: Option<f64>,
|
||||
|
||||
pub signature_cipher: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Quality {
|
||||
Tiny,
|
||||
Small,
|
||||
Medium,
|
||||
Large,
|
||||
Highres,
|
||||
Hd720,
|
||||
Hd1080,
|
||||
Hd1440,
|
||||
Hd2160,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum AudioQuality {
|
||||
#[serde(rename = "AUDIO_QUALITY_LOW", alias = "low")]
|
||||
Low,
|
||||
#[serde(rename = "AUDIO_QUALITY_MEDIUM", alias = "medium")]
|
||||
Medium,
|
||||
#[serde(rename = "AUDIO_QUALITY_HIGH", alias = "high")]
|
||||
High,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum FormatType {
|
||||
#[default]
|
||||
Default,
|
||||
/// This stream only works via DASH and not via progressive HTTP.
|
||||
FormatStreamTypeOtf,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Debug, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ColorInfo {
|
||||
pub primaries: Primaries,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum Primaries {
|
||||
#[default]
|
||||
ColorPrimariesBt709,
|
||||
ColorPrimariesBt2020,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Captions {
|
||||
pub player_captions_tracklist_renderer: PlayerCaptionsTracklistRenderer,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlayerCaptionsTracklistRenderer {
|
||||
pub caption_tracks: Vec<CaptionTrack>,
|
||||
pub translation_languages: Vec<TranslationLanguage>,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CaptionTrack {
|
||||
pub base_url: String,
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
pub name: String,
|
||||
pub language_code: String,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TranslationLanguage {
|
||||
pub language_code: String,
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
pub language_name: String
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VideoDetails {
|
||||
pub video_id: String,
|
||||
pub title: String,
|
||||
#[serde_as(as = "JsonString")]
|
||||
pub length_seconds: u32,
|
||||
#[serde(default)]
|
||||
pub keywords: Vec<String>,
|
||||
pub channel_id: String,
|
||||
#[serde(default)]
|
||||
pub short_description: String,
|
||||
pub thumbnail: Option<Thumbnails>,
|
||||
#[serde_as(as = "JsonString")]
|
||||
pub view_count: u64,
|
||||
pub author: String,
|
||||
pub is_live_content: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Thumbnails {
|
||||
pub thumbnails: Vec<Thumbnail>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Thumbnail {
|
||||
pub url: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Microformat {
|
||||
pub player_microformat_renderer: PlayerMicroformatRenderer,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlayerMicroformatRenderer {
|
||||
pub is_family_safe: bool,
|
||||
pub category: String,
|
||||
pub publish_date: NaiveDate,
|
||||
pub upload_date: NaiveDate,
|
||||
}
|
||||
|
|
@ -14,11 +14,13 @@ pub struct Deobfuscator {
|
|||
cache: RwLock<JSCache>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct JSCache {
|
||||
js_url: Option<String>,
|
||||
last_update: Instant,
|
||||
last_update: Option<Instant>,
|
||||
js_url: String,
|
||||
sig_fn: String,
|
||||
nsig_fn: String,
|
||||
sts: String,
|
||||
}
|
||||
|
||||
impl Deobfuscator {
|
||||
|
|
@ -26,12 +28,7 @@ impl Deobfuscator {
|
|||
pub fn new(http: Client) -> Self {
|
||||
Self {
|
||||
http,
|
||||
cache: RwLock::new(JSCache {
|
||||
js_url: None,
|
||||
last_update: Instant::now(),
|
||||
sig_fn: "".to_owned(),
|
||||
nsig_fn: "".to_owned(),
|
||||
}),
|
||||
cache: RwLock::new(JSCache::default()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -51,12 +48,14 @@ impl Deobfuscator {
|
|||
|
||||
let sig_fn = get_sig_fn(&player_js)?;
|
||||
let nsig_fn = get_nsig_fn(&player_js)?;
|
||||
let sts = get_sts(&player_js)?;
|
||||
|
||||
*cache = JSCache {
|
||||
js_url: Some(url.to_owned()),
|
||||
last_update: Instant::now(),
|
||||
last_update: Some(Instant::now()),
|
||||
js_url: url.to_owned(),
|
||||
sig_fn,
|
||||
nsig_fn,
|
||||
sts,
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -73,11 +72,20 @@ impl Deobfuscator {
|
|||
let cache = self.cache.read().await;
|
||||
deobfuscate_nsig(nsig, &cache.nsig_fn)
|
||||
}
|
||||
|
||||
pub async fn get_sts(&self) -> Result<String> {
|
||||
self.update().await?;
|
||||
let cache = self.cache.read().await;
|
||||
Ok(cache.sts.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl JSCache {
|
||||
fn is_stale(&self) -> bool {
|
||||
self.js_url.is_none() || Instant::now().duration_since(self.last_update).as_secs() > 3600
|
||||
match self.last_update {
|
||||
Some(last_update) => Instant::now().duration_since(last_update).as_secs() > 3600,
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -301,6 +309,19 @@ async fn get_response(http: &Client, url: &str) -> Result<String> {
|
|||
Ok(resp.text().await?)
|
||||
}
|
||||
|
||||
fn get_sts(player_js: &str) -> Result<String> {
|
||||
let sts_pattern = Regex::new("signatureTimestamp[=:](\\d+)").unwrap();
|
||||
|
||||
Ok(some_or_bail!(
|
||||
sts_pattern.captures(&player_js)?,
|
||||
Err(anyhow!("could not find sts"))
|
||||
)
|
||||
.get(1)
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -363,6 +384,12 @@ c[36](c[8],c[32]),c[20](c[25],c[10]),c[2](c[22],c[8]),c[32](c[20],c[16]),c[32](c
|
|||
assert_eq!(res, N_DEOBF_FUNC);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_get_sts() {
|
||||
let res = get_sts(TEST_JS).unwrap();
|
||||
assert_eq!(res, "19187")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_deobfuscate_nsig() {
|
||||
let res = deobfuscate_nsig("BI_n4PxQ22is-KKajKUW", N_DEOBF_FUNC).unwrap();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
mod macros;
|
||||
|
||||
mod util;
|
||||
mod serializer;
|
||||
mod deobfuscate;
|
||||
|
||||
pub mod client;
|
||||
|
|
|
|||
113
src/serializer/mime_type.rs
Normal file
113
src/serializer/mime_type.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
use once_cell::sync::Lazy;
|
||||
|
||||
use fancy_regex::Regex;
|
||||
use serde::de::{Deserialize, Deserializer, Error, Unexpected};
|
||||
use serde::ser::{Serialize, Serializer};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct MimeType {
|
||||
pub mime: String,
|
||||
pub codecs: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<MimeType, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
static PATTERN: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r#"(\w+/\w+);\scodecs="([a-zA-Z-0-9.,\s]*)""#).unwrap());
|
||||
|
||||
// deserializing into a &str gives back an error
|
||||
let s = String::deserialize(deserializer)?;
|
||||
|
||||
let captures = PATTERN.captures(&s).ok().flatten().ok_or_else(|| {
|
||||
D::Error::invalid_value(
|
||||
Unexpected::Str(&s),
|
||||
&"a valid mime type with the format <TYPE>/<SUBTYPE>",
|
||||
)
|
||||
})?;
|
||||
let mime = captures.get(1).unwrap().as_str().to_owned();
|
||||
let codecs = captures
|
||||
.get(2)
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.split(", ")
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
Ok(MimeType {
|
||||
mime,
|
||||
codecs,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn serialize<S>(mime_type: &MimeType, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut s = format!(r#"{}; codecs=""#, mime_type.mime,);
|
||||
|
||||
for codec in mime_type.codecs.iter() {
|
||||
s.push_str(codec);
|
||||
s.push(',');
|
||||
s.push(' ');
|
||||
}
|
||||
|
||||
s.pop();
|
||||
s.pop();
|
||||
s.push('"');
|
||||
s.serialize(serializer)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use super::*;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct S {
|
||||
#[serde(with = "crate::serializer::mime_type")]
|
||||
mime: MimeType,
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(
|
||||
r#"{"mime": "video/mp4; codecs=\"avc1.42001E, mp4a.40.2\""}"#,
|
||||
MimeType {
|
||||
mime: "video/mp4".to_owned(),
|
||||
codecs: vec!["avc1.42001E".to_owned(), "mp4a.40.2".to_owned()],
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
r#"{"mime": "video/webm; codecs=\"vp9\""}"#,
|
||||
MimeType {
|
||||
mime: "video/webm".to_owned(),
|
||||
codecs: vec!["vp9".to_owned()],
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
r#"{"mime": "audio/webm; codecs=\"opus\""}"#,
|
||||
MimeType {
|
||||
mime: "audio/webm".to_owned(),
|
||||
codecs: vec!["opus".to_owned()],
|
||||
}
|
||||
)]
|
||||
fn t_deserialize(#[case] test_json: &str, #[case] exp: MimeType) {
|
||||
let res = serde_json::from_str::<S>(&test_json).unwrap();
|
||||
assert_eq!(res.mime, exp)
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn t_serialize() {
|
||||
let s = S {
|
||||
mime: MimeType {
|
||||
mime: "video/webm".to_owned(),
|
||||
codecs: vec!["av01.0.08M.08".to_owned()],
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
assert_eq!(json, r#"{"mime":"video/webm; codecs=\"av01.0.08M.08\""}"#)
|
||||
}
|
||||
}
|
||||
3
src/serializer/mod.rs
Normal file
3
src/serializer/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod range;
|
||||
pub mod mime_type;
|
||||
pub mod text;
|
||||
27
src/serializer/range.rs
Normal file
27
src/serializer/range.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use serde_with::{DeserializeAs, json::JsonString, serde_as, SerializeAs};
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct Range {
|
||||
#[serde_as(as = "JsonString")]
|
||||
start: u32,
|
||||
#[serde_as(as = "JsonString")]
|
||||
end: u32,
|
||||
}
|
||||
|
||||
impl<'de> DeserializeAs<'de, std::ops::Range<u32>> for Range {
|
||||
fn deserialize_as<D>(deserializer: D) -> Result<std::ops::Range<u32>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de> {
|
||||
let range = Range::deserialize(deserializer)?;
|
||||
Ok(std::ops::Range { start: range.start, end: range.end })
|
||||
}
|
||||
}
|
||||
|
||||
impl SerializeAs<std::ops::Range<u32>> for Range {
|
||||
fn serialize_as<S>(&std::ops::Range { start, end }: &std::ops::Range<u32>, serializer: S) -> Result<<S as Serializer>::Ok, <S as Serializer>::Error> where
|
||||
S: Serializer {
|
||||
Range { start, end }.serialize(serializer)
|
||||
}
|
||||
}
|
||||
109
src/serializer/text.rs
Normal file
109
src/serializer/text.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
use serde::{Deserialize, Deserializer};
|
||||
use serde_with::{serde_as, DeserializeAs};
|
||||
|
||||
/// The YouTube API has multiple ways of outputting text. This deserializer
|
||||
/// is an attempt to unify them.
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "text": "Hello World"
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "simpleText": "Hello World"
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Multiple "runs" of text should be joined with spaces
|
||||
/// ```json
|
||||
/// {
|
||||
/// "runs": [
|
||||
/// {"text": "Hello"},
|
||||
/// {"text": "World"},
|
||||
/// ]
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Text {
|
||||
Simple {
|
||||
#[serde(alias = "simpleText")]
|
||||
text: String,
|
||||
},
|
||||
Multiple {
|
||||
#[serde_as(as = "Vec<crate::serializer::text::Text>")]
|
||||
runs: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl<'de> DeserializeAs<'de, String> for Text {
|
||||
fn deserialize_as<D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let text = Text::deserialize(deserializer)?;
|
||||
match text {
|
||||
Text::Simple { text } => Ok(text),
|
||||
Text::Multiple { runs } => Ok(runs.join("")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
use serde::Deserialize;
|
||||
use serde_with::serde_as;
|
||||
|
||||
#[rstest]
|
||||
#[case(
|
||||
r#"{
|
||||
"txt": {
|
||||
"text": "Hello World"
|
||||
}
|
||||
}"#,
|
||||
"Hello World"
|
||||
)]
|
||||
#[case(
|
||||
r#"{
|
||||
"txt": {
|
||||
"simpleText": "Hello World"
|
||||
}
|
||||
}"#,
|
||||
"Hello World"
|
||||
)]
|
||||
#[case(
|
||||
r#"{
|
||||
"txt": {
|
||||
"runs": [
|
||||
{
|
||||
"text": "Abo für "
|
||||
},
|
||||
{
|
||||
"text": "MBCkpop"
|
||||
},
|
||||
{
|
||||
"text": " beenden?"
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#,
|
||||
"Abo für MBCkpop beenden?"
|
||||
)]
|
||||
fn t_deserialize(#[case] test_json: &str, #[case] exp: &str) {
|
||||
#[serde_as]
|
||||
#[derive(Deserialize)]
|
||||
struct S {
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
txt: String,
|
||||
}
|
||||
|
||||
let res = serde_json::from_str::<S>(&test_json).unwrap();
|
||||
assert_eq!(res.txt, exp)
|
||||
}
|
||||
}
|
||||
11
src/util.rs
11
src/util.rs
|
|
@ -1,6 +1,9 @@
|
|||
use fancy_regex::Regex;
|
||||
use rand::Rng;
|
||||
|
||||
const CONTENT_PLAYBACK_NONCE_ALPHABET: &[u8; 64] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
|
||||
/// Return the given capture group that matches first in a list of regexes
|
||||
pub fn get_cg_from_regexes<'a, I>(mut regexes: I, text: &str, cg: usize) -> Option<String>
|
||||
where
|
||||
|
|
@ -26,3 +29,11 @@ pub fn random_string(charset: &[u8], length: usize) -> String {
|
|||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn generate_content_playback_nonce() -> String {
|
||||
random_string(CONTENT_PLAYBACK_NONCE_ALPHABET, 16)
|
||||
}
|
||||
|
||||
pub fn generate_t_parameter() -> String {
|
||||
random_string(CONTENT_PLAYBACK_NONCE_ALPHABET, 12)
|
||||
}
|
||||
|
|
|
|||
Reference in a new issue