├── .gitignore ├── app ├── requirements.txt └── main.py ├── .github ├── dependabot.yml └── workflows │ └── docker-publish.yml ├── Dockerfile ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | config 2 | .DS_Store 3 | *.code-workspace 4 | docker-compose.yml 5 | -------------------------------------------------------------------------------- /app/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==2.3.3 2 | Flask_RESTful==0.3.10 3 | requests==2.31.0 4 | waitress==2.1.2 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/app" 5 | schedule: 6 | interval: "monthly" 7 | versioning-strategy: "increase" 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim AS build 2 | 3 | WORKDIR /youtube-watching 4 | COPY ./app ./app 5 | RUN pip install -r ./app/requirements.txt 6 | 7 | 8 | FROM python:3.11-alpine 9 | 10 | WORKDIR /youtube-watching 11 | COPY --from=build /youtube-watching . 12 | COPY --from=build /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/ 13 | EXPOSE 5678 14 | 15 | CMD ["python", "./app/main.py"] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Mattias Persson 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 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Create and publish a Docker image 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: ["published"] 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | build-and-push-image: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v3 22 | 23 | - name: Log in to the Container registry 24 | uses: docker/login-action@v2 25 | with: 26 | registry: ${{ env.REGISTRY }} 27 | username: ${{ github.actor }} 28 | password: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | - name: Extract metadata (tags, labels) for Docker 31 | id: meta 32 | uses: docker/metadata-action@v4 33 | with: 34 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 35 | 36 | - name: Build and push Docker image 37 | uses: docker/build-push-action@v3 38 | with: 39 | context: . 40 | push: true 41 | tags: ${{ steps.meta.outputs.tags }} 42 | labels: ${{ steps.meta.outputs.labels }} 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # youtube-watching 2 | 3 | A containerized flask app to get the latest video from your YouTube watch history. This is as a workaround for YouTube Data API v3 deprecating "watchHistory". 4 | 5 | ```bash 6 | curl http://127.0.0.1:5678 7 | ``` 8 | 9 | ```json 10 | { 11 | "channel": "Bad Friends", 12 | "title": "Bobby's Bank Heist | Ep 129 | Bad Friends", 13 | "video_id": "7bT2zdzWAM4", 14 | "duration_string": "1:16:48", 15 | "thumbnail": "https://i.ytimg.com/vi/7bT2zdzWAM4/hqdefault.jpg", 16 | "original_url": "https://www.youtube.com/watch?v=7bT2zdzWAM4" 17 | } 18 | ``` 19 | 20 | ## Cookies 21 | 22 | To authenticate with youtube, you need to set a HTTP Cookie File. 23 | 24 | > "In order to extract cookies from browser use any conforming browser extension for exporting cookies. For example, [Get cookies.txt LOCALLY](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc) (for Chrome) or [cookies.txt](https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/) (for Firefox)". 25 | 26 | ## Install 27 | 28 | Pull and run with docker-compose. 29 | 30 | ```bash 31 | cd docker-compose && \ 32 | docker-compose up -d youtube-watching 33 | ``` 34 | 35 | ```yaml 36 | version: '3' 37 | services: 38 | youtube-watching: 39 | container_name: youtube-watching 40 | image: ghcr.io/matt8707/youtube-watching 41 | volumes: 42 | - /volume1/docker/youtube-watching/config:/youtube-watching/config/ 43 | environment: 44 | - COOKIE=./config/youtube.com_cookies.txt 45 | network_mode: bridge 46 | ports: 47 | - 5678:5678 48 | restart: always 49 | ``` 50 | 51 | ## Build 52 | 53 | You can also build the image with docker-compose. 54 | 55 | ```bash 56 | cd docker-compose && \ 57 | docker-compose up -d --build youtube-watching 58 | ``` 59 | 60 | ```yaml 61 | version: '3' 62 | services: 63 | youtube-watching: 64 | container_name: youtube-watching 65 | build: 66 | context: /volume1/docker/youtube-watching/ 67 | dockerfile: /volume1/docker/youtube-watching/Dockerfile 68 | volumes: 69 | - /volume1/docker/youtube-watching/config:/youtube-watching/config/ 70 | environment: 71 | - COOKIE=./config/youtube.com_cookies.txt 72 | network_mode: bridge 73 | ports: 74 | - 5678:5678 75 | restart: always 76 | ``` 77 | 78 | ## Home Assistant 79 | 80 | The "YouTube" Apple TV app doesn't expose artwork through AirPlay, so the Home Assistant `apple_tv` integration can't show an `entity_picture`. This is an example to get the thumbnail. 81 | 82 | **Note:** It's now possible without this container https://github.com/matt8707/hass-config/commit/ad624e0da9520a2b304f82a57b92c2b6f289a4ad 83 | 84 | ```yaml 85 | rest: 86 | - resource: http://192.168.1.241:5678 87 | sensor: 88 | name: youtube_watching 89 | value_template: > 90 | {{ value_json.thumbnail }} 91 | json_attributes: 92 | - channel 93 | - title 94 | - video_id 95 | - duration_string 96 | - original_url 97 | scan_interval: 86400 98 | 99 | automation: 100 | - alias: update_youtube_watching_thumbnail 101 | id: '1781428593188' 102 | mode: single 103 | max_exceeded: silent 104 | variables: 105 | ytw: sensor.youtube_watching 106 | trigger: 107 | platform: state 108 | entity_id: 109 | - media_player.vardagsrum 110 | - media_player.sovrum 111 | to: playing 112 | condition: > 113 | {{ is_state_attr(trigger.entity_id, 'app_id', 'com.google.ios.youtube') and 114 | state_attr(trigger.entity_id, 'media_title') != state_attr(ytw, 'title') }} 115 | action: 116 | - service: homeassistant.update_entity 117 | target: 118 | entity_id: '{{ ytw }}' 119 | ``` 120 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | youtube-watching 3 | """ 4 | 5 | import os 6 | from http.cookiejar import MozillaCookieJar 7 | import json 8 | import re 9 | import requests 10 | from flask import Flask 11 | from flask_restful import Resource, Api 12 | 13 | app = Flask(__name__) 14 | api = Api(app) 15 | 16 | def yt_history(cookie_path): 17 | """ 18 | get latest video from youtube history excluding reel shelf (shorts) 19 | """ 20 | 21 | # COOKIES 22 | if not os.path.exists(cookie_path): 23 | return _error(f"Cookie path '{cookie_path}' does not exist") 24 | 25 | cookie_jar = MozillaCookieJar(cookie_path) 26 | 27 | try: 28 | cookie_jar.load(ignore_discard=True, ignore_expires=True) 29 | except Exception as error: 30 | return _error(f"Failed to load cookies from '{cookie_path}': {error}") 31 | 32 | # SESSION 33 | session = requests.Session() 34 | session.headers = { 35 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)\ 36 | AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36", 37 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", 38 | "Accept-Language": "en-us,en;q=0.5", 39 | "Sec-Fetch-Mode": "navigate", 40 | } 41 | session.cookies = cookie_jar 42 | 43 | # RESPONSE 44 | try: 45 | response = session.get("https://www.youtube.com/feed/history") 46 | 47 | except Exception as error: 48 | return _error(f"Error fetching YouTube history: {error}") 49 | 50 | cookie_jar.save(ignore_discard=True, ignore_expires=True) 51 | html = response.text 52 | 53 | # REGEX 54 | try: 55 | regex = r"var ytInitialData = (.*);<\/script>" 56 | match = re.search(regex, html).group(1) 57 | 58 | except Exception as error: 59 | return _error(f"Failed to extract JSON using regex: {error}") 60 | 61 | # JSON 62 | try: 63 | data = json.loads(match) 64 | 65 | except Exception as error: 66 | return _error(f"Failed to parse JSON: {error}") 67 | 68 | # DATA 69 | try: 70 | path = data["contents"]["twoColumnBrowseResultsRenderer"]\ 71 | ["tabs"][0]["tabRenderer"]["content"]["sectionListRenderer"]\ 72 | ["contents"][0]["itemSectionRenderer"]["contents"] 73 | 74 | except Exception as error: 75 | return _error(f"Failed to extract data: {error}") 76 | 77 | try: 78 | if "reelShelfRenderer" in path[0]: 79 | key = path[1]["videoRenderer"] 80 | else: 81 | key = path[0]["videoRenderer"] 82 | 83 | return { 84 | "channel": key["longBylineText"]["runs"][0]["text"], 85 | "title": key["title"]["runs"][0]["text"], 86 | "video_id": key["videoId"], 87 | "duration_string": key["lengthText"]["simpleText"], 88 | "thumbnail": thumbnail(key["videoId"]), 89 | "original_url": f"https://www.youtube.com/watch?v={key['videoId']}", 90 | } 91 | 92 | except Exception as error: 93 | return _error(f"Failed to extract video details from YouTube data: {error}, {path}") 94 | 95 | def thumbnail(fid): 96 | """ 97 | return max resolution 98 | """ 99 | 100 | url = f"https://img.youtube.com/vi/{fid}" 101 | maxres = f"{url}/maxresdefault.jpg" 102 | default = f"{url}/0.jpg" 103 | 104 | if requests.get(maxres, timeout=3).status_code == 200: 105 | return maxres 106 | 107 | return default 108 | 109 | def _error(error_msg): 110 | """ 111 | Log and return error 112 | """ 113 | print(error_msg) 114 | 115 | return { 116 | "error": error_msg 117 | }, 500 118 | 119 | 120 | class RestApi(Resource): 121 | """ 122 | https://flask-restful.readthedocs.io/en/latest/quickstart.html 123 | """ 124 | 125 | def get(self): 126 | """ 127 | on GET request call yt_history 128 | """ 129 | 130 | cookie = os.environ.get('COOKIE') 131 | 132 | if not cookie: 133 | return _error("COOKIE environment variable not set") 134 | 135 | try: 136 | return yt_history(cookie) 137 | 138 | except Exception as error: 139 | return _error(f"Unhandled error occurred: {error}") 140 | 141 | 142 | api.add_resource(RestApi, "/") 143 | 144 | if __name__ == "__main__": 145 | try: 146 | from waitress import serve 147 | serve(app, host="0.0.0.0", port=5678) 148 | 149 | except Exception as error: 150 | _error(f"Server error: {error}") 151 | --------------------------------------------------------------------------------