├── .gitignore ├── Audiothekar.sln ├── License.md ├── Readme.md ├── audiothek-client ├── ApiRequester.cs ├── Models │ └── Root.cs └── audiothek-client.csproj ├── audiothekar-cli ├── Program.cs └── audiothekar-cli.csproj └── images └── screenshot.png /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.suo 3 | *.user 4 | .vs/ 5 | [Bb]in/ 6 | [Oo]bj/ 7 | _UpgradeReport_Files/ 8 | [Pp]ackages/ 9 | 10 | Thumbs.db 11 | Desktop.ini 12 | .DS_Store 13 | -------------------------------------------------------------------------------- /Audiothekar.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "audiothek-client", "audiothek-client\audiothek-client.csproj", "{4DA78C6D-3023-448F-B587-2B7B8A286F8D}" 4 | EndProject 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "audiothekar-cli", "audiothekar-cli\audiothekar-cli.csproj", "{85D6DD2F-0BC3-44B4-A8D2-68EFD0C1B216}" 6 | EndProject 7 | Global 8 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 9 | Debug|Any CPU = Debug|Any CPU 10 | Release|Any CPU = Release|Any CPU 11 | EndGlobalSection 12 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 13 | {4DA78C6D-3023-448F-B587-2B7B8A286F8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 14 | {4DA78C6D-3023-448F-B587-2B7B8A286F8D}.Debug|Any CPU.Build.0 = Debug|Any CPU 15 | {4DA78C6D-3023-448F-B587-2B7B8A286F8D}.Release|Any CPU.ActiveCfg = Release|Any CPU 16 | {4DA78C6D-3023-448F-B587-2B7B8A286F8D}.Release|Any CPU.Build.0 = Release|Any CPU 17 | {85D6DD2F-0BC3-44B4-A8D2-68EFD0C1B216}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {85D6DD2F-0BC3-44B4-A8D2-68EFD0C1B216}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {85D6DD2F-0BC3-44B4-A8D2-68EFD0C1B216}.Release|Any CPU.ActiveCfg = Release|Any CPU 20 | {85D6DD2F-0BC3-44B4-A8D2-68EFD0C1B216}.Release|Any CPU.Build.0 = Release|Any CPU 21 | EndGlobalSection 22 | EndGlobal 23 | -------------------------------------------------------------------------------- /License.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Felix Steinfurth 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Audiothekar 2 | Download-Client für die ARD-Audiothek https://www.ardaudiothek.de/ 3 | 4 | Spectre-Konsolenanwendung, die die GraphQL-API abfragt und ausgewählte Programmreihen herunterlädt. 5 | 6 | ``> audiothekar-cli.exe`` 7 | 8 | ![screenshot](images/screenshot.png "Screenshot") 9 | -------------------------------------------------------------------------------- /audiothek-client/ApiRequester.cs: -------------------------------------------------------------------------------- 1 | using GraphQL; 2 | using GraphQL.Client.Http; 3 | using GraphQL.Client.Serializer.SystemTextJson; 4 | 5 | namespace audiothek_client 6 | { 7 | public class ApiRequester 8 | { 9 | private const string ApiUrl = "https://api.ardaudiothek.de/graphql"; 10 | 11 | readonly GraphQLHttpClient _graphQlClient = new GraphQLHttpClient(ApiUrl, new SystemTextJsonSerializer()); 12 | 13 | GraphQLRequest AllProgramSetsRequest = new GraphQLRequest 14 | { 15 | Query = @" 16 | { 17 | programSets {nodes {title, numberOfElements, nodeId, rowId, editorialCategory{title, id}, lastItemAdded}} 18 | }" 19 | }; 20 | 21 | private GraphQLRequest ProgramSetByNodeIdRequest(string nodeId) 22 | { 23 | return new GraphQLRequest 24 | { 25 | Query = 26 | $"{{ programSetByNodeId(nodeId:\"{nodeId}\") {{ rowId, items{{nodes{{ title, audios{{url, downloadUrl, allowDownload}}, assetId, isPublished, publishDate, episodeNumber, summary, description, duration}}}}}}}}" 27 | }; 28 | } 29 | 30 | public async Task> GetAllProgramSets() 31 | { 32 | var graphQlResponse = await _graphQlClient.SendQueryAsync(AllProgramSetsRequest); 33 | return graphQlResponse.Data.programSets.nodes.Where(x => x.numberOfElements != null); 34 | } 35 | 36 | public async Task> GetFilesByNodeId(string nodeId) 37 | { 38 | GraphQLRequest query = ProgramSetByNodeIdRequest(nodeId); 39 | var graphQlResponse = await _graphQlClient.SendQueryAsync(query); 40 | return graphQlResponse.Data.programSetByNodeId.items.nodes; 41 | } 42 | 43 | public async Task DownloadAllFilesFromNodes(IEnumerable nodes, string parentTitle, string path) 44 | { 45 | string outputDir = Path.Combine(path, MakeValidFileName(parentTitle)); 46 | foreach (var node in nodes) 47 | { 48 | await Download(node, outputDir); 49 | } 50 | } 51 | 52 | public async Task Download(Node node, string outputDir) 53 | { 54 | int i = 0; 55 | var audios = node.audios.Where(x => x.downloadUrl != null); 56 | foreach (var audio in audios) 57 | { 58 | i++; 59 | string downloadUrl = audio.downloadUrl!; 60 | if (string.IsNullOrEmpty(downloadUrl) || string.IsNullOrEmpty(node.title)) 61 | continue; 62 | string partNumberInFilename = audios.Count() > 1 ? $" ({i})" : string.Empty; 63 | string filename = $"{MakeValidFileName(node.title)}{partNumberInFilename}.mp3"; 64 | await Download(downloadUrl, Path.Combine(outputDir, filename)); 65 | } 66 | } 67 | 68 | private async Task Download(string? downloadUrl, string filePath) 69 | { 70 | string? dirPath = Path.GetDirectoryName(filePath); 71 | Directory.CreateDirectory(dirPath); 72 | var uri = new Uri(downloadUrl); 73 | var httpClient = new HttpClient(); 74 | httpClient.Timeout = TimeSpan.FromSeconds(200); 75 | using (var s = await httpClient.GetStreamAsync(uri)) 76 | { 77 | using (var fs = new FileStream(filePath, FileMode.OpenOrCreate)) 78 | { 79 | await s.CopyToAsync(fs); 80 | } 81 | } 82 | } 83 | 84 | private static string MakeValidFileName(string name) 85 | { 86 | string invalidChars = 87 | System.Text.RegularExpressions.Regex.Escape(new string(System.IO.Path.GetInvalidFileNameChars())); 88 | string invalidRegStr = string.Format(@"([{0}]*\.+$)|([{0}]+)", invalidChars); 89 | 90 | return System.Text.RegularExpressions.Regex.Replace(name, invalidRegStr, "-"); 91 | } 92 | } 93 | } -------------------------------------------------------------------------------- /audiothek-client/Models/Root.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | 3 | public class Data 4 | { 5 | public ProgramSets programSets { get; set; } 6 | public ProgramSetByNodeId programSetByNodeId { get; set; } 7 | } 8 | 9 | public class EditorialCategory 10 | { 11 | public string title { get; set; } 12 | public string id { get; set; } 13 | } 14 | 15 | public class Node 16 | { 17 | public string title { get; set; } 18 | public int? numberOfElements { get; set; } 19 | public string nodeId { get; set; } 20 | public int rowId { get; set; } 21 | public string summary { get; set; } 22 | public string description { get; set; } 23 | public string assetId { get; set; } 24 | public int duration { get; set; } 25 | public bool isPublished { get; set; } 26 | public int? episodeNumber { get; set; } 27 | public DateTimeOffset? publishDate { get; set; } 28 | public DateTimeOffset? lastItemAdded { get; set; } 29 | public EditorialCategory? editorialCategory { get; set; } 30 | public List