using FireSharp.Config;
using FireSharp.Interfaces;
using FireSharp.Response;
using Herse.Models;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.IO.Compression;
using System.Configuration;
using Newtonsoft.Json;
using System.Security.Cryptography;
namespace SongCrawler
{
///
/// Main program class for the SongCrawler application.
/// Handles crawling, indexing, and managing karaoke songs from file systems and Firebase.
///
class Program
{
#region Properties and Configuration
///
/// Controller identifier from command line arguments
///
private static string Controller { get; set; }
///
/// Path to the songs folder from command line arguments
///
private static string SongsFolderPath { get; set; }
///
/// Original command line arguments array
///
private static string[] Args { get; set; }
///
/// Shared Firebase client instance
///
private static FireSharp.FirebaseClient FirebaseClient { get; set; }
#endregion
#region Main Entry Point
///
/// Main entry point for the application.
/// Initializes properties from command line arguments and routes to appropriate functionality.
///
/// Command line arguments: [controller] [songspath] [optional: delete]
static void Main(string[] args)
{
InitializeFromArgs(args);
if (args.Count() == 3)
{
DeleteSongs();
}
else
{
CrawlSongs();
}
}
#endregion
#region Initialization and Setup
///
/// Initializes application properties from command line arguments.
/// Sets up Controller, SongsFolderPath, Args, and FirebaseClient.
///
/// Command line arguments array
private static void InitializeFromArgs(string[] args)
{
Args = args;
if (args.Length > 0)
{
Controller = args[0];
}
if (args.Length > 1)
{
SongsFolderPath = args[1];
}
FirebaseClient = CreateFirebaseClient();
}
///
/// Creates and configures a Firebase client using settings from App.config.
///
/// Configured Firebase client instance
private static FireSharp.FirebaseClient CreateFirebaseClient()
{
IFirebaseConfig config = new FirebaseConfig
{
AuthSecret = ConfigurationManager.AppSettings["Firebase.Secret"],
BasePath = ConfigurationManager.AppSettings["Firebase.Path"]
};
return new FireSharp.FirebaseClient(config);
}
///
/// Validates command line arguments against expected length.
///
/// Arguments array to validate
/// Expected number of arguments
/// Usage message to display if validation fails
private static void ValidateArgs(string[] args, int expectedLength, string usage)
{
if (args.Length != expectedLength)
{
Console.WriteLine(usage);
return;
}
}
#endregion
#region Firebase Utilities
///
/// Generates Firebase path for a specific controller and data type.
///
/// Controller identifier
/// Type of data (songs, favorites, disabled, etc.)
/// Formatted Firebase path
private static string GetControllerPath(string controller, string pathType)
{
return string.Format("controllers/{0}/{1}", controller, pathType);
}
///
/// Loads data from Firebase with fallback handling for different response formats.
///
/// Type of data to load
/// Firebase client instance
/// Firebase path to load from
/// List of loaded objects
private static List LoadFirebaseData(FireSharp.FirebaseClient client, string path) where T : class
{
try
{
return client.Get(path).ResultAs>();
}
catch
{
return convertToList(client.Get(path).Body);
}
}
///
/// Converts dynamic JSON response to strongly-typed list.
/// Handles Firebase responses that don't deserialize directly to List.
///
/// Type to convert to
/// Dynamic JSON response
/// List of converted objects
private static List convertToList(dynamic json) where T : class
{
dynamic data = JsonConvert.DeserializeObject(json);
var list = new List();
foreach (var itemDynamic in data)
{
var fjson = itemDynamic.Value.ToString();
var f = JsonConvert.DeserializeObject(fjson);
list.Add(f);
}
return list;
}
#endregion
#region File System Operations
///
/// Scans directory for all supported music file types (mp3, mp4, zip).
///
/// Directory path to scan
/// List of all music file paths found
private static List GetAllMusicFiles(string songpath)
{
List files = new List();
files.AddRange(FindFiles("mp4", songpath));
files.AddRange(FindFiles("mp3", songpath));
files.AddRange(FindFiles("zip", songpath));
return files;
}
///
/// Finds all files with specified extension in directory and subdirectories.
///
/// File extension to search for
/// Directory path to search
/// Array of file paths found
private static string[] FindFiles(string ext, string path)
{
Console.Write(string.Format("\rscanning {0} for {1} - ", path, ext));
string[] files = Directory.GetFiles(path, "*." + ext, SearchOption.AllDirectories);
Console.WriteLine(string.Format("{0} found", files.Length));
return files;
}
#endregion
#region Song Processing
///
/// Processes a list of files and converts them to Song objects.
/// Handles duplicate checking, debug limits, and error handling.
///
/// List of file paths to process
/// List to add processed songs to
/// Whether to check for duplicate songs
/// Whether to run in debug mode (limited processing)
/// Maximum number of files to process in debug mode
private static void ProcessFilesToSongs(List files, List songs, bool checkDuplicates = false, bool debug = false, int debugLimit = 1000)
{
Song song = null;
int i = 0;
foreach (string filepath in files)
{
i++;
try
{
song = MakeSong(filepath);
Console.WriteLine(string.Format("{0:000000}/{1} - {2} - {3}", i, files.Count, song.Artist, song.Title));
if (checkDuplicates)
{
if (!songs.Any(s => s.Title.ToLower() == song.Title.ToLower() && s.Artist.ToLower() == song.Artist.ToLower()))
{
songs.Add(song);
}
}
else
{
songs.Add(song);
}
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
if (debug && i > debugLimit)
{
break;
}
}
}
///
/// Creates a Song object from a file path.
/// Handles different file types (mp3, mp4, zip) and extracts metadata.
///
/// Path to the music file
/// Song object with extracted metadata
private static Song MakeSong(string filepath)
{
Song song = null;
var ext = Path.GetExtension(filepath).ToLower();
switch (ext)
{
case ".mp3":
case ".mp4":
song = ReadId3(filepath);
break;
case ".zip":
song = ReadId3FromZip(filepath);
break;
}
CheckTitle(song);
song.Path = filepath;
return song;
}
///
/// Reads ID3 tags from audio files (mp3, mp4).
///
/// Path to audio file
/// Song object with extracted metadata
static Song ReadId3(string path)
{
Song song = new Song();
TagLib.File tagFile;
try
{
tagFile = TagLib.File.Create(path);
song.Title = tagFile.Tag.Title.Trim();
song.Artist = tagFile.Tag.FirstPerformer.Trim();
}
catch
{
// do nothing;
}
song.Path = path;
return song;
}
///
/// Reads ID3 tags from mp3 files inside zip archives.
///
/// Path to zip file
/// Song object with extracted metadata
private static Song ReadId3FromZip(string zippath)
{
ZipFile.ExtractToDirectory(zippath, "c:\\temp");
string filepath = Directory.GetFiles("c:\\temp", "*.mp3")[0];
Song song = ReadId3(filepath);
foreach (string file in Directory.GetFiles("c:\\temp")) File.Delete(file);
return song;
}
///
/// Extracts title and artist from filename if ID3 tags are missing.
/// Assumes format: "Artist - Title" or just "Title".
///
/// Song object to update
private static void CheckTitle(Song song)
{
if(string.IsNullOrEmpty(song.Title))
{
string file = Path.GetFileNameWithoutExtension(song.Path);
string[] parts = file.Split('-');
if (parts.Length == 1)
{
song.Title = parts[0].Trim();
}
else if (parts.Length > 1)
{
song.Artist = parts[parts.Length - 2].Trim();
song.Title = parts[parts.Length - 1].Trim();
}
}
}
#endregion
#region Song Management
///
/// Synchronizes song properties from source lists using path matching.
/// Used to restore favorites and disabled states after re-indexing.
///
/// Target song list to update
/// Source list containing property values
/// Action to set the property on matching songs
private static void SyncSongProperties(List songs, List sourceList, Action propertySetter)
{
if (sourceList != null && sourceList.Count > 0)
{
sourceList.ForEach(s =>
{
if (s != null)
{
var found = songs.Find(ls => s.Path.ToLower() == ls.Path.ToLower());
if (found != null)
{
propertySetter(found);
}
}
});
}
}
///
/// Finds duplicate songs based on artist and title matching.
///
/// List of songs to check for duplicates
/// Optional filter to apply before duplicate checking
/// Dictionary of duplicate groups keyed by "Artist - Title"
private static Dictionary> FindDuplicateSongs(List songs, Func filter = null)
{
Dictionary> dupes = new Dictionary>();
int i = 0;
foreach (var localsong in songs)
{
i++;
Console.WriteLine(string.Format("Checking for {0:000000}/{1}) {2} - {3}", i, songs.Count, localsong.Artist, localsong.Title));
if (!string.IsNullOrEmpty(localsong.Artist) && !string.IsNullOrEmpty(localsong.Title))
{
if (filter != null && !filter(localsong))
continue;
string key = localsong.Artist + " - " + localsong.Title;
if (!dupes.ContainsKey(key))
{
var dsongs = songs.Where(s => s.Title.ToLower() == localsong.Title.ToLower() && s.Artist.ToLower() == localsong.Artist.ToLower());
if (dsongs.Count() > 1)
{
List d = new List();
d.AddRange(dsongs.ToList());
dupes.Add(key, d);
}
}
}
}
return dupes;
}
#endregion
#region Core Operations
///
/// Main crawling operation. Scans music files, creates song objects, and uploads to Firebase.
/// Maintains favorites and disabled states from previous runs.
///
private static void CrawlSongs()
{
var debug = false;
ValidateArgs(Args, 2, "usage: songcrawler partyid songspath");
if (Args.Length != 2) return;
string songsPath = GetControllerPath(Controller, "songs");
string favoritesPath = GetControllerPath(Controller, "favorites");
string disabledPath = GetControllerPath(Controller, "disabled");
Console.WriteLine("Loading current library");
List songs = new List();
List disabled = LoadFirebaseData(FirebaseClient, disabledPath);
List favorited = LoadFirebaseData(FirebaseClient, favoritesPath);
FirebaseClient.Set(songsPath, songs);
List files = GetAllMusicFiles(SongsFolderPath);
ProcessFilesToSongs(files, songs, debug: debug, debugLimit: 1000);
//sync all favorite, history, disabled
SyncSongProperties(songs, favorited, song => song.Favorite = true);
SyncSongProperties(songs, disabled, song => song.Disabled = true);
FirebaseClient.Set(songsPath, songs);
// Create newSongs list from most recently created files
var created = songs.Select(s => new CreatedSong(File.GetCreationTime(s.Path), s)).ToList();
var first200 = created.Where(s => s.created != null).OrderByDescending(s => s.created).Take(200);
var added = first200.Select(s => new PathOnly(path: s.song.Path)).ToList();
string newSongs = GetControllerPath(Controller, "newSongs");
FirebaseClient.Set(newSongs, added);
}
///
/// Crawls songs without allowing duplicates based on artist and title.
///
private static void CrawlNoDupeSongs()
{
ValidateArgs(Args, 2, "usage: songcrawler partyid songspath");
if (Args.Length != 2) return;
string firepath = GetControllerPath(Controller, "songs");
Console.WriteLine("Loading current library");
List songs = new List();
List files = GetAllMusicFiles(SongsFolderPath);
ProcessFilesToSongs(files, songs, checkDuplicates: true);
Console.WriteLine(string.Format("{0:000000}/{1} unique songs", songs.Count(), files.Count()));
FirebaseClient.Set(firepath, songs);
}
///
/// Deletes all songs for the specified controller.
///
private static void DeleteSongs()
{
ValidateArgs(Args, 3, "usage: songcrawler partyid songspath delete");
if (Args.Length != 3) return;
string firepath = GetControllerPath(Controller, "songs");
Console.WriteLine("Deleting Songs ...");
List songs = new List();
FirebaseClient.Set(firepath, songs);
}
#endregion
#region Maintenance Operations
///
/// Fixes newSongs list by replacing path-only entries with full song objects.
///
private static void fixNewSongs()
{
string songsPath = GetControllerPath(Controller, "songs");
string newSongsPath = GetControllerPath(Controller, "newSongs");
List songs = FirebaseClient.Get(songsPath).ResultAs>();
List newSongs = FirebaseClient.Get(newSongsPath).ResultAs>();
List updated = new List();
foreach (Song n in newSongs){
var found = songs.First(s => s.Path == n.Path);
if(found != null)
{
updated.Add(found);
}
}
FirebaseClient.Set(newSongsPath, updated);
}
///
/// Fixes history list by ensuring proper JSON structure.
///
private static void fixHistory()
{
string historyPath = GetControllerPath(Controller, "history");
List history = null;
try
{
history = FirebaseClient.Get(historyPath).ResultAs>();
}
catch
{
dynamic data = JsonConvert.DeserializeObject(FirebaseClient.Get(historyPath).Body);
history = new List();
foreach (var itemDynamic in data)
{
var fjson = itemDynamic.Value.ToString();
var f = JsonConvert.DeserializeObject(fjson);
history.Add(f);
}
}
FirebaseClient.Set(historyPath, history);
}
///
/// Finds and disables duplicate songs, keeping only the first occurrence.
/// Only processes mp4 files that are not already disabled.
///
private static void DisableDuplicates()
{
ValidateArgs(Args, 1, "usage: songcrawler partyid songspath");
if (Args.Length != 1) return;
string firepath = GetControllerPath(Controller, "songs");
Console.WriteLine("Loading current library");
List songs = FirebaseClient.Get(firepath).ResultAs>();
var dupes = FindDuplicateSongs(songs, song => !song.Disabled && song.Path.Contains(".mp4"));
// Disable duplicate songs (keep the first one, disable the rest)
foreach (var duplicateGroup in dupes.Values)
{
for (int i = 1; i < duplicateGroup.Count; i++) // Skip first one, disable the rest
{
duplicateGroup[i].Disabled = true;
}
}
FirebaseClient.Set(firepath, songs);
}
///
/// Finds duplicates in a local directory and exports to JSON file.
///
/// Command line arguments (not used, hardcoded path)
private static void FindDuplicates(string[] args)
{
string songpath = @"D:\KaraokeData\Karaoke"; // args[0];
List songs = new List();
List files = GetAllMusicFiles(songpath);
ProcessFilesToSongs(files, songs);
var dupes = FindDuplicateSongs(songs);
File.WriteAllText(@"D:\dupliates.json", JsonConvert.SerializeObject(dupes.OrderBy(o => o.Key)));
}
#endregion
#region Utility Classes
///
/// Represents a song with its creation timestamp for sorting.
///
public class CreatedSong
{
public DateTime created { get; set; }
public Song song { get; set; }
public CreatedSong(DateTime created, Song song)
{
this.song = song;
this.created = created;
}
}
///
/// Lightweight class for storing only file paths in newSongs list.
///
private class PathOnly
{
[JsonProperty("path")]
public String Path { get; set; }
public PathOnly(string path)
{
this.Path = path;
}
}
///
/// Generates a consistent GUID from a string input using MD5 hashing.
///
/// String to generate GUID from
/// Consistent GUID for the input string
private static Guid GuidFromString(string input)
{
using (MD5 md5 = MD5.Create())
{
byte[] hash = md5.ComputeHash(Encoding.Default.GetBytes(input));
return new Guid(hash);
}
}
#endregion
}
}