initial commit

This commit is contained in:
Rudis Muiznieks 2025-03-31 20:46:21 -05:00
commit 7e3a70753b
Signed by: rudism
GPG key ID: CABF2F86EF7884F9
11 changed files with 623 additions and 0 deletions

135
.editorconfig Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,8 @@
{
"RoslynExtensionsOptions": {
"enableAnalyzersSupport": true
},
"FormattingOptions": {
"enableEditorConfigSupport": true
}
}