File Coverage

blib/lib/App/Plex/Archiver.pm
Criterion Covered Total %
statement 36 88 40.9
branch 0 32 0.0
condition 0 15 0.0
subroutine 12 16 75.0
pod 4 4 100.0
total 52 155 33.5


line stmt bran cond sub pod time code
1             package App::Plex::Archiver;
2              
3 1     1   180889 use strict;
  1         2  
  1         45  
4 1     1   6 use warnings;
  1         19  
  1         76  
5              
6             # ABSTRACT: Archives movies (mp4, avi, mov, mkv) into a directory structure for the Plex Media server
7              
8 1     1   6 use File::Basename;
  1         2  
  1         140  
9 1     1   611 use File::Copy qw( copy );
  1         6891  
  1         81  
10 1     1   7 use File::Spec;
  1         2  
  1         27  
11 1     1   831 use File::Slurp;
  1         31210  
  1         66  
12 1     1   572 use File::HomeDir;
  1         7987  
  1         85  
13 1     1   11 use Carp qw( croak );
  1         2  
  1         60  
14 1     1   836 use Lingua::EN::Titlecase;
  1         6283  
  1         10  
15 1     1   1082 use IO::Prompter;
  1         40407  
  1         7  
16 1     1   655 use Readonly;
  1         3467  
  1         59  
17              
18 1     1   6 use Exporter qw( import );
  1         1  
  1         751  
19             our @EXPORT_OK = qw( title_from_filename copy_file get_tmbd_api_key get_tmdb_info );
20              
21             Readonly my $DEFAULT_TMDB_API_KEY_FILENAME => ".tmdb-api-key";
22              
23             ## Version string
24             our $VERSION = qq{0.01};
25              
26             #------------------------------------------------------------------------------
27             #------------------------------------------------------------------------------
28             sub title_from_filename {
29 0   0 0 1   my $filename = shift // '';
30              
31 0 0         return '' unless ($filename);
32              
33             # Strip the path and extension
34 0           my ($basename, $path, undef) = fileparse($filename, qr/\.[^.]*$/);
35              
36             # The $basename now contains the filename without path or extension.
37             # Example: "my_document-v1.0.txt" -> "my_document-v1.0"
38              
39             # Convert underscores and dash to spaces
40 0           $basename =~ s/[_-]/ /g;
41              
42 0           return scalar(Lingua::EN::Titlecase->new($basename)->title());
43             }
44              
45             #------------------------------------------------------------------------------
46             #------------------------------------------------------------------------------
47             sub copy_file {
48 0     0 1   my ( $source, $destination, $delete_source ) = @_;
49              
50 0 0         croak "Source file '$source' does not exist" unless -e $source;
51 0 0         croak "Source '$source' is not a regular file" unless -f $source;
52 0 0         croak "Cannot read source file '$source'" unless -r $source;
53 0 0         croak "Destination directory is not writable" unless -w dirname($destination);
54              
55 0 0         copy( $source, $destination )
56             or croak "Failed to copy '$source' to '$destination': $!";
57              
58 0 0         if ($delete_source) {
59 0 0         unlink $source
60             or croak "Copied but failed to delete '$source': $!";
61             }
62             }
63              
64             #------------------------------------------------------------------------------
65             #------------------------------------------------------------------------------
66             sub get_tmbd_api_key {
67 0   0 0 1   my $filename = shift // "";
68              
69 0 0         if ($filename) {
70 0 0         if (!-f "$filename") {
71 0           return "'$filename' does not exist, or is not a regular file";
72             }
73              
74 0           return (undef, read_file($filename, chomp => 1,));
75             }
76              
77 0           my @tried;
78 0           for my $path ('.', File::HomeDir->my_home) {
79 0           my $filename = File::Spec->catfile($path, $DEFAULT_TMDB_API_KEY_FILENAME);
80 0 0         if (-f "$filename") {
81 0           return (undef, read_file($filename, chomp => 1,));
82             }
83 0           push(@tried, $filename);
84             }
85              
86 0           return sprintf("Could not locate TMDB API key file: '%s'", join("', '", @tried));
87             }
88              
89             #------------------------------------------------------------------------------
90             #------------------------------------------------------------------------------
91             sub get_tmdb_info {
92 0     0 1   my $tmdb = shift;
93 0   0       my $title = shift // "";
94 0   0       my $debug = shift // 0;
95              
96 0 0 0       return unless ($tmdb && $title);
97              
98 0           my @responses = $tmdb->search(max_pages => 1)->movie($title);
99              
100 0           for my $response (@responses) {
101 0   0       $response->{release_year} = $response->{release_date} // "";
102 0           my @parts = split(/-/, $response->{release_year});
103 0   0       $response->{release_year} = $parts[0] // "";
104             }
105              
106 0 0         if ($debug) {
107 0           my $idx = 1;
108 0           for my $response (@responses) {
109             printf( "%2d - [id: %s]:\t%s (%s)\n",
110             $idx,
111             $response->{id},
112             $response->{title},
113             $response->{release_year},
114 0           );
115 0           $idx++;
116             }
117             }
118              
119 0           my $menu = [];
120 0           for my $movie (@responses) {
121 0           push(@$menu, sprintf("\"%s\" (%s)", $movie->{title}, $movie->{release_year}));
122             }
123 0 0         if (scalar(@$menu)) {
124 0           my $selected = prompt(
125             "Matching titles ...",
126             -menu => $menu,
127             -number,
128             "Your choice: "
129             );
130              
131 0           my $idx = 0;
132 0           while ($idx < scalar(@$menu)) {
133 0 0         if ($selected eq $menu->[$idx]) {
134 0 0         printf("returning id='%s', release_year='%s'\n", $responses[$idx]->{id}, $responses[$idx]->{release_year}) if ($debug);
135 0           return ($responses[$idx]->{id}, $responses[$idx]->{release_year});
136             }
137 0           $idx++;
138             }
139             } else {
140 0           printf("No matching titles found!\n");
141             }
142              
143 0           return;
144             }
145              
146             1;
147              
148             __END__
149              
150             =head1 NAME
151              
152             App::Plex::Archiver - Core functionality for archiving Plex media files
153              
154             =head1 SYNOPSIS
155              
156             use App::Plex::Archiver qw( title_from_filename copy_file get_tmbd_api_key get_tmdb_info );
157              
158             # Extract a title from a filename
159             my $title = title_from_filename('/path/to/movie_file-2023.mp4');
160             # Returns: "Movie File 2023"
161              
162             # Copy or move a file
163             copy_file('/source/file.mp4', '/dest/file.mp4', 0); # Copy
164             copy_file('/source/file.mp4', '/dest/file.mp4', 1); # Move
165              
166             # Get TMDB API key
167             my ($error, $api_key) = get_tmbd_api_key();
168             die $error if $error;
169              
170             # Search for movie information
171             my $tmdb = TMDB->new(api_key => $api_key);
172             my ($tmdb_id, $release_year) = get_tmdb_info($tmdb, "Movie Title", 1);
173              
174             =head1 DESCRIPTION
175              
176             App::Plex::Archiver provides core functionality for archiving media files into a
177             directory structure suitable for Plex Media Server. The module handles file operations,
178             filename processing, TMDB API integration, and user interaction for movie metadata
179             retrieval.
180              
181             This module is designed to work with the plex-archiver command-line tool but can
182             be used independently for media file processing tasks.
183              
184             =head1 EXPORTED FUNCTIONS
185              
186             The following functions can be imported using the C<use> statement:
187              
188             =head2 title_from_filename
189              
190             my $title = title_from_filename($filename);
191              
192             Extracts a human-readable title from a filename by removing the path and extension,
193             converting underscores and dashes to spaces, and applying proper title case formatting.
194              
195             =over 4
196              
197             =item * B<Parameters>
198              
199             =over 4
200              
201             =item * C<$filename> - The full path or filename to process
202              
203             =back
204              
205             =item * B<Returns>
206              
207             A properly formatted title string, or empty string if no filename provided
208              
209             =item * B<Example>
210              
211             title_from_filename('/movies/the_dark_knight-2008.mp4')
212             # Returns: "The Dark Knight 2008"
213              
214             =back
215              
216             =head2 copy_file
217              
218             copy_file($source, $destination, $delete_source);
219              
220             Safely copies a file from source to destination with optional deletion of the source
221             file (move operation). Includes comprehensive error checking and validation.
222              
223             =over 4
224              
225             =item * B<Parameters>
226              
227             =over 4
228              
229             =item * C<$source> - Path to the source file
230              
231             =item * C<$destination> - Path to the destination file
232              
233             =item * C<$delete_source> - Boolean flag: if true, deletes source after successful copy
234              
235             =back
236              
237             =item * B<Returns>
238              
239             Nothing on success. Dies with descriptive error message on failure.
240              
241             =item * B<Validation>
242              
243             =over 4
244              
245             =item * Verifies source file exists and is readable
246              
247             =item * Verifies destination directory is writable
248              
249             =item * Ensures atomic operation (copy then delete for moves)
250              
251             =back
252              
253             =back
254              
255             =head2 get_tmbd_api_key
256              
257             my ($error, $api_key) = get_tmbd_api_key($filename);
258              
259             Retrieves the TMDB API key from a specified file or searches for it in default
260             locations (current directory and user home directory).
261              
262             =over 4
263              
264             =item * B<Parameters>
265              
266             =over 4
267              
268             =item * C<$filename> - Optional: specific file containing the API key
269              
270             =back
271              
272             =item * B<Returns>
273              
274             A two-element list:
275              
276             =over 4
277              
278             =item * C<$error> - Error message string, or C<undef> on success
279              
280             =item * C<$api_key> - The API key string, or C<undef> on error
281              
282             =back
283              
284             =item * B<Default Search Locations>
285              
286             =over 4
287              
288             =item * C<./.tmdb-api-key> (current directory)
289              
290             =item * C<$HOME/.tmdb-api-key> (user home directory)
291              
292             =back
293              
294             =back
295              
296             =head2 get_tmdb_info
297              
298             my ($tmdb_id, $release_year) = get_tmdb_info($tmdb, $title, $debug);
299              
300             Searches TMDB for movie information and presents an interactive menu for the user
301             to select the correct match. Returns the TMDB ID and release year for the selected movie.
302              
303             =over 4
304              
305             =item * B<Parameters>
306              
307             =over 4
308              
309             =item * C<$tmdb> - TMDB object instance (from TMDB module)
310              
311             =item * C<$title> - Movie title to search for
312              
313             =item * C<$debug> - Optional: enable debug output (default: 0)
314              
315             =back
316              
317             =item * B<Returns>
318              
319             A two-element list on success, or empty list if no selection made:
320              
321             =over 4
322              
323             =item * C<$tmdb_id> - TMDB movie ID
324              
325             =item * C<$release_year> - Movie release year (extracted from release_date)
326              
327             =back
328              
329             =item * B<Interactive Features>
330              
331             =over 4
332              
333             =item * Presents numbered menu of search results
334              
335             =item * Shows movie title and release year for each option
336              
337             =item * Handles user selection via IO::Prompter
338              
339             =back
340              
341             =back
342              
343             =head1 DEPENDENCIES
344              
345             This module requires the following Perl modules:
346              
347             =over 4
348              
349             =item * L<File::Basename> - File path manipulation
350              
351             =item * L<File::Copy> - File copying operations
352              
353             =item * L<File::Spec> - Cross-platform file path operations
354              
355             =item * L<File::Slurp> - Simple file reading
356              
357             =item * L<File::HomeDir> - User home directory detection
358              
359             =item * L<Carp> - Error handling
360              
361             =item * L<Lingua::EN::Titlecase> - Title case formatting
362              
363             =item * L<IO::Prompter> - Interactive user prompts
364              
365             =item * L<Readonly> - Read-only variables
366              
367             =item * L<TMDB> - The Movie Database API integration (external dependency)
368              
369             =back
370              
371             =head1 CONSTANTS
372              
373             =head2 $DEFAULT_TMDB_API_KEY_FILENAME
374              
375             The default filename to search for when looking for TMDB API key files.
376              
377             Default value: ".tmdb-api-key"
378              
379             =head1 ERROR HANDLING
380              
381             Functions in this module use different error handling strategies:
382              
383             =over 4
384              
385             =item * B<copy_file> - Dies with descriptive error messages using C<croak>
386              
387             =item * B<get_tmbd_api_key> - Returns error message as first return value
388              
389             =item * B<title_from_filename> - Returns empty string for invalid input
390              
391             =item * B<get_tmdb_info> - Returns empty list when no selection is made
392              
393             =back
394              
395             =head1 EXAMPLES
396              
397             =head2 Basic File Processing
398              
399             use App::Plex::Archiver qw( title_from_filename copy_file );
400              
401             my $source = '/downloads/movie_file_2023.mkv';
402             my $title = title_from_filename($source);
403             my $dest = "/plex/movies/$title/$title.mkv";
404              
405             # Create destination directory
406             use File::Path qw( make_path );
407             make_path(dirname($dest));
408              
409             # Copy file
410             copy_file($source, $dest, 0);
411              
412             =head2 TMDB Integration
413              
414             use App::Plex::Archiver qw( get_tmbd_api_key get_tmdb_info );
415             use TMDB;
416              
417             # Get API key
418             my ($error, $api_key) = get_tmbd_api_key();
419             die "Failed to get API key: $error" if $error;
420              
421             # Initialize TMDB
422             my $tmdb = TMDB->new(api_key => $api_key);
423              
424             # Search for movie
425             my ($tmdb_id, $year) = get_tmdb_info($tmdb, "The Matrix", 1);
426             if ($tmdb_id) {
427             print "Found: TMDB ID $tmdb_id, Year $year\n";
428             }
429              
430             =head1 VERSION
431              
432             Version 0.01
433              
434             =head1 LICENSE
435              
436             This program is free software; you can redistribute it and/or modify it
437             under the same terms as Perl itself.
438              
439             =head1 AUTHOR
440              
441             This module was created as part of the plex-archiver project.
442              
443             =head1 SEE ALSO
444              
445             =over 4
446              
447             =item * L<plex-archiver> - Command-line tool that uses this module
448              
449             =item * L<TMDB> - The Movie Database API module
450              
451             =item * L<https://www.themoviedb.org/> - The Movie Database website
452              
453             =back
454              
455             =cut