packages feed

link-canonical-0.1.0.0: src/Link/Canonical/Rules/YouTube.hs

-- | YouTube URL normalization rule
--
-- == Canonical Form
--
-- @
-- https://www.youtube.com/watch?v={VIDEO_ID}
-- @
--
-- == Handled Variants
--
-- * @youtu.be/{VIDEO_ID}@
-- * @youtube.com/watch?v={VIDEO_ID}@
-- * @www.youtube.com/watch?v={VIDEO_ID}@
-- * @m.youtube.com/watch?v={VIDEO_ID}@
-- * @youtube.com/embed/{VIDEO_ID}@
-- * @youtube.com/v/{VIDEO_ID}@
-- * @youtube.com/shorts/{VIDEO_ID}@
--
-- == Stripped Parameters
--
-- @t@, @start@, @end@, @feature@, @list@, @index@, @si@
--
-- Note: Timestamps are stripped per the resource-identity semantic model.
module Link.Canonical.Rules.YouTube
  ( youtubeRule,
    isYouTubeUrl,
    extractVideoId,
    youtubeStrippedParams,
  )
where

import Data.List.NonEmpty qualified as NE
import Data.Text qualified as T
import Link.Canonical.Prelude
import Link.Canonical.Rules.Types (DomainRule, domainRule, getHost)
import Text.URI qualified as URI

-- | YouTube domain rule
youtubeRule :: DomainRule
youtubeRule = domainRule "youtube" isYouTubeUrl normalizeYouTube

-- | Parameters to strip from YouTube URLs
youtubeStrippedParams :: [Text]
youtubeStrippedParams =
  [ "t", -- timestamp
    "start", -- start time
    "end", -- end time
    "feature", -- feature flag
    "list", -- playlist
    "index", -- playlist index
    "si" -- share identifier
  ]

-- | YouTube hostnames we recognize
youtubeHosts :: [Text]
youtubeHosts =
  [ "youtube.com",
    "www.youtube.com",
    "m.youtube.com",
    "youtu.be"
  ]

-- | Check if a URI is a YouTube URL
isYouTubeUrl :: URI -> Bool
isYouTubeUrl uri =
  case getHost uri of
    Nothing -> False
    Just host -> T.toLower host `elem` youtubeHosts

-- | Normalize a YouTube URL to canonical form
normalizeYouTube :: URI -> URI
normalizeYouTube uri =
  case extractVideoId uri of
    Nothing -> uri -- Can't extract video ID, return as-is
    Just videoId -> buildCanonicalYouTube videoId

-- | Extract the video ID from various YouTube URL formats
extractVideoId :: URI -> Maybe Text
extractVideoId uri = do
  host <- getHost uri
  let lowerHost = T.toLower host
  if lowerHost == "youtu.be"
    then extractFromShortUrl uri
    else extractFromLongUrl uri

-- | Extract video ID from youtu.be/{VIDEO_ID}
extractFromShortUrl :: URI -> Maybe Text
extractFromShortUrl uri = do
  (_, pathSegments) <- URI.uriPath uri
  -- First path segment is the video ID
  let segments = NE.toList pathSegments
  case segments of
    (firstSeg : _) ->
      let videoId = URI.unRText firstSeg
       in if T.null videoId then Nothing else Just videoId
    [] -> Nothing

-- | Extract video ID from youtube.com URLs
extractFromLongUrl :: URI -> Maybe Text
extractFromLongUrl uri =
  -- Try each extraction method in order
  extractFromWatchParam uri
    <|> extractFromEmbed uri
    <|> extractFromV uri
    <|> extractFromShorts uri

-- | Extract from ?v={VIDEO_ID}
extractFromWatchParam :: URI -> Maybe Text
extractFromWatchParam uri =
  let params = URI.uriQuery uri
   in findParamValue "v" params

-- | Extract from /embed/{VIDEO_ID}
extractFromEmbed :: URI -> Maybe Text
extractFromEmbed = extractFromPathPrefix "embed"

-- | Extract from /v/{VIDEO_ID}
extractFromV :: URI -> Maybe Text
extractFromV = extractFromPathPrefix "v"

-- | Extract from /shorts/{VIDEO_ID}
extractFromShorts :: URI -> Maybe Text
extractFromShorts = extractFromPathPrefix "shorts"

-- | Extract video ID from path like /{prefix}/{VIDEO_ID}
extractFromPathPrefix :: Text -> URI -> Maybe Text
extractFromPathPrefix prefix uri = do
  (_, pathSegments) <- URI.uriPath uri
  let segments = NE.toList pathSegments
  case segments of
    (firstSeg : secondSeg : _)
      | T.toLower (URI.unRText firstSeg) == prefix ->
          let videoId = URI.unRText secondSeg
           in if T.null videoId then Nothing else Just videoId
    _ -> Nothing

-- | Find a parameter value in query params
findParamValue :: Text -> [URI.QueryParam] -> Maybe Text
findParamValue key params =
  listToMaybe $ mapMaybe extractValue params
  where
    extractValue (URI.QueryParam k v)
      | URI.unRText k == key = Just (URI.unRText v)
    extractValue _ = Nothing

-- | Build the canonical YouTube URL
buildCanonicalYouTube :: Text -> URI
buildCanonicalYouTube videoId =
  -- We construct the URI manually since we know the exact format
  case URI.mkURI ("https://www.youtube.com/watch?v=" <> videoId) of
    Just uri -> uri
    Nothing ->
      -- Fallback: this shouldn't happen with valid video IDs
      -- but we need a safe fallback
      URI.emptyURI