diff --git a/roles/airconnect/defaults/main.yml b/roles/airconnect/defaults/main.yml index 653005a..21605b7 100644 --- a/roles/airconnect/defaults/main.yml +++ b/roles/airconnect/defaults/main.yml @@ -10,6 +10,7 @@ airconnect_group: # for flac: 'flc' airconnect_codec: mp3:320 +airconnect_latency: 1000:2000 airconnect_max_volume: "100" airconnect_upnp: [] diff --git a/roles/airconnect/templates/airupnp.xml.j2 b/roles/airconnect/templates/airupnp.xml.j2 index e45ee46..8ef0f7e 100644 --- a/roles/airconnect/templates/airupnp.xml.j2 +++ b/roles/airconnect/templates/airupnp.xml.j2 @@ -15,7 +15,7 @@ 1 1 - 0:500 + {{ airconnect_latency }} 0 info diff --git a/roles/audiobookshelf/files/fix-podcast-date.py b/roles/audiobookshelf/files/fix-podcast-date.py index b5ac854..e3cbd4a 100755 --- a/roles/audiobookshelf/files/fix-podcast-date.py +++ b/roles/audiobookshelf/files/fix-podcast-date.py @@ -5,13 +5,15 @@ from datetime import datetime import subprocess import json import sys -import shutil import os import time +import eyed3 from loguru import logger import dateutil.parser from dateutil.parser._parser import ParserError +from mutagen.id3 import ID3 + def delete_file(file_path): try: @@ -24,8 +26,7 @@ def delete_file(file_path): def replace_file(src, dest): try: logger.debug(f"src: {src}, dest: {dest}") - delete_file(dest) - shutil.move(src, dest) + os.replace(src, dest) logger.debug(f"replaced '{dest}'") except (PermissionError, OSError, FileNotFoundError) as e: logger.error(e) @@ -61,9 +62,8 @@ def ffmpeg_write_date_tag(podcast_file, out_file, iso_date_str): "-nostdin", "-y", # overwrite output file "-i", podcast_file, - #"-c", "copy", + "-c", "copy", "-metadata", f"date={iso_date_str}", - #"-metadata", f"releasedate={iso_date_str}", out_file ] @@ -80,24 +80,16 @@ def ffmpeg_write_date_tag(podcast_file, out_file, iso_date_str): def eyeD3_write_date_tag(podcast_file, iso_date_str): - - # import eyeD3 ? - - podcast_dir = os.path.basename(podcast_file) - cover_path = os.path.join(podcast_dir, "cover.jpg") - cmd = [ "eyeD3", "--release-date", iso_date_str, - "--orig-release-date", iso_date_str, - "--recording-date", iso_date_str, - # this overwrites 'release date' i think: - #"--release-year", iso_date_str.split("-")[0], - #"--preserve-file-times" + "--to-v2.4", + "--user-text-frame", f"releasedate:{iso_date_str}", + # "--preserve-file-times", + # "--recording-date", iso_date_str, + # "--orig-release-date", iso_date_str, + podcast_file ] - # if os.path.exists(cover_path): - # cmd.extend(["--add-image", f"{cover_path}:FRONT_COVER"]) - cmd.append(podcast_file) logger.debug(" ".join(cmd)) @@ -122,7 +114,6 @@ def get_podcast_name_from_dir(podcast_file): def eyeD3_write_album_tag(podcast_file, podcast_name): # "album" is the name of the podcast - cmd = ["eyeD3", "--album", podcast_name, podcast_file] try: subprocess.run(cmd, capture_output=True, check=True, stdin=None) @@ -132,27 +123,101 @@ def eyeD3_write_album_tag(podcast_file, podcast_name): def parse_iso_date(date_str): + if date_str is None: + return None + try: dt = dateutil.parser.parse(date_str) return dt.date().isoformat() except (ParserError, TypeError) as e: logger.warning(f"invalid date string: '{date_str}'") + return None -def parse_TDAT_tag(tag_tdat): +def parse_TDAT_tag(tag_tdat, tag_tyer): + if tag_tdat is None: + return None + if tag_tyer is None: + return None + if not isinstance(tag_tdat, str) or len(tag_tdat) != 4: + return None + if not isinstance(tag_tyer, str) or len(tag_tyer) != 4: + return None + + + # TDAT is id3 v2.3: DDMM + # TYER is id3 v2.3: YYYY try: - iso_date_str = tag_tdat.split(' ')[0] - return parse_iso_date(iso_date_str) + #iso_date_str = tag_tdat.split(' ')[0] + #return parse_iso_date(iso_date_str) + DD = tag_tdat[0:2] + MM = tag_tdat[2:4] + YYYY = tag_tyer[0:4] + isofmt = f"{YYYY}-{MM}-{DD}" + if is_iso_string(isofmt): + return isofmt + else: + logger.warning(f"invalid TDAT: {tag_tdat} and TYER: {tag_tyer}") + return None except (AttributeError, IndexError) as e: logger.debug(f"invalid 'TDAT' tag: '{tag_tdat}'") return None +def is_iso_string(iso_string): + if iso_string is None: + return False + try: + datetime.fromisoformat(iso_string) + return True + except ValueError: + return False + def get_iso_date_in_file(file_path): tags = ffprobe_get_tags(file_path) + l = [] + + # print(tag_tdat) + # print(tag_tyer) + for item in ["releasedate", "date"]: + parsed = parse_iso_date(tags.get(item)) + if is_iso_string(parsed): + l.append(parsed) + + + tag_tdat = tags.get("TDAT") + tag_tyer = tags.get("TYER") + if len(l) == 0 and (tag_tdat is not None and tag_tyer is not None): + logger.info(f"TDAT: {tag_tdat}") + logger.info(f"TYER: {tag_tyer}") + tdat = parse_TDAT_tag(tag_tdat, tag_tyer) + if is_iso_string(tdat): + l.append(tdat) + + + dates = set(l) + if len(dates) == 0: + logger.error(f"no valid date found for '{file_path}'") + raise SystemExit(3) + + + elif len(dates) == 1: + d = list(dates)[0] + logger.info(f"date found: {d}") + return d + + else: + logger.info(f"multiple dates found: {dates}, picking earliest") + earliest = min([datetime.fromisoformat(a) for a in dates]) + return earliest.isoformat() + + +def get_iso_date_in_file2(file_path): + tags = ffprobe_get_tags(file_path) tag_TDAT = tags.get("TDAT") tag_date = tags.get("date") + #tag_releasedate = tags.get("releasedate") parsed_TDAT = parse_TDAT_tag(tag_TDAT) parsed_date = parse_iso_date(tag_date) @@ -173,22 +238,24 @@ def get_iso_date_in_file(file_path): else: return parsed_TDAT +def st_time_to_iso(st_time): + return datetime.fromtimestamp(st_time).isoformat() + +def show_info(file_path): + statinfo = os.stat(file_path) + + atime = st_time_to_iso(statinfo.st_atime) + mtime = st_time_to_iso(statinfo.st_mtime) + ctime = st_time_to_iso(statinfo.st_ctime) + logger.info(f"atime: {atime}") + logger.info(f"mtime: {mtime}") + logger.info(f"ctime: {ctime}") -def file_dates_are_ok(file_path): - tags = ffprobe_get_tags(file_path) - tag_date = tags.get("date") - try: - dt = datetime.fromisoformat(tag_date) - ts = time.mktime(dt.timetuple()) - os.stat(file_path).st_mtime == ts - except ValueError: - return False def set_utime(file_path, iso_date_str): + # settings access and modified times dt = dateutil.parser.parse(iso_date_str) ts = time.mktime(dt.timetuple()) - # shutil.move(file_path, f"{file_path}.new") - # shutil.move(f"{file_path}.new", file_path) os.utime(file_path, (ts, ts)) try: os.utime(os.path.dirname(file_path), (ts, ts)) @@ -196,16 +263,24 @@ def set_utime(file_path, iso_date_str): pass return dt +def eyed3_dates(podcast_file, date): + a = eyed3.load(podcast_file) + + +def mutagen_dates(podcast_file, date): + id3 = ID3(podcast_file) + print(type(id3)) + def parse_args(): parser = argparse.ArgumentParser() - parser.add_argument("podcast_file") - parser.add_argument("--out-file", default="/tmp/out-{os.getpid()}.mp3") + parser.add_argument("action", choices=["show", "utime-only", "fix-dates", "fix-album-tag"]) + parser.add_argument("--podcast-file") parser.add_argument("--debug", action="store_true") parser.add_argument("--quiet", action="store_true") - parser.add_argument("--ffmpeg", action="store_true") - parser.add_argument("--mtime", action="store_true", help="only set mtime, no mp3 metadata") - parser.add_argument("--fix-album-tag", action="store_true", help="write album tag (podcast name)") + parser.add_argument("--ffmpeg-out-file", default=f"/tmp/out-{os.getpid()}.mp3") + parser.add_argument("--metadata-util", default="eyed3", choices=["mutagen", "eyeD3", "ffmpeg"], type=str.lower) + parser.add_argument("--utime-only", action="store_true", help="only set utime, no mp3 metadata") parser.add_argument("--podcast-name") args = parser.parse_args() @@ -216,7 +291,6 @@ def parse_args(): logger.add(sys.stderr, level="ERROR") else: logger.add(sys.stderr, level="INFO") - return args @@ -224,31 +298,33 @@ def main(): args = parse_args() logger.debug(f"checking: '{os.path.basename(args.podcast_file)}'") - if args.fix_album_tag: + date = get_iso_date_in_file(args.podcast_file) + + if args.action == "show": + show_info(args.podcast_file) + + if args.action == "utime-only": + dt = set_utime(args.podcast_file, date) + logger.info(f"set mtime for '{os.path.basename(args.podcast_file)}' to '{dt.isoformat()}' according to mp3 metadata") + + if args.action == "fix-dates": + if args.metadata_util == "ffmpeg": + ffmpeg_write_date_tag(args.podcast_file, args.out_file, date) + if args.metadata_util == "eyed3": + eyeD3_write_date_tag(args.podcast_file, date) + eyed3_dates(args.podcast_file, date) + if args.metadata_util == "mutagen": + mutagen_dates(args.podcast_file, date) + + set_utime(args.podcast_file, date) + logger.success(f"updated dates (metadata and file attributes) for '{args.podcast_file}' as {date}") + + if args.action == "fix-album-tag": podcast_name = get_podcast_name_from_dir(args.podcast_file) if podcast_name is not None: eyeD3_write_album_tag(args.podcast_file, podcast_name) logger.info(f"set album tag to '{podcast_name}' for '{args.podcast_file}'") - date = get_iso_date_in_file(args.podcast_file) - - if args.mtime: - dt = set_utime(args.podcast_filen, date) - logger.info(f"set mtime for '{os.path.basename(args.podcast_file)}' to '{dt.isoformat()}' according to mp3 metadata") - - elif file_dates_are_ok(args.podcast_file): - logger.info(f"metadata date and filesystem utimes ar ok for {args.podcast_file}', did not modify file") - else: - if args.ffmpeg: - ffmpeg_write_date_tag(args.podcast_file, args.out_file, date) - else: - eyeD3_write_date_tag(args.podcast_file, date) - - set_utime(args.podcast_file, date) - logger.success(f"updated date in '{args.podcast_file}' as {date}") - - - if __name__ == "__main__": main() diff --git a/roles/audiobookshelf/files/fix-podcast-date2.py b/roles/audiobookshelf/files/fix-podcast-date2.py new file mode 100644 index 0000000..c04cbeb --- /dev/null +++ b/roles/audiobookshelf/files/fix-podcast-date2.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 + +import argparse +from datetime import datetime +import subprocess +import json +import sys +import shutil +import os +import time + +import eyed3 +from loguru import logger +import dateutil.parser +from dateutil.parser._parser import ParserError +from mutagen.id3 import ID3 + +def delete_file(file_path): + try: + os.remove(file_path) + logger.debug(f"deleted: '{file_path}'") + except FileNotFoundError: + pass + + +def replace_file(src, dest): + try: + logger.debug(f"src: {src}, dest: {dest}") + #delete_file(dest) + #shutil.move(src, dest) + os.replace(src, dest) + + logger.debug(f"replaced '{dest}'") + except (PermissionError, OSError, FileNotFoundError) as e: + logger.error(e) + raise SystemExit(2) + + +def ffprobe_get_tags(file_path): + cmd = [ + "ffprobe", + "-v", "quiet", + file_path, + "-print_format", "json", + "-show_entries", + "stream_tags:format_tags" + ] + try: + p = subprocess.run(cmd, capture_output=True, check=True) + j = json.loads(p.stdout) + return j['format']['tags'] + except subprocess.CalledProcessError as e: + logger.error(f"{cmd[0]} exited with returncode {e.returncode} \n{e.stderr.decode()}") + raise SystemExit(e.returncode) + except KeyError as e: + logger.error(f"key {e} for file '{file_path}' not found in ffprobe stdout: {p.stdout.decode()}") + raise SystemExit(2) + + +def ffmpeg_write_date_tag(podcast_file, out_file, iso_date_str): + delete_file(out_file) + + cmd = [ + "ffmpeg", + "-nostdin", + "-y", # overwrite output file + "-i", podcast_file, + "-c", "copy", + "-metadata", f"date={iso_date_str}", + #"-metadata", f"releasedate={iso_date_str}", + out_file + ] + + try: + p = subprocess.run(cmd, capture_output=True, check=True, stdin=None) + p.check_returncode() + logger.debug(f"output: '{out_file}'") + replace_file(out_file, podcast_file) + except subprocess.CalledProcessError as e: + logger.error(f"{cmd[0]} exited with returncode {e.returncode} \n{e.stderr.decode()}") + raise SystemExit(e.returncode) + finally: + delete_file(out_file) + + +def eyeD3_write_date_tag(podcast_file, iso_date_str): + + # import eyeD3 ? + + podcast_dir = os.path.basename(podcast_file) + cover_path = os.path.join(podcast_dir, "cover.jpg") + + cmd = [ + "eyeD3", + "--release-date", iso_date_str, + "--to-v2.4", + "--user-text-frame", f"releasedate:{iso_date_str}", + #"--preserve-file-times", + # "--recording-date", iso_date_str, + # "--force-update", + podcast_file + #"--orig-release-date", iso_date_str, + + # this overwrites 'release date' i think: + #"--release-year", iso_date_str.split("-")[0], + + ] + # if os.path.exists(cover_path): + # cmd.extend(["--add-image", f"{cover_path}:FRONT_COVER"]) + #cmd.append(podcast_file) + + logger.debug(" ".join(cmd)) + + try: + subprocess.run(cmd, capture_output=True, check=True, stdin=None) + logger.debug(f"updated: '{podcast_file}'") + #logger.info(f"would write date: {iso_date_str}") + except subprocess.CalledProcessError as e: + logger.error(f"{cmd[0]} exited with returncode {e.returncode} \n{e.stderr.decode()}") + raise SystemExit(e.returncode) + + +def get_podcast_name_from_dir(podcast_file): + podcast_dir = os.path.dirname(podcast_file) + if podcast_dir.startswith("/"): + # for now lets just do absolute dirs for names + podcast_name = os.path.basename(podcast_dir) + logger.debug(f"podcast name: {podcast_name}") + return podcast_name + else: + return None + + +def eyeD3_write_album_tag(podcast_file, podcast_name): + # "album" is the name of the podcast + + cmd = ["eyeD3", "--album", podcast_name, podcast_file] + try: + subprocess.run(cmd, capture_output=True, check=True, stdin=None) + except subprocess.CalledProcessError as e: + logger.error(f"{cmd[0]} exited with returncode {e.returncode} \n{e.stderr.decode()}") + raise SystemExit(e.returncode) + + +def parse_iso_date(date_str): + try: + dt = dateutil.parser.parse(date_str) + return dt.date().isoformat() + except (ParserError, TypeError) as e: + logger.warning(f"invalid date string: '{date_str}'") + return None + +def parse_TDAT_tag(tag_tdat): + try: + iso_date_str = tag_tdat.split(' ')[0] + return parse_iso_date(iso_date_str) + except (AttributeError, IndexError) as e: + logger.debug(f"invalid 'TDAT' tag: '{tag_tdat}'") + return None + +def is_iso_string(iso_string): + if iso_string is None: + return False + try: + datetime.fromisoformat(iso_string) + return True + except ValueError: + return False + +# def get_iso_date_in_file(file_path): +# tags = ffprobe_get_tags(file_path): + +# search_tag_names = ["date", "releasedate", "TDAT"] +# tags_data = [tags.get(a) for a in search_tag_names] +# for item in tags_data: + + +# tags_parsed = [parse_iso_date(a) for a in tags_data] + + +# for item in ["date", "TDAT", "releasedate"]: +# tag_data = tags.get(item) +# parsed_data = parse_iso_date(tag_data) +# if is_iso_string(parsed_data) +# return parsed_data + + + +def get_iso_date_in_file(file_path): + tags = ffprobe_get_tags(file_path) + + tag_TDAT = tags.get("TDAT") + tag_date = tags.get("date") + + parsed_TDAT = parse_TDAT_tag(tag_TDAT) + parsed_date = parse_iso_date(tag_date) + + if parsed_TDAT is None and parsed_date is None: + logger.error(f"no valid date found in '{file_path}' - TDAT: '{tag_TDAT}', date: '{tag_date}'") + raise SystemExit(3) + + else: + logger.debug(f"TDAT: '{parsed_TDAT}' ('{tag_TDAT}'), date: '{parsed_date}' ('{tag_date}')") + logger.debug(f"date: {parsed_date}") + + if parsed_TDAT != parsed_date: + logger.debug(f"dates in 'TDAT' ({parsed_TDAT}) and 'date' ({parsed_date}) differ!") + + if parsed_date is not None: + return parsed_date + else: + return parsed_TDAT + + +# def file_dates_are_ok(file_path): +# tags = ffprobe_get_tags(file_path) +# tag_date = tags.get("date") +# try: +# dt = datetime.fromisoformat(tag_date) +# ts = time.mktime(dt.timetuple()) + +# os.stat(file_path).st_mtime == ts +# except ValueError: +# return False + +def st_time_to_iso(st_time): + return datetime.fromtimestamp(st_time).isoformat() + +def show_info(file_path): + statinfo = os.stat(file_path) + + atime = st_time_to_iso(statinfo.st_atime) + mtime = st_time_to_iso(statinfo.st_mtime) + ctime = st_time_to_iso(statinfo.st_ctime) + logger.info(f"atime: {atime}") + logger.info(f"mtime: {mtime}") + logger.info(f"ctime: {ctime}") + + + +def set_utime(file_path, iso_date_str): + # settings access and modified times + dt = dateutil.parser.parse(iso_date_str) + ts = time.mktime(dt.timetuple()) + # shutil.move(file_path, f"{file_path}.new") + # shutil.move(f"{file_path}.new", file_path) + os.utime(file_path, (ts, ts)) + try: + os.utime(os.path.dirname(file_path), (ts, ts)) + except FileNotFoundError: + pass + return dt + +def eyed3_dates(podcast_file, date): + a = eyed3.load(podcast_file) + + +def mutagen_dates(podcast_file, date): + id3 = ID3(podcast_file) + print(type(id3)) + + + + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument("action", choices=["show", "utime-only", "fix-dates", "fix-album-tag"]) + parser.add_argument("--podcast-file") + parser.add_argument("--debug", action="store_true") + parser.add_argument("--quiet", action="store_true") + parser.add_argument("--ffmpeg-out-file", default=f"/tmp/out-{os.getpid()}.mp3") + parser.add_argument("--metadata-util", default="eyed3", choices=["mutagen", "eyeD3", "ffmpeg"], type=str.lower) + parser.add_argument("--utime-only", action="store_true", help="only set utime, no mp3 metadata") + parser.add_argument("--podcast-name") + args = parser.parse_args() + + if not args.debug: + logger.remove() + + if args.quiet: + logger.add(sys.stderr, level="ERROR") + else: + logger.add(sys.stderr, level="INFO") + + return args + + +def main(): + args = parse_args() + logger.debug(f"checking: '{os.path.basename(args.podcast_file)}'") + + date = get_iso_date_in_file(args.podcast_file) + + if args.action == "show": + show_info(args.podcast_file) + + if args.action == "utime-only": + dt = set_utime(args.podcast_file, date) + logger.info(f"set mtime for '{os.path.basename(args.podcast_file)}' to '{dt.isoformat()}' according to mp3 metadata") + + + if args.action == "fix-dates": + # if file_dates_are_ok(args.podcast_file): + # logger.info(f"metadata date and filesystem utimes are ok for {args.podcast_file}', did not modify file") + # else: + if args.metadata_util == "ffmpeg": + ffmpeg_write_date_tag(args.podcast_file, args.out_file, date) + if args.metadata_util == "eyed3": + eyeD3_write_date_tag(args.podcast_file, date) + eyed3_dates(args.podcast_file, date) + if args.metadata_util == "mutagen": + mutagen_dates(args.podcast_file, date) + + + set_utime(args.podcast_file, date) + logger.success(f"updated dates (metadata and file attributes) for '{args.podcast_file}' as {date}") + + if args.action == "fix-album-tag": + podcast_name = get_podcast_name_from_dir(args.podcast_file) + if podcast_name is not None: + eyeD3_write_album_tag(args.podcast_file, podcast_name) + logger.info(f"set album tag to '{podcast_name}' for '{args.podcast_file}'") + + + +if __name__ == "__main__": + main() diff --git a/roles/audiobookshelf/files/pods.py b/roles/audiobookshelf/files/pods.py new file mode 100755 index 0000000..a522c60 --- /dev/null +++ b/roles/audiobookshelf/files/pods.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 + +import urllib.parse +import requests +import json +import sys +import os + +import dateutil.parser +from loguru import logger + +abs = "https://url" +authheaders = { + "Authorization": "Bearer $token"} + +try: + OUTPUT_DIR = sys.argv[1] +except IndexError: + OUTPUT_DIR = "/tmp/abs_metadata" + +logger.info(f"saving metadata json files to '{OUTPUT_DIR}'") + +s = requests.Session() +s.headers.update(authheaders) + +# idea: what is /s/? socket? tracks progress? +# check with browser dev tools +# could add nginx conf/proxy to inject auth header +# or something if i get progress tracking for free +# or custom proxy +# +# rss feed needs to be open for /feed/ urls to +# work. rss feed can be opened with api +# +# find if episode has been played +# https://api.audiobookshelf.org/#items-listened-to +# +# check with browser dev tools what happens when +# the button is clicked to mark an episode as played +# ANSWER: +# curl \ +# 'https://{url}/api/me/progress/li_eadrvpei17yz1unifv/ep_8zz0zme6qtzq9rio8y' \ +# -X PATCH \ +# -H 'Authorization: Bearer $token' \ +# -H 'Accept: application/json, text/plain, */*' \ +# -H 'Accept-Language: en-US,en;q=0.5' \ +# -H 'Accept-Encoding: gzip, deflate, br' \ +# -H 'Content-Type: application/json' \ +# -H 'Referer: https://{url}/item/li_eadrvpei17yz1unifv' \ +# -H 'Origin: https://{url}' \ +# -H 'DNT: 1' \ +# -H 'Sec-Fetch-Dest: empty' \ +# -H 'Sec-Fetch-Mode: cors' \ +# -H 'Sec-Fetch-Site: same-origin' \ +# -H 'Sec-GPC: 1' \ +# -H 'Connection: keep-alive' \ +# -H 'Pragma: no-cache' -H 'Cache-Control: no-cache' \ +# -H 'TE: trailers' \ +# --data-raw '{"isFinished":true}' +# +# $ curl -sX GET 'https://{url}/api/me/progress/li_eadrvpei17yz1unifv/ep_8zz0zme6qtzq9rio8y' -H 'Authorization: Bearer ${token}' | jq . +# { +# "duration": 0, +# "progress": 1, +# "currentTime": 0, +# "isFinished": true, +# "hideFromContinueListening": false, +# "finishedAt": 1679184864387 +# } +# ^ removed less interested fields in the response +# +# listening sessions can be streamed? +# +# use hass to remove from playlist when playback +# is finished? + +def playlist(): + + playlists = s.get(f"{abs}/api/playlists").json()["playlists"] + + for playlist in playlists: + for episode in playlist["items"]: + li_id = episode["libraryItemId"] + ep_id = episode["episodeId"] + file_name = episode["episode"]["audioTrack"]["metadata"]["filename"] + encoded_file_name = urllib.parse.quote_plus(file_name) + file_url = f"{abs}/feed/{li_id}/item/{ep_id}/{encoded_file_name}" + + print(file_url) + +def item_embed_metadata(li_id): + + embed_url = f"{abs}/api/tools/item/{li_id}/embed-metadata" + logger.info(embed_url) + try: + r = s.post(embed_url, data={"forceEmbedChapters": False}) + r.raise_for_status() + return r.json() + except requests.exceptions.HTTPError as e: + logger.error(e) + logger.error(r.text) + return None + + +def metadata_main(): + r = s.get(f"{abs}/api/libraries") + j = r.json() + podcasts = [a for a in j['libraries'] if a['mediaType'] == "podcast"] + logger.info(f"podcast libraries: {len(podcasts)}") + for item in podcasts: + metadata_library(podcasts) + + +def metadata_library(podcasts): + for item in podcasts: + r = s.get(f"{abs}/api/libraries/{item['id']}/items") + j = r.json() + lib_items = j['results'] + metadata_library_item(lib_items) + + +def metadata_library_item(lib_items): + logger.info(f"podcasts: {len(lib_items)}") + + for item in lib_items: + name = item['relPath'] + li_id = item['id'] + + #episodes = item['media']['episodes'] + #for episode in episodes: + # episode_id = episode['id'] + # #lib_item = s.get(f"{abs}/api/items/{episode_id}").json() + # #logger.info(f"{name}: {lib_item}") + # #metadata = item_embed_metadata(episode_id) + + lib_item = s.get(f"{abs}/api/items/{li_id}").json() + + item_name = lib_item['media']['metadata']['title'] + item_id = lib_item['id'] + + media = lib_item['media'] + metadata = lib_item['media']['metadata'] + podcast_path = lib_item['path'] + podcast_rel_path = lib_item['relPath'] + + save_dir = f"{OUTPUT_DIR}/{podcast_rel_path}" + logger.info(f"{name} ({item_id}): {save_dir} ") + os.makedirs(save_dir, exist_ok=True) + + podcast = { + 'podcast_name': item_name, + 'podcast_metadata': metadata, + 'feed_url': metadata['feedUrl'], + 'itunes_id': metadata['itunesId'], + 'path': podcast_path, + 'rel_path': podcast_rel_path, + 'abs_ids': { + 'library_id': lib_item['libraryId'], + 'item_id': item_id, + 'folder_id': lib_item['folderId'], + } + } + + episodes = [] + logger.info(f"latest epsiode: {media['episodes'][0]['title']}") + for ep in media['episodes']: + ep_file = ep['audioFile'] + ep_metadata = ep_file['metadata'] + ep_filename = ep_metadata['filename'] + ep_path = ep_metadata['path'] + ep_rel_path = f"{podcast_rel_path}/{ep_filename}" + + published_date = dateutil.parser.parse(ep['pubDate']).isoformat() + published_date_ts = ep['publishedAt'] + episode = { + 'library_item_id': ep['libraryItemId'], + 'id': ep['id'], + 'path': ep_path, + 'rel_path': ep_rel_path, + 'index': ep.get('index'), + 'title': ep['title'], + 'subtitle': ep.get('subititle'), + 'description': ep.get('description'), + 'published_date': published_date, + 'publised_date_ts': published_date_ts, + 'filename': ep_metadata['filename'], + 'ext': ep_metadata['ext'], + 'mtime_ms': int(ep_metadata['mtimeMs']), + 'ctime_ms': int(ep_metadata['ctimeMs']), + 'birthtime_ms': int(ep_metadata['birthtimeMs']), + 'added_at': int(ep_file['addedAt']), + 'updated_at': int(ep_file['updatedAt']), + 'duration': ep_file['duration'], + } + episodes.append(episode) + with open(f'{save_dir}/{ep_filename}.json', 'w') as f: + f.write(json.dumps(episode, indent=4)) + + + with open(f'{save_dir}/metadata_podcast.json', 'w') as f: + f.write(json.dumps(podcast, indent=4)) + + full_metadata = podcast.copy() + full_metadata['episodes'] = episodes.copy() + + with open(f'{save_dir}/metadata.json', 'w') as f: + f.write(json.dumps(full_metadata, indent=4)) + + + +def metadata_embed(item_id): + r = s.post(f"{abs}/api/tools/item/{tem_id}/embed-metadata") + print(r.text) + print(r.status_code) + +if __name__ == "__main__": + metadata_main() + + diff --git a/roles/audiobookshelf/tasks/audiobookshelf.yml b/roles/audiobookshelf/tasks/audiobookshelf.yml index 0889f83..4180783 100644 --- a/roles/audiobookshelf/tasks/audiobookshelf.yml +++ b/roles/audiobookshelf/tasks/audiobookshelf.yml @@ -84,6 +84,28 @@ - abs-container - docker-containers +- name: install python utilies for mp3 metadata + apt: + name: + - eyed3 + - python3-mutagen + state: present + tags: + - packages + - audiobookshelf-scripts + +- name: config file for podcast tools + copy: + dest: /usr/local/bin/podcasts.json + owner: root + group: "{{ audiobookshelf_group.gid }}" + mode: 0750 + content: "{{ podcast_tools_config | to_nice_json }}" + tags: + - abs-scripts + - audiobookshelf-scripts + - podcast-tools + - name: copy abs scripts copy: src: "{{ item }}" @@ -96,3 +118,19 @@ tags: - abs-scripts - audiobookshelf-scripts + - podcast-tools + +- name: cron file + template: + src: audiobookshelf-cron.j2 + dest: /etc/cron.d/audiobookshelf + owner: root + group: root + mode: 0600 + tags: + - cron + - abs-cron + - audiobookshelf-cron + - abs-scripts + - audiobookshelf-scripts + - podcast-tools diff --git a/roles/audiobookshelf/templates/02-audiobookshelf.conf.j2 b/roles/audiobookshelf/templates/02-audiobookshelf.conf.j2 index 0461508..b8f5db9 100644 --- a/roles/audiobookshelf/templates/02-audiobookshelf.conf.j2 +++ b/roles/audiobookshelf/templates/02-audiobookshelf.conf.j2 @@ -1,3 +1,7 @@ +map $request_uri $kill_stupid_serviceworker_cache { + ~*^/_nuxt/(.*)\.js$ 1; +} + server { listen 443 ssl http2; include listen-proxy-protocol.conf; @@ -22,8 +26,6 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; proxy_http_version 1.1; @@ -34,8 +36,25 @@ server { add_header "Content-Type" "application/rss+xml"; } + location /socket.io/ { + include /etc/nginx/require_auth.conf; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Protocol $scheme; + proxy_set_header X-Forwarded-Host $http_host; + proxy_pass http://{{ bridgewithdns.audiobookshelf }}:80; + } location / { include /etc/nginx/require_auth.conf; + if ($kill_stupid_serviceworker_cache) { + rewrite "^(.*)$" "$1?id=$request_id" redirect; + } + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; @@ -44,6 +63,16 @@ server { proxy_http_version 1.1; + # nuke cache + add_header Last-Modified $date_gmt always; + add_header Cache-Control 'no-store' always; + if_modified_since off; + expires off; + etag off; + + # nuke the service worker cache + sub_filter '.js' '.js?id=$request_id'; + proxy_pass http://{{ bridgewithdns.audiobookshelf }}:80; proxy_redirect http:// https://; } diff --git a/roles/audiobookshelf/templates/audiobookshelf-cron.j2 b/roles/audiobookshelf/templates/audiobookshelf-cron.j2 new file mode 100644 index 0000000..a6116ae --- /dev/null +++ b/roles/audiobookshelf/templates/audiobookshelf-cron.j2 @@ -0,0 +1,6 @@ +# m h dom mon dow command + +*/1 * * * * {{ audiobookshelf_user.username }} find {{ audiobookshelf_path }}/podcasts -mmin -20 -name "*.mp3" -exec /usr/local/bin/fix-podcast-date.py fix-dates --podcast-file {} {# --quiet #}\; + + +# diff --git a/roles/hass/defaults/main.yml b/roles/hass/defaults/main.yml index 551bba9..ed97d53 100644 --- a/roles/hass/defaults/main.yml +++ b/roles/hass/defaults/main.yml @@ -1,2 +1 @@ --- -blink1_enabled: false diff --git a/roles/hass/files/packages/adaptive_lighting.yaml b/roles/hass/files/packages/adaptive_lighting.yaml new file mode 100644 index 0000000..9c69ed4 --- /dev/null +++ b/roles/hass/files/packages/adaptive_lighting.yaml @@ -0,0 +1,141 @@ +--- + +input_number: + sun_adaptive_max_brightness_pct: + name: sun_adaptive_max_brightness_pct + min: 0 + max: 100 + step: 1.0 + mode: box + unit_of_measurement: "%" + + +template: + - sensor: + - name: sun_zenith + unit_of_measurement: "°" + state_class: measurement + icon: >- + {% if states("sun.sun") == "above_horizon" %} + {% if state_attr("sun.sun", "rising") %} + mdi:weather-sunset-up + {% else %} + mdi:white-balance-sunny + {% endif %} + {% else %} + mdi:moon-waning-crescent + {% endif%} + state: >- + {{ state_attr("sun.sun", "elevation") | default(0.0) | float }} + attributes: + friendly_name: Solar zenith angle + below_horizon: >- + {{ states("sun.sun") == "below_horizon" }} + setting: >- + {{ state_attr("sun.sun", "rising") == false }} + + # - name: sun_position + # state_class: measurement + # icon: mdi:white-balance-sunny + # state: >- + # {{ state_attr("switch.adaptive_lighting_adapt_brightness_home", "sun_position") }} + + - name: sun_adaptive_brightness + unit_of_measurement: "%" + state_class: measurement + icon: >- + {{ state_attr("sensor.sun_zenith", "icon") }} + state: >- + {% set sun_zenith_pct = states("sensor.sun_zenith_pct") | float %} + {% set max_brightness_pct = states("input_number.sun_adaptive_max_brightness_pct") | default(100.0) | float %} + {% set brightness_pct = 100.0 - sun_zenith_pct %} + {{ brightness_pct | int }} + attributes: + friendly_name: Adaptive light brightness + + - name: sun_zenith_pct + unit_of_measurement: "%" + state_class: measurement + icon: >- + {{ state_attr("sensor.sun_zenith", "icon") }} + state: >- + {% set sun_highest = states("sensor.sun_zenith_highest") | default(1.0) | float %} + {% set sun_zenith = states("sensor.sun_zenith") | default(0.0) | float %} + {% set daylight_pct = max(sun_zenith, 0) / sun_highest * 100.0 %} + {{ daylight_pct | round(1) }} + + # - name: sun_time_until_dawn + # unit_of_measurement: minutes + # device_class: timestamp + # state_class: measurement + # state: >- + # {% set sun_dawn = state_attr("sun.sun", "next_dawn") | as_datetime %} + # {{ sun_dawn - now() }} + # attributes: + # friendly_name: "Time until dawn" + + # - name: sun_time_until_dusk + # unit_of_measurement: minutes + # device_class: timestamp + # state_class: measurement + # state: >- + # {% set sun_dusk = state_attr("sun.sun", "next_dusk") | as_datetime %} + # {{ sun_dusk - now() }} + # attributes: + # friendly_name: "Time until dusk" + + # - name: sun_time_until_midnight + # state_class: measurement + # device_class: timestamp + # unit_of_measurement: minutes + # state: >- + # {% set sun_midnight = state_attr("sun.sun", "next_midnight") | as_datetime %} + # {{ sun_midnight - now() }} + # attributes: + # friendly_name: "Time until midnight" + + # - name: sun_time_until_noon + # unit_of_measurement: minutes + # state_class: measurement + # device_class: timestamp + # state: >- + # {% set sun_noon = state_attr("sun.sun", "next_noon") | as_datetime %} + # {{ now() - sun_noon - now() }} + # attributes: + # friendly_name: "Time until noon" + + # - name: sun_time_until_rising + # unit_of_measurement: minutes + # device_class: timestamp + # state_class: measurement + # state: >- + # {% set sun_rising = state_attr("sun.sun", "next_rising") | as_datetime %} + # {{ sun_rising - now() }} + # attributes: + # friendly_name: "Time until rising" + + # - name: sun_time_until_setting + # unit_of_measurement: minutes + # device_class: timestamp + # state_class: measurement + # state: >- + # {% set sun_setting = state_attr("sun.sun", "next_setting") | as_datetime %} + # {{ sun_setting - now() }} + # attributes: + # friendly_name: "Time until setting" + + +sensor: + - name: sun_zenith_highest + platform: statistics + entity_id: sensor.sun_zenith + state_characteristic: value_max + max_age: + hours: 24 + + - name: sun_zenith_lowest + platform: statistics + entity_id: sensor.sun_zenith + state_characteristic: value_min + max_age: + hours: 24 diff --git a/roles/hass/files/packages/grow_lights.yaml b/roles/hass/files/packages/grow_lights.yaml index 23a14bc..3b37e7f 100644 --- a/roles/hass/files/packages/grow_lights.yaml +++ b/roles/hass/files/packages/grow_lights.yaml @@ -124,28 +124,6 @@ script: value: "{{ brightness_steps | int }}" automation: - - alias: grow_lights_1_state - trigger: - - platform: time_pattern - minutes: /15 - condition: [] - action: - - service: >- - script.grow_lights_1_{{ states('binary_sensor.grow_lights_1_schedule') }} - data: {} - - - alias: grow_lights_1_hass_start - trigger: - - platform: homeassistant - event: start - condition: [] - action: - - service: >- - light.turn_{{ states('binary_sensor.grow_lights_1_schedule') }} - target: - entity_id: light.grow_lights_1 - data: {} - - alias: grow_lights_1_schedule trigger: - platform: state @@ -161,6 +139,27 @@ automation: target: entity_id: light.grow_lights_1 data: {} + - alias: grow_lights_1_cron_send_state + trigger: + - platform: time_pattern + minutes: /10 + condition: [] + action: + - if: + - condition: state + entity_id: binary_sensor.grow_lights_1_schedule + state: "on" + then: + - service: script.grow_lights_1_on + data: {} + - if: + - condition: state + entity_id: binary_sensor.grow_lights_1_schedule + state: "off" + then: + - service: script.grow_lights_1_off + data: {} + mode: single binary_sensor: diff --git a/roles/hass/files/packages/moosetv.yaml b/roles/hass/files/packages/moosetv.yaml new file mode 100644 index 0000000..140e845 --- /dev/null +++ b/roles/hass/files/packages/moosetv.yaml @@ -0,0 +1,8 @@ +--- + +binary_sensor: + - platform: tod + unique_id: moosetv_schedule + name: moosetv_shedule + after: "06:00" + before: "01:00" diff --git a/roles/hass/handlers/main.yml b/roles/hass/handlers/main.yml index e473eca..45b38f5 100644 --- a/roles/hass/handlers/main.yml +++ b/roles/hass/handlers/main.yml @@ -18,6 +18,7 @@ when: - hass_container is not defined or not hass_container.changed - hass_container_state|default("stopped") == "started" + - hass_restart_handler|default(true) - name: restart zwavejs container docker_container: @@ -30,3 +31,7 @@ - name: udevadm reload rules command: udevadm control --reload-rules + +- name: git-hass-config.sh + command: /usr/local/bin/git-hass-config.sh + become_user: "{{ systemuserlist.hass.username }}" diff --git a/roles/hass/tasks/hass.yml b/roles/hass/tasks/hass.yml index bb0b098..692318f 100644 --- a/roles/hass/tasks/hass.yml +++ b/roles/hass/tasks/hass.yml @@ -69,7 +69,7 @@ - hass-git - hass-git-clone -- name: home assistant config files +- name: home assistant config files and config packages template: src: "{{ item }}.j2" dest: "{{ systemuserlist.hass.home }}/home-assistant/config/{{ item }}" @@ -83,13 +83,14 @@ - templates.yaml - automations-ansible-managed.yaml - scripts-ansible-managed.yaml - - blink1.yaml # - packages/climate.yaml + - packages/blink1.yaml - packages/toothbrush.yaml + - packages/glados_tts.yaml tags: - hass-config -- name: copy config files +- name: copy config packages copy: src: "{{ item }}" dest: "{{ systemuserlist.hass.home }}/home-assistant/config/{{ item }}" @@ -101,8 +102,11 @@ - packages/usb_led_strings.yaml - packages/grow_lights.yaml - packages/fans.yaml + - packages/moosetv.yaml + - packages/adaptive_lighting.yaml tags: - hass-config + - hass-packages - name: copy dashboard config files copy: @@ -126,6 +130,7 @@ mode: 0775 owner: hass group: hass + notify: git-hass-config.sh tags: - hass-git diff --git a/roles/hass/templates/01-hass.conf.j2 b/roles/hass/templates/01-hass.conf.j2 index 8e7c0db..b645357 100644 --- a/roles/hass/templates/01-hass.conf.j2 +++ b/roles/hass/templates/01-hass.conf.j2 @@ -16,6 +16,14 @@ server { server_name {{ hass_url }}; + location /glados/ { + # TEMP: while glados hass integration is WIP + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass {{ hass_glados_tts_url }}; + + } + location / { proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; @@ -33,7 +41,7 @@ server { # autoindex_exact_size off; # } - {% if blink1_enabled -%} + {% if blink1_server_port is defined -%} location /blink1/ { {% for cidr in my_local_cidrs -%} allow {{ cidr }}; @@ -139,6 +147,8 @@ server { ssl_session_timeout 5m; ssl_certificate /usr/local/etc/certs/{{ domain }}/fullchain.pem; ssl_certificate_key /usr/local/etc/certs/{{ domain }}/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; fastcgi_hide_header X-Powered-By; } diff --git a/roles/hass/templates/01-zwavejs.conf.j2 b/roles/hass/templates/01-zwavejs.conf.j2 index 09b6bce..a3a7e8b 100644 --- a/roles/hass/templates/01-zwavejs.conf.j2 +++ b/roles/hass/templates/01-zwavejs.conf.j2 @@ -1,3 +1,8 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + #default $http_connection; + '' close; +} server { listen 443 ssl http2; {% if inventory_hostname in wg_clients -%} diff --git a/roles/hass/templates/blink1.yaml.j2 b/roles/hass/templates/blink1.yaml.j2 deleted file mode 100644 index 7c27e6b..0000000 --- a/roles/hass/templates/blink1.yaml.j2 +++ /dev/null @@ -1,71 +0,0 @@ -{% if blink1_enabled -%} -friendly_name: blink1 -value_template: >- - {% raw -%} - {{ state_attr('sensor.blink1', 'rgb') != "#000000" }} - {% endraw %} - -# color_template: >- -# {% raw -%} -# {{ state_attr('sensor.blink1', 'rgb') }} -# {% endraw %} - -turn_on: - - service: rest_command.blink1_turn_on - - delay: - milliseconds: 500 - - service: homeassistant.update_entity - target: - entity_id: sensor.blink1 -turn_off: - - service: rest_command.blink1_turn_off - - delay: - milliseconds: 500 - - service: homeassistant.update_entity - target: - entity_id: sensor.blink1 -set_color: - - service: rest_command.blink1_turn_off - - service: rest_command.blink1_set_color - data: - # https://github.com/velijv/home-assistant-color-helpers#rgb-to-hex - # https://community.home-assistant.io/t/advanced-light-template-help/175654 - # https://community.home-assistant.io/t/using-hsv-hsb-to-set-colored-lights/15472 - rgb: >- - {%raw%} - {%- set h2 = h / 360 -%} - {%- set s2 = s / 100 -%} - {%- set v = 100 -%} - {%- set i = (h2 * 6 ) | round(2,'floor') | int-%} - {%- set f = h2 * 6 - i -%} - {%- set p = v * (1 - s2) -%} - {%- set q = v * (1 - f * s2) -%} - {%- set t = v * (1 - (1 - f) * s2) -%} - {%- if i % 6 == 0 -%} - {%- set r = v | int -%} - {%- set g = t | int -%} - {%- set b = p | int -%} - {%- elif i % 6 == 1 -%} - {%- set r = q | int -%} - {%- set g = v | int -%} - {%- set b = p | int -%} - {%- elif i % 6 == 2 -%} - {%- set r = p | int -%} - {%- set g = v | int -%} - {%- set b = t | int -%} - {%- elif i % 6 == 3 -%} - {%- set r = p | int -%} - {%- set g = q | int -%} - {%- set b = v | int -%} - {%- elif i % 6 == 4 -%} - {%- set r = t | int -%} - {%- set g = p | int -%} - {%- set b = v | int -%} - {%- elif i % 6 == 5 -%} - {%- set r = v | int -%} - {%- set g = p | int -%} - {%- set b = q | int -%} - {%- endif -%} - {{ '%02x%02x%02x' | format(r, g, b) }} - {%endraw%} -{% endif %} diff --git a/roles/hass/templates/configuration.yaml.j2 b/roles/hass/templates/configuration.yaml.j2 index 15006e4..ab69217 100644 --- a/roles/hass/templates/configuration.yaml.j2 +++ b/roles/hass/templates/configuration.yaml.j2 @@ -64,6 +64,17 @@ scene: !include scenes.yaml template: !include templates.yaml #climate: !include climate.yaml +http: + server_host: 127.0.0.1 + server_port: 8123 + trusted_proxies: + - 127.0.0.1 + use_x_forwarded_for: true + +api: + +websocket_api: + # Text to speech tts: - platform: voicerss @@ -82,15 +93,6 @@ calendar: url: {{ item.url | trim }} {% endfor %} -http: - server_host: 127.0.0.1 - server_port: 8123 - trusted_proxies: - - 127.0.0.1 - use_x_forwarded_for: true - -api: - frontend: themes: !include_dir_merge_named themes @@ -261,16 +263,6 @@ sensor: #{% endfor %} #} - {% if blink1_enabled -%} - - platform: rest - resource: http://localhost:{{ blink1_server_port }}/blink1 - name: blink1 - json_attributes: - - rgb - - bright - value_template: "{%raw%}{{ value_json.rgb }}{%endraw%}" - {% endif %} - binary_sensor: - platform: workday country: DE @@ -335,13 +327,13 @@ device_tracker: # to known_devices.yaml when they are discovered, but they wont be # tracked unless 'track' is set to 'true' for a device there (edit # the file to track a discovered device). - track_new_devices: true + track_new_devices: false #new_device_defaults: # track_new_devices: false - platform: bluetooth_tracker request_rssi: true - track_new_devices: true + track_new_devices: false - platform: ping # these are probably not used, because they are "global settings" # and only the first values from the first platform (bluetooth_le_tracker) @@ -405,32 +397,6 @@ notify: shell_command: matrixmsg: /usr/local/bin/matrixmsg.py -rest_command: - {% if blink1_enabled -%} - blink1_turn_on: - url: {{ hass_blink1_url }}/blink1/on?bright=250 - #url: http://localhost:{{ blink1_server_port }}/blink1/fadeToRGB?rgb=ff0ff - method: GET - content_type: "application/json" - blink1_turn_off: - url: {{ hass_blink1_url }}/blink1/off - method: GET - content_type: "application/json" - blink1_turn_magenta: - url: {{ hass_blink1_url }}/blink1/fadeToRGB?rgb=ff00ff - method: GET - content_type: "application/json" - blink1_set_color: - url: "{{ hass_blink1_url }}/blink1/fadeToRGB?rgb={%raw%}{{ rgb }}{%endraw%}" - method: GET - {% endif %} - -light: - {% if blink1_enabled -%} - - platform: template - lights: - blink1: !include blink1.yaml - {% endif %} {# # feedreader: diff --git a/roles/hass/templates/git-hass-config.sh.j2 b/roles/hass/templates/git-hass-config.sh.j2 index 31c8215..4e38396 100644 --- a/roles/hass/templates/git-hass-config.sh.j2 +++ b/roles/hass/templates/git-hass-config.sh.j2 @@ -25,32 +25,34 @@ fi mkdir -p ${PATH_REPO}/config/ mkdir -p ${PATH_REPO}/config/.storage -{% for item in hass_config_repo_cp -%} -{% if item.dir|default(false) %}{% set cp_r = "r" -%} -{% else %}{% set cp_r = "" -%} -{% endif -%} -{% if item.src.endswith("*") -%} -{% if item.src.count("/") > 0 -%} -{% set dest = item.src.split("/")[:-1] | join("/") + "/" -%} -{% else -%} -{% set dest = "" -%} -{% endif -%} -{% else -%} -{% set dest = item.src %} -{% endif %} -cp -a{{ cp_r }} ${PATH_HASS}/config/{{ item.src }} ${PATH_REPO}/config/{{ dest }} +{% for item in hass_config_repo_cp_files -%} +cp -a ${PATH_HASS}/config/{{ item }} ${PATH_REPO}/config/{{ item }} {% endfor %} +{% for item in hass_config_repo_cp_dirs -%} +cp -ra ${PATH_HASS}/config/{{ item }} ${PATH_REPO}/config/ +{% endfor %} +{% for item in hass_config_repo_cp_globs -%} +{% set dest_dir = item.split('/')[:-1] | join("/") %} +cp -ra ${PATH_HASS}/config/{{ item }} ${PATH_REPO}/config/{{ dest_dir }} +{% endfor %} + +set +e + +{% for item in hass_config_repo_rm -%} +git rm -rf ${PATH_REPO}/config/{{ item }} &> /dev/null +{% endfor %} + +set -e if test -n "$(git status --porcelain)" ; then - {% for item in hass_config_repo_cp -%} - git add config/{{ item.src }} > /dev/null - {% endfor %} - + git add config/ > /dev/null git commit -m "config updated" > /dev/null fi git pull --quiet -git push --quiet 2> /dev/null +git push origin main --quiet 2> /dev/null +#git push test main --force + # TODO: copy changes from git diff --git a/roles/hass/templates/packages/blink1.yaml.j2 b/roles/hass/templates/packages/blink1.yaml.j2 new file mode 100644 index 0000000..d0decf7 --- /dev/null +++ b/roles/hass/templates/packages/blink1.yaml.j2 @@ -0,0 +1,111 @@ +--- + +template: + - binary_sensor: + - name: "blink1_on" + device_class: light + state: >- + {% raw -%} + {{ state_attr('sensor.blink1', 'rgb') != "#000000" }} + {% endraw %} + +sensor: + - platform: rest + resource: http://localhost:{{ blink1_server_port }}/blink1 + name: blink1 + json_attributes: + - rgb + - bright + value_template: "{%raw%}{{ value_json.rgb }}{%endraw%}" + +rest_command: + blink1_turn_on: + url: {{ hass_blink1_url }}/blink1/on?bright=250 + #url: http://localhost:{{ blink1_server_port }}/blink1/fadeToRGB?rgb=ff0ff + method: GET + content_type: "application/json" + blink1_turn_off: + url: {{ hass_blink1_url }}/blink1/off + method: GET + content_type: "application/json" + blink1_turn_magenta: + url: {{ hass_blink1_url }}/blink1/fadeToRGB?rgb=ff00ff + method: GET + content_type: "application/json" + blink1_set_color: + url: "{{ hass_blink1_url }}/blink1/fadeToRGB?rgb={%raw%}{{ rgb }}{%endraw%}" + method: GET + +light: + - platform: template + lights: + blink1: + friendly_name: blink1 + value_template: >- + {% raw -%} + {{ state_attr('sensor.blink1', 'rgb') != "#000000" }} + {% endraw %} + + # color_template: >- + # {% raw -%} + # {{ state_attr('sensor.blink1', 'rgb') }} + # {% endraw %} + + turn_on: + - service: rest_command.blink1_turn_on + - delay: + milliseconds: 500 + - service: homeassistant.update_entity + target: + entity_id: sensor.blink1 + turn_off: + - service: rest_command.blink1_turn_off + - delay: + milliseconds: 500 + - service: homeassistant.update_entity + target: + entity_id: sensor.blink1 + set_color: + - service: rest_command.blink1_turn_off + - service: rest_command.blink1_set_color + data: + # https://github.com/velijv/home-assistant-color-helpers#rgb-to-hex + # https://community.home-assistant.io/t/advanced-light-template-help/175654 + # https://community.home-assistant.io/t/using-hsv-hsb-to-set-colored-lights/15472 + rgb: >- + {%raw%} + {%- set h2 = h / 360 -%} + {%- set s2 = s / 100 -%} + {%- set v = 100 -%} + {%- set i = (h2 * 6 ) | round(2,'floor') | int-%} + {%- set f = h2 * 6 - i -%} + {%- set p = v * (1 - s2) -%} + {%- set q = v * (1 - f * s2) -%} + {%- set t = v * (1 - (1 - f) * s2) -%} + {%- if i % 6 == 0 -%} + {%- set r = v | int -%} + {%- set g = t | int -%} + {%- set b = p | int -%} + {%- elif i % 6 == 1 -%} + {%- set r = q | int -%} + {%- set g = v | int -%} + {%- set b = p | int -%} + {%- elif i % 6 == 2 -%} + {%- set r = p | int -%}n + {%- set g = v | int -%} + {%- set b = t | int -%} + {%- elif i % 6 == 3 -%} + {%- set r = p | int -%} + {%- set g = q | int -%} + {%- set b = v | int -%} + {%- elif i % 6 == 4 -%} + {%- set r = t | int -%} + {%- set g = p | int -%} + {%- set b = v | int -%} + {%- elif i % 6 == 5 -%} + {%- set r = v | int -%} + {%- set g = p | int -%} + {%- set b = q | int -%} + {%- endif -%} + {{ '%02x%02x%02x' | format(r, g, b) }} + {%endraw%} diff --git a/roles/hass/templates/packages/glados_tts.yaml.j2 b/roles/hass/templates/packages/glados_tts.yaml.j2 new file mode 100644 index 0000000..0a17cb9 --- /dev/null +++ b/roles/hass/templates/packages/glados_tts.yaml.j2 @@ -0,0 +1,23 @@ +--- + +# example of simple tts integration: +# https://github.com/nagyrobi/home-assistant-custom-components-marytts/blob/main/custom_components/marytts/tts.py + +template: + - binary_sensor: + - name: glados_tts_is_in_hass + state: false + +notify: + - name: glados + platform: rest + resource: https://{{ hass_url }}/glados/say.mp3 + method: POST_JSON + headers: + #Authorization: + content-type: application/json + data_template: + message: >- + {% raw -%} + {{ title ~ '\n' ~ message }} + {% endraw %} diff --git a/roles/hass/templates/templates.yaml.j2 b/roles/hass/templates/templates.yaml.j2 index e41a0a3..6afdaa3 100644 --- a/roles/hass/templates/templates.yaml.j2 +++ b/roles/hass/templates/templates.yaml.j2 @@ -66,15 +66,6 @@ #} - binary_sensor: - {% if blink1_enabled -%} - - name: "blink1_on" - device_class: light - state: >- - {% raw -%} - {{ state_attr('sensor.blink1', 'rgb') != "#000000" }} - {% endraw %} - {% endif %} - - name: "heating_on" icon: "mdi:home-thermometer" device_class: heat diff --git a/roles/homeaudio/tasks/homeaudio.yml b/roles/homeaudio/tasks/homeaudio.yml index b85f807..30a4d65 100644 --- a/roles/homeaudio/tasks/homeaudio.yml +++ b/roles/homeaudio/tasks/homeaudio.yml @@ -4,11 +4,20 @@ apt: name: - avahi-utils + - eyed3 - id3v2 - ffmpeg + - pulseaudio-dlna + - pulseaudio-module-bluetooth + #- pulseaudio-module-raop + #- pulseaudio-module-zeroconf + #- pulseaudio-module-lirc + #- pulseaudio-module-jack + state: present tags: - homeaudio-packages + - packages - name: install yt-dlp pip: diff --git a/roles/jellyfin/templates/01-jellyfin.j2 b/roles/jellyfin/templates/01-jellyfin.j2 index c06a736..3fa4046 100644 --- a/roles/jellyfin/templates/01-jellyfin.j2 +++ b/roles/jellyfin/templates/01-jellyfin.j2 @@ -208,7 +208,7 @@ server { # External Javascript (such as cast_sender.js for Chromecast) must # be allowlisted. # 'self' https://*.{{ domain }} https://{{ domain }} - add_header Content-Security-Policy "default-src https: data: blob: http://image.tmdb.org; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' https://www.gstatic.com/cv/js/sender/v1/cast_sender.js https://www.gstatic.com/eureka/clank/cast_sender.js https://www.gstatic.com/eureka/clank/94/cast_sender.js https://www.gstatic.com/eureka/clank/95/cast_sender.js https://www.gstatic.com/eureka/clank/96/cast_sender.js https://www.gstatic.com/eureka/clank/97/cast_sender.js https://www.gstatic.com/eureka/clank/105/cast_sender.js https://www.gstatic.com/eureka/clank/106/cast_sender.js https://www.youtube.com blob:; worker-src 'self' blob:; connect-src 'self'; object-src 'none'; frame-ancestors 'self'" always; + add_header Content-Security-Policy "default-src https: data: blob: http://image.tmdb.org; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' https://www.gstatic.com/cv/js/sender/v1/cast_sender.js https://www.gstatic.com/eureka/clank/cast_sender.js https://www.gstatic.com/eureka/clank/94/cast_sender.js https://www.gstatic.com/eureka/clank/95/cast_sender.js https://www.gstatic.com/eureka/clank/96/cast_sender.js https://www.gstatic.com/eureka/clank/97/cast_sender.js https://www.gstatic.com/eureka/clank/98/cast_sender.js https://www.gstatic.com/eureka/clank/105/cast_sender.js https://www.gstatic.com/eureka/clank/106/cast_sender.js https://www.gstatic.com/eureka/clank/111/cast_sender.js https://www.youtube.com blob:; worker-src 'self' blob:; connect-src 'self'; object-src 'none'; frame-ancestors 'self'" always; # kill cache #add_header date $date_gmt always; diff --git a/roles/owntone/templates/owntone-cron.j2 b/roles/owntone/templates/owntone-cron.j2 index c6c3415..2b3f9d6 100644 --- a/roles/owntone/templates/owntone-cron.j2 +++ b/roles/owntone/templates/owntone-cron.j2 @@ -3,5 +3,6 @@ # m h dom mon dow */60 * * * * {{ owntone_user.username }} touch {{ owntone_path }}/audio/trigger.init-rescan +08 08 * * * {{ owntone_user.username }} touch {{ owntone_path }}/audio/trigger.meta-rescan # diff --git a/roles/owntone/templates/owntone.conf.j2 b/roles/owntone/templates/owntone.conf.j2 index 3f6643c..98fcb4e 100644 --- a/roles/owntone/templates/owntone.conf.j2 +++ b/roles/owntone/templates/owntone.conf.j2 @@ -4,7 +4,7 @@ general { # below, and full access to the databases, log and local audio uid = "owntone" - db_path = "/config/dbase_and_logs/songs3.db" + db_path = "/config/{{ owntone_db }}" # Database backup location # Uncomment and specify a full path to enable abilty to use REST endpoint @@ -32,8 +32,14 @@ general { # client types like Remotes, DAAP clients (iTunes) and to the web # interface. Options are "any", "localhost" or the prefix to one or # more ipv4/6 networks. The default is { "localhost", "192.168", "fd" } - {% set s21 = s21_cidr.split(".")[:3] | join(".") -%} - trusted_networks = { "localhost", "{{ s21 }}", "fd" } + + trusted_networks = { + {% for item in owntone_networks -%} + "{{ item.split(".")[:3] | join(".") | trim }}", + {% endfor -%} + "localhost", + "fd" + } # Enable/disable IPv6 ipv6 = no @@ -112,7 +118,7 @@ library { # albums and artists with only one track. The tracks will still be # visible in other lists, e.g. songs and playlists. This setting # currently only works in some remotes. - #hide_singles = false + hide_singles = true # Internet streams in your playlists will by default be shown in the # "Radio" library, like iTunes does. However, some clients (like diff --git a/roles/wireguard/templates/wg0.conf.j2 b/roles/wireguard/templates/wg0.conf.j2 index 49122bc..a35daba 100644 --- a/roles/wireguard/templates/wg0.conf.j2 +++ b/roles/wireguard/templates/wg0.conf.j2 @@ -3,7 +3,7 @@ ## NEW [Interface] -Address = {{ wg.ip }}/32 +Address = {{ wg.ip }}/24 {% if wg.listen|default(false) %} ListenPort = {{ wireguard_port }} {% endif %}