Last active 1737511963

A Python script that syncs subscriptions and playlists from a Piped instance to Invidious

sync_piped_to_invidious.py Raw
1#!/usr/bin/env python3
2
3##########################################################################################
4# This script is used to import subscriptions and playlists from Piped to Invidious.
5#
6# It requires the following:
7#
8# - python3 + requests
9# - The Piped API URL (e.g. https://pipedapi.example.com).
10# Note: This is NOT the Piped frontend URL.
11# - The Invidious URL (e.g. https://invidious.example.com).
12# - A Piped API token (it can be obtained from an authenticated browser session),
13# passed on the `PIPED_TOKEN` environment variable
14# - The Invidious cookie header passed on the `INVIDIOUS_TOKEN` environment variable.
15# You can retrieve it in your browser via https://<invidious-url>/authorize_token?scopes=:*.
16#
17# Author: Fabio Manganiello <fabio@manganiello.tech>
18# License: MIT
19##########################################################################################
20
21import argparse
22import os
23import re
24import sys
25
26from abc import ABC, abstractmethod
27from typing import Sequence
28from urllib.parse import urljoin
29
30import requests
31
32
33class Session:
34 """
35 A session contains the data required to interact with the Piped API and the Invidious instance.
36 """
37
38 def __init__(
39 self,
40 piped_api_url: str,
41 invidious_url: str,
42 piped_token: str,
43 invidious_token: str,
44 skip_playlists: bool = False,
45 skip_subscriptions: bool = False,
46 ):
47 self.piped_api_url = piped_api_url
48 self.invidious_url = invidious_url
49 self.piped_token = piped_token
50 self.invidious_token = invidious_token
51 self.skip_playlists = skip_playlists
52 self.skip_subscriptions = skip_subscriptions
53 self.piped = PipedAPI(self)
54 self.invidious = InvidiousAPI(self)
55
56 @classmethod
57 def from_cmdline(cls, args: Sequence[str]) -> "Session":
58 argparser = argparse.ArgumentParser(
59 description="Import subscriptions and playlists from Piped to Invidious."
60 )
61
62 argparser.add_argument(
63 "--piped-api-url",
64 required=True,
65 help="The base URL of the Piped API of the instance you want to import from, e.g. https://pipedapi.example.com",
66 )
67
68 argparser.add_argument(
69 "--invidious-url",
70 required=True,
71 help="The base URL of the Invidious instance you want to import to, e.g. https://invidious.example.com",
72 )
73
74 argparser.add_argument(
75 "--skip-playlists", action="store_true", help="Skip importing playlists"
76 )
77
78 argparser.add_argument(
79 "--skip-subscriptions",
80 action="store_true",
81 help="Skip importing subscriptions",
82 )
83
84 piped_token = os.getenv("PIPED_TOKEN")
85 invidious_token = os.getenv("INVIDIOUS_TOKEN")
86
87 if not piped_token:
88 argparser.error("The PIPED_TOKEN environment variable is required")
89
90 if not invidious_token:
91 argparser.error(
92 "The INVIDIOUS_TOKEN environment variable is required.\n"
93 "You can retrieve it in your browser via https://<invidious-url>/authorize_token?scopes=:*"
94 )
95
96 opts, _ = argparser.parse_known_args(args)
97 return cls(
98 piped_api_url=opts.piped_api_url,
99 invidious_url=opts.invidious_url,
100 piped_token=piped_token,
101 invidious_token=invidious_token,
102 skip_playlists=opts.skip_playlists,
103 skip_subscriptions=opts.skip_subscriptions,
104 )
105
106 def migrate_subscriptions(self):
107 if self.skip_subscriptions:
108 print("=== Skipping subscriptions import ===")
109 return
110
111 print("=== Importing subscriptions ===")
112
113 try:
114 rs = self.piped.request("/subscriptions")
115 except requests.exceptions.HTTPError as e:
116 sys.stderr.write(f"===> [-] Could not fetch Piped subscriptions: {e}\n")
117 sys.exit(1)
118
119 piped_subs = rs.json()
120 print(f"=== Found {len(piped_subs)} Piped subscriptions ===")
121
122 invidious_subs = {
123 sub["authorId"]
124 for sub in self.invidious.request("/auth/subscriptions").json()
125 }
126
127 for subs in piped_subs:
128 channel = re.sub(r"^/channel/", "", subs["url"])
129 if channel in invidious_subs:
130 print(
131 f'[=] Channel "{subs["name"]}" already subscribed on Invidious, skipping'
132 )
133 continue
134
135 print(f'[+] Importing channel: {subs["name"]}')
136
137 try:
138 rs = self.invidious.request(
139 f"/auth/subscriptions/{channel}", method="POST"
140 )
141 except requests.exceptions.HTTPError as e:
142 sys.stderr.write(
143 f'===> [-] Could not subscribe to channel: {subs["name"]}: {e}\n'
144 )
145
146 def migrate_playlists(self):
147 if self.skip_playlists:
148 print("=== Skipping playlists import ===")
149 return
150
151 print("=== Importing playlists ===")
152
153 try:
154 rs = self.piped.request("/user/playlists")
155 except requests.exceptions.HTTPError as e:
156 sys.stderr.write(f"===> [-] Could not fetch Piped playlists: {e}\n")
157 sys.exit(1)
158
159 piped_playlists = rs.json()
160 print(f"=== Found {len(piped_playlists)} Piped playlists ===")
161
162 try:
163 rs = self.invidious.request("/auth/playlists")
164 except requests.exceptions.HTTPError as e:
165 sys.stderr.write(f"===> [-] Could not fetch Invidious playlists: {e}\n")
166 sys.exit(1)
167
168 invidious_playlists = {
169 pl["title"]: {
170 **pl,
171 "videos": {item["videoId"]: item for item in pl.get("videos", [])},
172 }
173 for pl in rs.json()
174 }
175
176 print(f"=== Found {len(invidious_playlists)} Invidious playlists ===")
177
178 for pl in piped_playlists:
179 try:
180 rs = self.piped.request(f'/playlists/{pl["id"]}')
181 except requests.exceptions.HTTPError as e:
182 sys.stderr.write(
183 f'===> [-] Could not fetch playlist: {pl["name"]}: {e}\n'
184 )
185 continue
186
187 items = [
188 re.sub(r"^/watch\?v=", "", item["url"])
189 for item in rs.json().get("relatedStreams", [])
190 ]
191
192 print(f'Importing {len(items)} videos from playlist: {pl["name"]}')
193
194 if pl["name"] in invidious_playlists:
195 playlist_id = invidious_playlists[pl["name"]]["playlistId"]
196 print(
197 f'[=] Playlist "{pl["name"]}" already exists on Invidious, skipping creation'
198 )
199 else:
200 try:
201 rs = self.invidious.request(
202 "/auth/playlists",
203 method="POST",
204 json={
205 "title": pl["name"],
206 "privacy": "unlisted",
207 },
208 )
209 except requests.exceptions.HTTPError as e:
210 sys.stderr.write(
211 f'===> [-] Could not create playlist on Invidious: {pl["name"]}: {e}\n'
212 )
213 continue
214
215 playlist_id = rs.json().get("playlistId")
216 if not playlist_id:
217 sys.stderr.write(
218 f'===> [-] Could not retrieve ID of the newly created Invidious playlist: {pl["name"]}\n'
219 )
220 continue
221
222 print(
223 f'[+] Created playlist "{pl["name"]}" on Invidious, ID: {playlist_id}'
224 )
225
226 for item in items:
227 if item in invidious_playlists.get(pl["name"], {}).get("videos", {}):
228 print(f'[=] Video "{item}" already exists on Invidious, skipping')
229 continue
230
231 try:
232 rs = self.invidious.request(
233 f"/auth/playlists/{playlist_id}/videos",
234 method="POST",
235 json={"videoId": item},
236 )
237
238 print(f'[+] Added video "{item}" to playlist "{pl["name"]}"')
239 except requests.exceptions.HTTPError as e:
240 sys.stderr.write(
241 f'===> [-] Could not add video {item} to playlist {pl["name"]}: {e}\n'
242 )
243 continue
244
245
246class API(ABC):
247 def __init__(self, session: Session, timeout: float = 10):
248 self.session = session
249 self.timeout = timeout
250
251 @abstractmethod
252 def request(self, url: str, method: str = "GET", **kwargs) -> requests.Response:
253 pass
254
255
256class InvidiousAPI(API):
257 def request(self, url: str, method: str = "GET", **kwargs) -> requests.Response:
258 rs = requests.request(
259 method,
260 urljoin(self.session.invidious_url, f'/api/v1/{url.lstrip("/")}'),
261 headers={
262 "Authorization": f"Bearer {self.session.invidious_token}",
263 },
264 **{
265 "timeout": self.timeout,
266 **kwargs,
267 },
268 )
269
270 rs.raise_for_status()
271 return rs
272
273
274class PipedAPI(API):
275 def request(self, url: str, method: str = "GET", **kwargs) -> requests.Response:
276 rs = requests.request(
277 method,
278 urljoin(self.session.piped_api_url, url.lstrip("/")),
279 headers={
280 "Authorization": self.session.piped_token,
281 },
282 **{
283 "timeout": self.timeout,
284 **kwargs,
285 },
286 )
287
288 rs.raise_for_status()
289 return rs
290
291
292def main():
293 session = Session.from_cmdline(sys.argv[1:])
294 session.migrate_subscriptions()
295 session.migrate_playlists()
296
297
298if __name__ == "__main__":
299 main()
300