sync_piped_to_invidious.py
· 9.9 KiB · Python
Raw
#!/usr/bin/env python3
##########################################################################################
# This script is used to import subscriptions and playlists from Piped to Invidious.
#
# It requires the following:
#
# - python3 + requests
# - The Piped API URL (e.g. https://pipedapi.example.com).
# Note: This is NOT the Piped frontend URL.
# - The Invidious URL (e.g. https://invidious.example.com).
# - A Piped API token (it can be obtained from an authenticated browser session),
# passed on the `PIPED_TOKEN` environment variable
# - The Invidious cookie header passed on the `INVIDIOUS_TOKEN` environment variable.
# You can retrieve it in your browser via https://<invidious-url>/authorize_token?scopes=:*.
#
# Author: Fabio Manganiello <fabio@manganiello.tech>
# License: MIT
##########################################################################################
import argparse
import os
import re
import sys
from abc import ABC, abstractmethod
from typing import Sequence
from urllib.parse import urljoin
import requests
class Session:
"""
A session contains the data required to interact with the Piped API and the Invidious instance.
"""
def __init__(
self,
piped_api_url: str,
invidious_url: str,
piped_token: str,
invidious_token: str,
skip_playlists: bool = False,
skip_subscriptions: bool = False,
):
self.piped_api_url = piped_api_url
self.invidious_url = invidious_url
self.piped_token = piped_token
self.invidious_token = invidious_token
self.skip_playlists = skip_playlists
self.skip_subscriptions = skip_subscriptions
self.piped = PipedAPI(self)
self.invidious = InvidiousAPI(self)
@classmethod
def from_cmdline(cls, args: Sequence[str]) -> "Session":
argparser = argparse.ArgumentParser(
description="Import subscriptions and playlists from Piped to Invidious."
)
argparser.add_argument(
"--piped-api-url",
required=True,
help="The base URL of the Piped API of the instance you want to import from, e.g. https://pipedapi.example.com",
)
argparser.add_argument(
"--invidious-url",
required=True,
help="The base URL of the Invidious instance you want to import to, e.g. https://invidious.example.com",
)
argparser.add_argument(
"--skip-playlists", action="store_true", help="Skip importing playlists"
)
argparser.add_argument(
"--skip-subscriptions",
action="store_true",
help="Skip importing subscriptions",
)
piped_token = os.getenv("PIPED_TOKEN")
invidious_token = os.getenv("INVIDIOUS_TOKEN")
if not piped_token:
argparser.error("The PIPED_TOKEN environment variable is required")
if not invidious_token:
argparser.error(
"The INVIDIOUS_TOKEN environment variable is required.\n"
"You can retrieve it in your browser via https://<invidious-url>/authorize_token?scopes=:*"
)
opts, _ = argparser.parse_known_args(args)
return cls(
piped_api_url=opts.piped_api_url,
invidious_url=opts.invidious_url,
piped_token=piped_token,
invidious_token=invidious_token,
skip_playlists=opts.skip_playlists,
skip_subscriptions=opts.skip_subscriptions,
)
def migrate_subscriptions(self):
if self.skip_subscriptions:
print("=== Skipping subscriptions import ===")
return
print("=== Importing subscriptions ===")
try:
rs = self.piped.request("/subscriptions")
except requests.exceptions.HTTPError as e:
sys.stderr.write(f"===> [-] Could not fetch Piped subscriptions: {e}\n")
sys.exit(1)
piped_subs = rs.json()
print(f"=== Found {len(piped_subs)} Piped subscriptions ===")
invidious_subs = {
sub["authorId"]
for sub in self.invidious.request("/auth/subscriptions").json()
}
for subs in piped_subs:
channel = re.sub(r"^/channel/", "", subs["url"])
if channel in invidious_subs:
print(
f'[=] Channel "{subs["name"]}" already subscribed on Invidious, skipping'
)
continue
print(f'[+] Importing channel: {subs["name"]}')
try:
rs = self.invidious.request(
f"/auth/subscriptions/{channel}", method="POST"
)
except requests.exceptions.HTTPError as e:
sys.stderr.write(
f'===> [-] Could not subscribe to channel: {subs["name"]}: {e}\n'
)
def migrate_playlists(self):
if self.skip_playlists:
print("=== Skipping playlists import ===")
return
print("=== Importing playlists ===")
try:
rs = self.piped.request("/user/playlists")
except requests.exceptions.HTTPError as e:
sys.stderr.write(f"===> [-] Could not fetch Piped playlists: {e}\n")
sys.exit(1)
piped_playlists = rs.json()
print(f"=== Found {len(piped_playlists)} Piped playlists ===")
try:
rs = self.invidious.request("/auth/playlists")
except requests.exceptions.HTTPError as e:
sys.stderr.write(f"===> [-] Could not fetch Invidious playlists: {e}\n")
sys.exit(1)
invidious_playlists = {
pl["title"]: {
**pl,
"videos": {item["videoId"]: item for item in pl.get("videos", [])},
}
for pl in rs.json()
}
print(f"=== Found {len(invidious_playlists)} Invidious playlists ===")
for pl in piped_playlists:
try:
rs = self.piped.request(f'/playlists/{pl["id"]}')
except requests.exceptions.HTTPError as e:
sys.stderr.write(
f'===> [-] Could not fetch playlist: {pl["name"]}: {e}\n'
)
continue
items = [
re.sub(r"^/watch\?v=", "", item["url"])
for item in rs.json().get("relatedStreams", [])
]
print(f'Importing {len(items)} videos from playlist: {pl["name"]}')
if pl["name"] in invidious_playlists:
playlist_id = invidious_playlists[pl["name"]]["playlistId"]
print(
f'[=] Playlist "{pl["name"]}" already exists on Invidious, skipping creation'
)
else:
try:
rs = self.invidious.request(
"/auth/playlists",
method="POST",
json={
"title": pl["name"],
"privacy": "unlisted",
},
)
except requests.exceptions.HTTPError as e:
sys.stderr.write(
f'===> [-] Could not create playlist on Invidious: {pl["name"]}: {e}\n'
)
continue
playlist_id = rs.json().get("playlistId")
if not playlist_id:
sys.stderr.write(
f'===> [-] Could not retrieve ID of the newly created Invidious playlist: {pl["name"]}\n'
)
continue
print(
f'[+] Created playlist "{pl["name"]}" on Invidious, ID: {playlist_id}'
)
for item in items:
if item in invidious_playlists.get(pl["name"], {}).get("videos", {}):
print(f'[=] Video "{item}" already exists on Invidious, skipping')
continue
try:
rs = self.invidious.request(
f"/auth/playlists/{playlist_id}/videos",
method="POST",
json={"videoId": item},
)
print(f'[+] Added video "{item}" to playlist "{pl["name"]}"')
except requests.exceptions.HTTPError as e:
sys.stderr.write(
f'===> [-] Could not add video {item} to playlist {pl["name"]}: {e}\n'
)
continue
class API(ABC):
def __init__(self, session: Session, timeout: float = 10):
self.session = session
self.timeout = timeout
@abstractmethod
def request(self, url: str, method: str = "GET", **kwargs) -> requests.Response:
pass
class InvidiousAPI(API):
def request(self, url: str, method: str = "GET", **kwargs) -> requests.Response:
rs = requests.request(
method,
urljoin(self.session.invidious_url, f'/api/v1/{url.lstrip("/")}'),
headers={
"Authorization": f"Bearer {self.session.invidious_token}",
},
**{
"timeout": self.timeout,
**kwargs,
},
)
rs.raise_for_status()
return rs
class PipedAPI(API):
def request(self, url: str, method: str = "GET", **kwargs) -> requests.Response:
rs = requests.request(
method,
urljoin(self.session.piped_api_url, url.lstrip("/")),
headers={
"Authorization": self.session.piped_token,
},
**{
"timeout": self.timeout,
**kwargs,
},
)
rs.raise_for_status()
return rs
def main():
session = Session.from_cmdline(sys.argv[1:])
session.migrate_subscriptions()
session.migrate_playlists()
if __name__ == "__main__":
main()
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 | |
21 | import argparse |
22 | import os |
23 | import re |
24 | import sys |
25 | |
26 | from abc import ABC, abstractmethod |
27 | from typing import Sequence |
28 | from urllib.parse import urljoin |
29 | |
30 | import requests |
31 | |
32 | |
33 | class 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 | |
246 | class 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 | |
256 | class 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 | |
274 | class 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 | |
292 | def main(): |
293 | session = Session.from_cmdline(sys.argv[1:]) |
294 | session.migrate_subscriptions() |
295 | session.migrate_playlists() |
296 | |
297 | |
298 | if __name__ == "__main__": |
299 | main() |
300 |