#!/usr/bin/env perl # -*- Perl -*- use strict; use warnings; use open qw(:std :encoding(UTF-8)); use Data::Dumper qw(Dumper); use Getopt::Std; use HTTP::Request; use IO::Handle; use JSON::Tiny qw(decode_json encode_json); use LWP::UserAgent; use Time::Duration; use URI::Escape; # --- Environment Variables --- use constant LIDARR_BASE => $ENV{LIDARR_BASE} || 'http://localhost:8686'; use constant LIDARR_API_KEY => $ENV{LIDARR_API_KEY} || die "FATAL: LIDARR_API_KEY environment variable is required\n"; use constant LIDARR_ROOT_FOLDER => $ENV{LIDARR_ROOT_FOLDER} || '/music'; use constant LIDARR_QUALITY_PROFILE_ID => $ENV{LIDARR_QUALITY_PROFILE_ID} || 1; use constant LIDARR_METADATA_PROFILE_ID => $ENV{LIDARR_METADATA_PROFILE_ID} || 1; use constant LOG_LEVEL => lc($ENV{LOG_LEVEL} || 'info'); # info, debug # ANSI escape sequences sub ERASE_EOL { return "\033[K"; } sub CURSOR_BACK { return "\033[${_[0]}D"; } sub STATUS { return $_[0] . ERASE_EOL . CURSOR_BACK(length($_[0])) } # Logging functions sub debug_log { my ($message) = @_; if (LOG_LEVEL eq 'debug') { print STDERR "[DEBUG] $message\n"; } } sub info_log { my ($message) = @_; print STDERR "[INFO] $message\n"; } sub error_log { my ($message) = @_; print STDERR "[ERROR] $message\n"; } getopts('bfhv'); my $fresh_result = defined $::opt_f ? $::opt_f : 0; # vs STALE # Define the list of IDs to process my @ids_to_process; my $ua = new LWP::UserAgent; # Initialize LWP::UserAgent my $json_content; # --- Input Logic: Prioritized Checks --- print STDERR "=== MBID Poller Starting ===\n"; print STDERR "Environment variables:\n"; print STDERR " LIDARR_BASE: " . (LIDARR_BASE) . "\n"; print STDERR " LIDARR_API_KEY: " . (LIDARR_API_KEY ? "***SET***" : "NOT SET") . "\n"; print STDERR " LIDARR_ROOT_FOLDER: " . (LIDARR_ROOT_FOLDER) . "\n"; print STDERR " LIDARR_QUALITY_PROFILE_ID: " . (LIDARR_QUALITY_PROFILE_ID) . "\n"; print STDERR " LIDARR_METADATA_PROFILE_ID: " . (LIDARR_METADATA_PROFILE_ID) . "\n"; print STDERR " LOG_LEVEL: " . (LOG_LEVEL) . "\n"; print STDERR " MBID_API_URL: " . ($ENV{MBID_API_URL} || 'NOT SET') . "\n"; print STDERR " MBID_JSON_FILE: " . ($ENV{MBID_JSON_FILE} || 'NOT SET') . "\n"; print STDERR " MBID_URL: " . ($ENV{MBID_URL} || 'NOT SET') . "\n"; print STDERR "\n"; # 1. Check for API URL (Highest Priority) if (my $api_url = $ENV{MBID_API_URL}) { print STDERR "Fetching IDs from API URL: $api_url\n"; my $res = $ua->get($api_url); unless ($res->is_success) { die "FATAL: Failed to fetch data from API: " . $res->status_line . "\n"; } $json_content = $res->content; print STDERR "Successfully fetched " . length($json_content) . " bytes from API\n"; } # 2. Check for JSON File (Second Priority) elsif (my $json_file = $ENV{MBID_JSON_FILE}) { print STDERR "Loading IDs from JSON file: $json_file\n"; if (-f $json_file) { open my $fh, '<:encoding(UTF-8)', $json_file or die "Could not open $json_file: $!"; $json_content = do { local $/; <$fh> }; close $fh; print STDERR "Successfully loaded " . length($json_content) . " bytes from file\n"; } else { die "FATAL: JSON file $json_file does not exist\n"; } } # 3. Check for a single URL/ID (Lowest Priority) elsif (my $single_url = $ENV{MBID_URL}) { print STDERR "Using single URL/ID: $single_url\n"; push @ids_to_process, $single_url; } else { die "FATAL: Must set MBID_API_URL, MBID_JSON_FILE, OR MBID_URL.\n"; } # --- JSON Parsing Logic (Applies to API URL and JSON File) --- if ($json_content) { print STDERR "Parsing JSON content...\n"; my $data; eval { $data = decode_json($json_content); }; if ($@) { die "Error decoding JSON from source: $@\n"; } # Extract the 'foreignId' from each object in the array if (ref $data eq 'ARRAY') { print STDERR "Found JSON array with " . scalar(@$data) . " items\n"; foreach my $item (@$data) { if (ref $item eq 'HASH' && exists $item->{foreignId}) { push @ids_to_process, $item->{foreignId}; print STDERR " - Added ID: $item->{foreignId}\n"; } else { warn "Skipping malformed item in input (missing 'foreignId').\n"; } } } else { die "Input content was not a JSON array.\n"; } } print STDERR "\nTotal IDs to process: " . scalar(@ids_to_process) . "\n"; if (scalar(@ids_to_process) == 0) { print STDERR "WARNING: No IDs found to process!\n"; exit 0; } # Counters for summary my $processed_count = 0; my $success_count = 0; my $error_count = 0; my $skip_count = 0; # --- Main Processing Loop (ENHANCED WITH BETTER ERROR HANDLING) --- print STDERR "\n=== Starting Main Processing ===\n"; foreach my $id (@ids_to_process) { chomp $id; $processed_count++; print "\n--- Processing ($processed_count/" . scalar(@ids_to_process) . "): $id ---\n"; print STDERR "Processing ID: $id\n"; my $type = 'artist'; # Assuming 'foreignId' provides an artist MBID # 1. Ping API to get artist data my $artist_data = ping_api($type, $id); # Check if we actually got valid data if (!$artist_data) { error_log("Failed to get artist data for $id - skipping"); $error_count++; next; } # 2. Validate data (e.g., check for albums) my @warnings = validate($type, $artist_data); if (@warnings) { warn "Skipping artist $id (" . ($artist_data->{artistname} || 'Unknown Artist') . ") due to warnings: " . join(', ', @warnings) . "\n"; $skip_count++; next; # Skip to the next ID if validation fails } # 3. Add artist to Lidarr (This is the critical step you wanted) my $add_result = add_artist_to_lidarr($artist_data); if ($add_result) { $success_count++; } else { $error_count++; } # Print the link for reference print "- add-to-lidarr: " . LIDARR_BASE . "/add/search?term=" . uri_escape("lidarr:$id") . "\n"; } # --- End of Main Processing Loop --- print STDERR "\n=== Processing Complete ===\n"; print STDERR "Summary:\n"; print STDERR " Total processed: $processed_count\n"; print STDERR " Successfully added: $success_count\n"; print STDERR " Errors: $error_count\n"; print STDERR " Skipped (validation failed): $skip_count\n"; # --- Subroutines --- sub ping_api { my ($type, $id) = @_; my $ua = new LWP::UserAgent; $ua->timeout(30); # Set reasonable timeout per request my $start = time(); my $loops = 0; my $max_attempts = 15; # Limit attempts to prevent infinite loops my $api = "https://api.lidarr.audio/api/v0.4/$type/$id"; my $json; print STDERR "Pinging $api\n"; while (1) { $loops++; if (LOG_LEVEL eq 'debug') { debug_log("attempt $loops: making request..."); } else { print STDERR STATUS("- attempt $loops"); } my $res = $ua->get($api); debug_log("attempt $loops: got response"); my $status = $res->code; if ($res->is_success) { debug_log("attempt $loops: success, parsing JSON..."); eval { $json = decode_json($res->content); }; if ($@) { chomp $@; if (LOG_LEVEL eq 'debug') { debug_log("attempt $loops: JSON parse error: $@"); } else { warn "Retrying: $@\n"; } } elsif ($fresh_result and defined $res->header('cf-cache-status') and $res->header('cf-cache-status') eq 'STALE') { $status .= ' STALE'; if (LOG_LEVEL eq 'debug') { debug_log("attempt $loops: $status (cache warming...)"); } else { print STDERR STATUS("- attempt $loops: $status (cache warming...)"); } } else { debug_log("attempt $loops: got valid data!"); if (LOG_LEVEL ne 'debug') { print STDERR ERASE_EOL; } last; # Got fresh data, exit retry loop } } else { if (LOG_LEVEL eq 'debug') { debug_log("attempt $loops: $status - " . $res->status_line); } else { print STDERR STATUS("- attempt $loops: $status - " . $res->status_line); } } # Break if we've exceeded max attempts if ($loops >= $max_attempts) { error_log("Failed after $max_attempts attempts"); if (LOG_LEVEL ne 'debug') { print STDERR ERASE_EOL; } last; } debug_log("sleeping 5 seconds before retry..."); sleep 5; } # Check if we exhausted all attempts without success if ($loops >= $max_attempts && !$json) { error_log("Failed to get valid response after $max_attempts attempts"); return undef; } my $elapsed = time() - $start; print "- ready ($loops attempts, " . duration($elapsed) . ")\n" if $loops > 1; if ($type eq 'artist') { print "- artist: " . ($json->{artistname} || 'UNKNOWN') . "\n"; debug_log("Artist data keys: " . join(", ", keys %$json)); debug_log("foreignArtistId: " . ($json->{foreignArtistId} || 'NOT FOUND')); debug_log("artistId: " . ($json->{artistId} || 'NOT FOUND')); debug_log("id: " . ($json->{id} || 'NOT FOUND')); } else { print "- album: " . ($json->{title} || 'UNKNOWN') . "\n"; } return $json; } sub validate { my ($type, $json) = @_; my @warnings; if ($type eq 'artist') { # make sure there are albums unless (exists $json->{Albums} and scalar @{$json->{Albums}}) { push(@warnings, 'no albums'); } } return @warnings; } sub add_artist_to_lidarr { my ($artist_data) = @_; info_log("Adding artist to Lidarr: " . ($artist_data->{artistname} || 'UNKNOWN')); # Debug: show what fields we have debug_log("Available fields in artist data: " . join(", ", keys %$artist_data)); # The foreignArtistId might be in a different field - let's check common possibilities my $foreign_artist_id = $artist_data->{foreignArtistId} || $artist_data->{artistId} || $artist_data->{id}; # Enhanced validation of required fields if (!$foreign_artist_id) { error_log("Missing foreignArtistId/artistId/id in artist data"); debug_log("Artist data dump: " . Dumper($artist_data)); return 0; } if (!$artist_data->{artistname}) { error_log("Missing artistname in artist data"); return 0; } my $ua = new LWP::UserAgent; $ua->timeout(30); # Clean artist name for path (remove invalid characters) my $clean_artist_name = $artist_data->{artistname}; $clean_artist_name =~ s/[<>:"|?*\\\/]/_/g; # Replace invalid path characters # Prepare the payload for Lidarr API my $payload = { 'foreignArtistId' => $foreign_artist_id, # Use the found ID 'artistName' => $artist_data->{artistname}, 'monitored' => JSON::Tiny::true, 'monitorNewItems' => 'all', 'qualityProfileId' => LIDARR_QUALITY_PROFILE_ID + 0, # Ensure it's a number 'metadataProfileId' => LIDARR_METADATA_PROFILE_ID + 0, # Ensure it's a number 'path' => LIDARR_ROOT_FOLDER . '/' . $clean_artist_name, 'rootFolderPath' => LIDARR_ROOT_FOLDER, 'addOptions' => { 'monitor' => 'all', 'searchForMissingAlbums' => JSON::Tiny::false } }; # Convert to JSON my $json_payload; eval { $json_payload = JSON::Tiny::encode_json($payload); }; if ($@) { error_log("Failed to encode JSON payload: $@"); return 0; } debug_log("Lidarr API payload: " . substr($json_payload, 0, 200) . "..."); # Make the API request my $lidarr_api_url = LIDARR_BASE . '/api/v1/artist'; debug_log("Making request to: $lidarr_api_url"); my $req = HTTP::Request->new(POST => $lidarr_api_url); $req->header('Content-Type' => 'application/json'); $req->header('X-Api-Key' => LIDARR_API_KEY); $req->content($json_payload); my $res = $ua->request($req); debug_log("Lidarr API Response Code: " . $res->code); debug_log("Lidarr API Response Message: " . $res->message); if ($res->is_success) { print "- added to Lidarr successfully\n"; info_log("SUCCESS: Artist added to Lidarr"); # Try to parse response to get artist ID if (my $response_data = eval { decode_json($res->content) }) { debug_log("Added artist with Lidarr ID: " . ($response_data->{id} || 'unknown')); } return 1; } elsif ($res->code == 400) { # Artist might already exist or validation failed print "- artist already exists in Lidarr or validation failed\n"; info_log("WARNING: " . $res->content); # Try to parse error response if (my $error_data = eval { decode_json($res->content) }) { if (ref $error_data eq 'ARRAY' && @$error_data) { info_log("Validation errors:"); foreach my $error (@$error_data) { if (ref $error eq 'HASH') { info_log(" - " . ($error->{errorMessage} || $error->{message} || "Unknown error")); } } } } return 0; } elsif ($res->code == 401) { error_log("Authentication failed - check your API key"); return 0; } elsif ($res->code == 404) { error_log("Lidarr API endpoint not found - check your Lidarr URL and version"); return 0; } else { error_log("Lidarr API request failed: " . $res->status_line); debug_log("Response body: " . $res->content); return 0; } }