initial commit
This commit is contained in:
commit
7e3a70753b
11 changed files with 623 additions and 0 deletions
135
.editorconfig
Normal file
135
.editorconfig
Normal file
|
@ -0,0 +1,135 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
|
||||
[*.csproj]
|
||||
indent_size = 2
|
||||
|
||||
[*.json]
|
||||
indent_size = 2
|
||||
|
||||
[*.ps1]
|
||||
indent_size = 2
|
||||
|
||||
[*.sh]
|
||||
indent_size = 2
|
||||
|
||||
[*.cs]
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
|
||||
# Code Style and Analysis Rules
|
||||
# warn on everything by default, then override specific ones we don't care as much about
|
||||
dotnet_analyzer_diagnostic.severity = warning
|
||||
|
||||
# IDE0072: Populate switch
|
||||
dotnet_diagnostic.IDE0072.severity = suggestion
|
||||
|
||||
# IDE0045, IDE0046: Simplify if statement
|
||||
dotnet_diagnostic.IDE0045.severity = suggestion
|
||||
dotnet_diagnostic.IDE0046.severity = suggestion
|
||||
|
||||
# CA1848: Use LoggerDelegates
|
||||
dotnet_diagnostic.CA1848.severity = none
|
||||
|
||||
# CA1861: Prefer static readonly arrays
|
||||
dotnet_diagnostic.CA1861.severity = suggestion
|
||||
|
||||
# Source layout config
|
||||
csharp_style_namespace_declarations = file_scoped:warning
|
||||
dotnet_sort_system_directives_first = true
|
||||
|
||||
# Newline and whitespace config
|
||||
csharp_new_line_before_open_brace = none
|
||||
csharp_new_line_before_else = false
|
||||
csharp_new_line_before_catch = false
|
||||
csharp_new_line_before_finally = false
|
||||
csharp_indent_case_contents_when_block = false
|
||||
csharp_indent_labels = flush_left
|
||||
dotnet_style_allow_multiple_blank_lines_experimental = false
|
||||
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false
|
||||
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = false
|
||||
csharp_space_around_declaration_statements = do_not_ignore
|
||||
|
||||
# Style preferences
|
||||
csharp_prefer_braces = when_multiline
|
||||
csharp_style_expression_bodied_methods = false:none
|
||||
csharp_style_expression_bodied_constructors = false:none
|
||||
csharp_style_expression_bodied_operators = false:none
|
||||
csharp_style_expression_bodied_properties = true:none
|
||||
csharp_style_expression_bodied_indexers = true:none
|
||||
csharp_style_expression_bodied_accessors = true:none
|
||||
csharp_style_var_for_built_in_types = true
|
||||
csharp_style_var_when_type_is_apparent = true
|
||||
csharp_style_var_elsewhere = true
|
||||
csharp_style_unused_value_expression_statement_preference = discard_variable:none
|
||||
csharp_prefer_braces = when_multiline
|
||||
|
||||
# Naming rules
|
||||
dotnet_naming_rule.constants_should_be_pascal_case.severity = warning
|
||||
dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants
|
||||
dotnet_naming_rule.constants_should_be_pascal_case.style = pascal_case
|
||||
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.severity = warning
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
|
||||
|
||||
dotnet_naming_rule.types_should_be_pascal_case.severity = warning
|
||||
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
|
||||
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
|
||||
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = warning
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
|
||||
|
||||
dotnet_naming_rule.non_public_static_fields_should_have_prefix.severity = warning
|
||||
dotnet_naming_rule.non_public_static_fields_should_have_prefix.symbols = non_public_static_fields
|
||||
dotnet_naming_rule.non_public_static_fields_should_have_prefix.style = s_underscore_prefix
|
||||
|
||||
dotnet_naming_rule.non_public_fields_should_have_prefix.severity = warning;
|
||||
dotnet_naming_rule.non_public_fields_should_have_prefix.symbols = non_public_fields
|
||||
dotnet_naming_rule.non_public_fields_should_have_prefix.style = underscore_prefix
|
||||
|
||||
dotnet_naming_rule.locals_should_be_camel_case.severity = warning
|
||||
dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters
|
||||
dotnet_naming_rule.locals_should_be_camel_case.style = camel_case
|
||||
|
||||
# Symbol specifications
|
||||
dotnet_naming_symbols.constants.applicable_kinds = *
|
||||
dotnet_naming_symbols.constants.applicable_accessibilities = *
|
||||
dotnet_naming_symbols.constants.required_modifiers = const
|
||||
|
||||
dotnet_naming_symbols.interface.applicable_kinds = interface
|
||||
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
|
||||
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
|
||||
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
|
||||
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
|
||||
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
|
||||
dotnet_naming_symbols.non_public_static_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.non_public_static_fields.applicable_accessibilities = internal, private, protected, protected_internal, private_protected
|
||||
dotnet_naming_symbols.non_public_static_fields.required_modifiers = static
|
||||
|
||||
dotnet_naming_symbols.non_public_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.non_public_fields.applicable_accessibilities = internal, private, protected, protected_internal, private_protected
|
||||
|
||||
dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local
|
||||
|
||||
# Naming styles
|
||||
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_style.begins_with_i.required_prefix = I
|
||||
dotnet_naming_style.begins_with_i.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_style.s_underscore_prefix.required_prefix = s_
|
||||
dotnet_naming_style.s_underscore_prefix.capitalization = camel_case
|
||||
|
||||
dotnet_naming_style.underscore_prefix.required_prefix = _
|
||||
dotnet_naming_style.underscore_prefix.capitalization = camel_case
|
||||
|
||||
dotnet_naming_style.camel_case.capitalization = camel_case
|
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
|
@ -0,0 +1,44 @@
|
|||
*.swp
|
||||
*.*~
|
||||
project.lock.json
|
||||
.DS_Store
|
||||
*.pyc
|
||||
nupkg/
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
|
||||
# Rider
|
||||
.idea/
|
||||
|
||||
# Visual Studio
|
||||
.vs/
|
||||
|
||||
# Fleet
|
||||
.fleet/
|
||||
|
||||
# Code Rush
|
||||
.cr/
|
||||
|
||||
# User-specific files
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
build/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Oo]ut/
|
||||
msbuild.log
|
||||
msbuild.err
|
||||
msbuild.wrn
|
11
AbsPod.csproj
Normal file
11
AbsPod.csproj
Normal file
|
@ -0,0 +1,11 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<RootNamespace>AbsPod</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
10
Model/AbsPodConfig.cs
Normal file
10
Model/AbsPodConfig.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
namespace AbsPod.Model;
|
||||
|
||||
public class AbsPodConfig {
|
||||
public required string HostUrl { get; set; }
|
||||
public required string Username { get; set; }
|
||||
public required string Password { get; set; }
|
||||
public required Guid LibraryId { get; set; }
|
||||
public required string SyncToDirectory { get; set; }
|
||||
public required int MaxEpisodesPerPodcast { get; set; }
|
||||
}
|
10
Model/PodcastMeta.cs
Normal file
10
Model/PodcastMeta.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
namespace AbsPod.Model;
|
||||
|
||||
public class PodcastMetaDetail {
|
||||
public required Guid Id { get; set; }
|
||||
public Guid? LastEpisodeId { get; set; }
|
||||
}
|
||||
|
||||
public class PodcastMeta {
|
||||
public required List<PodcastMetaDetail> Podcasts { get; set; }
|
||||
}
|
147
Program.cs
Normal file
147
Program.cs
Normal file
|
@ -0,0 +1,147 @@
|
|||
using System.Text.Json;
|
||||
using AbsPod.Model;
|
||||
using AbsPod.Util;
|
||||
|
||||
var configDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"abspod");
|
||||
var configFile = Path.Combine(configDir, "config.json");
|
||||
|
||||
// if no config file, generate one and exit
|
||||
if (!Directory.Exists(configDir)
|
||||
|| !File.Exists(configFile)) {
|
||||
// first run
|
||||
var exampleOptions = new AbsPodConfig {
|
||||
HostUrl = "https://myaudiobookshelfserver.com",
|
||||
Username = "myuser",
|
||||
Password = "mypassword",
|
||||
LibraryId = Guid.Empty,
|
||||
SyncToDirectory = "/path/to/podcasts",
|
||||
MaxEpisodesPerPodcast = 10,
|
||||
};
|
||||
|
||||
if (!Directory.Exists(configDir))
|
||||
Directory.CreateDirectory(configDir);
|
||||
|
||||
File.WriteAllText(configFile, JsonSerializer.Serialize(
|
||||
exampleOptions, JsonOpts.Options));
|
||||
Console.WriteLine(
|
||||
$"Generated example config at {configFile}. Manually edit before running again.");
|
||||
Environment.Exit(0);
|
||||
}
|
||||
|
||||
AbsPodConfig? config;
|
||||
var configStr = File.ReadAllText(configFile);
|
||||
config = JsonSerializer.Deserialize<AbsPodConfig>(configStr, JsonOpts.Options);
|
||||
|
||||
if (config == null) {
|
||||
Console.WriteLine("Unable to read config.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
if (!Directory.Exists(config.SyncToDirectory)) {
|
||||
Console.WriteLine($"Sync directory {config.SyncToDirectory} does not exist.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
var podMetaPath = Path.Combine(config.SyncToDirectory, ".podmeta.json");
|
||||
var podMeta = File.Exists(podMetaPath)
|
||||
? JsonSerializer.Deserialize<PodcastMeta>(File.ReadAllText(podMetaPath), JsonOpts.Options)
|
||||
: new() { Podcasts = [] };
|
||||
|
||||
if (podMeta == null) {
|
||||
Console.WriteLine("Error reading podcast metadata.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
var client = new AbsClient(
|
||||
config.HostUrl,
|
||||
config.Username,
|
||||
config.Password);
|
||||
|
||||
if (!await client.Authenticate()) {
|
||||
Console.WriteLine("Failed to authenticate.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
var podcasts = await client.GetPodcasts(config.LibraryId);
|
||||
|
||||
foreach (var (podcastId, podcastName) in podcasts) {
|
||||
var meta = podMeta.Podcasts.SingleOrDefault(p => p.Id == podcastId);
|
||||
if (meta == null) {
|
||||
Console.WriteLine($"Found new podcast: {podcastName}.");
|
||||
meta = new() {
|
||||
Id = podcastId,
|
||||
};
|
||||
podMeta.Podcasts.Add(meta);
|
||||
} else {
|
||||
Console.WriteLine($"Processing podcast: {podcastName}.");
|
||||
}
|
||||
|
||||
var podDir = Path.Combine(config.SyncToDirectory, FileNamer.PodDir(podcastName));
|
||||
if (!Directory.Exists(podDir)) Directory.CreateDirectory(podDir);
|
||||
|
||||
var episodes = await client.GetEpisodes(podcastId);
|
||||
var lastDownloadedIndex = -1;
|
||||
if (meta.LastEpisodeId.HasValue) {
|
||||
foreach (var (episodeId, _, _, _) in episodes) {
|
||||
lastDownloadedIndex++;
|
||||
if (episodeId == meta.LastEpisodeId) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var downloadedCount = 0;
|
||||
for (var i = 0; i < episodes.Length; i++) {
|
||||
var (episodeId, episodeName, episodeDate, extension) = episodes[i];
|
||||
var epFile = Path.Combine(podDir, FileNamer.EpFile(episodeName, episodeDate, extension));
|
||||
var fileExists = File.Exists(epFile);
|
||||
var isFinished = await client.GetMediaFinished(podcastId, episodeId);
|
||||
|
||||
if (i <= lastDownloadedIndex) {
|
||||
// handle files we already may have downloaded
|
||||
if (fileExists) {
|
||||
if (isFinished) {
|
||||
// episode was finished somewhere else, delete it
|
||||
Console.WriteLine($"Deleting finished episode: {episodeName}");
|
||||
File.Delete(epFile);
|
||||
} else {
|
||||
// episode was downloaded and not yet finished, keep it
|
||||
downloadedCount++;
|
||||
}
|
||||
} else {
|
||||
if (!isFinished) {
|
||||
// file was previously downloaded and deleted, mark it as finished on the server
|
||||
Console.WriteLine($"Marking episode finished: {episodeName}");
|
||||
await client.SetMediaFinished(podcastId, episodeId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (fileExists) {
|
||||
// probably means we crashed last time and didn't save metadata
|
||||
// let's delete it and re-process
|
||||
File.Delete(epFile);
|
||||
}
|
||||
|
||||
// handle new episodes not yet downloaded
|
||||
if (downloadedCount >= config.MaxEpisodesPerPodcast) {
|
||||
// if we're already at the max we don't need to download anymore
|
||||
break;
|
||||
}
|
||||
|
||||
if (!isFinished) {
|
||||
// download the episode
|
||||
await client.DownloadEpisode(podcastId, episodeId, episodeName, epFile);
|
||||
meta.LastEpisodeId = episodeId;
|
||||
downloadedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// save metadata
|
||||
var oldPath = $"{podMetaPath}.old";
|
||||
if (File.Exists(oldPath)) File.Delete(oldPath);
|
||||
if (File.Exists(podMetaPath)) File.Move(podMetaPath, oldPath);
|
||||
File.WriteAllText(podMetaPath, JsonSerializer.Serialize(podMeta, JsonOpts.Options));
|
38
README.md
Normal file
38
README.md
Normal file
|
@ -0,0 +1,38 @@
|
|||
## `abspod` - Audobookshelf Podcast Manager
|
||||
|
||||
Tool to manually sync podcast episodes from [Audiobookshelf](https://www.audiobookshelf.org/) with a local directory.
|
||||
|
||||
Intended for syncing podcasts with offline devices (such as [Rockbox](https://rockbox.org) players) in a way where episodes that you listen to on the server will be automatically removed from your device, and episodes that you listen to (and delete) on your device will be automatically be marked as finished in Audiobookshelf.
|
||||
|
||||
### Features
|
||||
|
||||
- Download the `maxEpisodesPerPodcast` oldest unlistened episodes from each podcast to a local directory.
|
||||
- Mark episodes as finished in Audiobookshelf when they're deleted from the local directory.
|
||||
- Delete episodes from the local directory when they're marked as finished on Audiobookshelf.
|
||||
|
||||
### Usage
|
||||
|
||||
Run once to generate a sample configuration file. Edit the file and configure according to your preferences. You can get the `libraryId` by navigating to your Podcast library in Audiobookshelf and copying it from the url, which should look like this:
|
||||
|
||||
```
|
||||
https://myaudiobookshelfserver.com/library/{libraryId}`
|
||||
```
|
||||
|
||||
Example configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"hostUrl": "https://myaudiobookshelfserver.com",
|
||||
"username": "myuser",
|
||||
"password": "mypassword",
|
||||
"libraryId": "c02bab76-687e-4126-9bb3-d6197391780b",
|
||||
"syncToDirectory": "/mnt/usb/podcasts",
|
||||
"maxEpisodesPerPodcast": 10
|
||||
}
|
||||
```
|
||||
|
||||
Run `abspod` again and the application will download the `maxEpisodesPerPodcast` oldest unfinished episodes from each podcast in your library, named by published date and episode title in folders named by podcast title.
|
||||
|
||||
If you listen to episodes on your server, the next time you run abspod those episodes will be deleted from your sync directory, and if any newer episodes are available they will be downloaded to make sure you still have `maxEpisodesPerPodcast` episodes.
|
||||
|
||||
If you delete an already downloaded file from your sync directory, the next time you run abspod those episodes will be marked as finished on your server, and if any newer episodes are available they will be downloaded to make sure you still have `maxEpisodesPerPodcast` episodes.
|
179
Util/AbsClient.cs
Normal file
179
Util/AbsClient.cs
Normal file
|
@ -0,0 +1,179 @@
|
|||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace AbsPod.Util;
|
||||
|
||||
public class AbsClient {
|
||||
private string Username { get; set; }
|
||||
private string Password { get; set; }
|
||||
|
||||
private string? Token { get; set; }
|
||||
private HttpClient Client { get; set; }
|
||||
|
||||
public AbsClient(
|
||||
string host,
|
||||
string username,
|
||||
string password) {
|
||||
Client = new() {
|
||||
BaseAddress = new Uri(host),
|
||||
};
|
||||
|
||||
Client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
|
||||
Username = username;
|
||||
Password = password;
|
||||
}
|
||||
|
||||
public async Task<bool> Authenticate() {
|
||||
Console.WriteLine("Authenticating...");
|
||||
|
||||
var resp = await Client.PostAsJsonAsync(
|
||||
"/login", new { Username, Password }, JsonOpts.Options);
|
||||
|
||||
if (!resp.IsSuccessStatusCode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var jnode = await resp.Content.ReadFromJsonAsync<JsonNode>();
|
||||
Token = jnode?["user"]?["token"]?.GetValue<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(Token)) {
|
||||
Client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", Token);
|
||||
}
|
||||
|
||||
return !string.IsNullOrEmpty(Token);
|
||||
}
|
||||
|
||||
public async Task<(Guid Id, string Name)[]> GetPodcasts(Guid libraryId) {
|
||||
var jnode = await ApiGetAsync($"/api/libraries/{libraryId}/items");
|
||||
|
||||
var mediaType = jnode?["mediaType"]?.GetValue<string>();
|
||||
if (string.IsNullOrEmpty(mediaType)) {
|
||||
Console.WriteLine("Unable to determine library media type.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
if (mediaType != "podcast") {
|
||||
Console.WriteLine($"Invalid library media type: {mediaType}.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
var results = jnode?["results"]?.AsArray() ?? [];
|
||||
return [.. results.Select(static r =>
|
||||
(r!["id"]!.GetValue<Guid>(),
|
||||
r!["media"]!["metadata"]!["title"]!.GetValue<string>()))];
|
||||
}
|
||||
|
||||
public async Task<(Guid Id, string Name, DateTime PubDate, string Extension)[]> GetEpisodes(
|
||||
Guid podcastId) {
|
||||
var jnode = await ApiGetAsync($"/api/items/{podcastId}");
|
||||
|
||||
var episodes = jnode?["media"]?["episodes"]?.AsArray() ?? [];
|
||||
return [.. episodes.Select(static e =>
|
||||
(e!["id"]!.GetValue<Guid>(),
|
||||
e!["title"]!.GetValue<string>(),
|
||||
DateTime.Parse(e!["pubDate"]!.GetValue<string>(),
|
||||
CultureInfo.InvariantCulture),
|
||||
e!["audioFile"]!["metaData"]!["ext"]!.GetValue<string>()))
|
||||
.OrderBy(static e => e.Item3)];
|
||||
}
|
||||
|
||||
public async Task<bool> GetMediaFinished(
|
||||
Guid podcastId,
|
||||
Guid episodeId) {
|
||||
var jnode = await ApiGetAsync($"/api/me/progress/{podcastId}/{episodeId}", false);
|
||||
return jnode != null && jnode["isFinished"]!.GetValue<bool>();
|
||||
}
|
||||
|
||||
public async Task SetMediaFinished(
|
||||
Guid podcastId,
|
||||
Guid episodeId) {
|
||||
var resp = await Client.PatchAsJsonAsync($"/api/me/progress/{podcastId}/{episodeId}",
|
||||
new { IsFinished = true }, JsonOpts.Options);
|
||||
|
||||
if (!resp.IsSuccessStatusCode) {
|
||||
Console.WriteLine("Error marking episode as finished.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DownloadEpisode(
|
||||
Guid podcastId,
|
||||
Guid episodeId,
|
||||
string episodeName,
|
||||
string episodeFilePath) {
|
||||
Console.WriteLine($"Downloading episode: {episodeName}.");
|
||||
|
||||
var resp = await Client.PostAsJsonAsync($"/api/items/{podcastId}/play/{episodeId}",
|
||||
new {
|
||||
DeviceInfo = new {
|
||||
DeviceId = "abspod",
|
||||
ClientName = "abspod",
|
||||
},
|
||||
ForeDirectPlay = false,
|
||||
ForceTranscode = false,
|
||||
MediaPlayer = "external",
|
||||
SupportedMimeTypes = new[] {
|
||||
"audio/flac",
|
||||
"audio/mpeg",
|
||||
"audio/mp4",
|
||||
"audio/ogg",
|
||||
"audio/aac",
|
||||
},
|
||||
}, JsonOpts.Options);
|
||||
|
||||
if (!resp.IsSuccessStatusCode) {
|
||||
Console.WriteLine("Error retrieving episode details.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
var jnode = await resp.Content.ReadFromJsonAsync<JsonNode>();
|
||||
|
||||
var contentUrl = jnode!["audioTracks"]!.AsArray().First()!["contentUrl"]!
|
||||
.GetValue<string>();
|
||||
|
||||
var fileResp = await Client.GetAsync(contentUrl);
|
||||
if (!fileResp.IsSuccessStatusCode) {
|
||||
Console.WriteLine("Error downloading episode.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
using var inStream = await fileResp.Content.ReadAsStreamAsync();
|
||||
using var outStream = File.Create(episodeFilePath);
|
||||
await inStream.CopyToAsync(outStream);
|
||||
outStream.Close();
|
||||
}
|
||||
|
||||
private async Task<JsonNode?> ApiGetAsync(
|
||||
string url,
|
||||
bool failOnError = true) {
|
||||
//Console.WriteLine($"Fetching {url}...");
|
||||
|
||||
try {
|
||||
var resp = await Client.GetAsync(url);
|
||||
|
||||
if (!resp.IsSuccessStatusCode && failOnError) {
|
||||
Console.WriteLine($"Request to {url} failed ({resp.StatusCode}).");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
var jnode = await resp.Content.ReadFromJsonAsync<JsonNode>();
|
||||
if (jnode == null && failOnError) {
|
||||
Console.WriteLine($"Request to {url} returned unexpected response.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
return jnode;
|
||||
} catch (Exception ex) {
|
||||
if (failOnError) {
|
||||
Console.WriteLine($"Error: {ex.Message}\n{ex.StackTrace}");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
27
Util/FileNamer.cs
Normal file
27
Util/FileNamer.cs
Normal file
|
@ -0,0 +1,27 @@
|
|||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace AbsPod.Util;
|
||||
|
||||
public static class FileNamer {
|
||||
public static string PodDir(string podcastName) => Sanitize(podcastName);
|
||||
|
||||
public static string EpFile(
|
||||
string episodeName,
|
||||
DateTime pubDate,
|
||||
string extension) =>
|
||||
$"{pubDate.ToString(
|
||||
"yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture)} {Sanitize(episodeName)}{extension}";
|
||||
|
||||
private static string Sanitize(string input) {
|
||||
var sanitized = new StringBuilder();
|
||||
// combined linux & windows illegal chars
|
||||
var invalid = new char[] { ':', '?', '"', '|', '<', '>', '\\', '*', '/' };
|
||||
foreach (var ch in input) {
|
||||
if (!invalid.Contains(ch)) sanitized.Append(ch);
|
||||
}
|
||||
|
||||
return sanitized.ToString();
|
||||
}
|
||||
}
|
14
Util/JsonOpts.cs
Normal file
14
Util/JsonOpts.cs
Normal file
|
@ -0,0 +1,14 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace AbsPod.Util;
|
||||
|
||||
public static class JsonOpts {
|
||||
public static readonly JsonSerializerOptions Options;
|
||||
|
||||
static JsonOpts() {
|
||||
Options = new JsonSerializerOptions(JsonSerializerDefaults.General) {
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
}
|
||||
}
|
8
omnisharp.json
Normal file
8
omnisharp.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"RoslynExtensionsOptions": {
|
||||
"enableAnalyzersSupport": true
|
||||
},
|
||||
"FormattingOptions": {
|
||||
"enableEditorConfigSupport": true
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue