Compare commits

..

No commits in common. "main" and "rc1" have entirely different histories.
main ... rc1

19 changed files with 122 additions and 759 deletions

1
.gitignore vendored
View File

@ -1,4 +1,3 @@
working/
# ---> VisualStudio # ---> VisualStudio
## Ignore Visual Studio temporary files, build results, and ## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons. ## files generated by popular Visual Studio add-ons.

View File

@ -9,9 +9,6 @@ namespace ttrss_co_client
public string Password { get; set; } public string Password { get; set; }
public string PodcastTitlePrefix { get; set; } public string PodcastTitlePrefix { get; set; }
public string OnDoneCopy { get; set; } public string OnDoneCopy { get; set; }
public string WorkingDirectory { get; set; } = "./working/";
public string ChatScript { get; set; }
public string UserAgent { get; set; }
public IEnumerable<FeedAction> feedActions { get; set; } public IEnumerable<FeedAction> feedActions { get; set; }
public class FeedAction public class FeedAction
{ {

View File

@ -1,203 +1,102 @@
using Newtonsoft.Json; using Newtonsoft.Json;
using System.Linq; using System.Linq;
using System.Diagnostics; using System.Diagnostics;
using System.Text.RegularExpressions;
using ttrss_co_client.tasks;
namespace ttrss_co_client namespace ttrss_co_client
{ {
class Program class Program
{ {
private static ttrss.ApiClient ttrssClient;
static async Task Main(string[] args) static async Task Main(string[] args)
{ {
var conf = Configure(); var conf = Configure();
Console.WriteLine($"{DateTime.Now.ToString("o")}"); var ttrssClient = new ttrss.ApiClient(conf.BaseURI);
ttrssClient = new ttrss.ApiClient(conf.BaseURI);
await ttrssClient.Login(conf.Username, conf.Password); await ttrssClient.Login(conf.Username, conf.Password);
var loggedin = await ttrssClient.IsLoggedIn(); var loggedin = await ttrssClient.IsLoggedIn();
Console.WriteLine($"logged in: {loggedin}"); Console.WriteLine($"logged in: {loggedin}");
#region phase 1 var miscTasks = new List<Task>();
Console.WriteLine("===== phase 1 =====");
var Phase1Tasks = new List<Task<WorkOrder>>();
Phase1Task.TtrssClient = ttrssClient;
Phase1Task.Conf = conf;
var phase1TaskTypes = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(domainAssembly => domainAssembly.GetTypes())
.Where(type => type.IsSubclassOf(typeof(Phase1Task)) && !type.IsAbstract)
.ToList();
var phase1TaskConcretions = new List<Phase1Task>();
foreach (var phase1TaskType in phase1TaskTypes)
{
var concretion = (Phase1Task)Activator.CreateInstance(phase1TaskType);
concretion.TriggerLabel = conf.feedActions.FirstOrDefault(fa => fa.command == concretion.TaskName).triggerlabelCaption;
phase1TaskConcretions.Add(concretion);
}
Console.WriteLine($"{phase1TaskConcretions.Count()} phase 1 task types understood");
var unreadFeeds = await ttrssClient.GetFeeds(cat_id: -3, unread_only: true); var unreadFeeds = await ttrssClient.GetFeeds(cat_id: -3, unread_only: true);
Console.WriteLine($"{unreadFeeds.Count()} feeds unread");
foreach (var uf in unreadFeeds) foreach (var uf in unreadFeeds)
{ {
Console.WriteLine($"unread feed: {uf.title}");
var headlines = await ttrssClient.GetHeadlines(uf.id, view_mode: ttrss.ApiClient.VIEWMODE.Unread, include_attachments: true); var headlines = await ttrssClient.GetHeadlines(uf.id, view_mode: ttrss.ApiClient.VIEWMODE.Unread);
foreach (var hl in headlines) foreach (var hl in headlines)
{ {
var labelsWRTArticle = (await ttrssClient.GetLabels(hl.id)); var labelsWRTFeed = (await ttrssClient.GetLabels(hl.id));
var actionsForFeed = conf.feedActions.Where(fa => var actionsForFeed = conf.feedActions.Where(fa =>
labelsWRTArticle.Where(l => l.@checked).Select(l => l.caption).Contains(fa.triggerlabelCaption))?.ToList(); labelsWRTFeed.Where(l => l.@checked).Select(l => l.caption).Contains(fa.triggerlabelCaption))?.ToList();
if(actionsForFeed != null && actionsForFeed.Any()) if(actionsForFeed != null && actionsForFeed.Any())
{ {
var action = actionsForFeed.First(); foreach(var action in actionsForFeed)
Console.WriteLine($" headline {hl.title} has label {action.triggerlabelCaption} / action {action.command}");
var appropriatePhase1Task = phase1TaskConcretions.FirstOrDefault(tt => tt.TaskName == action.command);
if (appropriatePhase1Task != null)
{ {
Phase1Tasks.Add(appropriatePhase1Task.ActOn(hl, labelsWRTArticle)); var noteString = hl.note;
} if (!string.IsNullOrWhiteSpace(noteString))
else
{ {
Console.Error.WriteLine($"couldn't find phase 1 task {action.command} for workorder referring to article id {hl.id}!"); noteString += $"{hl.note}\n";
} }
} switch (action.command)
}
}
Console.WriteLine($"done processing feeds. phase 1 tasks launched. wait to complete.");
var remainingWork = new List<WorkOrder>();
foreach (var lingeringTask in Phase1Tasks)
{ {
var wo = await lingeringTask; case "dl":
if (wo != null) var stdDLResult = await standardDL(hl.link.ToString());
miscTasks.Add(ttrssClient.UpdateArticleNote($"{noteString}[{DateTime.Now.ToLongTimeString()}] - {stdDLResult.Item2}", hl.id));
if (stdDLResult.Item1 == true)
{ {
Console.WriteLine($"articleId {wo.articleId} left a work order; it has {wo.Phase2TaskList.Count()} phase 2 task{(wo.Phase2TaskList.Count() == 1 ? "" : "s")}"); miscTasks.Add(
remainingWork.Add(wo); ttrssClient.SetArticleLabel(
labelsWRTFeed.First(l => l.caption == action.triggerlabelCaption).id,
false,
hl.id));
} }
}
Console.WriteLine($"phase 1 tasks complete, carrying {remainingWork.Count()} workorder{(remainingWork.Count() == 1 ? "" : "s")} to phase 2");
#endregion
#region local tasks (stripping ads, converting.)
Console.WriteLine("===== phase 2 =====");
//loop through working directory looking for other work orders to add
var workOrderFiles = Directory.GetFiles($"{conf.WorkingDirectory}", "workorder.json", new EnumerationOptions() { RecurseSubdirectories = true });
Console.WriteLine($"{workOrderFiles.Count()} workorder file{(workOrderFiles.Count() == 1 ? "" : "s")} located");
foreach (var workorderFile in workOrderFiles)
{
try
{
remainingWork.Add(JsonConvert.DeserializeObject<WorkOrder>(File.ReadAllText(workorderFile)));
Console.WriteLine($"picked up workorder task; {workorderFile}");
File.Delete(workorderFile);
}
catch (Exception e)
{
Console.Error.WriteLine($"error picking up work order file {workorderFile} - {e.Message}; {e.StackTrace}");
}
}
Console.WriteLine($"{remainingWork.Count} phase 2 workorders, between pulled from ttrss and read from files");
Phase2Task.TtrssClient = ttrssClient;
Phase2Task.Conf = conf;
var phase2TaskTypes = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(domainAssembly => domainAssembly.GetTypes())
.Where(type => type.IsSubclassOf(typeof(Phase2Task)) && !type.IsAbstract)
.ToList();
var phase2TaskConcretions = new List<Phase2Task>();
foreach (var phase2TaskType in phase2TaskTypes)
{
var concretion = (Phase2Task)Activator.CreateInstance(phase2TaskType);
phase2TaskConcretions.Add(concretion);
}
ChatMessage.ChatScript = conf.ChatScript;
while (remainingWork.Count > 0)
{
//todo: solve the halting problem
//ok but seriously, maybe I could time out work orders after a while? but converting a 3-hour 4k video could easily take an hour!
Console.WriteLine($"{phase2TaskConcretions.Count()} phase 2 task types understood");
var Phase2Tasks = new List<Task<Tuple<Phase2Task.TaskStatus, WorkOrder>>>();
Console.WriteLine($"launching first pass over work orders in phase 2.");
foreach (var wo in remainingWork)
{
var taskName = wo.Phase2TaskList[wo.Phase2TaskList.Keys.Min()];
var appropriatePhase2Task = phase2TaskConcretions.FirstOrDefault(tt => tt.TaskName == taskName);
if (appropriatePhase2Task != null)
{
wo.Phase2TaskList.Remove(wo.Phase2TaskList.Keys.Min());
Console.WriteLine("launching phase 2 task: " + taskName);
Phase2Tasks.Add(appropriatePhase2Task.ActOn(wo));
}
else
{
Console.Error.WriteLine($"couldn't find phase 2 task {taskName} for workorder referring to article id {wo.articleId}!");
}
}
Console.WriteLine($"phase 2 tasks launched. now the complex part.");
remainingWork = new List<WorkOrder>();
foreach (var lingeringTask in Phase2Tasks)
{
var wo = await lingeringTask;
var wop = Path.Combine(conf.WorkingDirectory, wo.Item2.guid.ToString(), "workorder.json");
//if you tell me it's done, or you need to continue now.... I believe you.
switch (wo.Item1)
{
case Phase2Task.TaskStatus.Done:
var workingSubDir = Path.Combine(conf.WorkingDirectory, wo.Item2.guid.ToString());
foreach(var file in Directory.GetFiles(workingSubDir, "*.*", SearchOption.AllDirectories))
{
File.Delete(file);
}
//imo this next line should also handle the above, but apparently I'm alone in that.
Directory.Delete(workingSubDir, true);
break; break;
case Phase2Task.TaskStatus.ContinueNow: case "podcastify":
remainingWork.Add(wo.Item2); var nameLabel = labelsWRTFeed.FirstOrDefault(l => l.caption?.StartsWith(conf.PodcastTitlePrefix) == true);
var podcastName = nameLabel?.caption.Substring(conf.PodcastTitlePrefix.Length)
?? hl.feed_title;
var podcastifyResult = await podcastify(hl.link.ToString(), podcastName);
miscTasks.Add(ttrssClient.UpdateArticleNote($"{noteString}[{DateTime.Now.ToLongTimeString()}] - {podcastifyResult.Item2}", hl.id));
if (podcastifyResult.Item1 == true)
{
miscTasks.Add(
ttrssClient.SetArticleLabel(
labelsWRTFeed.First(l => l.caption == action.triggerlabelCaption).id,
false,
hl.id));
if(nameLabel != null)
{
miscTasks.Add(ttrssClient.SetArticleLabel(nameLabel.id, false, hl.id));
}
}
break; break;
case Phase2Task.TaskStatus.TryLater: default:
noteString += $"[{DateTime.Now.ToLongTimeString()}] - feed configured but action not recognized";
File.WriteAllText(wop, JsonConvert.SerializeObject(wo.Item2));
break; break;
} }
miscTasks.Add(ttrssClient.UpdateArticleNote(noteString, hl.id));
} }
Console.WriteLine($"{remainingWork.Count} phase 2 tasks to be re-looped.");
} }
#endregion
await ttrssClient.Logout();
Console.WriteLine($"logged out of ttrss.");
#region cleanup
Console.WriteLine("===== phase 3 =====");
//loop through working directory looking for other work orders to add
var subdirs = Directory.GetDirectories(conf.WorkingDirectory);
foreach(var subdir in subdirs)
{
if(Directory.GetFiles(subdir)?.Length == 0)
{
Console.WriteLine($"{subdir} is empty");
Directory.Delete(subdir);
}
else
{
Console.WriteLine($"{subdir} is not empty: {string.Join(", ", Directory.GetFiles(subdir))}");
} }
} }
#endregion
miscTasks.Add(ttrssClient.Logout());
Console.WriteLine($"awaiting remaining download tasks");
Task.WaitAll(miscTasks.ToArray());
Console.WriteLine($"done, moving files from temporary location");
var resulted = Directory.GetFiles("tmp", "*.*", SearchOption.AllDirectories);
if(resulted.Count() > 0)
{
foreach(var f in resulted)
{
var moveTarget = Path.Combine(conf.OnDoneCopy, f.Substring("tmp/".Length));
if(!Path.Exists(Path.GetDirectoryName(moveTarget)))
{
Directory.CreateDirectory(Path.GetDirectoryName(moveTarget));
}
File.Move(f, moveTarget);
}
}
Console.WriteLine($"done for real"); Console.WriteLine($"done for real");
} }
static Configuration Configure(string configurationPath = "appsettings.json") static Configuration Configure(string configurationPath = "appsettings.json")
@ -227,5 +126,53 @@ namespace ttrss_co_client
return conf; return conf;
} }
private static async Task<Tuple<bool, string>> standardDL(string articleLink)
{
var ytdl = new YoutubeDLSharp.YoutubeDL();
ytdl.YoutubeDLPath = "yt-dlp";
ytdl.FFmpegPath = "ffmpeg";
ytdl.OutputFolder = "./tmp/recent episodes";
ytdl.OutputFileTemplate = "%(upload_date)s - %(title)s - [%(id)s].mp4";
var sw = new Stopwatch();
sw.Start();
var res = await ytdl.RunVideoDownload(articleLink);
sw.Stop();
var outputStr = $"{(res.Success ? "Success" : "fail")} in {sw.Elapsed}";
if(res.ErrorOutput != null && res.ErrorOutput.Length > 0)
{
outputStr += "\n" + string.Join('\n', res.ErrorOutput);
}
return new Tuple<bool, string>(res.Success, outputStr);
}
private static async Task<Tuple<bool, string>> podcastify(string articleLink, string podcastName)
{
var ytdl = new YoutubeDLSharp.YoutubeDL();
ytdl.YoutubeDLPath = "yt-dlp";
ytdl.FFmpegPath = "ffmpeg";
ytdl.OutputFolder = $"./tmp/podcasts/{podcastName}";
ytdl.OutputFileTemplate = "%(upload_date)s - %(title)s - [%(id)s].%(ext)s";
var sw = new Stopwatch();
sw.Start();
var res = await ytdl.RunVideoDownload(articleLink);
sw.Stop();
var outputStr = $"{(res.Success ? "Success" : "fail")} in {sw.Elapsed}";
if(res.ErrorOutput != null && res.ErrorOutput.Length > 0)
{
outputStr += "\n" + string.Join('\n', res.ErrorOutput);
}
if(!res.Data.EndsWith(".mp3"))
{
sw.Reset();
var outputFilename = res.Data.Substring(0, res.Data.LastIndexOf('.')) + ".mp3";
sw.Start();
var conversionProc =Process.Start("ffmpeg", $"-y -i \"{res.Data}\" \"{outputFilename}\"");
conversionProc.WaitForExit();
sw.Stop();
File.Delete(res.Data);
outputStr += $"\nconverted in {sw.Elapsed}";
}
return new Tuple<bool, string>(res.Success, outputStr);
}
} }
} }

View File

@ -4,9 +4,6 @@
"password": "sordph1sh", "password": "sordph1sh",
"podcastTitlePrefix": "[podcast title] - ", "podcastTitlePrefix": "[podcast title] - ",
"onDoneCopy":"./", "onDoneCopy":"./",
"workingDirectory":"working/",
"chatScript": "miscChat.sh",
"userAgent": "Mozilla/5.0 (compatible; wget-is-not-a-crime/1.0)",
"feedActions": "feedActions":
[ [
{ {
@ -14,12 +11,8 @@
"command":"dl" "command":"dl"
}, },
{ {
"triggerlabelCaption":"podcastify-yt plz", "triggerlabelCaption":"podcastify plz",
"command":"podcastifyYT" "command":"podcastify"
},
{
"triggerlabelCaption":"podcastify-attachment plz",
"command":"podcastifyAttachment"
} }
] ]
} }

View File

@ -1,16 +0,0 @@
using System.Collections.Generic;
namespace ttrss_co_client.sponsorblock
{
public class Segment
{
public string category { get; set; }
public string actionType { get; set; }
public double[] segment { get; set; } //start time, end time
public string UUID { get; set; }
public double videoDuration { get; set; } //yes, this is repeated in each segment
public int locked { get; set; }
public int votes { get; set; }
public string description { get; set; }
}
}

View File

@ -1,25 +0,0 @@
namespace ttrss_co_client.tasks;
using System.Linq;
using System.Diagnostics;
using ttrss_co_client.ttrss;
using ttrss_co_client.ttrss.datastructures;
using System.Threading.Tasks;
using System;
public class ChatMessage : Phase2Task
{
public static string ChatScript { get; set; }
public override string TaskName => "chatmessage";
public override async Task<Tuple<TaskStatus, WorkOrder>> ActOn(WorkOrder workOrder)
{
await Process.Start(ChatScript,
workOrder.data["chatmessage"] +
(await TtrssClient.GetArticles(workOrder.articleId)).First().feed_title +
": " +
(await TtrssClient.GetArticles(workOrder.articleId)).First().title
).WaitForExitAsync();
return new Tuple<TaskStatus, WorkOrder>(TaskStatus.Done, workOrder);
}
}

View File

@ -1,51 +0,0 @@
using System.Diagnostics;
using ttrss_co_client.ttrss;
using ttrss_co_client.ttrss.datastructures;
namespace ttrss_co_client.tasks
{
///<summary>ffmpegify</summary>
public class Convert : Phase2Task
{
public override string TaskName => "convert";
public override async Task<Tuple<TaskStatus, WorkOrder>> ActOn(WorkOrder wo)
{
var conversionFilenameTarget = wo.data["path"].Substring(0, wo.data["path"].LastIndexOf('.')) + wo.data["conversion-target"];
if(conversionFilenameTarget == wo.data["path"])
{
Console.WriteLine("no conversion needed");
return new Tuple<TaskStatus, WorkOrder>(TaskStatus.ContinueNow, wo);
}
var article = (await TtrssClient.GetArticles(wo.articleId))?.FirstOrDefault();
if(article == null){
Console.Error.WriteLine($"article {wo.articleId} couldn't be retrieved!");
return null;
}
var sw = new Stopwatch();
sw.Start();
var conversionProc = Process.Start("ffmpeg", $"-y -loglevel quiet -i \"{wo.data["path"]}\" \"{conversionFilenameTarget}\"");
conversionProc.WaitForExit();
sw.Stop();
if(File.Exists(conversionFilenameTarget))
{
Console.WriteLine($" converted {wo.data["path"]} -> {conversionFilenameTarget}");
File.Delete(wo.data["path"]);
wo.data["path"] =conversionFilenameTarget;
}
var outputStr = $"converted in {sw.Elapsed}";
Console.WriteLine(outputStr);
//re-get, in case conversion took a long time
article = (await TtrssClient.GetArticles(wo.articleId))?.FirstOrDefault();
await TtrssClient.UpdateArticleNote($"{article.note}\n{outputStr}", article.id);
var toReturn = new Tuple<TaskStatus, WorkOrder>(TaskStatus.ContinueNow, wo);
if(!wo.Phase2TaskList.Any())
{
Console.Error.WriteLine($"work order {wo.guid} came to conversion, but thinks its done?");
return new Tuple<TaskStatus, WorkOrder>(TaskStatus.Done, null);
}
return toReturn;
}
}
}

View File

@ -1,19 +0,0 @@
namespace ttrss_co_client.tasks;
using System.Linq;
using System.Diagnostics;
using ttrss_co_client.ttrss;
using ttrss_co_client.ttrss.datastructures;
using System.Threading.Tasks;
using System;
public class MarkRead : Phase2Task
{
public override string TaskName => "markread";
public override async Task<Tuple<TaskStatus, WorkOrder>> ActOn(WorkOrder workOrder)
{
await TtrssClient.UpdateArticleField(ApiClient.UPDATEFIELD.unread, ApiClient.UPDATEMODE.SetFalse, workOrder.articleId);
return new Tuple<TaskStatus, WorkOrder>(TaskStatus.ContinueNow, workOrder);
}
}

View File

@ -1,15 +0,0 @@
using ttrss_co_client.ttrss;
using ttrss_co_client.ttrss.datastructures;
namespace ttrss_co_client.tasks
{
///<summary>generally, download</summary>
public abstract class Phase1Task
{
public virtual string TaskName { get { return this.GetType().ToString(); } }
public static ApiClient TtrssClient { get; set; }
public static Configuration Conf { get; set; }
public virtual string TriggerLabel { get; set; }
public abstract Task<WorkOrder> ActOn(Headline headline, IEnumerable<Label> labels);
}
}

View File

@ -1,16 +0,0 @@
using ttrss_co_client.ttrss;
using ttrss_co_client.ttrss.datastructures;
namespace ttrss_co_client.tasks
{
///<summary>convert, rip ads, etc</summary>
public abstract class Phase2Task
{
public virtual string TaskName { get { return this.GetType().ToString(); } }
public static ApiClient TtrssClient { get; set; }
public static Configuration Conf { get; set; }
public enum TaskStatus {Done, ContinueNow, TryLater}
public abstract Task<Tuple<TaskStatus, WorkOrder>> ActOn(WorkOrder taskSpec);
}
}

View File

@ -1,96 +0,0 @@
using System.Diagnostics;
using ttrss_co_client.ttrss;
using ttrss_co_client.ttrss.datastructures;
namespace ttrss_co_client.tasks
{
///<summary>podcast, with attachment that is podcast</summary>
public class PodcastifyAttachment : Phase1Task
{
public override string TaskName => "podcastifyAttachment";
public override async Task<WorkOrder> ActOn(Headline headline, IEnumerable<Label> labelsWRTArticle)
{
Console.WriteLine($" podcast-is-attachment: {headline.link.ToString()}");
var myGuid = Guid.NewGuid().ToString();
var workingFolder = $"{Conf.WorkingDirectory}/{myGuid}";
var sw = new Stopwatch();
if(!Path.Exists(workingFolder))
{
Directory.CreateDirectory(workingFolder);
}
try
{
var attachmentLink = headline.attachments.Select(a => a.content_url)?.FirstOrDefault().ToString();
var extensionUpstream = attachmentLink.Substring(attachmentLink.LastIndexOf('.'));
var downloadPath = Path.Combine(workingFolder, headline.title) + extensionUpstream;
var downloader = new HttpClient();
downloader.DefaultRequestHeaders.UserAgent.ParseAdd(Conf.UserAgent);
sw.Start();
var dlResult = (await downloader.GetAsync(attachmentLink));
File.WriteAllBytes(downloadPath, await dlResult.Content.ReadAsByteArrayAsync());
sw.Stop();
var outputStr = $"{(dlResult.IsSuccessStatusCode ? "Success" : "fail")} in {sw.Elapsed}";
if(!dlResult.IsSuccessStatusCode)
{
outputStr += $"\n\t{dlResult.StatusCode} - {dlResult.ReasonPhrase}";
}
Console.WriteLine($" {attachmentLink}\n{outputStr}");
await TtrssClient.SetArticleLabel(labelsWRTArticle.First(l => l.caption?.ToLower() == this.TriggerLabel.ToLower()).id, false, headline.id);
Console.WriteLine($" {headline.title}: download trigger label removed");
var podcastTitle = headline.feed_title;
var titlingLabel = labelsWRTArticle.FirstOrDefault(l => l.@checked && l.caption?.ToLower().StartsWith(Conf.PodcastTitlePrefix) == true);
if(titlingLabel != null)
{
await TtrssClient.SetArticleLabel(titlingLabel.id, false, headline.id);
Console.WriteLine($" {headline.title}: podcast titling label removed");
podcastTitle = titlingLabel.caption.Substring(Conf.PodcastTitlePrefix.Length);
}
Console.WriteLine($"article {headline.id} - podcastifying; {podcastTitle}");
await TtrssClient.UpdateArticleNote($"{headline.note}\n[{DateTime.Now.ToString("o")}] - podcastify attachment (dl): {outputStr}", headline.id);
if (!dlResult.IsSuccessStatusCode)
{
return null;
}
else
{
Console.WriteLine($"{attachmentLink} downloaded.");
var toReturn = new WorkOrder()
{
articleId = headline.id,
Phase2TaskList = new Dictionary<int, string>(),
data = new Dictionary<string, string>(),
guid = Guid.Parse(myGuid)
};
toReturn.data["path"] = downloadPath;
toReturn.Phase2TaskList[1] = "filemovePublish";
toReturn.data["publish-target"] = $"{Conf.OnDoneCopy}/Podcasts/{podcastTitle}/";
if(extensionUpstream != ".mp3")
{
Console.WriteLine($"{headline.title} needs conversion task.");
toReturn.Phase2TaskList[0] = "convert";
toReturn.data["conversion-target"] = ".mp3";
}
toReturn.Phase2TaskList[2] = "markread";
toReturn.Phase2TaskList[3] = "chatmessage";
toReturn.data["chatmessage"] = "new podcast from ";
return toReturn;
}
}
catch (Exception e)
{
Console.Error.WriteLine($"fatal error in podcast attachment DL for {headline.link}");
Console.Error.WriteLine($"{e.ToString()}: {e.Message}.\n{e.StackTrace}");
await TtrssClient.UpdateArticleNote($"{headline.note}\n[{DateTime.Now.ToString("o")}] - fatal error {e.ToString()}: {e.Message}.\n{e.StackTrace}", headline.id);
return null;
}
}
}
}

View File

@ -1,59 +0,0 @@
using System.Diagnostics;
using ttrss_co_client.ttrss;
using ttrss_co_client.ttrss.datastructures;
namespace ttrss_co_client.tasks
{
///<summary>download from YT</summary>
public class PodcastifyYT : Phase1Task
{
public override string TaskName => "podcastifyYT";
public override async Task<WorkOrder> ActOn(Headline headline, IEnumerable<Label> labelsWRTArticle)
{
Console.WriteLine($" YT podcastify: {headline.link.ToString()}");
var myGuid = Guid.NewGuid().ToString();
var toReturn = new WorkOrder()
{
articleId = headline.id,
Phase2TaskList = new Dictionary<int, string>(),
data = new Dictionary<string, string>(),
guid = Guid.Parse(myGuid)
};
var podcastTitle = headline.feed_title;
var titlingLabel = labelsWRTArticle.FirstOrDefault(l => l.@checked && l.caption?.ToLower().StartsWith(Conf.PodcastTitlePrefix) == true);
if(titlingLabel != null)
{
podcastTitle = titlingLabel.caption.Substring(Conf.PodcastTitlePrefix.Length);
await TtrssClient.SetArticleLabel(titlingLabel.id, false, headline.id);
}
Console.WriteLine($"article {headline.id} - yt podcastifying; {podcastTitle}");
toReturn.Phase2TaskList[0] = "yt-dlp";
toReturn.data["ytdlp-link"] = headline.link.ToString();
toReturn.Phase2TaskList[1] = "convert";
toReturn.data["conversion-target"] = ".mp3";
if(headline.link.Host.EndsWith("youtube.com"))
{
toReturn.Phase2TaskList[2] = "sponsorblock";
}
toReturn.Phase2TaskList[3] = "filemovePublish";
toReturn.data["publish-target"] = $"{Conf.OnDoneCopy}/Podcasts/{podcastTitle}/";
toReturn.Phase2TaskList[4] = "markread";
toReturn.Phase2TaskList[5] = "chatmessage";
toReturn.data["chatmessage"] = "new podcast (via YT) from ";
await TtrssClient.SetArticleLabel(labelsWRTArticle.First(l => l.caption?.ToLower() == this.TriggerLabel.ToLower()).id, false, headline.id);
Console.WriteLine($" {headline.title}: download trigger label removed");
return toReturn;
}
}
}

View File

@ -1,32 +0,0 @@
using System.Linq;
using System.Diagnostics;
using ttrss_co_client.ttrss;
using ttrss_co_client.ttrss.datastructures;
namespace ttrss_co_client.tasks
{
///<summary>Move to output</summary>
public class Publish : Phase2Task
{
public override string TaskName => "filemovePublish";
public override async Task<Tuple<TaskStatus, WorkOrder>>ActOn(WorkOrder workOrder)
{
var targetDirectory = workOrder.data["publish-target"];
if(!Directory.Exists(targetDirectory))
{
Directory.CreateDirectory(targetDirectory);
}
var outputFilename = Path.Combine(workOrder.data["publish-target"], Path.GetFileName(workOrder.data["path"]));
foreach(char c in "'\":\\?")
{
outputFilename = outputFilename.Replace(c, ' ');
}
File.Move(workOrder.data["path"], outputFilename, true);
var article = (await TtrssClient.GetArticles(workOrder.articleId))?.FirstOrDefault();
await TtrssClient.UpdateArticleNote($"{article.note}\n[{DateTime.Now.ToString("o")}] - copied", article.id);
return new Tuple<TaskStatus, WorkOrder>(TaskStatus.ContinueNow, workOrder);
}
}
}

View File

@ -1,127 +0,0 @@
using System;
using System.Text;
using Newtonsoft.Json;
using System.Linq;
using System.Diagnostics;
using System.Text.RegularExpressions;
using ttrss_co_client.ttrss;
using ttrss_co_client.ttrss.datastructures;
namespace ttrss_co_client.tasks
{
public class Sponsorblock : Phase2Task
{
public override string TaskName => "sponsorblock";
public override async Task<Tuple<TaskStatus, WorkOrder>>ActOn(WorkOrder workOrder)
{
var sw = new Stopwatch();
sw.Start();
var article = (await TtrssClient.GetArticles(workOrder.articleId)).FirstOrDefault();
if(article == null)
{
Console.Error.WriteLine($"couldn't find article {workOrder.articleId} for workOrder {workOrder.guid}?!");
return new Tuple<TaskStatus, WorkOrder>(TaskStatus.Done, null);
}
if (!article.link.Host.EndsWith("youtube.com"))
{
Console.WriteLine("no sponsorblock segments on sites other than YT");
return new Tuple<TaskStatus, WorkOrder>(TaskStatus.ContinueNow, workOrder);
}
var match = Regex.Match(article.link.Query, "v=([^&]+)(&|$)");
var videoId = match.Groups?[1].Value;
var c = new HttpClient();
var sponsorblockcheck = await c.GetAsync($"https://sponsor.ajay.app/api/skipSegments?videoID={videoId}&category=sponsor&category=selfpromo&category=interaciton&category=intro&category=outro&category=preview&category=interaction");
IEnumerable<sponsorblock.Segment> segments = null;
try
{
segments = JsonConvert.DeserializeObject<IEnumerable<sponsorblock.Segment>>(await sponsorblockcheck.Content.ReadAsStringAsync());
}
catch (Exception e)
{
segments = null;
}
if (sponsorblockcheck.StatusCode == System.Net.HttpStatusCode.NotFound || segments == null || segments.Count() == 0)
{
Console.WriteLine($"sponsorblock reports that {videoId} has no entries (yet)");
var updateTimestamp = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);
updateTimestamp = updateTimestamp.AddSeconds(article.updated).ToLocalTime();
if (DateTime.Now - updateTimestamp > TimeSpan.FromMinutes(45))
{
await TtrssClient.UpdateArticleNote($"{article.note}\n[{DateTime.Now.ToString("o")}] updated {updateTimestamp} (more than 45 minutes ago), going to give up waiting for sponsorblock", article.id);
return new Tuple<TaskStatus, WorkOrder>(TaskStatus.ContinueNow, workOrder);
}
else
{
await TtrssClient.UpdateArticleNote($"{article.note}\n[{DateTime.Now.ToString("o")}] waiting for sponsorblock segments", article.id);
workOrder.Phase2TaskList[workOrder.Phase2TaskList.Keys.Min() - 1] = this.TaskName;
return new Tuple<TaskStatus, WorkOrder>(TaskStatus.TryLater, workOrder);
}
}
else
{
Console.WriteLine($"[{DateTime.Now.ToString("o")}] sponsorblock reports {segments.Count()} junk segments");
var contentSegments = new List<Tuple<double, double>>();
var previousEdge = 0.0;
var extension = workOrder.data["path"].Substring(workOrder.data["path"].LastIndexOf('.'));
var intermediatePathBase = workOrder.data["path"].Substring(0, workOrder.data["path"].LastIndexOf('.'));
var conversionProcesses = new List<Tuple<Process, Tuple<int, string>>>();
foreach(var junkSegment in segments.OrderBy(s => s.segment[0]))
{
contentSegments.Add(new Tuple<double, double>(previousEdge, junkSegment.segment[0]));
previousEdge = junkSegment.segment[1];
}
contentSegments.Add(new Tuple<double, double>(previousEdge, segments.First().videoDuration));
contentSegments = contentSegments.Except(contentSegments.Where(tup => tup.Item2 - tup.Item1 < 0.5)).ToList();
#region ffmpeg via intermediate files
var intermediateCount = 0;
foreach(var seg in contentSegments)
{
var intermediateTargetPath = $"{intermediatePathBase}-intermediate-{intermediateCount.ToString("D2")}{extension}";
conversionProcesses.Add(new Tuple<Process, Tuple<int, string>>(
Process.Start("ffmpeg", $"-y -loglevel quiet -i \"{workOrder.data["path"]}\" -ss {seg.Item1} -to {seg.Item2} \"{intermediateTargetPath}\""),
new Tuple<int, string>(intermediateCount, intermediateTargetPath)
));
Console.WriteLine("waiting for exit from task, for debugging reasons");
conversionProcesses.Last().Item1.WaitForExit();
intermediateCount++;
}
Console.WriteLine($"[{DateTime.Now.ToString("o")}] intermediate content segments being exported");
var intermediates = new Dictionary<int, string>();
foreach(var proc in conversionProcesses)
{
proc.Item1.WaitForExit();
intermediates[proc.Item2.Item1] = proc.Item2.Item2;
}
Console.WriteLine($"[{DateTime.Now.ToString("o")}] intermediate content segments should be exported, stitching together");
var sb = new StringBuilder();
sb.AppendLine("#ffmpeg demands it");
foreach(var intermediate in intermediates.OrderBy(kvp => kvp.Key))
{
sb.AppendLine($"file '{intermediate.Value}'");
}
var ffmpegFile = Path.Combine(Path.GetDirectoryName(workOrder.data["path"]), "ffmpeglist.txt");
File.WriteAllText(ffmpegFile, sb.ToString());
Process.Start("ffmpeg", $"-y -f concat -safe 0 -i {ffmpegFile} -c copy \"{workOrder.data["path"]}\"")
.WaitForExit();
File.Delete(ffmpegFile);
Console.WriteLine($"[{DateTime.Now.ToString("o")}] intermediate content segments stitched. Deleting originals.");
foreach(var intermediate in intermediates.Values)
{
File.Delete(intermediate);
}
#endregion
sw.Stop();
await TtrssClient.UpdateArticleNote($"{article.note}\n[{DateTime.Now.ToString("o")}] removed {segments.Count()} junk segments", article.id);
return new Tuple<TaskStatus, WorkOrder>(TaskStatus.ContinueNow, workOrder);
}
}
}
}

View File

@ -1,49 +0,0 @@
using System.Diagnostics;
using ttrss_co_client.ttrss;
using ttrss_co_client.ttrss.datastructures;
namespace ttrss_co_client.tasks
{
///<summary>download from YT</summary>
public class StandardDL : Phase1Task
{
public override string TaskName => "dl";
public override async Task<WorkOrder> ActOn(Headline headline, IEnumerable<Label> labelsWRTArticle)
{
Console.WriteLine($" standard download: {headline.link.ToString()}");
var myGuid = Guid.NewGuid().ToString();
var toReturn = new WorkOrder()
{
articleId = headline.id,
Phase2TaskList = new Dictionary<int, string>(),
data = new Dictionary<string, string>(),
guid = Guid.Parse(myGuid)
};
toReturn.Phase2TaskList[0] = "yt-dlp";
toReturn.data["ytdlp-link"] = headline.link.ToString();
toReturn.Phase2TaskList[1] = "convert";
toReturn.data["conversion-target"] = ".mp4";
if(headline.link.Host.EndsWith("youtube.com"))
{
toReturn.Phase2TaskList[2] = "sponsorblock";
}
toReturn.Phase2TaskList[3] = "filemovePublish";
toReturn.data["publish-target"] = $"{Conf.OnDoneCopy}/recent episodes/";
toReturn.Phase2TaskList[4] = "markread";
toReturn.Phase2TaskList[5] = "chatmessage";
toReturn.data["chatmessage"] = "new video from ";
await TtrssClient.SetArticleLabel(labelsWRTArticle.First(l => l.caption?.ToLower() == this.TriggerLabel.ToLower()).id, false, headline.id);
Console.WriteLine($" {headline.title}: download trigger label removed");
return toReturn;
}
}
}

View File

@ -1,12 +0,0 @@
using System.Collections.Generic;
namespace ttrss_co_client.tasks
{
public class WorkOrder
{
public int articleId { get; set; }
public Guid guid { get; set; }
public Dictionary<int, string> Phase2TaskList { get; set; } //so youtube downloads will download, then add a conversion to this, then add an adblock, then publish
public Dictionary<string, string> data { get; set; }
}
}

View File

@ -1,56 +0,0 @@
using System;
using System.Text;
using Newtonsoft.Json;
using System.Linq;
using System.Diagnostics;
using System.Text.RegularExpressions;
using ttrss_co_client.ttrss;
using ttrss_co_client.ttrss.datastructures;
namespace ttrss_co_client.tasks
{
///<summary>Move to output</summary>
public class Ytdlp : Phase2Task
{
public override string TaskName => "yt-dlp";
public override async Task<Tuple<TaskStatus, WorkOrder>>ActOn(WorkOrder workOrder)
{
var article = (await TtrssClient.GetArticles(workOrder.articleId)).FirstOrDefault();
var sw = new Stopwatch();
var ytdl = new YoutubeDLSharp.YoutubeDL();
ytdl.YoutubeDLPath = "yt-dlp";
ytdl.FFmpegPath = "ffmpeg";
ytdl.OutputFolder = $"{Conf.WorkingDirectory}/{workOrder.guid}";
ytdl.OutputFileTemplate = "%(upload_date)s - %(title)s - [%(id)s].%(ext)s";
if(!Path.Exists(ytdl.OutputFolder))
{
Directory.CreateDirectory(ytdl.OutputFolder);
}
sw.Start();
var res = await ytdl.RunVideoDownload(workOrder.data["ytdlp-link"]);
sw.Stop();
if (!res.Success)
{
await TtrssClient.UpdateArticleNote($"{article.note}\n[{DateTime.Now.ToString("o")}] yt-dlp fatal error. \n{string.Join('\n', res.ErrorOutput)}", article.id);
return null;
}
await TtrssClient.UpdateArticleNote($"{article.note}\n[{DateTime.Now.ToString("o")}] yt-dlp {(res.Success ? "success" : "fail")} in {sw.Elapsed}", article.id);
var outputFilename = res.Data;
foreach(char c in "'\"")
{
outputFilename = outputFilename.Replace(c, '_');
}
if(outputFilename != res.Data)
{
File.Move(res.Data, outputFilename);
}
workOrder.data["path"] = outputFilename;
return new Tuple<TaskStatus, WorkOrder>(TaskStatus.ContinueNow, workOrder);
}
}
}

View File

@ -12,4 +12,5 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="youtubedlsharp" Version="0.4.3" /> <PackageReference Include="youtubedlsharp" Version="0.4.3" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -18,7 +18,6 @@ namespace ttrss_co_client.ttrss.datastructures
public IEnumerable<string> tags { get; set; } public IEnumerable<string> tags { get; set; }
public string content { get; set; } public string content { get; set; }
///See <cref name="ttrss_co_client.ttrss.datastructures.Label" /> ///See <cref name="ttrss_co_client.ttrss.datastructures.Label" />
public IEnumerable<Attachment> attachments { get; set; }
public IEnumerable<IEnumerable<string>> labels { get; set; } public IEnumerable<IEnumerable<string>> labels { get; set; }
public string feed_title { get; set; } public string feed_title { get; set; }
public int comments_count { get; set; } public int comments_count { get; set; }