Skip to content

Commit

Permalink
feat: import youtube playlists
Browse files Browse the repository at this point in the history
  • Loading branch information
MSOB7YY committed Dec 20, 2023
1 parent ebd8d22 commit 867f803
Show file tree
Hide file tree
Showing 6 changed files with 276 additions and 15 deletions.
66 changes: 66 additions & 0 deletions lib/ui/widgets/custom_widgets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2412,6 +2412,72 @@ class NamidaInkWell extends StatelessWidget {
}
}

class NamidaInkWellButton extends StatelessWidget {
final Color? bgColor;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final double borderRadius;
final int animationDurationMS;
final IconData? icon;
final String text;
final bool enabled;
final bool showLoadingWhenDisabled;

const NamidaInkWellButton({
super.key,
this.bgColor,
this.onTap,
this.onLongPress,
this.borderRadius = 10.0,
this.animationDurationMS = 250,
required this.icon,
required this.text,
this.enabled = true,
this.showLoadingWhenDisabled = true,
});

@override
Widget build(BuildContext context) {
return IgnorePointer(
ignoring: !enabled,
child: AnimatedOpacity(
opacity: enabled ? 1.0 : 0.6,
duration: Duration(milliseconds: animationDurationMS),
child: NamidaInkWell(
animationDurationMS: animationDurationMS,
borderRadius: borderRadius,
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0),
bgColor: bgColor ?? context.theme.colorScheme.secondaryContainer.withOpacity(0.5),
onTap: onTap,
onLongPress: onLongPress,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
!enabled && showLoadingWhenDisabled
? const LoadingIndicator(boxHeight: 18.0)
: Icon(
icon,
size: 18.0,
color: context.theme.colorScheme.onBackground,
),
const SizedBox(width: 6.0),
],
Text(
text,
style: context.textTheme.displayMedium?.copyWith(
color: context.theme.colorScheme.onBackground,
),
),
const SizedBox(width: 4.0),
],
),
),
),
);
}
}

class HistoryJumpToDayIcon<T extends ItemWithDate, E> extends StatelessWidget {
final HistoryManager<T, E> controller;
const HistoryJumpToDayIcon({super.key, required this.controller});
Expand Down
162 changes: 161 additions & 1 deletion lib/youtube/controller/youtube_import_controller.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,77 @@
import 'dart:async';
import 'dart:io';
import 'package:get/get.dart';
import 'package:playlist_manager/module/playlist_id.dart';

import 'package:namida/class/video.dart';
import 'package:namida/core/extensions.dart';
import 'package:namida/youtube/class/youtube_id.dart';
import 'package:namida/youtube/class/youtube_subscription.dart';
import 'package:namida/youtube/controller/youtube_playlist_controller.dart';
import 'package:namida/youtube/controller/youtube_subscriptions_controller.dart';

enum _YTPlaylistVisibility { public, unlisted, private, unknown }

typedef _YTPlaylistEntry = ({String name, _YTPlaylistDetails? details});

typedef _YTPlaylistDetails = ({
String playlistID,
String channelID,
DateTime? timeCreated,
DateTime? timeUpdated,
String name,
String description,
_YTPlaylistVisibility visibility,
});

typedef _VideoEntry = ({String id, DateTime? dateAdded});

class YoutubeImportController {
static final YoutubeImportController inst = YoutubeImportController._internal();
YoutubeImportController._internal();

final isImportingPlaylists = false.obs;
final isImportingSubscriptions = false.obs;

Future<int> importPlaylists(String playlistsDirectoryPath) async {
isImportingPlaylists.value = true;
final res = await _parsePlaylistsFiles.thready(playlistsDirectoryPath);

final completer = Completer<void>();
res.loop((playlist, index) {
final details = playlist.$1.details;
final plID = details != null ? PlaylistID(id: details.playlistID) : null;
YoutubePlaylistController.inst.addNewPlaylistRaw(
playlist.$1.name,
creationDate: details?.timeCreated?.millisecondsSinceEpoch,
modifiedDate: details?.timeUpdated?.millisecondsSinceEpoch,
playlistID: plID,
comment: details?.description ?? '',
tracks: (playlistID) {
return playlist.$2
.map(
(e) => YoutubeID(
id: e.id,
watchNull: YTWatch(
dateNull: e.dateAdded,
isYTMusic: false,
),
playlistID: playlistID,
),
)
.toList();
},
).then((value) {
if (index == res.length - 1) completer.complete();
});
});
await completer.future;
isImportingPlaylists.value = false;
return res.length;
}

Future<int> importSubscriptions(String subscriptionsFilePath) async {
isImportingSubscriptions.value = true;
final res = await _parseSubscriptions.thready(subscriptionsFilePath);
res.loop((e, index) {
final valInMap = YoutubeSubscriptionsController.inst.subscribedChannels[e.id];
Expand All @@ -18,9 +84,103 @@ class YoutubeImportController {
});
YoutubeSubscriptionsController.inst.sortByLastFetched();
await YoutubeSubscriptionsController.inst.saveFile();
isImportingSubscriptions.value = false;
return res.length;
}

/// there are 2 types of takeouts for playlists as encountered:
/// - old takeouts: playlist file that contains playlist metadata as header, and the second part is the actual videos
/// - new takeouts: playlist file that contains the actual videos only. metadata is inside a separate `playlists.csv` file
///
/// both are being handled severally, some playlist data is present in old but not in new, and vice versa.
/// new behaviour was introduced somewhere between `08/6/2023` & `04/12/2023`
static List<(_YTPlaylistEntry, List<_VideoEntry>)> _parsePlaylistsFiles(String dirPath) {
final dir = Directory(dirPath);
final files = dir.listSyncSafe();
final playlists = <(_YTPlaylistEntry, List<_VideoEntry>)>[];

List<_VideoEntry> getVideos(List<String> lines) {
final videos = <_VideoEntry>[];
lines.loop((e, _) {
try {
final parts = e.split(','); // id, dateAdded
if (parts.length >= 2) videos.add((id: parts[0], dateAdded: DateTime.tryParse(parts[1]))); // should be only 2, but maybe more stuff will be appended in future
} catch (_) {}
});
return videos;
}

/// Must be of length 7 or more.
_YTPlaylistDetails getPlaylistDetailsOld(List<String> split) {
return (
playlistID: split[0],
channelID: split[1],
timeCreated: DateTime.tryParse(split[2]),
timeUpdated: DateTime.tryParse(split[3]),
name: split[4],
description: split[5],
visibility: _YTPlaylistVisibility.values.getEnumLoose(split[6]) ?? _YTPlaylistVisibility.unknown,
);
}

/// Must be of length 25 or more.
_YTPlaylistDetails getPlaylistDetailsNew(List<String> split) {
return (
playlistID: split[0],
channelID: '',
description: split[2],
name: split[19],
timeCreated: DateTime.tryParse(split[21]),
timeUpdated: DateTime.tryParse(split[22]),
visibility: _YTPlaylistVisibility.values.getEnumLoose(split[24]) ?? _YTPlaylistVisibility.unknown,
);
}

final playlistsMetadata = <String, _YTPlaylistDetails>{};

final plMetaFile = File("$dirPath/playlists.csv");
if (plMetaFile.existsSync()) {
files.remove(plMetaFile);
try {
final plLines = plMetaFile.readAsLinesSync();
plLines.removeAt(0);
plLines.loop((line, _) {
final splitted = line.split(',');
if (splitted.length >= 25) {
final details = getPlaylistDetailsNew(splitted);
playlistsMetadata[details.name] = details;
}
});
} catch (_) {}
}

files.loop((e, _) {
if (e is File) {
try {
String playlistName = e.path.getFilenameWOExt;
const extra = '-videos';
if (playlistName.endsWith(extra)) playlistName = playlistName.substring(0, playlistName.length - extra.length);
final lines = e.readAsLinesSync();
if (lines.isNotEmpty) {
final header = lines[0];
final splitted = header.split(',');
if (header.startsWith('Playlist Id') && splitted.length >= 7) {
// -- old method
final pld = getPlaylistDetailsOld(lines[1].split(','));
lines.removeRange(0, 8);
playlists.add(((name: playlistName, details: pld), getVideos(lines)));
} else {
// -- new method, doesnt contain playlist header.
lines.removeAt(0);
playlists.add(((name: playlistName, details: playlistsMetadata[playlistName]), getVideos(lines)));
}
}
} catch (_) {}
}
});
return playlists;
}

static List<({String id, String title})> _parseSubscriptions(String filePath) {
final file = File(filePath);
try {
Expand All @@ -30,7 +190,7 @@ class YoutubeImportController {
lines.loop((e, _) {
try {
final parts = e.split(','); // id, url, name
if (parts.length == 3) list.add((id: parts[0], title: parts[2]));
if (parts.length >= 3) list.add((id: parts[0], title: parts[2])); // should be only 3, but maybe more stuff will be appended in future
} catch (_) {}
});
return list;
Expand Down
3 changes: 3 additions & 0 deletions lib/youtube/controller/youtube_playlist_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import 'dart:async';
import 'dart:io';

import 'package:playlist_manager/module/playlist_id.dart';
import 'package:playlist_manager/playlist_manager.dart';

import 'package:namida/class/video.dart';
Expand All @@ -25,6 +26,7 @@ class YoutubePlaylistController extends PlaylistManager<YoutubeID> {
int? creationDate,
String comment = '',
List<String> moods = const [],
PlaylistID? playlistID,
}) async {
super.addNewPlaylistRaw(
name,
Expand All @@ -43,6 +45,7 @@ class YoutubePlaylistController extends PlaylistManager<YoutubeID> {
creationDate: creationDate,
comment: comment,
moods: moods,
playlistID: playlistID,
);
}

Expand Down
16 changes: 10 additions & 6 deletions lib/youtube/pages/yt_channels_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ class _YoutubeChannelsPageState extends State<YoutubeChannelsPage> {
final files = await FilePicker.platform.pickFiles(allowedExtensions: ['csv', 'CSV'], type: FileType.custom);
final fp = files?.files.firstOrNull?.path;
if (fp != null) {
final imported = await YoutubeImportController().importSubscriptions(fp);
final imported = await YoutubeImportController.inst.importSubscriptions(fp);
if (imported > 0) {
snackyy(message: lang.IMPORTED_N_CHANNELS_SUCCESSFULLY.replaceFirst('_NUM_', '$imported'));
} else {
Expand Down Expand Up @@ -205,7 +205,7 @@ class _YoutubeChannelsPageState extends State<YoutubeChannelsPage> {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
child: ch == null
? SingleChildScrollView(
scrollDirection: Axis.horizontal,
Expand Down Expand Up @@ -301,10 +301,14 @@ class _YoutubeChannelsPageState extends State<YoutubeChannelsPage> {
),
),
const SizedBox(width: 4.0),
NamidaButton(
text: lang.IMPORT,
onPressed: _onSubscriptionFileImportTap,
)
Obx(
() => NamidaInkWellButton(
icon: Broken.add_circle,
text: lang.IMPORT,
enabled: !YoutubeImportController.inst.isImportingSubscriptions.value,
onTap: _onSubscriptionFileImportTap,
),
),
],
),
),
Expand Down
40 changes: 34 additions & 6 deletions lib/youtube/youtube_playlists_view.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:playlist_manager/module/playlist_id.dart';
Expand All @@ -11,6 +12,7 @@ import 'package:namida/core/translations/language.dart';
import 'package:namida/ui/widgets/custom_widgets.dart';
import 'package:namida/youtube/class/youtube_id.dart';
import 'package:namida/youtube/controller/youtube_history_controller.dart';
import 'package:namida/youtube/controller/youtube_import_controller.dart';
import 'package:namida/youtube/controller/youtube_playlist_controller.dart';
import 'package:namida/youtube/functions/yt_playlist_utils.dart';
import 'package:namida/youtube/pages/yt_history_page.dart';
Expand Down Expand Up @@ -230,12 +232,38 @@ class YoutubePlaylistsView extends StatelessWidget {
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 2.0),
child: Obx(
() => SearchPageTitleRow(
title: "${lang.PLAYLISTS} - ${YoutubePlaylistController.inst.playlistsMap.length}",
icon: Broken.music_library_2,
trailing: const SizedBox(),
),
child: Row(
children: [
Expanded(
child: Obx(
() => SearchPageTitleRow(
title: "${lang.PLAYLISTS} - ${YoutubePlaylistController.inst.playlistsMap.length}",
icon: Broken.music_library_2,
trailing: const SizedBox(),
),
),
),
const SizedBox(width: 4.0),
Obx(
() => NamidaInkWellButton(
icon: Broken.add_circle,
text: lang.IMPORT,
enabled: !YoutubeImportController.inst.isImportingPlaylists.value,
onTap: () async {
final dirPath = await FilePicker.platform.getDirectoryPath();
if (dirPath != null) {
final imported = await YoutubeImportController.inst.importPlaylists(dirPath);
if (imported > 0) {
snackyy(message: lang.IMPORTED_N_PLAYLISTS_SUCCESSFULLY.replaceFirst('_NUM_', '$imported'));
} else {
snackyy(message: "Failed to import\nPlease choose a valid playlists directory taken from google takeout", isError: true);
}
}
},
),
),
const SizedBox(width: 4.0),
],
),
),
),
Expand Down
Loading

0 comments on commit 867f803

Please sign in to comment.