diff --git a/spec/invidious/channel/community_spec.cr b/spec/invidious/channel/community_spec.cr
new file mode 100644
index 000000000..0639b2468
--- /dev/null
+++ b/spec/invidious/channel/community_spec.cr
@@ -0,0 +1,23 @@
+require "../../spec_helper"
+
+Spectator.describe Invidious::Channels::Community do
+ describe ".produce_channel_community_continuation" do
+ it "correctly produces a continuation token for a channel community" do
+ expect(Invidious::Channels::Community.produce_channel_community_continuation("UCCj956IF62FbT7Gouszaj9w", "Egljb21tdW5pdHm4")).to eq("4qmFsgIsEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaEEVnbGpiMjF0ZFc1cGRIbTQ%3D")
+ expect(Invidious::Channels::Community.produce_channel_community_continuation("UCCj956IF62FbT7Gouszaj9w", "Egljb21tdW5pdHm4AQCqAyQaIBIaVWd3cE9NQmVwWEdjclhsUHg2WjRBYUFCQ1FIZGgDKAA%3D")).to eq("4qmFsgJmEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaSkVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2QzY0U5TlFtVndXRWRqY2xoc1VIZzJXalJCWVVGQ1ExRklaR2dES0FBJTNE")
+
+ expect(Invidious::Channels::Community.produce_channel_community_continuation("UC-lHJZR3Gqxm24_Vd_AJ5Yw", "Egljb21tdW5pdHm4AQCqAyQaIBIaVWd5RTI2NW1rUkk2cE9uS21nbDRBYUFCQ1FIZGgDKAA%3D")).to eq("4qmFsgJmEhhVQy1sSEpaUjNHcXhtMjRfVmRfQUo1WXcaSkVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2Q1UlRJMk5XMXJVa2syY0U5dVMyMW5iRFJCWVVGQ1ExRklaR2dES0FBJTNE")
+ expect(Invidious::Channels::Community.produce_channel_community_continuation("UC-lHJZR3Gqxm24_Vd_AJ5Yw", "Egljb21tdW5pdHm4AQCqA-cOCsAOUVVSVFNsOXBNWEYxYlVablFXaGFiWFJNTW5WM1ZHSXdPVU5EWTNoeFJWWlVjRWRGVTBOa1prTktjVUoyWjBZemNEZHRPV2cwV1hWbVJtaFVPWFJwVjJaUU4xTXlNRWRaYlZwSVFUa3dlak5pTUV0dll6QkRVMlpsWHpoVFdUbHFSR0o1YkRkM1kydEhMVTVwWDFCdFdXOUhjR0Z6ZEMxbldVcEhUMjkzUm1saGRXSkViVmR6ZFhwd1QxTnpOME54TW5KUloxQlBkME5QU1VWVWMybHlNbFZvUVV0NlVIZFVhMVV5UzNWUmJHRldkRmszU1dKd1pVUllVMkZFVG1aV1ZsRnVUMGhsZFd0T01sVndTbGd3TkhweVdDMVBTRUphV25GNk5Yb3dYMWRCVTFnMlltODBPRmhIV205WlQwNW1YMjV1UlVKTWNucHNNSGR5Y1hKaFltUkVkblJYZG1Kc1FVaHFUV3BwTkc5R1pUQkVlbGw2ZHpSM2FISlBTSFJoYjJGbVMwNTBiV1pxV2pCSVNWWnZTalpRT0RoclVGVmhia1p5VFhsaWFTMVBjREZZV1dSTFdERkZjSHB0ZUhseWFtRXdNR1JmTkhOWmFEVlZTbVZ1ZUVkRU1XRlFhbU4xVERabk4wdDVSSGxHU2xsT1VEQlJXR1ZLTUhGM1UwWkJTSE5oWkRWQ2NXZHNaMFpqYW1ST1YxZFlhMDVOVUZSSFZWVktRekZSYVhodlUxTm1SV1EwTUdsdWNEWXlPV1YwUjNkcGFVcEVTM040YUZadmRXbHJhblkyZFdFelNHWXpUV3hMYURCa2JIRTFSblJ4Wms4NU1XbGtOM0pHYjBGeU4xZFJNMU5qYkZCd05rZE9jV1JqT1hGRGIyNU5Xak5TUlhkemFsUXRObGt4UWxkUE16ZGFaRTlxVGtaZlIweEhRbXRNWXpCWE9GUjNOMHBsYVhwS2RtSlZkMmxGTVhCbVNIWkdkVTFJY0MxbFdYSkVZM0V0ZFROWWRtVlFlV3hhYlVKMmVreGZUMGxOU2xaSlRFTlBZMVpEUjFwd1RHZFhZMmhIYVVKakxUSmFabXd0U1RNeFJEWkhlSGhYTkhOMU1GZGhOMjFCVlVnNGNFTlJXSGx2WW5ScWNUaHZXWGxKT1d0TVRXc3lRMWc0Um5wU2JEVjBlRGxpTW5vMVRYaEtkelExY201S1JHSmZkamhmTlhOWmRGYzRjak5FVVdkMlpXVnNRWEJyZW5OdFpHcEljVGhWYzFsZkxWa3dRVTkyTVZVMmIyMTNVeTFLVEUxeFIwUldRbmc0VEdsTlpGVktjVmxzTkZGa1UwazFabE0wZUhsRk5WZ3lWR0ZaYzJadlYyaHRPRFpzTjNCT1dHRnBiMHhUVDBkMmRuZFVOMlptVm05dWIwRTFZVkZuYldKNmIwMUNaMng2VGkxSk56bHhXV3BJVGt4RFYwVllUM05pTVcwemRHc3lUVWN6TVVKcVRHdElNVWg1YmtKQmVrbFNVMnczZEVKUlJGOUlNVWRyZERsbFJraHVYekJXZUhGbE1rTTBlVE40YVU1T1pFcGpVMkpFZFMxWVdITjNTMnhWVjJwYVgzVXRXbGcwZG5OSE1qUXpYMlJHTVhSV1kxWkZRMlZwU25OdVlXTkdVek5wVUd4b2FUbDVSRVp4YVhsbFRqbG1aRWxYVFZCMVFWbG9OMEl3TW5KV1JUVjRkREJLZG5obmJGZHhSVlY1ZWpjMFIyeGlZemRIVmkxeFpESmlaMnhFZGxkcVRuSjZNVEZWUkRWamVIQlFkRk5DVmtSU2RITlRaSGhWZG05WE9VUkNhWEYwTm1kSFRtb3RNV1pNYlhSeVJWTnJhRWhIVDB0SU0yVkxUbFZ2V1VGNlJTMDJialJZYkRKdFFUVnJhRVJ4WmpjeFptcERNR001UmpkM2QwNW1VRXd5YUZCZlEwWjFSbEUzY0doRk5ISkZZMWxTTWs5d2RXRnhiRzFrYjBVMmIxWkJaRzkyU2xneFZWOXNiMDVWWkUxRFJ6QjBjWGhpVjBVMldYY3pTUzF4UVcxa1RuZEJRVGRvWVZFNGNsSTBaVUl0UmxacVdETnJXazVLY21aRk9HVndRbWxqUjB0blRFZEZVR3N6YzJOclkwSTNlVlZZVEdkcE1YQkdiMHAyZVU1aGRVZFdVblJQYVhaQlZtdHZSa0UzTFU1Sk1XaFJRMUpMV2kxSWJ6WkxjWEkxZGtSTWJsOVdUa0ZFVmpKZmMwUlFWV3gwUTJ0TFRsbDJaM2gxZFVOSVkzbEVORUpRZVUxMVREQnpOMVowWDI1MWRrVmlUMU54TkRkUk5rVjViMEpRTUZGNmR6RlJSR2RxY1U1eVgwNTBjMDkxWm14R2NUVjBlRkJGT1dGVmFXeFJTMEZYYldwQlVVbHNOVmgwZERZdGFFRlViMWxmUjFWc1EycG1WVkJQV0hkcFVRPT0aIBIaVWd5RTI2NW1rUkk2cE9uS21nbDRBYUFCQ1FIZGgDKGM%3D")).to eq("4qmFsgKXFBIYVUMtbEhKWlIzR3F4bTI0X1ZkX0FKNVl3GvoTRWdsamIyMXRkVzVwZEhtNEFRQ3FBLWNPQ3NBT1VWVlNWRk5zT1hCTldFWXhZbFZhYmxGWGFHRmlXRkpOVFc1V00xWkhTWGRQVlU1RVdUTm9lRkpXV2xWalJXUkdWVEJPYTFwclRrdGpWVW95V2pCWmVtTkVaSFJQVjJjd1YxaFdiVkp0YUZWUFdGSndWakphVVU0eFRYbE5SV1JhWWxad1NWRlVhM2RsYWs1cFRVVjBkbGw2UWtSVk1scHNXSHBvVkZkVWJIRlNSMG8xWWtSa00xa3lkRWhNVlRWd1dERkNkRmRYT1VoalIwWjZaRU14YmxkVmNFaFVNamt6VW0xc2FHUlhTa1ZpVm1SNlpGaHdkMVF4VG5wT01FNTRUVzVLVWxveFFsQmtNRTVRVTFWV1ZXTXliSGxOYkZadlVWVjBObFZJWkZWaE1WVjVVek5XVW1KSFJsZGtSbXN6VTFkS2QxcFZVbGxWTWtaRlZHMWFWMVpzUm5WVU1HaHNaRmQwVDAxc1ZuZFRiR2QzVGtod2VWZERNVkJUUlVwaFYyNUdOazVZYjNkWU1XUkNWVEZuTWxsdE9EQlBSbWhJVjIwNVdsUXdOVzFZTWpWMVVsVktUV051Y0hOTlNHUjVZMWhLYUZsdFVrVmtibEpZWkcxS2MxRlZhSEZVVjNCd1RrYzVSMXBVUWtWbGJHdzJaSHBTTTJGSVNsQlRTRkpvWWpKR2JWTXdOVEJpVjFweFYycENTVk5XV25aVGFscFJUMFJvY2xWR1ZtaGlhMXA1VkZoc2FXRlRNVkJqUkVaWlYxZFNURmRFUmtaalNIQjBaVWhzZVdGdFJYZE5SMUptVGtoT1dtRkVWbFpUYlZaMVpVVmtSVTFYUmxGaGJVNHhWRVJhYms0d2REVlNTR3hIVTJ4c1QxVkVRbEpYUjFaTFRVaEdNMVV3V2tKVFNFNW9Xa1JXUTJOWFpITmFNRnBxWVcxU1QxWXhaRmxoTURWT1ZVWlNTRlpXVmt0UmVrWlNZVmhvZGxVeFRtMVNWMUV3VFVkc2RXTkVXWGxQVjFZd1VqTmtjR0ZWY0VWVE0wNDBZVVphZG1SWGJISmhibGt5WkZkRmVsTkhXWHBVVjNoTVlVUkNhMkpJUlRGU2JsSjRXbXM0TlUxWGJHdE9NMHBIWWpCR2VVNHhaRkpOTVU1cVlrWkNkMDVyWkU5alYxSnFUMWhHUkdJeU5VNVhhazVUVWxoa2VtRnNVWFJPYkd0NFVXeGtVRTE2WkdGYVJUbHhWR3RhWmxJd2VFaFJiWFJOV1hwQ1dFOUdVak5PTUhCc1lWaHdTMlJ0U2xaa01teEdUVmhDYlZOSVdrZGtWVEZKWTBNeGJGZFlTa1ZaTTBWMFpGUk9XV1J0VmxGbFYzaGhZbFZLTW1WcmVHWlVNR3hPVTJ4YVNsUkZUbEJaTVZwRVVqRndkMVJIWkZoWk1taElZVlZLYWt4VVNtRmFiWGQwVTFSTmVGSkVXa2hsU0doWVRraE9NVTFHWkdoT01qRkNWbFZuTkdORlRsSlhTR3gyV1c1U2NXTlVhSFpYV0d4S1QxZDBUVlJYYzNsUk1XYzBVbTV3VTJKRVZqQmxSR3hwVFc1dk1WUllhRXRrZWxFeFkyMDFTMUpIU21aa2FtaG1UbGhPV21SR1l6UmphazVGVlZka01scFhWbk5SV0VKeVpXNU9kRnBIY0VsalZHaFdZekZzWmt4V2EzZFJWVGt5VFZaVk1tSXlNVE5WZVRGTFZFVXhlRkl3VWxkUmJtYzBWRWRzVGxwR1ZrdGpWbXh6VGtaR2ExVXdhekZhYkUwd1pVaHNSazVXWjNsV1IwWmFZekphZGxZeWFIUlBSRnB6VGpOQ1QxZEhSbkJpTUhoVVZEQmtNbVJ1WkZWT01scHRWbTA1ZFdJd1JURlpWa1p1WWxkS05tSXdNVU5hTW5nMlZHa3hTazU2YkhoWFYzQkpWR3Q0UkZZd1ZsbFVNMDVwVFZjd2VtUkhjM2xVVldONlRWVktjVlJIZEVsTlZXZzFZbXRLUW1WcmJGTlZNbmN6WkVWS1VsSkdPVWxOVldSeVpFUnNiRkpyYUhWWWVrSlhaVWhHYkUxclRUQmxWRTQwWVZVMVQxcEZjR3BWTWtwRlpGTXhXVmRJVGpOVE1uaFdWakp3WVZnelZYUlhiR2N3Wkc1T1NFMXFVWHBZTWxKSFRWaFNWMWt4V2taUk1sWndVMjVPZFZsWFRrZFZlazV3VlVkNGIyRlViRFZTUlZwNFlWaHNiRlJxYkcxYVJXeFlWRlpDTVZGV2JHOU9NRWwzVFc1S1YxSlVWalJrUkVKTFpHNW9ibUpHWkhoU1ZsWTFaV3BqTUZJeWVHbFplbVJJVm1reGVGcEVTbWxhTW5oRlpHeGtjVlJ1U2paTlZFWldVa1JXYW1WSVFsRmtSazVEVm10U1UyUklUbFJhU0doV1pHMDVXRTlWVWtOaFdFWXdUbTFrU0ZSdGIzUk5WMXBOWWxoU2VWSldUbkpoUldoSVZEQjBTVTB5Vmt4VWJGWjJWMVZHTmxKVE1ESmlhbEpaWWtSS2RGRlVWbkpoUlZKNFdtcGplRnB0Y0VSTlIwMDFVbXBrTTJRd05XMVZSWGQ1WVVaQ1psRXdXakZTYkVVelkwZG9SazVJU2taWk1XeFRUV3M1ZDJSWFJuaGlSekZyWWpCVk1tSXhXa0phUnpreVUyeG5lRlpXT1hOaU1EVldXa1V4UkZKNlFqQmpXR2hwVmpCVk1sZFlZM3BUVXpGNFVWY3hhMVJ1WkVKUlZHUnZXVlpGTkdOc1NUQmFWVWwwVW14YWNWZEVUbkpYYXpWTFkyMWFSazlIVm5kUmJXeHFVakIwYmxSRlpFWlZSM042WXpKT2Nsa3dTVE5sVmxaWlZFZGtjRTFZUWtkaU1IQXlaVlUxYUdSVlpGZFZibEpRWVZoYVFsWnRkSFpTYTBVelRGVTFTazFYYUZKUk1VcE1WMmt4U1dKNldreGpXRWt4Wkd0U1RXSnNPVmRVYTBaRlZtcEtabU13VWxGV1YzZ3dVVEowVEZSc2JESmFNMmd4WkZWT1NWa3piRVZPUlVwUlpWVXhNVlJFUW5wT01Wb3dXREkxTVdSclZtbFVNVTU0VGtSa1VrNXJWalZpTUVwUlRVWkdObVI2UmxKU1IyUnhZMVUxZVZnd05UQmpNRGt4V20xNFIyTlVWakJsUmtKR1QxZEdWbUZYZUZKVE1FWllZbGR3UWxWVmJITk9WbWd3WkVSWmRHRkZSbFZpTVd4bVVqRldjMUV5Y0cxV1ZrSlFWMGhrY0ZWUlBUMGFJQklhVldkNVJUSTJOVzFyVWtrMmNFOXVTMjFuYkRSQllVRkNRMUZJWkdnREtHTSUzRA%3D%3D")
+ end
+ end
+
+ describe ".extract_channel_community_cursor" do
+ it "correctly extracts a community cursor from a given continuation" do
+ expect(Invidious::Channels::Community.extract_channel_community_cursor("4qmFsgIsEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaEEVnbGpiMjF0ZFc1cGRIbTQ%3D")).to eq("Egljb21tdW5pdHk=")
+ expect(Invidious::Channels::Community.extract_channel_community_cursor("4qmFsgJoEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaTEVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2QzY0U5TlFtVndXRWRqY2xoc1VIZzJXalJCWVVGQ1ExRklaR2dES0FBJTI1M0Q%3D")).to eq("Egljb21tdW5pdHm4AQCqAyQaIEhkaAMSGlVnd3BPTUJlcFhHY3JYbFB4Nlo0QWFBQkNRKAA=")
+
+ expect(Invidious::Channels::Community.extract_channel_community_cursor("4qmFsgJoEhhVQy1sSEpaUjNHcXhtMjRfVmRfQUo1WXcaTEVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2Q1UlRJMk5XMXJVa2syY0U5dVMyMW5iRFJCWVVGQ1ExRklaR2dES0FBJTI1M0Q%3D")).to eq("Egljb21tdW5pdHm4AQCqAyQaIEhkaAMSGlVneUUyNjVta1JJNnBPbkttZ2w0QWFBQkNRKAA=")
+ expect(Invidious::Channels::Community.extract_channel_community_cursor("4qmFsgKZFBIYVUMtbEhKWlIzR3F4bTI0X1ZkX0FKNVl3GvwTRWdsamIyMXRkVzVwZEhtNEFRQ3FBLWNPQ3NBT1VWVlNWRk5zT1hCTldFWXhZbFZhYmxGWGFHRmlXRkpOVFc1V00xWkhTWGRQVlU1RVdUTm9lRkpXV2xWalJXUkdWVEJPYTFwclRrdGpWVW95V2pCWmVtTkVaSFJQVjJjd1YxaFdiVkp0YUZWUFdGSndWakphVVU0eFRYbE5SV1JhWWxad1NWRlVhM2RsYWs1cFRVVjBkbGw2UWtSVk1scHNXSHBvVkZkVWJIRlNSMG8xWWtSa00xa3lkRWhNVlRWd1dERkNkRmRYT1VoalIwWjZaRU14YmxkVmNFaFVNamt6VW0xc2FHUlhTa1ZpVm1SNlpGaHdkMVF4VG5wT01FNTRUVzVLVWxveFFsQmtNRTVRVTFWV1ZXTXliSGxOYkZadlVWVjBObFZJWkZWaE1WVjVVek5XVW1KSFJsZGtSbXN6VTFkS2QxcFZVbGxWTWtaRlZHMWFWMVpzUm5WVU1HaHNaRmQwVDAxc1ZuZFRiR2QzVGtod2VWZERNVkJUUlVwaFYyNUdOazVZYjNkWU1XUkNWVEZuTWxsdE9EQlBSbWhJVjIwNVdsUXdOVzFZTWpWMVVsVktUV051Y0hOTlNHUjVZMWhLYUZsdFVrVmtibEpZWkcxS2MxRlZhSEZVVjNCd1RrYzVSMXBVUWtWbGJHdzJaSHBTTTJGSVNsQlRTRkpvWWpKR2JWTXdOVEJpVjFweFYycENTVk5XV25aVGFscFJUMFJvY2xWR1ZtaGlhMXA1VkZoc2FXRlRNVkJqUkVaWlYxZFNURmRFUmtaalNIQjBaVWhzZVdGdFJYZE5SMUptVGtoT1dtRkVWbFpUYlZaMVpVVmtSVTFYUmxGaGJVNHhWRVJhYms0d2REVlNTR3hIVTJ4c1QxVkVRbEpYUjFaTFRVaEdNMVV3V2tKVFNFNW9Xa1JXUTJOWFpITmFNRnBxWVcxU1QxWXhaRmxoTURWT1ZVWlNTRlpXVmt0UmVrWlNZVmhvZGxVeFRtMVNWMUV3VFVkc2RXTkVXWGxQVjFZd1VqTmtjR0ZWY0VWVE0wNDBZVVphZG1SWGJISmhibGt5WkZkRmVsTkhXWHBVVjNoTVlVUkNhMkpJUlRGU2JsSjRXbXM0TlUxWGJHdE9NMHBIWWpCR2VVNHhaRkpOTVU1cVlrWkNkMDVyWkU5alYxSnFUMWhHUkdJeU5VNVhhazVUVWxoa2VtRnNVWFJPYkd0NFVXeGtVRTE2WkdGYVJUbHhWR3RhWmxJd2VFaFJiWFJOV1hwQ1dFOUdVak5PTUhCc1lWaHdTMlJ0U2xaa01teEdUVmhDYlZOSVdrZGtWVEZKWTBNeGJGZFlTa1ZaTTBWMFpGUk9XV1J0VmxGbFYzaGhZbFZLTW1WcmVHWlVNR3hPVTJ4YVNsUkZUbEJaTVZwRVVqRndkMVJIWkZoWk1taElZVlZLYWt4VVNtRmFiWGQwVTFSTmVGSkVXa2hsU0doWVRraE9NVTFHWkdoT01qRkNWbFZuTkdORlRsSlhTR3gyV1c1U2NXTlVhSFpYV0d4S1QxZDBUVlJYYzNsUk1XYzBVbTV3VTJKRVZqQmxSR3hwVFc1dk1WUllhRXRrZWxFeFkyMDFTMUpIU21aa2FtaG1UbGhPV21SR1l6UmphazVGVlZka01scFhWbk5SV0VKeVpXNU9kRnBIY0VsalZHaFdZekZzWmt4V2EzZFJWVGt5VFZaVk1tSXlNVE5WZVRGTFZFVXhlRkl3VWxkUmJtYzBWRWRzVGxwR1ZrdGpWbXh6VGtaR2ExVXdhekZhYkUwd1pVaHNSazVXWjNsV1IwWmFZekphZGxZeWFIUlBSRnB6VGpOQ1QxZEhSbkJpTUhoVVZEQmtNbVJ1WkZWT01scHRWbTA1ZFdJd1JURlpWa1p1WWxkS05tSXdNVU5hTW5nMlZHa3hTazU2YkhoWFYzQkpWR3Q0UkZZd1ZsbFVNMDVwVFZjd2VtUkhjM2xVVldONlRWVktjVlJIZEVsTlZXZzFZbXRLUW1WcmJGTlZNbmN6WkVWS1VsSkdPVWxOVldSeVpFUnNiRkpyYUhWWWVrSlhaVWhHYkUxclRUQmxWRTQwWVZVMVQxcEZjR3BWTWtwRlpGTXhXVmRJVGpOVE1uaFdWakp3WVZnelZYUlhiR2N3Wkc1T1NFMXFVWHBZTWxKSFRWaFNWMWt4V2taUk1sWndVMjVPZFZsWFRrZFZlazV3VlVkNGIyRlViRFZTUlZwNFlWaHNiRlJxYkcxYVJXeFlWRlpDTVZGV2JHOU9NRWwzVFc1S1YxSlVWalJrUkVKTFpHNW9ibUpHWkhoU1ZsWTFaV3BqTUZJeWVHbFplbVJJVm1reGVGcEVTbWxhTW5oRlpHeGtjVlJ1U2paTlZFWldVa1JXYW1WSVFsRmtSazVEVm10U1UyUklUbFJhU0doV1pHMDVXRTlWVWtOaFdFWXdUbTFrU0ZSdGIzUk5WMXBOWWxoU2VWSldUbkpoUldoSVZEQjBTVTB5Vmt4VWJGWjJWMVZHTmxKVE1ESmlhbEpaWWtSS2RGRlVWbkpoUlZKNFdtcGplRnB0Y0VSTlIwMDFVbXBrTTJRd05XMVZSWGQ1WVVaQ1psRXdXakZTYkVVelkwZG9SazVJU2taWk1XeFRUV3M1ZDJSWFJuaGlSekZyWWpCVk1tSXhXa0phUnpreVUyeG5lRlpXT1hOaU1EVldXa1V4UkZKNlFqQmpXR2hwVmpCVk1sZFlZM3BUVXpGNFVWY3hhMVJ1WkVKUlZHUnZXVlpGTkdOc1NUQmFWVWwwVW14YWNWZEVUbkpYYXpWTFkyMWFSazlIVm5kUmJXeHFVakIwYmxSRlpFWlZSM042WXpKT2Nsa3dTVE5sVmxaWlZFZGtjRTFZUWtkaU1IQXlaVlUxYUdSVlpGZFZibEpRWVZoYVFsWnRkSFpTYTBVelRGVTFTazFYYUZKUk1VcE1WMmt4U1dKNldreGpXRWt4Wkd0U1RXSnNPVmRVYTBaRlZtcEtabU13VWxGV1YzZ3dVVEowVEZSc2JESmFNMmd4WkZWT1NWa3piRVZPUlVwUlpWVXhNVlJFUW5wT01Wb3dXREkxTVdSclZtbFVNVTU0VGtSa1VrNXJWalZpTUVwUlRVWkdObVI2UmxKU1IyUnhZMVUxZVZnd05UQmpNRGt4V20xNFIyTlVWakJsUmtKR1QxZEdWbUZYZUZKVE1FWllZbGR3UWxWVmJITk9WbWd3WkVSWmRHRkZSbFZpTVd4bVVqRldjMUV5Y0cxV1ZrSlFWMGhrY0ZWUlBUMGFJQklhVldkNVJUSTJOVzFyVWtrMmNFOXVTMjFuYkRSQllVRkNRMUZJWkdnREtHTSUyNTNE")).to eq("Egljb21tdW5pdHm4AQCqA-kOCsAOUVVSVFNsOXBNWEYxYlVablFXaGFiWFJNTW5WM1ZHSXdPVU5EWTNoeFJWWlVjRWRGVTBOa1prTktjVUoyWjBZemNEZHRPV2cwV1hWbVJtaFVPWFJwVjJaUU4xTXlNRWRaYlZwSVFUa3dlak5pTUV0dll6QkRVMlpsWHpoVFdUbHFSR0o1YkRkM1kydEhMVTVwWDFCdFdXOUhjR0Z6ZEMxbldVcEhUMjkzUm1saGRXSkViVmR6ZFhwd1QxTnpOME54TW5KUloxQlBkME5QU1VWVWMybHlNbFZvUVV0NlVIZFVhMVV5UzNWUmJHRldkRmszU1dKd1pVUllVMkZFVG1aV1ZsRnVUMGhsZFd0T01sVndTbGd3TkhweVdDMVBTRUphV25GNk5Yb3dYMWRCVTFnMlltODBPRmhIV205WlQwNW1YMjV1UlVKTWNucHNNSGR5Y1hKaFltUkVkblJYZG1Kc1FVaHFUV3BwTkc5R1pUQkVlbGw2ZHpSM2FISlBTSFJoYjJGbVMwNTBiV1pxV2pCSVNWWnZTalpRT0RoclVGVmhia1p5VFhsaWFTMVBjREZZV1dSTFdERkZjSHB0ZUhseWFtRXdNR1JmTkhOWmFEVlZTbVZ1ZUVkRU1XRlFhbU4xVERabk4wdDVSSGxHU2xsT1VEQlJXR1ZLTUhGM1UwWkJTSE5oWkRWQ2NXZHNaMFpqYW1ST1YxZFlhMDVOVUZSSFZWVktRekZSYVhodlUxTm1SV1EwTUdsdWNEWXlPV1YwUjNkcGFVcEVTM040YUZadmRXbHJhblkyZFdFelNHWXpUV3hMYURCa2JIRTFSblJ4Wms4NU1XbGtOM0pHYjBGeU4xZFJNMU5qYkZCd05rZE9jV1JqT1hGRGIyNU5Xak5TUlhkemFsUXRObGt4UWxkUE16ZGFaRTlxVGtaZlIweEhRbXRNWXpCWE9GUjNOMHBsYVhwS2RtSlZkMmxGTVhCbVNIWkdkVTFJY0MxbFdYSkVZM0V0ZFROWWRtVlFlV3hhYlVKMmVreGZUMGxOU2xaSlRFTlBZMVpEUjFwd1RHZFhZMmhIYVVKakxUSmFabXd0U1RNeFJEWkhlSGhYTkhOMU1GZGhOMjFCVlVnNGNFTlJXSGx2WW5ScWNUaHZXWGxKT1d0TVRXc3lRMWc0Um5wU2JEVjBlRGxpTW5vMVRYaEtkelExY201S1JHSmZkamhmTlhOWmRGYzRjak5FVVdkMlpXVnNRWEJyZW5OdFpHcEljVGhWYzFsZkxWa3dRVTkyTVZVMmIyMTNVeTFLVEUxeFIwUldRbmc0VEdsTlpGVktjVmxzTkZGa1UwazFabE0wZUhsRk5WZ3lWR0ZaYzJadlYyaHRPRFpzTjNCT1dHRnBiMHhUVDBkMmRuZFVOMlptVm05dWIwRTFZVkZuYldKNmIwMUNaMng2VGkxSk56bHhXV3BJVGt4RFYwVllUM05pTVcwemRHc3lUVWN6TVVKcVRHdElNVWg1YmtKQmVrbFNVMnczZEVKUlJGOUlNVWRyZERsbFJraHVYekJXZUhGbE1rTTBlVE40YVU1T1pFcGpVMkpFZFMxWVdITjNTMnhWVjJwYVgzVXRXbGcwZG5OSE1qUXpYMlJHTVhSV1kxWkZRMlZwU25OdVlXTkdVek5wVUd4b2FUbDVSRVp4YVhsbFRqbG1aRWxYVFZCMVFWbG9OMEl3TW5KV1JUVjRkREJLZG5obmJGZHhSVlY1ZWpjMFIyeGlZemRIVmkxeFpESmlaMnhFZGxkcVRuSjZNVEZWUkRWamVIQlFkRk5DVmtSU2RITlRaSGhWZG05WE9VUkNhWEYwTm1kSFRtb3RNV1pNYlhSeVJWTnJhRWhIVDB0SU0yVkxUbFZ2V1VGNlJTMDJialJZYkRKdFFUVnJhRVJ4WmpjeFptcERNR001UmpkM2QwNW1VRXd5YUZCZlEwWjFSbEUzY0doRk5ISkZZMWxTTWs5d2RXRnhiRzFrYjBVMmIxWkJaRzkyU2xneFZWOXNiMDVWWkUxRFJ6QjBjWGhpVjBVMldYY3pTUzF4UVcxa1RuZEJRVGRvWVZFNGNsSTBaVUl0UmxacVdETnJXazVLY21aRk9HVndRbWxqUjB0blRFZEZVR3N6YzJOclkwSTNlVlZZVEdkcE1YQkdiMHAyZVU1aGRVZFdVblJQYVhaQlZtdHZSa0UzTFU1Sk1XaFJRMUpMV2kxSWJ6WkxjWEkxZGtSTWJsOVdUa0ZFVmpKZmMwUlFWV3gwUTJ0TFRsbDJaM2gxZFVOSVkzbEVORUpRZVUxMVREQnpOMVowWDI1MWRrVmlUMU54TkRkUk5rVjViMEpRTUZGNmR6RlJSR2RxY1U1eVgwNTBjMDkxWm14R2NUVjBlRkJGT1dGVmFXeFJTMEZYYldwQlVVbHNOVmgwZERZdGFFRlViMWxmUjFWc1EycG1WVkJQV0hkcFVRPT0aIhIcVWd5RTI2NW1rUkk2cE9uS21nbDRBYUFCQ1E9PUhkaAMoYw==")
+ end
+ end
+end
diff --git a/spec/invidious/helpers/tokens_spec.cr b/spec/invidious/helpers/tokens_spec.cr
new file mode 100644
index 000000000..2bb47f060
--- /dev/null
+++ b/spec/invidious/helpers/tokens_spec.cr
@@ -0,0 +1,26 @@
+require "../../spec_helper"
+
+Spectator.describe Invidious::Helpers::Tokens do
+ describe ".sign_token" do
+ it "correctly signs a given hash" do
+ token = {
+ "session" => "v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "expires" => 1554680038,
+ "scopes" => [
+ ":notifications",
+ ":subscriptions/*",
+ "GET:tokens*",
+ ],
+ "signature" => "f__2hS20th8pALF305PJFK-D2aVtvefNnQheILHD2vU=",
+ }
+ expect(Invidious::Helpers::Tokens.sign_token("SECRET_KEY", token)).to eq(token["signature"])
+
+ token = {
+ "session" => "v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "scopes" => [":notifications", "POST:subscriptions/*"],
+ "signature" => "fNvXoT0MRAL9eE6lTE33CEg8HitYJDOL9a22rSN2Ihg=",
+ }
+ expect(Invidious::Helpers::Tokens.sign_token("SECRET_KEY", token)).to eq(token["signature"])
+ end
+ end
+end
diff --git a/spec/invidious/helpers_spec.cr b/spec/invidious/helpers_spec.cr
deleted file mode 100644
index 9fbb6d6fe..000000000
--- a/spec/invidious/helpers_spec.cr
+++ /dev/null
@@ -1,56 +0,0 @@
-require "../spec_helper"
-
-CONFIG = Config.from_yaml(File.open("config/config.example.yml"))
-
-Spectator.describe "Helper" do
- describe "#produce_channel_search_continuation" do
- it "correctly produces token for searching a specific channel" do
- expect(produce_channel_search_continuation("UCXuqSBlHAE6Xw-yeJA0Tunw", "", 100)).to eq("4qmFsgJqEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaIEVnWnpaV0Z5WTJnd0FUZ0JZQUY2QkVkS2IxaTRBUUE9WgCaAilicm93c2UtZmVlZFVDWHVxU0JsSEFFNlh3LXllSkEwVHVud3NlYXJjaA%3D%3D")
-
- expect(produce_channel_search_continuation("UCXuqSBlHAE6Xw-yeJA0Tunw", "По ожиशुपतिरपि子而時ஸ்றீனி", 0)).to eq("4qmFsgKoARIYVUNYdXFTQmxIQUU2WHcteWVKQTBUdW53GiBFZ1p6WldGeVkyZ3dBVGdCWUFGNkJFZEJRVDI0QVFBPVo-0J_QviDQvtC20LjgpLbgpYHgpKrgpKTgpL_gpLDgpKrgpL_lrZDogIzmmYLgrrjgr43grrHgr4Dgrqngrr-aAilicm93c2UtZmVlZFVDWHVxU0JsSEFFNlh3LXllSkEwVHVud3NlYXJjaA%3D%3D")
- end
- end
-
- describe "#produce_channel_community_continuation" do
- it "correctly produces a continuation token for a channel community" do
- expect(produce_channel_community_continuation("UCCj956IF62FbT7Gouszaj9w", "Egljb21tdW5pdHm4")).to eq("4qmFsgIsEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaEEVnbGpiMjF0ZFc1cGRIbTQ%3D")
- expect(produce_channel_community_continuation("UCCj956IF62FbT7Gouszaj9w", "Egljb21tdW5pdHm4AQCqAyQaIBIaVWd3cE9NQmVwWEdjclhsUHg2WjRBYUFCQ1FIZGgDKAA%3D")).to eq("4qmFsgJmEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaSkVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2QzY0U5TlFtVndXRWRqY2xoc1VIZzJXalJCWVVGQ1ExRklaR2dES0FBJTNE")
-
- expect(produce_channel_community_continuation("UC-lHJZR3Gqxm24_Vd_AJ5Yw", "Egljb21tdW5pdHm4AQCqAyQaIBIaVWd5RTI2NW1rUkk2cE9uS21nbDRBYUFCQ1FIZGgDKAA%3D")).to eq("4qmFsgJmEhhVQy1sSEpaUjNHcXhtMjRfVmRfQUo1WXcaSkVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2Q1UlRJMk5XMXJVa2syY0U5dVMyMW5iRFJCWVVGQ1ExRklaR2dES0FBJTNE")
- expect(produce_channel_community_continuation("UC-lHJZR3Gqxm24_Vd_AJ5Yw", "Egljb21tdW5pdHm4AQCqA-cOCsAOUVVSVFNsOXBNWEYxYlVablFXaGFiWFJNTW5WM1ZHSXdPVU5EWTNoeFJWWlVjRWRGVTBOa1prTktjVUoyWjBZemNEZHRPV2cwV1hWbVJtaFVPWFJwVjJaUU4xTXlNRWRaYlZwSVFUa3dlak5pTUV0dll6QkRVMlpsWHpoVFdUbHFSR0o1YkRkM1kydEhMVTVwWDFCdFdXOUhjR0Z6ZEMxbldVcEhUMjkzUm1saGRXSkViVmR6ZFhwd1QxTnpOME54TW5KUloxQlBkME5QU1VWVWMybHlNbFZvUVV0NlVIZFVhMVV5UzNWUmJHRldkRmszU1dKd1pVUllVMkZFVG1aV1ZsRnVUMGhsZFd0T01sVndTbGd3TkhweVdDMVBTRUphV25GNk5Yb3dYMWRCVTFnMlltODBPRmhIV205WlQwNW1YMjV1UlVKTWNucHNNSGR5Y1hKaFltUkVkblJYZG1Kc1FVaHFUV3BwTkc5R1pUQkVlbGw2ZHpSM2FISlBTSFJoYjJGbVMwNTBiV1pxV2pCSVNWWnZTalpRT0RoclVGVmhia1p5VFhsaWFTMVBjREZZV1dSTFdERkZjSHB0ZUhseWFtRXdNR1JmTkhOWmFEVlZTbVZ1ZUVkRU1XRlFhbU4xVERabk4wdDVSSGxHU2xsT1VEQlJXR1ZLTUhGM1UwWkJTSE5oWkRWQ2NXZHNaMFpqYW1ST1YxZFlhMDVOVUZSSFZWVktRekZSYVhodlUxTm1SV1EwTUdsdWNEWXlPV1YwUjNkcGFVcEVTM040YUZadmRXbHJhblkyZFdFelNHWXpUV3hMYURCa2JIRTFSblJ4Wms4NU1XbGtOM0pHYjBGeU4xZFJNMU5qYkZCd05rZE9jV1JqT1hGRGIyNU5Xak5TUlhkemFsUXRObGt4UWxkUE16ZGFaRTlxVGtaZlIweEhRbXRNWXpCWE9GUjNOMHBsYVhwS2RtSlZkMmxGTVhCbVNIWkdkVTFJY0MxbFdYSkVZM0V0ZFROWWRtVlFlV3hhYlVKMmVreGZUMGxOU2xaSlRFTlBZMVpEUjFwd1RHZFhZMmhIYVVKakxUSmFabXd0U1RNeFJEWkhlSGhYTkhOMU1GZGhOMjFCVlVnNGNFTlJXSGx2WW5ScWNUaHZXWGxKT1d0TVRXc3lRMWc0Um5wU2JEVjBlRGxpTW5vMVRYaEtkelExY201S1JHSmZkamhmTlhOWmRGYzRjak5FVVdkMlpXVnNRWEJyZW5OdFpHcEljVGhWYzFsZkxWa3dRVTkyTVZVMmIyMTNVeTFLVEUxeFIwUldRbmc0VEdsTlpGVktjVmxzTkZGa1UwazFabE0wZUhsRk5WZ3lWR0ZaYzJadlYyaHRPRFpzTjNCT1dHRnBiMHhUVDBkMmRuZFVOMlptVm05dWIwRTFZVkZuYldKNmIwMUNaMng2VGkxSk56bHhXV3BJVGt4RFYwVllUM05pTVcwemRHc3lUVWN6TVVKcVRHdElNVWg1YmtKQmVrbFNVMnczZEVKUlJGOUlNVWRyZERsbFJraHVYekJXZUhGbE1rTTBlVE40YVU1T1pFcGpVMkpFZFMxWVdITjNTMnhWVjJwYVgzVXRXbGcwZG5OSE1qUXpYMlJHTVhSV1kxWkZRMlZwU25OdVlXTkdVek5wVUd4b2FUbDVSRVp4YVhsbFRqbG1aRWxYVFZCMVFWbG9OMEl3TW5KV1JUVjRkREJLZG5obmJGZHhSVlY1ZWpjMFIyeGlZemRIVmkxeFpESmlaMnhFZGxkcVRuSjZNVEZWUkRWamVIQlFkRk5DVmtSU2RITlRaSGhWZG05WE9VUkNhWEYwTm1kSFRtb3RNV1pNYlhSeVJWTnJhRWhIVDB0SU0yVkxUbFZ2V1VGNlJTMDJialJZYkRKdFFUVnJhRVJ4WmpjeFptcERNR001UmpkM2QwNW1VRXd5YUZCZlEwWjFSbEUzY0doRk5ISkZZMWxTTWs5d2RXRnhiRzFrYjBVMmIxWkJaRzkyU2xneFZWOXNiMDVWWkUxRFJ6QjBjWGhpVjBVMldYY3pTUzF4UVcxa1RuZEJRVGRvWVZFNGNsSTBaVUl0UmxacVdETnJXazVLY21aRk9HVndRbWxqUjB0blRFZEZVR3N6YzJOclkwSTNlVlZZVEdkcE1YQkdiMHAyZVU1aGRVZFdVblJQYVhaQlZtdHZSa0UzTFU1Sk1XaFJRMUpMV2kxSWJ6WkxjWEkxZGtSTWJsOVdUa0ZFVmpKZmMwUlFWV3gwUTJ0TFRsbDJaM2gxZFVOSVkzbEVORUpRZVUxMVREQnpOMVowWDI1MWRrVmlUMU54TkRkUk5rVjViMEpRTUZGNmR6RlJSR2RxY1U1eVgwNTBjMDkxWm14R2NUVjBlRkJGT1dGVmFXeFJTMEZYYldwQlVVbHNOVmgwZERZdGFFRlViMWxmUjFWc1EycG1WVkJQV0hkcFVRPT0aIBIaVWd5RTI2NW1rUkk2cE9uS21nbDRBYUFCQ1FIZGgDKGM%3D")).to eq("4qmFsgKXFBIYVUMtbEhKWlIzR3F4bTI0X1ZkX0FKNVl3GvoTRWdsamIyMXRkVzVwZEhtNEFRQ3FBLWNPQ3NBT1VWVlNWRk5zT1hCTldFWXhZbFZhYmxGWGFHRmlXRkpOVFc1V00xWkhTWGRQVlU1RVdUTm9lRkpXV2xWalJXUkdWVEJPYTFwclRrdGpWVW95V2pCWmVtTkVaSFJQVjJjd1YxaFdiVkp0YUZWUFdGSndWakphVVU0eFRYbE5SV1JhWWxad1NWRlVhM2RsYWs1cFRVVjBkbGw2UWtSVk1scHNXSHBvVkZkVWJIRlNSMG8xWWtSa00xa3lkRWhNVlRWd1dERkNkRmRYT1VoalIwWjZaRU14YmxkVmNFaFVNamt6VW0xc2FHUlhTa1ZpVm1SNlpGaHdkMVF4VG5wT01FNTRUVzVLVWxveFFsQmtNRTVRVTFWV1ZXTXliSGxOYkZadlVWVjBObFZJWkZWaE1WVjVVek5XVW1KSFJsZGtSbXN6VTFkS2QxcFZVbGxWTWtaRlZHMWFWMVpzUm5WVU1HaHNaRmQwVDAxc1ZuZFRiR2QzVGtod2VWZERNVkJUUlVwaFYyNUdOazVZYjNkWU1XUkNWVEZuTWxsdE9EQlBSbWhJVjIwNVdsUXdOVzFZTWpWMVVsVktUV051Y0hOTlNHUjVZMWhLYUZsdFVrVmtibEpZWkcxS2MxRlZhSEZVVjNCd1RrYzVSMXBVUWtWbGJHdzJaSHBTTTJGSVNsQlRTRkpvWWpKR2JWTXdOVEJpVjFweFYycENTVk5XV25aVGFscFJUMFJvY2xWR1ZtaGlhMXA1VkZoc2FXRlRNVkJqUkVaWlYxZFNURmRFUmtaalNIQjBaVWhzZVdGdFJYZE5SMUptVGtoT1dtRkVWbFpUYlZaMVpVVmtSVTFYUmxGaGJVNHhWRVJhYms0d2REVlNTR3hIVTJ4c1QxVkVRbEpYUjFaTFRVaEdNMVV3V2tKVFNFNW9Xa1JXUTJOWFpITmFNRnBxWVcxU1QxWXhaRmxoTURWT1ZVWlNTRlpXVmt0UmVrWlNZVmhvZGxVeFRtMVNWMUV3VFVkc2RXTkVXWGxQVjFZd1VqTmtjR0ZWY0VWVE0wNDBZVVphZG1SWGJISmhibGt5WkZkRmVsTkhXWHBVVjNoTVlVUkNhMkpJUlRGU2JsSjRXbXM0TlUxWGJHdE9NMHBIWWpCR2VVNHhaRkpOTVU1cVlrWkNkMDVyWkU5alYxSnFUMWhHUkdJeU5VNVhhazVUVWxoa2VtRnNVWFJPYkd0NFVXeGtVRTE2WkdGYVJUbHhWR3RhWmxJd2VFaFJiWFJOV1hwQ1dFOUdVak5PTUhCc1lWaHdTMlJ0U2xaa01teEdUVmhDYlZOSVdrZGtWVEZKWTBNeGJGZFlTa1ZaTTBWMFpGUk9XV1J0VmxGbFYzaGhZbFZLTW1WcmVHWlVNR3hPVTJ4YVNsUkZUbEJaTVZwRVVqRndkMVJIWkZoWk1taElZVlZLYWt4VVNtRmFiWGQwVTFSTmVGSkVXa2hsU0doWVRraE9NVTFHWkdoT01qRkNWbFZuTkdORlRsSlhTR3gyV1c1U2NXTlVhSFpYV0d4S1QxZDBUVlJYYzNsUk1XYzBVbTV3VTJKRVZqQmxSR3hwVFc1dk1WUllhRXRrZWxFeFkyMDFTMUpIU21aa2FtaG1UbGhPV21SR1l6UmphazVGVlZka01scFhWbk5SV0VKeVpXNU9kRnBIY0VsalZHaFdZekZzWmt4V2EzZFJWVGt5VFZaVk1tSXlNVE5WZVRGTFZFVXhlRkl3VWxkUmJtYzBWRWRzVGxwR1ZrdGpWbXh6VGtaR2ExVXdhekZhYkUwd1pVaHNSazVXWjNsV1IwWmFZekphZGxZeWFIUlBSRnB6VGpOQ1QxZEhSbkJpTUhoVVZEQmtNbVJ1WkZWT01scHRWbTA1ZFdJd1JURlpWa1p1WWxkS05tSXdNVU5hTW5nMlZHa3hTazU2YkhoWFYzQkpWR3Q0UkZZd1ZsbFVNMDVwVFZjd2VtUkhjM2xVVldONlRWVktjVlJIZEVsTlZXZzFZbXRLUW1WcmJGTlZNbmN6WkVWS1VsSkdPVWxOVldSeVpFUnNiRkpyYUhWWWVrSlhaVWhHYkUxclRUQmxWRTQwWVZVMVQxcEZjR3BWTWtwRlpGTXhXVmRJVGpOVE1uaFdWakp3WVZnelZYUlhiR2N3Wkc1T1NFMXFVWHBZTWxKSFRWaFNWMWt4V2taUk1sWndVMjVPZFZsWFRrZFZlazV3VlVkNGIyRlViRFZTUlZwNFlWaHNiRlJxYkcxYVJXeFlWRlpDTVZGV2JHOU9NRWwzVFc1S1YxSlVWalJrUkVKTFpHNW9ibUpHWkhoU1ZsWTFaV3BqTUZJeWVHbFplbVJJVm1reGVGcEVTbWxhTW5oRlpHeGtjVlJ1U2paTlZFWldVa1JXYW1WSVFsRmtSazVEVm10U1UyUklUbFJhU0doV1pHMDVXRTlWVWtOaFdFWXdUbTFrU0ZSdGIzUk5WMXBOWWxoU2VWSldUbkpoUldoSVZEQjBTVTB5Vmt4VWJGWjJWMVZHTmxKVE1ESmlhbEpaWWtSS2RGRlVWbkpoUlZKNFdtcGplRnB0Y0VSTlIwMDFVbXBrTTJRd05XMVZSWGQ1WVVaQ1psRXdXakZTYkVVelkwZG9SazVJU2taWk1XeFRUV3M1ZDJSWFJuaGlSekZyWWpCVk1tSXhXa0phUnpreVUyeG5lRlpXT1hOaU1EVldXa1V4UkZKNlFqQmpXR2hwVmpCVk1sZFlZM3BUVXpGNFVWY3hhMVJ1WkVKUlZHUnZXVlpGTkdOc1NUQmFWVWwwVW14YWNWZEVUbkpYYXpWTFkyMWFSazlIVm5kUmJXeHFVakIwYmxSRlpFWlZSM042WXpKT2Nsa3dTVE5sVmxaWlZFZGtjRTFZUWtkaU1IQXlaVlUxYUdSVlpGZFZibEpRWVZoYVFsWnRkSFpTYTBVelRGVTFTazFYYUZKUk1VcE1WMmt4U1dKNldreGpXRWt4Wkd0U1RXSnNPVmRVYTBaRlZtcEtabU13VWxGV1YzZ3dVVEowVEZSc2JESmFNMmd4WkZWT1NWa3piRVZPUlVwUlpWVXhNVlJFUW5wT01Wb3dXREkxTVdSclZtbFVNVTU0VGtSa1VrNXJWalZpTUVwUlRVWkdObVI2UmxKU1IyUnhZMVUxZVZnd05UQmpNRGt4V20xNFIyTlVWakJsUmtKR1QxZEdWbUZYZUZKVE1FWllZbGR3UWxWVmJITk9WbWd3WkVSWmRHRkZSbFZpTVd4bVVqRldjMUV5Y0cxV1ZrSlFWMGhrY0ZWUlBUMGFJQklhVldkNVJUSTJOVzFyVWtrMmNFOXVTMjFuYkRSQllVRkNRMUZJWkdnREtHTSUzRA%3D%3D")
- end
- end
-
- describe "#extract_channel_community_cursor" do
- it "correctly extracts a community cursor from a given continuation" do
- expect(extract_channel_community_cursor("4qmFsgIsEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaEEVnbGpiMjF0ZFc1cGRIbTQ%3D")).to eq("Egljb21tdW5pdHk=")
- expect(extract_channel_community_cursor("4qmFsgJoEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaTEVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2QzY0U5TlFtVndXRWRqY2xoc1VIZzJXalJCWVVGQ1ExRklaR2dES0FBJTI1M0Q%3D")).to eq("Egljb21tdW5pdHm4AQCqAyQaIEhkaAMSGlVnd3BPTUJlcFhHY3JYbFB4Nlo0QWFBQkNRKAA=")
-
- expect(extract_channel_community_cursor("4qmFsgJoEhhVQy1sSEpaUjNHcXhtMjRfVmRfQUo1WXcaTEVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2Q1UlRJMk5XMXJVa2syY0U5dVMyMW5iRFJCWVVGQ1ExRklaR2dES0FBJTI1M0Q%3D")).to eq("Egljb21tdW5pdHm4AQCqAyQaIEhkaAMSGlVneUUyNjVta1JJNnBPbkttZ2w0QWFBQkNRKAA=")
- expect(extract_channel_community_cursor("4qmFsgKZFBIYVUMtbEhKWlIzR3F4bTI0X1ZkX0FKNVl3GvwTRWdsamIyMXRkVzVwZEhtNEFRQ3FBLWNPQ3NBT1VWVlNWRk5zT1hCTldFWXhZbFZhYmxGWGFHRmlXRkpOVFc1V00xWkhTWGRQVlU1RVdUTm9lRkpXV2xWalJXUkdWVEJPYTFwclRrdGpWVW95V2pCWmVtTkVaSFJQVjJjd1YxaFdiVkp0YUZWUFdGSndWakphVVU0eFRYbE5SV1JhWWxad1NWRlVhM2RsYWs1cFRVVjBkbGw2UWtSVk1scHNXSHBvVkZkVWJIRlNSMG8xWWtSa00xa3lkRWhNVlRWd1dERkNkRmRYT1VoalIwWjZaRU14YmxkVmNFaFVNamt6VW0xc2FHUlhTa1ZpVm1SNlpGaHdkMVF4VG5wT01FNTRUVzVLVWxveFFsQmtNRTVRVTFWV1ZXTXliSGxOYkZadlVWVjBObFZJWkZWaE1WVjVVek5XVW1KSFJsZGtSbXN6VTFkS2QxcFZVbGxWTWtaRlZHMWFWMVpzUm5WVU1HaHNaRmQwVDAxc1ZuZFRiR2QzVGtod2VWZERNVkJUUlVwaFYyNUdOazVZYjNkWU1XUkNWVEZuTWxsdE9EQlBSbWhJVjIwNVdsUXdOVzFZTWpWMVVsVktUV051Y0hOTlNHUjVZMWhLYUZsdFVrVmtibEpZWkcxS2MxRlZhSEZVVjNCd1RrYzVSMXBVUWtWbGJHdzJaSHBTTTJGSVNsQlRTRkpvWWpKR2JWTXdOVEJpVjFweFYycENTVk5XV25aVGFscFJUMFJvY2xWR1ZtaGlhMXA1VkZoc2FXRlRNVkJqUkVaWlYxZFNURmRFUmtaalNIQjBaVWhzZVdGdFJYZE5SMUptVGtoT1dtRkVWbFpUYlZaMVpVVmtSVTFYUmxGaGJVNHhWRVJhYms0d2REVlNTR3hIVTJ4c1QxVkVRbEpYUjFaTFRVaEdNMVV3V2tKVFNFNW9Xa1JXUTJOWFpITmFNRnBxWVcxU1QxWXhaRmxoTURWT1ZVWlNTRlpXVmt0UmVrWlNZVmhvZGxVeFRtMVNWMUV3VFVkc2RXTkVXWGxQVjFZd1VqTmtjR0ZWY0VWVE0wNDBZVVphZG1SWGJISmhibGt5WkZkRmVsTkhXWHBVVjNoTVlVUkNhMkpJUlRGU2JsSjRXbXM0TlUxWGJHdE9NMHBIWWpCR2VVNHhaRkpOTVU1cVlrWkNkMDVyWkU5alYxSnFUMWhHUkdJeU5VNVhhazVUVWxoa2VtRnNVWFJPYkd0NFVXeGtVRTE2WkdGYVJUbHhWR3RhWmxJd2VFaFJiWFJOV1hwQ1dFOUdVak5PTUhCc1lWaHdTMlJ0U2xaa01teEdUVmhDYlZOSVdrZGtWVEZKWTBNeGJGZFlTa1ZaTTBWMFpGUk9XV1J0VmxGbFYzaGhZbFZLTW1WcmVHWlVNR3hPVTJ4YVNsUkZUbEJaTVZwRVVqRndkMVJIWkZoWk1taElZVlZLYWt4VVNtRmFiWGQwVTFSTmVGSkVXa2hsU0doWVRraE9NVTFHWkdoT01qRkNWbFZuTkdORlRsSlhTR3gyV1c1U2NXTlVhSFpYV0d4S1QxZDBUVlJYYzNsUk1XYzBVbTV3VTJKRVZqQmxSR3hwVFc1dk1WUllhRXRrZWxFeFkyMDFTMUpIU21aa2FtaG1UbGhPV21SR1l6UmphazVGVlZka01scFhWbk5SV0VKeVpXNU9kRnBIY0VsalZHaFdZekZzWmt4V2EzZFJWVGt5VFZaVk1tSXlNVE5WZVRGTFZFVXhlRkl3VWxkUmJtYzBWRWRzVGxwR1ZrdGpWbXh6VGtaR2ExVXdhekZhYkUwd1pVaHNSazVXWjNsV1IwWmFZekphZGxZeWFIUlBSRnB6VGpOQ1QxZEhSbkJpTUhoVVZEQmtNbVJ1WkZWT01scHRWbTA1ZFdJd1JURlpWa1p1WWxkS05tSXdNVU5hTW5nMlZHa3hTazU2YkhoWFYzQkpWR3Q0UkZZd1ZsbFVNMDVwVFZjd2VtUkhjM2xVVldONlRWVktjVlJIZEVsTlZXZzFZbXRLUW1WcmJGTlZNbmN6WkVWS1VsSkdPVWxOVldSeVpFUnNiRkpyYUhWWWVrSlhaVWhHYkUxclRUQmxWRTQwWVZVMVQxcEZjR3BWTWtwRlpGTXhXVmRJVGpOVE1uaFdWakp3WVZnelZYUlhiR2N3Wkc1T1NFMXFVWHBZTWxKSFRWaFNWMWt4V2taUk1sWndVMjVPZFZsWFRrZFZlazV3VlVkNGIyRlViRFZTUlZwNFlWaHNiRlJxYkcxYVJXeFlWRlpDTVZGV2JHOU9NRWwzVFc1S1YxSlVWalJrUkVKTFpHNW9ibUpHWkhoU1ZsWTFaV3BqTUZJeWVHbFplbVJJVm1reGVGcEVTbWxhTW5oRlpHeGtjVlJ1U2paTlZFWldVa1JXYW1WSVFsRmtSazVEVm10U1UyUklUbFJhU0doV1pHMDVXRTlWVWtOaFdFWXdUbTFrU0ZSdGIzUk5WMXBOWWxoU2VWSldUbkpoUldoSVZEQjBTVTB5Vmt4VWJGWjJWMVZHTmxKVE1ESmlhbEpaWWtSS2RGRlVWbkpoUlZKNFdtcGplRnB0Y0VSTlIwMDFVbXBrTTJRd05XMVZSWGQ1WVVaQ1psRXdXakZTYkVVelkwZG9SazVJU2taWk1XeFRUV3M1ZDJSWFJuaGlSekZyWWpCVk1tSXhXa0phUnpreVUyeG5lRlpXT1hOaU1EVldXa1V4UkZKNlFqQmpXR2hwVmpCVk1sZFlZM3BUVXpGNFVWY3hhMVJ1WkVKUlZHUnZXVlpGTkdOc1NUQmFWVWwwVW14YWNWZEVUbkpYYXpWTFkyMWFSazlIVm5kUmJXeHFVakIwYmxSRlpFWlZSM042WXpKT2Nsa3dTVE5sVmxaWlZFZGtjRTFZUWtkaU1IQXlaVlUxYUdSVlpGZFZibEpRWVZoYVFsWnRkSFpTYTBVelRGVTFTazFYYUZKUk1VcE1WMmt4U1dKNldreGpXRWt4Wkd0U1RXSnNPVmRVYTBaRlZtcEtabU13VWxGV1YzZ3dVVEowVEZSc2JESmFNMmd4WkZWT1NWa3piRVZPUlVwUlpWVXhNVlJFUW5wT01Wb3dXREkxTVdSclZtbFVNVTU0VGtSa1VrNXJWalZpTUVwUlRVWkdObVI2UmxKU1IyUnhZMVUxZVZnd05UQmpNRGt4V20xNFIyTlVWakJsUmtKR1QxZEdWbUZYZUZKVE1FWllZbGR3UWxWVmJITk9WbWd3WkVSWmRHRkZSbFZpTVd4bVVqRldjMUV5Y0cxV1ZrSlFWMGhrY0ZWUlBUMGFJQklhVldkNVJUSTJOVzFyVWtrMmNFOXVTMjFuYkRSQllVRkNRMUZJWkdnREtHTSUyNTNE")).to eq("Egljb21tdW5pdHm4AQCqA-kOCsAOUVVSVFNsOXBNWEYxYlVablFXaGFiWFJNTW5WM1ZHSXdPVU5EWTNoeFJWWlVjRWRGVTBOa1prTktjVUoyWjBZemNEZHRPV2cwV1hWbVJtaFVPWFJwVjJaUU4xTXlNRWRaYlZwSVFUa3dlak5pTUV0dll6QkRVMlpsWHpoVFdUbHFSR0o1YkRkM1kydEhMVTVwWDFCdFdXOUhjR0Z6ZEMxbldVcEhUMjkzUm1saGRXSkViVmR6ZFhwd1QxTnpOME54TW5KUloxQlBkME5QU1VWVWMybHlNbFZvUVV0NlVIZFVhMVV5UzNWUmJHRldkRmszU1dKd1pVUllVMkZFVG1aV1ZsRnVUMGhsZFd0T01sVndTbGd3TkhweVdDMVBTRUphV25GNk5Yb3dYMWRCVTFnMlltODBPRmhIV205WlQwNW1YMjV1UlVKTWNucHNNSGR5Y1hKaFltUkVkblJYZG1Kc1FVaHFUV3BwTkc5R1pUQkVlbGw2ZHpSM2FISlBTSFJoYjJGbVMwNTBiV1pxV2pCSVNWWnZTalpRT0RoclVGVmhia1p5VFhsaWFTMVBjREZZV1dSTFdERkZjSHB0ZUhseWFtRXdNR1JmTkhOWmFEVlZTbVZ1ZUVkRU1XRlFhbU4xVERabk4wdDVSSGxHU2xsT1VEQlJXR1ZLTUhGM1UwWkJTSE5oWkRWQ2NXZHNaMFpqYW1ST1YxZFlhMDVOVUZSSFZWVktRekZSYVhodlUxTm1SV1EwTUdsdWNEWXlPV1YwUjNkcGFVcEVTM040YUZadmRXbHJhblkyZFdFelNHWXpUV3hMYURCa2JIRTFSblJ4Wms4NU1XbGtOM0pHYjBGeU4xZFJNMU5qYkZCd05rZE9jV1JqT1hGRGIyNU5Xak5TUlhkemFsUXRObGt4UWxkUE16ZGFaRTlxVGtaZlIweEhRbXRNWXpCWE9GUjNOMHBsYVhwS2RtSlZkMmxGTVhCbVNIWkdkVTFJY0MxbFdYSkVZM0V0ZFROWWRtVlFlV3hhYlVKMmVreGZUMGxOU2xaSlRFTlBZMVpEUjFwd1RHZFhZMmhIYVVKakxUSmFabXd0U1RNeFJEWkhlSGhYTkhOMU1GZGhOMjFCVlVnNGNFTlJXSGx2WW5ScWNUaHZXWGxKT1d0TVRXc3lRMWc0Um5wU2JEVjBlRGxpTW5vMVRYaEtkelExY201S1JHSmZkamhmTlhOWmRGYzRjak5FVVdkMlpXVnNRWEJyZW5OdFpHcEljVGhWYzFsZkxWa3dRVTkyTVZVMmIyMTNVeTFLVEUxeFIwUldRbmc0VEdsTlpGVktjVmxzTkZGa1UwazFabE0wZUhsRk5WZ3lWR0ZaYzJadlYyaHRPRFpzTjNCT1dHRnBiMHhUVDBkMmRuZFVOMlptVm05dWIwRTFZVkZuYldKNmIwMUNaMng2VGkxSk56bHhXV3BJVGt4RFYwVllUM05pTVcwemRHc3lUVWN6TVVKcVRHdElNVWg1YmtKQmVrbFNVMnczZEVKUlJGOUlNVWRyZERsbFJraHVYekJXZUhGbE1rTTBlVE40YVU1T1pFcGpVMkpFZFMxWVdITjNTMnhWVjJwYVgzVXRXbGcwZG5OSE1qUXpYMlJHTVhSV1kxWkZRMlZwU25OdVlXTkdVek5wVUd4b2FUbDVSRVp4YVhsbFRqbG1aRWxYVFZCMVFWbG9OMEl3TW5KV1JUVjRkREJLZG5obmJGZHhSVlY1ZWpjMFIyeGlZemRIVmkxeFpESmlaMnhFZGxkcVRuSjZNVEZWUkRWamVIQlFkRk5DVmtSU2RITlRaSGhWZG05WE9VUkNhWEYwTm1kSFRtb3RNV1pNYlhSeVJWTnJhRWhIVDB0SU0yVkxUbFZ2V1VGNlJTMDJialJZYkRKdFFUVnJhRVJ4WmpjeFptcERNR001UmpkM2QwNW1VRXd5YUZCZlEwWjFSbEUzY0doRk5ISkZZMWxTTWs5d2RXRnhiRzFrYjBVMmIxWkJaRzkyU2xneFZWOXNiMDVWWkUxRFJ6QjBjWGhpVjBVMldYY3pTUzF4UVcxa1RuZEJRVGRvWVZFNGNsSTBaVUl0UmxacVdETnJXazVLY21aRk9HVndRbWxqUjB0blRFZEZVR3N6YzJOclkwSTNlVlZZVEdkcE1YQkdiMHAyZVU1aGRVZFdVblJQYVhaQlZtdHZSa0UzTFU1Sk1XaFJRMUpMV2kxSWJ6WkxjWEkxZGtSTWJsOVdUa0ZFVmpKZmMwUlFWV3gwUTJ0TFRsbDJaM2gxZFVOSVkzbEVORUpRZVUxMVREQnpOMVowWDI1MWRrVmlUMU54TkRkUk5rVjViMEpRTUZGNmR6RlJSR2RxY1U1eVgwNTBjMDkxWm14R2NUVjBlRkJGT1dGVmFXeFJTMEZYYldwQlVVbHNOVmgwZERZdGFFRlViMWxmUjFWc1EycG1WVkJQV0hkcFVRPT0aIhIcVWd5RTI2NW1rUkk2cE9uS21nbDRBYUFCQ1E9PUhkaAMoYw==")
- end
- end
-
- describe "#sign_token" do
- it "correctly signs a given hash" do
- token = {
- "session" => "v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
- "expires" => 1554680038,
- "scopes" => [
- ":notifications",
- ":subscriptions/*",
- "GET:tokens*",
- ],
- "signature" => "f__2hS20th8pALF305PJFK-D2aVtvefNnQheILHD2vU=",
- }
- expect(sign_token("SECRET_KEY", token)).to eq(token["signature"])
-
- token = {
- "session" => "v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
- "scopes" => [":notifications", "POST:subscriptions/*"],
- "signature" => "fNvXoT0MRAL9eE6lTE33CEg8HitYJDOL9a22rSN2Ihg=",
- }
- expect(sign_token("SECRET_KEY", token)).to eq(token["signature"])
- end
- end
-end
diff --git a/spec/invidious/search/ctoken_spec.cr b/spec/invidious/search/ctoken_spec.cr
new file mode 100644
index 000000000..97ec9a22e
--- /dev/null
+++ b/spec/invidious/search/ctoken_spec.cr
@@ -0,0 +1,11 @@
+require "../../spec_helper"
+
+Spectator.describe Invidious::Search::CToken do
+ describe ".produce_channel_search_continuation" do
+ it "correctly produces token for searching a specific channel" do
+ expect(Invidious::Search::CToken.produce_channel_search_continuation("UCXuqSBlHAE6Xw-yeJA0Tunw", "", 100)).to eq("4qmFsgJqEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaIEVnWnpaV0Z5WTJnd0FUZ0JZQUY2QkVkS2IxaTRBUUE9WgCaAilicm93c2UtZmVlZFVDWHVxU0JsSEFFNlh3LXllSkEwVHVud3NlYXJjaA%3D%3D")
+
+ expect(Invidious::Search::CToken.produce_channel_search_continuation("UCXuqSBlHAE6Xw-yeJA0Tunw", "По ожиशुपतिरपि子而時ஸ்றீனி", 0)).to eq("4qmFsgKoARIYVUNYdXFTQmxIQUU2WHcteWVKQTBUdW53GiBFZ1p6WldGeVkyZ3dBVGdCWUFGNkJFZEJRVDI0QVFBPVo-0J_QviDQvtC20LjgpLbgpYHgpKrgpKTgpL_gpLDgpKrgpL_lrZDogIzmmYLgrrjgr43grrHgr4Dgrqngrr-aAilicm93c2UtZmVlZFVDWHVxU0JsSEFFNlh3LXllSkEwVHVud3NlYXJjaA%3D%3D")
+ end
+ end
+end
diff --git a/spec/invidious/hashtag_spec.cr b/spec/invidious/search/hashtag_spec.cr
similarity index 99%
rename from spec/invidious/hashtag_spec.cr
rename to spec/invidious/search/hashtag_spec.cr
index abc81225c..813646c60 100644
--- a/spec/invidious/hashtag_spec.cr
+++ b/spec/invidious/search/hashtag_spec.cr
@@ -1,4 +1,4 @@
-require "../parsers_helper.cr"
+require "../../parsers_helper.cr"
Spectator.describe Invidious::Hashtag do
it "parses richItemRenderer containers (test 1)" do
diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr
index b3060acfa..3635c1ea0 100644
--- a/spec/spec_helper.cr
+++ b/spec/spec_helper.cr
@@ -9,7 +9,6 @@ require "../src/invidious/videos/caption"
require "../src/invidious/videos"
require "../src/invidious/playlists"
require "../src/invidious/search/ctoken"
-require "../src/invidious/trending"
require "spectator"
Spectator.configure do |config|
diff --git a/src/invidious.cr b/src/invidious.cr
index cc65dde0e..9811b8d7b 100644
--- a/src/invidious.cr
+++ b/src/invidious.cr
@@ -44,6 +44,8 @@ require "./invidious/jsonify/**"
require "./invidious/*"
require "./invidious/comments/*"
require "./invidious/channels/*"
+require "./invidious/playlists/*"
+require "./invidious/feeds/*"
require "./invidious/user/*"
require "./invidious/search/*"
require "./invidious/routes/**"
@@ -230,7 +232,7 @@ error 404 do |env|
end
error 500 do |env, exception|
- error_template(500, exception)
+ Errors.error_template(500, exception)
end
# Init Kemal
diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr
index 247f628a0..759d89380 100644
--- a/src/invidious/channels/about.cr
+++ b/src/invidious/channels/about.cr
@@ -19,202 +19,206 @@ record AboutChannel,
verified : Bool,
is_age_gated : Bool
-def get_about_info(ucid) : AboutChannel
- begin
- # Fetch channel information from channel home page
- initdata = YoutubeAPI.browse(browse_id: ucid, params: "")
- rescue
- raise InfoException.new("Could not get channel info.")
- end
+module Invidious::Channels::About
+ extend self
- if initdata.dig?("alerts", 0, "alertRenderer", "type") == "ERROR"
- error_message = initdata["alerts"][0]["alertRenderer"]["text"]["simpleText"].as_s
- if error_message == "This channel does not exist."
- raise NotFoundException.new(error_message)
- else
- raise InfoException.new(error_message)
+ def get_about_info(ucid) : AboutChannel
+ begin
+ # Fetch channel information from channel home page
+ initdata = YoutubeAPI.browse(browse_id: ucid, params: "")
+ rescue
+ raise InfoException.new("Could not get channel info.")
end
- end
- if browse_endpoint = initdata["onResponseReceivedActions"]?.try &.[0]?.try &.["navigateAction"]?.try &.["endpoint"]?.try &.["browseEndpoint"]?
- raise ChannelRedirect.new(channel_id: browse_endpoint["browseId"].to_s)
- end
+ if initdata.dig?("alerts", 0, "alertRenderer", "type") == "ERROR"
+ error_message = initdata["alerts"][0]["alertRenderer"]["text"]["simpleText"].as_s
+ if error_message == "This channel does not exist."
+ raise NotFoundException.new(error_message)
+ else
+ raise InfoException.new(error_message)
+ end
+ end
- auto_generated = false
- # Check for special auto generated gaming channels
- if !initdata.has_key?("metadata")
- auto_generated = true
- end
+ if browse_endpoint = initdata["onResponseReceivedActions"]?.try &.[0]?.try &.["navigateAction"]?.try &.["endpoint"]?.try &.["browseEndpoint"]?
+ raise ChannelRedirect.new(channel_id: browse_endpoint["browseId"].to_s)
+ end
- tags = [] of String
- tab_names = [] of String
- total_views = 0_i64
- joined = Time.unix(0)
-
- if age_gate_renderer = initdata.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs", 0, "tabRenderer", "content", "sectionListRenderer", "contents", 0, "channelAgeGateRenderer")
- description_node = nil
- author = age_gate_renderer["channelTitle"].as_s
- ucid = initdata.dig("responseContext", "serviceTrackingParams", 0, "params", 0, "value").as_s
- author_url = "https://www.youtube.com/channel/#{ucid}"
- author_thumbnail = age_gate_renderer.dig("avatar", "thumbnails", 0, "url").as_s
- banner = nil
- is_family_friendly = false
- is_age_gated = true
- tab_names = ["videos", "shorts", "streams"]
auto_generated = false
- else
- if auto_generated
- author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s
- author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s
- author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s
+ # Check for special auto generated gaming channels
+ if !initdata.has_key?("metadata")
+ auto_generated = true
+ end
- # Raises a KeyError on failure.
- banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
- banner = banners.try &.[-1]?.try &.["url"].as_s?
+ tags = [] of String
+ tab_names = [] of String
+ total_views = 0_i64
+ joined = Time.unix(0)
- description_base_node = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]
- # some channels have the description in a simpleText
- # ex: https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg/
- description_node = description_base_node.dig?("simpleText") || description_base_node
-
- tags = initdata.dig?("header", "interactiveTabbedHeaderRenderer", "badges")
- .try &.as_a.map(&.["metadataBadgeRenderer"]["label"].as_s) || [] of String
+ if age_gate_renderer = initdata.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs", 0, "tabRenderer", "content", "sectionListRenderer", "contents", 0, "channelAgeGateRenderer")
+ description_node = nil
+ author = age_gate_renderer["channelTitle"].as_s
+ ucid = initdata.dig("responseContext", "serviceTrackingParams", 0, "params", 0, "value").as_s
+ author_url = "https://www.youtube.com/channel/#{ucid}"
+ author_thumbnail = age_gate_renderer.dig("avatar", "thumbnails", 0, "url").as_s
+ banner = nil
+ is_family_friendly = false
+ is_age_gated = true
+ tab_names = ["videos", "shorts", "streams"]
+ auto_generated = false
else
- author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s
- author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s
- author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s
- author_badge = initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "title", "dynamicTextViewModel", "text", "attachmentRuns", 0, "element", "type", "imageType", "image", "sources", 0, "clientResource", "imageName")
- .try &.as_s
- # CHECK_CIRCLE_FILLED is used for normal channels and AUDIO_BADGE if used For
- # music/artist channels
- # TODO: Maybe separate verified author from verified artist?
- author_verified = author_badge.try { |badge| badge == "CHECK_CIRCLE_FILLED" || badge == "AUDIO_BADGE" } || false
- ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s
+ if auto_generated
+ author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s
+ author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s
+ author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s
- # Raises a KeyError on failure.
- # TODO: Check if `c4TabbedHeaderRenderer` still exists on some channels.
- banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
- banners ||= initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "banner", "imageBannerViewModel", "image", "sources")
- banner = banners.try &.[-1]?.try &.["url"].as_s?
+ # Raises a KeyError on failure.
+ banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
+ banner = banners.try &.[-1]?.try &.["url"].as_s?
- # if banner.includes? "channels/c4/default_banner"
- # banner = nil
- # end
+ description_base_node = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]
+ # some channels have the description in a simpleText
+ # ex: https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg/
+ description_node = description_base_node.dig?("simpleText") || description_base_node
- description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?
- tags = initdata.dig?("microformat", "microformatDataRenderer", "tags").try &.as_a.map(&.as_s) || [] of String
- end
+ tags = initdata.dig?("header", "interactiveTabbedHeaderRenderer", "badges")
+ .try &.as_a.map(&.["metadataBadgeRenderer"]["label"].as_s) || [] of String
+ else
+ author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s
+ author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s
+ author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s
+ author_badge = initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "title", "dynamicTextViewModel", "text", "attachmentRuns", 0, "element", "type", "imageType", "image", "sources", 0, "clientResource", "imageName")
+ .try &.as_s
+ # CHECK_CIRCLE_FILLED is used for normal channels and AUDIO_BADGE if used For
+ # music/artist channels
+ # TODO: Maybe separate verified author from verified artist?
+ author_verified = author_badge.try { |badge| badge == "CHECK_CIRCLE_FILLED" || badge == "AUDIO_BADGE" } || false
+ ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s
- is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
- if tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]?
- # Get the name of the tabs available on this channel
- tab_names = tabs_json.as_a.compact_map do |entry|
- name = entry.dig?("tabRenderer", "title").try &.as_s.downcase
+ # Raises a KeyError on failure.
+ # TODO: Check if `c4TabbedHeaderRenderer` still exists on some channels.
+ banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
+ banners ||= initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "banner", "imageBannerViewModel", "image", "sources")
+ banner = banners.try &.[-1]?.try &.["url"].as_s?
- # This is a small fix to not add extra code on the HTML side
- # I.e, the URL for the "live" tab is .../streams, so use "streams"
- # everywhere for the sake of simplicity
- (name == "live") ? "streams" : name
+ # if banner.includes? "channels/c4/default_banner"
+ # banner = nil
+ # end
+
+ description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?
+ tags = initdata.dig?("microformat", "microformatDataRenderer", "tags").try &.as_a.map(&.as_s) || [] of String
end
- # Get the currently active tab ("About")
- about_tab = extract_selected_tab(tabs_json)
+ is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
+ if tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]?
+ # Get the name of the tabs available on this channel
+ tab_names = tabs_json.as_a.compact_map do |entry|
+ name = entry.dig?("tabRenderer", "title").try &.as_s.downcase
- # Try to find the about metadata section
- channel_about_meta = about_tab.dig?(
- "content",
- "sectionListRenderer", "contents", 0,
- "itemSectionRenderer", "contents", 0,
- "channelAboutFullMetadataRenderer"
- )
+ # This is a small fix to not add extra code on the HTML side
+ # I.e, the URL for the "live" tab is .../streams, so use "streams"
+ # everywhere for the sake of simplicity
+ (name == "live") ? "streams" : name
+ end
- if !channel_about_meta.nil?
- total_views = channel_about_meta.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D/, "").to_i64? || 0_i64
+ # Get the currently active tab ("About")
+ about_tab = extract_selected_tab(tabs_json)
- # The joined text is split to several sub strings. The reduce joins those strings before parsing the date.
- joined = extract_text(channel_about_meta["joinedDateText"]?)
- .try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0)
-
- # Normal Auto-generated channels
- # https://support.google.com/youtube/answer/2579942
- # For auto-generated channels, channel_about_meta only has
- # ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"]
- auto_generated = (
- (channel_about_meta["primaryLinks"]?.try &.size) == 1 && \
- extract_text(channel_about_meta.dig?("primaryLinks", 0, "title")) == "Auto-generated by YouTube" ||
- channel_about_meta.dig?("links", 0, "channelExternalLinkViewModel", "title", "content").try &.as_s == "Auto-generated by YouTube"
+ # Try to find the about metadata section
+ channel_about_meta = about_tab.dig?(
+ "content",
+ "sectionListRenderer", "contents", 0,
+ "itemSectionRenderer", "contents", 0,
+ "channelAboutFullMetadataRenderer"
)
+
+ if !channel_about_meta.nil?
+ total_views = channel_about_meta.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D/, "").to_i64? || 0_i64
+
+ # The joined text is split to several sub strings. The reduce joins those strings before parsing the date.
+ joined = extract_text(channel_about_meta["joinedDateText"]?)
+ .try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0)
+
+ # Normal Auto-generated channels
+ # https://support.google.com/youtube/answer/2579942
+ # For auto-generated channels, channel_about_meta only has
+ # ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"]
+ auto_generated = (
+ (channel_about_meta["primaryLinks"]?.try &.size) == 1 && \
+ extract_text(channel_about_meta.dig?("primaryLinks", 0, "title")) == "Auto-generated by YouTube" ||
+ channel_about_meta.dig?("links", 0, "channelExternalLinkViewModel", "title", "content").try &.as_s == "Auto-generated by YouTube"
+ )
+ end
end
end
- end
- allowed_regions = initdata
- .dig?("microformat", "microformatDataRenderer", "availableCountries")
- .try &.as_a.map(&.as_s) || [] of String
+ allowed_regions = initdata
+ .dig?("microformat", "microformatDataRenderer", "availableCountries")
+ .try &.as_a.map(&.as_s) || [] of String
- description = !description_node.nil? ? description_node.as_s : ""
- description_html = HTML.escape(description)
+ description = !description_node.nil? ? description_node.as_s : ""
+ description_html = HTML.escape(description)
- if !description_node.nil?
- if description_node.as_h?.nil?
- description_node = text_to_parsed_content(description_node.as_s)
- end
- description_html = parse_content(description_node)
- if description_html == "" && description != ""
- description_html = HTML.escape(description)
- end
- end
-
- sub_count = 0
- pronouns = nil
-
- if (metadata_rows = initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "metadata", "contentMetadataViewModel", "metadataRows").try &.as_a)
- metadata_rows.each do |row|
- subscribe_metadata_part = row.dig?("metadataParts").try &.as_a.find { |i| i.dig?("text", "content").try &.as_s.includes?("subscribers") }
- if !subscribe_metadata_part.nil?
- sub_count = short_text_to_number(subscribe_metadata_part.dig("text", "content").as_s.split(" ")[0]).to_i32
+ if !description_node.nil?
+ if description_node.as_h?.nil?
+ description_node = text_to_parsed_content(description_node.as_s)
end
-
- pronoun_metadata_part = row.dig?("metadataParts").try &.as_a.find { |i| i.dig?("tooltip").try &.as_s.includes?("Pronouns") }
- if !pronoun_metadata_part.nil?
- pronouns = pronoun_metadata_part.dig("text", "content").as_s
+ description_html = parse_content(description_node)
+ if description_html == "" && description != ""
+ description_html = HTML.escape(description)
end
-
- break if sub_count != 0 && !pronouns.nil?
end
+
+ sub_count = 0
+ pronouns = nil
+
+ if (metadata_rows = initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "metadata", "contentMetadataViewModel", "metadataRows").try &.as_a)
+ metadata_rows.each do |row|
+ subscribe_metadata_part = row.dig?("metadataParts").try &.as_a.find { |i| i.dig?("text", "content").try &.as_s.includes?("subscribers") }
+ if !subscribe_metadata_part.nil?
+ sub_count = short_text_to_number(subscribe_metadata_part.dig("text", "content").as_s.split(" ")[0]).to_i32
+ end
+
+ pronoun_metadata_part = row.dig?("metadataParts").try &.as_a.find { |i| i.dig?("tooltip").try &.as_s.includes?("Pronouns") }
+ if !pronoun_metadata_part.nil?
+ pronouns = pronoun_metadata_part.dig("text", "content").as_s
+ end
+
+ break if sub_count != 0 && !pronouns.nil?
+ end
+ end
+
+ AboutChannel.new(
+ ucid: ucid,
+ author: author,
+ auto_generated: auto_generated,
+ author_url: author_url,
+ author_thumbnail: author_thumbnail,
+ banner: banner,
+ description: description,
+ description_html: description_html,
+ total_views: total_views,
+ sub_count: sub_count,
+ joined: joined,
+ is_family_friendly: is_family_friendly,
+ pronouns: pronouns,
+ allowed_regions: allowed_regions,
+ tabs: tab_names,
+ tags: tags,
+ verified: author_verified || false,
+ is_age_gated: is_age_gated || false,
+ )
end
- AboutChannel.new(
- ucid: ucid,
- author: author,
- auto_generated: auto_generated,
- author_url: author_url,
- author_thumbnail: author_thumbnail,
- banner: banner,
- description: description,
- description_html: description_html,
- total_views: total_views,
- sub_count: sub_count,
- joined: joined,
- is_family_friendly: is_family_friendly,
- pronouns: pronouns,
- allowed_regions: allowed_regions,
- tabs: tab_names,
- tags: tags,
- verified: author_verified || false,
- is_age_gated: is_age_gated || false,
- )
-end
-
-def fetch_related_channels(about_channel : AboutChannel, continuation : String? = nil) : {Array(SearchChannel), String?}
- if continuation.nil?
- # params is {"2:string":"channels"} encoded
- initial_data = YoutubeAPI.browse(browse_id: about_channel.ucid, params: "EghjaGFubmVscw%3D%3D")
- else
- initial_data = YoutubeAPI.browse(continuation)
- end
-
- items, continuation = extract_items(initial_data)
-
- return items.select(SearchChannel), continuation
+ def fetch_related_channels(about_channel : AboutChannel, continuation : String? = nil) : {Array(SearchChannel), String?}
+ if continuation.nil?
+ # params is {"2:string":"channels"} encoded
+ initial_data = YoutubeAPI.browse(browse_id: about_channel.ucid, params: "EghjaGFubmVscw%3D%3D")
+ else
+ initial_data = YoutubeAPI.browse(continuation)
+ end
+
+ items, continuation = extract_items(initial_data)
+
+ return items.select(SearchChannel), continuation
+ end
end
diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr
index 64f0484df..fa257bdd7 100644
--- a/src/invidious/channels/channels.cr
+++ b/src/invidious/channels/channels.cr
@@ -123,7 +123,7 @@ def get_batch_channels(channels)
active_threads += 1
spawn do
begin
- get_channel(ucid)
+ Invidious::Channels::Channels.get_channel(ucid)
finished_channel.send(ucid)
rescue ex
finished_channel.send(nil)
@@ -144,153 +144,157 @@ def get_batch_channels(channels)
return final
end
-def get_channel(id) : InvidiousChannel
- channel = Invidious::Database::Channels.select(id)
+module Invidious::Channels::Channels
+ extend self
- if channel.nil? || (Time.utc - channel.updated) > 2.days
- channel = fetch_channel(id, pull_all_videos: false)
- Invidious::Database::Channels.insert(channel, update_on_conflict: true)
+ def get_channel(id) : InvidiousChannel
+ channel = Invidious::Database::Channels.select(id)
+
+ if channel.nil? || (Time.utc - channel.updated) > 2.days
+ channel = self.fetch_channel(id, pull_all_videos: false)
+ Invidious::Database::Channels.insert(channel, update_on_conflict: true)
+ end
+
+ return channel
end
- return channel
-end
+ def fetch_channel(ucid, pull_all_videos : Bool)
+ LOGGER.debug("fetch_channel: #{ucid}")
+ LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}")
-def fetch_channel(ucid, pull_all_videos : Bool)
- LOGGER.debug("fetch_channel: #{ucid}")
- LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}")
+ namespaces = {
+ "yt" => "http://www.youtube.com/xml/schemas/2015",
+ "media" => "http://search.yahoo.com/mrss/",
+ "default" => "http://www.w3.org/2005/Atom",
+ }
- namespaces = {
- "yt" => "http://www.youtube.com/xml/schemas/2015",
- "media" => "http://search.yahoo.com/mrss/",
- "default" => "http://www.w3.org/2005/Atom",
- }
+ LOGGER.trace("fetch_channel: #{ucid} : Downloading RSS feed")
+ rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}").body
+ LOGGER.trace("fetch_channel: #{ucid} : Parsing RSS feed")
+ rss = XML.parse(rss)
- LOGGER.trace("fetch_channel: #{ucid} : Downloading RSS feed")
- rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}").body
- LOGGER.trace("fetch_channel: #{ucid} : Parsing RSS feed")
- rss = XML.parse(rss)
+ author = rss.xpath_node("//default:feed/default:title", namespaces)
+ if !author
+ raise InfoException.new("Deleted or invalid channel")
+ end
- author = rss.xpath_node("//default:feed/default:title", namespaces)
- if !author
- raise InfoException.new("Deleted or invalid channel")
- end
+ author = author.content
- author = author.content
+ # Auto-generated channels
+ # https://support.google.com/youtube/answer/2579942
+ if author.ends_with?(" - Topic") ||
+ {"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? author
+ auto_generated = true
+ end
- # Auto-generated channels
- # https://support.google.com/youtube/answer/2579942
- if author.ends_with?(" - Topic") ||
- {"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? author
- auto_generated = true
- end
+ LOGGER.trace("fetch_channel: #{ucid} : author = #{author}, auto_generated = #{auto_generated}")
- LOGGER.trace("fetch_channel: #{ucid} : author = #{author}, auto_generated = #{auto_generated}")
-
- channel = InvidiousChannel.new({
- id: ucid,
- author: author,
- updated: Time.utc,
- deleted: false,
- subscribed: nil,
- })
-
- LOGGER.trace("fetch_channel: #{ucid} : Downloading channel videos page")
- videos, continuation = IV::Channel::Tabs.get_videos(channel)
-
- LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed")
- rss.xpath_nodes("//default:feed/default:entry", namespaces).each do |entry|
- video_id = entry.xpath_node("yt:videoId", namespaces).not_nil!.content
- title = entry.xpath_node("default:title", namespaces).not_nil!.content
-
- published = Time.parse_rfc3339(
- entry.xpath_node("default:published", namespaces).not_nil!.content
- )
- updated = Time.parse_rfc3339(
- entry.xpath_node("default:updated", namespaces).not_nil!.content
- )
-
- author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content
- ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content
-
- views = entry
- .xpath_node("media:group/media:community/media:statistics", namespaces)
- .try &.["views"]?.try &.to_i64? || 0_i64
-
- channel_video = videos
- .select(SearchVideo)
- .select(&.id.== video_id)[0]?
-
- length_seconds = channel_video.try &.length_seconds
- length_seconds ||= 0
-
- live_now = channel_video.try &.badges.live_now?
- live_now ||= false
-
- premiere_timestamp = channel_video.try &.premiere_timestamp
-
- video = ChannelVideo.new({
- id: video_id,
- title: title,
- published: published,
- updated: updated,
- ucid: ucid,
- author: author,
- length_seconds: length_seconds,
- live_now: live_now,
- premiere_timestamp: premiere_timestamp,
- views: views,
+ channel = InvidiousChannel.new({
+ id: ucid,
+ author: author,
+ updated: Time.utc,
+ deleted: false,
+ subscribed: nil,
})
- LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updating or inserting video")
+ LOGGER.trace("fetch_channel: #{ucid} : Downloading channel videos page")
+ videos, continuation = IV::Channel::Tabs.get_videos(channel)
- # We don't include the 'premiere_timestamp' here because channel pages don't include them,
- # meaning the above timestamp is always null
- was_insert = Invidious::Database::ChannelVideos.insert(video)
+ LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed")
+ rss.xpath_nodes("//default:feed/default:entry", namespaces).each do |entry|
+ video_id = entry.xpath_node("yt:videoId", namespaces).not_nil!.content
+ title = entry.xpath_node("default:title", namespaces).not_nil!.content
- if was_insert
- LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions")
- NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video))
- else
- LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated")
+ published = Time.parse_rfc3339(
+ entry.xpath_node("default:published", namespaces).not_nil!.content
+ )
+ updated = Time.parse_rfc3339(
+ entry.xpath_node("default:updated", namespaces).not_nil!.content
+ )
+
+ author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content
+ ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content
+
+ views = entry
+ .xpath_node("media:group/media:community/media:statistics", namespaces)
+ .try &.["views"]?.try &.to_i64? || 0_i64
+
+ channel_video = videos
+ .select(SearchVideo)
+ .select(&.id.== video_id)[0]?
+
+ length_seconds = channel_video.try &.length_seconds
+ length_seconds ||= 0
+
+ live_now = channel_video.try &.badges.live_now?
+ live_now ||= false
+
+ premiere_timestamp = channel_video.try &.premiere_timestamp
+
+ video = ChannelVideo.new({
+ id: video_id,
+ title: title,
+ published: published,
+ updated: updated,
+ ucid: ucid,
+ author: author,
+ length_seconds: length_seconds,
+ live_now: live_now,
+ premiere_timestamp: premiere_timestamp,
+ views: views,
+ })
+
+ LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updating or inserting video")
+
+ # We don't include the 'premiere_timestamp' here because channel pages don't include them,
+ # meaning the above timestamp is always null
+ was_insert = Invidious::Database::ChannelVideos.insert(video)
+
+ if was_insert
+ LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions")
+ NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video))
+ else
+ LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated")
+ end
end
- end
- if pull_all_videos
- loop do
- # Keep fetching videos using the continuation token retrieved earlier
- videos, continuation = IV::Channel::Tabs.get_videos(channel, continuation: continuation)
+ if pull_all_videos
+ loop do
+ # Keep fetching videos using the continuation token retrieved earlier
+ videos, continuation = IV::Channel::Tabs.get_videos(channel, continuation: continuation)
- count = 0
- videos.select(SearchVideo).each do |video|
- count += 1
- video = ChannelVideo.new({
- id: video.id,
- title: video.title,
- published: video.published,
- updated: Time.utc,
- ucid: video.ucid,
- author: video.author,
- length_seconds: video.length_seconds,
- live_now: video.badges.live_now?,
- premiere_timestamp: video.premiere_timestamp,
- views: video.views,
- })
+ count = 0
+ videos.select(SearchVideo).each do |video|
+ count += 1
+ video = ChannelVideo.new({
+ id: video.id,
+ title: video.title,
+ published: video.published,
+ updated: Time.utc,
+ ucid: video.ucid,
+ author: video.author,
+ length_seconds: video.length_seconds,
+ live_now: video.badges.live_now?,
+ premiere_timestamp: video.premiere_timestamp,
+ views: video.views,
+ })
- # We are notified of Red videos elsewhere (PubSub), which includes a correct published date,
- # so since they don't provide a published date here we can safely ignore them.
- if Time.utc - video.published > 1.minute
- was_insert = Invidious::Database::ChannelVideos.insert(video)
- if was_insert
- NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video))
+ # We are notified of Red videos elsewhere (PubSub), which includes a correct published date,
+ # so since they don't provide a published date here we can safely ignore them.
+ if Time.utc - video.published > 1.minute
+ was_insert = Invidious::Database::ChannelVideos.insert(video)
+ if was_insert
+ NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video))
+ end
end
end
+
+ break if count < 25
+ sleep 500.milliseconds
end
-
- break if count < 25
- sleep 500.milliseconds
end
- end
- channel.updated = Time.utc
- return channel
+ channel.updated = Time.utc
+ return channel
+ end
end
diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr
index 34a214265..947b8b5b5 100644
--- a/src/invidious/channels/community.cr
+++ b/src/invidious/channels/community.cr
@@ -1,198 +1,201 @@
-private IMAGE_QUALITIES = {320, 560, 640, 1280, 2000}
+module Invidious::Channels::Community
+ extend self
+ private IMAGE_QUALITIES = {320, 560, 640, 1280, 2000}
-# TODO: Add "sort_by"
-def fetch_channel_community(ucid, cursor, locale, format, thin_mode)
- if cursor.nil?
- # EgVwb3N0c_IGBAoCSgA%3D is the protobuf object to load "posts"
- initial_data = YoutubeAPI.browse(ucid, params: "EgVwb3N0c_IGBAoCSgA%3D")
+ # TODO: Add "sort_by"
+ def fetch_channel_community(ucid, cursor, locale, format, thin_mode)
+ if cursor.nil?
+ # EgVwb3N0c_IGBAoCSgA%3D is the protobuf object to load "posts"
+ initial_data = YoutubeAPI.browse(ucid, params: "EgVwb3N0c_IGBAoCSgA%3D")
+
+ items = [] of JSON::Any
+ extract_items(initial_data) do |item|
+ items << item
+ end
+ else
+ continuation = self.produce_channel_community_continuation(ucid, cursor)
+ initial_data = YoutubeAPI.browse(continuation: continuation)
+
+ container = initial_data.dig?("continuationContents", "itemSectionContinuation", "contents")
+
+ raise InfoException.new("Can't extract community data") if container.nil?
+
+ items = container.as_a
+ end
+
+ return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode)
+ end
+
+ def decode_ucid_from_post_protobuf(params)
+ decoded_protobuf = params.try { |i| URI.decode_www_form(i) }
+ .try { |i| Base64.decode(i) }
+ .try { |i| IO::Memory.new(i) }
+ .try { |i| Protodec::Any.parse(i) }
+
+ return decoded_protobuf.try(&.["56:0:embedded"]["2:0:string"].as_s)
+ end
+
+ def fetch_channel_community_post(ucid, post_id, locale, format, thin_mode)
+ object = {
+ "56:embedded" => {
+ "2:string" => ucid,
+ "3:string" => post_id.to_s,
+ "11:string" => ucid,
+ },
+ }
+ params = object.try { |i| Protodec::Any.cast_json(i) }
+ .try { |i| Protodec::Any.from_json(i) }
+ .try { |i| Base64.urlsafe_encode(i) }
+ .try { |i| URI.encode_www_form(i) }
+
+ initial_data = YoutubeAPI.browse("FEpost_detail", params: params)
items = [] of JSON::Any
extract_items(initial_data) do |item|
items << item
end
- else
- continuation = produce_channel_community_continuation(ucid, cursor)
- initial_data = YoutubeAPI.browse(continuation: continuation)
- container = initial_data.dig?("continuationContents", "itemSectionContinuation", "contents")
-
- raise InfoException.new("Can't extract community data") if container.nil?
-
- items = container.as_a
+ return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode, is_single_post: true)
end
- return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode)
-end
-
-def decode_ucid_from_post_protobuf(params)
- decoded_protobuf = params.try { |i| URI.decode_www_form(i) }
- .try { |i| Base64.decode(i) }
- .try { |i| IO::Memory.new(i) }
- .try { |i| Protodec::Any.parse(i) }
-
- return decoded_protobuf.try(&.["56:0:embedded"]["2:0:string"].as_s)
-end
-
-def fetch_channel_community_post(ucid, post_id, locale, format, thin_mode)
- object = {
- "56:embedded" => {
- "2:string" => ucid,
- "3:string" => post_id.to_s,
- "11:string" => ucid,
- },
- }
- params = object.try { |i| Protodec::Any.cast_json(i) }
- .try { |i| Protodec::Any.from_json(i) }
- .try { |i| Base64.urlsafe_encode(i) }
- .try { |i| URI.encode_www_form(i) }
-
- initial_data = YoutubeAPI.browse("FEpost_detail", params: params)
-
- items = [] of JSON::Any
- extract_items(initial_data) do |item|
- items << item
- end
-
- return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode, is_single_post: true)
-end
-
-def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_single_post : Bool = false)
- if message = items[0]["messageRenderer"]?
- error_message = (message["text"]["simpleText"]? ||
- message["text"]["runs"]?.try &.[0]?.try &.["text"]?)
- .try &.as_s || ""
- if error_message == "This channel does not exist."
- raise NotFoundException.new(error_message)
- else
- raise InfoException.new(error_message)
- end
- end
-
- response = JSON.build do |json|
- json.object do
- json.field "authorId", ucid
- if is_single_post
- json.field "singlePost", true
+ def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_single_post : Bool = false)
+ if message = items[0]["messageRenderer"]?
+ error_message = (message["text"]["simpleText"]? ||
+ message["text"]["runs"]?.try &.[0]?.try &.["text"]?)
+ .try &.as_s || ""
+ if error_message == "This channel does not exist."
+ raise NotFoundException.new(error_message)
+ else
+ raise InfoException.new(error_message)
end
- json.field "comments" do
- json.array do
- items.each do |post|
- comments = post["backstagePostThreadRenderer"]?.try &.["comments"]? ||
- post["backstageCommentsContinuation"]?
+ end
- post = post["backstagePostThreadRenderer"]?.try &.["post"]["backstagePostRenderer"]? ||
- post["commentThreadRenderer"]?.try &.["comment"]["commentRenderer"]?
+ response = JSON.build do |json|
+ json.object do
+ json.field "authorId", ucid
+ if is_single_post
+ json.field "singlePost", true
+ end
+ json.field "comments" do
+ json.array do
+ items.each do |post|
+ comments = post["backstagePostThreadRenderer"]?.try &.["comments"]? ||
+ post["backstageCommentsContinuation"]?
- next if !post
+ post = post["backstagePostThreadRenderer"]?.try &.["post"]["backstagePostRenderer"]? ||
+ post["commentThreadRenderer"]?.try &.["comment"]["commentRenderer"]?
- content_html = post["contentText"]?.try { |t| parse_content(t) } || ""
- author = post["authorText"]["runs"]?.try &.[0]?.try &.["text"]? || ""
+ next if !post
- json.object do
- json.field "author", author
- json.field "authorThumbnails" do
- json.array do
- qualities = {32, 48, 76, 100, 176, 512}
- author_thumbnail = post["authorThumbnail"]["thumbnails"].as_a[0]["url"].as_s
+ content_html = post["contentText"]?.try { |t| parse_content(t) } || ""
+ author = post["authorText"]["runs"]?.try &.[0]?.try &.["text"]? || ""
- qualities.each do |quality|
- json.object do
- json.field "url", author_thumbnail.gsub(/s\d+-/, "s#{quality}-")
- json.field "width", quality
- json.field "height", quality
+ json.object do
+ json.field "author", author
+ json.field "authorThumbnails" do
+ json.array do
+ qualities = {32, 48, 76, 100, 176, 512}
+ author_thumbnail = post["authorThumbnail"]["thumbnails"].as_a[0]["url"].as_s
+
+ qualities.each do |quality|
+ json.object do
+ json.field "url", author_thumbnail.gsub(/s\d+-/, "s#{quality}-")
+ json.field "width", quality
+ json.field "height", quality
+ end
end
end
end
- end
- if post["authorEndpoint"]?
- json.field "authorId", post["authorEndpoint"]["browseEndpoint"]["browseId"]
- json.field "authorUrl", post["authorEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"].as_s
- else
- json.field "authorId", ""
- json.field "authorUrl", ""
- end
+ if post["authorEndpoint"]?
+ json.field "authorId", post["authorEndpoint"]["browseEndpoint"]["browseId"]
+ json.field "authorUrl", post["authorEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"].as_s
+ else
+ json.field "authorId", ""
+ json.field "authorUrl", ""
+ end
- published_text = post["publishedTimeText"]["runs"][0]["text"].as_s
- published = decode_date(published_text.rchop(" (edited)"))
+ published_text = post["publishedTimeText"]["runs"][0]["text"].as_s
+ published = decode_date(published_text.rchop(" (edited)"))
- if published_text.includes?(" (edited)")
- json.field "isEdited", true
- else
- json.field "isEdited", false
- end
+ if published_text.includes?(" (edited)")
+ json.field "isEdited", true
+ else
+ json.field "isEdited", false
+ end
- like_count = post["actionButtons"]["commentActionButtonsRenderer"]["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"]
- .try &.as_s.gsub(/\D/, "").to_i? || 0
+ like_count = post["actionButtons"]["commentActionButtonsRenderer"]["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"]
+ .try &.as_s.gsub(/\D/, "").to_i? || 0
- reply_count = short_text_to_number(post.dig?("actionButtons", "commentActionButtonsRenderer", "replyButton", "buttonRenderer", "text", "simpleText").try &.as_s || "0")
+ reply_count = short_text_to_number(post.dig?("actionButtons", "commentActionButtonsRenderer", "replyButton", "buttonRenderer", "text", "simpleText").try &.as_s || "0")
- json.field "content", Helpers.html_to_content(content_html)
- json.field "contentHtml", content_html
+ json.field "content", Invidious::Helpers.html_to_content(content_html)
+ json.field "contentHtml", content_html
- json.field "published", published.to_unix
- json.field "publishedText", I18n.translate(locale, "`x` ago", recode_date(published, locale))
+ json.field "published", published.to_unix
+ json.field "publishedText", I18n.translate(locale, "`x` ago", recode_date(published, locale))
- json.field "likeCount", like_count
- json.field "replyCount", reply_count
- json.field "commentId", post["postId"]? || post["commentId"]? || ""
- json.field "authorIsChannelOwner", post["authorEndpoint"]["browseEndpoint"]["browseId"] == ucid
+ json.field "likeCount", like_count
+ json.field "replyCount", reply_count
+ json.field "commentId", post["postId"]? || post["commentId"]? || ""
+ json.field "authorIsChannelOwner", post["authorEndpoint"]["browseEndpoint"]["browseId"] == ucid
- if attachment = post["backstageAttachment"]?
- json.field "attachment" do
- case attachment.as_h
- when .has_key?("videoRenderer")
- parse_item(attachment)
- .as(SearchVideo | ProblematicTimelineItem)
- .to_json(locale, json)
- when .has_key?("backstageImageRenderer")
- json.object do
- attachment = attachment["backstageImageRenderer"]
- json.field "type", "image"
+ if attachment = post["backstageAttachment"]?
+ json.field "attachment" do
+ case attachment.as_h
+ when .has_key?("videoRenderer")
+ parse_item(attachment)
+ .as(SearchVideo | ProblematicTimelineItem)
+ .to_json(locale, json)
+ when .has_key?("backstageImageRenderer")
+ json.object do
+ attachment = attachment["backstageImageRenderer"]
+ json.field "type", "image"
- json.field "imageThumbnails" do
- json.array do
- thumbnail = attachment["image"]["thumbnails"][0].as_h
- width = thumbnail["width"].as_i
- height = thumbnail["height"].as_i
- aspect_ratio = (width.to_f / height.to_f)
- url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640")
+ json.field "imageThumbnails" do
+ json.array do
+ thumbnail = attachment["image"]["thumbnails"][0].as_h
+ width = thumbnail["width"].as_i
+ height = thumbnail["height"].as_i
+ aspect_ratio = (width.to_f / height.to_f)
+ url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640")
- IMAGE_QUALITIES.each do |quality|
- json.object do
- json.field "url", url.gsub(/=s\d+/, "=s#{quality}")
- json.field "width", quality
- json.field "height", (quality / aspect_ratio).ceil.to_i
+ IMAGE_QUALITIES.each do |quality|
+ json.object do
+ json.field "url", url.gsub(/=s\d+/, "=s#{quality}")
+ json.field "width", quality
+ json.field "height", (quality / aspect_ratio).ceil.to_i
+ end
end
end
end
end
- end
- when .has_key?("pollRenderer")
- json.object do
- attachment = attachment["pollRenderer"]
- json.field "type", "poll"
- json.field "totalVotes", short_text_to_number(attachment["totalVotes"]["simpleText"].as_s.split(" ")[0])
- json.field "choices" do
- json.array do
- attachment["choices"].as_a.each do |choice|
- json.object do
- json.field "text", choice.dig("text", "runs", 0, "text").as_s
- # A choice can have an image associated with it.
- # Ex post: https://www.youtube.com/post/UgkxD4XavXUD4NQiddJXXdohbwOwcVqrH9Re
- if choice["image"]?
- thumbnail = choice["image"]["thumbnails"][0].as_h
- width = thumbnail["width"].as_i
- height = thumbnail["height"].as_i
- aspect_ratio = (width.to_f / height.to_f)
- url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640")
- json.field "image" do
- json.array do
- IMAGE_QUALITIES.each do |quality|
- json.object do
- json.field "url", url.gsub(/=s\d+/, "=s#{quality}")
- json.field "width", quality
- json.field "height", (quality / aspect_ratio).ceil.to_i
+ when .has_key?("pollRenderer")
+ json.object do
+ attachment = attachment["pollRenderer"]
+ json.field "type", "poll"
+ json.field "totalVotes", short_text_to_number(attachment["totalVotes"]["simpleText"].as_s.split(" ")[0])
+ json.field "choices" do
+ json.array do
+ attachment["choices"].as_a.each do |choice|
+ json.object do
+ json.field "text", choice.dig("text", "runs", 0, "text").as_s
+ # A choice can have an image associated with it.
+ # Ex post: https://www.youtube.com/post/UgkxD4XavXUD4NQiddJXXdohbwOwcVqrH9Re
+ if choice["image"]?
+ thumbnail = choice["image"]["thumbnails"][0].as_h
+ width = thumbnail["width"].as_i
+ height = thumbnail["height"].as_i
+ aspect_ratio = (width.to_f / height.to_f)
+ url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640")
+ json.field "image" do
+ json.array do
+ IMAGE_QUALITIES.each do |quality|
+ json.object do
+ json.field "url", url.gsub(/=s\d+/, "=s#{quality}")
+ json.field "width", quality
+ json.field "height", (quality / aspect_ratio).ceil.to_i
+ end
end
end
end
@@ -202,137 +205,137 @@ def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_sing
end
end
end
- end
- when .has_key?("postMultiImageRenderer")
- json.object do
- attachment = attachment["postMultiImageRenderer"]
- json.field "type", "multiImage"
- json.field "images" do
- json.array do
- attachment["images"].as_a.each do |image|
- json.array do
- thumbnail = image["backstageImageRenderer"]["image"]["thumbnails"][0].as_h
- width = thumbnail["width"].as_i
- height = thumbnail["height"].as_i
- aspect_ratio = (width.to_f / height.to_f)
- url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640")
+ when .has_key?("postMultiImageRenderer")
+ json.object do
+ attachment = attachment["postMultiImageRenderer"]
+ json.field "type", "multiImage"
+ json.field "images" do
+ json.array do
+ attachment["images"].as_a.each do |image|
+ json.array do
+ thumbnail = image["backstageImageRenderer"]["image"]["thumbnails"][0].as_h
+ width = thumbnail["width"].as_i
+ height = thumbnail["height"].as_i
+ aspect_ratio = (width.to_f / height.to_f)
+ url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640")
- IMAGE_QUALITIES.each do |quality|
- json.object do
- json.field "url", url.gsub(/=s\d+/, "=s#{quality}")
- json.field "width", quality
- json.field "height", (quality / aspect_ratio).ceil.to_i
+ IMAGE_QUALITIES.each do |quality|
+ json.object do
+ json.field "url", url.gsub(/=s\d+/, "=s#{quality}")
+ json.field "width", quality
+ json.field "height", (quality / aspect_ratio).ceil.to_i
+ end
end
end
end
end
end
end
- end
- when .has_key?("playlistRenderer")
- parse_item(attachment)
- .as(SearchPlaylist)
- .to_json(locale, json)
- when .has_key?("quizRenderer")
- json.object do
- attachment = attachment["quizRenderer"]
- json.field "type", "quiz"
- json.field "totalVotes", short_text_to_number(attachment["totalVotes"]["simpleText"].as_s.split(" ")[0])
- json.field "choices" do
- json.array do
- attachment["choices"].as_a.each do |choice|
- json.object do
- json.field "text", choice.dig("text", "runs", 0, "text").as_s
- json.field "isCorrect", choice["isCorrect"].as_bool
+ when .has_key?("playlistRenderer")
+ parse_item(attachment)
+ .as(SearchPlaylist)
+ .to_json(locale, json)
+ when .has_key?("quizRenderer")
+ json.object do
+ attachment = attachment["quizRenderer"]
+ json.field "type", "quiz"
+ json.field "totalVotes", short_text_to_number(attachment["totalVotes"]["simpleText"].as_s.split(" ")[0])
+ json.field "choices" do
+ json.array do
+ attachment["choices"].as_a.each do |choice|
+ json.object do
+ json.field "text", choice.dig("text", "runs", 0, "text").as_s
+ json.field "isCorrect", choice["isCorrect"].as_bool
+ end
end
end
end
end
- end
- else
- json.object do
- json.field "type", "unknown"
- json.field "error", "Unrecognized attachment type."
+ else
+ json.object do
+ json.field "type", "unknown"
+ json.field "error", "Unrecognized attachment type."
+ end
end
end
end
- end
- if comments && (reply_count = (comments["backstageCommentsRenderer"]["moreText"]["simpleText"]? ||
- comments["backstageCommentsRenderer"]["moreText"]["runs"]?.try &.[0]?.try &.["text"]?)
- .try &.as_s.gsub(/\D/, "").to_i?)
- continuation = comments["backstageCommentsRenderer"]["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s
- continuation ||= ""
+ if comments && (reply_count = (comments["backstageCommentsRenderer"]["moreText"]["simpleText"]? ||
+ comments["backstageCommentsRenderer"]["moreText"]["runs"]?.try &.[0]?.try &.["text"]?)
+ .try &.as_s.gsub(/\D/, "").to_i?)
+ continuation = comments["backstageCommentsRenderer"]["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s
+ continuation ||= ""
- json.field "replies" do
- json.object do
- json.field "replyCount", reply_count
- json.field "continuation", extract_channel_community_cursor(continuation)
+ json.field "replies" do
+ json.object do
+ json.field "replyCount", reply_count
+ json.field "continuation", extract_channel_community_cursor(continuation)
+ end
end
end
end
end
end
end
- end
- if !is_single_post
- if cont = items.dig?(-1, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token")
- json.field "continuation", extract_channel_community_cursor(cont.as_s)
+ if !is_single_post
+ if cont = items.dig?(-1, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token")
+ json.field "continuation", extract_channel_community_cursor(cont.as_s)
+ end
end
end
end
- end
- if format == "html"
- response = JSON.parse(response)
- content_html = IV::Frontend::Comments.template_youtube(response, locale, thin_mode)
+ if format == "html"
+ response = JSON.parse(response)
+ content_html = IV::Frontend::Comments.template_youtube(response, locale, thin_mode)
- response = JSON.build do |json|
- json.object do
- json.field "contentHtml", content_html
+ response = JSON.build do |json|
+ json.object do
+ json.field "contentHtml", content_html
+ end
end
end
+
+ return response
end
- return response
-end
+ def produce_channel_community_continuation(ucid, cursor)
+ object = {
+ "80226972:embedded" => {
+ "2:string" => ucid,
+ "3:string" => cursor || "",
+ },
+ }
-def produce_channel_community_continuation(ucid, cursor)
- object = {
- "80226972:embedded" => {
- "2:string" => ucid,
- "3:string" => cursor || "",
- },
- }
-
- continuation = object.try { |i| Protodec::Any.cast_json(i) }
- .try { |i| Protodec::Any.from_json(i) }
- .try { |i| Base64.urlsafe_encode(i) }
- .try { |i| URI.encode_www_form(i) }
-
- return continuation
-end
-
-def extract_channel_community_cursor(continuation)
- object = URI.decode_www_form(continuation)
- .try { |i| Base64.decode(i) }
- .try { |i| IO::Memory.new(i) }
- .try { |i| Protodec::Any.parse(i) }
- .try(&.["80226972:0:embedded"]["3:1:base64"].as_h)
-
- if object["53:2:embedded"]?.try &.["3:0:embedded"]?
- object["53:2:embedded"]["3:0:embedded"]["2:0:string"] = object["53:2:embedded"]["3:0:embedded"]
- .try(&.["2:0:base64"].as_h)
- .try { |i| Protodec::Any.cast_json(i) }
+ continuation = object.try { |i| Protodec::Any.cast_json(i) }
.try { |i| Protodec::Any.from_json(i) }
- .try { |i| Base64.urlsafe_encode(i, padding: false) }
+ .try { |i| Base64.urlsafe_encode(i) }
+ .try { |i| URI.encode_www_form(i) }
- object["53:2:embedded"]["3:0:embedded"].as_h.delete("2:0:base64")
+ return continuation
end
- cursor = Protodec::Any.cast_json(object)
- .try { |i| Protodec::Any.from_json(i) }
- .try { |i| Base64.urlsafe_encode(i) }
+ def extract_channel_community_cursor(continuation)
+ object = URI.decode_www_form(continuation)
+ .try { |i| Base64.decode(i) }
+ .try { |i| IO::Memory.new(i) }
+ .try { |i| Protodec::Any.parse(i) }
+ .try(&.["80226972:0:embedded"]["3:1:base64"].as_h)
- cursor
+ if object["53:2:embedded"]?.try &.["3:0:embedded"]?
+ object["53:2:embedded"]["3:0:embedded"]["2:0:string"] = object["53:2:embedded"]["3:0:embedded"]
+ .try(&.["2:0:base64"].as_h)
+ .try { |i| Protodec::Any.cast_json(i) }
+ .try { |i| Protodec::Any.from_json(i) }
+ .try { |i| Base64.urlsafe_encode(i, padding: false) }
+
+ object["53:2:embedded"]["3:0:embedded"].as_h.delete("2:0:base64")
+ end
+
+ cursor = Protodec::Any.cast_json(object)
+ .try { |i| Protodec::Any.from_json(i) }
+ .try { |i| Base64.urlsafe_encode(i) }
+
+ cursor
+ end
end
diff --git a/src/invidious/channels/playlists.cr b/src/invidious/channels/playlists.cr
index cba1abd9c..d7491873a 100644
--- a/src/invidious/channels/playlists.cr
+++ b/src/invidious/channels/playlists.cr
@@ -1,55 +1,59 @@
-def fetch_channel_playlists(ucid, author, continuation, sort_by)
- if continuation
- initial_data = YoutubeAPI.browse(continuation)
- else
- params =
- case sort_by
- when "last", "last_added"
- # Equivalent to "&sort=lad"
- # {"2:string": "playlists", "3:varint": 4, "4:varint": 1, "6:varint": 1, "110:embedded": {"1:embedded": {"8:string": ""}}}
- "EglwbGF5bGlzdHMYBCABMAHyBgQKAkIA"
- when "oldest", "oldest_created"
- # formerly "&sort=da"
- # Not available anymore :c or maybe ??
- # {"2:string": "playlists", "3:varint": 2, "4:varint": 1, "6:varint": 1, "110:embedded": {"1:embedded": {"8:string": ""}}}
- "EglwbGF5bGlzdHMYAiABMAHyBgQKAkIA"
- # {"2:string": "playlists", "3:varint": 1, "4:varint": 1, "6:varint": 1}
- # "EglwbGF5bGlzdHMYASABMAE%3D"
- when "newest", "newest_created"
- # Formerly "&sort=dd"
- # {"2:string": "playlists", "3:varint": 3, "4:varint": 1, "6:varint": 1, "110:embedded": {"1:embedded": {"8:string": ""}}}
- "EglwbGF5bGlzdHMYAyABMAHyBgQKAkIA"
- end
+module Invidious::Channels::Playlists
+ extend self
- initial_data = YoutubeAPI.browse(ucid, params: params || "")
+ def fetch_channel_playlists(ucid, author, continuation, sort_by)
+ if continuation
+ initial_data = YoutubeAPI.browse(continuation)
+ else
+ params =
+ case sort_by
+ when "last", "last_added"
+ # Equivalent to "&sort=lad"
+ # {"2:string": "playlists", "3:varint": 4, "4:varint": 1, "6:varint": 1, "110:embedded": {"1:embedded": {"8:string": ""}}}
+ "EglwbGF5bGlzdHMYBCABMAHyBgQKAkIA"
+ when "oldest", "oldest_created"
+ # formerly "&sort=da"
+ # Not available anymore :c or maybe ??
+ # {"2:string": "playlists", "3:varint": 2, "4:varint": 1, "6:varint": 1, "110:embedded": {"1:embedded": {"8:string": ""}}}
+ "EglwbGF5bGlzdHMYAiABMAHyBgQKAkIA"
+ # {"2:string": "playlists", "3:varint": 1, "4:varint": 1, "6:varint": 1}
+ # "EglwbGF5bGlzdHMYASABMAE%3D"
+ when "newest", "newest_created"
+ # Formerly "&sort=dd"
+ # {"2:string": "playlists", "3:varint": 3, "4:varint": 1, "6:varint": 1, "110:embedded": {"1:embedded": {"8:string": ""}}}
+ "EglwbGF5bGlzdHMYAyABMAHyBgQKAkIA"
+ end
+
+ initial_data = YoutubeAPI.browse(ucid, params: params || "")
+ end
+
+ return extract_items(initial_data, author, ucid)
end
- return extract_items(initial_data, author, ucid)
-end
-
-def fetch_channel_podcasts(ucid, author, continuation)
- if continuation
- initial_data = YoutubeAPI.browse(continuation)
- else
- initial_data = YoutubeAPI.browse(ucid, params: "Eghwb2RjYXN0c_IGBQoDugEA")
+ def fetch_channel_podcasts(ucid, author, continuation)
+ if continuation
+ initial_data = YoutubeAPI.browse(continuation)
+ else
+ initial_data = YoutubeAPI.browse(ucid, params: "Eghwb2RjYXN0c_IGBQoDugEA")
+ end
+ return extract_items(initial_data, author, ucid)
end
- return extract_items(initial_data, author, ucid)
-end
-def fetch_channel_releases(ucid, author, continuation)
- if continuation
- initial_data = YoutubeAPI.browse(continuation)
- else
- initial_data = YoutubeAPI.browse(ucid, params: "EghyZWxlYXNlc_IGBQoDsgEA")
+ def fetch_channel_releases(ucid, author, continuation)
+ if continuation
+ initial_data = YoutubeAPI.browse(continuation)
+ else
+ initial_data = YoutubeAPI.browse(ucid, params: "EghyZWxlYXNlc_IGBQoDsgEA")
+ end
+ return extract_items(initial_data, author, ucid)
end
- return extract_items(initial_data, author, ucid)
-end
-def fetch_channel_courses(ucid, author, continuation)
- if continuation
- initial_data = YoutubeAPI.browse(continuation)
- else
- initial_data = YoutubeAPI.browse(ucid, params: "Egdjb3Vyc2Vz8gYFCgPCAQA%3D")
+ def fetch_channel_courses(ucid, author, continuation)
+ if continuation
+ initial_data = YoutubeAPI.browse(continuation)
+ else
+ initial_data = YoutubeAPI.browse(ucid, params: "Egdjb3Vyc2Vz8gYFCgPCAQA%3D")
+ end
+ return extract_items(initial_data, author, ucid)
end
- return extract_items(initial_data, author, ucid)
end
diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/tabs.cr
similarity index 100%
rename from src/invidious/channels/videos.cr
rename to src/invidious/channels/tabs.cr
diff --git a/src/invidious/comments/youtube.cr b/src/invidious/comments/youtube.cr
index 134a3d712..d2679aee5 100644
--- a/src/invidious/comments/youtube.cr
+++ b/src/invidious/comments/youtube.cr
@@ -246,7 +246,7 @@ module Invidious::Comments
end
content_html = html_content || ""
- json.field "content", Helpers.html_to_content(content_html)
+ json.field "content", Invidious::Helpers.html_to_content(content_html)
json.field "contentHtml", content_html
if published_text != nil
diff --git a/src/invidious/ext/kemal_static_file_handler.cr b/src/invidious/ext/kemal_static_file_handler.cr
new file mode 100644
index 000000000..16cb84fbf
--- /dev/null
+++ b/src/invidious/ext/kemal_static_file_handler.cr
@@ -0,0 +1,216 @@
+{% if compare_versions(Crystal::VERSION, "1.17.0-dev") >= 0 %}
+ # Strip StaticFileHandler from the binary
+ #
+ # This allows us to compile on 1.17.0 as the compiler won't try to
+ # semantically check the outdated upstream code.
+ class Kemal::Config
+ private def setup_static_file_handler
+ end
+ end
+
+ # Nullify `Kemal::StaticFileHandler`
+ #
+ # Needed until the next release of Kemal after 1.7
+ class Kemal::StaticFileHandler < HTTP::StaticFileHandler
+ def call(context : HTTP::Server::Context)
+ end
+ end
+
+ {% skip_file %}
+{% end %}
+
+# Since systems have a limit on number of open files (`ulimit -a`),
+# we serve them from memory to avoid 'Too many open files' without needing
+# to modify ulimit.
+#
+# Very heavily re-used:
+# https://github.com/kemalcr/kemal/blob/master/src/kemal/helpers/helpers.cr
+# https://github.com/kemalcr/kemal/blob/master/src/kemal/static_file_handler.cr
+#
+# Changes:
+# - A `send_file` overload is added which supports sending a Slice, file_path, filestat
+# - `StaticFileHandler` is patched to cache to and serve from @cached_files
+
+private def multipart(file, env : HTTP::Server::Context)
+ # See http://httpwg.org/specs/rfc7233.html
+ fileb = file.size
+ startb = endb = 0
+
+ if match = env.request.headers["Range"].match /bytes=(\d{1,})-(\d{0,})/
+ startb = match[1].to_i { 0 } if match.size >= 2
+ endb = match[2].to_i { 0 } if match.size >= 3
+ end
+
+ endb = fileb - 1 if endb == 0
+
+ if startb < endb < fileb
+ content_length = 1 + endb - startb
+ env.response.status_code = 206
+ env.response.content_length = content_length
+ env.response.headers["Accept-Ranges"] = "bytes"
+ env.response.headers["Content-Range"] = "bytes #{startb}-#{endb}/#{fileb}" # MUST
+
+ if startb > 1024
+ skipped = 0
+ # file.skip only accepts values less or equal to 1024 (buffer size, undocumented)
+ until (increase_skipped = skipped + 1024) > startb
+ file.skip(1024)
+ skipped = increase_skipped
+ end
+ if (skipped_minus_startb = skipped - startb) > 0
+ file.skip skipped_minus_startb
+ end
+ else
+ file.skip(startb)
+ end
+
+ IO.copy(file, env.response, content_length)
+ else
+ env.response.content_length = fileb
+ env.response.status_code = 200 # Range not satisfable, see 4.4 Note
+ IO.copy(file, env.response)
+ end
+end
+
+# Set the Content-Disposition to "attachment" with the specified filename,
+# instructing the user agents to prompt to save.
+private def attachment(env : HTTP::Server::Context, filename : String? = nil, disposition : String? = nil)
+ disposition = "attachment" if disposition.nil? && filename
+ if disposition && filename
+ env.response.headers["Content-Disposition"] = "#{disposition}; filename=\"#{File.basename(filename)}\""
+ end
+end
+
+def send_file(env : HTTP::Server::Context, file_path : String, data : Slice(UInt8), filestat : File::Info, filename : String? = nil, disposition : String? = nil)
+ config = Kemal.config.serve_static
+ mime_type = MIME.from_filename(file_path, "application/octet-stream")
+ env.response.content_type = mime_type
+ env.response.headers["Accept-Ranges"] = "bytes"
+ env.response.headers["X-Content-Type-Options"] = "nosniff"
+ minsize = 860 # http://webmasters.stackexchange.com/questions/31750/what-is-recommended-minimum-object-size-for-gzip-performance-benefits ??
+ request_headers = env.request.headers
+ filesize = data.bytesize
+ attachment(env, filename, disposition)
+
+ Kemal.config.static_headers.try(&.call(env, file_path, filestat))
+
+ file = IO::Memory.new(data)
+ if env.request.method == "GET" && env.request.headers.has_key?("Range")
+ return multipart(file, env)
+ end
+
+ condition = config.is_a?(Hash) && config["gzip"]? == true && filesize > minsize && Kemal::Utils.zip_types(file_path)
+ if condition && request_headers.includes_word?("Accept-Encoding", "gzip")
+ env.response.headers["Content-Encoding"] = "gzip"
+ Compress::Gzip::Writer.open(env.response) do |deflate|
+ IO.copy(file, deflate)
+ end
+ elsif condition && request_headers.includes_word?("Accept-Encoding", "deflate")
+ env.response.headers["Content-Encoding"] = "deflate"
+ Compress::Deflate::Writer.open(env.response) do |deflate|
+ IO.copy(file, deflate)
+ end
+ else
+ env.response.content_length = filesize
+ IO.copy(file, env.response)
+ end
+
+ return
+end
+
+module Kemal
+ class StaticFileHandler < HTTP::StaticFileHandler
+ CACHE_LIMIT = 5_000_000 # 5MB
+ @cached_files = {} of String => {data: Bytes, filestat: File::Info}
+
+ def call(context : HTTP::Server::Context)
+ return call_next(context) if context.request.path.not_nil! == "/"
+
+ case context.request.method
+ when "GET", "HEAD"
+ else
+ if @fallthrough
+ call_next(context)
+ else
+ context.response.status = HTTP::Status::METHOD_NOT_ALLOWED
+ context.response.headers.add("Allow", "GET, HEAD")
+ end
+ return
+ end
+
+ config = Kemal.config.serve_static
+ original_path = context.request.path.not_nil!
+ request_path = URI.decode_www_form(original_path)
+
+ # File path cannot contains '\0' (NUL) because all filesystem I know
+ # don't accept '\0' character as file name.
+ if request_path.includes? '\0'
+ context.response.status = HTTP::Status::BAD_REQUEST
+ return
+ end
+
+ expanded_path = File.expand_path(request_path, "/")
+ is_dir_path = if original_path.ends_with?('/') && !expanded_path.ends_with? '/'
+ expanded_path = expanded_path + '/'
+ true
+ else
+ expanded_path.ends_with? '/'
+ end
+
+ file_path = File.join(@public_dir, expanded_path)
+
+ if file = @cached_files[file_path]?
+ last_modified = file[:filestat].modification_time
+ add_cache_headers(context.response.headers, last_modified)
+
+ if cache_request?(context, last_modified)
+ context.response.status = HTTP::Status::NOT_MODIFIED
+ return
+ end
+
+ send_file(context, file_path, file[:data], file[:filestat])
+ else
+ file_info = File.info?(file_path)
+ is_dir = file_info.try &.directory? || false
+ is_file = file_info.try &.file? || false
+
+ if request_path != expanded_path
+ redirect_to context, expanded_path
+ elsif is_dir && !is_dir_path
+ redirect_to context, expanded_path + '/'
+ end
+
+ return call_next(context) if file_info.nil?
+
+ if is_dir
+ if config.is_a?(Hash) && config["dir_listing"] == true
+ context.response.content_type = "text/html"
+ directory_listing(context.response, request_path, file_path)
+ else
+ call_next(context)
+ end
+ elsif is_file
+ last_modified = file_info.modification_time
+ add_cache_headers(context.response.headers, last_modified)
+
+ if cache_request?(context, last_modified)
+ context.response.status = HTTP::Status::NOT_MODIFIED
+ return
+ end
+
+ if @cached_files.sum(&.[1][:data].bytesize) + (size = File.size(file_path)) < CACHE_LIMIT
+ data = Bytes.new(size)
+ File.open(file_path, &.read(data))
+
+ @cached_files[file_path] = {data: data, filestat: file_info}
+ send_file(context, file_path, data, file_info)
+ else
+ send_file(context, file_path)
+ end
+ else # Not a normal file (FIFO/device/socket)
+ call_next(context)
+ end
+ end
+ end
+ end
+end
diff --git a/src/invidious/feeds/trending.cr b/src/invidious/feeds/trending.cr
new file mode 100644
index 000000000..41c2eb330
--- /dev/null
+++ b/src/invidious/feeds/trending.cr
@@ -0,0 +1,52 @@
+module Invidious::Feeds::Trending
+ extend self
+
+ def fetch(trending_type, region, locale)
+ region ||= "US"
+ region = region.upcase
+
+ plid = nil
+
+ browse_id = ""
+
+ case trending_type.try &.downcase
+ when "gaming"
+ browse_id = "UCOpNcN46UbXVtpKMrmU4Abg"
+ params = "Egh0cmVuZGluZw%3D%3D"
+ when "livestreams"
+ browse_id = "UC4R8DWoMoI7CAwX8_LjQHig"
+ params = "EgdsaXZldGFikgEDCKEK"
+ else
+ # Livestreams is the default one as Youtube removed
+ # the aggregated trending page
+ # https://github.com/iv-org/invidious/issues/5397#issuecomment-3218928458
+ browse_id = "UC4R8DWoMoI7CAwX8_LjQHig"
+ params = "EgdsaXZldGFikgEDCKEK"
+ end
+
+ client_config = YoutubeAPI::ClientConfig.new(region: region)
+ initial_data = YoutubeAPI.browse(browse_id, params: params, client_config: client_config)
+
+ items, _ = extract_items(initial_data)
+
+ extracted = [] of SearchItem
+
+ deduplicate = items.size > 1
+
+ items.each do |itm|
+ if itm.is_a?(Category)
+ # Ignore the smaller categories, as they generally contain a sponsored
+ # channel, which brings a lot of noise on the trending page.
+ # See: https://github.com/iv-org/invidious/issues/2989
+ next if (itm.contents.size < 24 && deduplicate)
+
+ extracted.concat itm.contents.select(SearchItem)
+ else
+ extracted << itm
+ end
+ end
+
+ # Deduplicate items before returning results
+ return extracted.select(SearchVideo | ProblematicTimelineItem).uniq!(&.id), plid
+ end
+end
diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr
index cee6eb2c8..7ab92314c 100644
--- a/src/invidious/helpers/errors.cr
+++ b/src/invidious/helpers/errors.cr
@@ -2,26 +2,29 @@
# Issue template
# -------------------
-macro error_template(*args)
- error_template_helper(env, {{args.splat}})
-end
+module Errors
+ extend self
-def github_details(summary : String, content : String)
- details = %(\n)
- details += %(\n#{summary}
)
- details += %(\n)
- details += %(\n \n```\n)
- details += content.strip
- details += %(\n```)
- details += %(\n
)
- details += %(\n )
- return HTML.escape(details)
-end
+ macro error_template(*args)
+ Errors.error_template_helper(env, {{args.splat}})
+ end
-def get_issue_template(env : HTTP::Server::Context, exception : Exception) : Tuple(String, String)
- issue_title = "#{exception.message} (#{exception.class})"
+ def github_details(summary : String, content : String)
+ details = %(\n)
+ details += %(\n#{summary}
)
+ details += %(\n)
+ details += %(\n \n```\n)
+ details += content.strip
+ details += %(\n```)
+ details += %(\n
)
+ details += %(\n )
+ return HTML.escape(details)
+ end
- issue_template = <<-TEXT
+ def get_issue_template(env : HTTP::Server::Context, exception : Exception) : Tuple(String, String)
+ issue_title = "#{exception.message} (#{exception.class})"
+
+ issue_template = <<-TEXT
Title: `#{HTML.escape(issue_title)}`
Date: `#{Time::Format::ISO_8601_DATE_TIME.format(Time.utc)}`
Route: `#{HTML.escape(env.request.resource)}`
@@ -29,39 +32,39 @@ def get_issue_template(env : HTTP::Server::Context, exception : Exception) : Tup
TEXT
- issue_template += github_details("Backtrace", exception.inspect_with_backtrace)
+ issue_template += github_details("Backtrace", exception.inspect_with_backtrace)
- return issue_title, issue_template
-end
-
-def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception)
- if exception.is_a?(InfoException)
- return error_template_helper(env, status_code, exception.message || "")
+ return issue_title, issue_template
end
- locale = env.get("preferences").as(Preferences).locale
+ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception)
+ if exception.is_a?(InfoException)
+ return error_template_helper(env, status_code, exception.message || "")
+ end
- env.response.content_type = "text/html"
- env.response.status_code = status_code
+ locale = env.get("preferences").as(Preferences).locale
- # Unpacking into issue_title, issue_template directly causes a compiler error
- # I have no idea why.
- issue_template_components = get_issue_template(env, exception)
- issue_title, issue_template = issue_template_components
+ env.response.content_type = "text/html"
+ env.response.status_code = status_code
- # URLs for the error message below
- url_faq = "https://github.com/iv-org/documentation/blob/master/docs/faq.md"
- url_search_issues = "https://github.com/iv-org/invidious/issues"
- url_search_issues += "?q=is:issue+is:open+"
- url_search_issues += URI.encode_www_form("[Bug] #{issue_title}")
+ # Unpacking into issue_title, issue_template directly causes a compiler error
+ # I have no idea why.
+ issue_template_components = self.get_issue_template(env, exception)
+ issue_title, issue_template = issue_template_components
- url_switch = "https://redirect.invidious.io" + env.request.resource
+ # URLs for the error message below
+ url_faq = "https://github.com/iv-org/documentation/blob/master/docs/faq.md"
+ url_search_issues = "https://github.com/iv-org/invidious/issues"
+ url_search_issues += "?q=is:issue+is:open+"
+ url_search_issues += URI.encode_www_form("[Bug] #{issue_title}")
- url_new_issue = "https://github.com/iv-org/invidious/issues/new"
- url_new_issue += "?labels=bug&template=bug_report.md&title="
- url_new_issue += URI.encode_www_form("[Bug] " + issue_title)
+ url_switch = "https://redirect.invidious.io" + env.request.resource
- error_message = <<-END_HTML
+ url_new_issue = "https://github.com/iv-org/invidious/issues/new"
+ url_new_issue += "?labels=bug&template=bug_report.md&title="
+ url_new_issue += URI.encode_www_form("[Bug] " + issue_title)
+
+ error_message = <<-END_HTML
#{I18n.translate(locale, "crash_page_you_found_a_bug")}
@@ -82,116 +85,116 @@ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exce
END_HTML
- # Don't show the usual "next steps" widget. The same options are
- # proposed above the error message, just worded differently.
- next_steps = ""
+ # Don't show the usual "next steps" widget. The same options are
+ # proposed above the error message, just worded differently.
+ next_steps = ""
- return templated "error"
-end
-
-def error_template_helper(env : HTTP::Server::Context, status_code : Int32, message : String)
- env.response.content_type = "text/html"
- env.response.status_code = status_code
-
- locale = env.get("preferences").as(Preferences).locale
-
- error_message = I18n.translate(locale, message)
- next_steps = error_redirect_helper(env)
-
- return templated "error"
-end
-
-# -------------------
-# Atom feeds
-# -------------------
-
-macro error_atom(*args)
- error_atom_helper(env, {{args.splat}})
-end
-
-def error_atom_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception)
- if exception.is_a?(InfoException)
- return error_atom_helper(env, status_code, exception.message || "")
+ return templated "error"
end
- env.response.content_type = "application/atom+xml"
- env.response.status_code = status_code
+ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, message : String)
+ env.response.content_type = "text/html"
+ env.response.status_code = status_code
- return "#{exception.inspect_with_backtrace}"
-end
+ locale = env.get("preferences").as(Preferences).locale
-def error_atom_helper(env : HTTP::Server::Context, status_code : Int32, message : String)
- env.response.content_type = "application/atom+xml"
- env.response.status_code = status_code
+ error_message = I18n.translate(locale, message)
+ next_steps = error_redirect_helper(env)
- return "#{message}"
-end
-
-# -------------------
-# JSON
-# -------------------
-
-macro error_json(*args)
- error_json_helper(env, {{args.splat}})
-end
-
-def error_json_helper(
- env : HTTP::Server::Context,
- status_code : Int32,
- exception : Exception,
- additional_fields : Hash(String, Object) | Nil = nil,
-)
- if exception.is_a?(InfoException)
- return error_json_helper(env, status_code, exception.message || "", additional_fields)
+ return templated "error"
end
- env.response.content_type = "application/json"
- env.response.status_code = status_code
+ # -------------------
+ # Atom feeds
+ # -------------------
- error_message = {"error" => exception.message, "errorBacktrace" => exception.inspect_with_backtrace}
-
- if additional_fields
- error_message = error_message.merge(additional_fields)
+ macro error_atom(*args)
+ Errors.error_atom_helper(env, {{args.splat}})
end
- return error_message.to_json
-end
+ def error_atom_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception)
+ if exception.is_a?(InfoException)
+ return error_atom_helper(env, status_code, exception.message || "")
+ end
-def error_json_helper(
- env : HTTP::Server::Context,
- status_code : Int32,
- message : String,
- additional_fields : Hash(String, Object) | Nil = nil,
-)
- env.response.content_type = "application/json"
- env.response.status_code = status_code
+ env.response.content_type = "application/atom+xml"
+ env.response.status_code = status_code
- error_message = {"error" => message}
-
- if additional_fields
- error_message = error_message.merge(additional_fields)
+ return "#{exception.inspect_with_backtrace}"
end
- return error_message.to_json
-end
+ def error_atom_helper(env : HTTP::Server::Context, status_code : Int32, message : String)
+ env.response.content_type = "application/atom+xml"
+ env.response.status_code = status_code
-# -------------------
-# Redirect
-# -------------------
+ return "#{message}"
+ end
-def error_redirect_helper(env : HTTP::Server::Context)
- request_path = env.request.path
+ # -------------------
+ # JSON
+ # -------------------
- locale = env.get("preferences").as(Preferences).locale
+ macro error_json(*args)
+ Errors.error_json_helper(env, {{args.splat}})
+ end
- if request_path.starts_with?("/search") || request_path.starts_with?("/watch") ||
- request_path.starts_with?("/channel") || request_path.starts_with?("/playlist?list=PL")
- next_steps_text = I18n.translate(locale, "next_steps_error_message")
- refresh = I18n.translate(locale, "next_steps_error_message_refresh")
- go_to_youtube = I18n.translate(locale, "next_steps_error_message_go_to_youtube")
- switch_instance = I18n.translate(locale, "Switch Invidious Instance")
+ def error_json_helper(
+ env : HTTP::Server::Context,
+ status_code : Int32,
+ exception : Exception,
+ additional_fields : Hash(String, Object) | Nil = nil,
+ )
+ if exception.is_a?(InfoException)
+ return error_json_helper(env, status_code, exception.message || "", additional_fields)
+ end
- return <<-END_HTML
+ env.response.content_type = "application/json"
+ env.response.status_code = status_code
+
+ error_message = {"error" => exception.message, "errorBacktrace" => exception.inspect_with_backtrace}
+
+ if additional_fields
+ error_message = error_message.merge(additional_fields)
+ end
+
+ return error_message.to_json
+ end
+
+ def error_json_helper(
+ env : HTTP::Server::Context,
+ status_code : Int32,
+ message : String,
+ additional_fields : Hash(String, Object) | Nil = nil,
+ )
+ env.response.content_type = "application/json"
+ env.response.status_code = status_code
+
+ error_message = {"error" => message}
+
+ if additional_fields
+ error_message = error_message.merge(additional_fields)
+ end
+
+ return error_message.to_json
+ end
+
+ # -------------------
+ # Redirect
+ # -------------------
+
+ def error_redirect_helper(env : HTTP::Server::Context)
+ request_path = env.request.path
+
+ locale = env.get("preferences").as(Preferences).locale
+
+ if request_path.starts_with?("/search") || request_path.starts_with?("/watch") ||
+ request_path.starts_with?("/channel") || request_path.starts_with?("/playlist?list=PL")
+ next_steps_text = I18n.translate(locale, "next_steps_error_message")
+ refresh = I18n.translate(locale, "next_steps_error_message_refresh")
+ go_to_youtube = I18n.translate(locale, "next_steps_error_message_go_to_youtube")
+ switch_instance = I18n.translate(locale, "Switch Invidious Instance")
+
+ return <<-END_HTML
#{next_steps_text}
-
@@ -205,7 +208,8 @@ def error_redirect_helper(env : HTTP::Server::Context)
END_HTML
- else
- return ""
+ else
+ return ""
+ end
end
end
diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr
index 8395c7a4d..377ba4194 100644
--- a/src/invidious/helpers/handlers.cr
+++ b/src/invidious/helpers/handlers.cr
@@ -83,7 +83,7 @@ class AuthHandler < Kemal::Handler
if token = env.request.headers["Authorization"]?
token = JSON.parse(URI.decode_www_form(token.lchop("Bearer ")))
session = URI.decode_www_form(token["session"].as_s)
- scopes, _, _ = validate_request(token, session, env.request, HMAC_KEY, nil)
+ scopes, _, _ = Invidious::Helpers::Tokens.validate_request(token, session, env.request, HMAC_KEY, nil)
if email = Invidious::Database::SessionIDs.select_email(session)
user = Invidious::Database::Users.select!(email: email)
diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr
index a08135eaa..c987fc6ec 100644
--- a/src/invidious/helpers/helpers.cr
+++ b/src/invidious/helpers/helpers.cr
@@ -22,7 +22,7 @@ struct Annotation
property annotations : String
end
-module Helpers
+module Invidious::Helpers
extend self
private TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"}
@@ -62,7 +62,7 @@ module Helpers
end
def create_notification_stream(env, topics, connection_channel)
- connection = Channel(PQ::Notification).new(8)
+ connection = ::Channel(PQ::Notification).new(8)
connection_channel.send({true, connection})
locale = env.get("preferences").as(Preferences).locale
diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr
index 17dff5664..32eae12bd 100644
--- a/src/invidious/helpers/serialized_yt_data.cr
+++ b/src/invidious/helpers/serialized_yt_data.cr
@@ -53,7 +53,7 @@ struct SearchVideo
xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg")
end
- xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text Helpers.html_to_content(self.description_html) }
+ xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text Invidious::Helpers.html_to_content(self.description_html) }
end
end
@@ -63,7 +63,7 @@ struct SearchVideo
xml.element("media:title") { xml.text self.title }
xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg",
width: "320", height: "180")
- xml.element("media:description") { xml.text Helpers.html_to_content(self.description_html) }
+ xml.element("media:description") { xml.text Invidious::Helpers.html_to_content(self.description_html) }
end
xml.element("media:community") do
@@ -111,7 +111,7 @@ struct SearchVideo
Invidious::JSONify::APIv1.thumbnails(json, self.id)
end
- json.field "description", Helpers.html_to_content(self.description_html)
+ json.field "description", Invidious::Helpers.html_to_content(self.description_html)
json.field "descriptionHtml", self.description_html
json.field "viewCount", self.views
@@ -255,7 +255,7 @@ struct SearchChannel
json.field "videoCount", self.video_count
json.field "channelHandle", self.channel_handle
- json.field "description", Helpers.html_to_content(self.description_html)
+ json.field "description", Invidious::Helpers.html_to_content(self.description_html)
json.field "descriptionHtml", self.description_html
end
end
@@ -332,7 +332,7 @@ struct ProblematicTimelineItem
end
xml.element("pre") do
- get_issue_template(env, @parse_exception)
+ Errors.get_issue_template(env, @parse_exception)
end
end
end
diff --git a/src/invidious/helpers/tokens.cr b/src/invidious/helpers/tokens.cr
index a44988cd3..623937f8c 100644
--- a/src/invidious/helpers/tokens.cr
+++ b/src/invidious/helpers/tokens.cr
@@ -1,145 +1,149 @@
require "crypto/subtle"
-def generate_token(email, scopes, expire, key)
- session = "v1:#{Base64.urlsafe_encode(Random::Secure.random_bytes(32))}"
- Invidious::Database::SessionIDs.insert(session, email)
+module Invidious::Helpers::Tokens
+ extend self
- token = {
- "session" => session,
- "scopes" => scopes,
- "expire" => expire,
- }
+ def generate_token(email, scopes, expire, key)
+ session = "v1:#{Base64.urlsafe_encode(Random::Secure.random_bytes(32))}"
+ Invidious::Database::SessionIDs.insert(session, email)
- if !expire
- token.delete("expire")
- end
+ token = {
+ "session" => session,
+ "scopes" => scopes,
+ "expire" => expire,
+ }
- token["signature"] = sign_token(key, token)
-
- return token.to_json
-end
-
-def generate_response(session, scopes, key, expire = 6.hours, use_nonce = false)
- expire = Time.utc + expire
-
- token = {
- "session" => session,
- "expire" => expire.to_unix,
- "scopes" => scopes,
- }
-
- if use_nonce
- nonce = Random::Secure.hex(16)
- Invidious::Database::Nonces.insert(nonce, expire)
- token["nonce"] = nonce
- end
-
- token["signature"] = sign_token(key, token)
-
- return token.to_json
-end
-
-def sign_token(key, hash)
- string_to_sign = [] of String
-
- # TODO: figure out which "key" variable is used
- # Ameba reports a warning for "Lint/ShadowingOuterLocalVar" on this
- # variable, but it's preferable to not touch that (works fine atm).
- hash.each do |key, value|
- next if key == "signature"
-
- if value.is_a?(JSON::Any) && value.as_a?
- value = value.as_a.map(&.as_s)
+ if !expire
+ token.delete("expire")
end
- case value
- when Array
- string_to_sign << "#{key}=#{value.sort.join(",")}"
- when Tuple
- string_to_sign << "#{key}=#{value.to_a.sort.join(",")}"
- else
- string_to_sign << "#{key}=#{value}"
+ token["signature"] = sign_token(key, token)
+
+ return token.to_json
+ end
+
+ def generate_response(session, scopes, key, expire = 6.hours, use_nonce = false)
+ expire = Time.utc + expire
+
+ token = {
+ "session" => session,
+ "expire" => expire.to_unix,
+ "scopes" => scopes,
+ }
+
+ if use_nonce
+ nonce = Random::Secure.hex(16)
+ Invidious::Database::Nonces.insert(nonce, expire)
+ token["nonce"] = nonce
end
+
+ token["signature"] = sign_token(key, token)
+
+ return token.to_json
end
- string_to_sign = string_to_sign.sort.join("\n")
- return Base64.urlsafe_encode(OpenSSL::HMAC.digest(:sha256, key, string_to_sign)).strip
-end
+ def sign_token(key, hash)
+ string_to_sign = [] of String
-def validate_request(token, session, request, key, locale = nil)
- case token
- when String
- token = JSON.parse(URI.decode_www_form(token)).as_h
- when JSON::Any
- token = token.as_h
- when Nil
- raise InfoException.new("Hidden field \"token\" is a required field")
+ # TODO: figure out which "key" variable is used
+ # Ameba reports a warning for "Lint/ShadowingOuterLocalVar" on this
+ # variable, but it's preferable to not touch that (works fine atm).
+ hash.each do |key, value|
+ next if key == "signature"
+
+ if value.is_a?(JSON::Any) && value.as_a?
+ value = value.as_a.map(&.as_s)
+ end
+
+ case value
+ when Array
+ string_to_sign << "#{key}=#{value.sort.join(",")}"
+ when Tuple
+ string_to_sign << "#{key}=#{value.to_a.sort.join(",")}"
+ else
+ string_to_sign << "#{key}=#{value}"
+ end
+ end
+
+ string_to_sign = string_to_sign.sort.join("\n")
+ return Base64.urlsafe_encode(OpenSSL::HMAC.digest(:sha256, key, string_to_sign)).strip
end
- expire = token["expire"]?.try &.as_i
- if expire.try &.< Time.utc.to_unix
- raise InfoException.new("Token is expired, please try again")
- end
+ def validate_request(token, session, request, key, locale = nil)
+ case token
+ when String
+ token = JSON.parse(URI.decode_www_form(token)).as_h
+ when JSON::Any
+ token = token.as_h
+ when Nil
+ raise InfoException.new("Hidden field \"token\" is a required field")
+ end
- if token["session"] != session
- raise InfoException.new("Erroneous token")
- end
+ expire = token["expire"]?.try &.as_i
+ if expire.try &.< Time.utc.to_unix
+ raise InfoException.new("Token is expired, please try again")
+ end
- scopes = token["scopes"].as_a.map(&.as_s)
- scope = "#{request.method}:#{request.path.lchop("/api/v1/auth/").lstrip("/")}"
- if !scopes_include_scope(scopes, scope)
- raise InfoException.new("Invalid scope")
- end
-
- if !Crypto::Subtle.constant_time_compare(token["signature"].to_s, sign_token(key, token))
- raise InfoException.new("Invalid signature")
- end
-
- if token["nonce"]? && (nonce = Invidious::Database::Nonces.select(token["nonce"].as_s))
- if nonce[1] > Time.utc
- Invidious::Database::Nonces.update_set_expired(nonce[0])
- else
+ if token["session"] != session
raise InfoException.new("Erroneous token")
end
- end
- return {scopes, expire, token["signature"].as_s}
-end
-
-def scope_includes_scope(scope, subset)
- methods, endpoint = scope.split(":")
- methods = methods.split(";").map(&.upcase).reject(&.empty?).sort!
- endpoint = endpoint.downcase
-
- subset_methods, subset_endpoint = subset.split(":")
- subset_methods = subset_methods.split(";").map(&.upcase).sort!
- subset_endpoint = subset_endpoint.downcase
-
- if methods.empty?
- methods = %w(GET POST PUT HEAD DELETE PATCH OPTIONS)
- end
-
- if methods & subset_methods != subset_methods
- return false
- end
-
- if endpoint.ends_with?("*") && !subset_endpoint.starts_with? endpoint.rchop("*")
- return false
- end
-
- if !endpoint.ends_with?("*") && subset_endpoint != endpoint
- return false
- end
-
- return true
-end
-
-def scopes_include_scope(scopes, subset)
- scopes.each do |scope|
- if scope_includes_scope(scope, subset)
- return true
+ scopes = token["scopes"].as_a.map(&.as_s)
+ scope = "#{request.method}:#{request.path.lchop("/api/v1/auth/").lstrip("/")}"
+ if !scopes_include_scope(scopes, scope)
+ raise InfoException.new("Invalid scope")
end
+
+ if !Crypto::Subtle.constant_time_compare(token["signature"].to_s, sign_token(key, token))
+ raise InfoException.new("Invalid signature")
+ end
+
+ if token["nonce"]? && (nonce = Invidious::Database::Nonces.select(token["nonce"].as_s))
+ if nonce[1] > Time.utc
+ Invidious::Database::Nonces.update_set_expired(nonce[0])
+ else
+ raise InfoException.new("Erroneous token")
+ end
+ end
+
+ return {scopes, expire, token["signature"].as_s}
end
- return false
+ def scope_includes_scope(scope, subset)
+ methods, endpoint = scope.split(":")
+ methods = methods.split(";").map(&.upcase).reject(&.empty?).sort!
+ endpoint = endpoint.downcase
+
+ subset_methods, subset_endpoint = subset.split(":")
+ subset_methods = subset_methods.split(";").map(&.upcase).sort!
+ subset_endpoint = subset_endpoint.downcase
+
+ if methods.empty?
+ methods = %w(GET POST PUT HEAD DELETE PATCH OPTIONS)
+ end
+
+ if methods & subset_methods != subset_methods
+ return false
+ end
+
+ if endpoint.ends_with?("*") && !subset_endpoint.starts_with? endpoint.rchop("*")
+ return false
+ end
+
+ if !endpoint.ends_with?("*") && subset_endpoint != endpoint
+ return false
+ end
+
+ return true
+ end
+
+ def scopes_include_scope(scopes, subset)
+ scopes.each do |scope|
+ if self.scope_includes_scope(scope, subset)
+ return true
+ end
+ end
+
+ return false
+ end
end
diff --git a/src/invidious/jobs/refresh_channels_job.cr b/src/invidious/jobs/refresh_channels_job.cr
index 5c5ecc837..43374f46b 100644
--- a/src/invidious/jobs/refresh_channels_job.cr
+++ b/src/invidious/jobs/refresh_channels_job.cr
@@ -30,7 +30,7 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob
spawn do
begin
LOGGER.trace("RefreshChannelsJob: #{id} fiber : Fetching channel")
- channel = fetch_channel(id, pull_all_videos: CONFIG.full_refresh)
+ channel = Invidious::Channels::Channels.fetch_channel(id, pull_all_videos: CONFIG.full_refresh)
lim_fibers = max_fibers
diff --git a/src/invidious/jobs/refresh_feeds_job.cr b/src/invidious/jobs/refresh_feeds_job.cr
index 4f8130dfd..dfb93ebf4 100644
--- a/src/invidious/jobs/refresh_feeds_job.cr
+++ b/src/invidious/jobs/refresh_feeds_job.cr
@@ -54,7 +54,7 @@ class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob
# While iterating through, we may have an email stored from a deleted account
if db.query_one?("SELECT true FROM users WHERE email = $1", email, as: Bool)
LOGGER.info("RefreshFeedsJob: CREATE #{view_name}")
- db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(email)}")
+ db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{Invidious::User::Users::MATERIALIZED_VIEW_SQL.call(email)}")
db.exec("UPDATE users SET feed_needs_update = false WHERE email = $1", email)
end
rescue ex
diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr
deleted file mode 100644
index e6a06633a..000000000
--- a/src/invidious/playlists.cr
+++ /dev/null
@@ -1,579 +0,0 @@
-struct PlaylistVideo
- include DB::Serializable
-
- property title : String
- property id : String
- property author : String
- property ucid : String
- property length_seconds : Int32
- property published : Time
- property plid : String
- property index : Int64
- property live_now : Bool
-
- def to_xml(xml : XML::Builder)
- xml.element("entry") do
- xml.element("id") { xml.text "yt:video:#{self.id}" }
- xml.element("yt:videoId") { xml.text self.id }
- xml.element("yt:channelId") { xml.text self.ucid }
- xml.element("title") { xml.text self.title }
- xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?v=#{self.id}")
-
- xml.element("author") do
- xml.element("name") { xml.text self.author }
- xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" }
- end
-
- xml.element("content", type: "xhtml") do
- xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do
- xml.element("a", href: "#{HOST_URL}/watch?v=#{self.id}") do
- xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg")
- end
- end
- end
-
- xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") }
-
- xml.element("media:group") do
- xml.element("media:title") { xml.text self.title }
- xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg",
- width: "320", height: "180")
- end
- end
- end
-
- def to_xml(_xml : Nil = nil)
- XML.build { |xml| to_xml(xml) }
- end
-
- def to_json(locale : String?, json : JSON::Builder)
- to_json(json)
- end
-
- def to_json(json : JSON::Builder, index : Int32? = nil)
- json.object do
- json.field "type", "video"
-
- json.field "title", self.title
- json.field "videoId", self.id
-
- json.field "author", self.author
- json.field "authorId", self.ucid
- json.field "authorUrl", "/channel/#{self.ucid}"
-
- json.field "videoThumbnails" do
- Invidious::JSONify::APIv1.thumbnails(json, self.id)
- end
-
- if index
- json.field "index", index
- json.field "indexId", self.index.to_u64.to_s(16).upcase
- else
- json.field "index", self.index
- end
-
- json.field "lengthSeconds", self.length_seconds
- json.field "liveNow", self.live_now
- end
- end
-
- def to_json(_json : Nil, index : Int32? = nil)
- JSON.build { |json| to_json(json, index: index) }
- end
-end
-
-struct Playlist
- include DB::Serializable
-
- property title : String
- property id : String
- property author : String
- property author_thumbnail : String
- property ucid : String
- property description : String
- property description_html : String
- property video_count : Int32
- property views : Int64
- property updated : Time
- property thumbnail : String?
- property subtitle : String?
-
- def to_json(offset, json : JSON::Builder, video_id : String? = nil)
- json.object do
- json.field "type", "playlist"
- json.field "title", self.title
- json.field "playlistId", self.id
- json.field "playlistThumbnail", self.thumbnail
-
- json.field "author", self.author
- json.field "authorId", self.ucid
- if !self.ucid.empty?
- json.field "authorUrl", "/channel/#{self.ucid}"
- else
- json.field "authorUrl", ""
- end
- json.field "subtitle", self.subtitle
-
- json.field "authorThumbnails" do
- json.array do
- qualities = {32, 48, 76, 100, 176, 512}
-
- qualities.each do |quality|
- json.object do
- json.field "url", self.author_thumbnail.not_nil!.gsub(/=\d+/, "=s#{quality}")
- json.field "width", quality
- json.field "height", quality
- end
- end
- end
- end
-
- json.field "description", self.description
- json.field "descriptionHtml", self.description_html
- json.field "videoCount", self.video_count
-
- json.field "viewCount", self.views
- json.field "updated", self.updated.to_unix
- json.field "isListed", self.privacy.public?
-
- json.field "videos" do
- json.array do
- videos = get_playlist_videos(self, offset: offset, video_id: video_id)
- videos.each do |video|
- video.to_json(json)
- end
- end
- end
- end
- end
-
- def to_json(offset, _json : Nil = nil, video_id : String? = nil)
- JSON.build do |json|
- to_json(offset, json, video_id: video_id)
- end
- end
-
- def privacy
- PlaylistPrivacy::Public
- end
-end
-
-enum PlaylistPrivacy
- Public = 0
- Unlisted = 1
- Private = 2
-end
-
-struct InvidiousPlaylist
- include DB::Serializable
-
- property title : String
- property id : String
- property author : String
- property description : String = ""
- property video_count : Int32
- property created : Time
- property updated : Time
-
- @[DB::Field(converter: InvidiousPlaylist::PlaylistPrivacyConverter)]
- property privacy : PlaylistPrivacy = PlaylistPrivacy::Private
- property index : Array(Int64)
-
- @[DB::Field(ignore: true)]
- property thumbnail_id : String?
-
- module PlaylistPrivacyConverter
- def self.from_rs(rs)
- return PlaylistPrivacy.parse(String.new(rs.read(Slice(UInt8))))
- end
- end
-
- def to_json(offset, json : JSON::Builder, video_id : String? = nil)
- json.object do
- json.field "type", "invidiousPlaylist"
- json.field "title", self.title
- json.field "playlistId", self.id
-
- json.field "author", self.author
- json.field "authorId", self.ucid
- json.field "authorUrl", nil
- json.field "authorThumbnails", [] of String
-
- json.field "description", Helpers.html_to_content(self.description_html)
- json.field "descriptionHtml", self.description_html
- json.field "videoCount", self.video_count
-
- json.field "viewCount", self.views
- json.field "updated", self.updated.to_unix
- json.field "isListed", self.privacy.public?
-
- json.field "videos" do
- json.array do
- if (!offset || offset == 0) && !video_id.nil?
- index = Invidious::Database::PlaylistVideos.select_index(self.id, video_id)
- offset = self.index.index(index) || 0
- end
-
- videos = get_playlist_videos(self, offset: offset, video_id: video_id)
- videos.each_with_index do |video, idx|
- video.to_json(json, offset + idx)
- end
- end
- end
- end
- end
-
- def to_json(offset, _json : Nil = nil, video_id : String? = nil)
- JSON.build do |json|
- to_json(offset, json, video_id: video_id)
- end
- end
-
- def thumbnail
- # TODO: Get playlist thumbnail from playlist data rather than first video
- @thumbnail_id ||= Invidious::Database::PlaylistVideos.select_one_id(self.id, self.index) || "-----------"
- "/vi/#{@thumbnail_id}/mqdefault.jpg"
- end
-
- def author_thumbnail
- nil
- end
-
- def ucid
- nil
- end
-
- def views
- 0_i64
- end
-
- def description_html
- HTML.escape(self.description)
- end
-end
-
-def create_playlist(title, privacy, user)
- plid = "IVPL#{Random::Secure.urlsafe_base64(24)[0, 31]}"
-
- playlist = InvidiousPlaylist.new({
- title: title.byte_slice(0, 150),
- id: plid,
- author: user.email,
- description: "", # Max 5000 characters
- video_count: 0,
- created: Time.utc,
- updated: Time.utc,
- privacy: privacy,
- index: [] of Int64,
- })
-
- Invidious::Database::Playlists.insert(playlist)
-
- return playlist
-end
-
-def subscribe_playlist(user, playlist)
- playlist = InvidiousPlaylist.new({
- title: playlist.title[..150],
- id: playlist.id,
- author: user.email,
- description: "", # Max 5000 characters
- video_count: playlist.video_count,
- created: Time.utc,
- updated: playlist.updated,
- privacy: PlaylistPrivacy::Private,
- index: [] of Int64,
- })
-
- Invidious::Database::Playlists.insert(playlist)
-
- return playlist
-end
-
-def produce_playlist_continuation(id, index)
- if id.starts_with? "UC"
- id = "UU" + id.lchop("UC")
- end
- plid = "VL" + id
-
- # Emulate a "request counter" increment, to make perfectly valid
- # ctokens, even if at the time of writing, it's ignored by youtube.
- request_count = (index / 100).to_i64 || 1_i64
-
- data = {"1:varint" => index.to_i64}
- .try { |i| Protodec::Any.cast_json(i) }
- .try { |i| Protodec::Any.from_json(i) }
- .try { |i| Base64.urlsafe_encode(i, padding: false) }
-
- object = {
- "80226972:embedded" => {
- "2:string" => plid,
- "3:base64" => {
- "1:varint" => request_count,
- "15:string" => "PT:#{data}",
- "104:embedded" => {"1:0:varint" => 0_i64},
- },
- "35:string" => id,
- },
- }
-
- continuation = object.try { |i| Protodec::Any.cast_json(i) }
- .try { |i| Protodec::Any.from_json(i) }
- .try { |i| Base64.urlsafe_encode(i) }
- .try { |i| URI.encode_www_form(i) }
-
- return continuation
-end
-
-def get_playlist(plid : String)
- if plid.starts_with? "IV"
- if playlist = Invidious::Database::Playlists.select(id: plid)
- return playlist
- else
- raise NotFoundException.new("Playlist does not exist.")
- end
- else
- return fetch_playlist(plid)
- end
-end
-
-def fetch_playlist(plid : String)
- if plid.starts_with? "UC"
- plid = "UU#{plid.lchop("UC")}"
- end
-
- initial_data = YoutubeAPI.browse("VL" + plid, params: "")
-
- playlist_sidebar_renderer = initial_data.dig?("sidebar", "playlistSidebarRenderer", "items")
- raise InfoException.new("Could not extract playlistSidebarRenderer.") if !playlist_sidebar_renderer
-
- playlist_info = playlist_sidebar_renderer.dig?(0, "playlistSidebarPrimaryInfoRenderer")
- raise InfoException.new("Could not extract playlist info") if !playlist_info
-
- title = playlist_info.dig?("title", "runs", 0, "text").try &.as_s || ""
-
- desc_item = playlist_info["description"]?
-
- description_txt = desc_item.try &.["runs"]?.try &.as_a
- .map(&.["text"].as_s).join("") || desc_item.try &.["simpleText"]?.try &.as_s || ""
-
- description_html = desc_item.try &.["runs"]?.try &.as_a
- .try { |run| content_to_comment_html(run).try &.to_s } || ""
-
- thumbnail = playlist_info.dig?(
- "thumbnailRenderer", "playlistVideoThumbnailRenderer",
- "thumbnail", "thumbnails", 0, "url"
- ).try &.as_s || playlist_info.dig?(
- "thumbnailRenderer", "playlistCustomThumbnailRenderer",
- "thumbnail", "thumbnails", 0, "url"
- ).try &.as_s
-
- views = 0_i64
- updated = Time.utc
- video_count = 0
-
- subtitle = extract_text(initial_data.dig?("header", "playlistHeaderRenderer", "subtitle"))
-
- playlist_info["stats"]?.try &.as_a.each do |stat|
- text = stat["runs"]?.try &.as_a.map(&.["text"].as_s).join("") || stat["simpleText"]?.try &.as_s
- next if !text
-
- if text.includes? "video"
- video_count = text.gsub(/\D/, "").to_i? || 0
- elsif text.includes? "episode"
- video_count = text.gsub(/\D/, "").to_i? || 0
- elsif text.includes? "view"
- views = text.gsub(/\D/, "").to_i64? || 0_i64
- elsif !text.includes? "Pay to watch"
- updated = decode_date(text.lchop("Last updated on ").lchop("Updated "))
- end
- end
-
- if playlist_sidebar_renderer.size < 2
- author = ""
- author_thumbnail = ""
- ucid = ""
- else
- author_info = playlist_sidebar_renderer[1].dig?(
- "playlistSidebarSecondaryInfoRenderer", "videoOwner", "videoOwnerRenderer"
- )
-
- raise InfoException.new("Could not extract author info") if !author_info
-
- author = author_info.dig?("title", "runs", 0, "text").try &.as_s || ""
- author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url").try &.as_s || ""
- ucid = author_info.dig?("title", "runs", 0, "navigationEndpoint", "browseEndpoint", "browseId").try &.as_s || ""
- end
-
- return Playlist.new({
- title: title,
- id: plid,
- author: author,
- author_thumbnail: author_thumbnail,
- ucid: ucid,
- description: description_txt,
- description_html: description_html,
- video_count: video_count,
- views: views,
- updated: updated,
- thumbnail: thumbnail,
- subtitle: subtitle,
- })
-end
-
-def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32, video_id = nil)
- # Show empty playlist if requested page is out of range
- # (e.g, when a new playlist has been created, offset will be negative)
- if offset >= playlist.video_count || offset < 0
- return [] of PlaylistVideo
- end
-
- if playlist.is_a? InvidiousPlaylist
- Invidious::Database::PlaylistVideos.select(playlist.id, playlist.index, offset, limit: 100)
- else
- if video_id
- initial_data = YoutubeAPI.next({
- "videoId" => video_id,
- "playlistId" => playlist.id,
- })
- offset = initial_data.dig?("contents", "twoColumnWatchNextResults", "playlist", "playlist", "currentIndex").try &.as_i || offset
- end
-
- videos = [] of PlaylistVideo | ProblematicTimelineItem
-
- until videos.size >= 200 || videos.size == playlist.video_count || offset >= playlist.video_count
- # 100 videos per request
- ctoken = produce_playlist_continuation(playlist.id, offset)
- initial_data = YoutubeAPI.browse(ctoken)
- videos += extract_playlist_videos(playlist.id, initial_data)
-
- offset += 100
- end
-
- return videos
- end
-end
-
-# TODO (2026-06-24): Migrate this function to use parsers instead, as it uses,
-# the same LockupViewModel used in Channel videos and Youtube playlists that
-# appears on searches (Invidious /search endpoint).
-# Related to https://github.com/iv-org/invidious/pull/5736
-def extract_playlist_videos(playlist_id : String, initial_data : Hash(String, JSON::Any))
- videos = [] of PlaylistVideo | ProblematicTimelineItem
-
- if initial_data["contents"]?
- tabs = initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]
- tabs_renderer = tabs.as_a.select(&.["tabRenderer"]["selected"]?.try &.as_bool)[0]["tabRenderer"]
-
- # Watch out the two versions, with and without "s"
- if tabs_renderer["contents"]? || tabs_renderer["content"]?
- # Initial playlist data
- tabs_contents = tabs_renderer.["contents"]? || tabs_renderer.["content"]
-
- list_renderer = tabs_contents.["sectionListRenderer"]["contents"][0]
- contents = list_renderer.["itemSectionRenderer"]["contents"].as_a
- else
- # Continuation data
- contents = initial_data["onResponseReceivedActions"][0]?
- .try &.["appendContinuationItemsAction"]["continuationItems"].as_a
- end
- else
- contents = initial_data["response"]?.try &.["continuationContents"]["playlistVideoListContinuation"]["contents"].as_a
- end
-
- contents.try &.each do |item|
- if i = item["lockupViewModel"]?
- thumbnail_view_model = i.dig?(
- "contentImage", "thumbnailViewModel"
- )
-
- watch_endpoint = i.dig?("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint")
- video_id = watch_endpoint.try &.["videoId"]?.try &.as_s
- plid = watch_endpoint.try &.["playlistId"]?.try &.as_s || playlist_id
- index = watch_endpoint.try &.["index"]?.try &.as_i64
-
- metadata = i["metadata"]?
- lockup_metadata_view_model = metadata.try &.dig?("lockupMetadataViewModel")
- title = lockup_metadata_view_model.try &.dig?("title", "content").try &.as_s
- lockup_metadata = lockup_metadata_view_model.try &.dig?("metadata")
- metadata_rows = lockup_metadata.try &.dig?("contentMetadataViewModel", "metadataRows").try &.as_a
-
- # Find the metadataParts with commandRuns inside, which contains author
- # information.
- metadata_parts = metadata_rows.try &.find { |row|
- parts = row["metadataParts"]?.try &.as_a
- parts && parts.any? { |item2| item2.dig?("text", "commandRuns").try &.as_a }
- }.try &.["metadataParts"].as_a
-
- if author_info = metadata_parts.try &.find(&.dig?("text", "commandRuns"))
- .try &.["text"]
- author = author_info["content"].as_s
- ucid = author_info.dig?("commandRuns", 0, "onTap", "innertubeCommand", "browseEndpoint", "browseId")
- .try &.as_s
- end
-
- length = thumbnail_view_model.try &.dig?("overlays", 0, "thumbnailBottomOverlayViewModel", "badges", 0, "thumbnailBadgeViewModel", "text").try &.as_s
- length_seconds = decode_length_seconds(length) if length
-
- live = false
-
- if !length_seconds
- live = true
- length_seconds = 0
- end
-
- videos << PlaylistVideo.new({
- title: title || "",
- id: video_id || "",
- author: author || "",
- ucid: ucid || "",
- length_seconds: length_seconds,
- published: Time.utc,
- plid: plid,
- live_now: live,
- index: index || -1_i64,
- })
- end
- rescue ex
- videos << ProblematicTimelineItem.new(parse_exception: ex)
- end
-
- return videos
-end
-
-def template_playlist(playlist, listen)
- html = <<-END_HTML
-
-
-
- END_HTML
-
- html
-end
diff --git a/src/invidious/mixes.cr b/src/invidious/playlists/mixes.cr
similarity index 97%
rename from src/invidious/mixes.cr
rename to src/invidious/playlists/mixes.cr
index 7dd8a8309..cb4a5bdf9 100644
--- a/src/invidious/mixes.cr
+++ b/src/invidious/playlists/mixes.cr
@@ -27,7 +27,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
video_id = "CvFH_6DNRCY" if rdid.starts_with? "OLAK5uy_"
response = YT_POOL.client &.get("/watch?v=#{video_id}&list=#{rdid}&gl=US&hl=en", headers)
- initial_data = Helpers.extract_initial_data(response.body)
+ initial_data = Invidious::Helpers.extract_initial_data(response.body)
if !initial_data["contents"]["twoColumnWatchNextResults"]["playlist"]?
raise InfoException.new("Could not create mix.")
diff --git a/src/invidious/playlists/playlists.cr b/src/invidious/playlists/playlists.cr
new file mode 100644
index 000000000..1091972a2
--- /dev/null
+++ b/src/invidious/playlists/playlists.cr
@@ -0,0 +1,583 @@
+struct PlaylistVideo
+ include DB::Serializable
+
+ property title : String
+ property id : String
+ property author : String
+ property ucid : String
+ property length_seconds : Int32
+ property published : Time
+ property plid : String
+ property index : Int64
+ property live_now : Bool
+
+ def to_xml(xml : XML::Builder)
+ xml.element("entry") do
+ xml.element("id") { xml.text "yt:video:#{self.id}" }
+ xml.element("yt:videoId") { xml.text self.id }
+ xml.element("yt:channelId") { xml.text self.ucid }
+ xml.element("title") { xml.text self.title }
+ xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?v=#{self.id}")
+
+ xml.element("author") do
+ xml.element("name") { xml.text self.author }
+ xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" }
+ end
+
+ xml.element("content", type: "xhtml") do
+ xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do
+ xml.element("a", href: "#{HOST_URL}/watch?v=#{self.id}") do
+ xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg")
+ end
+ end
+ end
+
+ xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") }
+
+ xml.element("media:group") do
+ xml.element("media:title") { xml.text self.title }
+ xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg",
+ width: "320", height: "180")
+ end
+ end
+ end
+
+ def to_xml(_xml : Nil = nil)
+ XML.build { |xml| to_xml(xml) }
+ end
+
+ def to_json(locale : String?, json : JSON::Builder)
+ to_json(json)
+ end
+
+ def to_json(json : JSON::Builder, index : Int32? = nil)
+ json.object do
+ json.field "type", "video"
+
+ json.field "title", self.title
+ json.field "videoId", self.id
+
+ json.field "author", self.author
+ json.field "authorId", self.ucid
+ json.field "authorUrl", "/channel/#{self.ucid}"
+
+ json.field "videoThumbnails" do
+ Invidious::JSONify::APIv1.thumbnails(json, self.id)
+ end
+
+ if index
+ json.field "index", index
+ json.field "indexId", self.index.to_u64.to_s(16).upcase
+ else
+ json.field "index", self.index
+ end
+
+ json.field "lengthSeconds", self.length_seconds
+ json.field "liveNow", self.live_now
+ end
+ end
+
+ def to_json(_json : Nil, index : Int32? = nil)
+ JSON.build { |json| to_json(json, index: index) }
+ end
+end
+
+struct Playlist
+ include DB::Serializable
+
+ property title : String
+ property id : String
+ property author : String
+ property author_thumbnail : String
+ property ucid : String
+ property description : String
+ property description_html : String
+ property video_count : Int32
+ property views : Int64
+ property updated : Time
+ property thumbnail : String?
+ property subtitle : String?
+
+ def to_json(offset, json : JSON::Builder, video_id : String? = nil)
+ json.object do
+ json.field "type", "playlist"
+ json.field "title", self.title
+ json.field "playlistId", self.id
+ json.field "playlistThumbnail", self.thumbnail
+
+ json.field "author", self.author
+ json.field "authorId", self.ucid
+ if !self.ucid.empty?
+ json.field "authorUrl", "/channel/#{self.ucid}"
+ else
+ json.field "authorUrl", ""
+ end
+ json.field "subtitle", self.subtitle
+
+ json.field "authorThumbnails" do
+ json.array do
+ qualities = {32, 48, 76, 100, 176, 512}
+
+ qualities.each do |quality|
+ json.object do
+ json.field "url", self.author_thumbnail.not_nil!.gsub(/=\d+/, "=s#{quality}")
+ json.field "width", quality
+ json.field "height", quality
+ end
+ end
+ end
+ end
+
+ json.field "description", self.description
+ json.field "descriptionHtml", self.description_html
+ json.field "videoCount", self.video_count
+
+ json.field "viewCount", self.views
+ json.field "updated", self.updated.to_unix
+ json.field "isListed", self.privacy.public?
+
+ json.field "videos" do
+ json.array do
+ videos = Invidious::Playlists::Playlists.get_playlist_videos(self, offset: offset, video_id: video_id)
+ videos.each do |video|
+ video.to_json(json)
+ end
+ end
+ end
+ end
+ end
+
+ def to_json(offset, _json : Nil = nil, video_id : String? = nil)
+ JSON.build do |json|
+ to_json(offset, json, video_id: video_id)
+ end
+ end
+
+ def privacy
+ PlaylistPrivacy::Public
+ end
+end
+
+enum PlaylistPrivacy
+ Public = 0
+ Unlisted = 1
+ Private = 2
+end
+
+struct InvidiousPlaylist
+ include DB::Serializable
+
+ property title : String
+ property id : String
+ property author : String
+ property description : String = ""
+ property video_count : Int32
+ property created : Time
+ property updated : Time
+
+ @[DB::Field(converter: InvidiousPlaylist::PlaylistPrivacyConverter)]
+ property privacy : PlaylistPrivacy = PlaylistPrivacy::Private
+ property index : Array(Int64)
+
+ @[DB::Field(ignore: true)]
+ property thumbnail_id : String?
+
+ module PlaylistPrivacyConverter
+ def self.from_rs(rs)
+ return PlaylistPrivacy.parse(String.new(rs.read(Slice(UInt8))))
+ end
+ end
+
+ def to_json(offset, json : JSON::Builder, video_id : String? = nil)
+ json.object do
+ json.field "type", "invidiousPlaylist"
+ json.field "title", self.title
+ json.field "playlistId", self.id
+
+ json.field "author", self.author
+ json.field "authorId", self.ucid
+ json.field "authorUrl", nil
+ json.field "authorThumbnails", [] of String
+
+ json.field "description", Invidious::Helpers.html_to_content(self.description_html)
+ json.field "descriptionHtml", self.description_html
+ json.field "videoCount", self.video_count
+
+ json.field "viewCount", self.views
+ json.field "updated", self.updated.to_unix
+ json.field "isListed", self.privacy.public?
+
+ json.field "videos" do
+ json.array do
+ if (!offset || offset == 0) && !video_id.nil?
+ index = Invidious::Database::PlaylistVideos.select_index(self.id, video_id)
+ offset = self.index.index(index) || 0
+ end
+
+ videos = Invidious::Playlists::Playlists.get_playlist_videos(self, offset: offset, video_id: video_id)
+ videos.each_with_index do |video, idx|
+ video.to_json(json, offset + idx)
+ end
+ end
+ end
+ end
+ end
+
+ def to_json(offset, _json : Nil = nil, video_id : String? = nil)
+ JSON.build do |json|
+ to_json(offset, json, video_id: video_id)
+ end
+ end
+
+ def thumbnail
+ # TODO: Get playlist thumbnail from playlist data rather than first video
+ @thumbnail_id ||= Invidious::Database::PlaylistVideos.select_one_id(self.id, self.index) || "-----------"
+ "/vi/#{@thumbnail_id}/mqdefault.jpg"
+ end
+
+ def author_thumbnail
+ nil
+ end
+
+ def ucid
+ nil
+ end
+
+ def views
+ 0_i64
+ end
+
+ def description_html
+ HTML.escape(self.description)
+ end
+end
+
+module Invidious::Playlists::Playlists
+ extend self
+
+ def create_playlist(title, privacy, user)
+ plid = "IVPL#{Random::Secure.urlsafe_base64(24)[0, 31]}"
+
+ playlist = InvidiousPlaylist.new({
+ title: title.byte_slice(0, 150),
+ id: plid,
+ author: user.email,
+ description: "", # Max 5000 characters
+ video_count: 0,
+ created: Time.utc,
+ updated: Time.utc,
+ privacy: privacy,
+ index: [] of Int64,
+ })
+
+ Invidious::Database::Playlists.insert(playlist)
+
+ return playlist
+ end
+
+ def subscribe_playlist(user, playlist)
+ playlist = InvidiousPlaylist.new({
+ title: playlist.title[..150],
+ id: playlist.id,
+ author: user.email,
+ description: "", # Max 5000 characters
+ video_count: playlist.video_count,
+ created: Time.utc,
+ updated: playlist.updated,
+ privacy: PlaylistPrivacy::Private,
+ index: [] of Int64,
+ })
+
+ Invidious::Database::Playlists.insert(playlist)
+
+ return playlist
+ end
+
+ def produce_playlist_continuation(id, index)
+ if id.starts_with? "UC"
+ id = "UU" + id.lchop("UC")
+ end
+ plid = "VL" + id
+
+ # Emulate a "request counter" increment, to make perfectly valid
+ # ctokens, even if at the time of writing, it's ignored by youtube.
+ request_count = (index / 100).to_i64 || 1_i64
+
+ data = {"1:varint" => index.to_i64}
+ .try { |i| Protodec::Any.cast_json(i) }
+ .try { |i| Protodec::Any.from_json(i) }
+ .try { |i| Base64.urlsafe_encode(i, padding: false) }
+
+ object = {
+ "80226972:embedded" => {
+ "2:string" => plid,
+ "3:base64" => {
+ "1:varint" => request_count,
+ "15:string" => "PT:#{data}",
+ "104:embedded" => {"1:0:varint" => 0_i64},
+ },
+ "35:string" => id,
+ },
+ }
+
+ continuation = object.try { |i| Protodec::Any.cast_json(i) }
+ .try { |i| Protodec::Any.from_json(i) }
+ .try { |i| Base64.urlsafe_encode(i) }
+ .try { |i| URI.encode_www_form(i) }
+
+ return continuation
+ end
+
+ def get_playlist(plid : String)
+ if plid.starts_with? "IV"
+ if playlist = Invidious::Database::Playlists.select(id: plid)
+ return playlist
+ else
+ raise NotFoundException.new("Playlist does not exist.")
+ end
+ else
+ return fetch_playlist(plid)
+ end
+ end
+
+ def fetch_playlist(plid : String)
+ if plid.starts_with? "UC"
+ plid = "UU#{plid.lchop("UC")}"
+ end
+
+ initial_data = YoutubeAPI.browse("VL" + plid, params: "")
+
+ playlist_sidebar_renderer = initial_data.dig?("sidebar", "playlistSidebarRenderer", "items")
+ raise InfoException.new("Could not extract playlistSidebarRenderer.") if !playlist_sidebar_renderer
+
+ playlist_info = playlist_sidebar_renderer.dig?(0, "playlistSidebarPrimaryInfoRenderer")
+ raise InfoException.new("Could not extract playlist info") if !playlist_info
+
+ title = playlist_info.dig?("title", "runs", 0, "text").try &.as_s || ""
+
+ desc_item = playlist_info["description"]?
+
+ description_txt = desc_item.try &.["runs"]?.try &.as_a
+ .map(&.["text"].as_s).join("") || desc_item.try &.["simpleText"]?.try &.as_s || ""
+
+ description_html = desc_item.try &.["runs"]?.try &.as_a
+ .try { |run| content_to_comment_html(run).try &.to_s } || ""
+
+ thumbnail = playlist_info.dig?(
+ "thumbnailRenderer", "playlistVideoThumbnailRenderer",
+ "thumbnail", "thumbnails", 0, "url"
+ ).try &.as_s || playlist_info.dig?(
+ "thumbnailRenderer", "playlistCustomThumbnailRenderer",
+ "thumbnail", "thumbnails", 0, "url"
+ ).try &.as_s
+
+ views = 0_i64
+ updated = Time.utc
+ video_count = 0
+
+ subtitle = extract_text(initial_data.dig?("header", "playlistHeaderRenderer", "subtitle"))
+
+ playlist_info["stats"]?.try &.as_a.each do |stat|
+ text = stat["runs"]?.try &.as_a.map(&.["text"].as_s).join("") || stat["simpleText"]?.try &.as_s
+ next if !text
+
+ if text.includes? "video"
+ video_count = text.gsub(/\D/, "").to_i? || 0
+ elsif text.includes? "episode"
+ video_count = text.gsub(/\D/, "").to_i? || 0
+ elsif text.includes? "view"
+ views = text.gsub(/\D/, "").to_i64? || 0_i64
+ elsif !text.includes? "Pay to watch"
+ updated = decode_date(text.lchop("Last updated on ").lchop("Updated "))
+ end
+ end
+
+ if playlist_sidebar_renderer.size < 2
+ author = ""
+ author_thumbnail = ""
+ ucid = ""
+ else
+ author_info = playlist_sidebar_renderer[1].dig?(
+ "playlistSidebarSecondaryInfoRenderer", "videoOwner", "videoOwnerRenderer"
+ )
+
+ raise InfoException.new("Could not extract author info") if !author_info
+
+ author = author_info.dig?("title", "runs", 0, "text").try &.as_s || ""
+ author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url").try &.as_s || ""
+ ucid = author_info.dig?("title", "runs", 0, "navigationEndpoint", "browseEndpoint", "browseId").try &.as_s || ""
+ end
+
+ return Playlist.new({
+ title: title,
+ id: plid,
+ author: author,
+ author_thumbnail: author_thumbnail,
+ ucid: ucid,
+ description: description_txt,
+ description_html: description_html,
+ video_count: video_count,
+ views: views,
+ updated: updated,
+ thumbnail: thumbnail,
+ subtitle: subtitle,
+ })
+ end
+
+ def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32, video_id = nil)
+ # Show empty playlist if requested page is out of range
+ # (e.g, when a new playlist has been created, offset will be negative)
+ if offset >= playlist.video_count || offset < 0
+ return [] of PlaylistVideo
+ end
+
+ if playlist.is_a? InvidiousPlaylist
+ Invidious::Database::PlaylistVideos.select(playlist.id, playlist.index, offset, limit: 100)
+ else
+ if video_id
+ initial_data = YoutubeAPI.next({
+ "videoId" => video_id,
+ "playlistId" => playlist.id,
+ })
+ offset = initial_data.dig?("contents", "twoColumnWatchNextResults", "playlist", "playlist", "currentIndex").try &.as_i || offset
+ end
+
+ videos = [] of PlaylistVideo | ProblematicTimelineItem
+
+ until videos.size >= 200 || videos.size == playlist.video_count || offset >= playlist.video_count
+ # 100 videos per request
+ ctoken = produce_playlist_continuation(playlist.id, offset)
+ initial_data = YoutubeAPI.browse(ctoken)
+ videos += extract_playlist_videos(playlist.id, initial_data)
+
+ offset += 100
+ end
+
+ return videos
+ end
+ end
+
+ # TODO (2026-06-24): Migrate this function to use parsers instead, as it uses,
+ # the same LockupViewModel used in Channel videos and Youtube playlists that
+ # appears on searches (Invidious /search endpoint).
+ # Related to https://github.com/iv-org/invidious/pull/5736
+ def extract_playlist_videos(playlist_id : String, initial_data : Hash(String, JSON::Any))
+ videos = [] of PlaylistVideo | ProblematicTimelineItem
+
+ if initial_data["contents"]?
+ tabs = initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]
+ tabs_renderer = tabs.as_a.select(&.["tabRenderer"]["selected"]?.try &.as_bool)[0]["tabRenderer"]
+
+ # Watch out the two versions, with and without "s"
+ if tabs_renderer["contents"]? || tabs_renderer["content"]?
+ # Initial playlist data
+ tabs_contents = tabs_renderer.["contents"]? || tabs_renderer.["content"]
+
+ list_renderer = tabs_contents.["sectionListRenderer"]["contents"][0]
+ contents = list_renderer.["itemSectionRenderer"]["contents"].as_a
+ else
+ # Continuation data
+ contents = initial_data["onResponseReceivedActions"][0]?
+ .try &.["appendContinuationItemsAction"]["continuationItems"].as_a
+ end
+ else
+ contents = initial_data["response"]?.try &.["continuationContents"]["playlistVideoListContinuation"]["contents"].as_a
+ end
+
+ contents.try &.each do |item|
+ if i = item["lockupViewModel"]?
+ thumbnail_view_model = i.dig?(
+ "contentImage", "thumbnailViewModel"
+ )
+
+ watch_endpoint = i.dig?("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint")
+ video_id = watch_endpoint.try &.["videoId"]?.try &.as_s
+ plid = watch_endpoint.try &.["playlistId"]?.try &.as_s || playlist_id
+ index = watch_endpoint.try &.["index"]?.try &.as_i64
+
+ metadata = i["metadata"]?
+ lockup_metadata_view_model = metadata.try &.dig?("lockupMetadataViewModel")
+ title = lockup_metadata_view_model.try &.dig?("title", "content").try &.as_s
+ lockup_metadata = lockup_metadata_view_model.try &.dig?("metadata")
+ metadata_rows = lockup_metadata.try &.dig?("contentMetadataViewModel", "metadataRows").try &.as_a
+
+ # Find the metadataParts with commandRuns inside, which contains author
+ # information.
+ metadata_parts = metadata_rows.try &.find { |row|
+ parts = row["metadataParts"]?.try &.as_a
+ parts && parts.any? { |item2| item2.dig?("text", "commandRuns").try &.as_a }
+ }.try &.["metadataParts"].as_a
+
+ if author_info = metadata_parts.try &.find(&.dig?("text", "commandRuns"))
+ .try &.["text"]
+ author = author_info["content"].as_s
+ ucid = author_info.dig?("commandRuns", 0, "onTap", "innertubeCommand", "browseEndpoint", "browseId")
+ .try &.as_s
+ end
+
+ length = thumbnail_view_model.try &.dig?("overlays", 0, "thumbnailBottomOverlayViewModel", "badges", 0, "thumbnailBadgeViewModel", "text").try &.as_s
+ length_seconds = decode_length_seconds(length) if length
+
+ live = false
+
+ if !length_seconds
+ live = true
+ length_seconds = 0
+ end
+
+ videos << PlaylistVideo.new({
+ title: title || "",
+ id: video_id || "",
+ author: author || "",
+ ucid: ucid || "",
+ length_seconds: length_seconds,
+ published: Time.utc,
+ plid: plid,
+ live_now: live,
+ index: index || -1_i64,
+ })
+ end
+ rescue ex
+ videos << ProblematicTimelineItem.new(parse_exception: ex)
+ end
+
+ return videos
+ end
+
+ def template_playlist(playlist, listen)
+ html = <<-END_HTML
+
+
+
+ END_HTML
+
+ html
+ end
+end
diff --git a/src/invidious/routes/account.cr b/src/invidious/routes/account.cr
index c8db207c2..5028fc8f5 100644
--- a/src/invidious/routes/account.cr
+++ b/src/invidious/routes/account.cr
@@ -21,7 +21,7 @@ module Invidious::Routes::Account
user = user.as(User)
sid = sid.as(String)
- csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY)
+ csrf_token = Invidious::Helpers::Tokens.generate_response(sid, {":change_password"}, HMAC_KEY)
templated "user/change_password"
end
@@ -43,33 +43,33 @@ module Invidious::Routes::Account
token = env.params.body["csrf_token"]?
begin
- validate_request(token, sid, env.request, HMAC_KEY, locale)
+ Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
- return error_template(400, ex)
+ return Errors.error_template(400, ex)
end
password = env.params.body["password"]?
if password.nil? || password.empty?
- return error_template(401, "Password is a required field")
+ return Errors.error_template(401, "Password is a required field")
end
new_passwords = env.params.body.select { |k, _| k.match(/^new_password\[\d+\]$/) }.map { |_, v| v }
if new_passwords.size <= 1 || new_passwords.uniq.size != 1
- return error_template(400, "New passwords must match")
+ return Errors.error_template(400, "New passwords must match")
end
new_password = new_passwords.uniq[0]
if new_password.empty?
- return error_template(401, "Password cannot be empty")
+ return Errors.error_template(401, "Password cannot be empty")
end
if new_password.bytesize > 55
- return error_template(400, "Password cannot be longer than 55 characters")
+ return Errors.error_template(400, "Password cannot be longer than 55 characters")
end
if !Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55))
- return error_template(401, "Incorrect password")
+ return Errors.error_template(401, "Incorrect password")
end
new_password = Crypto::Bcrypt::Password.create(new_password, cost: 10)
@@ -96,7 +96,7 @@ module Invidious::Routes::Account
user = user.as(User)
sid = sid.as(String)
- csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY)
+ csrf_token = Invidious::Helpers::Tokens.generate_response(sid, {":delete_account"}, HMAC_KEY)
templated "user/delete_account"
end
@@ -118,9 +118,9 @@ module Invidious::Routes::Account
token = env.params.body["csrf_token"]?
begin
- validate_request(token, sid, env.request, HMAC_KEY, locale)
+ Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
- return error_template(400, ex)
+ return Errors.error_template(400, ex)
end
view_name = "subscriptions_#{sha256(user.email)}"
@@ -154,7 +154,7 @@ module Invidious::Routes::Account
user = user.as(User)
sid = sid.as(String)
- csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY)
+ csrf_token = Invidious::Helpers::Tokens.generate_response(sid, {":clear_watch_history"}, HMAC_KEY)
templated "user/clear_watch_history"
end
@@ -176,9 +176,9 @@ module Invidious::Routes::Account
token = env.params.body["csrf_token"]?
begin
- validate_request(token, sid, env.request, HMAC_KEY, locale)
+ Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
- return error_template(400, ex)
+ return Errors.error_template(400, ex)
end
Invidious::Database::Users.clear_watch_history(user)
@@ -203,7 +203,7 @@ module Invidious::Routes::Account
user = user.as(User)
sid = sid.as(String)
- csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY)
+ csrf_token = Invidious::Helpers::Tokens.generate_response(sid, {":authorize_token"}, HMAC_KEY)
scopes = env.params.query["scopes"]?.try &.split(",")
scopes ||= [] of String
@@ -235,16 +235,16 @@ module Invidious::Routes::Account
token = env.params.body["csrf_token"]?
begin
- validate_request(token, sid, env.request, HMAC_KEY, locale)
+ Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
- return error_template(400, ex)
+ return Errors.error_template(400, ex)
end
scopes = env.params.body.select { |k, _| k.match(/^scopes\[\d+\]$/) }.map { |_, v| v }
callback_url = env.params.body["callbackUrl"]?
expire = env.params.body["expire"]?.try &.to_i?
- access_token = generate_token(user.email, scopes, expire, HMAC_KEY)
+ access_token = Invidious::Helpers::Tokens.generate_token(user.email, scopes, expire, HMAC_KEY)
if callback_url
access_token = URI.encode_www_form(access_token)
@@ -310,7 +310,7 @@ module Invidious::Routes::Account
if redirect
return env.redirect referer
else
- return error_json(403, "No such user")
+ return Errors.error_json(403, "No such user")
end
end
@@ -319,12 +319,12 @@ module Invidious::Routes::Account
token = env.params.body["csrf_token"]?
begin
- validate_request(token, sid, env.request, HMAC_KEY, locale)
+ Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
if redirect
- return error_template(400, ex)
+ return Errors.error_template(400, ex)
else
- return error_json(400, ex)
+ return Errors.error_json(400, ex)
end
end
@@ -333,7 +333,7 @@ module Invidious::Routes::Account
session = env.params.query["session"]
Invidious::Database::SessionIDs.delete(sid: session, email: user.email)
else
- return error_json(400, "Unsupported action #{action}")
+ return Errors.error_json(400, "Unsupported action #{action}")
end
if redirect
diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr
index da706a9ef..c9497d462 100644
--- a/src/invidious/routes/api/v1/authenticated.cr
+++ b/src/invidious/routes/api/v1/authenticated.cr
@@ -8,7 +8,7 @@ module Invidious::Routes::API::V1::Authenticated
# topics = env.params.body["topics"]?.try &.split(",").uniq.first(1000)
# topics ||= [] of String
- # Helpers.create_notification_stream(env, topics, connection_channel)
+ # Invidious::Helpers.create_notification_stream(env, topics, connection_channel)
# end
def self.get_preferences(env)
@@ -78,13 +78,13 @@ module Invidious::Routes::API::V1::Authenticated
user = env.get("user").as(User)
if !user.preferences.watch_history
- return error_json(409, "Watch history is disabled in preferences.")
+ return Errors.error_json(409, "Watch history is disabled in preferences.")
end
# Sanity checks
id = env.params.url["id"]
unless validate_video_id(id)
- return error_json(400, InvalidVideoID.new(id))
+ return Errors.error_json(400, InvalidVideoID.new(id))
end
Invidious::Database::Users.mark_watched(user, id)
@@ -95,12 +95,12 @@ module Invidious::Routes::API::V1::Authenticated
user = env.get("user").as(User)
if !user.preferences.watch_history
- return error_json(409, "Watch history is disabled in preferences.")
+ return Errors.error_json(409, "Watch history is disabled in preferences.")
end
video_id = env.params.url["id"]
unless video_id && validate_video_id(video_id)
- return error_json(400, InvalidVideoID.new(video_id))
+ return Errors.error_json(400, InvalidVideoID.new(video_id))
end
Invidious::Database::Users.mark_unwatched(user, video_id)
@@ -127,7 +127,7 @@ module Invidious::Routes::API::V1::Authenticated
page = env.params.query["page"]?.try &.to_i?
page ||= 1
- videos, notifications = get_subscription_feed(user, max_results, page)
+ videos, notifications = Invidious::User::Users.get_subscription_feed(user, max_results, page)
JSON.build do |json|
json.object do
@@ -175,7 +175,7 @@ module Invidious::Routes::API::V1::Authenticated
ucid = env.params.url["ucid"]
if !user.subscriptions.includes? ucid
- get_channel(ucid)
+ Invidious::Channels::Channels.get_channel(ucid)
Invidious::Database::Users.subscribe_channel(user, ucid)
end
@@ -214,19 +214,19 @@ module Invidious::Routes::API::V1::Authenticated
title = env.params.json["title"]?.try &.as(String).delete("<>").byte_slice(0, 150)
if !title
- return error_json(400, "Invalid title.")
+ return Errors.error_json(400, "Invalid title.")
end
privacy = env.params.json["privacy"]?.try { |p| PlaylistPrivacy.parse(p.as(String).downcase) }
if !privacy
- return error_json(400, "Invalid privacy setting.")
+ return Errors.error_json(400, "Invalid privacy setting.")
end
if Invidious::Database::Playlists.count_owned_by(user.email) >= 100
- return error_json(400, "User cannot have more than 100 playlists.")
+ return Errors.error_json(400, "User cannot have more than 100 playlists.")
end
- playlist = create_playlist(title, privacy, user)
+ playlist = Invidious::Playlists::Playlists.create_playlist(title, privacy, user)
env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{playlist.id}"
env.response.status_code = 201
{
@@ -241,16 +241,16 @@ module Invidious::Routes::API::V1::Authenticated
plid = env.params.url["plid"]?
if !plid || plid.empty?
- return error_json(400, "A playlist ID is required")
+ return Errors.error_json(400, "A playlist ID is required")
end
playlist = Invidious::Database::Playlists.select(id: plid)
if !playlist || playlist.author != user.email && playlist.privacy.private?
- return error_json(404, "Playlist does not exist.")
+ return Errors.error_json(404, "Playlist does not exist.")
end
if playlist.author != user.email
- return error_json(403, "Invalid user")
+ return Errors.error_json(403, "Invalid user")
end
title = env.params.json["title"].try &.as(String).delete("<>").byte_slice(0, 150) || playlist.title
@@ -278,11 +278,11 @@ module Invidious::Routes::API::V1::Authenticated
playlist = Invidious::Database::Playlists.select(id: plid)
if !playlist || playlist.author != user.email && playlist.privacy.private?
- return error_json(404, "Playlist does not exist.")
+ return Errors.error_json(404, "Playlist does not exist.")
end
if playlist.author != user.email
- return error_json(403, "Invalid user")
+ return Errors.error_json(403, "Invalid user")
end
Invidious::Database::Playlists.delete(plid)
@@ -298,29 +298,29 @@ module Invidious::Routes::API::V1::Authenticated
playlist = Invidious::Database::Playlists.select(id: plid)
if !playlist || playlist.author != user.email && playlist.privacy.private?
- return error_json(404, "Playlist does not exist.")
+ return Errors.error_json(404, "Playlist does not exist.")
end
if playlist.author != user.email
- return error_json(403, "Invalid user")
+ return Errors.error_json(403, "Invalid user")
end
if playlist.index.size >= CONFIG.playlist_length_limit
- return error_json(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos")
+ return Errors.error_json(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos")
end
video_id = env.params.json["videoId"].try &.as(String)
# Sanity checks
unless video_id && validate_video_id(video_id)
- return error_json(400, InvalidVideoID.new(video_id))
+ return Errors.error_json(400, InvalidVideoID.new(video_id))
end
begin
video = get_video(video_id)
rescue ex : NotFoundException
- return error_json(404, ex)
+ return Errors.error_json(404, ex)
rescue ex
- return error_json(500, ex)
+ return Errors.error_json(500, ex)
end
playlist_video = PlaylistVideo.new({
@@ -355,15 +355,15 @@ module Invidious::Routes::API::V1::Authenticated
playlist = Invidious::Database::Playlists.select(id: plid)
if !playlist || playlist.author != user.email && playlist.privacy.private?
- return error_json(404, "Playlist does not exist.")
+ return Errors.error_json(404, "Playlist does not exist.")
end
if playlist.author != user.email
- return error_json(403, "Invalid user")
+ return Errors.error_json(403, "Invalid user")
end
if !playlist.index.includes? index
- return error_json(404, "Playlist does not contain index")
+ return Errors.error_json(404, "Playlist does not contain index")
end
Invidious::Database::PlaylistVideos.delete(index, plid)
@@ -411,7 +411,7 @@ module Invidious::Routes::API::V1::Authenticated
callback_url = env.params.json["callbackUrl"]?.try &.as(String)
expire = env.params.json["expire"]?.try &.as(Int64)
else
- return error_json(400, "Invalid or missing header 'Content-Type'")
+ return Errors.error_json(400, "Invalid or missing header 'Content-Type'")
end
if callback_url && callback_url.empty?
@@ -427,7 +427,7 @@ module Invidious::Routes::API::V1::Authenticated
# Used by template bellow.
# ameba:disable Lint/UselessAssign
- csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, use_nonce: true)
+ csrf_token = Invidious::Helpers::Tokens.generate_response(sid, {":authorize_token"}, HMAC_KEY, use_nonce: true)
return templated "user/authorize_token"
else
env.response.content_type = "application/json"
@@ -436,12 +436,12 @@ module Invidious::Routes::API::V1::Authenticated
authorized_scopes = [] of String
scopes.each do |scope|
- if scopes_include_scope(superset_scopes, scope)
+ if Invidious::Helpers::Tokens.scopes_include_scope(superset_scopes, scope)
authorized_scopes << scope
end
end
- access_token = generate_token(user.email, authorized_scopes, expire, HMAC_KEY)
+ access_token = Invidious::Helpers::Tokens.generate_token(user.email, authorized_scopes, expire, HMAC_KEY)
if callback_url
access_token = URI.encode_www_form(access_token)
@@ -473,10 +473,10 @@ module Invidious::Routes::API::V1::Authenticated
# Allow tokens to revoke other tokens with correct scope
if session == env.get("session").as(String)
Invidious::Database::SessionIDs.delete(sid: session)
- elsif scopes_include_scope(scopes, "GET:tokens")
+ elsif Invidious::Helpers::Tokens.scopes_include_scope(scopes, "GET:tokens")
Invidious::Database::SessionIDs.delete(sid: session)
else
- return error_json(400, "Cannot revoke session #{session}")
+ return Errors.error_json(400, "Cannot revoke session #{session}")
end
env.response.status_code = 204
@@ -489,6 +489,6 @@ module Invidious::Routes::API::V1::Authenticated
topics = raw_topics.try &.split(",").uniq!.first(1000)
topics ||= [] of String
- Helpers.create_notification_stream(env, topics, CONNECTION_CHANNEL)
+ Invidious::Helpers.create_notification_stream(env, topics, CONNECTION_CHANNEL)
end
end
diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr
index 8c21fe19c..00512cb5a 100644
--- a/src/invidious/routes/api/v1/channels.cr
+++ b/src/invidious/routes/api/v1/channels.cr
@@ -3,14 +3,14 @@ module Invidious::Routes::API::V1::Channels
# This sets the `channel` variable, or handles Exceptions.
private macro get_channel
begin
- channel = get_about_info(ucid)
+ channel = Invidious::Channels::About.get_about_info(ucid)
rescue ex : ChannelRedirect
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
- return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
+ return Errors.error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
rescue ex : NotFoundException
- return error_json(404, ex)
+ return Errors.error_json(404, ex)
rescue ex
- return error_json(500, ex)
+ return Errors.error_json(500, ex)
end
end
@@ -29,8 +29,8 @@ module Invidious::Routes::API::V1::Channels
if channel.is_age_gated
begin
- playlist = get_playlist(channel.ucid.sub("UC", "UULF"))
- videos = get_playlist_videos(playlist, offset: 0)
+ playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UULF"))
+ videos = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
videos = [] of PlaylistVideo
@@ -39,7 +39,7 @@ module Invidious::Routes::API::V1::Channels
begin
videos, _ = Channel::Tabs.get_videos(channel, sort_by: sort_by)
rescue ex
- return error_json(500, ex)
+ return Errors.error_json(500, ex)
end
end
@@ -96,7 +96,7 @@ module Invidious::Routes::API::V1::Channels
json.field "autoGenerated", channel.auto_generated
json.field "ageGated", channel.is_age_gated
json.field "isFamilyFriendly", channel.is_family_friendly
- json.field "description", Helpers.html_to_content(channel.description_html)
+ json.field "description", Invidious::Helpers.html_to_content(channel.description_html)
json.field "descriptionHtml", channel.description_html
json.field "allowedRegions", channel.allowed_regions
@@ -117,7 +117,7 @@ module Invidious::Routes::API::V1::Channels
json.array do
# Fetch related channels
begin
- related_channels, _ = fetch_related_channels(channel)
+ related_channels, _ = Invidious::Channels::About.fetch_related_channels(channel)
rescue ex
related_channels = [] of SearchChannel
end
@@ -155,8 +155,8 @@ module Invidious::Routes::API::V1::Channels
if channel.is_age_gated
begin
- playlist = get_playlist(channel.ucid.sub("UC", "UULF"))
- videos = get_playlist_videos(playlist, offset: 0)
+ playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UULF"))
+ videos = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
videos = [] of PlaylistVideo
@@ -168,7 +168,7 @@ module Invidious::Routes::API::V1::Channels
channel, continuation: continuation, sort_by: sort_by
)
rescue ex
- return error_json(500, ex)
+ return Errors.error_json(500, ex)
end
end
@@ -201,8 +201,8 @@ module Invidious::Routes::API::V1::Channels
if channel.is_age_gated
begin
- playlist = get_playlist(channel.ucid.sub("UC", "UUSH"))
- videos = get_playlist_videos(playlist, offset: 0)
+ playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UUSH"))
+ videos = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
videos = [] of PlaylistVideo
@@ -214,7 +214,7 @@ module Invidious::Routes::API::V1::Channels
channel, continuation: continuation, sort_by: sort_by
)
rescue ex
- return error_json(500, ex)
+ return Errors.error_json(500, ex)
end
end
@@ -247,8 +247,8 @@ module Invidious::Routes::API::V1::Channels
if channel.is_age_gated
begin
- playlist = get_playlist(channel.ucid.sub("UC", "UULV"))
- videos = get_playlist_videos(playlist, offset: 0)
+ playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UULV"))
+ videos = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
videos = [] of PlaylistVideo
@@ -260,7 +260,7 @@ module Invidious::Routes::API::V1::Channels
channel, continuation: continuation, sort_by: sort_by
)
rescue ex
- return error_json(500, ex)
+ return Errors.error_json(500, ex)
end
end
@@ -292,7 +292,7 @@ module Invidious::Routes::API::V1::Channels
channel = nil # Make the compiler happy
get_channel()
- items, next_continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by)
+ items, next_continuation = Invidious::Channels::Playlists.fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by)
JSON.build do |json|
json.object do
@@ -321,7 +321,7 @@ module Invidious::Routes::API::V1::Channels
channel = nil # Make the compiler happy
get_channel()
- items, next_continuation = fetch_channel_podcasts(channel.ucid, channel.author, continuation)
+ items, next_continuation = Invidious::Channels::Playlists.fetch_channel_podcasts(channel.ucid, channel.author, continuation)
JSON.build do |json|
json.object do
@@ -350,7 +350,7 @@ module Invidious::Routes::API::V1::Channels
channel = nil # Make the compiler happy
get_channel()
- items, next_continuation = fetch_channel_releases(channel.ucid, channel.author, continuation)
+ items, next_continuation = Invidious::Channels::Playlists.fetch_channel_releases(channel.ucid, channel.author, continuation)
JSON.build do |json|
json.object do
@@ -379,7 +379,7 @@ module Invidious::Routes::API::V1::Channels
channel = nil # Make the compiler happy
get_channel()
- items, next_continuation = fetch_channel_courses(channel.ucid, channel.author, continuation)
+ items, next_continuation = Invidious::Channels::Playlists.fetch_channel_courses(channel.ucid, channel.author, continuation)
JSON.build do |json|
json.object do
@@ -413,9 +413,9 @@ module Invidious::Routes::API::V1::Channels
# sort_by = env.params.query["sort_by"]?.try &.downcase
begin
- fetch_channel_community(ucid, continuation, locale, format, thin_mode)
+ Invidious::Channels::Community.fetch_channel_community(ucid, continuation, locale, format, thin_mode)
rescue ex
- return error_json(500, ex)
+ return Errors.error_json(500, ex)
end
end
@@ -434,16 +434,16 @@ module Invidious::Routes::API::V1::Channels
if ucid.nil?
response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}")
- return error_json(400, "Invalid post ID") if response["error"]?
- ucid = decode_ucid_from_post_protobuf(response.dig("endpoint", "browseEndpoint", "params").as_s)
+ return Errors.error_json(400, "Invalid post ID") if response["error"]?
+ ucid = Invidious::Channels::Community.decode_ucid_from_post_protobuf(response.dig("endpoint", "browseEndpoint", "params").as_s)
else
ucid = ucid.to_s
end
begin
- fetch_channel_community_post(ucid, id, locale, format, thin_mode)
+ Invidious::Channels::Community.fetch_channel_community_post(ucid, id, locale, format, thin_mode)
rescue ex
- return error_json(500, ex)
+ return Errors.error_json(500, ex)
end
end
@@ -487,9 +487,9 @@ module Invidious::Routes::API::V1::Channels
continuation = env.params.query["continuation"]?
begin
- items, next_continuation = fetch_related_channels(channel, continuation)
+ items, next_continuation = Invidious::Channels::About.fetch_related_channels(channel, continuation)
rescue ex
- return error_json(500, ex)
+ return Errors.error_json(500, ex)
end
JSON.build do |json|
@@ -520,7 +520,7 @@ module Invidious::Routes::API::V1::Channels
begin
search_results = query.process
rescue ex
- return error_json(400, ex)
+ return Errors.error_json(400, ex)
end
JSON.build do |json|
diff --git a/src/invidious/routes/api/v1/feeds.cr b/src/invidious/routes/api/v1/feeds.cr
index 46618008a..cbde8abd4 100644
--- a/src/invidious/routes/api/v1/feeds.cr
+++ b/src/invidious/routes/api/v1/feeds.cr
@@ -8,9 +8,9 @@ module Invidious::Routes::API::V1::Feeds
trending_type = env.params.query["type"]?
begin
- trending, _ = fetch_trending(trending_type, region, locale)
+ trending, _ = Invidious::Feeds::Trending.fetch(trending_type, region, locale)
rescue ex
- return error_json(500, ex)
+ return Errors.error_json(500, ex)
end
videos = JSON.build do |json|
diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr
index 4ae877a8b..2a3119435 100644
--- a/src/invidious/routes/api/v1/misc.cr
+++ b/src/invidious/routes/api/v1/misc.cr
@@ -50,16 +50,16 @@ module Invidious::Routes::API::V1::Misc
end
begin
- playlist = get_playlist(plid)
+ playlist = Invidious::Playlists::Playlists.get_playlist(plid)
rescue ex : InfoException
- return error_json(404, ex)
+ return Errors.error_json(404, ex)
rescue ex
- return error_json(404, "Playlist does not exist.")
+ return Errors.error_json(404, "Playlist does not exist.")
end
user = env.get?("user").try &.as(User)
if !playlist || playlist.privacy.private? && playlist.author != user.try &.email
- return error_json(404, "Playlist does not exist.")
+ return Errors.error_json(404, "Playlist does not exist.")
end
# includes into the playlist a maximum of 20 videos, before the offset
@@ -88,7 +88,7 @@ module Invidious::Routes::API::V1::Misc
end
if format == "html"
- playlist_html = template_playlist(json_response, listen)
+ playlist_html = Invidious::Playlists::Playlists.template_playlist(json_response, listen)
index, next_video = json_response["videos"].as_a.skip(1 + lookback).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil}
response = {
@@ -127,7 +127,7 @@ module Invidious::Routes::API::V1::Misc
mix.videos = mix.videos[index..-1]
rescue ex
- return error_json(500, ex)
+ return Errors.error_json(500, ex)
end
response = JSON.build do |json|
@@ -178,14 +178,14 @@ module Invidious::Routes::API::V1::Misc
env.response.content_type = "application/json"
url = env.params.query["url"]?
- return error_json(400, "Missing URL to resolve") if !url
+ return Errors.error_json(400, "Missing URL to resolve") if !url
begin
resolved_url = YoutubeAPI.resolve_url(url.as(String))
endpoint = resolved_url["endpoint"]
page_type = endpoint.dig?("commandMetadata", "webCommandMetadata", "webPageType").try &.as_s || ""
if page_type == "WEB_PAGE_TYPE_UNKNOWN"
- return error_json(400, "Unknown url")
+ return Errors.error_json(400, "Unknown url")
end
sub_endpoint = endpoint["watchEndpoint"]? || endpoint["browseEndpoint"]? || endpoint
@@ -204,7 +204,7 @@ module Invidious::Routes::API::V1::Misc
post_id = nil
end
rescue ex
- return error_json(500, ex)
+ return Errors.error_json(500, ex)
end
JSON.build do |json|
json.object do
diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr
index 59a30745e..14b19c837 100644
--- a/src/invidious/routes/api/v1/search.cr
+++ b/src/invidious/routes/api/v1/search.cr
@@ -10,7 +10,7 @@ module Invidious::Routes::API::V1::Search
begin
search_results = query.process
rescue ex
- return error_json(400, ex)
+ return Errors.error_json(400, ex)
end
JSON.build do |json|
@@ -53,7 +53,7 @@ module Invidious::Routes::API::V1::Search
end
end
rescue ex
- return error_json(500, ex)
+ return Errors.error_json(500, ex)
end
end
@@ -67,9 +67,9 @@ module Invidious::Routes::API::V1::Search
env.response.content_type = "application/json"
begin
- results = Invidious::Hashtag.fetch(hashtag, page, region)
+ results = Invidious::Search::Hashtag.fetch_hashtag(hashtag, page, region)
rescue ex
- return error_json(400, ex)
+ return Errors.error_json(400, ex)
end
JSON.build do |json|
diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr
index 14221ed03..da55e39e1 100644
--- a/src/invidious/routes/api/v1/videos.cr
+++ b/src/invidious/routes/api/v1/videos.cr
@@ -16,9 +16,9 @@ module Invidious::Routes::API::V1::Videos
begin
video = get_video(id, region: region)
rescue ex : NotFoundException
- return error_json(404, ex)
+ return Errors.error_json(404, ex)
rescue ex
- return error_json(500, ex)
+ return Errors.error_json(500, ex)
end
return JSON.build do |json|
@@ -34,7 +34,7 @@ module Invidious::Routes::API::V1::Videos
# Sanity checks
unless validate_video_id(id)
- return error_json(400, InvalidVideoID.new(id))
+ return Errors.error_json(400, InvalidVideoID.new(id))
end
# See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354
@@ -301,7 +301,7 @@ module Invidious::Routes::API::V1::Videos
annotations = response.body
- Helpers.cache_annotation(video_id, annotations)
+ Invidious::Helpers.cache_annotation(video_id, annotations)
end
else # "youtube"
response = YT_POOL.client &.get("/annotations_invideo?video_id=#{video_id}")
@@ -354,9 +354,9 @@ module Invidious::Routes::API::V1::Videos
begin
comments = Comments.fetch_youtube(id, continuation, format, locale, thin_mode, region, sort_by: sort_by)
rescue ex : NotFoundException
- return error_json(404, ex)
+ return Errors.error_json(404, ex)
rescue ex
- return error_json(500, ex)
+ return Errors.error_json(500, ex)
end
return comments
@@ -371,7 +371,7 @@ module Invidious::Routes::API::V1::Videos
end
if !reddit_thread || !comments
- return error_json(404, "No reddit threads found")
+ return Errors.error_json(404, "No reddit threads found")
end
if format == "json"
@@ -404,10 +404,10 @@ module Invidious::Routes::API::V1::Videos
proxy = {"1", "true"}.any? &.== env.params.query["local"]?
response = YoutubeAPI.resolve_url("https://www.youtube.com/clip/#{clip_id}")
- return error_json(400, "Invalid clip ID") if response["error"]?
+ return Errors.error_json(400, "Invalid clip ID") if response["error"]?
video_id = response.dig?("endpoint", "watchEndpoint", "videoId").try &.as_s
- return error_json(400, "Invalid clip ID") if video_id.nil?
+ return Errors.error_json(400, "Invalid clip ID") if video_id.nil?
start_time = nil
end_time = nil
@@ -420,9 +420,9 @@ module Invidious::Routes::API::V1::Videos
begin
video = get_video(video_id, region: region)
rescue ex : NotFoundException
- return error_json(404, ex)
+ return Errors.error_json(404, ex)
rescue ex
- return error_json(500, ex)
+ return Errors.error_json(500, ex)
end
return JSON.build do |json|
@@ -454,9 +454,9 @@ module Invidious::Routes::API::V1::Videos
begin
video = get_video(id)
rescue ex : NotFoundException
- return error_json(404, ex)
+ return Errors.error_json(404, ex)
rescue ex
- return error_json(500, ex)
+ return Errors.error_json(500, ex)
end
response = JSON.build do |json|
@@ -494,14 +494,14 @@ module Invidious::Routes::API::V1::Videos
begin
video = get_video(id)
rescue ex : NotFoundException
- return error_json(404, ex)
+ return Errors.error_json(404, ex)
rescue ex
- return error_json(500, ex)
+ return Errors.error_json(500, ex)
end
target_transcript = video.captions.select(&.name.== label)
if target_transcript.empty?
- return error_json(404, NotFoundException.new("Requested transcript does not exist"))
+ return Errors.error_json(404, NotFoundException.new("Requested transcript does not exist"))
else
target_transcript = target_transcript[0]
lang, auto_generated = target_transcript.language_code, target_transcript.auto_generated
@@ -515,9 +515,9 @@ module Invidious::Routes::API::V1::Videos
YoutubeAPI.get_transcript(params), lang, auto_generated
)
rescue ex : NotFoundException
- return error_json(404, ex)
+ return Errors.error_json(404, ex)
rescue ex
- return error_json(500, ex)
+ return Errors.error_json(500, ex)
end
return transcript.to_json
diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr
index b75574b69..f8a4869e0 100644
--- a/src/invidious/routes/before_all.cr
+++ b/src/invidious/routes/before_all.cr
@@ -1,4 +1,6 @@
module Invidious::Routes::BeforeAll
+ extend self
+
struct CompanionCSP
property companion_urls : String = ""
@@ -89,7 +91,7 @@ module Invidious::Routes::BeforeAll
if email = Database::SessionIDs.select_email(sid)
user = Database::Users.select!(email: email)
- csrf_token = generate_response(sid, {
+ csrf_token = Invidious::Helpers::Tokens.generate_response(sid, {
":authorize_token",
":playlist_ajax",
":signout",
@@ -107,7 +109,7 @@ module Invidious::Routes::BeforeAll
end
end
- dark_mode = convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s
+ dark_mode = Invidious::User::Converters.convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s
thin_mode = env.params.query["thin_mode"]?
thin_mode = (thin_mode == "true") || preferences.thin_mode
locale = env.params.query["hl"]? || preferences.locale
diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr
index ff8b0f270..a77a66aed 100644
--- a/src/invidious/routes/channels.cr
+++ b/src/invidious/routes/channels.cr
@@ -23,7 +23,7 @@ module Invidious::Routes::Channels
sort_by ||= "last"
sort_options = {"last", "oldest", "newest"}
- items, next_continuation = fetch_channel_playlists(
+ items, next_continuation = Invidious::Channels::Playlists.fetch_channel_playlists(
channel.ucid, channel.author, continuation, sort_by
)
@@ -42,8 +42,8 @@ module Invidious::Routes::Channels
sort_by = ""
sort_options = [] of String
begin
- playlist = get_playlist(channel.ucid.sub("UC", "UULF"))
- items = get_playlist_videos(playlist, offset: 0)
+ playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UULF"))
+ items = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
items = [] of PlaylistVideo
@@ -77,8 +77,8 @@ module Invidious::Routes::Channels
sort_by = ""
sort_options = [] of String
begin
- playlist = get_playlist(channel.ucid.sub("UC", "UUSH"))
- items = get_playlist_videos(playlist, offset: 0)
+ playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UUSH"))
+ items = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
items = [] of PlaylistVideo
@@ -112,8 +112,8 @@ module Invidious::Routes::Channels
sort_by = ""
sort_options = [] of String
begin
- playlist = get_playlist(channel.ucid.sub("UC", "UULV"))
- items = get_playlist_videos(playlist, offset: 0)
+ playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UULV"))
+ items = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
items = [] of PlaylistVideo
@@ -146,7 +146,7 @@ module Invidious::Routes::Channels
return env.redirect "/channel/#{channel.ucid}"
end
- items, next_continuation = fetch_channel_playlists(
+ items, next_continuation = Invidious::Channels::Playlists.fetch_channel_playlists(
channel.ucid, channel.author, continuation, (sort_by || "last")
)
@@ -166,7 +166,7 @@ module Invidious::Routes::Channels
sort_by = ""
sort_options = [] of String
- items, next_continuation = fetch_channel_podcasts(
+ items, next_continuation = Invidious::Channels::Playlists.fetch_channel_podcasts(
channel.ucid, channel.author, continuation
)
@@ -186,7 +186,7 @@ module Invidious::Routes::Channels
sort_by = ""
sort_options = [] of String
- items, next_continuation = fetch_channel_releases(
+ items, next_continuation = Invidious::Channels::Playlists.fetch_channel_releases(
channel.ucid, channel.author, continuation
)
@@ -206,7 +206,7 @@ module Invidious::Routes::Channels
sort_by = ""
sort_options = [] of String
- items, next_continuation = fetch_channel_courses(
+ items, next_continuation = Invidious::Channels::Playlists.fetch_channel_courses(
channel.ucid, channel.author, continuation
)
@@ -247,7 +247,7 @@ module Invidious::Routes::Channels
sort_options = [] of String
begin
- items = JSON.parse(fetch_channel_community(ucid, continuation, locale, "json", thin_mode))
+ items = JSON.parse(Invidious::Channels::Community.fetch_channel_community(ucid, continuation, locale, "json", thin_mode))
rescue ex : InfoException
env.response.status_code = 500
error_message = ex.message
@@ -255,7 +255,7 @@ module Invidious::Routes::Channels
env.response.status_code = 404
error_message = ex.message
rescue ex
- return error_template(500, ex)
+ return Errors.error_template(500, ex)
end
templated "community"
@@ -280,14 +280,14 @@ module Invidious::Routes::Channels
if !ucid.nil?
ucid = ucid.to_s
- post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode)
+ post_response = Invidious::Channels::Community.fetch_channel_community_post(ucid, id, locale, "json", thin_mode)
else
# resolve the url to get the author's UCID
response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}")
- return error_template(400, "Invalid post ID") if response["error"]?
+ return Errors.error_template(400, "Invalid post ID") if response["error"]?
- ucid = decode_ucid_from_post_protobuf(response.dig("endpoint", "browseEndpoint", "params").as_s)
- post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode)
+ ucid = Invidious::Channels::Community.decode_ucid_from_post_protobuf(response.dig("endpoint", "browseEndpoint", "params").as_s)
+ post_response = Invidious::Channels::Community.fetch_channel_community_post(ucid, id, locale, "json", thin_mode)
end
post_response = JSON.parse(post_response)
@@ -309,7 +309,7 @@ module Invidious::Routes::Channels
return env.redirect "/channel/#{channel.ucid}"
end
- items, next_continuation = fetch_related_channels(channel, continuation)
+ items, next_continuation = Invidious::Channels::About.fetch_related_channels(channel, continuation)
# Featured/related channels can't be sorted
sort_options = [] of String
@@ -354,7 +354,7 @@ module Invidious::Routes::Channels
resolved_url = YoutubeAPI.resolve_url("https://www.youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}")
ucid = resolved_url["endpoint"]["browseEndpoint"]["browseId"]
rescue ex : InfoException | KeyError
- return error_template(404, I18n.translate(locale, "This channel does not exist."))
+ return Errors.error_template(404, I18n.translate(locale, "This channel does not exist."))
end
selected_tab = env.params.url["tab"]?
@@ -380,7 +380,7 @@ module Invidious::Routes::Channels
user = env.params.query["user"]?
if !user
- return error_template(404, "This channel does not exist.")
+ return Errors.error_template(404, "This channel does not exist.")
else
env.redirect "/user/#{user}#{uri_params}"
end
@@ -433,13 +433,13 @@ module Invidious::Routes::Channels
continuation = env.params.query["continuation"]?
begin
- channel = get_about_info(ucid)
+ channel = Invidious::Channels::About.get_about_info(ucid)
rescue ex : ChannelRedirect
return env.redirect env.request.resource.gsub(ucid, ex.channel_id)
rescue ex : NotFoundException
- return error_template(404, ex)
+ return Errors.error_template(404, ex)
rescue ex
- return error_template(500, ex)
+ return Errors.error_template(500, ex)
end
env.set "search", "channel:#{ucid} "
diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr
index 41dc517c2..d12fc7140 100644
--- a/src/invidious/routes/embed.cr
+++ b/src/invidious/routes/embed.cr
@@ -5,9 +5,9 @@ module Invidious::Routes::Embed
locale = env.get("preferences").as(Preferences).locale
if plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
begin
- playlist = get_playlist(plid)
+ playlist = Invidious::Playlists::Playlists.get_playlist(plid)
offset = env.params.query["index"]?.try &.to_i? || 0
- videos = get_playlist_videos(playlist, offset: offset)
+ videos = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: offset)
if videos.empty?
url = "/playlist?list=#{plid}"
raise NotFoundException.new(I18n.translate(locale, "error_video_not_in_playlist", url))
@@ -15,9 +15,9 @@ module Invidious::Routes::Embed
first_playlist_video = videos[0].as(PlaylistVideo)
rescue ex : NotFoundException
- return error_template(404, ex)
+ return Errors.error_template(404, ex)
rescue ex
- return error_template(500, ex)
+ return Errors.error_template(500, ex)
end
url = "/embed/#{first_playlist_video.id}?#{env.params.query}"
@@ -66,9 +66,9 @@ module Invidious::Routes::Embed
if plid
begin
- playlist = get_playlist(plid)
+ playlist = Invidious::Playlists::Playlists.get_playlist(plid)
offset = env.params.query["index"]?.try &.to_i? || 0
- videos = get_playlist_videos(playlist, offset: offset)
+ videos = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: offset)
if videos.empty?
url = "/playlist?list=#{plid}"
raise NotFoundException.new(I18n.translate(locale, "error_video_not_in_playlist", url))
@@ -76,9 +76,9 @@ module Invidious::Routes::Embed
first_playlist_video = videos[0].as(PlaylistVideo)
rescue ex : NotFoundException
- return error_template(404, ex)
+ return Errors.error_template(404, ex)
rescue ex
- return error_template(500, ex)
+ return Errors.error_template(500, ex)
end
url = "/embed/#{first_playlist_video.id}"
@@ -101,7 +101,7 @@ module Invidious::Routes::Embed
env.params.query.delete_all("channel")
if !video_id || video_id == "live_stream"
- return error_template(500, "Video is unavailable.")
+ return Errors.error_template(500, "Video is unavailable.")
end
url = "/embed/#{video_id}"
@@ -135,9 +135,9 @@ module Invidious::Routes::Embed
begin
video = get_video(id, region: params.region)
rescue ex : NotFoundException
- return error_template(404, ex)
+ return Errors.error_template(404, ex)
rescue ex
- return error_template(500, ex)
+ return Errors.error_template(500, ex)
end
if preferences.annotations_subscribed &&
diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr
index ebb5f04b1..5fa32a973 100644
--- a/src/invidious/routes/feeds.cr
+++ b/src/invidious/routes/feeds.cr
@@ -53,9 +53,9 @@ module Invidious::Routes::Feeds
region ||= preferences.region
begin
- trending, plid = fetch_trending(trending_type, region, locale)
+ trending, plid = Invidious::Feeds::Trending.fetch(trending_type, region, locale)
rescue ex
- return error_template(500, ex)
+ return Errors.error_template(500, ex)
end
templated "feeds/trending"
@@ -91,7 +91,7 @@ module Invidious::Routes::Feeds
page = env.params.query["page"]?.try &.to_i?
page ||= 1
- videos, notifications = get_subscription_feed(user, max_results, page)
+ videos, notifications = Invidious::User::Users.get_subscription_feed(user, max_results, page)
if CONFIG.enable_user_notifications
# "updated" here is used for delivering new notifications, so if
@@ -150,7 +150,7 @@ module Invidious::Routes::Feeds
if env.params.url["ucid"].matches?(/^[\w-]+$/)
ucid = env.params.url["ucid"]
else
- return error_atom(400, InfoException.new("Invalid channel ucid provided."))
+ return Errors.error_atom(400, InfoException.new("Invalid channel ucid provided."))
end
params = HTTP::Params.parse(env.params.query["params"]? || "")
@@ -162,7 +162,7 @@ module Invidious::Routes::Feeds
}
response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}")
- return error_atom(404, NotFoundException.new("Channel does not exist.")) if response.status_code == 404
+ return Errors.error_atom(404, NotFoundException.new("Channel does not exist.")) if response.status_code == 404
rss = XML.parse(response.body)
videos = rss.xpath_nodes("//default:feed/default:entry", namespaces).map do |entry|
@@ -250,7 +250,7 @@ module Invidious::Routes::Feeds
params = HTTP::Params.parse(env.params.query["params"]? || "")
- videos, notifications = get_subscription_feed(user, max_results, page)
+ videos, notifications = Invidious::User::Users.get_subscription_feed(user, max_results, page)
XML.build(indent: " ", encoding: "UTF-8") do |xml|
xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015",
@@ -281,11 +281,11 @@ module Invidious::Routes::Feeds
if plid.starts_with? "IV"
if playlist = Invidious::Database::Playlists.select(id: plid)
- videos = get_playlist_videos(playlist, offset: 0)
+ videos = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
user = env.get?("user").try &.as(User)
if !playlist || playlist.privacy.private? && playlist.author != user.try &.email
- return error_atom(404, "Playlist does not exist.")
+ return Errors.error_atom(404, "Playlist does not exist.")
end
return XML.build(indent: " ", encoding: "UTF-8") do |xml|
@@ -317,7 +317,7 @@ module Invidious::Routes::Feeds
end
response = YT_POOL.client &.get("/feeds/videos.xml?playlist_id=#{plid}")
- return error_atom(404, NotFoundException.new("Playlist does not exist.")) if response.status_code == 404
+ return Errors.error_atom(404, NotFoundException.new("Playlist does not exist.")) if response.status_code == 404
document = XML.parse(response.body)
document.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node|
diff --git a/src/invidious/routes/images.cr b/src/invidious/routes/images.cr
index d76bcc582..432bccd8d 100644
--- a/src/invidious/routes/images.cr
+++ b/src/invidious/routes/images.cr
@@ -106,7 +106,7 @@ module Invidious::Routes::Images
break
end
- Helpers.proxy_file(response, env)
+ Invidious::Helpers.proxy_file(response, env)
end
rescue ex
end
@@ -158,6 +158,6 @@ module Invidious::Routes::Images
return env.response.headers.delete("Transfer-Encoding")
end
- return Helpers.proxy_file(response, env)
+ return Invidious::Helpers.proxy_file(response, env)
end
end
diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr
index 7d7da4879..ea6e50da5 100644
--- a/src/invidious/routes/login.cr
+++ b/src/invidious/routes/login.cr
@@ -11,7 +11,7 @@ module Invidious::Routes::Login
return env.redirect referer if user
if !CONFIG.login_enabled
- return error_template(400, "Login has been disabled by administrator.")
+ return Errors.error_template(400, "Login has been disabled by administrator.")
end
email = nil
@@ -31,7 +31,7 @@ module Invidious::Routes::Login
referer = get_referer(env, "/feed/subscriptions")
if !CONFIG.login_enabled
- return error_template(403, "Login has been disabled by administrator.")
+ return Errors.error_template(403, "Login has been disabled by administrator.")
end
# https://stackoverflow.com/a/574698
@@ -44,11 +44,11 @@ module Invidious::Routes::Login
case account_type
when "invidious"
if email.nil? || email.empty?
- return error_template(401, "User ID is a required field")
+ return Errors.error_template(401, "User ID is a required field")
end
if password.nil? || password.empty?
- return error_template(401, "Password is a required field")
+ return Errors.error_template(401, "Password is a required field")
end
user = Invidious::Database::Users.select(email: email)
@@ -64,7 +64,7 @@ module Invidious::Routes::Login
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
end
else
- return error_template(401, "Wrong username or password")
+ return Errors.error_template(401, "Wrong username or password")
end
# Since this user has already registered, we don't want to overwrite their preferences
@@ -75,16 +75,16 @@ module Invidious::Routes::Login
end
else
if !CONFIG.registration_enabled
- return error_template(400, "Registration has been disabled by administrator.")
+ return Errors.error_template(400, "Registration has been disabled by administrator.")
end
if password.empty?
- return error_template(401, "Password cannot be empty")
+ return Errors.error_template(401, "Password cannot be empty")
end
# See https://security.stackexchange.com/a/39851
if password.bytesize > 55
- return error_template(400, "Password cannot be longer than 55 characters")
+ return Errors.error_template(400, "Password cannot be longer than 55 characters")
end
password = password.byte_slice(0, 55)
@@ -102,11 +102,11 @@ module Invidious::Routes::Login
answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer)
begin
- validate_request(tokens[0], answer, env.request, HMAC_KEY, locale)
+ Invidious::Helpers::Tokens.validate_request(tokens[0], answer, env.request, HMAC_KEY, locale)
rescue ex : InfoException
- return error_template(400, InfoException.new("Erroneous CAPTCHA"))
+ return Errors.error_template(400, InfoException.new("Erroneous CAPTCHA"))
rescue ex
- return error_template(400, ex)
+ return Errors.error_template(400, ex)
end
else
return templated "user/login"
@@ -114,7 +114,7 @@ module Invidious::Routes::Login
end
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
- user, sid = create_user(sid, email, password)
+ user, sid = Invidious::User::Users.create_user(sid, email, password)
if language_header = env.request.headers["Accept-Language"]?
if language = ANG.language_negotiator.best(language_header, I18n::LOCALES.keys)
@@ -126,7 +126,7 @@ module Invidious::Routes::Login
Invidious::Database::SessionIDs.insert(sid, email)
view_name = "subscriptions_#{sha256(user.email)}"
- PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
+ PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{Invidious::User::Users::MATERIALIZED_VIEW_SQL.call(user.email)}")
if alt = CONFIG.alternative_domains.index(host)
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid)
@@ -166,9 +166,9 @@ module Invidious::Routes::Login
token = env.params.body["csrf_token"]?
begin
- validate_request(token, sid, env.request, HMAC_KEY, locale)
+ Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
- return error_template(400, ex)
+ return Errors.error_template(400, ex)
end
Invidious::Database::SessionIDs.delete(sid: sid)
diff --git a/src/invidious/routes/notifications.cr b/src/invidious/routes/notifications.cr
index 8922b7405..b5e6b4ab5 100644
--- a/src/invidious/routes/notifications.cr
+++ b/src/invidious/routes/notifications.cr
@@ -18,7 +18,7 @@ module Invidious::Routes::Notifications
if redirect
return env.redirect referer
else
- return error_json(403, "No such user")
+ return Errors.error_json(403, "No such user")
end
end
diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr
index 3a633d507..aec61f83d 100644
--- a/src/invidious/routes/playlists.cr
+++ b/src/invidious/routes/playlists.cr
@@ -12,7 +12,7 @@ module Invidious::Routes::Playlists
user = user.as(User)
sid = sid.as(String)
- csrf_token = generate_response(sid, {":create_playlist"}, HMAC_KEY)
+ csrf_token = Invidious::Helpers::Tokens.generate_response(sid, {":create_playlist"}, HMAC_KEY)
templated "create_playlist"
end
@@ -31,26 +31,26 @@ module Invidious::Routes::Playlists
token = env.params.body["csrf_token"]?
begin
- validate_request(token, sid, env.request, HMAC_KEY, locale)
+ Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
- return error_template(400, ex)
+ return Errors.error_template(400, ex)
end
title = env.params.body["title"]?.try &.as(String)
if !title || title.empty?
- return error_template(400, "Title cannot be empty.")
+ return Errors.error_template(400, "Title cannot be empty.")
end
privacy = PlaylistPrivacy.parse?(env.params.body["privacy"]?.try &.as(String) || "")
if !privacy
- return error_template(400, "Invalid privacy setting.")
+ return Errors.error_template(400, "Invalid privacy setting.")
end
if Invidious::Database::Playlists.count_owned_by(user.email) >= 100
- return error_template(400, "User cannot have more than 100 playlists.")
+ return Errors.error_template(400, "User cannot have more than 100 playlists.")
end
- playlist = create_playlist(title, privacy, user)
+ playlist = Invidious::Playlists::Playlists.create_playlist(title, privacy, user)
env.redirect "/playlist?list=#{playlist.id}"
end
@@ -67,13 +67,13 @@ module Invidious::Routes::Playlists
playlist_id = env.params.query["list"]
begin
- playlist = get_playlist(playlist_id)
+ playlist = Invidious::Playlists::Playlists.get_playlist(playlist_id)
rescue ex : NotFoundException
- return error_template(404, ex)
+ return Errors.error_template(404, ex)
rescue ex
- return error_template(500, ex)
+ return Errors.error_template(500, ex)
end
- subscribe_playlist(user, playlist)
+ Invidious::Playlists::Playlists.subscribe_playlist(user, playlist)
env.redirect "/playlist?list=#{playlist.id}"
end
@@ -92,7 +92,7 @@ module Invidious::Routes::Playlists
plid = env.params.query["list"]?
if !plid || plid.empty?
- return error_template(400, "A playlist ID is required")
+ return Errors.error_template(400, "A playlist ID is required")
end
playlist = Invidious::Database::Playlists.select(id: plid)
@@ -100,7 +100,7 @@ module Invidious::Routes::Playlists
return env.redirect referer
end
- csrf_token = generate_response(sid, {":delete_playlist"}, HMAC_KEY)
+ csrf_token = Invidious::Helpers::Tokens.generate_response(sid, {":delete_playlist"}, HMAC_KEY)
templated "delete_playlist"
end
@@ -122,9 +122,9 @@ module Invidious::Routes::Playlists
token = env.params.body["csrf_token"]?
begin
- validate_request(token, sid, env.request, HMAC_KEY, locale)
+ Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
- return error_template(400, ex)
+ return Errors.error_template(400, ex)
end
playlist = Invidious::Database::Playlists.select(id: plid)
@@ -163,12 +163,12 @@ module Invidious::Routes::Playlists
end
begin
- items = get_playlist_videos(playlist, offset: (page - 1) * 100)
+ items = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: (page - 1) * 100)
rescue ex
items = [] of PlaylistVideo
end
- csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY)
+ csrf_token = Invidious::Helpers::Tokens.generate_response(sid, {":edit_playlist"}, HMAC_KEY)
# Pagination
page_nav_html = Frontend::Pagination.nav_numeric(locale,
@@ -197,9 +197,9 @@ module Invidious::Routes::Playlists
token = env.params.body["csrf_token"]?
begin
- validate_request(token, sid, env.request, HMAC_KEY, locale)
+ Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
- return error_template(400, ex)
+ return Errors.error_template(400, ex)
end
playlist = Invidious::Database::Playlists.select(id: plid)
@@ -286,7 +286,7 @@ module Invidious::Routes::Playlists
if redirect
return env.redirect referer
else
- return error_json(403, "No such user")
+ return Errors.error_json(403, "No such user")
end
end
@@ -295,26 +295,26 @@ module Invidious::Routes::Playlists
token = env.params.body["csrf_token"]?
begin
- validate_request(token, sid, env.request, HMAC_KEY, locale)
+ Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
if redirect
- return error_template(400, ex)
+ return Errors.error_template(400, ex)
else
- return error_json(400, ex)
+ return Errors.error_json(400, ex)
end
end
begin
playlist_id = env.params.query["playlist_id"]
- playlist = get_playlist(playlist_id).as(InvidiousPlaylist)
+ playlist = Invidious::Playlists::Playlists.get_playlist(playlist_id).as(InvidiousPlaylist)
raise "Invalid user" if playlist.author != user.email
rescue ex : NotFoundException
- return error_json(404, ex)
+ return Errors.error_json(404, ex)
rescue ex
if redirect
- return error_template(400, ex)
+ return Errors.error_template(400, ex)
else
- return error_json(400, ex)
+ return Errors.error_json(400, ex)
end
end
@@ -322,9 +322,9 @@ module Invidious::Routes::Playlists
when "add_video"
if playlist.index.size >= CONFIG.playlist_length_limit
if redirect
- return error_template(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos")
+ return Errors.error_template(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos")
else
- return error_json(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos")
+ return Errors.error_json(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos")
end
end
@@ -333,12 +333,12 @@ module Invidious::Routes::Playlists
begin
video = get_video(video_id)
rescue ex : NotFoundException
- return error_json(404, ex)
+ return Errors.error_json(404, ex)
rescue ex
if redirect
- return error_template(500, ex)
+ return Errors.error_template(500, ex)
else
- return error_json(500, ex)
+ return Errors.error_json(500, ex)
end
end
@@ -359,7 +359,7 @@ module Invidious::Routes::Playlists
when "remove_video"
index = env.params.query["set_video_id"].to_i64?
if index.nil? || !playlist.index.includes? index
- return error_json(404, "Playlist does not contain index")
+ return Errors.error_json(404, "Playlist does not contain index")
end
Invidious::Database::PlaylistVideos.delete(index, playlist_id)
@@ -367,9 +367,9 @@ module Invidious::Routes::Playlists
when "move_video_before"
# TODO: Playlist stub
when nil
- return error_json(400, "Missing action")
+ return Errors.error_json(400, "Missing action")
else
- return error_json(400, "Unsupported action #{action}")
+ return Errors.error_json(400, "Unsupported action #{action}")
end
if redirect
@@ -399,11 +399,11 @@ module Invidious::Routes::Playlists
end
begin
- playlist = get_playlist(plid)
+ playlist = Invidious::Playlists::Playlists.get_playlist(plid)
rescue ex : NotFoundException
- return error_template(404, ex)
+ return Errors.error_template(404, ex)
rescue ex
- return error_template(500, ex)
+ return Errors.error_template(500, ex)
end
if playlist.is_a? InvidiousPlaylist
@@ -419,17 +419,17 @@ module Invidious::Routes::Playlists
end
if playlist.privacy == PlaylistPrivacy::Private && playlist.author != user.try &.email
- return error_template(403, "This playlist is private.")
+ return Errors.error_template(403, "This playlist is private.")
end
begin
if playlist.is_a? InvidiousPlaylist
- items = get_playlist_videos(playlist, offset: (page - 1) * 100)
+ items = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: (page - 1) * 100)
else
- items = get_playlist_videos(playlist, offset: (page - 1) * 200)
+ items = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: (page - 1) * 200)
end
rescue ex
- return error_template(500, "Error encountered while retrieving playlist videos.
#{ex.message}")
+ return Errors.error_template(500, "Error encountered while retrieving playlist videos.
#{ex.message}")
end
if playlist.author == user.try &.email
@@ -460,7 +460,7 @@ module Invidious::Routes::Playlists
begin
mix = fetch_mix(rdid, continuation, locale: locale)
rescue ex
- return error_template(500, ex)
+ return Errors.error_template(500, ex)
end
templated "mix"
diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr
index 77ea02e91..ef0c4f4e3 100644
--- a/src/invidious/routes/preferences.cr
+++ b/src/invidious/routes/preferences.cr
@@ -329,7 +329,7 @@ module Invidious::Routes::PreferencesRoute
if !success
haltf(env, status_code: 415,
- response: error_template(415, "Invalid subscription file uploaded")
+ response: Errors.error_template(415, "Invalid subscription file uploaded")
)
end
when "import_youtube_pl"
@@ -338,7 +338,7 @@ module Invidious::Routes::PreferencesRoute
if !success
haltf(env, status_code: 415,
- response: error_template(415, "Invalid playlist file uploaded")
+ response: Errors.error_template(415, "Invalid playlist file uploaded")
)
end
when "import_youtube_wh"
@@ -347,7 +347,7 @@ module Invidious::Routes::PreferencesRoute
if !success
haltf(env, status_code: 415,
- response: error_template(415, "Invalid watch history file uploaded")
+ response: Errors.error_template(415, "Invalid watch history file uploaded")
)
end
when "import_freetube"
@@ -359,7 +359,7 @@ module Invidious::Routes::PreferencesRoute
if !success
haltf(env, status_code: 415,
- response: error_template(415, "Uploaded file is too large")
+ response: Errors.error_template(415, "Uploaded file is too large")
)
end
else nil # Ignore
diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr
index 968a5554d..022a348af 100644
--- a/src/invidious/routes/search.cr
+++ b/src/invidious/routes/search.cr
@@ -71,9 +71,9 @@ module Invidious::Routes::Search
items = query.process
end
rescue ex : ChannelSearchException
- return error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It should look like 'UC4QobU6STFB0P71PMvOGN5A'.")
+ return Errors.error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It should look like 'UC4QobU6STFB0P71PMvOGN5A'.")
rescue ex
- return error_template(500, ex)
+ return Errors.error_template(500, ex)
end
redirect_url = Invidious::Frontend::Misc.redirect_url(env)
@@ -100,7 +100,7 @@ module Invidious::Routes::Search
hashtag = env.params.url["hashtag"]?
if hashtag.nil? || hashtag.empty?
- return error_template(400, "Invalid request")
+ return Errors.error_template(400, "Invalid request")
end
page = env.params.query["page"]?
@@ -112,9 +112,9 @@ module Invidious::Routes::Search
end
begin
- items = Invidious::Hashtag.fetch(hashtag, page)
+ items = Invidious::Search::Hashtag.fetch_hashtag(hashtag, page)
rescue ex
- return error_template(500, ex)
+ return Errors.error_template(500, ex)
end
# Pagination
diff --git a/src/invidious/routes/subscriptions.cr b/src/invidious/routes/subscriptions.cr
index 1de655d20..6c7422116 100644
--- a/src/invidious/routes/subscriptions.cr
+++ b/src/invidious/routes/subscriptions.cr
@@ -14,7 +14,7 @@ module Invidious::Routes::Subscriptions
if redirect
return env.redirect referer
else
- return error_json(403, "No such user")
+ return Errors.error_json(403, "No such user")
end
end
@@ -23,12 +23,12 @@ module Invidious::Routes::Subscriptions
token = env.params.body["csrf_token"]?
begin
- validate_request(token, sid, env.request, HMAC_KEY, locale)
+ Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
if redirect
- return error_template(400, ex)
+ return Errors.error_template(400, ex)
else
- return error_json(400, ex)
+ return Errors.error_json(400, ex)
end
end
@@ -38,13 +38,13 @@ module Invidious::Routes::Subscriptions
case action = env.params.query["action"]?
when "create_subscription_to_channel"
if !user.subscriptions.includes? channel_id
- get_channel(channel_id)
+ Invidious::Channels::Channels.get_channel(channel_id)
Invidious::Database::Users.subscribe_channel(user, channel_id)
end
when "remove_subscriptions"
Invidious::Database::Users.unsubscribe_channel(user, channel_id)
else
- return error_json(400, "Unsupported action #{action}")
+ return Errors.error_json(400, "Unsupported action #{action}")
end
if redirect
diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr
index f7909c8e1..99a81fd53 100644
--- a/src/invidious/routes/video_playback.cr
+++ b/src/invidious/routes/video_playback.cr
@@ -24,7 +24,7 @@ module Invidious::Routes::VideoPlayback
# Sanity check, to avoid being used as an open proxy
if !host.matches?(/[\w-]+\.(?:googlevideo|c\.youtube)\.com/)
- return error_template(400, "Invalid \"host\" parameter.")
+ return Errors.error_template(400, "Invalid \"host\" parameter.")
end
host = "https://#{host}"
@@ -83,7 +83,7 @@ module Invidious::Routes::VideoPlayback
# Remove the Range header added previously.
headers.delete("Range") if range_header.nil?
- playback_statistics = Helpers.get_playback_statistic
+ playback_statistics = Invidious::Helpers.get_playback_statistic
playback_statistics["totalRequests"] += 1
if response.status_code >= 400
@@ -95,7 +95,7 @@ module Invidious::Routes::VideoPlayback
if url.includes? "&file=seg.ts"
if CONFIG.disabled?("livestreams")
- return error_template(403, "Administrator has disabled this endpoint.")
+ return Errors.error_template(403, "Administrator has disabled this endpoint.")
end
begin
@@ -120,7 +120,7 @@ module Invidious::Routes::VideoPlayback
else
if query_params["title"]? && CONFIG.disabled?("downloads") ||
CONFIG.disabled?("dash")
- return error_template(403, "Administrator has disabled this endpoint.")
+ return Errors.error_template(403, "Administrator has disabled this endpoint.")
end
content_length = nil
@@ -195,7 +195,7 @@ module Invidious::Routes::VideoPlayback
end
end
- Helpers.proxy_file(resp, env)
+ Invidious::Helpers.proxy_file(resp, env)
end
rescue ex
if ex.message != "Error reading socket: Connection reset by peer"
@@ -269,11 +269,11 @@ module Invidious::Routes::VideoPlayback
# Sanity checks
unless id && validate_video_id(id)
- return error_template(400, InvalidVideoID.new(id))
+ return Errors.error_template(400, InvalidVideoID.new(id))
end
if !itag.nil? && (itag <= 0 || itag >= 1000)
- return error_template(400, "Invalid itag")
+ return Errors.error_template(400, "Invalid itag")
end
region = env.params.query["region"]?
@@ -282,15 +282,15 @@ module Invidious::Routes::VideoPlayback
title = env.params.query["title"]?
if title && CONFIG.disabled?("downloads")
- return error_template(403, "Administrator has disabled this endpoint.")
+ return Errors.error_template(403, "Administrator has disabled this endpoint.")
end
begin
video = get_video(id, region: region)
rescue ex : NotFoundException
- return error_template(404, ex)
+ return Errors.error_template(404, ex)
rescue ex
- return error_template(500, ex)
+ return Errors.error_template(500, ex)
end
if itag.nil?
diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr
index 4bca37531..ef3b80522 100644
--- a/src/invidious/routes/watch.cr
+++ b/src/invidious/routes/watch.cr
@@ -15,11 +15,11 @@ module Invidious::Routes::Watch
id = env.params.query["v"]
if env.params.query["v"].empty?
- return error_template(400, "Invalid parameters.")
+ return Errors.error_template(400, "Invalid parameters.")
end
unless validate_video_id(id)
- return error_template(400, InvalidVideoID.new(id))
+ return Errors.error_template(400, InvalidVideoID.new(id))
end
else
return env.redirect "/"
@@ -48,10 +48,10 @@ module Invidious::Routes::Watch
video = get_video(id, region: params.region)
rescue ex : NotFoundException
LOGGER.error("get_video not found: #{id} : #{ex.message}")
- return error_template(404, ex)
+ return Errors.error_template(404, ex)
rescue ex
LOGGER.error("get_video: #{id} : #{ex.message}")
- return error_template(500, ex)
+ return Errors.error_template(500, ex)
end
if preferences.annotations_subscribed &&
@@ -220,7 +220,7 @@ module Invidious::Routes::Watch
if redirect
return env.redirect referer
else
- return error_json(403, "No such user")
+ return Errors.error_json(403, "No such user")
end
end
@@ -235,12 +235,12 @@ module Invidious::Routes::Watch
end
begin
- validate_request(token, sid, env.request, HMAC_KEY, locale)
+ Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
if redirect
- return error_template(400, ex)
+ return Errors.error_template(400, ex)
else
- return error_json(400, ex)
+ return Errors.error_json(400, ex)
end
end
@@ -250,7 +250,7 @@ module Invidious::Routes::Watch
when "mark_unwatched"
Invidious::Database::Users.mark_unwatched(user, id)
else
- return error_json(400, "Unsupported action #{action}")
+ return Errors.error_json(400, "Unsupported action #{action}")
end
if redirect
@@ -264,10 +264,10 @@ module Invidious::Routes::Watch
def self.clip(env)
clip_id = env.params.url["clip"]?
- return error_template(400, "A clip ID is required") if !clip_id
+ return Errors.error_template(400, "A clip ID is required") if !clip_id
response = YoutubeAPI.resolve_url("https://www.youtube.com/clip/#{clip_id}")
- return error_template(400, "Invalid clip ID") if response["error"]?
+ return Errors.error_template(400, "Invalid clip ID") if response["error"]?
if video_id = response.dig?("endpoint", "watchEndpoint", "videoId")
if params = response.dig?("endpoint", "watchEndpoint", "params").try &.as_s
@@ -278,16 +278,16 @@ module Invidious::Routes::Watch
return env.redirect "/watch?v=#{video_id}{env.params.query}"
else
- return error_template(404, "The requested clip doesn't exist")
+ return Errors.error_template(404, "The requested clip doesn't exist")
end
end
def self.download(env)
if CONFIG.disabled?("downloads")
- return error_template(403, "Administrator has disabled this endpoint.")
+ return Errors.error_template(403, "Administrator has disabled this endpoint.")
end
if CONFIG.invidious_companion.present?
- return error_template(403, "Downloads should be routed through Companion when present")
+ return Errors.error_template(403, "Downloads should be routed through Companion when present")
end
title = env.params.body["title"]? || ""
@@ -295,7 +295,7 @@ module Invidious::Routes::Watch
selection = env.params.body["download_widget"]?
if title.empty? || video_id.empty? || selection.nil?
- return error_template(400, "Missing form data")
+ return Errors.error_template(400, "Missing form data")
end
download_widget = JSON.parse(selection)
@@ -326,7 +326,7 @@ module Invidious::Routes::Watch
return Invidious::Routes::VideoPlayback.latest_version(env)
else
- return error_template(400, "Invalid label or itag")
+ return Errors.error_template(400, "Invalid label or itag")
end
end
end
diff --git a/src/invidious/search/ctoken.cr b/src/invidious/search/ctoken.cr
index 161065e0f..89c5bc18e 100644
--- a/src/invidious/search/ctoken.cr
+++ b/src/invidious/search/ctoken.cr
@@ -1,32 +1,36 @@
-def produce_channel_search_continuation(ucid, query, page)
- if page <= 1
- idx = 0_i64
- else
- idx = 30_i64 * (page - 1)
- end
+module Invidious::Search::CToken
+ extend self
- object = {
- "80226972:embedded" => {
- "2:string" => ucid,
- "3:base64" => {
- "2:string" => "search",
- "6:varint" => 1_i64,
- "7:varint" => 1_i64,
- "12:varint" => 1_i64,
- "15:base64" => {
- "3:varint" => idx,
+ def produce_channel_search_continuation(ucid, query, page)
+ if page <= 1
+ idx = 0_i64
+ else
+ idx = 30_i64 * (page - 1)
+ end
+
+ object = {
+ "80226972:embedded" => {
+ "2:string" => ucid,
+ "3:base64" => {
+ "2:string" => "search",
+ "6:varint" => 1_i64,
+ "7:varint" => 1_i64,
+ "12:varint" => 1_i64,
+ "15:base64" => {
+ "3:varint" => idx,
+ },
+ "23:varint" => 0_i64,
},
- "23:varint" => 0_i64,
+ "11:string" => query,
+ "35:string" => "browse-feed#{ucid}search",
},
- "11:string" => query,
- "35:string" => "browse-feed#{ucid}search",
- },
- }
+ }
- continuation = object.try { |i| Protodec::Any.cast_json(i) }
- .try { |i| Protodec::Any.from_json(i) }
- .try { |i| Base64.urlsafe_encode(i) }
- .try { |i| URI.encode_www_form(i) }
+ continuation = object.try { |i| Protodec::Any.cast_json(i) }
+ .try { |i| Protodec::Any.from_json(i) }
+ .try { |i| Base64.urlsafe_encode(i) }
+ .try { |i| URI.encode_www_form(i) }
- return continuation
+ return continuation
+ end
end
diff --git a/src/invidious/hashtag.cr b/src/invidious/search/hashtag.cr
similarity index 89%
rename from src/invidious/hashtag.cr
rename to src/invidious/search/hashtag.cr
index d9d584c96..373dbcffe 100644
--- a/src/invidious/hashtag.cr
+++ b/src/invidious/search/hashtag.cr
@@ -1,7 +1,7 @@
-module Invidious::Hashtag
+module Invidious::Search::Hashtag
extend self
- def fetch(hashtag : String, page : Int, region : String? = nil) : Array(SearchItem)
+ def fetch_hashtag(hashtag : String, page : Int, region : String? = nil) : Array(SearchItem)
cursor = (page - 1) * 60
ctoken = generate_continuation(hashtag, cursor)
diff --git a/src/invidious/search/processors.cr b/src/invidious/search/processors.cr
index ecf9aeb31..9da295e31 100644
--- a/src/invidious/search/processors.cr
+++ b/src/invidious/search/processors.cr
@@ -1,45 +1,44 @@
-module Invidious::Search
- module Processors
- extend self
+module Invidious::Search::Processors
+ extend self
- # Regular search (`/search` endpoint)
- def regular(query : Query) : Array(SearchItem)
- search_params = query.filters.to_yt_params(page: query.page)
+ # Regular search (`/search` endpoint)
+ def regular(query : Query) : Array(SearchItem)
+ search_params = query.filters.to_yt_params(page: query.page)
- client_config = YoutubeAPI::ClientConfig.new(region: query.region)
- initial_data = YoutubeAPI.search(query.text, search_params, client_config: client_config)
+ client_config = YoutubeAPI::ClientConfig.new(region: query.region)
+ initial_data = YoutubeAPI.search(query.text, search_params, client_config: client_config)
- items, _ = extract_items(initial_data)
- return items.reject!(Category)
+ items, _ = extract_items(initial_data)
+ return items.reject!(Category)
+ end
+
+ # Search a youtube channel
+ # TODO: clean code, and rely more on YoutubeAPI
+ def channel(query : Query) : Array(SearchItem)
+ response = YT_POOL.client &.get("/channel/#{query.channel}")
+
+ if response.status_code == 404
+ response = YT_POOL.client &.get("/user/#{query.channel}")
+ response = YT_POOL.client &.get("/c/#{query.channel}") if response.status_code == 404
+ initial_data = Invidious::Helpers.extract_initial_data(response.body)
+ ucid = initial_data.dig?("header", "c4TabbedHeaderRenderer", "channelId").try(&.as_s?)
+ raise ChannelSearchException.new(query.channel) if !ucid
+ else
+ ucid = query.channel
end
- # Search a youtube channel
- # TODO: clean code, and rely more on YoutubeAPI
- def channel(query : Query) : Array(SearchItem)
- response = YT_POOL.client &.get("/channel/#{query.channel}")
+ continuation = Invidious::Search::CToken.produce_channel_search_continuation(ucid, query.text, query.page)
+ response_json = YoutubeAPI.browse(continuation)
- if response.status_code == 404
- response = YT_POOL.client &.get("/user/#{query.channel}")
- response = YT_POOL.client &.get("/c/#{query.channel}") if response.status_code == 404
- initial_data = Helpers.extract_initial_data(response.body)
- ucid = initial_data.dig?("header", "c4TabbedHeaderRenderer", "channelId").try(&.as_s?)
- raise ChannelSearchException.new(query.channel) if !ucid
- else
- ucid = query.channel
- end
+ items, _ = extract_items(response_json, "", ucid)
+ return items.reject!(Category)
+ end
- continuation = produce_channel_search_continuation(ucid, query.text, query.page)
- response_json = YoutubeAPI.browse(continuation)
+ # Search inside of user subscriptions
+ def subscriptions(query : Query, user : Invidious::User) : Array(ChannelVideo)
+ view_name = "subscriptions_#{sha256(user.email)}"
- items, _ = extract_items(response_json, "", ucid)
- return items.reject!(Category)
- end
-
- # Search inside of user subscriptions
- def subscriptions(query : Query, user : Invidious::User) : Array(ChannelVideo)
- view_name = "subscriptions_#{sha256(user.email)}"
-
- return PG_DB.query_all("
+ return PG_DB.query_all("
SELECT id,title,published,updated,ucid,author,length_seconds
FROM (
SELECT *,
@@ -48,9 +47,8 @@ module Invidious::Search
as document
FROM #{view_name}
) v_search WHERE v_search.document @@ plainto_tsquery($1) LIMIT 20 OFFSET $2;",
- query.text, (query.page - 1) * 20,
- as: ChannelVideo
- )
- end
+ query.text, (query.page - 1) * 20,
+ as: ChannelVideo
+ )
end
end
diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr
deleted file mode 100644
index 622fe5172..000000000
--- a/src/invidious/trending.cr
+++ /dev/null
@@ -1,48 +0,0 @@
-def fetch_trending(trending_type, region, locale)
- region ||= "US"
- region = region.upcase
-
- plid = nil
-
- browse_id = ""
-
- case trending_type.try &.downcase
- when "gaming"
- browse_id = "UCOpNcN46UbXVtpKMrmU4Abg"
- params = "Egh0cmVuZGluZw%3D%3D"
- when "livestreams"
- browse_id = "UC4R8DWoMoI7CAwX8_LjQHig"
- params = "EgdsaXZldGFikgEDCKEK"
- else
- # Livestreams is the default one as Youtube removed
- # the aggregated trending page
- # https://github.com/iv-org/invidious/issues/5397#issuecomment-3218928458
- browse_id = "UC4R8DWoMoI7CAwX8_LjQHig"
- params = "EgdsaXZldGFikgEDCKEK"
- end
-
- client_config = YoutubeAPI::ClientConfig.new(region: region)
- initial_data = YoutubeAPI.browse(browse_id, params: params, client_config: client_config)
-
- items, _ = extract_items(initial_data)
-
- extracted = [] of SearchItem
-
- deduplicate = items.size > 1
-
- items.each do |itm|
- if itm.is_a?(Category)
- # Ignore the smaller categories, as they generally contain a sponsored
- # channel, which brings a lot of noise on the trending page.
- # See: https://github.com/iv-org/invidious/issues/2989
- next if (itm.contents.size < 24 && deduplicate)
-
- extracted.concat itm.contents.select(SearchItem)
- else
- extracted << itm
- end
- end
-
- # Deduplicate items before returning results
- return extracted.select(SearchVideo | ProblematicTimelineItem).uniq!(&.id), plid
-end
diff --git a/src/invidious/user/captcha.cr b/src/invidious/user/captcha.cr
index b175c3b92..94c115ac5 100644
--- a/src/invidious/user/captcha.cr
+++ b/src/invidious/user/captcha.cr
@@ -55,7 +55,7 @@ struct Invidious::User
return {
question: image,
- tokens: {generate_response(answer, {":login"}, key, use_nonce: true)},
+ tokens: {Invidious::Helpers::Tokens.generate_response(answer, {":login"}, key, use_nonce: true)},
}
end
end
diff --git a/src/invidious/user/converters.cr b/src/invidious/user/converters.cr
index dcbf8c534..14bcb75d8 100644
--- a/src/invidious/user/converters.cr
+++ b/src/invidious/user/converters.cr
@@ -1,12 +1,16 @@
-def convert_theme(theme)
- case theme
- when "true"
- "dark"
- when "false"
- "light"
- when "", nil
- nil
- else
- theme
+module Invidious::User::Converters
+ extend self
+
+ def convert_theme(theme)
+ case theme
+ when "true"
+ "dark"
+ when "false"
+ "light"
+ when "", nil
+ nil
+ else
+ theme
+ end
end
end
diff --git a/src/invidious/user/exports.cr b/src/invidious/user/exports.cr
index 3fa468bad..9ce823ae9 100644
--- a/src/invidious/user/exports.cr
+++ b/src/invidious/user/exports.cr
@@ -15,7 +15,7 @@ struct Invidious::User
playlists.each do |playlist|
json.object do
json.field "title", playlist.title
- json.field "description", Helpers.html_to_content(playlist.description_html)
+ json.field "description", Invidious::Helpers.html_to_content(playlist.description_html)
json.field "privacy", playlist.privacy.to_s
json.field "videos" do
json.array do
diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr
index 7c4101cc1..e664016ef 100644
--- a/src/invidious/user/imports.cr
+++ b/src/invidious/user/imports.cr
@@ -43,7 +43,7 @@ struct Invidious::User
description = "This is the default description of an imported playlist. Feel Free to change it as you see fit."
privacy = PlaylistPrivacy::Private
- playlist = create_playlist(title, privacy, user)
+ playlist = Invidious::Playlists::Playlists.create_playlist(title, privacy, user)
Invidious::Database::Playlists.update_description(playlist.id, description)
# Add each video to the playlist from the body content
@@ -117,7 +117,7 @@ struct Invidious::User
next if !description
next if !privacy
- playlist = create_playlist(title, privacy, user)
+ playlist = Invidious::Playlists::Playlists.create_playlist(title, privacy, user)
Invidious::Database::Playlists.update_description(playlist.id, description)
item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx|
diff --git a/src/invidious/user/users.cr b/src/invidious/user/users.cr
new file mode 100644
index 000000000..ebcce2d51
--- /dev/null
+++ b/src/invidious/user/users.cr
@@ -0,0 +1,109 @@
+require "crypto/bcrypt/password"
+
+module Invidious::User::Users
+ extend self
+ # Materialized views may not be defined using bound parameters (`$1` as used elsewhere)
+ MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" }
+
+ def create_user(sid, email, password)
+ password = Crypto::Bcrypt::Password.create(password, cost: 10)
+ token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
+
+ user = Invidious::User.new({
+ updated: Time.utc,
+ notifications: [] of String,
+ subscriptions: [] of String,
+ email: email,
+ preferences: Preferences.new(CONFIG.default_user_preferences.to_tuple),
+ password: password.to_s,
+ token: token,
+ watched: [] of String,
+ feed_needs_update: true,
+ })
+
+ return user, sid
+ end
+
+ def get_subscription_feed(user, max_results = 40, page = 1)
+ limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE)
+ offset = (page - 1) * limit
+
+ notifications = Invidious::Database::Users.select_notifications(user)
+ view_name = "subscriptions_#{sha256(user.email)}"
+
+ if user.preferences.notifications_only && !notifications.empty?
+ # Only show notifications
+ notifications = Invidious::Database::ChannelVideos.select(notifications)
+ videos = [] of ChannelVideo
+
+ notifications.sort_by!(&.published).reverse!
+
+ case user.preferences.sort
+ when "alphabetically"
+ notifications.sort_by!(&.title)
+ when "alphabetically - reverse"
+ notifications.sort_by!(&.title).reverse!
+ when "channel name"
+ notifications.sort_by!(&.author)
+ when "channel name - reverse"
+ notifications.sort_by!(&.author).reverse!
+ else nil # Ignore
+ end
+ else
+ if user.preferences.latest_only
+ if user.preferences.unseen_only
+ # Show latest video from a channel that a user hasn't watched
+ # "unseen_only" isn't really correct here, more accurate would be "unwatched_only"
+
+ if user.watched.empty?
+ values = "'{}'"
+ else
+ values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}"
+ end
+ videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} WHERE NOT id = ANY (#{values}) ORDER BY ucid, published DESC", as: ChannelVideo)
+ else
+ # Show latest video from each channel
+
+ videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} ORDER BY ucid, published DESC", as: ChannelVideo)
+ end
+
+ videos.sort_by!(&.published).reverse!
+ else
+ if user.preferences.unseen_only
+ # Only show unwatched
+
+ if user.watched.empty?
+ values = "'{}'"
+ else
+ values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}"
+ end
+ videos = PG_DB.query_all("SELECT * FROM #{view_name} WHERE NOT id = ANY (#{values}) ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
+ else
+ # Sort subscriptions as normal
+
+ videos = PG_DB.query_all("SELECT * FROM #{view_name} ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
+ end
+ end
+
+ case user.preferences.sort
+ when "published - reverse"
+ videos.sort_by!(&.published)
+ when "alphabetically"
+ videos.sort_by!(&.title)
+ when "alphabetically - reverse"
+ videos.sort_by!(&.title).reverse!
+ when "channel name"
+ videos.sort_by!(&.author)
+ when "channel name - reverse"
+ videos.sort_by!(&.author).reverse!
+ else nil # Ignore
+ end
+
+ notifications = Invidious::Database::Users.select_notifications(user)
+ notifications = videos.select { |v| notifications.includes? v.id }
+ videos = videos - notifications
+ end
+
+ return videos, notifications
+ end
+end
diff --git a/src/invidious/users.cr b/src/invidious/users.cr
deleted file mode 100644
index 65566d207..000000000
--- a/src/invidious/users.cr
+++ /dev/null
@@ -1,106 +0,0 @@
-require "crypto/bcrypt/password"
-
-# Materialized views may not be defined using bound parameters (`$1` as used elsewhere)
-MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" }
-
-def create_user(sid, email, password)
- password = Crypto::Bcrypt::Password.create(password, cost: 10)
- token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
-
- user = Invidious::User.new({
- updated: Time.utc,
- notifications: [] of String,
- subscriptions: [] of String,
- email: email,
- preferences: Preferences.new(CONFIG.default_user_preferences.to_tuple),
- password: password.to_s,
- token: token,
- watched: [] of String,
- feed_needs_update: true,
- })
-
- return user, sid
-end
-
-def get_subscription_feed(user, max_results = 40, page = 1)
- limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE)
- offset = (page - 1) * limit
-
- notifications = Invidious::Database::Users.select_notifications(user)
- view_name = "subscriptions_#{sha256(user.email)}"
-
- if user.preferences.notifications_only && !notifications.empty?
- # Only show notifications
- notifications = Invidious::Database::ChannelVideos.select(notifications)
- videos = [] of ChannelVideo
-
- notifications.sort_by!(&.published).reverse!
-
- case user.preferences.sort
- when "alphabetically"
- notifications.sort_by!(&.title)
- when "alphabetically - reverse"
- notifications.sort_by!(&.title).reverse!
- when "channel name"
- notifications.sort_by!(&.author)
- when "channel name - reverse"
- notifications.sort_by!(&.author).reverse!
- else nil # Ignore
- end
- else
- if user.preferences.latest_only
- if user.preferences.unseen_only
- # Show latest video from a channel that a user hasn't watched
- # "unseen_only" isn't really correct here, more accurate would be "unwatched_only"
-
- if user.watched.empty?
- values = "'{}'"
- else
- values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}"
- end
- videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} WHERE NOT id = ANY (#{values}) ORDER BY ucid, published DESC", as: ChannelVideo)
- else
- # Show latest video from each channel
-
- videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} ORDER BY ucid, published DESC", as: ChannelVideo)
- end
-
- videos.sort_by!(&.published).reverse!
- else
- if user.preferences.unseen_only
- # Only show unwatched
-
- if user.watched.empty?
- values = "'{}'"
- else
- values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}"
- end
- videos = PG_DB.query_all("SELECT * FROM #{view_name} WHERE NOT id = ANY (#{values}) ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
- else
- # Sort subscriptions as normal
-
- videos = PG_DB.query_all("SELECT * FROM #{view_name} ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
- end
- end
-
- case user.preferences.sort
- when "published - reverse"
- videos.sort_by!(&.published)
- when "alphabetically"
- videos.sort_by!(&.title)
- when "alphabetically - reverse"
- videos.sort_by!(&.title).reverse!
- when "channel name"
- videos.sort_by!(&.author)
- when "channel name - reverse"
- videos.sort_by!(&.author).reverse!
- else nil # Ignore
- end
-
- notifications = Invidious::Database::Users.select_notifications(user)
- notifications = videos.select { |v| notifications.includes? v.id }
- videos = videos - notifications
- end
-
- return videos, notifications
-end
diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr
index 8367fcd78..16fc09e46 100644
--- a/src/invidious/videos/parser.cr
+++ b/src/invidious/videos/parser.cr
@@ -87,7 +87,7 @@ module Invidious::Videos::Parser
# 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.
- Helpers.get_playback_statistic["totalRequests"] += 1
+ Invidious::Helpers.get_playback_statistic["totalRequests"] += 1
return {
"version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64),
diff --git a/src/invidious/videos.cr b/src/invidious/videos/videos.cr
similarity index 100%
rename from src/invidious/videos.cr
rename to src/invidious/videos/videos.cr
diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr
index ece2efe8a..8ea92a8f7 100644
--- a/src/invidious/views/components/item.ecr
+++ b/src/invidious/views/components/item.ecr
@@ -106,7 +106,7 @@
<%=I18n.translate(locale, "timeline_parse_error_show_technical_details")%>
- <%=get_issue_template(env, item.parse_exception)[1]%>
+ <%=Errors.get_issue_template(env, item.parse_exception)[1]%>
<% else %>