├── server ├── models │ ├── __init__.py │ ├── tonie.py │ └── audio.py ├── toniecloud │ ├── errors.py │ ├── __init__.py │ ├── session.py │ └── client.py ├── localstorage │ ├── __init__.py │ └── client.py ├── requirements.txt └── app.py ├── sample.png ├── client ├── babel.config.js ├── public │ ├── favicon.ico │ └── index.html ├── src │ ├── main.js │ ├── router.js │ ├── App.vue │ └── components │ │ ├── Tonies.vue │ │ └── AudioBooks.vue ├── jest.config.js ├── package.json └── tests │ └── unit │ └── audiobook.spec.js ├── .editorconfig ├── .env.sample ├── LICENSE ├── docker-compose.yml ├── README.md └── .gitignore /server/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/toniecloud/errors.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/localstorage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/toniecloud/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/croesnick/toniebox-audio-match/HEAD/sample.png -------------------------------------------------------------------------------- /server/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask ~= 1.1.2 2 | Flask-Cors ~= 3.0.8 3 | requests ~= 2.25.1 4 | tinytag ~= 1.5.0 5 | -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset', 4 | ], 5 | }; 6 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/croesnick/toniebox-audio-match/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | max_line_length = 100 8 | -------------------------------------------------------------------------------- /server/models/tonie.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass(frozen=True) 5 | class Household: 6 | id: str 7 | 8 | 9 | @dataclass(frozen=True) 10 | class Tonie: 11 | id: str 12 | household: Household 13 | name: str 14 | image: str 15 | -------------------------------------------------------------------------------- /client/src/main.js: -------------------------------------------------------------------------------- 1 | import 'bootstrap/dist/css/bootstrap.css'; 2 | import Vue from 'vue'; 3 | import App from './App.vue'; 4 | import router from './router'; 5 | 6 | Vue.config.productionTip = false; 7 | 8 | new Vue({ 9 | router, 10 | render: (h) => h(App), 11 | }).$mount('#app'); 12 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | TONIE_AUDIO_MATCH_MEDIA_PATH=path/to/your/local/media/library 2 | 3 | TONIE_AUDIO_MATCH_FRONTEND_PORT=80 4 | 5 | TONIE_AUDIO_MATCH_BACKEND_SCHEME=http 6 | TONIE_AUDIO_MATCH_BACKEND_HOST=localhost 7 | TONIE_AUDIO_MATCH_BACKEND_PORT=8080 8 | 9 | TONIE_AUDIO_MATCH_USER=your@email.com 10 | TONIE_AUDIO_MATCH_PASS=your password on tonies.de 11 | -------------------------------------------------------------------------------- /client/src/router.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Router from 'vue-router'; 3 | import AudioBooks from './components/AudioBooks.vue'; 4 | 5 | Vue.use(Router); 6 | 7 | export default new Router({ 8 | mode: 'history', 9 | base: process.env.BASE_URL, 10 | routes: [ 11 | { 12 | path: '/', 13 | name: 'AudioBooks', 14 | component: AudioBooks, 15 | }, 16 | ], 17 | }); 18 | -------------------------------------------------------------------------------- /client/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: true, 3 | roots: ['/src/', '/tests/'], 4 | moduleFileExtensions: ['js', 'vue'], 5 | moduleNameMapper: { 6 | '^@/(.*)$': '/src/$1', 7 | }, 8 | transform: { 9 | '^.+\\.js$': 'babel-jest', 10 | '^.+\\.vue$': 'vue-jest', 11 | }, 12 | snapshotSerializers: [ 13 | '/node_modules/jest-serializer-vue', 14 | ], 15 | }; 16 | -------------------------------------------------------------------------------- /client/src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 23 | -------------------------------------------------------------------------------- /client/src/components/Tonies.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 25 | -------------------------------------------------------------------------------- /server/toniecloud/session.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode 2 | from typing import ClassVar 3 | 4 | import requests 5 | 6 | 7 | class TonieCloudSession(requests.Session): 8 | URI: ClassVar[str] = "https://api.tonie.cloud/v2" 9 | OPENID_CONNECT: ClassVar[str] = "https://login.tonies.com/auth/realms/tonies/protocol/openid-connect/token" 10 | 11 | def __init__(self): 12 | super().__init__() 13 | self.token: str = None # type: ignore 14 | 15 | def acquire_token(self, username: str, password: str) -> None: 16 | self.token = self._acquire_token(username, password) 17 | 18 | def _acquire_token(self, username: str, password: str) -> str: 19 | data = { 20 | "grant_type": 'password', 21 | "client_id": "my-tonies", 22 | "scope": "openid", 23 | "username": username, 24 | "password": password, 25 | } 26 | response = requests.post(self.OPENID_CONNECT, data=data) 27 | return response.json()["access_token"] 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Carsten Rösnick-Neugebauer 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 | -------------------------------------------------------------------------------- /server/localstorage/client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | from pathlib import Path 4 | from typing import Set 5 | 6 | from tinytag import TinyTag 7 | 8 | logger = logging.getLogger(__name__) 9 | AUDIO_EXTENSIONS = [".mp3", ".m4a", ".wma"] 10 | 11 | 12 | @dataclass 13 | class AudioTag: 14 | album: str 15 | artist: str 16 | title: str 17 | track: int 18 | disc: int 19 | 20 | 21 | def audiobooks(root: Path) -> Set[Path]: 22 | all_dirs = root.glob("**/") 23 | all_audiobooks = set() 24 | for dir in all_dirs: 25 | if dir.parent in all_audiobooks: 26 | all_audiobooks.remove(dir.parent) 27 | all_audiobooks.add(dir) 28 | 29 | return all_audiobooks 30 | 31 | 32 | def audiofiles(album: Path) -> Set[Path]: 33 | return {track for track in album.glob("*.*") if track.suffix in AUDIO_EXTENSIONS} 34 | 35 | 36 | def metadata(file: Path) -> AudioTag: 37 | tags = TinyTag.get(str(file)) 38 | logger.debug("Fetched metadata for file '%s': %s", file, tags) 39 | 40 | return AudioTag(album=tags.album, artist=tags.albumartist, title=tags.title, track=tags.track, disc=tags.disc) 41 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '3' 3 | services: 4 | frontend: 5 | image: node:14.7-stretch 6 | environment: 7 | - VUE_APP_BACKEND_SCHEME=${TONIE_AUDIO_MATCH_BACKEND_SCHEME} 8 | - VUE_APP_BACKEND_HOST=${TONIE_AUDIO_MATCH_BACKEND_HOST} 9 | - VUE_APP_BACKEND_PORT=${TONIE_AUDIO_MATCH_BACKEND_PORT} 10 | command: > 11 | bash -c "cd /frontend && 12 | npm install && 13 | npm run serve" 14 | networks: 15 | - toniebox 16 | ports: 17 | - ${TONIE_AUDIO_MATCH_FRONTEND_PORT}:8080 18 | volumes: 19 | - ./client/:/frontend 20 | - ./albumart/:/frontend/public/assets/covers 21 | 22 | backend: 23 | image: python:3.8-buster 24 | environment: 25 | - TONIE_AUDIO_MATCH_MEDIA_PATH 26 | - TONIE_AUDIO_MATCH_USER 27 | - TONIE_AUDIO_MATCH_PASS 28 | command: > 29 | bash -c "cd /backend && 30 | pip install -r requirements.txt && 31 | python app.py" 32 | networks: 33 | - toniebox 34 | ports: 35 | - ${TONIE_AUDIO_MATCH_BACKEND_PORT}:5000 36 | volumes: 37 | - ./server/:/backend 38 | - ./albumart/:/backend/assets/covers 39 | - ${TONIE_AUDIO_MATCH_MEDIA_PATH}:/backend/assets/audiobooks 40 | 41 | networks: 42 | toniebox: -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tonie-audio-match 2 | 3 | Your 1-click audio book upload to your [creative tonies](https://tonies.com). 4 | 5 | ## The Story 6 | 7 | It's bedtime for one of your kids, they are tired (just like you), their patience is down to a minimum. 8 | And now they want to hear their beloved audio book. 9 | Which of course is not on one of their creative tonies anymore. 10 | Darn. 11 | 12 | So... you are going to pick up your laptop, open the tonies website, log in... all the way down to uploading your child's desired audio book the proper tonie. 13 | That's cumbersome. 14 | 15 | How about a simple UI which shows all your audio books where one click suffices to upload an audio book to a tonie? 16 | Congratulations, search no more, you are right here, I got your back! :) 17 | 18 | ![Example](sample.png) 19 | 20 | So yet another UI to access from your laptop? 21 | Not at all! 22 | Put it onto a RaspberryPi and voilà -- accessible from your mobile whenever you need it! 🙂 23 | 24 | ## Configuration & Start 25 | 26 | Place an `.env` file in this project's root to configure your service (like credentials for [tonies.de](https://tonies.de)). 27 | Please see [.env.sample](.env.sample) for a sample configuration. 28 | 29 | Once configured, start the whole application with `docker-compose up` and, after some initial processing of your media library, access your album covers locally at [http://localhost](http://localhost). -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "toniebox-audio-match-frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint", 9 | "test": "vue-cli-service test:unit" 10 | }, 11 | "dependencies": { 12 | "axios": "^0.19.2", 13 | "bootstrap": "^4.5.2", 14 | "core-js": "^3.6.5", 15 | "vue": "^2.6.11", 16 | "vue-router": "^3.4.3" 17 | }, 18 | "devDependencies": { 19 | "@vue/cli-plugin-babel": "~4.4.0", 20 | "@vue/cli-plugin-eslint": "~4.4.0", 21 | "@vue/cli-plugin-unit-jest": "^4.5.3", 22 | "@vue/cli-service": "^4.5.3", 23 | "@vue/eslint-config-airbnb": "^5.0.2", 24 | "@vue/test-utils": "^1.0.3", 25 | "babel-eslint": "^10.1.0", 26 | "eslint": "^6.7.2", 27 | "eslint-plugin-import": "^2.20.2", 28 | "eslint-plugin-vue": "^6.2.2", 29 | "jest": "^26.4.0", 30 | "jest-each": "^26.4.0", 31 | "vue-jest": "^3.0.6", 32 | "vue-template-compiler": "^2.6.11" 33 | }, 34 | "eslintConfig": { 35 | "root": true, 36 | "env": { 37 | "node": true 38 | }, 39 | "extends": [ 40 | "plugin:vue/essential", 41 | "@vue/airbnb" 42 | ], 43 | "parserOptions": { 44 | "parser": "babel-eslint" 45 | }, 46 | "rules": {} 47 | }, 48 | "browserslist": [ 49 | "> 1%", 50 | "last 2 versions", 51 | "not dead" 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /client/tests/unit/audiobook.spec.js: -------------------------------------------------------------------------------- 1 | // import { mount } from '@vue/test-utils'; 2 | import each from 'jest-each'; 3 | import AudioBooks from '@/components/AudioBooks.vue'; 4 | 5 | describe('value comparison', () => { 6 | each([ 7 | ['Alice', 'Bob', -1], 8 | ['Alice', 'Alice', 0], 9 | ['Bob', 'Alice', 1], 10 | ['Alice', null, -1], 11 | [null, null, 0], 12 | [1, 2, -1], 13 | [1, 1, 0], 14 | [2, 1, 1], 15 | [null, 1, 1], 16 | ]).it("when the input is '%s' and '%s'", (lhs, rhs, expected) => { 17 | const cmp = AudioBooks.cmp(lhs, rhs); 18 | expect(cmp).toEqual(expected); 19 | }); 20 | }); 21 | 22 | describe('audibook comparison', () => { 23 | each([ 24 | [ 25 | { artist: 'Bob der Baumeister', disc: 1, title: 'Bob hilft dem Weihnachtsmann' }, 26 | { artist: 'Bob der Baumeister', disc: 1, title: 'Spass im Schnee' }, 27 | -1, 28 | ], 29 | [ 30 | { artist: 'Bob der Baumeister', disc: 3, title: 'Bob hilft dem Weihnachtsmann' }, 31 | { artist: 'Leo Lausemaus', disc: 1, title: 'Will nicht baden' }, 32 | -1, 33 | ], 34 | [ 35 | { artist: 'Bob der Baumeister', disc: null, title: 'Bob hilft dem Weihnachtsmann' }, 36 | { artist: 'Bob der Baumeister', disc: 1, title: 'Bob hilft dem Weihnachtsmann' }, 37 | 1, 38 | ], 39 | ]).it("when the input is '%s' and '%s'", (lhs, rhs, expected) => { 40 | const cmp = AudioBooks.methods.cmpAudioBooks(lhs, rhs); 41 | expect(cmp).toEqual(expected); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /server/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import sys 4 | from dataclasses import dataclass 5 | from pathlib import Path 6 | 7 | from flask import Flask, jsonify, request 8 | from flask_cors import CORS 9 | 10 | import localstorage.client 11 | 12 | # configuration 13 | from models.audio import AudioBook 14 | from models.tonie import Tonie 15 | from toniecloud.client import TonieCloud 16 | 17 | logging.basicConfig(level=logging.DEBUG, stream=sys.stderr) 18 | logger = logging.getLogger(__name__) 19 | 20 | DEBUG = True 21 | 22 | # instantiate the app 23 | app = Flask(__name__) 24 | app.config.from_object(__name__) 25 | 26 | # enable CORS 27 | CORS(app, resources={r"/*": {"origins": "*"}}) 28 | 29 | tonie_cloud_api = TonieCloud(os.environ.get("TONIE_AUDIO_MATCH_USER"), os.environ.get("TONIE_AUDIO_MATCH_PASS")) 30 | 31 | 32 | def audiobooks(): 33 | audiobooks = localstorage.client.audiobooks(Path("assets/audiobooks")) 34 | logger.debug("Discovered audiobook paths: %s", audiobooks) 35 | for album in audiobooks: 36 | audiobook = AudioBook.from_path(album) 37 | if audiobook: 38 | yield audiobook 39 | 40 | 41 | audio_books_models = list(audiobooks()) 42 | audio_books = [ 43 | { 44 | "id": album.id, 45 | "artist": album.artist, 46 | "title": album.album, 47 | "disc": album.album_no, 48 | "cover_uri": str(album.cover_relative) if album.cover else None, 49 | } 50 | for album in audio_books_models 51 | ] 52 | 53 | creative_tonies = tonie_cloud_api.creativetonies() 54 | 55 | 56 | @app.route("/ping", methods=["GET"]) 57 | def ping_pong(): 58 | return jsonify("pong!") 59 | 60 | 61 | @app.route("/audiobooks", methods=["GET"]) 62 | def all_audiobooks(): 63 | return jsonify({"status": "success", "audiobooks": audio_books,}) 64 | 65 | 66 | @app.route("/creativetonies", methods=["GET"]) 67 | def all_creativetonies(): 68 | return jsonify({"status": "success", "creativetonies": creative_tonies,}) 69 | 70 | 71 | @dataclass 72 | class Upload: 73 | tonie: Tonie 74 | audiobook: AudioBook 75 | 76 | @classmethod 77 | def from_ids(cls, tonie: str, audiobook: str) -> "Upload": 78 | return cls( 79 | next(filter(lambda t: t.id == tonie, creative_tonies), None), 80 | next(filter(lambda a: a.id == audiobook, audio_books_models), None), 81 | ) 82 | 83 | 84 | @app.route("/upload", methods=["POST"]) 85 | def upload_album_to_tonie(): 86 | body = request.json 87 | upload = Upload.from_ids(tonie=body["tonie_id"], audiobook=body["audiobook_id"]) 88 | logger.debug(f"Created upload object: {upload}") 89 | 90 | status = tonie_cloud_api.put_album_on_tonie(upload.audiobook, upload.tonie) 91 | return jsonify({"status": "success" if status else "failure", "upload_id": str(upload)}), 201 92 | 93 | 94 | if __name__ == "__main__": 95 | app.run(host="0.0.0.0") 96 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 15 | 16 |
17 | 37 | 56 |
57 | 58 |
59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /server/models/audio.py: -------------------------------------------------------------------------------- 1 | import imghdr 2 | import logging 3 | from dataclasses import dataclass 4 | from hashlib import sha512 5 | from io import BytesIO 6 | from pathlib import Path 7 | from typing import List, ClassVar, Optional 8 | 9 | from tinytag import TinyTag 10 | 11 | from localstorage.client import audiofiles, metadata 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | @dataclass(frozen=True) 17 | class AudioTrack: 18 | album: str 19 | title: str 20 | track: int 21 | file: Path 22 | 23 | 24 | @dataclass(frozen=True) 25 | class AudioBook: 26 | covers: ClassVar[Path] = Path("assets/covers") 27 | 28 | id: str 29 | album: str 30 | album_no: int 31 | artist: str 32 | cover: Optional[Path] 33 | tracks: List[AudioTrack] 34 | 35 | @property 36 | def cover_relative(self) -> Optional[Path]: 37 | if not self.cover: 38 | return 39 | 40 | return self.cover.relative_to(self.covers) 41 | 42 | @classmethod 43 | def from_path(cls, album: Path) -> Optional["AudioBook"]: 44 | tracks_files = audiofiles(album) 45 | if not tracks_files: 46 | logger.error("Album without tracks or no tracks with expected extension: %s", album) 47 | return None 48 | 49 | tracks: List[AudioTrack] = [] 50 | 51 | for file in tracks_files: 52 | tags = metadata(file) 53 | tracks.append(AudioTrack(album=tags.album, title=tags.title, track=tags.track, file=file)) 54 | 55 | if not len({track.album for track in tracks}) == 1: 56 | print("WARNING De-normalized album title.") 57 | 58 | tags_first = metadata(tracks[0].file) 59 | 60 | album_id = cls.path_hash(album) 61 | cover_path = cls.cover_path_for(album_id) 62 | cover_path = cls.persist_cover(cover_path, TinyTag.get(str(tracks[0].file), image=True).get_image()) 63 | 64 | return cls( 65 | id=album_id, 66 | album=tracks[0].album, 67 | album_no=tags_first.disc, 68 | artist=tags_first.artist, 69 | cover=cover_path, 70 | tracks=tracks, 71 | ) 72 | 73 | @staticmethod 74 | def path_hash(path: Path) -> str: 75 | return sha512(str(path).encode("utf-8")).hexdigest() 76 | 77 | @classmethod 78 | def cover_path_for(cls, id: str) -> Path: 79 | return cls.covers.joinpath(id) 80 | 81 | @staticmethod 82 | def persist_cover(file: Path, image: Optional[bytes]) -> Optional[Path]: 83 | if not image: 84 | return 85 | 86 | image_stream = BytesIO(image) 87 | image_type = imghdr.what(image_stream) 88 | 89 | if not image_type: 90 | logger.error("Could not determine image type for file: %s", file) 91 | return 92 | 93 | file = file.with_suffix(f".{image_type}") 94 | with file.open("wb") as ch: 95 | ch.write(image) 96 | 97 | return file 98 | -------------------------------------------------------------------------------- /client/src/components/AudioBooks.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 126 | -------------------------------------------------------------------------------- /server/toniecloud/client.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import logging 3 | import mimetypes 4 | from pathlib import Path 5 | from typing import List 6 | 7 | from models.audio import AudioBook, AudioTrack 8 | from models.tonie import Tonie, Household 9 | from toniecloud.session import TonieCloudSession 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | MIME_TO_CONTENT_TYPE = { 14 | "audio/mp4a-latm": "audio/x-m4a", 15 | } 16 | 17 | 18 | class Verb(enum.Enum): 19 | GET = "GET" 20 | POST = "POST" 21 | 22 | 23 | class TonieCloud: 24 | def __init__(self, username: str, password: str) -> None: 25 | self.session = TonieCloudSession() 26 | 27 | self._username = username 28 | self._password = password 29 | 30 | self.session.acquire_token(username, password) 31 | 32 | @property 33 | def url(self): 34 | return self.session.URI 35 | 36 | @property 37 | def auth_header(self): 38 | return {"Authorization": f"Bearer {self.session.token}"} 39 | 40 | def households(self) -> List[Household]: 41 | return [Household(household["id"]) for household in self._get("households")] 42 | 43 | def creativetonies(self) -> List[Tonie]: 44 | tonies: List[Tonie] = [] 45 | for household in self.households(): 46 | url = f"households/{household.id}/creativetonies" 47 | data = self._get(url) 48 | for tonie in data: 49 | tonies.append(Tonie(id=tonie["id"], household=household, name=tonie["name"], image=tonie["imageUrl"])) 50 | 51 | return tonies 52 | 53 | def put_album_on_tonie(self, audiobook: AudioBook, tonie: Tonie) -> bool: 54 | data = { 55 | "chapters": [ 56 | {"title": track.title, "file": self._upload_track(track)} 57 | for track in sorted(audiobook.tracks, key=lambda t: t.track) 58 | ] 59 | } 60 | 61 | logger.debug("Sending chapter data from audio book %r to tonie %r: %r", audiobook.album, tonie.name, data) 62 | response = self.session.patch( 63 | f"{self.url}/households/{tonie.household.id}/creativetonies/{tonie.id}", headers=self.auth_header, json=data 64 | ) 65 | 66 | if not response.ok: 67 | logger.error("Something went wrong :'( -> %s", response) 68 | return False 69 | 70 | body = response.json() 71 | 72 | logger.info("Yay! Uploaded album %r to tonie %r! Response: %s", audiobook.album, tonie.name, response) 73 | 74 | logger.debug("Transcoding errors: %r", body["transcodingErrors"]) 75 | logger.debug("Chapters on tonie %r: %r", tonie.name, body["chapters"]) 76 | logger.debug("Seconds remaining on tonie %r: %r", tonie.name, body["secondsRemaining"]) 77 | 78 | return True 79 | 80 | def _upload_track(self, track: AudioTrack) -> str: 81 | return self._upload_file(track.file) 82 | 83 | def _upload_file(self, file: Path) -> str: 84 | data = self.session.post(f"{self.url}/file", headers=self.auth_header).json() 85 | logger.debug("Response of POST /file: %r", data) 86 | 87 | payload = data["request"]["fields"] 88 | 89 | audio_mime_type = mimetypes.guess_type(file) 90 | logger.debug("Guessed MIME type %r for file %r", audio_mime_type, str(file)) 91 | if audio_mime_type in MIME_TO_CONTENT_TYPE: 92 | files = {"file": (data["request"]["fields"]["key"], file.open("rb"), MIME_TO_CONTENT_TYPE[audio_mime_type])} 93 | else: 94 | files = {"file": (data["request"]["fields"]["key"], file.open("rb"))} 95 | 96 | response = self.session.post(data["request"]["url"], data=payload, files=files) 97 | if not response.ok: 98 | raise ValueError("Well, something went wrong. :'(") 99 | 100 | logger.debug("File location: %r, id: %r", response.headers["Location"], data["fileId"]) 101 | return data["fileId"] 102 | 103 | def _get(self, path: str) -> dict: 104 | headers = {"Authorization": f"Bearer {self.session.token}"} 105 | 106 | resp = self.session.request("GET", f"{self.url}/{path}", headers=headers, data={}) 107 | 108 | if not resp.ok: 109 | # TODO Properly handle errors, especially outdated tokens 110 | logger.error("HTTP request failed: %s", resp) 111 | return {} 112 | 113 | return resp.json() 114 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/node,python,pycharm 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,python,pycharm 4 | 5 | .env 6 | .idea 7 | 8 | ### Node ### 9 | node_modules 10 | client/node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | lerna-debug.log* 19 | 20 | # Diagnostic reports (https://nodejs.org/api/report.html) 21 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 22 | 23 | # Runtime data 24 | pids 25 | *.pid 26 | *.seed 27 | *.pid.lock 28 | 29 | # Directory for instrumented libs generated by jscoverage/JSCover 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | coverage 34 | *.lcov 35 | 36 | # nyc test coverage 37 | .nyc_output 38 | 39 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 40 | .grunt 41 | 42 | # Bower dependency directory (https://bower.io/) 43 | bower_components 44 | 45 | # node-waf configuration 46 | .lock-wscript 47 | 48 | # Compiled binary addons (https://nodejs.org/api/addons.html) 49 | build/Release 50 | 51 | # Dependency directories 52 | node_modules/ 53 | jspm_packages/ 54 | 55 | # TypeScript v1 declaration files 56 | typings/ 57 | 58 | # TypeScript cache 59 | *.tsbuildinfo 60 | 61 | # Optional npm cache directory 62 | .npm 63 | 64 | # Optional eslint cache 65 | .eslintcache 66 | 67 | # Microbundle cache 68 | .rpt2_cache/ 69 | .rts2_cache_cjs/ 70 | .rts2_cache_es/ 71 | .rts2_cache_umd/ 72 | 73 | # Optional REPL history 74 | .node_repl_history 75 | 76 | # Output of 'npm pack' 77 | *.tgz 78 | 79 | # Yarn Integrity file 80 | .yarn-integrity 81 | 82 | # dotenv environment variables file 83 | .env 84 | .env.test 85 | 86 | # parcel-bundler cache (https://parceljs.org/) 87 | .cache 88 | 89 | # Next.js build output 90 | .next 91 | 92 | # Nuxt.js build / generate output 93 | .nuxt 94 | dist 95 | 96 | # Gatsby files 97 | .cache/ 98 | # Comment in the public line in if your project uses Gatsby and not Next.js 99 | # https://nextjs.org/blog/next-9-1#public-directory-support 100 | # public 101 | 102 | # vuepress build output 103 | .vuepress/dist 104 | 105 | # Serverless directories 106 | .serverless/ 107 | 108 | # FuseBox cache 109 | .fusebox/ 110 | 111 | # DynamoDB Local files 112 | .dynamodb/ 113 | 114 | # TernJS port file 115 | .tern-port 116 | 117 | # Stores VSCode versions used for testing VSCode extensions 118 | .vscode-test 119 | 120 | ### PyCharm ### 121 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 122 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 123 | 124 | # User-specific stuff 125 | .idea/**/workspace.xml 126 | .idea/**/tasks.xml 127 | .idea/**/usage.statistics.xml 128 | .idea/**/dictionaries 129 | .idea/**/shelf 130 | 131 | # Generated files 132 | .idea/**/contentModel.xml 133 | 134 | # Sensitive or high-churn files 135 | .idea/**/dataSources/ 136 | .idea/**/dataSources.ids 137 | .idea/**/dataSources.local.xml 138 | .idea/**/sqlDataSources.xml 139 | .idea/**/dynamic.xml 140 | .idea/**/uiDesigner.xml 141 | .idea/**/dbnavigator.xml 142 | 143 | # Gradle 144 | .idea/**/gradle.xml 145 | .idea/**/libraries 146 | 147 | # Gradle and Maven with auto-import 148 | # When using Gradle or Maven with auto-import, you should exclude module files, 149 | # since they will be recreated, and may cause churn. Uncomment if using 150 | # auto-import. 151 | # .idea/artifacts 152 | # .idea/compiler.xml 153 | # .idea/jarRepositories.xml 154 | # .idea/modules.xml 155 | # .idea/*.iml 156 | # .idea/modules 157 | # *.iml 158 | # *.ipr 159 | 160 | # CMake 161 | cmake-build-*/ 162 | 163 | # Mongo Explorer plugin 164 | .idea/**/mongoSettings.xml 165 | 166 | # File-based project format 167 | *.iws 168 | 169 | # IntelliJ 170 | out/ 171 | 172 | # mpeltonen/sbt-idea plugin 173 | .idea_modules/ 174 | 175 | # JIRA plugin 176 | atlassian-ide-plugin.xml 177 | 178 | # Cursive Clojure plugin 179 | .idea/replstate.xml 180 | 181 | # Crashlytics plugin (for Android Studio and IntelliJ) 182 | com_crashlytics_export_strings.xml 183 | crashlytics.properties 184 | crashlytics-build.properties 185 | fabric.properties 186 | 187 | # Editor-based Rest Client 188 | .idea/httpRequests 189 | 190 | # Android studio 3.1+ serialized cache file 191 | .idea/caches/build_file_checksums.ser 192 | 193 | ### PyCharm Patch ### 194 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 195 | 196 | # *.iml 197 | # modules.xml 198 | # .idea/misc.xml 199 | # *.ipr 200 | 201 | # Sonarlint plugin 202 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 203 | .idea/**/sonarlint/ 204 | 205 | # SonarQube Plugin 206 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 207 | .idea/**/sonarIssues.xml 208 | 209 | # Markdown Navigator plugin 210 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 211 | .idea/**/markdown-navigator.xml 212 | .idea/**/markdown-navigator-enh.xml 213 | .idea/**/markdown-navigator/ 214 | 215 | # Cache file creation bug 216 | # See https://youtrack.jetbrains.com/issue/JBR-2257 217 | .idea/$CACHE_FILE$ 218 | 219 | # CodeStream plugin 220 | # https://plugins.jetbrains.com/plugin/12206-codestream 221 | .idea/codestream.xml 222 | 223 | ### Python ### 224 | # Byte-compiled / optimized / DLL files 225 | __pycache__/ 226 | *.py[cod] 227 | *$py.class 228 | 229 | # C extensions 230 | *.so 231 | 232 | # Distribution / packaging 233 | .Python 234 | build/ 235 | develop-eggs/ 236 | dist/ 237 | downloads/ 238 | eggs/ 239 | .eggs/ 240 | lib/ 241 | lib64/ 242 | parts/ 243 | sdist/ 244 | var/ 245 | wheels/ 246 | pip-wheel-metadata/ 247 | share/python-wheels/ 248 | *.egg-info/ 249 | .installed.cfg 250 | *.egg 251 | MANIFEST 252 | 253 | # PyInstaller 254 | # Usually these files are written by a python script from a template 255 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 256 | *.manifest 257 | *.spec 258 | 259 | # Installer logs 260 | pip-log.txt 261 | pip-delete-this-directory.txt 262 | 263 | # Unit test / coverage reports 264 | htmlcov/ 265 | .tox/ 266 | .nox/ 267 | .coverage 268 | .coverage.* 269 | nosetests.xml 270 | coverage.xml 271 | *.cover 272 | *.py,cover 273 | .hypothesis/ 274 | .pytest_cache/ 275 | pytestdebug.log 276 | 277 | # Translations 278 | *.mo 279 | *.pot 280 | 281 | # Django stuff: 282 | local_settings.py 283 | db.sqlite3 284 | db.sqlite3-journal 285 | 286 | # Flask stuff: 287 | instance/ 288 | .webassets-cache 289 | 290 | # Scrapy stuff: 291 | .scrapy 292 | 293 | # Sphinx documentation 294 | docs/_build/ 295 | doc/_build/ 296 | 297 | # PyBuilder 298 | target/ 299 | 300 | # Jupyter Notebook 301 | .ipynb_checkpoints 302 | 303 | # IPython 304 | profile_default/ 305 | ipython_config.py 306 | 307 | # pyenv 308 | .python-version 309 | 310 | # pipenv 311 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 312 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 313 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 314 | # install all needed dependencies. 315 | #Pipfile.lock 316 | 317 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 318 | __pypackages__/ 319 | 320 | # Celery stuff 321 | celerybeat-schedule 322 | celerybeat.pid 323 | 324 | # SageMath parsed files 325 | *.sage.py 326 | 327 | # Environments 328 | .venv 329 | env/ 330 | venv/ 331 | ENV/ 332 | env.bak/ 333 | venv.bak/ 334 | 335 | # Spyder project settings 336 | .spyderproject 337 | .spyproject 338 | 339 | # Rope project settings 340 | .ropeproject 341 | 342 | # mkdocs documentation 343 | /site 344 | 345 | # mypy 346 | .mypy_cache/ 347 | .dmypy.json 348 | dmypy.json 349 | 350 | # Pyre type checker 351 | .pyre/ 352 | 353 | # pytype static type analyzer 354 | .pytype/ 355 | 356 | # End of https://www.toptal.com/developers/gitignore/api/node,python,pycharm --------------------------------------------------------------------------------