module PublishHelperBot.YoutubeDl open System open System.Collections.Generic open System.IO open System.Net.Http open System.Text open System.Threading open Microsoft.FSharp.Core open Newtonsoft.Json open Nito.AsyncEx open Telegram.Bot open Telegram.Bot.Types.InputFiles type public CreateYoutubeDLUrl = string type public ChatId = int64 type public CreateYoutubeDLJob = { url: string savePath: string } type public CreateYoutubeDLJobSuccess = { task: Guid } type public YoutubeDlStateResponse = { state: string } type public YoutubeDlError = { message: string } type public YoutubeDlJob<'A> = { internalId: Guid externalId: 'A url: string state: string savePath: string } type YoutubeDlClientActions = Create | Check | Delete type HttpMethods = GET | POST | DELETE type CreateJobResult = Result Async type CheckJobResult = Result Async type CleanJobResult = Result Async type StartYoutubeDlServiceArgs = HttpClient * CreateYoutubeDLUrl * ITelegramBotClient * ChatId * CancellationToken type YoutubeDlJobWithId = YoutubeDlJob type YoutubeDlJobWithoutId = YoutubeDlJob type YoutubeDlCurrentJob = | Created of YoutubeDlJobWithoutId | Awaiting of YoutubeDlJobWithId | Downloaded of YoutubeDlJobWithId | Done of YoutubeDlJobWithId | None of unit let inline () (lck: AsyncLock) f = async { use! __ = Async.AwaitTask <| lck.LockAsync().AsTask() return! f } type YoutubeDlClient(baseUrl: string, client: HttpClient) = let lock = AsyncLock() let apiPrefix = $"{baseUrl}api/"; let ResolvePath(action: YoutubeDlClientActions) = match action with | Create -> $"{apiPrefix}download" | Check -> $"{apiPrefix}status" | Delete -> $"{apiPrefix}clear" let doHttp (url: string, method: HttpMethods, content: HttpContent): Result<'TRes, YoutubeDlError> Async = async { try let! res = match method with | POST -> client.PostAsync(url, content) |> Async.AwaitTask | GET -> client.GetAsync(url) |> Async.AwaitTask | DELETE -> client.DeleteAsync(url) |> Async.AwaitTask let! content = res.Content.ReadAsStringAsync() |> Async.AwaitTask return match res.IsSuccessStatusCode with | true -> Ok (JsonConvert.DeserializeObject<'TRes> <| content) | false -> Error { message = "Unknown network error" } with | ex -> return Error { message = ex.Message } } member this.CreateJob(model: CreateYoutubeDLJob): CreateJobResult = lock async { use content = new StringContent(JsonConvert.SerializeObject <| model, Encoding.UTF8, "application/json") return! doHttp <| (ResolvePath Create, POST, content) } member this.CheckJob(id: Guid): CheckJobResult = lock async { let arg = [KeyValuePair("id", id.ToString())] use content = new FormUrlEncodedContent(arg) let! query = content.ReadAsStringAsync() |> Async.AwaitTask return! doHttp <| ($"{ResolvePath Check}?{query}", GET, content) } member this.CleanJob(id: Guid): CleanJobResult = lock async { let arg = [KeyValuePair("id", id.ToString())] use content = new FormUrlEncodedContent(arg) let! query = content.ReadAsStringAsync() |> Async.AwaitTask return! doHttp <| ($"{ResolvePath Delete}?{query}", DELETE, content) } type YoutubeDlBackgroundService(requirements: StartYoutubeDlServiceArgs) = let (http, url, tg, chatId, ct) = requirements let lock = AsyncLock() let mutable currentJob: YoutubeDlCurrentJob = None () let jobPool = Queue() let ytClient = YoutubeDlClient <| (url, http) let mapJobToApi (job: YoutubeDlJob<_>): CreateYoutubeDLJob = { url = job.url savePath = job.savePath } let attachExternalId (id: Guid, job: YoutubeDlJobWithoutId): YoutubeDlCurrentJob = Awaiting { internalId = job.internalId; state = job.state; url = job.state; externalId = id; savePath = job.savePath } let tryAssignNewJob() = async { let (result, job) = jobPool.TryDequeue() match result with | true -> currentJob <- Created job | false -> currentJob <- None () } let uploadToYtDl(job: YoutubeDlJobWithoutId) = async { match! ytClient.CreateJob <| mapJobToApi job with | Ok x -> currentJob <- attachExternalId <| (x.task, job) // TODO: Logging! | Error _ -> currentJob <- None () } let checkJob(job: YoutubeDlJobWithId) = async { match! ytClient.CheckJob <| job.externalId with | Ok x when x.state.Equals("Finished", StringComparison.OrdinalIgnoreCase) -> currentJob <- Downloaded job | Error _ -> currentJob <- None () | _ -> () // That's take a while do! Async.Sleep 5000 } let postVideo(job: YoutubeDlJobWithId) = async { use file = File.OpenRead <| job.savePath let input = InputOnlineFile(file, job.savePath) let caption = $"Source: {job.url}" do! tg.SendVideoAsync(chatId, input, caption = caption) |> Async.AwaitTask |> Async.Ignore currentJob <- Done job } let cleanUp(job: YoutubeDlJobWithId) = async { match! ytClient.CleanJob <| job.externalId with | Ok _ -> currentJob <- None () | Error _ -> currentJob <- None () } let chooseAction() = lock async { do! match currentJob with | None _ -> tryAssignNewJob() | Created x -> x |> uploadToYtDl | Awaiting x -> x |> checkJob | Downloaded x -> x |> postVideo | Done x -> x |> cleanUp } let rec loop () = async { do! match ct.IsCancellationRequested with | false -> chooseAction() | true -> async { () } do! Async.Sleep 150 return! loop() } member public this.StartYoutubeDlService() = loop() |> Async.Start member public this.EnqueueJob(url: string) = lock async { let id = Guid.NewGuid() jobPool.Enqueue({ internalId = id; externalId = (); state = "new"; url = url; savePath = Path.GetTempFileName() }) return id }