commit
408e018a59
@ -0,0 +1 @@
|
||||
/target
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "piped_playlist_importer"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Mageas <dev@mageas.net>"]
|
||||
description = "Import playlist to Piped from text files"
|
||||
keywords = ["piped"]
|
||||
license = "GPL-3.0"
|
||||
repository = "https://gitea.heartnerds.org/Mageas/piped_playlist_importer"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1.0.37"
|
||||
anyhow = "1.0.66"
|
||||
reqwest = { version = "0.11.12", features = ["json"] }
|
||||
tokio = { version = "1.21.2", features = ["full"] }
|
||||
clap = { version = "4.0.18", features = ["derive"] }
|
||||
serde = { version = "1.0.147", features = ["derive"] }
|
||||
serde_json = "1.0.87"
|
@ -0,0 +1,38 @@
|
||||
# Piped Playlist Importer
|
||||
|
||||
Import your playlists to Piped from text files.
|
||||
|
||||
## **How to use**
|
||||
|
||||
``` text
|
||||
Usage: piped_playlist_importer [OPTIONS] --authorization <AUTHORIZATION>
|
||||
|
||||
Options:
|
||||
-p, --playlists-directory <PLAYLISTS_DIRECTORY>
|
||||
Path of the directory containing playlists
|
||||
-a, --authorization <AUTHORIZATION>
|
||||
Authorization code found in the local storage of your browser (authToken...)
|
||||
-i, --instance <INSTANCE>
|
||||
Authentication instance [default: https://pipedapi.kavin.rocks]
|
||||
-h, --help
|
||||
Print help information
|
||||
-V, --version
|
||||
Print version information
|
||||
```
|
||||
|
||||
## **Install instructions**
|
||||
|
||||
Clone the repository:
|
||||
```
|
||||
git clone https://gitea.heartnerds.org/Mageas/piped_playlist_importer
|
||||
```
|
||||
|
||||
Move into the project directory:
|
||||
```
|
||||
cd piped_playlist_importer
|
||||
```
|
||||
|
||||
Install the project with cargo:
|
||||
```
|
||||
cargo install --path=.
|
||||
```
|
@ -0,0 +1 @@
|
||||
edition = "2021"
|
@ -0,0 +1,15 @@
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about)]
|
||||
pub struct Cli {
|
||||
/// Path of the directory containing playlists
|
||||
#[arg(short, long)]
|
||||
pub playlists_directory: Option<String>,
|
||||
/// Authorization code found in the local storage of your browser (authToken...)
|
||||
#[arg(short, long)]
|
||||
pub authorization: String,
|
||||
/// Authentication instance
|
||||
#[arg(short, long, default_value = "https://pipedapi.kavin.rocks")]
|
||||
pub instance: String,
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum PipedPlaylistImporterError {
|
||||
#[error("Unable to parse the filename for {0}")]
|
||||
FileName(std::path::PathBuf),
|
||||
|
||||
#[error("Unable to list files for {1}: {0}")]
|
||||
ListFiles(#[source] std::io::Error, String),
|
||||
|
||||
#[error("Unable to read lines from {1}: {0}")]
|
||||
ReadLines(#[source] std::io::Error, String),
|
||||
|
||||
#[error("Unable to read : {0}")]
|
||||
ReadFile(#[from] std::io::Error),
|
||||
|
||||
#[error("Unable to deserialize : {0}")]
|
||||
Deserialize(#[from] serde_json::Error),
|
||||
|
||||
#[error("Unable contact '{1}' : {0}")]
|
||||
ContactApi(#[source] reqwest::Error, String),
|
||||
|
||||
#[error("{0}")]
|
||||
GeneralRequest(#[from] reqwest::Error),
|
||||
|
||||
#[error("[{0}] Unable to contact '{1}' : {2}")]
|
||||
Request(reqwest::StatusCode, String, String),
|
||||
}
|
||||
|
||||
pub type PipedPlaylistImporterResult<T = ()> = Result<T, PipedPlaylistImporterError>;
|
@ -0,0 +1,83 @@
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
mod error;
|
||||
|
||||
mod piped;
|
||||
use piped::*;
|
||||
|
||||
mod playlist;
|
||||
use playlist::*;
|
||||
|
||||
mod args;
|
||||
use args::*;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = Cli::parse();
|
||||
|
||||
let current_directory =
|
||||
std::env::current_dir().context("Unable to get the current directory from the env")?;
|
||||
|
||||
let directory = match &args.playlists_directory {
|
||||
Some(input) => std::path::PathBuf::from(input),
|
||||
None => current_directory,
|
||||
};
|
||||
|
||||
let directory_files = directory.read_dir().context(format!(
|
||||
"Unable to read the directory {}",
|
||||
&directory.display()
|
||||
))?;
|
||||
|
||||
let playlists = Playlists::new(directory_files, &directory)?;
|
||||
|
||||
let client = PipedClient::new(&args.instance, &args.authorization);
|
||||
|
||||
let piped_playlists = client.get_playlists().await?;
|
||||
|
||||
let mut global_count = 0;
|
||||
playlists
|
||||
.iter()
|
||||
.for_each(|playlist| global_count += playlist.urls.len());
|
||||
|
||||
let mut errors: Vec<(String, String)> = vec![];
|
||||
|
||||
let mut count = 0;
|
||||
for playlist in playlists {
|
||||
if piped_playlists.iter().any(|c| c.name == playlist.name) {
|
||||
println!("Skip {}", playlist.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
let response = client.create_playlist(&playlist.name).await?;
|
||||
let playlist_id = &response.playlist_id;
|
||||
println!("Created {} ({})", playlist.name, playlist_id);
|
||||
|
||||
for video in playlist.urls {
|
||||
let video_id = video.split('=').nth(1).unwrap();
|
||||
|
||||
let response = client.add_video_to_playlist(video_id, playlist_id).await?;
|
||||
|
||||
count += 1;
|
||||
if response.message == "ok" {
|
||||
println!(
|
||||
"({}/{}) Added {} to {} ({})",
|
||||
count, global_count, video_id, playlist.name, playlist_id
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"({}/{}) Error while adding {} to {} ({})",
|
||||
count, global_count, video_id, playlist.name, playlist_id
|
||||
);
|
||||
errors.push((playlist_id.to_owned(), video_id.to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for error in errors {
|
||||
println!("Unable to add {} in {}", error.0, error.1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
@ -0,0 +1,146 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use reqwest::header::{AUTHORIZATION, USER_AGENT};
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::error::{PipedPlaylistImporterError, PipedPlaylistImporterResult};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PipedClient {
|
||||
pub httpclient: Client,
|
||||
pub instance: String,
|
||||
pub authorization: String,
|
||||
}
|
||||
|
||||
const USER_AGENT_: &str = "Mozilla/5.0 (Windows NT 10.0; rv:78.0) Gecko/20100101 Firefox/78.0";
|
||||
|
||||
impl PipedClient {
|
||||
/// New piped client
|
||||
pub fn new<S: AsRef<str>>(instance: S, authorization: S) -> PipedClient {
|
||||
let mut url = instance.as_ref().to_owned();
|
||||
if !url.starts_with("http") {
|
||||
url = format!("https://{}", url);
|
||||
}
|
||||
if url.ends_with('/') {
|
||||
url.pop();
|
||||
}
|
||||
|
||||
PipedClient {
|
||||
httpclient: Client::new(),
|
||||
instance: url,
|
||||
authorization: authorization.as_ref().to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve the playlists from piped
|
||||
pub async fn get_playlists(
|
||||
&self,
|
||||
) -> PipedPlaylistImporterResult<Vec<PipedGetPlaylistResponse>> {
|
||||
let url = format!("{}/user/playlists", self.instance);
|
||||
let response = self.get(&url).await?;
|
||||
Ok(serde_json::from_str::<Vec<PipedGetPlaylistResponse>>(
|
||||
&response,
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Create a playlist
|
||||
pub async fn create_playlist(
|
||||
&self,
|
||||
name: &str,
|
||||
) -> PipedPlaylistImporterResult<PipedCreatePlaylistResponse> {
|
||||
let url = format!("{}/user/playlists/create", self.instance);
|
||||
let body = std::collections::HashMap::from([("name", name)]);
|
||||
let response = self.post(&url, &body).await?;
|
||||
Ok(serde_json::from_str::<PipedCreatePlaylistResponse>(
|
||||
&response,
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Add a video to a playlist
|
||||
pub async fn add_video_to_playlist(
|
||||
&self,
|
||||
video_id: &str,
|
||||
playlist_id: &str,
|
||||
) -> PipedPlaylistImporterResult<PipedAddVideoToPlaylistResponse> {
|
||||
let url = format!("{}/user/playlists/add", self.instance);
|
||||
let body =
|
||||
std::collections::HashMap::from([("videoId", video_id), ("playlistId", playlist_id)]);
|
||||
let response = self.post(&url, &body).await?;
|
||||
Ok(serde_json::from_str::<PipedAddVideoToPlaylistResponse>(
|
||||
&response,
|
||||
)?)
|
||||
}
|
||||
}
|
||||
|
||||
impl PipedClient {
|
||||
/// Post request
|
||||
async fn post<S>(&self, url: &str, body: &S) -> PipedPlaylistImporterResult<String>
|
||||
where
|
||||
S: Serialize,
|
||||
{
|
||||
let response = self
|
||||
.httpclient
|
||||
.post(url)
|
||||
.json(&body)
|
||||
.header(USER_AGENT, USER_AGENT_)
|
||||
.header(AUTHORIZATION, &self.authorization)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| PipedPlaylistImporterError::ContactApi(e, url.to_owned()))?;
|
||||
|
||||
if response.status() != 200 {
|
||||
return Err(PipedPlaylistImporterError::Request(
|
||||
response.status(),
|
||||
url.to_owned(),
|
||||
response.text().await?,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| PipedPlaylistImporterError::ContactApi(e, url.to_owned()))?)
|
||||
}
|
||||
|
||||
/// Get request
|
||||
async fn get(&self, url: &str) -> PipedPlaylistImporterResult<String> {
|
||||
let response = self
|
||||
.httpclient
|
||||
.get(url)
|
||||
.header(USER_AGENT, USER_AGENT_)
|
||||
.header(AUTHORIZATION, &self.authorization)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| PipedPlaylistImporterError::ContactApi(e, url.to_owned()))?;
|
||||
|
||||
if response.status() != 200 {
|
||||
return Err(PipedPlaylistImporterError::Request(
|
||||
response.status(),
|
||||
url.to_owned(),
|
||||
response.text().await?,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| PipedPlaylistImporterError::ContactApi(e, url.to_owned()))?)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PipedGetPlaylistResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PipedCreatePlaylistResponse {
|
||||
pub playlist_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PipedAddVideoToPlaylistResponse {
|
||||
pub message: String,
|
||||
}
|
@ -0,0 +1,89 @@
|
||||
use std::fs::ReadDir;
|
||||
use std::io::{prelude::*, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::error::{PipedPlaylistImporterError, PipedPlaylistImporterResult};
|
||||
|
||||
pub struct Playlists {}
|
||||
|
||||
impl Playlists {
|
||||
pub fn new(files: ReadDir, directory: &Path) -> PipedPlaylistImporterResult<Vec<Playlist>> {
|
||||
let playlists = Self::list_directory_files(files, directory)?;
|
||||
let playlists = Self::convert_to_playlist(playlists)?;
|
||||
Ok(playlists)
|
||||
}
|
||||
|
||||
/// Convert to vec of playlists
|
||||
fn convert_to_playlist(playlists: Vec<PathBuf>) -> PipedPlaylistImporterResult<Vec<Playlist>> {
|
||||
let mut return_playlists: Vec<Playlist> = vec![];
|
||||
let mut return_actual_playlist: Playlist = Playlist::new("".to_string());
|
||||
|
||||
for playlist in playlists {
|
||||
let path = playlist
|
||||
.to_str()
|
||||
.ok_or_else(|| PipedPlaylistImporterError::FileName(playlist.clone()))?;
|
||||
let urls = Self::read_file(path)?;
|
||||
|
||||
let name = playlist
|
||||
.file_name()
|
||||
.ok_or_else(|| PipedPlaylistImporterError::FileName(playlist.clone()))?
|
||||
.to_str()
|
||||
.ok_or_else(|| PipedPlaylistImporterError::FileName(playlist.clone()))?;
|
||||
|
||||
for url in urls {
|
||||
if name != return_actual_playlist.name {
|
||||
return_playlists.push(return_actual_playlist);
|
||||
return_actual_playlist = Playlist::new(name.to_owned())
|
||||
}
|
||||
return_actual_playlist.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
return_playlists.remove(0);
|
||||
Ok(return_playlists)
|
||||
}
|
||||
|
||||
/// List the files from a directory
|
||||
fn list_directory_files(
|
||||
files: ReadDir,
|
||||
directory: &Path,
|
||||
) -> PipedPlaylistImporterResult<Vec<PathBuf>> {
|
||||
let mut output = vec![];
|
||||
for file in files {
|
||||
let file = file
|
||||
.map_err(|e| {
|
||||
PipedPlaylistImporterError::ListFiles(e, directory.to_str().unwrap().to_owned())
|
||||
})?
|
||||
.path();
|
||||
output.push(file.to_owned());
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Read the file to a vec of strings
|
||||
fn read_file(path: &str) -> PipedPlaylistImporterResult<Vec<String>> {
|
||||
let mut lines = vec![];
|
||||
for line in BufReader::new(std::fs::File::open(path)?).lines() {
|
||||
let line =
|
||||
line.map_err(|e| PipedPlaylistImporterError::ReadLines(e, path.to_owned()))?;
|
||||
lines.push(line);
|
||||
}
|
||||
Ok(lines)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Playlist {
|
||||
pub name: String,
|
||||
pub urls: Vec<String>,
|
||||
}
|
||||
|
||||
impl Playlist {
|
||||
fn new(name: String) -> Self {
|
||||
Self { name, urls: vec![] }
|
||||
}
|
||||
|
||||
fn push(&mut self, url: String) {
|
||||
self.urls.push(url);
|
||||
}
|
||||
}
|
Loading…
Reference in new issue