;;; yeetube.el --- Scrape YouTube, Play with mpv & Download with yt-dlp  -*- lexical-binding: t; -*-

;; Copyright (C) 2023-2024  Thanos Apollo

;; Author: Thanos Apollo <public@thanosapollo.org>
;; Keywords: extensions youtube videos
;; URL: https://thanosapollo.org/projects/yeetube/
;; Package-Version: 20260705.2147
;; Package-Revision: 4bbb5c37b778

;; Package-Requires: ((emacs "29.1") (compat "29.1.4.2") (keymap-popup "0.2.0"))

;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with this program.  If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:

;; This package provides the ability to scrape YouTube, with the results
;; displayed in a tabulated list.
;;
;; Core features include:
;;
;; - Executing YouTube queries
;; - Playing videos, by default using MPV
;; - Downloading videos, using yt-dlp
;; - Bookmarking and saving video URLs
;; - A standalone, minimalist yt-dlp frontend

;;; Code:

(require 'compat)
(require 'url)
(require 'cl-lib)
(require 'seq)
(require 'socks)
(require 'url-handlers)
(require 'xdg)
(require 'json)
(require 'subr-x)
(require 'xml)
(require 'dom)

(require 'keymap-popup)
(require 'yeetube-scraper)
(require 'yeetube-ui)
(require 'yeetube-mpv)
(require 'yeetube-download)

(defgroup yeetube nil
  "Youtube Front-End."
  :group 'external
  :prefix "yeetube-")

(defcustom yeetube-torsocks-program (and (executable-find "torsocks") "torsocks")
  "Path for torsocks executable."
  :type '(choice (const :tag "Not found" nil) string))

(defcustom yeetube-ytdlp-program (and (executable-find "yt-dlp") "yt-dlp")
  "Path for yt-dlp executable."
  :type '(choice (const :tag "Not found" nil) string))

(defcustom yeetube-results-limit 20
  "Define a limit for search results."
  :type 'natnum)

(defcustom yeetube-play-function #'yeetube-mpv-play
  "Select media player function."
  :type 'function)

(defcustom yeetube-download-audio-format nil
  "Download videos as specified audio only formats."
  :type '(radio (const :tag "None" nil)
		(const :tag "AAC" "aac")
		(const :tag "ALAC" "alac")
		(const :tag "FLAC" "flac")
		(const :tag "M4A" "m4a")
		(const :tag "MP3" "mp3")
		(const :tag "OPUS" "opus")
		(const :tag "Vorbis" "vorbis")
		(const :tag "WAV" "wav")))

(defcustom yeetube-download-directory (or (xdg-user-dir "DOWNLOAD") (getenv "HOME"))
  "Default directory to download videos."
  :type 'string)

(defcustom yeetube-filter "Relevance"
  "Sort search results for value.
Valid options include:
- \"Relevance\"
- \"Date\"
- \"Views\"
- \"Rating\""
  :type '(radio (const "Relevance")
		(const "Date")
		(const "Views")
		(const "Rating")))

(defcustom yeetube-default-sort-column nil
  "Column to sort the search results table."
  :type '(radio (const :tag "None" nil)
		(const "Title")
                (const "Views")
                (const "Duration")
                (const "Channel")))

(defcustom yeetube-default-sort-ascending nil
  "Whether to sort the search results in ascending order."
  :type 'boolean)

(defcustom yeetube-enable-tor nil
  "Enable routing through tor."
  :type 'boolean)

(defcustom yeetube-enable-emojis (and (char-displayable-p ?\N{GRINNING FACE}) t)
  "Enable emojis in *yeetube* buffer."
  :type 'boolean)

(defcustom yeetube-pop-to-same-window-p t
  "When non-nil, create *yeetube* buffer at the same window."
  :type 'boolean)

(defcustom yeetube-thumbnail-size '(120 . 90)
  "Thumbnail size (width . height)."
  :type '(cons integer integer))

(defcustom yeetube-display-thumbnails-p t
  "When non-nil, fetch & display thumbnails."
  :type 'boolean)

(defvar yeetube-invidious-instances
  '("vid.puffyan.us" "inv.nadeko.net" "invidious.flokinet.to")
  "List of invidious instances.")

(defconst yeetube--buffer-name "*yeetube*"
  "Name of the buffer displaying search results.")

(defvar yeetube-content nil
  "Tabulated-list rows (ID VECTOR) pairs.")

(defvar yeetube-items nil
  "List of scraped item plists.")

(defvar-local yeetube--continuation nil
  "Continuation plist for pagination.")

;; Switch vars yeetube-mpv-no-video and yeetube-mpv-enable-torsocks are
;; auto-generated by the yeetube-settings-map definition below.

(defvar-local yeetube--results-limit nil
  "Buffer-local results limit.")

(defvar-local yeetube-mpv-video-quality nil
  "Buffer-local video quality.")

(defvar-local yeetube--download-directory nil
  "Buffer-local download directory.")

(defvar-local yeetube--audio-format nil
  "Buffer-local audio format.")

(defvar-local yeetube--current-url nil
  "URL that produced the current results.
Used to re-fetch when the results limit changes.")

(defvar yeetube-saved-videos nil
  "Saved/bookmarked video urls.")

(defvar yeetube-history nil
  "Stored urls & titles of recently played content.")

(defvar yeetube-search-history nil
  "History of search terms.")

(defvar yeetube-video-url "https://youtube.com/watch?v="
  "URL used to play videos from.

You can change this value to an invidious instance.  Although yeetube
will still query youtube, `yeetube-play' will use the above url to play
videos from.")

(defvar yeetube-playlist-url "https://youtube.com/playlist?list="
  "URL used to play playlists from.

You can change this value to an invidious instance.  Although yeetube
will still query youtube, `yeetube-play' will use the above url to play
videos from.")

(defvar-local yeetube--channel-id nil
  "Current channel ID.")

(defconst yeetube-rss-feed-url
  "https://www.youtube.com/feeds/videos.xml?channel_id="
  "Base URL for YouTube RSS feeds.")

(defvar yeetube-filter-code-alist
  '(("Relevance" . "EgIQAQ%253D%253D")
    ("Date" . "CAISAhAB")
    ("Views" . "CAMSAhAB")
    ("Rating" . "CAESAhAB"))
  "YouTube's opaque sort filter codes, appended as &sp= parameter.")

(defcustom yeetube-request-headers
  '(("Accept-Language" . "en-US,en;q=0.9")
    ("Accept" . "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8")
    ("User-Agent" . "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0")
    ("Cookie" . "ucbcb=1; gdpr=1; cookieconsent_status=allow"))
  "HTTP request headers sent on every yeetube fetch.
Bound around `url-retrieve' as `url-request-extra-headers'.  The
default `Cookie' header bypasses YouTube's EU cookie-consent
redirect via `ucbcb=1', plus the broader `gdpr=1' /
`cookieconsent_status=allow' pair for any CMP we land on.  Adjust
`Accept-Language' to bias responses to your locale."
  :type '(alist :key-type string :value-type string))

(defvar yeetube--client-version "2.20260414.01.00"
  "YouTube API client version for continuation requests.")


;;; Helpers

(defun yeetube--find-item (id)
  "Find item plist with ID in `yeetube-items'."
  (cl-find id yeetube-items
           :key (lambda (item) (plist-get item :id))
           :test #'equal))

(defun yeetube-get-filter-code (filter)
  "Get FILTER code for sorting search results."
  (cdr (assoc filter yeetube-filter-code-alist)))

(defmacro yeetube-with-tor-socks (&rest body)
  "Execute BODY, routed through tor when `yeetube-enable-tor' is non-nil."
  `(let ((url-gateway-method (if yeetube-enable-tor 'socks url-gateway-method))
         (socks-noproxy (if yeetube-enable-tor '("localhost") socks-noproxy))
         (socks-server (if yeetube-enable-tor
                           '("Default server" "127.0.0.1" 9050 5)
                         socks-server)))
     ,@body))

;; Function wrapper for use from other modules: the macro above is not
;; available when they are byte-compiled, a call here resolves at runtime.
(defun yeetube--queue-retrieve (url callback cbargs)
  "Queue retrieval of URL, calling CALLBACK with CBARGS.
Routed through tor when `yeetube-enable-tor' is non-nil."
  (yeetube-with-tor-socks
   (url-queue-retrieve url callback cbargs 'silent 'inhibit-cookies)))

(defun yeetube-get-url (&optional id type)
  "Get video or playlist url for entry ID, adjusted for TYPE."
  (let* ((id (or id (tabulated-list-get-id)))
         (item (yeetube--find-item id))
         (type (or type (plist-get item :type))))
    (format "%s%s" (if (eq type 'video)
                       yeetube-video-url
                     yeetube-playlist-url)
            id)))

(defun yeetube-channel-id-at-point ()
  "Return yeetube channel id at point."
  (when (derived-mode-p 'yeetube-mode)
    (let* ((id (tabulated-list-get-id))
           (item (yeetube--find-item id)))
      (plist-get item :channel-id))))

(defun yeetube--read-channel-id ()
  "Read a YouTube channel identifier."
  (let ((channel (string-trim (read-string "Channel: "))))
    (cond ((string-empty-p channel)
           (user-error "No channel specified"))
          ((or (string-prefix-p "@" channel)
               (string-prefix-p "/" channel))
           channel)
          (t (format "@%s" channel)))))

(defun yeetube--browse-id-at-point ()
  "Return YouTube browse ID for the item at point.
Dormant infrastructure: consumed by the future `yeetube-home'
RSS-feed view (see the `yeetube--rss-*' family in this file)."
  (let* ((id (tabulated-list-get-id))
         (item (yeetube--find-item id)))
    (plist-get item :browse-id)))

(defun yeetube--normalize-channel-id (channel-id)
  "Return normalized channel identifier from CHANNEL-ID."
  (cond ((string-prefix-p "/@" channel-id) (substring channel-id 2))
        ((string-prefix-p "@" channel-id) (substring channel-id 1))
        ((string-prefix-p "/channel/" channel-id) (substring channel-id 9))
        (t channel-id)))

(defun yeetube--channel-url (channel-id path)
  "Return a YouTube channel URL for CHANNEL-ID and PATH."
  (format "https://youtube.com/%s/%s"
          (string-remove-prefix "/" channel-id)
          (string-remove-prefix "/" path)))

(defun yeetube-read-query ()
  "Interactively read a search term."
  (read-string "Yeetube Search: " nil 'yeetube-search-history))

(defun yeetube--display-loading ()
  "Pop to the results buffer and show a loading indicator."
  (pop-to-buffer-same-window yeetube--buffer-name)
  (let ((inhibit-read-only t))
    (erase-buffer)
    (insert (propertize "Loading..." 'face 'bold-italic))))


;;; Playback

;;;###autoload
(defun yeetube-play ()
  "Play video at point in *yeetube* buffer."
  (interactive)
  (let* ((id (tabulated-list-get-id))
         (item (yeetube--find-item id))
         (url (yeetube-get-url id (plist-get item :type)))
         (title (plist-get item :title))
         (proc (apply yeetube-play-function url
                      (and yeetube-mpv-modeline-mode (list title)))))
    (when (processp proc)
      (process-put proc :now-playing title))
    (push (list :url url :title title) yeetube-history)
    (message "Playing: %s" title)))

;;;###autoload
(defun yeetube-copy-url ()
  "Copy entry URL at point."
  (interactive)
  (cl-assert (derived-mode-p 'yeetube-mode) nil "Yeetube mode not enabled")
  (let ((url (yeetube-get-url)))
    (kill-new url)
    (message "Copied url: %s" url)))

;;;###autoload
(defun yeetube-copy-rss-feed-url ()
  "Copy the RSS feed URL for the channel at point."
  (interactive)
  (cl-assert (derived-mode-p 'yeetube-mode) nil "Yeetube mode not enabled")
  (let* ((id (tabulated-list-get-id))
         (item (yeetube--find-item id))
         (browse-id (plist-get item :browse-id)))
    (if (and browse-id (not (string-empty-p browse-id)))
        (let ((url (concat yeetube-rss-feed-url browse-id)))
          (kill-new url)
          (message "Copied RSS feed: %s" url))
      (message "No channel ID available for this entry"))))

;;;###autoload
(defun yeetube-replay ()
  "Select entry from history to replay.

Select entry title from `yeetube-history' and play corresponding URL."
  (interactive)
  (let* ((titles (mapcar (lambda (entry) (plist-get entry :title)) yeetube-history))
         (selected (completing-read "Replay: " titles))
         (selected-entry (cl-find-if (lambda (entry)
				       (string= selected (plist-get entry :title)))
				     yeetube-history))
	 (title (plist-get selected-entry :title))
         (url (plist-get selected-entry :url)))
    (funcall yeetube-play-function url (and yeetube-mpv-modeline-mode title))
    (message "Replaying: %s" selected)))

;;;###autoload
(defun yeetube-browse-url ()
  "Open URL for video at point, using an invidious instance."
  (interactive)
  (let ((instance (cond ((stringp yeetube-invidious-instances)
                         yeetube-invidious-instances)
                        (yeetube-invidious-instances
                         (seq-random-elt yeetube-invidious-instances))
                        (t (user-error "No invidious instances configured")))))
    (browse-url
     (string-replace "youtube.com" instance (yeetube-get-url)))))


;;; Bookmarks

(defun yeetube-load-saved-videos ()
  "Load saved videos."
  (let ((file-path (locate-user-emacs-file "yeetube")))
    (if (file-exists-p file-path)
	(with-temp-buffer
	  (insert-file-contents file-path)
	  (goto-char (point-min))
	  (let ((contents (read (current-buffer))))
	    (setf yeetube-saved-videos contents)))
      (write-region "nil" nil file-path))))

;;;###autoload
(defun yeetube-save-video (arg)
  "Save url at point.

If ARG is non-nil, save as a playlist URL."
  (interactive "P")
  (yeetube-load-saved-videos)
  (let ((name (read-string "Save as: "))
	(url (yeetube-get-url (tabulated-list-get-id) (if arg 'playlist 'video))))
    (push (cons name url) yeetube-saved-videos)
    (yeetube-save-saved-videos)))

;; We could use keywords here, but it would break users saved videos
;; from previous versions.
;;;###autoload
(defun yeetube-play-saved-video ()
  "Select & Play a saved video."
  (interactive)
  (yeetube-load-saved-videos)
  (let* ((video (completing-read "Select video: " yeetube-saved-videos nil t))
         (entry (assoc video yeetube-saved-videos))
         (title (car entry))
         (url (cdr entry)))
    (funcall yeetube-play-function url (and yeetube-mpv-modeline-mode title))
    (message "Playing: %s" title)))

;;;###autoload
(defun yeetube-remove-saved-video ()
  "Select video to remove from saved videos."
  (interactive)
  (yeetube-load-saved-videos)
  (let ((video (completing-read "Select video: " yeetube-saved-videos nil t)))
    (setf yeetube-saved-videos (remq (assoc video yeetube-saved-videos) yeetube-saved-videos))
    (yeetube-save-saved-videos)))

;;;###autoload
(defun yeetube-remove-all-saved-videos ()
  "Clear all saved videos from yeetube."
  (interactive)
  (when (y-or-n-p "Delete saved?")
    (setf yeetube-saved-videos nil)
    (yeetube-save-saved-videos)))

(defun yeetube-save-saved-videos ()
  "Write `yeetube-saved-videos' to disk."
  (let ((file-path (locate-user-emacs-file "yeetube")))
    (with-temp-buffer
      (insert (pp-to-string yeetube-saved-videos))
      (write-region (point-min) (point-max) file-path))))


;;; Download

;;;###autoload
(defun yeetube-download-video (&optional url)
  "Download entry at point in *yeetube* buffer with yt-dlp.

Content will be downloaded at `yeetube-download-directory'.
Optionally, provide custom own URL."
  (interactive)
  (let* ((id (tabulated-list-get-id))
         (item (yeetube--find-item id))
         (type (plist-get item :type))
         (url (or url (yeetube-get-url id type)))
         (title (or (plist-get item :title) "Unknown")))
    (when (string-prefix-p "http" url)
      (let ((default-directory (or yeetube--download-directory
                                   yeetube-download-directory)))
        (yeetube-download--ytdlp url nil (or yeetube--audio-format
                                             yeetube-download-audio-format))
        (message "Downloading: '%s' at '%s'" title default-directory)))))


;;; Search & Callbacks

;; The `yeetube--rss-*' family and `yeetube-display-rss-feed' below are
;; dormant infrastructure intended for the upcoming `yeetube-home'
;; subscription/feed view.  They are not wired into any interactive
;; command yet: `yeetube-channel-videos' scrapes the HTML videos tab
;; instead, which is the only way to get duration/views and to paginate
;; past YouTube's 15-entry RSS cap.

(defun yeetube--rss-feed-url (browse-id)
  "Return YouTube RSS feed URL for BROWSE-ID."
  (concat yeetube-rss-feed-url browse-id))

(defun yeetube--rss-entry-text (entry tag)
  "Return text for direct child TAG in RSS ENTRY, or an empty string."
  (or (when-let* ((node (dom-child-by-tag entry tag)))
        ;; Not `dom-inner-text', which is new in Emacs 31.
        (string-trim (dom-texts node "")))
      ""))

(defun yeetube--rss-author-name (entry)
  "Return author name from RSS ENTRY, or an empty string."
  (or (when-let* ((author (dom-child-by-tag entry 'author))
                  (name (dom-child-by-tag author 'name)))
        (string-trim (dom-texts name "")))
      ""))

(defun yeetube--rss-channel-path (browse-id)
  "Return channel path for BROWSE-ID, or an empty string."
  (if (string-empty-p browse-id)
      ""
    (format "/channel/%s" browse-id)))

(defun yeetube--rss-entry-views (entry)
  "Return view count string from ENTRY's `media:statistics' tag.
YouTube nests `media:statistics' under `media:group', so this uses
recursive descent.  The view count is exposed as the `views'
attribute.  Returns an empty string when unavailable."
  (or (when-let* ((node (car (dom-by-tag entry 'media:statistics)))
                  (views (dom-attr node 'views)))
        views)
      ""))

(defun yeetube--rss-entry-thumbnail (entry video-id)
  "Return thumbnail URL for ENTRY, falling back to VIDEO-ID's default.
YouTube nests `media:thumbnail' under `media:group', so this uses
recursive descent.  Rewrites `hqdefault' to `mqdefault' because
hqdefault adds black bars at the top and bottom (NewPipeExtractor
does the same rewrite for the same reason)."
  (or (when-let* ((node (car (dom-by-tag entry 'media:thumbnail)))
                  (url (dom-attr node 'url))
                  ((not (string-empty-p url))))
        (string-replace "hqdefault" "mqdefault" url))
      (format "https://i.ytimg.com/vi/%s/default.jpg" video-id)))

(defun yeetube--rss-entry-item (entry)
  "Convert RSS ENTRY to a yeetube item plist."
  (let* ((id (yeetube--rss-entry-text entry 'yt:videoId))
         (browse-id (yeetube--rss-entry-text entry 'yt:channelId))
         (published (yeetube--rss-entry-text entry 'published))
         (date (or (and (not (string-empty-p published)) published)
                   (yeetube--rss-entry-text entry 'updated))))
    (and (not (string-empty-p id))
         (list :id id
               :title (yeetube--rss-entry-text entry 'title)
               :channel (yeetube--rss-author-name entry)
               :channel-id (yeetube--rss-channel-path browse-id)
               :browse-id browse-id
               :thumbnail-url (yeetube--rss-entry-thumbnail entry id)
               :views (yeetube--rss-entry-views entry)
               :duration ""
               :date date
               :type 'video))))

(defun yeetube--rss-parse-buffer ()
  "Parse current YouTube RSS feed buffer into yeetube item plists."
  (let* ((feed (car (xml-parse-region (point-min) (point-max))))
         (entries (and feed (dom-by-tag feed 'entry))))
    (delq nil (mapcar #'yeetube--rss-entry-item entries))))

(defun yeetube--render-items (items limit &optional continuation)
  "Render ITEMS into the *yeetube* buffer with LIMIT and CONTINUATION.
Pops to the buffer per `yeetube-pop-to-same-window-p', resets the
major mode, populates state, and kicks off thumbnail fetching."
  (let ((pop-fn (if yeetube-pop-to-same-window-p
                    #'pop-to-buffer-same-window
                  #'pop-to-buffer)))
    (funcall pop-fn yeetube--buffer-name)
    ;; Enabling the mode kills buffer-locals, so re-renders must not
    ;; reset settings like video quality or download directory.
    (unless (derived-mode-p 'yeetube-mode)
      (yeetube-mode))
    (setq yeetube-items items)
    (setq-local yeetube--continuation continuation)
    (setq-local yeetube--results-limit limit)
    (yeetube-ui-render items)
    (yeetube-ui-fetch-thumbnails items yeetube--buffer-name)))

(defun yeetube--current-limit ()
  "Return the active results limit, reading from the *yeetube* buffer."
  (let ((buf (get-buffer yeetube--buffer-name)))
    (or (and buf (buffer-local-value 'yeetube--results-limit buf))
        yeetube-results-limit)))

(defun yeetube--decode-url-buffer (url-buffer)
  "Insert URL-BUFFER's body into the current buffer, decoded as UTF-8.
Call from inside a `with-temp-buffer' so the inserted text is
scoped to a throwaway buffer for parsing."
  (set-buffer-multibyte t)
  (url-insert url-buffer)
  (decode-coding-region (point-min) (point-max) 'utf-8))

(defun yeetube--rss-callback (status &optional fallback-url)
  "Yeetube RSS callback handling STATUS with FALLBACK-URL."
  (let ((url-buffer (current-buffer)))
    (unwind-protect
        (if (plist-get status :error)
            (when fallback-url (yeetube-display-content-from-url fallback-url))
          (let* ((limit (yeetube--current-limit))
                 (items (condition-case nil
                            (with-temp-buffer
                              (yeetube--decode-url-buffer url-buffer)
                              (yeetube--rss-parse-buffer))
                          (error nil))))
            (cond (items (yeetube--render-items items limit))
                  (fallback-url
                   (message "No RSS videos found")
                   (yeetube-display-content-from-url fallback-url))
                  (t (message "No RSS videos found")))))
      (kill-buffer url-buffer))))

(defun yeetube-display-rss-feed (browse-id &optional fallback-url)
  "Display channel videos from RSS feed for BROWSE-ID.
When RSS fails, display FALLBACK-URL with the regular scraper."
  (with-current-buffer (get-buffer-create yeetube--buffer-name)
    (setq-local yeetube--current-url (yeetube--rss-feed-url browse-id))
    (let ((url-request-extra-headers yeetube-request-headers)
          (callback-args (and fallback-url (list fallback-url))))
      (yeetube-with-tor-socks
       (url-retrieve yeetube--current-url #'yeetube--rss-callback
                     callback-args 'silent 'inhibit-cookies)))))

(defun yeetube--callback (status)
  "Yeetube callback handling STATUS."
  (let ((url-buffer (current-buffer)))
    (unwind-protect
        (unless (plist-get status :error)
          (let* ((limit (yeetube--current-limit))
                 (result (with-temp-buffer
                           (yeetube--decode-url-buffer url-buffer)
                           (yeetube-scraper-parse)))
                 (items (plist-get result :items))
                 (continuation (plist-get result :continuation)))
            (when items
              (yeetube--render-items items limit continuation)
              (when (and continuation (length< items limit))
                (yeetube--auto-paginate limit)))))
      (kill-buffer url-buffer))))

(defun yeetube-display-content-from-url (url)
  "Display the video results from URL."
  (with-current-buffer (get-buffer-create yeetube--buffer-name)
    (setq-local yeetube--current-url url)
    (let ((url-request-extra-headers yeetube-request-headers))
      (yeetube-with-tor-socks
       (url-retrieve url #'yeetube--callback nil 'silent 'inhibit-cookies)))))

;;;###autoload
(defun yeetube-search (query)
  "Search for QUERY."
  (interactive (list (yeetube-read-query)))
  (yeetube--display-loading)
  (yeetube-display-content-from-url
   ;; `ucbcb=1' bypasses EU cookie consent; sent via the Cookie header
   ;; configured in `yeetube-request-headers'.
   (format "https://youtube.com/search?q=%s%s"
           (url-hexify-string query)
           (if yeetube-filter
	       (format "&sp=%s" (yeetube-get-filter-code yeetube-filter))
	     ""))))


;;; Pagination

(defun yeetube--auto-paginate (limit)
  "Automatically fetch next page if current items are below LIMIT."
  (when (and yeetube--continuation
             (length< yeetube-items limit))
    (yeetube-next-page)))

;;;###autoload
(defun yeetube-next-page ()
  "Fetch and append the next page of results."
  (interactive)
  (unless yeetube--continuation
    (user-error "No more results"))
  (let* ((token (plist-get yeetube--continuation :token))
         (api-path (plist-get yeetube--continuation :url))
         (endpoint (concat "https://www.youtube.com" api-path))
         (url-request-method "POST")
         (url-request-extra-headers
          (append yeetube-request-headers
                  '(("Content-Type" . "application/json"))))
         (url-request-data
          (json-serialize
           `((context (client (clientName . "WEB")
                              (clientVersion . ,yeetube--client-version)))
             (continuation . ,token)))))
    (yeetube-with-tor-socks
     (url-retrieve endpoint #'yeetube--pagination-callback
                   nil 'silent 'inhibit-cookies))))

(defun yeetube--pagination-callback (status)
  "Handle pagination response with STATUS."
  (let ((url-buffer (current-buffer)))
    (unwind-protect
        (unless (plist-get status :error)
          (let* ((result (with-temp-buffer
                           (yeetube--decode-url-buffer url-buffer)
                           (goto-char (point-min))
                           (search-forward "{" nil t)
                           (backward-char)
                           (yeetube-scraper-parse-continuation-response
                            (json-parse-buffer :object-type 'alist
                                               :array-type 'list))))
                 (items (plist-get result :items))
                 (continuation (plist-get result :continuation)))
            (when items
              (with-current-buffer yeetube--buffer-name
                (setq yeetube-items (append yeetube-items items))
                (setq-local yeetube--continuation continuation)
                (yeetube-ui-append items)
                (yeetube-ui-fetch-thumbnails items yeetube--buffer-name)
                (when (and continuation
                           (length< yeetube-items
                                    (or yeetube--results-limit yeetube-results-limit)))
                  (yeetube--auto-paginate
                   (or yeetube--results-limit yeetube-results-limit)))))))
      (kill-buffer url-buffer))))


;;; Channel browsing

(defun yeetube--display-channel-path (channel-id path)
  "Display channel CHANNEL-ID content at PATH."
  (unless (and (stringp channel-id)
               (not (string-empty-p (string-trim channel-id))))
    (user-error "No channel ID available"))
  (let* ((channel-id (string-trim channel-id))
         (normalized-id (yeetube--normalize-channel-id channel-id))
         (url (yeetube--channel-url channel-id path)))
    (message "Fetching channel %s from %s" normalized-id url)
    (with-current-buffer (get-buffer-create yeetube--buffer-name)
      (setf yeetube--channel-id normalized-id)
      (yeetube-display-content-from-url url))))

;;;###autoload
(defun yeetube-channel-videos (&optional channel-id)
  "View videos for the channel with CHANNEL-ID."
  (interactive (list (or (yeetube-channel-id-at-point)
                         (yeetube--read-channel-id))))
  (yeetube--display-channel-path channel-id "videos"))

;;;###autoload
(defun yeetube-channel-streams (&optional channel-id)
  "View streams for the channel with CHANNEL-ID."
  (interactive (list (or (yeetube-channel-id-at-point)
                         (yeetube--read-channel-id))))
  (yeetube--display-channel-path channel-id "streams"))

(defun yeetube-channel-search (channel-id query)
  "Search channel with CHANNEL-ID for videos matching QUERY."
  (interactive (list (yeetube-channel-id-at-point) (yeetube-read-query)))
  (yeetube-display-content-from-url
   (yeetube--channel-url
    channel-id (format "search?query=%s" (url-hexify-string query)))))


;;; Mode

(defun yeetube--read-video-quality (prompt)
  "Read video quality with PROMPT."
  (completing-read prompt '("1080" "720" "480" "360" "240" "144") nil t))

(defun yeetube--read-audio-format (prompt)
  "Read audio format with PROMPT."
  (let ((choice (completing-read prompt
				 '("none" "aac" "alac" "flac" "m4a" "mp3" "opus" "vorbis" "wav")
				 nil t)))
    (if (equal choice "none") nil choice)))

(defun yeetube-set-results-limit ()
  "Set results limit."
  (interactive)
  (setq-local yeetube--results-limit (read-number "Results limit: "))
  (message "Results limit: %s" yeetube--results-limit))

(defun yeetube-set-video-quality ()
  "Set video quality."
  (interactive)
  (setq-local yeetube-mpv-video-quality (yeetube--read-video-quality "Video quality: "))
  (message "Video quality: %s" yeetube-mpv-video-quality))

(defun yeetube-set-download-directory ()
  "Set download directory."
  (interactive)
  (setq-local yeetube--download-directory (read-directory-name "Download dir: "))
  (message "Download dir: %s" yeetube--download-directory))

(defun yeetube-set-audio-format ()
  "Set audio format."
  (interactive)
  (setq-local yeetube--audio-format (yeetube--read-audio-format "Audio format: "))
  (message "Audio format: %s" (or yeetube--audio-format "none")))

(defun yeetube--setting-desc (label value)
  "Return LABEL and VALUE formatted for popup display."
  (format "%s: %s" label
          (propertize (format "%s" value) 'face 'font-lock-constant-face)))

(keymap-popup-define yeetube-settings-map
  "Yeetube settings menu."
  :description "Yeetube Settings"
  :group "Session"
  "l" ((lambda () (yeetube--setting-desc
                   "Results limit" (or yeetube--results-limit
                                       yeetube-results-limit)))
       yeetube-set-results-limit :stay-open t)
  "v" ((lambda () (yeetube--setting-desc
                   "Video quality" (or yeetube-mpv-video-quality "default")))
       yeetube-set-video-quality :stay-open t)
  "d" ((lambda () (yeetube--setting-desc
                   "Download dir" (or yeetube--download-directory
                                      yeetube-download-directory)))
       yeetube-set-download-directory :stay-open t)
  "a" ((lambda () (yeetube--setting-desc
                   "Audio format" (or yeetube--audio-format "none")))
       yeetube-set-audio-format :stay-open t)
  :group "Switches"
  "n" ("No video" :switch yeetube-mpv-no-video)
  "t" ("Torsocks" :switch yeetube-mpv-enable-torsocks))

(keymap-popup-define yeetube-mode-map
  "Yeetube Mode Map"
  :description "Yeetube Menu"
  :group "Play"
  "RET" ("Play" yeetube-play)
  "r" ("Replay" yeetube-replay)
  "P" ("Play saved" yeetube-play-saved-video)
  :group "Navigate"
  "s" ("Search" yeetube-search)
  "M-n" ("Next page" yeetube-next-page)
  "c" ("Channel videos" yeetube-channel-videos)
  "L" ("Channel streams" yeetube-channel-streams)
  :group "Playback"
  "p" ("Toggle pause" yeetube-mpv-toggle-pause :inapt-if #'yeetube-mpv--idle-p)
  "v" ("Toggle video" yeetube-mpv-toggle-video :inapt-if #'yeetube-mpv--idle-p)
  :group "Actions"
  "S" ("Save video" yeetube-save-video)
  "C" ("Copy URL" yeetube-copy-url)
  "R" ("Copy RSS feed" yeetube-copy-rss-feed-url)
  "b" ("Browse (invidious)" yeetube-browse-url)
  "d" ("Download" yeetube-download-video)
  "o" ("Settings" :keymap yeetube-settings-map))

(define-derived-mode yeetube-mode tabulated-list-mode "Yeetube"
  "Yeetube mode."
  :keymap yeetube-mode-map
  (setq-local truncate-string-ellipsis " ")
  ;; Set defaults for macro-generated buffer-local vars
  (unless yeetube--results-limit
    (setq-local yeetube--results-limit yeetube-results-limit))
  (unless yeetube-mpv-video-quality
    (setq-local yeetube-mpv-video-quality "720"))
  (unless yeetube--download-directory
    (setq-local yeetube--download-directory yeetube-download-directory))
  (unless yeetube--audio-format
    (setq-local yeetube--audio-format yeetube-download-audio-format))
  (setq-local yeetube-mpv-no-video
              (member "--no-video" yeetube-mpv-additional-flags))
  (display-line-numbers-mode 0))

;;;###autoload
(defun yeetube ()
  "YeeTube entrypoint."
  (interactive)
  (keymap-popup yeetube-mode-map))

(provide 'yeetube)
;;; yeetube.el ends here
