Resolver/Extractors/SimpleExtractors.cs
using System.Net.Http;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Threading;

namespace Bimp.Resolver.Extractors;

/// <summary> What a site says about one piece of media. </summary>
public sealed class SiteMedia
{
	public string Title;

	/// <summary> Seconds, 0 if unknown (or live). </summary>
	public float Duration;
	public bool AudioOnly;
	public bool IsLive;

	/// <summary> Progressive files the engine plays straight from the site (mp4, webm, mp3...). </summary>
	public List<MediaFormat> Files = new();

	/// <summary> An HLS playlist (master or media): played when there are no files, and for live media. </summary>
	public string Hls;
}

/// <summary>
/// Shared plumbing for sites: a progressive file when the site has one (played directly, seeked natively), else its
/// HLS playlist - remuxed locally from a start time (VOD, seeks by reopening), or followed at its live edge.
/// </summary>
public abstract class SiteExtractor : IExtractor
{
	public abstract string Key { get; }
	protected abstract string[] Hosts { get; }

	public virtual bool CanHandle( Uri url )
	{
		var host = url.Host.ToLowerInvariant();
		return Hosts.Any( h => host == h || host.EndsWith( "." + h ) ) && ParseId( url ) is not null;
	}

	protected abstract string ParseId( Uri url );

	protected abstract Task<SiteMedia> FetchAsync( string id, CancellationToken ct );

	public async Task<MediaInfo> GetInfoAsync( Uri url, bool audioOnly, CancellationToken ct )
	{
		var id = ParseId( url ) ?? throw new ResolveException( "That link isn't supported." );
		var m = await FetchAsync( id, ct );
		if ( m.Files.Count == 0 && m.Hls is null ) throw new ResolveException( "No playable files found for that link." );

		var heights = m.AudioOnly || audioOnly || m.Files.Count == 0 ? new List<int>()
			: m.Files.Where( f => f.HasVideo ).Select( f => f.Height ).Where( h => h > 0 ).Distinct().OrderByDescending( h => h ).ToList();
		return new MediaInfo
		{
			Extractor = Key,
			Id = id,
			Title = m.Title ?? id,
			Duration = m.IsLive ? 0 : m.Duration,
			AudioOnly = audioOnly || m.AudioOnly,
			IsLive = m.IsLive,
			// HLS VOD is remuxed from a start time, so it seeks by reopening like merged media
			Merged = !m.IsLive && m.Files.Count == 0,
			Qualities = heights.Count > 1 ? heights : new List<int>(),
		};
	}

	public async Task<StreamPlan> GetStreamsAsync( string id, StreamRequest request, CancellationToken ct )
	{
		var m = await FetchAsync( id, ct );
		if ( m.IsLive )
			return new StreamPlan { Kind = StreamKind.Live, DirectUrl = m.Hls ?? m.Files.FirstOrDefault()?.Url ?? throw new ResolveException( "The stream isn't live right now." ) };
		if ( m.Files.Count == 0 )
			return new StreamPlan { Kind = StreamKind.Hls, DirectUrl = m.Hls ?? throw new ResolveException( "No playable files found for that link." ) };

		if ( m.AudioOnly )
		{
			var best = m.Files.OrderByDescending( f => f.Bitrate ).FirstOrDefault() ?? throw new ResolveException( "No playable audio found." );
			return new StreamPlan { Kind = StreamKind.Direct, DirectUrl = best.Url };
		}

		var limit = request.Height > 0 ? request.Height : Math.Clamp( request.MaxHeight, 144, 2160 );
		var pick = m.Files.Where( f => f.Height <= limit ).OrderByDescending( f => f.Height ).ThenByDescending( f => f.Bitrate ).FirstOrDefault()
			?? m.Files.OrderBy( f => f.Height ).FirstOrDefault()
			?? throw new ResolveException( "No playable files found for that link." );
		return new StreamPlan { Kind = StreamKind.Direct, DirectUrl = pick.Url };
	}

	protected static string Https( string url ) => url is not null && url.StartsWith( "//" ) ? "https:" + url : url;

	protected static async Task<JsonNode> GetJson( string url, CancellationToken ct, Dictionary<string, string> headers = null )
	{
		using var r = await Http.RequestAsync( url, headers: headers, cancellationToken: ct );
		return await ReadJson( r, ct );
	}

	protected static async Task<JsonNode> PostJson( string url, JsonNode body, CancellationToken ct, Dictionary<string, string> headers = null )
	{
		using var content = new StringContent( body.ToJsonString(), Encoding.UTF8, "application/json" );
		using var r = await Http.RequestAsync( url, "POST", content, headers, ct );
		return await ReadJson( r, ct );
	}

	static async Task<JsonNode> ReadJson( System.Net.Http.HttpResponseMessage r, CancellationToken ct )
	{
		var status = (int)r.StatusCode;
		if ( status == 404 ) throw new ResolveException( "That media doesn't exist (or is private)." );
		if ( status is 401 or 403 ) throw new ResolveException( "That media is private, or the site refused the request." );
		if ( status != 200 ) throw new ResolveException( $"The site returned {status}." );
		return JsonNode.Parse( await r.Content.ReadAsStringAsync( ct ) );
	}
}

/// <summary>
/// streamable.com/{shortcode}
/// </summary>
public sealed class StreamableExtractor : SiteExtractor
{
	public override string Key => "streamable";
	protected override string[] Hosts => new[] { "streamable.com" };

	static readonly Regex Shortcode = new( "^(?:e/|o/|s/)?([a-z0-9]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled );

	protected override string ParseId( Uri url )
	{
		var m = Shortcode.Match( url.AbsolutePath.Trim( '/' ) );
		return m.Success ? m.Groups[1].Value : null;
	}

	protected override async Task<SiteMedia> FetchAsync( string id, CancellationToken ct )
	{
		var j = await GetJson( $"https://api.streamable.com/videos/{id}", ct );
		var m = new SiteMedia { Title = j?["title"].Str() };

		if ( j?["files"] is JsonObject files )
		{
			foreach ( var (_, f) in files )
			{
				var url = Https( f?["url"].Str() );
				if ( string.IsNullOrEmpty( url ) ) continue;
				m.Duration = MathF.Max( m.Duration, f["duration"].Float() );
				m.Files.Add( new MediaFormat { Id = url, Url = url, Container = "mp4", VideoCodec = "h264", AudioCodec = "aac", Height = f["height"].Int(), Bitrate = f["bitrate"].Long() } );
			}
		}
		return m;
	}
}

/// <summary>
/// vimeo.com/{id} and player.vimeo.com/video/{id}: its progressive files, else its HLS (newer uploads are often HLS
/// only, with the audio in a separate playlist). Live events play live.
/// </summary>
public sealed class VimeoExtractor : SiteExtractor
{
	public override string Key => "vimeo";
	protected override string[] Hosts => new[] { "vimeo.com" };

	static readonly Regex Id = new( "(?:^|/)(?:video/)?(\\d{5,})(?:/|$)", RegexOptions.Compiled );

	protected override string ParseId( Uri url )
	{
		var m = Id.Match( url.AbsolutePath );
		return m.Success ? m.Groups[1].Value : null;
	}

	protected override async Task<SiteMedia> FetchAsync( string id, CancellationToken ct )
	{
		var j = await GetJson( $"https://player.vimeo.com/video/{id}/config", ct );
		var files = j?["request"]?["files"];
		var m = new SiteMedia
		{
			Title = j?["video"]?["title"].Str(),
			Duration = j?["video"]?["duration"].Float() ?? 0,
			IsLive = j?["video"]?["live_event"] is JsonObject,
		};
		m.Files = files?["progressive"].Items()
			.Select( f => new MediaFormat { Id = f["id"].Str(), Url = f["url"].Str(), Container = "mp4", VideoCodec = "h264", AudioCodec = "aac", Height = f["height"].Int(), Fps = f["fps"].Float() } )
			.Where( f => !string.IsNullOrEmpty( f.Url ) )
			.ToList() ?? new List<MediaFormat>();

		var hls = files?["hls"];
		var cdn = hls?["default_cdn"].Str();
		m.Hls = (cdn is not null ? hls?["cdns"]?[cdn]?["url"].Str() : null) ?? hls?["cdns"]?.Find( "url" ).Str();
		if ( m.IsLive ) m.Files.Clear();
		return m;
	}
}

/// <summary>
/// soundcloud.com/{user}/{track}. Uses the public web client id (scraped from the site's scripts): the progressive
/// mp3 if the track has one, else its HLS AAC.
/// </summary>
public sealed class SoundCloudExtractor : SiteExtractor
{
	public override string Key => "soundcloud";
	protected override string[] Hosts => new[] { "soundcloud.com" };

	static string clientId;
	static readonly Regex ScriptSrc = new( "<script[^>]+src=\"(https://a-v2\\.sndcdn\\.com/assets/[^\"]+\\.js)\"", RegexOptions.Compiled );
	static readonly Regex ClientIdPattern = new( "client_id\\s*:\\s*\"([0-9a-zA-Z]{32})\"", RegexOptions.Compiled );

	protected override string ParseId( Uri url )
	{
		var parts = url.AbsolutePath.Trim( '/' ).Split( '/', StringSplitOptions.RemoveEmptyEntries );
		if ( parts.Length < 2 || parts[1] is "sets" or "likes" or "tracks" or "reposts" or "albums" ) return null;
		return $"https://soundcloud.com/{parts[0]}/{parts[1]}";
	}

	static async Task<string> GetClientId( CancellationToken ct, bool refresh = false )
	{
		if ( clientId is not null && !refresh ) return clientId;

		var html = await Http.RequestStringAsync( "https://soundcloud.com/", cancellationToken: ct );
		foreach ( var src in ScriptSrc.Matches( html ).Select( m => m.Groups[1].Value ).Reverse() )
		{
			var js = await Http.RequestStringAsync( src, cancellationToken: ct );
			var m = ClientIdPattern.Match( js );
			if ( m.Success ) return clientId = m.Groups[1].Value;
		}

		throw new ResolveException( "Couldn't talk to SoundCloud right now." );
	}

	protected override async Task<SiteMedia> FetchAsync( string id, CancellationToken ct )
	{
		JsonNode track;
		try
		{
			track = await GetJson( $"https://api-v2.soundcloud.com/resolve?url={Uri.EscapeDataString( id )}&client_id={await GetClientId( ct )}", ct );
		}
		catch ( ResolveException ) when ( clientId is not null )
		{
			// the client id rotates now and then
			track = await GetJson( $"https://api-v2.soundcloud.com/resolve?url={Uri.EscapeDataString( id )}&client_id={await GetClientId( ct, true )}", ct );
		}

		if ( track?["kind"].Str() != "track" )
			throw new ResolveException( "Only single SoundCloud tracks can be played." );

		var m = new SiteMedia { Title = track["title"].Str(), Duration = track["duration"].Long() / 1000f, AudioOnly = true };
		var transcodings = track["media"]?["transcodings"].Items().ToList() ?? new List<JsonNode>();

		foreach ( var t in transcodings.Where( t => t["format"]?["protocol"].Str() == "progressive" ) )
		{
			var url = (await GetJson( $"{t["url"].Str()}?client_id={clientId}", ct ))?["url"].Str();
			if ( string.IsNullOrEmpty( url ) ) continue;
			var mime = t["format"]?["mime_type"].Str() ?? "audio/mpeg";
			m.Files.Add( new MediaFormat { Id = t["preset"].Str(), Url = url, Container = mime.Contains( "mpeg" ) ? "mp3" : "ogg", AudioCodec = mime } );
		}

		if ( m.Files.Count == 0 )
		{
			// HLS: AAC (fMP4) - the mp3 and Opus ones aren't in a container the segmenter reads
			var aac = transcodings
				.Where( t => t["format"]?["protocol"].Str() == "hls" && (t["format"]?["mime_type"].Str() ?? "").Contains( "mp4a" ) )
				.OrderByDescending( t => t["preset"].Str()?.Contains( "160" ) ?? false )
				.FirstOrDefault();
			if ( aac is not null ) m.Hls = (await GetJson( $"{aac["url"].Str()}?client_id={clientId}", ct ))?["url"].Str();
		}

		if ( m.Files.Count == 0 && m.Hls is null )
			throw new ResolveException( "This SoundCloud track can't be streamed (no mp3 or AAC stream)." );
		return m;
	}
}