diff --git a/config/config.example.yml b/config/config.example.yml index 08005a12..63135b52 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -296,12 +296,13 @@ https_only: false # ----------------------------- ## -## Enable/Disable the "Popular" tab on the main page. +## Enable/Disable specific pages on the main page. ## -## Accepted values: true, false -## Default: true +#pages_enabled: +# trending: false +# popular: true +# search: true ## -#popular_enabled: true ## ## Enable/Disable statstics (available at /api/v1/stats). diff --git a/docker-compose.yml b/docker-compose.yml index cb53bdd6..dedd2e20 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,10 +14,6 @@ services: restart: unless-stopped ports: - "127.0.0.1:3000:3000" - depends_on: - invidious-db: - condition: service_healthy - restart: true environment: # Please read the following file for a comprehensive list of all available # configuration options and their associated syntax: diff --git a/locales/en-US.json b/locales/en-US.json index 5b2ef8d0..f75cd219 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -1,510 +1,520 @@ { - "Add to playlist": "Add to playlist", - "Add to playlist: ": "Add to playlist: ", - "Answer": "Answer", - "Search for videos": "Search for videos", - "The Popular feed has been disabled by the administrator.": "The Popular feed has been disabled by the administrator.", - "generic_channels_count": "{{count}} channel", - "generic_channels_count_plural": "{{count}} channels", - "generic_views_count": "{{count}} view", - "generic_views_count_plural": "{{count}} views", - "generic_videos_count": "{{count}} video", - "generic_videos_count_plural": "{{count}} videos", - "generic_playlists_count": "{{count}} playlist", - "generic_playlists_count_plural": "{{count}} playlists", - "generic_subscribers_count": "{{count}} subscriber", - "generic_subscribers_count_plural": "{{count}} subscribers", - "generic_subscriptions_count": "{{count}} subscription", - "generic_subscriptions_count_plural": "{{count}} subscriptions", - "generic_button_delete": "Delete", - "generic_button_edit": "Edit", - "generic_button_save": "Save", - "generic_button_cancel": "Cancel", - "generic_button_rss": "RSS", - "LIVE": "LIVE", - "Shared `x` ago": "Shared `x` ago", - "Unsubscribe": "Unsubscribe", - "Subscribe": "Subscribe", - "View channel on YouTube": "View channel on YouTube", - "View playlist on YouTube": "View playlist on YouTube", - "newest": "newest", - "oldest": "oldest", - "popular": "popular", - "last": "last", - "Next page": "Next page", - "Previous page": "Previous page", - "First page": "First page", - "Clear watch history?": "Clear watch history?", - "New password": "New password", - "New passwords must match": "New passwords must match", - "Authorize token?": "Authorize token?", - "Authorize token for `x`?": "Authorize token for `x`?", - "Yes": "Yes", - "No": "No", - "Import and Export Data": "Import and Export Data", - "Import": "Import", - "Import Invidious data": "Import Invidious JSON data", - "Import YouTube subscriptions": "Import YouTube CSV or OPML subscriptions", - "Import YouTube playlist (.csv)": "Import YouTube playlist (.csv)", - "Import YouTube watch history (.json)": "Import YouTube watch history (.json)", - "Import FreeTube subscriptions (.db)": "Import FreeTube subscriptions (.db)", - "Import NewPipe subscriptions (.json)": "Import NewPipe subscriptions (.json)", - "Import NewPipe data (.zip)": "Import NewPipe data (.zip)", - "Export": "Export", - "Export subscriptions as OPML": "Export subscriptions as OPML", - "Export subscriptions as OPML (for NewPipe & FreeTube)": "Export subscriptions as OPML (for NewPipe & FreeTube)", - "Export data as JSON": "Export Invidious data as JSON", - "Delete account?": "Delete account?", - "History": "History", - "An alternative front-end to YouTube": "An alternative front-end to YouTube", - "JavaScript license information": "JavaScript license information", - "source": "source", - "Log in": "Log in", - "Log in/register": "Log in/register", - "User ID": "User ID", - "Password": "Password", - "Time (h:mm:ss):": "Time (h:mm:ss):", - "Sign In": "Sign In", - "Register": "Register", - "E-mail": "E-mail", - "Preferences": "Preferences", - "preferences_category_player": "Player preferences", - "preferences_video_loop_label": "Always loop: ", - "preferences_preload_label": "Preload video data: ", - "preferences_autoplay_label": "Autoplay: ", - "preferences_continue_label": "Play next by default: ", - "preferences_continue_autoplay_label": "Autoplay next video: ", - "preferences_listen_label": "Listen by default: ", - "preferences_local_label": "Proxy videos: ", - "preferences_watch_history_label": "Enable watch history: ", - "preferences_speed_label": "Default speed: ", - "preferences_quality_label": "Preferred video quality: ", - "preferences_quality_option_dash": "DASH (adaptive quality)", - "preferences_quality_option_hd720": "HD720", - "preferences_quality_option_medium": "Medium", - "preferences_quality_option_small": "Small", - "preferences_quality_dash_label": "Preferred DASH video quality: ", - "preferences_quality_dash_option_auto": "Auto", - "preferences_quality_dash_option_best": "Best", - "preferences_quality_dash_option_worst": "Worst", - "preferences_quality_dash_option_4320p": "4320p", - "preferences_quality_dash_option_2160p": "2160p", - "preferences_quality_dash_option_1440p": "1440p", - "preferences_quality_dash_option_1080p": "1080p", - "preferences_quality_dash_option_720p": "720p", - "preferences_quality_dash_option_480p": "480p", - "preferences_quality_dash_option_360p": "360p", - "preferences_quality_dash_option_240p": "240p", - "preferences_quality_dash_option_144p": "144p", - "preferences_volume_label": "Player volume: ", - "preferences_comments_label": "Default comments: ", - "youtube": "YouTube", - "reddit": "Reddit", - "invidious": "Invidious", - "preferences_captions_label": "Default captions: ", - "Fallback captions: ": "Fallback captions: ", - "preferences_related_videos_label": "Show related videos: ", - "preferences_annotations_label": "Show annotations by default: ", - "preferences_extend_desc_label": "Automatically extend video description: ", - "preferences_vr_mode_label": "Interactive 360 degree videos (requires WebGL): ", - "preferences_category_visual": "Visual preferences", - "preferences_region_label": "Content country: ", - "preferences_player_style_label": "Player style: ", - "Dark mode: ": "Dark mode: ", - "preferences_dark_mode_label": "Theme: ", - "dark": "dark", - "light": "light", - "preferences_thin_mode_label": "Thin mode: ", - "preferences_category_misc": "Miscellaneous preferences", - "preferences_automatic_instance_redirect_label": "Automatic instance redirection (fallback to redirect.invidious.io): ", - "preferences_category_subscription": "Subscription preferences", - "preferences_annotations_subscribed_label": "Show annotations by default for subscribed channels? ", - "Redirect homepage to feed: ": "Redirect homepage to feed: ", - "preferences_max_results_label": "Number of videos shown in feed: ", - "preferences_sort_label": "Sort videos by: ", - "preferences_default_playlist": "Default playlist: ", - "preferences_default_playlist_none": "No default playlist set", - "published": "published", - "published - reverse": "published - reverse", - "alphabetically": "alphabetically", - "alphabetically - reverse": "alphabetically - reverse", - "channel name": "channel name", - "channel name - reverse": "channel name - reverse", - "Only show latest video from channel: ": "Only show latest video from channel: ", - "Only show latest unwatched video from channel: ": "Only show latest unwatched video from channel: ", - "preferences_unseen_only_label": "Only show unwatched: ", - "preferences_notifications_only_label": "Only show notifications (if there are any): ", - "Enable web notifications": "Enable web notifications", - "`x` uploaded a video": "`x` uploaded a video", - "`x` is live": "`x` is live", - "preferences_category_data": "Data preferences", - "Clear watch history": "Clear watch history", - "Import/export data": "Import/export data", - "Change password": "Change password", - "Manage subscriptions": "Manage subscriptions", - "Manage tokens": "Manage tokens", - "Watch history": "Watch history", - "Delete account": "Delete account", - "preferences_category_admin": "Administrator preferences", - "preferences_default_home_label": "Default homepage: ", - "preferences_feed_menu_label": "Feed menu: ", - "preferences_show_nick_label": "Show nickname on top: ", - "Popular enabled: ": "Popular enabled: ", - "Top enabled: ": "Top enabled: ", - "CAPTCHA enabled: ": "CAPTCHA enabled: ", - "Login enabled: ": "Login enabled: ", - "Registration enabled: ": "Registration enabled: ", - "Report statistics: ": "Report statistics: ", - "Save preferences": "Save preferences", - "Subscription manager": "Subscription manager", - "Token manager": "Token manager", - "Token": "Token", - "tokens_count": "{{count}} token", - "tokens_count_plural": "{{count}} tokens", - "Import/export": "Import/export", - "unsubscribe": "unsubscribe", - "revoke": "revoke", - "Subscriptions": "Subscriptions", - "subscriptions_unseen_notifs_count": "{{count}} unseen notification", - "subscriptions_unseen_notifs_count_plural": "{{count}} unseen notifications", - "search": "search", - "Log out": "Log out", - "Released under the AGPLv3 on Github.": "Released under the AGPLv3 on GitHub.", - "Source available here.": "Source available here.", - "View JavaScript license information.": "View JavaScript license information.", - "View privacy policy.": "View privacy policy.", - "Trending": "Trending", - "Public": "Public", - "Unlisted": "Unlisted", - "Private": "Private", - "View all playlists": "View all playlists", - "Updated `x` ago": "Updated `x` ago", - "Delete playlist `x`?": "Delete playlist `x`?", - "Delete playlist": "Delete playlist", - "Create playlist": "Create playlist", - "Title": "Title", - "Playlist privacy": "Playlist privacy", - "Editing playlist `x`": "Editing playlist `x`", - "playlist_button_add_items": "Add videos", - "Show more": "Show more", - "Show less": "Show less", - "Watch on YouTube": "Watch on YouTube", - "Switch Invidious Instance": "Switch Invidious Instance", - "search_message_no_results": "No results found.", - "search_message_change_filters_or_query": "Try widening your search query and/or changing the filters.", - "search_message_use_another_instance": "You can also search on another instance.", - "Hide annotations": "Hide annotations", - "Show annotations": "Show annotations", - "Genre: ": "Genre: ", - "License: ": "License: ", - "Standard YouTube license": "Standard YouTube license", - "Family friendly? ": "Family friendly? ", - "Wilson score: ": "Wilson score: ", - "Engagement: ": "Engagement: ", - "Whitelisted regions: ": "Whitelisted regions: ", - "Blacklisted regions: ": "Blacklisted regions: ", - "Music in this video": "Music in this video", - "Artist: ": "Artist: ", - "Song: ": "Song: ", - "Album: ": "Album: ", - "Shared `x`": "Shared `x`", - "Premieres in `x`": "Premieres in `x`", - "Premieres `x`": "Premieres `x`", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.", - "View YouTube comments": "View YouTube comments", - "View more comments on Reddit": "View more comments on Reddit", - "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "View `x` comment", - "": "View `x` comments" - }, - "View Reddit comments": "View Reddit comments", - "Hide replies": "Hide replies", - "Show replies": "Show replies", - "Incorrect password": "Incorrect password", - "Wrong answer": "Wrong answer", - "Erroneous CAPTCHA": "Erroneous CAPTCHA", - "CAPTCHA is a required field": "CAPTCHA is a required field", - "User ID is a required field": "User ID is a required field", - "Password is a required field": "Password is a required field", - "Wrong username or password": "Wrong username or password", - "Password cannot be empty": "Password cannot be empty", - "Password cannot be longer than 55 characters": "Password cannot be longer than 55 characters", - "Please log in": "Please log in", - "Invidious Private Feed for `x`": "Invidious Private Feed for `x`", - "channel:`x`": "channel:`x`", - "Deleted or invalid channel": "Deleted or invalid channel", - "This channel does not exist.": "This channel does not exist.", - "Could not get channel info.": "Could not get channel info.", - "Could not fetch comments": "Could not fetch comments", - "comments_view_x_replies": "View {{count}} reply", - "comments_view_x_replies_plural": "View {{count}} replies", - "`x` ago": "`x` ago", - "Load more": "Load more", - "comments_points_count": "{{count}} point", - "comments_points_count_plural": "{{count}} points", - "Could not create mix.": "Could not create mix.", - "Empty playlist": "Empty playlist", - "Not a playlist.": "Not a playlist.", - "Playlist does not exist.": "Playlist does not exist.", - "Could not pull trending pages.": "Could not pull trending pages.", - "Hidden field \"challenge\" is a required field": "Hidden field \"challenge\" is a required field", - "Hidden field \"token\" is a required field": "Hidden field \"token\" is a required field", - "Erroneous challenge": "Erroneous challenge", - "Erroneous token": "Erroneous token", - "No such user": "No such user", - "Token is expired, please try again": "Token is expired, please try again", - "English": "English", - "English (United Kingdom)": "English (United Kingdom)", - "English (United States)": "English (United States)", - "English (auto-generated)": "English (auto-generated)", - "Afrikaans": "Afrikaans", - "Albanian": "Albanian", - "Amharic": "Amharic", - "Arabic": "Arabic", - "Armenian": "Armenian", - "Azerbaijani": "Azerbaijani", - "Bangla": "Bangla", - "Basque": "Basque", - "Belarusian": "Belarusian", - "Bosnian": "Bosnian", - "Bulgarian": "Bulgarian", - "Burmese": "Burmese", - "Cantonese (Hong Kong)": "Cantonese (Hong Kong)", - "Catalan": "Catalan", - "Cebuano": "Cebuano", - "Chinese": "Chinese", - "Chinese (China)": "Chinese (China)", - "Chinese (Hong Kong)": "Chinese (Hong Kong)", - "Chinese (Simplified)": "Chinese (Simplified)", - "Chinese (Taiwan)": "Chinese (Taiwan)", - "Chinese (Traditional)": "Chinese (Traditional)", - "Corsican": "Corsican", - "Croatian": "Croatian", - "Czech": "Czech", - "Danish": "Danish", - "Dutch": "Dutch", - "Dutch (auto-generated)": "Dutch (auto-generated)", - "Esperanto": "Esperanto", - "Estonian": "Estonian", - "Filipino": "Filipino", - "Filipino (auto-generated)": "Filipino (auto-generated)", - "Finnish": "Finnish", - "French": "French", - "French (auto-generated)": "French (auto-generated)", - "Galician": "Galician", - "Georgian": "Georgian", - "German": "German", - "German (auto-generated)": "German (auto-generated)", - "Greek": "Greek", - "Gujarati": "Gujarati", - "Haitian Creole": "Haitian Creole", - "Hausa": "Hausa", - "Hawaiian": "Hawaiian", - "Hebrew": "Hebrew", - "Hindi": "Hindi", - "Hmong": "Hmong", - "Hungarian": "Hungarian", - "Icelandic": "Icelandic", - "Igbo": "Igbo", - "Indonesian": "Indonesian", - "Indonesian (auto-generated)": "Indonesian (auto-generated)", - "Interlingue": "Interlingue", - "Irish": "Irish", - "Italian": "Italian", - "Italian (auto-generated)": "Italian (auto-generated)", - "Japanese": "Japanese", - "Japanese (auto-generated)": "Japanese (auto-generated)", - "Javanese": "Javanese", - "Kannada": "Kannada", - "Kazakh": "Kazakh", - "Khmer": "Khmer", - "Korean": "Korean", - "Korean (auto-generated)": "Korean (auto-generated)", - "Kurdish": "Kurdish", - "Kyrgyz": "Kyrgyz", - "Lao": "Lao", - "Latin": "Latin", - "Latvian": "Latvian", - "Lithuanian": "Lithuanian", - "Luxembourgish": "Luxembourgish", - "Macedonian": "Macedonian", - "Malagasy": "Malagasy", - "Malay": "Malay", - "Malayalam": "Malayalam", - "Maltese": "Maltese", - "Maori": "Maori", - "Marathi": "Marathi", - "Mongolian": "Mongolian", - "Nepali": "Nepali", - "Norwegian Bokmål": "Norwegian Bokmål", - "Nyanja": "Nyanja", - "Pashto": "Pashto", - "Persian": "Persian", - "Polish": "Polish", - "Portuguese": "Portuguese", - "Portuguese (auto-generated)": "Portuguese (auto-generated)", - "Portuguese (Brazil)": "Portuguese (Brazil)", - "Punjabi": "Punjabi", - "Romanian": "Romanian", - "Russian": "Russian", - "Russian (auto-generated)": "Russian (auto-generated)", - "Samoan": "Samoan", - "Scottish Gaelic": "Scottish Gaelic", - "Serbian": "Serbian", - "Shona": "Shona", - "Sindhi": "Sindhi", - "Sinhala": "Sinhala", - "Slovak": "Slovak", - "Slovenian": "Slovenian", - "Somali": "Somali", - "Southern Sotho": "Southern Sotho", - "Spanish": "Spanish", - "Spanish (auto-generated)": "Spanish (auto-generated)", - "Spanish (Latin America)": "Spanish (Latin America)", - "Spanish (Mexico)": "Spanish (Mexico)", - "Spanish (Spain)": "Spanish (Spain)", - "Sundanese": "Sundanese", - "Swahili": "Swahili", - "Swedish": "Swedish", - "Tajik": "Tajik", - "Tamil": "Tamil", - "Telugu": "Telugu", - "Thai": "Thai", - "Turkish": "Turkish", - "Turkish (auto-generated)": "Turkish (auto-generated)", - "Ukrainian": "Ukrainian", - "Urdu": "Urdu", - "Uzbek": "Uzbek", - "Vietnamese": "Vietnamese", - "Vietnamese (auto-generated)": "Vietnamese (auto-generated)", - "Welsh": "Welsh", - "Western Frisian": "Western Frisian", - "Xhosa": "Xhosa", - "Yiddish": "Yiddish", - "Yoruba": "Yoruba", - "Zulu": "Zulu", - "generic_count_years": "{{count}} year", - "generic_count_years_plural": "{{count}} years", - "generic_count_months": "{{count}} month", - "generic_count_months_plural": "{{count}} months", - "generic_count_weeks": "{{count}} week", - "generic_count_weeks_plural": "{{count}} weeks", - "generic_count_days": "{{count}} day", - "generic_count_days_plural": "{{count}} days", - "generic_count_hours": "{{count}} hour", - "generic_count_hours_plural": "{{count}} hours", - "generic_count_minutes": "{{count}} minute", - "generic_count_minutes_plural": "{{count}} minutes", - "generic_count_seconds": "{{count}} second", - "generic_count_seconds_plural": "{{count}} seconds", - "Fallback comments: ": "Fallback comments: ", - "Popular": "Popular", - "Search": "Search", - "Top": "Top", - "About": "About", - "Rating: ": "Rating: ", - "preferences_locale_label": "Language: ", - "View as playlist": "View as playlist", - "Default": "Default", - "Music": "Music", - "Gaming": "Gaming", - "Livestreams": "Livestreams", - "News": "News", - "Movies": "Movies", - "Download": "Download", - "Download as: ": "Download as: ", - "Download is disabled": "Download is disabled", - "%A %B %-d, %Y": "%A %B %-d, %Y", - "(edited)": "(edited)", - "YouTube comment permalink": "YouTube comment permalink", - "permalink": "permalink", - "`x` marked it with a ❤": "`x` marked it with a ❤", - "Channel Sponsor": "Channel Sponsor", - "Audio mode": "Audio mode", - "Video mode": "Video mode", - "Playlists": "Playlists", - "search_filters_title": "Filters", - "search_filters_date_label": "Upload date", - "search_filters_date_option_none": "Any date", - "search_filters_date_option_hour": "Last hour", - "search_filters_date_option_today": "Today", - "search_filters_date_option_week": "This week", - "search_filters_date_option_month": "This month", - "search_filters_date_option_year": "This year", - "search_filters_type_label": "Type", - "search_filters_type_option_all": "Any type", - "search_filters_type_option_video": "Video", - "search_filters_type_option_channel": "Channel", - "search_filters_type_option_playlist": "Playlist", - "search_filters_type_option_movie": "Movie", - "search_filters_type_option_show": "Show", - "search_filters_duration_label": "Duration", - "search_filters_duration_option_none": "Any duration", - "search_filters_duration_option_short": "Short (< 4 minutes)", - "search_filters_duration_option_medium": "Medium (4 - 20 minutes)", - "search_filters_duration_option_long": "Long (> 20 minutes)", - "search_filters_features_label": "Features", - "search_filters_features_option_live": "Live", - "search_filters_features_option_four_k": "4K", - "search_filters_features_option_hd": "HD", - "search_filters_features_option_subtitles": "Subtitles/CC", - "search_filters_features_option_c_commons": "Creative Commons", - "search_filters_features_option_three_sixty": "360°", - "search_filters_features_option_vr180": "VR180", - "search_filters_features_option_three_d": "3D", - "search_filters_features_option_hdr": "HDR", - "search_filters_features_option_location": "Location", - "search_filters_features_option_purchased": "Purchased", - "search_filters_sort_label": "Sort By", - "search_filters_sort_option_relevance": "Relevance", - "search_filters_sort_option_rating": "Rating", - "search_filters_sort_option_date": "Upload date", - "search_filters_sort_option_views": "View count", - "search_filters_apply_button": "Apply selected filters", - "Current version: ": "Current version: ", - "next_steps_error_message": "After which you should try to: ", - "next_steps_error_message_refresh": "Refresh", - "next_steps_error_message_go_to_youtube": "Go to YouTube", - "footer_donate_page": "Donate", - "footer_documentation": "Documentation", - "footer_source_code": "Source code", - "footer_original_source_code": "Original source code", - "footer_modfied_source_code": "Modified source code", - "adminprefs_modified_source_code_url_label": "URL to modified source code repository", - "none": "none", - "videoinfo_started_streaming_x_ago": "Started streaming `x` ago", - "videoinfo_watch_on_youTube": "Watch on YouTube", - "videoinfo_youTube_embed_link": "Embed", - "videoinfo_invidious_embed_link": "Embed Link", - "download_subtitles": "Subtitles - `x` (.vtt)", - "user_created_playlists": "`x` created playlists", - "user_saved_playlists": "`x` saved playlists", - "Video unavailable": "Video unavailable", - "preferences_save_player_pos_label": "Save playback position: ", - "crash_page_you_found_a_bug": "It looks like you found a bug in Invidious!", - "crash_page_before_reporting": "Before reporting a bug, make sure that you have:", - "crash_page_refresh": "tried to refresh the page", - "crash_page_switch_instance": "tried to use another instance", - "crash_page_read_the_faq": "read the Frequently Asked Questions (FAQ)", - "crash_page_search_issue": "searched for existing issues on GitHub", - "crash_page_report_issue": "If none of the above helped, please open a new issue on GitHub (preferably in English) and include the following text in your message (do NOT translate that text):", - "error_video_not_in_playlist": "The requested video doesn't exist in this playlist. Click here for the playlist home page.", - "channel_tab_videos_label": "Videos", - "channel_tab_shorts_label": "Shorts", - "channel_tab_streams_label": "Livestreams", - "channel_tab_podcasts_label": "Podcasts", - "channel_tab_releases_label": "Releases", - "channel_tab_courses_label": "Courses", - "channel_tab_playlists_label": "Playlists", - "channel_tab_community_label": "Community", - "channel_tab_posts_label": "Posts", - "channel_tab_channels_label": "Channels", - "toggle_theme": "Toggle Theme", - "carousel_slide": "Slide {{current}} of {{total}}", - "carousel_skip": "Skip the Carousel", - "carousel_go_to": "Go to slide `x`", - "timeline_parse_error_placeholder_heading": "Unable to parse item", - "timeline_parse_error_placeholder_message": "Invidious encountered an error while trying to parse this item. For more information see below:", - "timeline_parse_error_show_technical_details": "Show technical details", - "dmca_content": "This video cannot be downloaded on this instance due to a DMCA/copyright infringement letter sent to the instance administrator." + "Add to playlist": "Add to playlist", + "Add to playlist: ": "Add to playlist: ", + "Answer": "Answer", + "Search for videos": "Search for videos", + "popular_page_disabled": "The Popular feed has been disabled by the administrator.", + "trending_page_disabled": "The Trending feed has been disabled by the administrator.", + "search_page_disabled": "The Search feature has been disabled by the administrator.", + "search_subscriptions_placeholder": "Search subscriptions & playlists...", + "search_subscriptions_hint": "YouTube search is disabled. Search within your subscriptions and playlists.", + "search_subscriptions_login_required": "Please log in to search your subscriptions and playlists.", + "search_subscriptions_mode_notice": "Searching within your subscriptions and playlists only.", + "search_subscriptions_no_results": "No results found in your subscriptions or playlists.", + "search_subscriptions_no_results_hint": "Try a different search term, or make sure the channel is in your subscriptions.", + "generic_channels_count": "{{count}} channel", + "generic_channels_count_plural": "{{count}} channels", + "generic_views_count": "{{count}} view", + "generic_views_count_plural": "{{count}} views", + "generic_videos_count": "{{count}} video", + "generic_videos_count_plural": "{{count}} videos", + "generic_playlists_count": "{{count}} playlist", + "generic_playlists_count_plural": "{{count}} playlists", + "generic_subscribers_count": "{{count}} subscriber", + "generic_subscribers_count_plural": "{{count}} subscribers", + "generic_subscriptions_count": "{{count}} subscription", + "generic_subscriptions_count_plural": "{{count}} subscriptions", + "generic_button_delete": "Delete", + "generic_button_edit": "Edit", + "generic_button_save": "Save", + "generic_button_cancel": "Cancel", + "generic_button_rss": "RSS", + "LIVE": "LIVE", + "Shared `x` ago": "Shared `x` ago", + "Unsubscribe": "Unsubscribe", + "Subscribe": "Subscribe", + "View channel on YouTube": "View channel on YouTube", + "View playlist on YouTube": "View playlist on YouTube", + "newest": "newest", + "oldest": "oldest", + "popular": "popular", + "last": "last", + "Next page": "Next page", + "Previous page": "Previous page", + "First page": "First page", + "Clear watch history?": "Clear watch history?", + "New password": "New password", + "New passwords must match": "New passwords must match", + "Authorize token?": "Authorize token?", + "Authorize token for `x`?": "Authorize token for `x`?", + "Yes": "Yes", + "No": "No", + "Import and Export Data": "Import and Export Data", + "Import": "Import", + "Import Invidious data": "Import Invidious JSON data", + "Import YouTube subscriptions": "Import YouTube CSV or OPML subscriptions", + "Import YouTube playlist (.csv)": "Import YouTube playlist (.csv)", + "Import YouTube watch history (.json)": "Import YouTube watch history (.json)", + "Import FreeTube subscriptions (.db)": "Import FreeTube subscriptions (.db)", + "Import NewPipe subscriptions (.json)": "Import NewPipe subscriptions (.json)", + "Import NewPipe data (.zip)": "Import NewPipe data (.zip)", + "Export": "Export", + "Export subscriptions as OPML": "Export subscriptions as OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Export subscriptions as OPML (for NewPipe & FreeTube)", + "Export data as JSON": "Export Invidious data as JSON", + "Delete account?": "Delete account?", + "History": "History", + "An alternative front-end to YouTube": "An alternative front-end to YouTube", + "JavaScript license information": "JavaScript license information", + "source": "source", + "Log in": "Log in", + "Log in/register": "Log in/register", + "User ID": "User ID", + "Password": "Password", + "Time (h:mm:ss):": "Time (h:mm:ss):", + "Sign In": "Sign In", + "Register": "Register", + "E-mail": "E-mail", + "Preferences": "Preferences", + "preferences_category_player": "Player preferences", + "preferences_video_loop_label": "Always loop: ", + "preferences_preload_label": "Preload video data: ", + "preferences_autoplay_label": "Autoplay: ", + "preferences_continue_label": "Play next by default: ", + "preferences_continue_autoplay_label": "Autoplay next video: ", + "preferences_listen_label": "Listen by default: ", + "preferences_local_label": "Proxy videos: ", + "preferences_watch_history_label": "Enable watch history: ", + "preferences_speed_label": "Default speed: ", + "preferences_quality_label": "Preferred video quality: ", + "preferences_quality_option_dash": "DASH (adaptive quality)", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "Medium", + "preferences_quality_option_small": "Small", + "preferences_quality_dash_label": "Preferred DASH video quality: ", + "preferences_quality_dash_option_auto": "Auto", + "preferences_quality_dash_option_best": "Best", + "preferences_quality_dash_option_worst": "Worst", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "preferences_volume_label": "Player volume: ", + "preferences_comments_label": "Default comments: ", + "youtube": "YouTube", + "reddit": "Reddit", + "invidious": "Invidious", + "preferences_captions_label": "Default captions: ", + "Fallback captions: ": "Fallback captions: ", + "preferences_related_videos_label": "Show related videos: ", + "preferences_annotations_label": "Show annotations by default: ", + "preferences_extend_desc_label": "Automatically extend video description: ", + "preferences_vr_mode_label": "Interactive 360 degree videos (requires WebGL): ", + "preferences_category_visual": "Visual preferences", + "preferences_region_label": "Content country: ", + "preferences_player_style_label": "Player style: ", + "Dark mode: ": "Dark mode: ", + "preferences_dark_mode_label": "Theme: ", + "dark": "dark", + "light": "light", + "preferences_thin_mode_label": "Thin mode: ", + "preferences_category_misc": "Miscellaneous preferences", + "preferences_automatic_instance_redirect_label": "Automatic instance redirection (fallback to redirect.invidious.io): ", + "preferences_category_subscription": "Subscription preferences", + "preferences_annotations_subscribed_label": "Show annotations by default for subscribed channels? ", + "Redirect homepage to feed: ": "Redirect homepage to feed: ", + "preferences_max_results_label": "Number of videos shown in feed: ", + "preferences_sort_label": "Sort videos by: ", + "preferences_default_playlist": "Default playlist: ", + "preferences_default_playlist_none": "No default playlist set", + "published": "published", + "published - reverse": "published - reverse", + "alphabetically": "alphabetically", + "alphabetically - reverse": "alphabetically - reverse", + "channel name": "channel name", + "channel name - reverse": "channel name - reverse", + "Only show latest video from channel: ": "Only show latest video from channel: ", + "Only show latest unwatched video from channel: ": "Only show latest unwatched video from channel: ", + "preferences_unseen_only_label": "Only show unwatched: ", + "preferences_notifications_only_label": "Only show notifications (if there are any): ", + "Enable web notifications": "Enable web notifications", + "`x` uploaded a video": "`x` uploaded a video", + "`x` is live": "`x` is live", + "preferences_category_data": "Data preferences", + "Clear watch history": "Clear watch history", + "Import/export data": "Import/export data", + "Change password": "Change password", + "Manage subscriptions": "Manage subscriptions", + "Manage tokens": "Manage tokens", + "Watch history": "Watch history", + "Delete account": "Delete account", + "preferences_category_admin": "Administrator preferences", + "preferences_default_home_label": "Default homepage: ", + "preferences_feed_menu_label": "Feed menu: ", + "preferences_show_nick_label": "Show nickname on top: ", + "Popular enabled: ": "Popular enabled: ", + "Top enabled: ": "Top enabled: ", + "CAPTCHA enabled: ": "CAPTCHA enabled: ", + "Login enabled: ": "Login enabled: ", + "Registration enabled: ": "Registration enabled: ", + "Report statistics: ": "Report statistics: ", + "Save preferences": "Save preferences", + "Subscription manager": "Subscription manager", + "Token manager": "Token manager", + "Token": "Token", + "tokens_count": "{{count}} token", + "tokens_count_plural": "{{count}} tokens", + "Import/export": "Import/export", + "unsubscribe": "unsubscribe", + "revoke": "revoke", + "Subscriptions": "Subscriptions", + "subscriptions_unseen_notifs_count": "{{count}} unseen notification", + "subscriptions_unseen_notifs_count_plural": "{{count}} unseen notifications", + "search": "search", + "Log out": "Log out", + "Released under the AGPLv3 on Github.": "Released under the AGPLv3 on GitHub.", + "Source available here.": "Source available here.", + "View JavaScript license information.": "View JavaScript license information.", + "View privacy policy.": "View privacy policy.", + "Trending": "Trending", + "Public": "Public", + "Unlisted": "Unlisted", + "Private": "Private", + "View all playlists": "View all playlists", + "Updated `x` ago": "Updated `x` ago", + "Delete playlist `x`?": "Delete playlist `x`?", + "Delete playlist": "Delete playlist", + "Create playlist": "Create playlist", + "Title": "Title", + "Playlist privacy": "Playlist privacy", + "Editing playlist `x`": "Editing playlist `x`", + "playlist_button_add_items": "Add videos", + "Show more": "Show more", + "Show less": "Show less", + "Watch on YouTube": "Watch on YouTube", + "Switch Invidious Instance": "Switch Invidious Instance", + "search_message_no_results": "No results found.", + "search_message_change_filters_or_query": "Try widening your search query and/or changing the filters.", + "search_message_use_another_instance": "You can also search on another instance.", + "Hide annotations": "Hide annotations", + "Show annotations": "Show annotations", + "Genre: ": "Genre: ", + "License: ": "License: ", + "Standard YouTube license": "Standard YouTube license", + "Family friendly? ": "Family friendly? ", + "Wilson score: ": "Wilson score: ", + "Engagement: ": "Engagement: ", + "Whitelisted regions: ": "Whitelisted regions: ", + "Blacklisted regions: ": "Blacklisted regions: ", + "Music in this video": "Music in this video", + "Artist: ": "Artist: ", + "Song: ": "Song: ", + "Album: ": "Album: ", + "Shared `x`": "Shared `x`", + "Premieres in `x`": "Premieres in `x`", + "Premieres `x`": "Premieres `x`", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.", + "View YouTube comments": "View YouTube comments", + "View more comments on Reddit": "View more comments on Reddit", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "View `x` comment", + "": "View `x` comments" + }, + "View Reddit comments": "View Reddit comments", + "Hide replies": "Hide replies", + "Show replies": "Show replies", + "Incorrect password": "Incorrect password", + "Wrong answer": "Wrong answer", + "Erroneous CAPTCHA": "Erroneous CAPTCHA", + "CAPTCHA is a required field": "CAPTCHA is a required field", + "User ID is a required field": "User ID is a required field", + "Password is a required field": "Password is a required field", + "Wrong username or password": "Wrong username or password", + "Password cannot be empty": "Password cannot be empty", + "Password cannot be longer than 55 characters": "Password cannot be longer than 55 characters", + "Please log in": "Please log in", + "Invidious Private Feed for `x`": "Invidious Private Feed for `x`", + "channel:`x`": "channel:`x`", + "Deleted or invalid channel": "Deleted or invalid channel", + "This channel does not exist.": "This channel does not exist.", + "Could not get channel info.": "Could not get channel info.", + "Could not fetch comments": "Could not fetch comments", + "comments_view_x_replies": "View {{count}} reply", + "comments_view_x_replies_plural": "View {{count}} replies", + "`x` ago": "`x` ago", + "Load more": "Load more", + "comments_points_count": "{{count}} point", + "comments_points_count_plural": "{{count}} points", + "Could not create mix.": "Could not create mix.", + "Empty playlist": "Empty playlist", + "Not a playlist.": "Not a playlist.", + "Playlist does not exist.": "Playlist does not exist.", + "Could not pull trending pages.": "Could not pull trending pages.", + "Hidden field \"challenge\" is a required field": "Hidden field \"challenge\" is a required field", + "Hidden field \"token\" is a required field": "Hidden field \"token\" is a required field", + "Erroneous challenge": "Erroneous challenge", + "Erroneous token": "Erroneous token", + "No such user": "No such user", + "Token is expired, please try again": "Token is expired, please try again", + "English": "English", + "English (United Kingdom)": "English (United Kingdom)", + "English (United States)": "English (United States)", + "English (auto-generated)": "English (auto-generated)", + "Afrikaans": "Afrikaans", + "Albanian": "Albanian", + "Amharic": "Amharic", + "Arabic": "Arabic", + "Armenian": "Armenian", + "Azerbaijani": "Azerbaijani", + "Bangla": "Bangla", + "Basque": "Basque", + "Belarusian": "Belarusian", + "Bosnian": "Bosnian", + "Bulgarian": "Bulgarian", + "Burmese": "Burmese", + "Cantonese (Hong Kong)": "Cantonese (Hong Kong)", + "Catalan": "Catalan", + "Cebuano": "Cebuano", + "Chinese": "Chinese", + "Chinese (China)": "Chinese (China)", + "Chinese (Hong Kong)": "Chinese (Hong Kong)", + "Chinese (Simplified)": "Chinese (Simplified)", + "Chinese (Taiwan)": "Chinese (Taiwan)", + "Chinese (Traditional)": "Chinese (Traditional)", + "Corsican": "Corsican", + "Croatian": "Croatian", + "Czech": "Czech", + "Danish": "Danish", + "Dutch": "Dutch", + "Dutch (auto-generated)": "Dutch (auto-generated)", + "Esperanto": "Esperanto", + "Estonian": "Estonian", + "Filipino": "Filipino", + "Filipino (auto-generated)": "Filipino (auto-generated)", + "Finnish": "Finnish", + "French": "French", + "French (auto-generated)": "French (auto-generated)", + "Galician": "Galician", + "Georgian": "Georgian", + "German": "German", + "German (auto-generated)": "German (auto-generated)", + "Greek": "Greek", + "Gujarati": "Gujarati", + "Haitian Creole": "Haitian Creole", + "Hausa": "Hausa", + "Hawaiian": "Hawaiian", + "Hebrew": "Hebrew", + "Hindi": "Hindi", + "Hmong": "Hmong", + "Hungarian": "Hungarian", + "Icelandic": "Icelandic", + "Igbo": "Igbo", + "Indonesian": "Indonesian", + "Indonesian (auto-generated)": "Indonesian (auto-generated)", + "Interlingue": "Interlingue", + "Irish": "Irish", + "Italian": "Italian", + "Italian (auto-generated)": "Italian (auto-generated)", + "Japanese": "Japanese", + "Japanese (auto-generated)": "Japanese (auto-generated)", + "Javanese": "Javanese", + "Kannada": "Kannada", + "Kazakh": "Kazakh", + "Khmer": "Khmer", + "Korean": "Korean", + "Korean (auto-generated)": "Korean (auto-generated)", + "Kurdish": "Kurdish", + "Kyrgyz": "Kyrgyz", + "Lao": "Lao", + "Latin": "Latin", + "Latvian": "Latvian", + "Lithuanian": "Lithuanian", + "Luxembourgish": "Luxembourgish", + "Macedonian": "Macedonian", + "Malagasy": "Malagasy", + "Malay": "Malay", + "Malayalam": "Malayalam", + "Maltese": "Maltese", + "Maori": "Maori", + "Marathi": "Marathi", + "Mongolian": "Mongolian", + "Nepali": "Nepali", + "Norwegian Bokmål": "Norwegian Bokmål", + "Nyanja": "Nyanja", + "Pashto": "Pashto", + "Persian": "Persian", + "Polish": "Polish", + "Portuguese": "Portuguese", + "Portuguese (auto-generated)": "Portuguese (auto-generated)", + "Portuguese (Brazil)": "Portuguese (Brazil)", + "Punjabi": "Punjabi", + "Romanian": "Romanian", + "Russian": "Russian", + "Russian (auto-generated)": "Russian (auto-generated)", + "Samoan": "Samoan", + "Scottish Gaelic": "Scottish Gaelic", + "Serbian": "Serbian", + "Shona": "Shona", + "Sindhi": "Sindhi", + "Sinhala": "Sinhala", + "Slovak": "Slovak", + "Slovenian": "Slovenian", + "Somali": "Somali", + "Southern Sotho": "Southern Sotho", + "Spanish": "Spanish", + "Spanish (auto-generated)": "Spanish (auto-generated)", + "Spanish (Latin America)": "Spanish (Latin America)", + "Spanish (Mexico)": "Spanish (Mexico)", + "Spanish (Spain)": "Spanish (Spain)", + "Sundanese": "Sundanese", + "Swahili": "Swahili", + "Swedish": "Swedish", + "Tajik": "Tajik", + "Tamil": "Tamil", + "Telugu": "Telugu", + "Thai": "Thai", + "Turkish": "Turkish", + "Turkish (auto-generated)": "Turkish (auto-generated)", + "Ukrainian": "Ukrainian", + "Urdu": "Urdu", + "Uzbek": "Uzbek", + "Vietnamese": "Vietnamese", + "Vietnamese (auto-generated)": "Vietnamese (auto-generated)", + "Welsh": "Welsh", + "Western Frisian": "Western Frisian", + "Xhosa": "Xhosa", + "Yiddish": "Yiddish", + "Yoruba": "Yoruba", + "Zulu": "Zulu", + "generic_count_years": "{{count}} year", + "generic_count_years_plural": "{{count}} years", + "generic_count_months": "{{count}} month", + "generic_count_months_plural": "{{count}} months", + "generic_count_weeks": "{{count}} week", + "generic_count_weeks_plural": "{{count}} weeks", + "generic_count_days": "{{count}} day", + "generic_count_days_plural": "{{count}} days", + "generic_count_hours": "{{count}} hour", + "generic_count_hours_plural": "{{count}} hours", + "generic_count_minutes": "{{count}} minute", + "generic_count_minutes_plural": "{{count}} minutes", + "generic_count_seconds": "{{count}} second", + "generic_count_seconds_plural": "{{count}} seconds", + "Fallback comments: ": "Fallback comments: ", + "Popular": "Popular", + "Search": "Search", + "Top": "Top", + "About": "About", + "Rating: ": "Rating: ", + "preferences_locale_label": "Language: ", + "View as playlist": "View as playlist", + "Default": "Default", + "Music": "Music", + "Gaming": "Gaming", + "Livestreams": "Livestreams", + "News": "News", + "Movies": "Movies", + "Download": "Download", + "Download as: ": "Download as: ", + "Download is disabled": "Download is disabled", + "%A %B %-d, %Y": "%A %B %-d, %Y", + "(edited)": "(edited)", + "YouTube comment permalink": "YouTube comment permalink", + "permalink": "permalink", + "`x` marked it with a ❤": "`x` marked it with a ❤", + "Channel Sponsor": "Channel Sponsor", + "Audio mode": "Audio mode", + "Video mode": "Video mode", + "Playlists": "Playlists", + "search_filters_title": "Filters", + "search_filters_date_label": "Upload date", + "search_filters_date_option_none": "Any date", + "search_filters_date_option_hour": "Last hour", + "search_filters_date_option_today": "Today", + "search_filters_date_option_week": "This week", + "search_filters_date_option_month": "This month", + "search_filters_date_option_year": "This year", + "search_filters_type_label": "Type", + "search_filters_type_option_all": "Any type", + "search_filters_type_option_video": "Video", + "search_filters_type_option_channel": "Channel", + "search_filters_type_option_playlist": "Playlist", + "search_filters_type_option_movie": "Movie", + "search_filters_type_option_show": "Show", + "search_filters_duration_label": "Duration", + "search_filters_duration_option_none": "Any duration", + "search_filters_duration_option_short": "Short (< 4 minutes)", + "search_filters_duration_option_medium": "Medium (4 - 20 minutes)", + "search_filters_duration_option_long": "Long (> 20 minutes)", + "search_filters_features_label": "Features", + "search_filters_features_option_live": "Live", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Subtitles/CC", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_sixty": "360°", + "search_filters_features_option_vr180": "VR180", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_hdr": "HDR", + "search_filters_features_option_location": "Location", + "search_filters_features_option_purchased": "Purchased", + "search_filters_sort_label": "Sort By", + "search_filters_sort_option_relevance": "Relevance", + "search_filters_sort_option_rating": "Rating", + "search_filters_sort_option_date": "Upload date", + "search_filters_sort_option_views": "View count", + "search_filters_apply_button": "Apply selected filters", + "Current version: ": "Current version: ", + "next_steps_error_message": "After which you should try to: ", + "next_steps_error_message_refresh": "Refresh", + "next_steps_error_message_go_to_youtube": "Go to YouTube", + "footer_donate_page": "Donate", + "footer_documentation": "Documentation", + "footer_source_code": "Source code", + "footer_original_source_code": "Original source code", + "footer_modfied_source_code": "Modified source code", + "adminprefs_modified_source_code_url_label": "URL to modified source code repository", + "none": "none", + "videoinfo_started_streaming_x_ago": "Started streaming `x` ago", + "videoinfo_watch_on_youTube": "Watch on YouTube", + "videoinfo_youTube_embed_link": "Embed", + "videoinfo_invidious_embed_link": "Embed Link", + "download_subtitles": "Subtitles - `x` (.vtt)", + "user_created_playlists": "`x` created playlists", + "user_saved_playlists": "`x` saved playlists", + "Video unavailable": "Video unavailable", + "preferences_save_player_pos_label": "Save playback position: ", + "crash_page_you_found_a_bug": "It looks like you found a bug in Invidious!", + "crash_page_before_reporting": "Before reporting a bug, make sure that you have:", + "crash_page_refresh": "tried to refresh the page", + "crash_page_switch_instance": "tried to use another instance", + "crash_page_read_the_faq": "read the Frequently Asked Questions (FAQ)", + "crash_page_search_issue": "searched for existing issues on GitHub", + "crash_page_report_issue": "If none of the above helped, please open a new issue on GitHub (preferably in English) and include the following text in your message (do NOT translate that text):", + "error_video_not_in_playlist": "The requested video doesn't exist in this playlist. Click here for the playlist home page.", + "channel_tab_videos_label": "Videos", + "channel_tab_shorts_label": "Shorts", + "channel_tab_streams_label": "Livestreams", + "channel_tab_podcasts_label": "Podcasts", + "channel_tab_releases_label": "Releases", + "channel_tab_courses_label": "Courses", + "channel_tab_playlists_label": "Playlists", + "channel_tab_community_label": "Community", + "channel_tab_posts_label": "Posts", + "channel_tab_channels_label": "Channels", + "toggle_theme": "Toggle Theme", + "carousel_slide": "Slide {{current}} of {{total}}", + "carousel_skip": "Skip the Carousel", + "carousel_go_to": "Go to slide `x`", + "preferences_trending_enabled_label": "Trending enabled: ", + "preferences_search_enabled_label": "Search enabled: ", + "timeline_parse_error_placeholder_heading": "Unable to parse item", + "timeline_parse_error_placeholder_message": "Invidious encountered an error while trying to parse this item. For more information see below:", + "timeline_parse_error_show_technical_details": "Show technical details", + "dmca_content": "This video cannot be downloaded on this instance due to a DMCA/copyright infringement letter sent to the instance administrator." } diff --git a/spec/invidious/config_spec.cr b/spec/invidious/config_spec.cr new file mode 100644 index 00000000..50465851 --- /dev/null +++ b/spec/invidious/config_spec.cr @@ -0,0 +1,50 @@ +require "../spec_helper" +require "../../src/invidious/jobs.cr" +require "../../src/invidious/jobs/*" +require "../../src/invidious/config.cr" +require "../../src/invidious/user/preferences.cr" + +# Allow this file to be executed independently of other specs +{% if !@type.has_constant?("CONFIG") %} + CONFIG = Config.from_yaml("") +{% end %} + +private def construct_config(yaml) + config = Config.from_yaml(yaml) + File.open(File::NULL, "w") { |io| config.process_deprecation(io) } + return config +end + +Spectator.describe Config do + context "page_enabled" do + it "Can disable pages" do + config = construct_config <<-YAML + pages_enabled: + popular: false + search: false + YAML + + expect(config.page_enabled?("trending")).to eq(false) + expect(config.page_enabled?("popular")).to eq(false) + expect(config.page_enabled?("search")).to eq(false) + end + + it "Takes precedence over popular_enabled" do + config = construct_config <<-YAML + popular_enabled: false + pages_enabled: + popular: true + YAML + + expect(config.page_enabled?("popular")).to eq(true) + end + end + + it "Deprecated popular_enabled still works" do + config = construct_config <<-YAML + popular_enabled: false + YAML + + expect(config.page_enabled?("popular")).to eq(false) + end +end diff --git a/src/ext/kemal_static_file_handler.cr b/src/ext/kemal_static_file_handler.cr index 16cb84fb..9266d51e 100644 --- a/src/ext/kemal_static_file_handler.cr +++ b/src/ext/kemal_static_file_handler.cr @@ -185,7 +185,7 @@ module Kemal 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) + directory_listing(context.response, Path[request_path], Path[file_path]) else call_next(context) end diff --git a/src/invidious.cr b/src/invidious.cr index d7c5b80b..9ce002dd 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -178,7 +178,7 @@ if (CONFIG.use_pubsub_feeds.is_a?(Bool) && CONFIG.use_pubsub_feeds.as(Bool)) || Invidious::Jobs.register Invidious::Jobs::SubscribeToFeedsJob.new(PG_DB, HMAC_KEY) end -if CONFIG.popular_enabled +if CONFIG.page_enabled?("popular") Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) end @@ -200,6 +200,12 @@ end before_all do |env| Invidious::Routes::BeforeAll.handle(env) + + # If before_all flagged a halt (e.g. disabled page), stop the route handler. + # Use halt with the already-set status code to prevent the route handler from running. + if env.get?("halted") + halt env, status_code: env.response.status_code + end end Invidious::Routing.register_all diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 7853d9a3..933c6654 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -73,6 +73,31 @@ struct HTTPProxyConfig property port : Int32 end +# Structure used for global per-page feature toggles +record PagesEnabled, + trending : Bool = false, + popular : Bool = true, + search : Bool = true do + include YAML::Serializable + + def [](key : String) : Bool + fetch(key) { raise KeyError.new("Unknown page '#{key}'") } + end + + def []?(key : String) : Bool + fetch(key) { nil } + end + + private def fetch(key : String, &) + case key + when "trending" then @trending + when "popular" then @popular + when "search" then @search + else yield + end + end +end + class Config include YAML::Serializable @@ -116,13 +141,37 @@ class Config # Used to tell Invidious it is behind a proxy, so links to resources should be https:// property https_only : Bool? + # HMAC signing key for CSRF tokens and verifying pubsub subscriptions property hmac_key : String = "" # Domain to be used for links to resources on the site where an absolute URL is required property domain : String? # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) property use_pubsub_feeds : Bool | Int32 = false + + # ————————————————————————————————————————————————————————————————————————————————————— + + # A @{{key}}_present variable is required for both fields in order to handle the precedence for + # the deprecated `popular_enabled` in relations to `pages_enabled` + + # DEPRECATED: use `pages_enabled["popular"]` instead. + @[Deprecated("`popular_enabled` will be removed in a future release; use pages_enabled[\"popular\"] instead")] + @[YAML::Field(presence: true)] property popular_enabled : Bool = true + + @[YAML::Field(ignore: true)] + property popular_enabled_present : Bool + + # Global per-page feature toggles. + # Valid keys: "trending", "popular", "search" + # If someone sets both `popular_enabled` and `pages_enabled["popular"]`, the latter takes precedence. + @[YAML::Field(presence: true)] + property pages_enabled : PagesEnabled = PagesEnabled.from_yaml("") + + @[YAML::Field(ignore: true)] + property pages_enabled_present : Bool + # ————————————————————————————————————————————————————————————————————————————————————— + property captcha_enabled : Bool = true property login_enabled : Bool = true property registration_enabled : Bool = true @@ -185,16 +234,17 @@ class Config when Bool return disabled when Array - if disabled.includes? option - return true - else - return false - end + disabled.includes?(option) else - return false + false end end + # Centralized page toggle with legacy fallback for `popular_enabled` + def page_enabled?(page : String) : Bool + return @pages_enabled[page] + end + def self.load # Load config from file or YAML string env var env_config_file = "INVIDIOUS_CONFIG_FILE" @@ -232,6 +282,12 @@ class Config begin config.{{ivar.id}} = ivar_type.from_yaml(env_value) success = true + + # Update associated _present key if any + {% other_ivar = @type.instance_vars.find { |other_ivar| other_ivar.name == ivar.name + "_present" } %} + {% if other_ivar && (ann = other_ivar.annotation(YAML::Field)) && ann[:ignore] == true %} + config.{{other_ivar.name.id}} = true + {% end %} rescue # nop end @@ -283,6 +339,8 @@ class Config exit(1) end + config.process_deprecation + # Build database_url from db.* if it's not set directly if config.database_url.to_s.empty? if db = config.db @@ -320,4 +378,24 @@ class Config return config end + + # Processes deprecated values + # + # Warns when they are set and handles any precedence issue that may arise when present alongside a successor attribute + # + # This method is public as to allow specs to test the behavior without going through #load + # + # :nodoc: + def process_deprecation(log_io : IO = STDOUT) + # Handle deprecated popular_enabled config and warn if it is set + if self.popular_enabled_present + log_io.puts "Warning: `popular_enabled` has been deprecated and replaced by the `pages_enabled` config" + log_io.puts "If both are set `pages_enabled` will take precedence over `popular_enabled`" + + # Only use popular_enabled value when pages_enabled is unset + if !self.pages_enabled_present + self.pages_enabled = self.pages_enabled.copy_with(popular: self.popular_enabled) + end + end + end end diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index a35d2f2b..c469eb6c 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -313,6 +313,11 @@ module Invidious::Routes::API::V1::Authenticated return error_json(403, "Invalid videoId") end + # Prevent duplicate videos in the same playlist + if Invidious::Database::PlaylistVideos.select_index(plid, video_id) + return error_json(409, "Video already exists in this playlist") + end + begin video = get_video(video_id) rescue ex : NotFoundException diff --git a/src/invidious/routes/api/v1/feeds.cr b/src/invidious/routes/api/v1/feeds.cr index fea2993c..2e8328cb 100644 --- a/src/invidious/routes/api/v1/feeds.cr +++ b/src/invidious/routes/api/v1/feeds.cr @@ -29,11 +29,6 @@ module Invidious::Routes::API::V1::Feeds env.response.content_type = "application/json" - if !CONFIG.popular_enabled - error_message = {"error" => "Administrator has disabled this endpoint."}.to_json - haltf env, 403, error_message - end - JSON.build do |json| json.array do popular_videos.each do |video| diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr index 6d374fff..3ed881cf 100644 --- a/src/invidious/routes/before_all.cr +++ b/src/invidious/routes/before_all.cr @@ -115,6 +115,8 @@ module Invidious::Routes::BeforeAll preferences.locale = locale env.set "preferences", preferences + path = env.request.path + # Allow media resources to be loaded from google servers # TODO: check if *.youtube.com can be removed # @@ -130,7 +132,7 @@ module Invidious::Routes::BeforeAll env.response.headers["Content-Security-Policy"] = env.response.headers["Content-Security-Policy"].gsub("media-src", "media-src https://*.googlevideo.com:443 https://*.youtube.com:443") end - current_page = env.request.path + current_page = path if env.request.query query = HTTP::Params.parse(env.request.query.not_nil!) @@ -142,5 +144,72 @@ module Invidious::Routes::BeforeAll end env.set "current_page", URI.encode_www_form(current_page) + + page_key = case path + when "/feed/popular", "/api/v1/popular" + "popular" + when "/feed/trending", "/api/v1/trending" + "trending" + when "/api/v1/search", "/api/v1/search/suggestions" + "search" + when .starts_with?("/api/v1/hashtag/") + "search" + when "/search", "/results" + # Handled by the search route (subscription-only mode when search disabled) + nil + when .starts_with?("/hashtag/") + "search" + else + nil + end + + if page_key && !CONFIG.page_enabled?(page_key) + env.response.status_code = 403 + env.set "halted", true + + if path.starts_with?("/api/") + env.response.content_type = "application/json" + env.response.print({error: "Administrator has disabled this endpoint."}.to_json) + else + preferences = env.get("preferences").as(Preferences) + locale = preferences.locale + dark_mode = preferences.dark_mode + theme_class = dark_mode.blank? ? "no" : dark_mode + error_message = translate(locale, "#{page_key}_page_disabled") + + env.response.content_type = "text/html" + env.response.print <<-HTML + + + + + + Error - Invidious + + + + + + +
+
+ +
+

#{error_message}

+

← #{translate(locale, "Back")}

+
+
+
+ + + HTML + end + + return + end end end diff --git a/src/invidious/routes/debug_config.cr b/src/invidious/routes/debug_config.cr new file mode 100644 index 00000000..8378929b --- /dev/null +++ b/src/invidious/routes/debug_config.cr @@ -0,0 +1,114 @@ +# Debug route to verify configuration is loaded correctly +# This can help diagnose issues with pages_enabled configuration + +module Invidious::Routes::DebugConfig + def self.show(env) + # Only allow access to admins or in development mode + if CONFIG.admins.empty? || (user = env.get? "user") + admin_user = user.try &.as(User) + if !admin_user || !CONFIG.admins.includes?(admin_user.email) + return error_template(403, "Administrator privileges required") + end + else + # If no user is logged in and admins are configured, deny access + return error_template(403, "Administrator privileges required") + end + + html = <<-HTML + + + + Configuration Debug - Invidious + + + +

Invidious Configuration Debug

+ +

Pages Configuration

+ + + + + + + + + + + + + + + + + + + + + +
PageStatusConfiguration Value
Popular + #{CONFIG.page_enabled?("popular") ? "ENABLED" : "DISABLED"} + #{CONFIG.pages_enabled.popular}
Trending + #{CONFIG.page_enabled?("trending") ? "ENABLED" : "DISABLED"} + #{CONFIG.pages_enabled.trending}
Search + #{CONFIG.page_enabled?("search") ? "ENABLED" : "DISABLED"} + #{CONFIG.pages_enabled.search}
+ +

Configuration Flags

+ + + + + + + + + + + + + + + + + +
FlagValue
pages_enabled_present#{CONFIG.pages_enabled_present}
popular_enabled_present (deprecated)#{CONFIG.popular_enabled_present}
popular_enabled (deprecated)#{CONFIG.popular_enabled}
+ +

Blocked Routes

+

The following routes should be blocked based on current configuration:

+ + +

Test Links

+

Click these links to verify they are properly blocked:

+ + +

Raw Configuration

+
pages_enabled: #{CONFIG.pages_enabled.inspect}
+ +

Environment Check

+
INVIDIOUS_CONFIG present: #{ENV.has_key?("INVIDIOUS_CONFIG")}
+
INVIDIOUS_PAGES_ENABLED present: #{ENV.has_key?("INVIDIOUS_PAGES_ENABLED")}
+ + + HTML + + env.response.content_type = "text/html" + env.response.print html + end +end \ No newline at end of file diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index ce173760..99ab19f1 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -33,13 +33,7 @@ module Invidious::Routes::Feeds def self.popular(env) locale = env.get("preferences").as(Preferences).locale - - if CONFIG.popular_enabled - templated "feeds/popular" - else - message = translate(locale, "The Popular feed has been disabled by the administrator.") - templated "message" - end + templated "feeds/popular" end def self.trending(env) diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index 56e529b2..7fd0caf3 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -330,6 +330,15 @@ module Invidious::Routes::Playlists video_id = env.params.query["video_id"] + # Prevent duplicate videos in the same playlist + if Invidious::Database::PlaylistVideos.select_index(playlist_id, video_id) + if redirect + return env.redirect referer + else + return error_json(409, "Video already exists in this playlist") + end + end + begin video = get_video(video_id) rescue ex : NotFoundException diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index d9fad1b1..154f4637 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -201,9 +201,11 @@ module Invidious::Routes::PreferencesRoute end CONFIG.default_user_preferences.feed_menu = admin_feed_menu - popular_enabled = env.params.body["popular_enabled"]?.try &.as(String) - popular_enabled ||= "off" - CONFIG.popular_enabled = popular_enabled == "on" + CONFIG.pages_enabled = PagesEnabled.new( + popular: (env.params.body["popular_enabled"]?.try &.as(String) || "off") == "on", + trending: (env.params.body["trending_enabled"]?.try &.as(String) || "off") == "on", + search: (env.params.body["search_enabled"]?.try &.as(String) || "off") == "on", + ) captcha_enabled = env.params.body["captcha_enabled"]?.try &.as(String) captcha_enabled ||= "off" diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index 11e6f171..a8999a5e 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -40,52 +40,78 @@ module Invidious::Routes::Search preferences = env.get("preferences").as(Preferences) locale = preferences.locale + search_disabled = !CONFIG.page_enabled?("search") + region = env.params.query["region"]? || preferences.region query = Invidious::Search::Query.new(env.params.query, :regular, region) + # empty query → show homepage if query.empty? - # Display the full page search box implemented in #1977 env.set "search", "" - templated "search_homepage", navbar_search: false - else - user = env.get? "user" + return templated "search_homepage", navbar_search: false + end - # An URL was copy/pasted in the search box. - # Redirect the user to the appropriate page. - if query.url? - return env.redirect UrlSanitizer.process(query.text).to_s + # When YouTube search is disabled, force subscription-only mode + if search_disabled + user = env.get?("user") + if !user + return error_template(403, translate(locale, "search_subscriptions_login_required")) end + user = user.as(User) + begin - if user - items = query.process(user.as(User)) - else - 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'.") + items = Invidious::Search::Processors.subscriptions_and_playlists(query, user) rescue ex - return error_template(500, ex) + return error_template 500, ex end redirect_url = Invidious::Frontend::Misc.redirect_url(env) - # Pagination page_nav_html = Frontend::Pagination.nav_numeric(locale, base_url: "/search?#{query.to_http_params}", current_page: query.page, show_next: (items.size >= 20) ) - if query.type == Invidious::Search::Query::Type::Channel - env.set "search", "channel:#{query.channel} #{query.text}" - else - env.set "search", query.text - end + env.set "search", query.text + env.set "subscription_only_search", true - templated "search" + return templated "search" end + + # non‐empty query → process it + user = env.get?("user") + if query.url? + return env.redirect UrlSanitizer.process(query.text).to_s + end + + begin + items = user ? query.process(user.as(User)) : query.process + rescue ex : ChannelSearchException + return error_template 404, "Unable to find channel with id "#{HTML.escape(ex.channel)}"…" + rescue ex + return error_template 500, ex + end + + redirect_url = Invidious::Frontend::Misc.redirect_url(env) + + # Pagination + page_nav_html = Frontend::Pagination.nav_numeric(locale, + base_url: "/search?#{query.to_http_params}", + current_page: query.page, + show_next: (items.size >= 20) + ) + + # If it's a channel search, prefix the box; otherwise just show the text + if query.type == Invidious::Search::Query::Type::Channel + env.set "search", "channel:#{query.channel} #{query.text}" + else + env.set "search", query.text + end + + templated "search" end def self.hashtag(env : HTTP::Server::Context) diff --git a/src/invidious/search/processors.cr b/src/invidious/search/processors.cr index 25edb936..1416c58f 100644 --- a/src/invidious/search/processors.cr +++ b/src/invidious/search/processors.cr @@ -52,5 +52,57 @@ module Invidious::Search as: ChannelVideo ) end + + # Search subscriptions AND saved playlists (used when YouTube search is disabled) + def subscriptions_and_playlists(query : Query, user : Invidious::User) : Array(ChannelVideo) + view_name = "subscriptions_#{sha256(user.email)}" + offset = (query.page - 1) * 20 + + # Search subscription videos via the materialized view + sub_results = PG_DB.query_all(" + SELECT id, title, published, updated, ucid, author, length_seconds + FROM ( + SELECT *, + to_tsvector(#{view_name}.title) || + to_tsvector(#{view_name}.author) + as document + FROM #{view_name} + ) v_search WHERE v_search.document @@ plainto_tsquery($1) + ORDER BY published DESC + LIMIT 20 OFFSET $2;", + query.text, offset, + as: ChannelVideo + ) + + # Search playlist videos from user's playlists (both created and saved) + playlist_results = PG_DB.query_all(" + SELECT pv.id, pv.title, pv.published, pv.published AS updated, + pv.ucid, pv.author, pv.length_seconds + FROM playlist_videos pv + INNER JOIN playlists p ON p.id = pv.plid + WHERE p.author = $1 + AND (to_tsvector(pv.title) || to_tsvector(pv.author)) @@ plainto_tsquery($2) + ORDER BY pv.published DESC + LIMIT 20 OFFSET $3;", + user.email, query.text, offset, + as: ChannelVideo + ) + + # Merge, deduplicate by video ID, sort by published date, limit to 20 + seen = Set(String).new + combined = [] of ChannelVideo + + (sub_results + playlist_results) + .sort_by { |v| v.published } + .reverse! + .each do |video| + next if seen.includes?(video.id) + seen.add(video.id) + combined << video + break if combined.size >= 20 + end + + return combined + end end end diff --git a/src/invidious/views/components/feed_menu.ecr b/src/invidious/views/components/feed_menu.ecr index 3dbeaf37..b8e60e3a 100644 --- a/src/invidious/views/components/feed_menu.ecr +++ b/src/invidious/views/components/feed_menu.ecr @@ -3,6 +3,18 @@ <% if !env.get?("user") %> <% feed_menu.reject! {|item| {"Subscriptions", "Playlists"}.includes? item} %> <% end %> + <% feed_menu.reject! do |feed| + case feed + when "Popular" + !CONFIG.page_enabled?("popular") + when "Trending" + !CONFIG.page_enabled?("trending") + when "" + !CONFIG.page_enabled?("search") + else + false + end + end %> <% feed_menu.each do |feed| %> <%= translate(locale, feed) %> diff --git a/src/invidious/views/components/search_box.ecr b/src/invidious/views/components/search_box.ecr index 29da2c52..3fb31628 100644 --- a/src/invidious/views/components/search_box.ecr +++ b/src/invidious/views/components/search_box.ecr @@ -1,12 +1,13 @@
+ <% search_placeholder = CONFIG.page_enabled?("search") ? translate(locale, "search") : translate(locale, "search_subscriptions_placeholder") %> autofocus<% end %> - name="q" placeholder="<%= translate(locale, "search") %>" - title="<%= translate(locale, "search") %>" + name="q" placeholder="<%= search_placeholder %>" + title="<%= search_placeholder %>" value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>">
-
diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr index b1300214..0848bfef 100644 --- a/src/invidious/views/search.ecr +++ b/src/invidious/views/search.ecr @@ -3,17 +3,31 @@ <% end %> +<% subscription_only = env.get?("subscription_only_search") %> + +<% if subscription_only %> +
+

<%= translate(locale, "search_subscriptions_mode_notice") %>

+
+
+<% else %> <%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %>
+<% end %> <%- if items.empty? -%>
- <%= translate(locale, "search_message_no_results") %>

- <%= translate(locale, "search_message_change_filters_or_query") %>

- <%= translate(locale, "search_message_use_another_instance", redirect_url) %> + <% if subscription_only %> + <%= translate(locale, "search_subscriptions_no_results") %>

+ <%= translate(locale, "search_subscriptions_no_results_hint") %> + <% else %> + <%= translate(locale, "search_message_no_results") %>

+ <%= translate(locale, "search_message_change_filters_or_query") %>

+ <%= translate(locale, "search_message_use_another_instance", redirect_url) %> + <% end %>
<%- else -%> diff --git a/src/invidious/views/search_homepage.ecr b/src/invidious/views/search_homepage.ecr index 2424a1cf..2feff931 100644 --- a/src/invidious/views/search_homepage.ecr +++ b/src/invidious/views/search_homepage.ecr @@ -17,4 +17,11 @@ <% autofocus = true %><%= rendered "components/search_box" %>
+ <% if !CONFIG.page_enabled?("search") %> +
+
+

<%= translate(locale, "search_subscriptions_hint") %>

+
+
+ <% end %> diff --git a/src/invidious/views/user/preferences.ecr b/src/invidious/views/user/preferences.ecr index 23cb89f6..80011e65 100644 --- a/src/invidious/views/user/preferences.ecr +++ b/src/invidious/views/user/preferences.ecr @@ -182,11 +182,20 @@ checked<% end %>> - <% if env.get?("user") %> - <% feed_options = {"", "Popular", "Trending", "Subscriptions", "Playlists"} %> - <% else %> - <% feed_options = {"", "Popular", "Trending"} %> - <% end %> + <% + # Build feed options based on enabled pages + feed_options = [] of String + # Empty string represents Search page + feed_options << "" if CONFIG.page_enabled?("search") + feed_options << "Popular" if CONFIG.page_enabled?("popular") + feed_options << "Trending" if CONFIG.page_enabled?("trending") + if env.get?("user") + feed_options << "Subscriptions" + feed_options << "Playlists" + end + # Always add "none" option as fallback + feed_options << "" if !feed_options.includes?("") + %>
@@ -302,9 +311,18 @@
- checked<% end %>> + checked<% end %>>
+
+ + checked<% end %>> +
+ +
+ + checked<% end %>> +