Compare commits
11
Commits
75b038bb06
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f80b99e4e | ||
|
|
3d6ba1ef09 | ||
|
|
3530c755ce | ||
|
|
2867d69305 | ||
|
|
2de26dd9e6 | ||
|
|
fa79f9a39c | ||
|
|
9642af40d0 | ||
|
|
b55a79356f | ||
|
|
8003d98e1e | ||
|
|
5b4972f03d | ||
|
|
f369ffb220 |
@@ -0,0 +1,3 @@
|
|||||||
|
# Currently needed by dev environment, will be replace by some kind of config file that's generated by nix
|
||||||
|
DISCORD_APP_ID=
|
||||||
|
LB_USER=
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
if ! has nix_direnv_version || ! nix_direnv_version 3.0.4; then
|
||||||
|
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.4/direnvrc" "sha256-DzlYZ33mWF/Gs8DDeyjr8mnVmQGx7ASYqA5WlxwvBG4="
|
||||||
|
fi
|
||||||
|
use flake
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.direnv/
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Added by cargo
|
||||||
|
|
||||||
|
/target
|
||||||
Generated
+1128
File diff suppressed because it is too large
Load Diff
+16
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "lb-discord-rpc"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.79"
|
||||||
|
discord-presence = "3.1"
|
||||||
|
dotenv = "0.15.0"
|
||||||
|
env_logger = "0.11.1"
|
||||||
|
log = "0.4.20"
|
||||||
|
serde = "1.0.196"
|
||||||
|
serde_json = "1.0.113"
|
||||||
|
ureq = "2.9.5"
|
||||||
Generated
+61
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1768395095,
|
||||||
|
"narHash": "sha256-ZhuYJbwbZT32QA95tSkXd9zXHcdZj90EzHpEXBMabaw=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "13868c071cc73a5e9f610c47d7bb08e5da64fdd5",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
description = "Discord Rich Presence for ListenBrainz";
|
||||||
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||||
|
inputs.flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
|
||||||
|
outputs = { nixpkgs, flake-utils, ... }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
cargo
|
||||||
|
clippy
|
||||||
|
rustc
|
||||||
|
rust-analyzer
|
||||||
|
openssl
|
||||||
|
pkg-config
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
use std::{collections::HashMap, u8, usize};
|
||||||
|
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use log::debug;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use ureq::{Agent, AgentBuilder, Response};
|
||||||
|
|
||||||
|
use super::musicbrainz::{Recording, RecordingSearch};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LBResponse<T> {
|
||||||
|
pub payload: T
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> LBResponse<T> {
|
||||||
|
pub fn inner(self) -> T {
|
||||||
|
self.payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ListenResponsePayload {
|
||||||
|
pub count: u32,
|
||||||
|
pub user_id: String,
|
||||||
|
pub listens: Vec<Listen>,
|
||||||
|
pub playing_now: Option<bool>
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Listen {
|
||||||
|
pub track_metadata: TrackMetadata,
|
||||||
|
pub listened_at: Option<u64>,
|
||||||
|
pub playing_now: Option<bool>
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct TrackMetadata {
|
||||||
|
pub additional_info: Option<AdditionalListenInfo>,
|
||||||
|
pub artist_name: String,
|
||||||
|
pub track_name: String,
|
||||||
|
pub release_name: Option<String>
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AdditionalListenInfo {
|
||||||
|
pub artist_mbids: Option<Vec<String>>,
|
||||||
|
pub release_group_mbid: Option<String>,
|
||||||
|
pub release_mbid: Option<String>,
|
||||||
|
pub recording_mbid: Option<String>,
|
||||||
|
pub track_mbid: Option<String>,
|
||||||
|
pub work_mbids: Option<Vec<String>>,
|
||||||
|
pub tracknumber: Option<u8>,
|
||||||
|
pub isrc: Option<String>,
|
||||||
|
pub spotify_id: Option<String>,
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
pub media_player: Option<String>,
|
||||||
|
pub media_player_version: Option<String>,
|
||||||
|
pub submission_client: Option<String>,
|
||||||
|
pub submission_client_version: Option<String>,
|
||||||
|
pub music_service: Option<String>,
|
||||||
|
pub music_service_name: Option<String>,
|
||||||
|
pub origin_url: Option<String>,
|
||||||
|
pub duration_ms: Option<usize>,
|
||||||
|
pub duration: Option<usize>
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Client {
|
||||||
|
user_token: Option<String>,
|
||||||
|
recording_cache: HashMap<String, Recording>,
|
||||||
|
agent: Agent
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
user_token: None,
|
||||||
|
recording_cache: HashMap::new(),
|
||||||
|
agent: AgentBuilder::new()
|
||||||
|
.user_agent("lb-discord-rpc/0.1.0 (contact@ixhby.dev)")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn new_authorized(user_token: String) -> Self {
|
||||||
|
Self {
|
||||||
|
user_token: Some(user_token),
|
||||||
|
recording_cache: HashMap::new(),
|
||||||
|
agent: AgentBuilder::new()
|
||||||
|
.user_agent("lb-discord-rpc/0.1.0 (contact@ixhby.dev)")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, url: String) -> Result<Response, Box<ureq::Error>> {
|
||||||
|
let mut req = self.agent.get(&url).set("Accept", "application/json");
|
||||||
|
if self.user_token.is_some() {
|
||||||
|
req = req.set("Authorization", &format!("Token {}", self.user_token.clone().unwrap()));
|
||||||
|
}
|
||||||
|
Ok(req.call()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now_playing(&self, user: &str) -> anyhow::Result<ListenResponsePayload> {
|
||||||
|
// TODO: Allow custom lb/mb instances
|
||||||
|
let res = self.get(format!("https://api.listenbrainz.org/1/user/{user}/playing-now"));
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Err(e) => {
|
||||||
|
Err(e.into())
|
||||||
|
},
|
||||||
|
Ok(a) => {
|
||||||
|
Ok(serde_json::from_str::<LBResponse<ListenResponsePayload>>(&a.into_string()?)?.inner())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_track(&mut self, track: &str, artist: &str, release_name: Option<&str>) -> anyhow::Result<Recording> {
|
||||||
|
let hash_key = format!("{} - {}", &track, &artist);
|
||||||
|
|
||||||
|
if let Some(rec) = self.recording_cache.get(&hash_key) {
|
||||||
|
debug!("Got recording from cache");
|
||||||
|
return Ok(rec.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = if let Some(release_name) = release_name {
|
||||||
|
format!("https://musicbrainz.org/ws/2/recording/?query=artist:{} AND recording:{} AND release:{} AND video:false",artist,track,release_name)
|
||||||
|
} else {
|
||||||
|
format!("https://musicbrainz.org/ws/2/recording/?query=artist:{} AND recording:{} AND video:false",artist,track)
|
||||||
|
};
|
||||||
|
let res = self.get(query);
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Err(e) => {
|
||||||
|
Err(e.into())
|
||||||
|
},
|
||||||
|
Ok(a) => {
|
||||||
|
let search = serde_json::from_str::<RecordingSearch>(&a.into_string()?)?;
|
||||||
|
if let Some(rec) = search.recordings.first() {
|
||||||
|
if rec.releases.is_some() {
|
||||||
|
self.recording_cache.insert(hash_key, rec.clone());
|
||||||
|
Ok(rec.clone())
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("Recording has no releases"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("No recordings found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Move caching here
|
||||||
|
pub fn cover_art(&mut self, track: &str, artist: &str, release_name: Option<&str>, index: usize) -> anyhow::Result<String> {
|
||||||
|
let found_track = self.search_track(track, artist, release_name)?;
|
||||||
|
|
||||||
|
let res = &found_track.releases.clone().unwrap();
|
||||||
|
|
||||||
|
if index >= res.len() {
|
||||||
|
return Err(anyhow!("No cover art found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = &res.get(index).unwrap().id;
|
||||||
|
|
||||||
|
let res = self.get(format!("https://coverartarchive.org/release/{}/front-250", id));
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Err(_) => {
|
||||||
|
self.cover_art(track, artist, release_name, index + 1)
|
||||||
|
},
|
||||||
|
Ok(a) => {
|
||||||
|
if a.status() == 307 {
|
||||||
|
Ok(a.header("Location").unwrap().to_string())
|
||||||
|
} else if a.status() == 200 {
|
||||||
|
// TODOO: Don't follow redirect and instead always return the new location
|
||||||
|
// Maybe HEAD requests? idfk
|
||||||
|
Ok(a.get_url().to_string())
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("idfk"))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// TODO: Make this whole thing a separate crate, because HOLY SHIT
|
||||||
|
pub mod listenbrainz;
|
||||||
|
pub mod musicbrainz;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
use std::u8;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct RecordingSearch {
|
||||||
|
pub created: String,
|
||||||
|
pub count: usize,
|
||||||
|
pub offset: u8,
|
||||||
|
pub recordings: Vec<Recording>
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct Recording {
|
||||||
|
pub id: String,
|
||||||
|
pub score: u8,
|
||||||
|
pub title: String,
|
||||||
|
pub length: Option<u64>,
|
||||||
|
pub video: Option<Value>, // What even is this?
|
||||||
|
#[serde(rename = "artist-credit")]
|
||||||
|
pub artist_credit: Vec<ArtistCredit>,
|
||||||
|
#[serde(rename = "first-release-date")]
|
||||||
|
pub first_release_date: Option<String>,
|
||||||
|
pub releases: Option<Vec<Release>>
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct ArtistCredit {
|
||||||
|
pub joinphrase: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
pub artist: Artist
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct Artist {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "sort-name")]
|
||||||
|
pub sort_name: String,
|
||||||
|
pub aliases: Option<Value>, // No, just no
|
||||||
|
pub disambiguation: Option<String>
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct Release {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "status-id")]
|
||||||
|
pub status_id: Option<String>,
|
||||||
|
pub count: u8,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(rename = "artist-credit")]
|
||||||
|
pub artist_credit: Option<Vec<ArtistCredit>>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
#[serde(rename = "release-group")]
|
||||||
|
pub release_group: ReleaseGroup,
|
||||||
|
pub date: Option<String>,
|
||||||
|
pub country: Option<String>,
|
||||||
|
#[serde(rename = "release-events")]
|
||||||
|
pub release_events: Option<Value>, // idgaf anymore
|
||||||
|
#[serde(rename = "track-count")]
|
||||||
|
pub track_count: u16,
|
||||||
|
pub media: Value // don't care
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct ReleaseGroup {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "type-id")]
|
||||||
|
pub type_id: Option<String>,
|
||||||
|
#[serde(rename = "primary-type-id")]
|
||||||
|
pub primary_type_id: Option<String>,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(rename = "primary-type")]
|
||||||
|
pub primary_type: Option<String>
|
||||||
|
}
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
mod lb;
|
||||||
|
|
||||||
|
use core::time;
|
||||||
|
use std::{collections::{HashMap, HashSet}, env, error::Error, thread};
|
||||||
|
|
||||||
|
use discord_presence::{
|
||||||
|
models::{ActivityType, DisplayType},
|
||||||
|
Client, Event,
|
||||||
|
};
|
||||||
|
use dotenv::dotenv;
|
||||||
|
use env_logger::Env;
|
||||||
|
use lb::listenbrainz;
|
||||||
|
use log::{debug, error};
|
||||||
|
|
||||||
|
// TODOOO: Replace .env with some $XDG_CONFIG_DIRS file
|
||||||
|
// TODOO: Save HashMap cache to $XDG_CACHE_DIR file(s)
|
||||||
|
// TODOO: Nix packaging
|
||||||
|
// TODO: Packaging for other repos
|
||||||
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
dotenv().ok();
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
env_logger::init_from_env(Env::new().default_filter_or("debug"));
|
||||||
|
} else {
|
||||||
|
env_logger::init_from_env(Env::new().default_filter_or("info"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lb = listenbrainz::Client::new();
|
||||||
|
let mut drpc = Client::new(env::var("DISCORD_APP_ID")?.parse()?);
|
||||||
|
drpc.start();
|
||||||
|
|
||||||
|
let mut cover_cache: HashMap<String, String> = HashMap::new();
|
||||||
|
let mut cover_failure_cache: HashSet<String> = HashSet::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// TODO: Error handling lol
|
||||||
|
let np = lb.now_playing(&env::var("LB_USER")?)?;
|
||||||
|
let listen = np.listens.first();
|
||||||
|
|
||||||
|
if listen.is_none() {
|
||||||
|
drpc.clear_activity().unwrap();
|
||||||
|
thread::sleep(time::Duration::from_secs(15));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = &listen.unwrap().track_metadata;
|
||||||
|
|
||||||
|
let track = metadata.track_name.clone();
|
||||||
|
let artist = metadata.artist_name.clone();
|
||||||
|
let release_name = metadata.release_name.clone();
|
||||||
|
|
||||||
|
// TODOO: Change this to the release ID?
|
||||||
|
let hash_key = format!("{} - {}", &track, &artist);
|
||||||
|
let cover_art = if cover_cache.contains_key(&hash_key) {
|
||||||
|
debug!("Got cover art from cache");
|
||||||
|
Some(cover_cache.get(&hash_key).unwrap().clone())
|
||||||
|
} else if cover_failure_cache.contains(&hash_key) {
|
||||||
|
debug!("Cached cover failure");
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let youtube_cover_url = metadata.additional_info.as_ref()
|
||||||
|
.and_then(|info| info.music_service_name.as_ref())
|
||||||
|
.filter(
|
||||||
|
|service|
|
||||||
|
["YouTube", "YouTube Music"].contains(&service.as_str())
|
||||||
|
)
|
||||||
|
.and_then(|_| metadata.additional_info.as_ref())
|
||||||
|
.and_then(|info| info.origin_url.as_ref())
|
||||||
|
.and_then(|url| extract_youtube_video_id(url))
|
||||||
|
.map(|video_id| format!(
|
||||||
|
"https://img.youtube.com/vi/{}/sddefault.jpg",
|
||||||
|
video_id
|
||||||
|
));
|
||||||
|
|
||||||
|
if let Some(url) = youtube_cover_url {
|
||||||
|
cover_cache.insert(hash_key.clone(), url.clone());
|
||||||
|
Some(url)
|
||||||
|
} else {
|
||||||
|
match lb.cover_art(&track, &artist, release_name.as_deref(), 0) {
|
||||||
|
Ok(url) => {
|
||||||
|
cover_cache.insert(hash_key.clone(), url.clone());
|
||||||
|
Some(url)
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
error!("Error fetching cover_art: {}", e);
|
||||||
|
cover_failure_cache.insert(hash_key);
|
||||||
|
None
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
drpc.set_activity(|act| {
|
||||||
|
// TODO: Add buttons
|
||||||
|
let mut t = act
|
||||||
|
.activity_type(ActivityType::Listening)
|
||||||
|
.status_display(DisplayType::State)
|
||||||
|
.state(artist.clone())
|
||||||
|
.details(track.clone());
|
||||||
|
|
||||||
|
if let Some(cover_url) = cover_art {
|
||||||
|
t = t.assets(|ass| {
|
||||||
|
ass
|
||||||
|
.large_image(cover_url)
|
||||||
|
.large_text(format!("{} - {}", &track, &artist))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Provide backup image, no image looks kind of ugly
|
||||||
|
|
||||||
|
t
|
||||||
|
}).unwrap();
|
||||||
|
|
||||||
|
thread::sleep(time::Duration::from_secs(15));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_youtube_video_id(url: &str) -> Option<&str> {
|
||||||
|
let patterns = [
|
||||||
|
"youtube.com/watch?v=",
|
||||||
|
"youtu.be/",
|
||||||
|
"youtube.com/v/",
|
||||||
|
"youtube.com/embed/",
|
||||||
|
];
|
||||||
|
|
||||||
|
for pattern in patterns.iter() {
|
||||||
|
if let Some(pos) = url.find(pattern) {
|
||||||
|
let start = pos + pattern.len();
|
||||||
|
let end = url[start..]
|
||||||
|
.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
|
||||||
|
.map(|end_pos| start + end_pos)
|
||||||
|
.unwrap_or(url.len());
|
||||||
|
|
||||||
|
let video_id = &url[start..end];
|
||||||
|
if !video_id.is_empty() {
|
||||||
|
return Some(video_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user