publishhelperbot/PublishHelperBot/YoutubeDl.fs

177 lines
6.0 KiB
Forth

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<CreateYoutubeDLJobSuccess, YoutubeDlError> Async
type CheckJobResult = Result<YoutubeDlStateResponse, YoutubeDlError> Async
type CleanJobResult = Result<YoutubeDlStateResponse, YoutubeDlError> Async
type StartYoutubeDlServiceArgs = HttpClient * CreateYoutubeDLUrl * ITelegramBotClient * ChatId * CancellationToken
type YoutubeDlJobWithId = YoutubeDlJob<Guid>
type YoutubeDlJobWithoutId = YoutubeDlJob<unit>
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<YoutubeDlJobWithoutId>()
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.url; 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 {
File.Delete <| job.savePath
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()}.mp4" })
return id
}