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 |