Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Support downloading collections and individual episodes #2

Merged
merged 1 commit into from
Jan 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<!-- ABOUT THE PROJECT -->
## About The Project

`audiotheker` allows downloading all episodes of a program in the [ARD Audiothek](https://www.ardaudiothek.de/). It queries the official [GraphQL API](https://api.ardaudiothek.de/docs/#/GraphQL) to gather the download URLs.
`audiotheker` allows downloading all episodes of a program/collection or an individual episode in the [ARD Audiothek](https://www.ardaudiothek.de/). It queries the official [GraphQL API](https://api.ardaudiothek.de/docs/#/GraphQL) to gather the download URLs.

<p align="right">(<a href="#top">back to top</a>)</p>

Expand Down Expand Up @@ -77,7 +77,10 @@
<!-- USAGE EXAMPLES -->
## Usage

Copy the URL to a program, collection, or an individual episode from your browser and provide the URL and a target directory to the binary or a Docker container.

### Built binary

```sh
$ ./audiotheker download \
"https://www.ardaudiothek.de/sendung/j-r-r-tolkien-der-herr-der-ringe-fantasy-hoerspiel-klassiker/12197351/" \
Expand Down
176 changes: 159 additions & 17 deletions cmd/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ import (
"github.com/spf13/cobra"
)

var GRAPHQL_ENDPOINT = "https://api.ardaudiothek.de/graphql"

type QueryType int64

const (
Episode QueryType = iota
Collection
Program
Unknown
)

func init() {
rootCmd.AddCommand(downloadCmd)
}
Expand Down Expand Up @@ -58,7 +69,7 @@ func downloadFile(url string, targetDirectory string) (err error) {
return nil
}

func extractDownloadUrls(response *Response) []string {
func extractDownloadUrls(response *ItemsResponse) []string {
var urls []string

for _, nodes := range response.Result.Items.Nodes {
Expand All @@ -72,13 +83,37 @@ func extractDownloadUrls(response *Response) []string {
return urls
}

func extractProgramId(url string) string {
func extractQueryId(url string) (string, QueryType) {
normalizedUrl := strings.TrimSuffix(url, "/")
pattern := regexp.MustCompile(`^https:\/\/www\.ardaudiothek\.de\/sendung\/.*\/(\d*)$`)
return pattern.FindStringSubmatch(normalizedUrl)[1]

// Program
programPattern := regexp.MustCompile(`^https:\/\/www\.ardaudiothek\.de\/sendung\/.*\/(\d*)$`)
matches := programPattern.FindStringSubmatch(normalizedUrl)

if matches != nil {
return matches[1], Program
}

// Collection
collectionPattern := regexp.MustCompile(`^https:\/\/www\.ardaudiothek\.de\/sammlung\/.*\/(\d*)$`)
matches = collectionPattern.FindStringSubmatch(normalizedUrl)

if matches != nil {
return matches[1], Collection
}

// Episode
episodePattern := regexp.MustCompile(`^https:\/\/www\.ardaudiothek\.de\/episode\/.*\/(\d*)$`)
matches = episodePattern.FindStringSubmatch(normalizedUrl)

if matches != nil {
return matches[1], Episode
}

return "", Unknown
}

type Response struct {
type ItemsResponse struct {
Result struct {
Items struct {
Nodes []struct {
Expand All @@ -90,11 +125,22 @@ type Response struct {
}
}

func run(url string, targetDirectory string) {
programId := extractProgramId(url)
func sendGraphQlQuery(query string, variables map[string]interface{}, response interface{}) error {
client := graphql.NewClient(GRAPHQL_ENDPOINT, nil)

client := graphql.NewClient("https://api.ardaudiothek.de/graphql", nil)
rawGraphqlResponse, graphQlErr := client.ExecRaw(context.Background(), query, variables)
if graphQlErr != nil {
return graphQlErr
}

if jsonError := json.Unmarshal(rawGraphqlResponse, &response); jsonError != nil {
return jsonError
}

return nil
}

func getProgramUrls(queryId string) ([]string, error) {
query := `query ProgramSetEpisodesQuery($id: ID!, $offset: Int!, $count: Int!) {
result: programSet(id: $id) {
items(
Expand All @@ -111,30 +157,126 @@ func run(url string, targetDirectory string) {
}
}
}`

variables := map[string]interface{}{
"id": programId,
"id": queryId,
"offset": 0,
"count": 100,
}

rawGraphqlResponse, graphQlErr := client.ExecRaw(context.Background(), query, variables)
if graphQlErr != nil {
panic(graphQlErr)
var response ItemsResponse
graphQlError := sendGraphQlQuery(query, variables, &response)

if graphQlError != nil {
return nil, graphQlError
}

var response Response
if jsonError := json.Unmarshal(rawGraphqlResponse, &response); jsonError != nil {
panic(jsonError)
urls := extractDownloadUrls(&response)

return urls, nil
}

func getCollectionUrls(queryId string) ([]string, error) {
query := `query EpisodesQuery($id: ID!, $offset: Int!, $limit: Int!) {
result: editorialCollection(id: $id, offset: $offset, limit: $limit) {
items {
nodes {
id
audios {
url
downloadUrl
allowDownload
}
}
}
}
}`
variables := map[string]interface{}{
"id": queryId,
"offset": 0,
"limit": 100,
}

var response ItemsResponse
graphQlError := sendGraphQlQuery(query, variables, &response)

if graphQlError != nil {
return nil, graphQlError
}

urls := extractDownloadUrls(&response)

return urls, nil
}

type ItemResponse struct {
Result struct {
Audios []struct {
DownloadUrl *string
}
}
}

func getEpisodeUrls(queryId string) ([]string, error) {
query := `query EpisodeQuery($id: ID!) {
result: item(id: $id) {
audios {
downloadUrl
}
}
}`
variables := map[string]interface{}{
"id": queryId,
}

var response ItemResponse
graphQlError := sendGraphQlQuery(query, variables, &response)

if graphQlError != nil {
return nil, graphQlError
}

var urls []string
for _, audios := range response.Result.Audios {
if audios.DownloadUrl != nil {
if *audios.DownloadUrl != "" {
urls = append(urls, *audios.DownloadUrl)
}
}
}

return urls, nil
}

func getDownloadUrls(url string) ([]string, error) {
queryId, queryType := extractQueryId(url)

switch queryType {
case Episode:
return getEpisodeUrls(queryId)

case Collection:
return getCollectionUrls(queryId)

case Program:
return getProgramUrls(queryId)

default:
return nil, fmt.Errorf("URL is not supported: %s", url)
}
}

func run(url string, targetDirectory string) {
urls, err := getDownloadUrls(url)

if err != nil {
panic(err)
}

for _, url := range urls {
downloadErr := downloadFile(url, targetDirectory)

if downloadErr != nil {
fmt.Printf("Downloading URL %s failed with error: %v\n", url, downloadErr)
fmt.Printf("Downloading file %s failed with error: %v\n", url, downloadErr)
}
}
}